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 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"
}
FieldTypeDescription
event_idstringULID; globally unique. Safe to persist as the dedup key on your side.
event_typestringOne of the 16 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.
signaturestringsha256=<hex> of the signing string. See below.

Delivery also sets three HTTP headers on the inbound POST:

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

  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

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:

  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