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 byevent_typeslike every other event. An endpoint withoutpingin its list gets nothing, and the route still answers202— so a silent no-op looks identical to a delivery failure. Includepingwhen you register. - Every one of your active
pingendpoints 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:
- 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.
- Poll instead.
GET /v1/eventsneeds 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:read — not 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
| Webhooks | GET /v1/events | |
|---|---|---|
| Latency | Seconds | Your polling interval |
| Needs a public HTTPS endpoint | Yes | No |
| Needs signature verification | Yes | No — it is your own authenticated request |
| Missed events | Retried, then dead-lettered | Still there, for 90 days |
| Scope | webhooks:manage to configure | bookings: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
2xxin well under 10 seconds and does its real work afterwards. - Your framework does not return
410for anything on the webhook path. - The contact email on your partner account is one somebody reads — that is where the endpoint-disabled warning goes.