An economic calendar API answers one question for any program that has to know about scheduled macro releases: what is coming, when exactly, and how much does it usually matter? A backtest that wants to sit out the minute a CPI print lands, a dashboard that marks the jobs report on a chart, an alerting bot that pings a channel fifteen minutes before a rate decision. All three need the same input, a list of upcoming events as structured data with a UTC timestamp, an impact tier, and the published figures once they exist.
This post fixes three things up front. The reader is a developer or quant who already has a price feed in place and now needs release times as data rather than as a web page. After reading, you can fetch the next month of high-impact US releases, filter to the events your code cares about, window through a longer range without silently losing rows, and write the small function that gates a strategy or fires an alert ahead of a release. Nothing here is a forecast or trading advice. It's plumbing.
What the economic calendar API returns#
The endpoint is GET /v1/fnd/economic-calendar. Called with no parameters it returns the next 30 days of US events, ordered by scheduled time. Here is the call most people start with, restricted to high-impact releases:
curl -H "X-API-Key: $SIFTING_KEY" \
"https://api.sifting.io/v1/fnd/economic-calendar?impact=high"
The response is an envelope with three keys: events, count, and filter. The filter object echoes back the parameters the server actually applied, which is useful when you rely on defaults. A trimmed example:
{
"events": [
{
"event_id": "us_cpi",
"name": "US Consumer Price Index",
"country": "US",
"currency": "USD",
"agency": "BLS",
"impact": "high",
"scheduled_at": "2026-05-13T12:30:00Z",
"actual": null,
"previous": null,
"consensus": null,
"released_at": null
},
{
"event_id": "us_fomc_decision",
"name": "FOMC Rate Decision",
"country": "US",
"currency": "USD",
"agency": "Fed",
"impact": "high",
"scheduled_at": "2026-06-17T18:00:00Z",
"actual": null,
"previous": null,
"consensus": null,
"released_at": null
}
],
"count": 2,
"filter": { "from": "2026-05-04T00:00:00Z", "to": "2026-06-03T00:00:00Z", "country": "US", "impact": "high", "limit": 100 }
}
Each event has eleven fields. event_id is a stable slug you can key on across runs. country is ISO 3166-1 alpha-2 and currency is ISO 4217. agency is the publishing body: BLS, BEA, Census, Fed, DOL, EIA, ISM, ConferenceBoard, UMich, NAR, or Treasury. impact is one of low, medium, or high. scheduled_at is an RFC 3339 timestamp in UTC with second precision, so 12:30:00Z is exactly the moment the release is due, and you can line it up against price bars to the second. The last four fields are nullable: actual, previous, and consensus are floats that stay null until a figure exists, and released_at is null until the release has actually happened.
The query parameters are from (defaults to now, UTC), to (defaults to from plus 30 days, and the range is capped at 365 days), country (defaults to US, which is the current coverage), impact, agency, event_id, and limit (default 100, maximum 500). That is the complete list.
Filtering to CPI, the jobs report, and rate decisions#
Most code doesn't want 25 event types. It wants three or four. There are two ways to narrow the list. event_id pins a single series, so event_id=us_cpi returns only CPI dates and event_id=us_fomc_decision returns only rate decisions. agency pulls everything one publisher releases, so agency=BLS covers CPI and the monthly employment report together without you having to know every id. The full table of event ids for all 25 types is in the docs; don't guess at one.
In Python the fetch is a few lines. The one thing worth getting right immediately is timestamp parsing. Convert scheduled_at to an aware datetime in UTC the moment it arrives and never let a string or a naive datetime past that boundary.
import os
from datetime import datetime, timezone
import requests
KEY = os.environ["SIFTING_KEY"]
BASE = "https://api.sifting.io/v1/fnd/economic-calendar"
def parse_ts(s):
if s is None:
return None
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def fetch_events(**params):
r = requests.get(BASE, params=params, headers={"X-API-Key": KEY}, timeout=10)
r.raise_for_status()
body = r.json()
events = body["events"]
for e in events:
e["scheduled_at"] = parse_ts(e["scheduled_at"])
e["released_at"] = parse_ts(e["released_at"])
return events, body["count"], body["filter"]
high, count, applied = fetch_events(impact="high", limit=500)
cpi, _, _ = fetch_events(event_id="us_cpi", limit=500)
The replace call exists because datetime.fromisoformat only accepts a trailing Z from Python 3.11 onward. On 3.9 and 3.10 it raises ValueError on every row, which is a confusing first failure for a script that worked on a laptop and died on a server.
Gating a strategy or alerting before a release#
With an aware datetime per event, the gate is a comparison. The function below reports whether the current moment falls inside a blackout window around any high-impact release, and a second function returns the next release so an alerting job can compute a lead time.
from datetime import timedelta
def in_blackout(now, events, before=timedelta(minutes=5), after=timedelta(minutes=15)):
for e in events:
t = e["scheduled_at"]
if t - before <= now <= t + after:
return e
return None
def next_release(now, events):
upcoming = [e for e in events if e["scheduled_at"] > now]
return min(upcoming, key=lambda e: e["scheduled_at"], default=None)
now = datetime.now(timezone.utc)
hit = in_blackout(now, high)
if hit:
print("skip: inside window for", hit["name"])
nxt = next_release(now, high)
if nxt:
lead = nxt["scheduled_at"] - now
print(f"next high-impact release {nxt['event_id']} in {lead}")
For a backtest, the same function runs against historical bars: place the window in the past with from and to, and skip or flag any bar whose open time falls inside a blackout. To annotate a chart, convert scheduled_at to epoch milliseconds and floor it to the bar interval, because bar timestamps are integer milliseconds while the calendar sends strings. That join has its own subtleties, covered in the post on joining each release to its price bar. Once a figure is out, the questions change to what the number was against consensus, how it gets revised later, and which bar absorbed the move, which is the subject of the companion post on revisions, timezones, and the bar each release moved.
Refresh cadence matters more than it looks. Schedules move rarely, but they do move, so a daily refresh of the upcoming window is the minimum. Refresh again shortly after each scheduled_at to pick up actual and released_at. If you would rather be pushed than poll, webhook alerts for CPI and jobs report releases remove the polling loop entirely.
Windowing through a year of events#
This endpoint does not use cursor pagination. There is no next_cursor in the envelope. limit caps a single response at 500 rows, and anything beyond that is simply absent. The correct pattern for a long range is to step through it in windows and check count against limit on every step:
def fetch_range(start, end, step_days=30, **params):
out, cur = [], start
while cur < end:
nxt = min(cur + timedelta(days=step_days), end)
events, count, _ = fetch_events(
**{"from": cur.isoformat(), "to": nxt.isoformat(), "limit": 500, **params})
if count >= 500:
raise RuntimeError(f"window {cur} to {nxt} truncated; use a smaller step")
out.extend(events)
cur = nxt
return out
year = fetch_range(datetime(2026, 1, 1, tzinfo=timezone.utc),
datetime(2026, 12, 31, tzinfo=timezone.utc))
Thirty-day windows of all US events stay well under the cap, but the check costs nothing and protects you if you ever widen the step. The range between from and to on any single call cannot exceed 365 days; how far back the window may be placed depends on your plan, so check the docs before backfilling several years.
Common pitfalls#
Hard-coding the release clock time is the most common one. A US release scheduled for 8:30 local time arrives at 12:30Z during daylight saving and 13:30Z in winter. A script that assumes one of those will be an hour off for half the year. Always use the scheduled_at the API returns, and keep every comparison in aware UTC datetimes. Comparing an aware datetime to a naive one in Python raises TypeError, which is the desired failure mode. Silent wrongness is worse.
The second pitfall is treating scheduled_at < now as proof that the release happened. It isn't. A release can be delayed, and until the figure is public released_at stays null and actual stays null. Test released_at for the has-happened check, and never call float(e["actual"]) without a null guard. The same applies to consensus and previous, which may be null for some events even close to release. See the docs for what each event carries.
The third is expecting the general cursor convention used by the historical bars endpoints. It does not apply here. A response with count equal to limit is truncated, no error is raised, and no continuation token is offered. If your yearly event count looks suspiciously round, that is why.



