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: 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
X-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:
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.
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 Settingsonce 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 Replaybutton. 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.
