A product team that ships an economic calendar needs per-user country filters, release times in each viewer's zone and a way to show a moved release. Narwhal returns releases with a UTC time, the publisher's zone, the period, the previous value and a revision counter, and the app adds the filter, the clock and the cache.
What the app asks the calendar API for
A product team that ships an economic calendar inside its own app has three jobs the data source cannot do for it: let each user pick countries, show every time in the viewer's zone and keep what is on screen true when a release moves. The API supplies the releases, and the app supplies the filter, the clock and the cache. GET /v1/economics/calendar takes countries (repeat it for each country, using three-letter codes such as USA or CAN), start_date, end_date, limit and cursor. This request fetches two countries for two days.
curl -s -H "Authorization: Bearer $NARWHAL_API_KEY" \
"https://api.narwhalapi.com/v1/economics/calendar?countries=USA&countries=CAN&start_date=2026-11-05&end_date=2026-11-06&limit=100"{
"releases": [
{
"release_id": "1b503ce9-69ee-4ec4-8dd9-deba7f1a6554",
"revision": 2,
"country": "USA",
"series_id": "usa-employment-report",
"name": "Employment report",
"topic": "employment",
"family": "labour",
"period": "2026-10",
"status": "scheduled",
"at": "2026-11-06T13:30:00Z",
"timezone": "America/New_York",
"values": [
{
"measure": "nonfarm_payrolls_change",
"concept": "payrolls",
"unit": "thousands",
"transformation": "change",
"previous": "29"
},
{
"measure": "unemployment_rate",
"concept": "unemployment_rate",
"unit": "percent",
"previous": "4.2"
}
]
},
{
"release_id": "20c30c26-9a9a-4218-a8fb-6233ac8d291a",
"revision": 1,
"country": "USA",
"series_id": "usa-productivity-and-costs",
"name": "Productivity and costs",
"topic": "other",
"family": "labour",
"period": "2026-Q3",
"stage": "preliminary",
"status": "scheduled",
"at": "2026-11-05T13:30:00Z",
"timezone": "America/New_York",
"values": []
}
],
"next_cursor": null
}The full response holds five releases. at is the release time in UTC and timezone is the publisher's own zone, so the app converts at for each viewer and can show the publisher's zone as a second label. period is the reference period, and values[].previous is the figure that came before. release_id identifies a release across requests, and revision rises when its details change. values is always present and can be an empty list.
Cache per country and render in the viewer's zone
Users pick different country sets, so cache each country on its own and merge the lists per user. Two users who both follow the United States then share one cached list.
- Fetch one country at a time
country_releasesrequests one country and followsnext_cursoruntil it isnull, since a page holds at most 100 releases. - Hold the answer for a short lifetime
TTLis the number of seconds a cached country stays fresh. Choose it from how quickly your users need to see a moved time. - Merge for the user and convert
user_calendarcollects the user's countries, converts eachatwith the user's zone and sorts by local time.ZoneInfoapplies the viewer's clock changes, so the app stores no offsets.import os import time from datetime import datetime from zoneinfo import ZoneInfo import requests BASE = "https://api.narwhalapi.com" HEADERS = {"Authorization": f"Bearer {os.environ['NARWHAL_API_KEY']}"} TTL = 300 # seconds a cached country stays fresh cache = {} # (country, start, end) -> (fetched_at, releases) def country_releases(country, start, end): key = (country, start, end) hit = cache.get(key) if hit and time.time() - hit[0] < TTL: return hit[1] params = {"countries": country, "start_date": start, "end_date": end, "limit": 100} releases = [] while True: r = requests.get(f"{BASE}/v1/economics/calendar", params=params, headers=HEADERS, timeout=10) r.raise_for_status() body = r.json() releases += body["releases"] if not body["next_cursor"]: break params["cursor"] = body["next_cursor"] cache[key] = (time.time(), releases) return releases def user_calendar(user, start, end): zone = ZoneInfo(user["tz"]) rows = [] for country in user["countries"]: for x in country_releases(country, start, end): if "at" in x: local = datetime.fromisoformat(x["at"].replace("Z", "+00:00")).astimezone(zone) rows.append((local, x)) return sorted(rows, key=lambda t: t[0]) user = {"countries": ["USA", "CAN"], "tz": "Asia/Jakarta"} day = None for local, x in user_calendar(user, "2026-11-05", "2026-11-06"): if local.date() != day: day = local.date() print(day.isoformat()) print(f" {local:%H:%M} {x['country']} {x['name']} {x.get('period', '-')}")
The output below is for a user in Jakarta who follows the United States and Canada. The Credit statistics release is at 20:00 UTC on 6 November, which is 03:00 on 7 November in Jakarta, so it falls under a different date in that user's view. Group by the viewer's local date, never by the UTC date.
2026-11-05
20:30 USA Productivity and costs 2026-Q3
20:30 USA Jobless claims 2026-10-31
2026-11-06
20:30 USA Employment report 2026-10
20:30 CAN Labour force survey 2026-10
2026-11-07
03:00 USA Credit statistics 2026-09Keep the screen true when a release moves
Store release_id and revision with each row you show. After a refresh, a row whose revision is higher than the stored one has changed, so redraw it and flag the new time. A release with status cancelled should be struck through or hidden, and a row that no longer comes back for the window may have moved to another date, so drop it from that view.
Some releases have no exact time and carry date, between or by instead of at. The script above skips them. In a real app, list them at the top of their day as all-day items. For a push, webhooks report release.scheduled, release.rescheduled and release.cancelled; see receive webhooks.
Decisions the product team makes once
| Decision | Choice | Field or parameter |
|---|---|---|
| Country filter per user | Send the user's countries, one cached list per country | countries |
| Time shown | Convert the UTC time to the viewer's zone, and show the publisher's zone as a label | at, timezone |
| Date grouping | Group by the viewer's local date | at |
| Change detection | Compare the stored revision with the new one | release_id, revision |
| Topic or family chips | Filter in the app, since the request has no topic parameter | family, topic |
| Rate limits and retries | Use a cache and back off on a 429 response, see cache responses and handle 429 errors | Response headers |
Can users filter by topic or family?
The request takes countries, dates, limit and cursor, and has no topic filter. Every release has a family and most have a topic, so filter in your own code. Use x.get("topic"), because some releases have no topic and a plain lookup raises a key error.
Does the date window follow UTC or the publisher's local date?
It follows the publisher's local date. Japan's releases at 23:30 or 23:50 UTC fall on the next morning in Tokyo, so they sit in the following day's window. When the app builds a day in the viewer's zone, request one extra day on each side and filter on at.
How many requests does one screen need?
One request per country in the cache, plus one more for each extra page when next_cursor has a value. Cached countries cost nothing until their lifetime ends.
Published .