WebhooksTesting and polling

Testing and polling

Sending a test event

curl -sS -X POST "https://{your-base-url}/v1/webhooks/42/test" \
  -H "Authorization: Bearer $TOKEN"

Answers 202 and queues a ping event. It arrives signed, retried and delivered exactly like a real one — which is the point: it exercises your signature verification and your handler end to end.

{ "id": "evt_…", "type": "ping", "data": { "ok": true, "endpoint_id": 42, "message": "This is a test event from DriveCars." } }

Two things about it that surprise people:

  • The endpoint must be subscribed to ping. The ping fans out by event_types like every other event. An endpoint without ping in its list gets nothing, and the route still answers 202 — so a silent no-op looks identical to a delivery failure. Include ping when you register.
  • Every one of your active ping endpoints receives it, not only the one in the URL. The ping is scoped to your partner account; the {id} in the path is there to check the endpoint is yours. If you run staging and production endpoints under one account, both hear it.

The endpoint must also be active. A disabled one receives nothing.

Local development

You cannot point a webhook at localhost. Registration requires a public HTTPS hostname and rejects loopback and private addresses outright, on every attempt, not just the first.

Two ways round it:

  1. A tunnel. Any tunnelling tool gives you a public HTTPS hostname that forwards to your machine. Register that URL. This is the only way to exercise the real delivery path locally.
  2. Poll instead. GET /v1/events needs no inbound connectivity at all and is genuinely the easier path while you are still building the handler.

Polling: GET /v1/events

Every event we would have delivered is also readable on demand. You can use this as a backstop for a webhook integration, or instead of one.

curl -sS "https://{your-base-url}/v1/events?since_id=0&limit=100" \
  -H "Authorization: Bearer $TOKEN"

Requires bookings:readnot webhooks:manage. A partner who never registers an endpoint can still poll.

{
  "data": [
    {
      "id": "evt_9c1f4a7b2e8d05364f1a9b7c3d2e5f80",
      "type": "booking.confirmed",
      "created_at": "2026-09-09T10:00:00.000Z",
      "livemode": true,
      "data": { }
    }
  ],
  "has_more": false,
  "last_id": 84213
}

Ordered oldest to newest, the opposite of the delivery list. That is what makes it resumable.

since_id is not the event id

This is the one thing to get right. since_id takes the numeric last_id from the previous response — an internal sequence number — not the evt_… string in each row’s id field.

The loop:

let cursor = loadCursor() ?? 0;
 
for (;;) {
  const res = await fetch(`${BASE}/v1/events?since_id=${cursor}&limit=100`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  const page = await res.json();
 
  for (const event of page.data) await handle(event);   // dedupe on event.id
 
  if (page.last_id !== null) {
    cursor = page.last_id;
    saveCursor(cursor);   // persist BEFORE the next request
  }
  if (!page.has_more) break;
}

Persist the cursor only after the events in that page are safely handled. Save it first and crash, and you have skipped a page permanently — there is no “go back” other than replaying from a lower id.

last_id is null when a page is empty. Keep your existing cursor.

Filtering

curl -sS "https://{your-base-url}/v1/events?types=booking.confirmed,booking.cancelled" \
  -H "Authorization: Bearer $TOKEN"

Comma-separated. Unlike endpoint registration — which silently drops types it does not recognise — this route rejects an unknown one with 400 invalid_types and names it. Handy for checking a type name you are unsure of.

limit defaults to 50 and caps at 200.

The 90-day window

Events older than 90 days are not returned, by either GET /v1/events or GET /v1/events/{event_id}. An expired event is a 404, indistinguishable from one that never existed or belongs to someone else.

If you need a longer history than that, keep your own copy as you consume.

One event by id

curl -sS "https://{your-base-url}/v1/events/evt_9c1f4a7b2e8d05364f1a9b7c3d2e5f80" \
  -H "Authorization: Bearer $TOKEN"

Useful for filling a gap: a webhook you logged but failed to process can be re-read here, in full, without a replay.

Polling versus webhooks

WebhooksGET /v1/events
LatencySecondsYour polling interval
Needs a public HTTPS endpointYesNo
Needs signature verificationYesNo — it is your own authenticated request
Missed eventsRetried, then dead-letteredStill there, for 90 days
Scopewebhooks:manage to configurebookings:read

Doing both is a reasonable pattern: take webhooks for latency, and run a slow poll — hourly, say — as a backstop that catches anything that dead-lettered while you were down. Since both sides deduplicate on the same event id, the overlap costs nothing.

A checklist before going live

  • Signature verification is on, and rejects a deliberately corrupted body. Verify that by editing one byte, not by assuming.
  • Your verifier accepts both secrets, so a rotation is a non-event.
  • Duplicate events are dropped by id.
  • Your handler answers 2xx in well under 10 seconds and does its real work afterwards.
  • Your framework does not return 410 for anything on the webhook path.
  • The contact email on your partner account is one somebody reads — that is where the endpoint-disabled warning goes.