Webhook Overview
Webhooks stream platform state changes to a URL you control. The platform signs every delivery with HMAC-SHA256, retries transient failures on an exponential schedule, and drops persistently failing payloads into a dead-letter queue so your operators can replay them once the receiver is healthy.
This page locks the envelope shape, the signing recipe, the replay-protection rules, and the retry behaviour. Integrators should port the verifier to their own stack exactly as shown: the recipe is validated bit-for-bit against frozen test vectors in our server-side integration suite, and every sample payload on the per-event pages is a live fixture you can replay against your verifier.
Envelope shape
Every delivery body is a single JSON object with SIX top-level keys.
The HMAC signature is delivered as the X-Webhook-Signature request
header on the inbound POST to your endpoint (NOT as a body field;
including it in the body would make the HMAC a function of its own
output). Fields appear in a stable order; receivers SHOULD NOT rely
on key order but MAY parse with standard JSON libraries.
{
"event_id": "evt_01JXYZTESTEVTID0000000000",
"event_type": "listing.created",
"api_version": "2026-04-17",
"timestamp": 1745339401,
"nonce": "01HXNONCE0000000000000000Z",
"data": {
"agency_id": "user_01HXAGENCY0000000000000",
"listing_id": "66312a4b5c6d7e8f90a1b2c3",
"listing_number": "abc1234",
"address": "123 Main St, Portland, OR 97201",
"status": "Coming Soon",
"is_activated": false,
"created_by": "apikey:key_01HXAPIKEY000000000000",
"public_url": "https://valara.cloud/property/abc1234"
}
}
| Field | Type | Description |
|---|---|---|
event_id | string | ULID; globally unique. Safe to persist as the dedup key on your side. |
event_type | string | One of the 17 catalog entries. See events. |
api_version | string | Payload schema version (YYYY-MM-DD). Locked per event type. |
timestamp | integer | Unix epoch seconds at dispatch time. |
nonce | string | ULID; unique per delivery. Used for replay protection. |
data | object | Event-specific payload. Locked by contract tests per event type. |
Delivery also sets three HTTP headers on the inbound POST:
| Header | Meaning |
|---|---|
X-Webhook-Event-Id | Mirrors event_id; cheaper than parsing the body to dedup. |
X-Webhook-Timestamp | Mirrors timestamp for replay-window checks. |
X-Webhook-Signature | sha256=<hex> HMAC over <timestamp>.<body>, computed by the recipe below. |
HMAC signing recipe
The signing algorithm is HMAC-SHA256 with the per-endpoint secret
returned once at registration. The input to the HMAC is the ASCII
string <timestamp>.<raw_request_body>:
signing_string = str(timestamp) + "." + raw_request_body_bytes
expected_digest = HMAC_SHA256(secret_bytes, signing_string)
header_value = "sha256=" + hex(expected_digest)
Three invariants fixed by test vectors:
- The prefix is lowercase
sha256=(64 lowercase hex characters follow). - The timestamp is decimal ASCII with no leading zero and no
whitespace, separator is exactly one dot
.. - The raw request body is the exact bytes delivered on the wire: no JSON canonicalisation, no re-serialisation, no whitespace trimming.
Known test vector
This minimal vector verifies the HMAC recipe in isolation (correct secret encoding, correct signing-string construction, correct hex output). It is NOT shaped like a real delivery body; for a full-envelope check replay one of the per-event fixtures on webhook events.
secret: test_secret_001
timestamp: 1745339401
body: {"event_id":"evt_01HXTEST"}
signature: sha256=d465098201421848bbd11af4f0d13aca6b98d61b2304ccec9032a913aa281795
If your verifier cannot reproduce that hex against that exact body, the bug is in your HMAC plumbing. Once the minimal vector works, move to the per-event pages: each one ships a pretty-printed envelope body and the signature header computed over those exact bytes; a correct verifier reproduces both.
Python verifier
import hmac
import hashlib
import time
def verify(
secret: str,
timestamp: int,
raw_body: bytes,
header_value: str,
enforce_skew: bool = True,
) -> bool:
"""Return True iff the header_value is a valid HMAC-SHA256 signature.
Rejects deliveries that are more than 5 minutes off the wall clock
when ``enforce_skew`` is True (the production default). Replay
protection (nonce cache) is the caller's responsibility.
Pass ``enforce_skew=False`` when replaying the documented test
vectors on this page or the per-event fixtures, whose timestamps
are pinned (1745339401) and WILL fall outside the skew window.
"""
if enforce_skew and abs(time.time() - timestamp) > 300:
return False
signing_string = f"{timestamp}.".encode("ascii") + raw_body
expected = hmac.new(
secret.encode("utf-8"),
signing_string,
hashlib.sha256,
).hexdigest()
# Strip the "sha256=" prefix if present; be lenient on case.
if header_value.lower().startswith("sha256="):
header_value = header_value[len("sha256="):]
return hmac.compare_digest(expected, header_value.lower())
Node verifier (ESM only, per CLAUDE.md rule 2)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, timestamp, rawBody, headerValue, { enforceSkew = true } = {}) {
// 5 minute skew window. Reject anything outside it before touching HMAC.
// Pass { enforceSkew: false } when replaying the documented test vectors
// whose pinned timestamp (1745339401) will fall outside the window.
const now = Math.floor(Date.now() / 1000);
if (enforceSkew && Math.abs(now - timestamp) > 300) return false;
const signingString = Buffer.concat([
Buffer.from(`${timestamp}.`, "ascii"),
Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody),
]);
const expected = createHmac("sha256", secret).update(signingString).digest("hex");
const value = headerValue.toLowerCase().startsWith("sha256=")
? headerValue.slice("sha256=".length)
: headerValue;
// constant-time compare; timingSafeEqual throws on length mismatch
if (value.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(value, "hex"));
}
Replay protection
The platform guarantees:
timestampis within 5 minutes of server wall clock at dispatch.nonceis a fresh ULID for every delivery (NOT the same asevent_id; retries of the same event carry the sameevent_idbut a newnonce+ fresh signature).
Your receiver MUST:
- Reject any delivery whose
timestampis more than 5 minutes off your own wall clock. NTP skew on your side counts: runchrony. - Remember every
nonceseen in the last 10 minutes and reject duplicates. A RedisSET ... EX 600keyed on the nonce is the canonical pattern.
Rejecting on event_id alone is WRONG because the dispatcher retries
the same event with the same event_id when your endpoint returns a
transient error. Use event_id for dedup on your side (idempotent
downstream effects) and nonce for replay rejection (preventing a
captured delivery from being rebroadcast). Both are required.
Retry behaviour
Deliveries that get a non-2xx response, a TCP reset, or a timeout are retried with exponential backoff. The sequence of delay intervals (in seconds) between attempts:
2, 4, 8, 16, 32
That is five retry attempts plus the initial attempt for a total of
six deliveries over approximately 62 seconds of wall clock. The
Retry-After response header is respected if present and greater
than the next scheduled interval.
A request is considered failed and eligible for retry when the
response status is 5xx, 408, 425, 429 (with Retry-After honored), or
when the TCP connection is reset / times out after 15 seconds. Any 2xx
response terminates the retry loop successfully. Any 4xx response
other than those listed terminates the retry loop with a permanent
failure (we do not retry 400 Bad Request from your server; fix the
bug on your side and replay from the dead-letter queue).
After the sixth attempt fails, the delivery is written to the
dead-letter queue. Dashboard users with the media_agency role can
see DLQ rows on Settings > API Keys > Webhook Endpoint >
Deliveries and replay individual deliveries once the receiver is
healthy.
Endpoint health
If an endpoint fails more than 10 consecutive deliveries with none succeeding in between, the platform auto-disables the endpoint and emails the media agency owner. Re-enable from the dashboard after fixing the receiver; in-flight deliveries continue to queue against the disabled endpoint for up to 24 hours so no events are lost during the outage.
See also
- Webhook events - 17 event types, sample payloads.
- Authentication - managing the API key that mints webhook endpoints.
- Error codes - canonical 4xx/5xx envelope.