Skip to main content

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"
}
}
FieldTypeDescription
event_idstringULID; globally unique. Safe to persist as the dedup key on your side.
event_typestringOne of the 17 catalog entries. See events.
api_versionstringPayload schema version (YYYY-MM-DD). Locked per event type.
timestampintegerUnix epoch seconds at dispatch time.
noncestringULID; unique per delivery. Used for replay protection.
dataobjectEvent-specific payload. Locked by contract tests per event type.

Delivery also sets three HTTP headers on the inbound POST:

HeaderMeaning
X-Webhook-Event-IdMirrors event_id; cheaper than parsing the body to dedup.
X-Webhook-TimestampMirrors timestamp for replay-window checks.
X-Webhook-Signaturesha256=<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:

  1. The prefix is lowercase sha256= (64 lowercase hex characters follow).
  2. The timestamp is decimal ASCII with no leading zero and no whitespace, separator is exactly one dot ..
  3. 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:

  1. timestamp is within 5 minutes of server wall clock at dispatch.
  2. nonce is a fresh ULID for every delivery (NOT the same as event_id; retries of the same event carry the same event_id but a new nonce + fresh signature).

Your receiver MUST:

  1. Reject any delivery whose timestamp is more than 5 minutes off your own wall clock. NTP skew on your side counts: run chrony.
  2. Remember every nonce seen in the last 10 minutes and reject duplicates. A Redis SET ... EX 600 keyed 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