Sending events
Signing events
Ingest endpoints carry no bearer key — the unguessable endpoint key in the URL plus an HMAC signature over the body is the credential. This is the format to use when events come from somewhere you can't safely store an API key, like a store webhook.
The algorithm
Exactly one rule, and it's deliberately simple:
X-Signature is the lowercase hex sha256 HMAC of the exact raw
request body, keyed with the endpoint's signing secret. No timestamp, no
version tag, no canonicalisation — just the bytes you send, hashed with the secret.
The server recomputes the same HMAC over the raw body it received and compares in constant time. If they match, the request is authentic.
Sign the bytes you send, not a re-serialised copy
The single most common 401 cause: signing one JSON string, then sending a differently-serialised one (different whitespace or key order). The HMAC is over raw bytes, so any difference breaks it. Build the body string once, sign that string, and send that same string.
Worked example
BODY='{"event_type":"order.completed","participant":{"external_id":"cus_84h2"},"payload":{"total":349}}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" | sed 's/^.* //') curl -X POST https://api.flyhalf.run/v1/ingest/YOUR_ENDPOINT_KEY \ -H "X-Signature: $SIG" \ -H "Content-Type: application/json" \ -d "$BODY"
$body = json_encode([ 'event_type' => 'order.completed', 'participant' => ['external_id' => 'cus_84h2'], 'payload' => ['total' => 349], ], JSON_UNESCAPED_SLASHES); $signature = hash_hmac('sha256', $body, $signingSecret); // send $body verbatim with header X-Signature: $signature
const crypto = require('crypto'); const body = JSON.stringify({ event_type: 'order.completed', participant: { external_id: 'cus_84h2' }, payload: { total: 349 }, }); const signature = crypto .createHmac('sha256', signingSecret) .update(body) .digest('hex'); await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Signature': signature }, body, });
What the endpoint checks, in order
- 404 — the endpoint key is unknown or the endpoint is inactive.
- 403 — the endpoint has an IP allow-list and your source IP isn't on it (see below).
- 401 —
X-Signatureis missing or doesn't match the recomputed HMAC. - 422 — the body isn't a JSON object.
- 202 — accepted and queued.
IP allow-list
An endpoint can optionally pin an allow-list of source IPs. Leave it empty to accept any
source; populate it and requests from other IPs get a 403 permission_error.
Useful when your sender has stable egress IPs. It's checked before the signature.
The authenticated alternative
If the caller is your own backend and can hold an API key, skip signing
entirely: POST /v1/events with a bearer key and the
events:write ability takes the identical body and runs the identical
pipeline — no X-Signature needed. Signing exists for senders you don't fully
trust with a key.