Show an economic calendar inside your own app

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, 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"
Response, first two releases
{
  "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.

  1. Fetch one country at a time country_releases requests one country and follows next_cursor until it is null, since a page holds at most 100 releases.
  2. Hold the answer for a short lifetime TTL is the number of seconds a cached country stays fresh. Choose it from how quickly your users need to see a moved time.
  3. Merge for the user and convert user_calendar collects the user's countries, converts each at with the user's zone and sorts by local time. ZoneInfo applies the viewer's clock changes, so the app stores no offsets.
    Python, a per-user calendar with a cache
    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.

Output for a user in Asia/Jakarta
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-09

Keep 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

DecisionChoiceField or parameter
Country filter per userSend the user's countries, one cached list per countrycountries
Time shownConvert the UTC time to the viewer's zone, and show the publisher's zone as a labelat, timezone
Date groupingGroup by the viewer's local dateat
Change detectionCompare the stored revision with the new onerelease_id, revision
Topic or family chipsFilter in the app, since the request has no topic parameterfamily, topic
Rate limits and retriesUse a cache and back off on a 429 response, see cache responses and handle 429 errorsResponse 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 .

Next steps