API Docs

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: false while 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: 1

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:

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

Frameworks like Express (without 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 ===

Don't use === 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

Without a skew check, an attacker who captured a valid delivery once can replay it years later. Reject any signature where |now − t| > 300 seconds.

PHP-specific: php-fpm / NGINX request buffering

On large payloads, NGINX may close the upstream connection before the body is fully buffered to PHP. Symptoms: 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:

AttemptWait before
1immediate
25 seconds
35 minutes
430 minutes
52 hours
65 hours
710 hours
810 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.