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
dashboard/fastapi_backend/tests/integration/webhook_delivery/test_signing_correctness.py.
Envelope shape
Every delivery body is a single JSON object with seven top-level keys. 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-05-29",
"timestamp": 1745339401,
"nonce": "01HXNONCE0000000000000000",
"data": {
"agency_id": "user_01HXAGENCY",
"listing_id": "66312a4b5c6d7e8f90a1b2c3",
"listing_number": "ABC123",
"address": "123 Main St, Portland, OR 97201",
"status": "Coming Soon",
"is_activated": false,
"created_by": "apikey:66201a1b2c3d4e5f60718293",
"public_url": "https://valara.cloud/property/ABC123"
},
"signature": "sha256=d465098201421848bbd11af4f0d13aca6b98d61b2304ccec9032a913aa281795"
}
| 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 16 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. |
signature | string | sha256=<hex> of the signing string. See below. |
Delivery also sets three HTTP headers on the inbound POST:
| Header | Meaning |
|---|---|
X-Valara-Event-Id | Mirrors event_id; cheaper than parsing the body to dedup. |
X-Valara-Timestamp | Mirrors timestamp for replay-window checks. |
X-Valara-Signature | Mirrors signature so receivers can verify without parsing body. |
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
secret: test_secret_001
timestamp: 1745339401
body: {"event_id":"evt_01HXTEST"}
signature: sha256=d465098201421848bbd11af4f0d13aca6b98d61b2304ccec9032a913aa281795
If your verifier cannot reproduce that hex for that input, the bug is on your side.
Python verifier
import hmac
import hashlib
import time
def verify(secret: str, timestamp: int, raw_body: bytes, header_value: str) -> 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.
Replay protection (nonce cache) is the caller's responsibility.
"""
if 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) {
// 5 minute skew window. Reject anything outside it before touching HMAC.
const now = Math.floor(Date.now() / 1000);
if (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 - 16 event types, sample payloads.
- Authentication - managing the API key that mints webhook endpoints.
- Error codes - canonical 4xx/5xx envelope.