Create a webhook destination with your HTTPS URL, the countries and families you want, then verify the signature of every delivery before you trust it. Webhooks need a Basic plan (3 destinations) or Pro (10); Free has none.
Create a destination and receive deliveries
Narwhal uses one account and one API key across its data APIs. REST and MCP share that key. Keep it in the NARWHAL_API_KEY environment variable and send it as Authorization: Bearer YOUR_KEY to https://api.narwhalapi.com.
- Create the destination Send your HTTPS URL, country codes and family slugs to
POST /v1/webhooks/destinations. The URL can contain up to 2,048 characters; choose 1 to 60 alpha-3 country codes orWLD, and 1 to 13 family slugs, includingholidaysandpredictions. The request can also includeenabled: true. The curl request selectsUSAandMEXwith thepricesandlabourfamilies. Replace its example HTTPS URL with the URL where your receiver accepts deliveries.Create a webhook destinationcurl -s -X POST https://api.narwhalapi.com/v1/webhooks/destinations -H "Authorization: Bearer $NARWHAL_API_KEY" -H "Content-Type: application/json" -d '{"url":"https://example.com/narwhal","countries":["USA","MEX"],"families":["prices","labour"]}' - Save the destination secret The response includes
destination_idand asecret, which is shown only once. SetNARWHAL_WEBHOOK_SECRETto that secret for the receiver. Keep the destination ID for requests that address this destination, including the test-delivery request. Use the returned secret to compute delivery signatures; the API key stays in the authorization header for your API requests. A secret returned byrotate-secretis also shown only once; for 24 hours after rotation, the signature header carries a second value signed with the old secret. - Verify signatures before acting Use the receiver below to compare signatures in constant time and reject timestamps older than five minutes. Accept a delivery if any signature value matches. The Python SDK also has a verifier at
from narwhalapi.webhooks import verify. Return a 2xx quickly and do the work after.Python (Flask), a receiver that verifies the signatureimport hashlib, hmac, json, os, time from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["NARWHAL_WEBHOOK_SECRET"] # returned once when you create the destination seen = set() # use a database table in production @app.post("/narwhal") def narwhal(): raw = request.get_data() # exact bytes, before any JSON parsing stamp = request.headers.get("x-narwhal-timestamp", "") if not stamp.isdigit() or abs(time.time() - int(stamp)) > 300: abort(400) # missing, or older than five minutes expected = hmac.new(SECRET.encode(), f"{stamp}.".encode() + raw, hashlib.sha256).hexdigest().encode() header = request.headers.get("x-narwhal-signature", "") sent = [part.strip().removeprefix("sha256=").encode() for part in header.split(",")] if not any(hmac.compare_digest(expected, candidate) for candidate in sent): abort(401) # a missing or malformed header lands here, not in a 500 try: event = json.loads(raw) except ValueError: abort(400) if not isinstance(event, dict): abort(400) if event.get("event_id") in seen: # retries and replays repeat the same event_id return "", 200 seen.add(event.get("event_id")) print(event.get("type"), event.get("country"), event.get("family")) return "", 200 - Request a test delivery Call
POST /v1/webhooks/destinations/{destination_id}/testwith the saved destination ID. A test delivery hasis_test: trueand no cursor. Delivery states arepending,leased,succeeded,failedandcancelled. A family outside your plan returns403 plan_upgrade_required; creating a destination at your plan's destination limit returns409 limit_reached.
Event types include release.scheduled, release.rescheduled, release.cancelled and release.updated; dataset.published and dataset.revised; yield_curve.published and yield_curve.revised; and holiday.calendar.revised. Predictions have prediction.event.closed and prediction.event.settled. Use the payload's type, country and family fields when handling an event.
The receiver returns 400 for a missing or invalid timestamp and for a body that cannot be parsed as a JSON object. A missing or malformed signature returns 401. Once the signature comparison succeeds, the receiver parses the body before reading its event fields and recording the event ID.
Receive webhooks without code
A Zapier Catch Hook, Make Custom webhook or n8n Webhook node can supply the destination URL. Put that URL in the destination's url field and choose the countries and families for the flow. Keep the catch-hook URL secret: these no-code destinations do not verify the Narwhal signature for you.
Signature verification needs code such as the Python receiver above. If you need that verification, use the receiver for incoming deliveries. The webhook reference has the destination, delivery history and replay operations; the events reference describes the REST journal for reading events from a cursor.
Choose a receiver or polling
| Situation | Best option | Why |
|---|---|---|
| A service of your own | The Python receiver | It verifies the signature and handles repeated event IDs |
| A no-code flow | A Zapier, Make or n8n catch hook | It supplies a destination URL; keep that URL secret |
| Polling instead of deliveries | GET /v1/events or the economic calendar | The journal works from a cursor; the calendar covers dates |
Why verify the raw webhook body?
The signature covers the exact bytes of the body, so parsing and re-serialising it changes what you compare. Read x-narwhal-timestamp as Unix seconds and join its value, a period and the raw body. Compute the lowercase hex HMAC-SHA256 with the secret encoded as UTF-8.
The x-narwhal-signature header holds sha256=<hex>. Compare the computed value with the supplied signature in constant time before parsing the JSON, as the receiver does.
What if my webhook server was down?
Deliveries retry, and the delivery history lets you see failures and replay a delivery. Use GET .../deliveries for the destination's history, then POST .../deliveries/{delivery_id}/replay for the delivery you want to receive again. A replay repeats the same event.
To catch up from a saved position, read GET /v1/events from your last cursor. Keep that cursor for the journal path; the test-delivery operation does not supply one.
Can I receive the same event twice?
Yes, retries and replays repeat the same event_id. Use it as the idempotency key and store it before acting on the event. In production, use a database table for stored IDs, as the receiver's comment indicates.
The example receiver keeps IDs in seen. When an ID is already present, it returns 200 without printing the event again. For an unseen ID, it adds the ID before printing the event's type, country and family.
Worked example: receive one delivery
For the event recorded at 2026-09-25T15:44:56Z, the payload identifies dataset.published, country MEX and family labour. It also carries the schema version, event ID, cursor, resource ID and resource version.
{"schema_version":"1","event_id":"...","cursor":"1","type":"dataset.published","country":"MEX","family":"labour","resource_id":"...","resource_version":"...","recorded_at":"2026-09-25T15:44:56Z"}The delivery arrives with the timestamp and signature headers. The receiver reads the timestamp and requires an age under 300 seconds, then recomputes the HMAC of <timestamp>.<body>. After a signature matches, it parses the payload and looks up event_id. For an unseen event, it stores the ID and prints the fields below before returning 200.
dataset.published MEX labourPublished .
