Receive Narwhal webhooks

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.

  1. 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 or WLD, and 1 to 13 family slugs, including holidays and predictions. The request can also include enabled: true. The curl request selects USA and MEX with the prices and labour families. Replace its example HTTPS URL with the URL where your receiver accepts deliveries.
    Create a webhook destination
    curl -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"]}'
  2. Save the destination secret The response includes destination_id and a secret, which is shown only once. Set NARWHAL_WEBHOOK_SECRET to 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 by rotate-secret is also shown only once; for 24 hours after rotation, the signature header carries a second value signed with the old secret.
  3. 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 signature
    import 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
  4. Request a test delivery Call POST /v1/webhooks/destinations/{destination_id}/test with the saved destination ID. A test delivery has is_test: true and no cursor. Delivery states are pending, leased, succeeded, failed and cancelled. A family outside your plan returns 403 plan_upgrade_required; creating a destination at your plan's destination limit returns 409 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

SituationBest optionWhy
A service of your ownThe Python receiverIt verifies the signature and handles repeated event IDs
A no-code flowA Zapier, Make or n8n catch hookIt supplies a destination URL; keep that URL secret
Polling instead of deliveriesGET /v1/events or the economic calendarThe 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.

Economics event payload
{"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.

Receiver output
dataset.published MEX labour

Published .