Webhooks
Outbound webhooks deliver signed event notifications to your URL whenever something happens on the dealer side: a listing publishes, an order arrives, a customer matches. The signing scheme is deliberately Stripe-shaped, so any verification code you've written for Stripe-style webhooks works here too.
Envelope
Every webhook is a POST to your configured URL with a JSON body shaped like:
{
"id": "evt_b6d0dfb9dfb64613bed8709ba8a5d079",
"object": "event",
"type": "listing.published",
"api_version": "2026-05-07",
"created_at": "2026-05-08T08:41:33.114Z",
"livemode": false,
"data": {
"object": { /* per-event payload, see /docs/api/events */ },
"previous_attributes": { /* present on update events */ }
}
}- id: unique event ID. Use it to dedupe at-least-once deliveries.
- type: full event name (
listing.published,order.received, …). - api_version: the version pinned to the integration that produced the event.
- livemode:
falsewhile the integration was in test mode. - data.object: the resource the event is about. See Event catalog.
- data.previous_attributes: present on
*.updated-style events; map of changed fields with their previous values.
Headers
POST /webhooks/wth HTTP/1.1
Content-Type: application/json
X-WTH-Signature: t=1778229903,v1=5a81dbf590e046237fd578e75f5824f210fff7bf4cabd6de7a767169362b5044
X-WTH-Event-Id: evt_b6d0dfb9dfb64613bed8709ba8a5d079
X-WTH-Event-Type: listing.published
X-WTH-Delivery-Id: 55fbd03c-5744-4b8e-a057-a29af4729317
X-WTH-Delivery-Attempt: 1X-WTH-Delivery-Id is unique per dispatch attempt. Useful for de-duping at the receiver. X-WTH-Event-Id is unique per event but identical across retries.
Signing algorithm
The X-WTH-Signature header carries one timestamp and one or more HMAC-SHA256 signatures. The format:
X-WTH-Signature: t=<unix_seconds>,v1=<hex>[,v1=<hex>...]Each v1 value is HMAC_SHA256(signing_secret, "{t}.{rawBody}") as lowercase hexadecimal. The signed string is the timestamp, a literal period, and the raw request body. Exactly the bytes we sent you, no whitespace normalisation.
Multiple v1= values mean we are rotating secrets. Match any of them. Senders never include a stale signature, so seeing more than one v1= is a transient sign of a live rotation, not an error.
Verification recipes
Three implementations of the same algorithm. Pick your language, copy the snippet, deploy. Don't roll your own. The gotchas below bite real integrators.
# cURL can't verify a signature itself, but it CAN show you the parts you
# need to verify in your real receiver. Capture headers + body of an
# incoming webhook by piping it through your verifier process, or, for
# a one-shot manual sanity check, recompute the signature locally:
TIMESTAMP="1778229903"
RAW_BODY='{"id":"evt_b6d0dfb9...","object":"event",...}'
SECRET="whsec_YOUR_SIGNING_SECRET"
EXPECTED=$(printf '%s.%s' "$TIMESTAMP" "$RAW_BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -binary \
| xxd -p -c 256)
echo "Expected v1: $EXPECTED"
# Compare with the v1=<hex> value in the X-WTH-Signature header.Gotchas every integrator hits
Read the raw body BEFORE any JSON parser touches it
express.raw()), Laravel, and Symfony auto-parse JSON request bodies into objects. Hashing a re-serialised JSON value gives a different hash because of whitespace / key order / number formatting. Read req.text() / file_get_contents('php://input') first, hash that string, then parse.Constant-time compare, not ===
=== or strcmp: they leak timing. Node: crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')). PHP: hash_equals(\$expected, \$got).Skew check is not optional
|now − t| > 300 seconds.PHP-specific: php-fpm / NGINX request buffering
php://input returns truncated JSON, hash never matches. Set client_max_body_size 10m; and proxy_request_buffering off; on the receiver location. Modern PHP (≥7.0), no need to defensively stripslashes; magic_quotes_gpc is gone.Delivery, retries, dead-letter
We treat any 2xx response as success. Anything else (including non-responses / timeouts) is a failure that triggers a retry on this exact schedule:
| Attempt | Wait before |
|---|---|
| 1 | immediate |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 hours |
After 8 attempts (~28 hours) a delivery transitions to dead_letter. It stays in the database for forensics but no further automatic retries fire. You can re-fire it manually from the Deliveries tab's Replay button.
A 410 Gone response is special-cased. We treat five consecutive 410s as a signal that your endpoint is permanently dead and auto-disable webhooks for the integration. Re-enable from Settings once you've fixed the receiver.
Timeouts and idempotency
Each delivery attempt has a 10-second total timeout. Slow receivers will be retried, so design for at-least-once delivery and de-dupe by X-WTH-Event-Id at your end.
Reply quickly. If your handler does heavy work (image processing, downstream API calls), enqueue the work and return 200 immediately. A 200 you can't honour is worse than a 5xx that triggers a well-paced retry.
Replay from the dashboard
The Deliveries tab on /channels/custom lists the most-recent 50 deliveries with their status, attempt count, and last response code. Failed and dead-letter deliveries get a Replay button. Clicking it requeues the same event with a fresh attempt counter. Useful when you've fixed a bug in your receiver and want to backfill the last few hours.
Echo-suppression
Events triggered by your own integration POSTs are not delivered back to your webhook URL. Specifically, order.received and customer.matched are suppressed for the source integration; events triggered by other sources (the dashboard, other channel integrations) still fire. Details on the events page.