Polling webhooks with /v1/events
Learn how to poll webhook events with the Webhook Relay /v1/events API. Pull the oldest undelivered webhooks for a bucket, consume them as a drain queue, and report delivery outcomes back to Webhook Relay.
Overview
The /v1/events API lets your application pull webhook events from Webhook Relay instead of keeping a persistent WebSocket connection open. It is useful for workers, scheduled jobs, serverless consumers, back-office tools, and integrations that prefer a simple HTTP polling API.
Polling works as a drain queue, not a replayable stream. Each call returns the oldest unsent webhooks for a bucket and marks them delivered, so the next call returns the next batch and the queue drains to an empty response once you are caught up. There is no cursor to store — the queue position is the webhook delivery status itself, so you simply keep calling the same URL.
Use polling when:
- You want to consume webhook events from an HTTP API rather than a WebSocket client.
- Your consumer runs periodically, such as a cron job or serverless function.
- You want a self-managing feed with no cursor or offset to persist.
- You want to pull webhooks into your own queue, database, or event processor and acknowledge them by simply receiving them.
Use WebSockets when your application needs a continuously connected, push-based stream.
How the drain queue works
A webhook that arrives in a bucket starts in the received state. If the bucket has outputs, Webhook Relay delivers it to those destinations and the status moves to sent or failed. /v1/events is the pull alternative: it returns the webhooks that are still received — the ones nobody has delivered yet — and treats handing them to you as the delivery.
Concretely, on every poll Webhook Relay:
- Selects up to
limitof the oldest webhooks still in thereceivedstate for your bucket. - Marks each one delivered (
received→sent) before writing the response. The mark is synchronous, so the next poll will not return it again. - Returns the consumed webhooks in the
logsarray, oldest first.
Because the pull is the delivery, there is nothing to acknowledge in the happy path. If your consumer actually failed to process a webhook, you can correct the recorded outcome afterwards with PUT /v1/logs/{id} — see below.
Endpoint
GET https://my.webhookrelay.com/v1/events
Create an access token from the tokens page and pass it as a bearer token. Tokens look like sk-whrm-….
export RELAY_TOKEN=your-api-token
export BUCKET=your-bucket-name-or-id
The dashboard generates a ready-to-run snippet for you: open a bucket, choose Connect Agent → Poll, and copy the pre-filled
curl/ Node.js commands.
Basic authentication with a token key and secret (-u key:secret) also works if your integration already uses it.
Pull events
Every request must include a bucket query parameter — there is no cursor to carry the scope. The reference can be the bucket ID or its account-unique name. Optionally pass output to drain webhooks for a single destination only.
curl -sS -H "Authorization: Bearer $RELAY_TOKEN" \
"https://my.webhookrelay.com/v1/events?bucket=$BUCKET&limit=1"
Example response:
{
"logs": [
{
"id": "9b31d6dc-6d14-4f83-90cb-0b402c02e3cc",
"created_at": 1717243200,
"updated_at": 1717243200,
"bucket_id": "2cf96f7f-7a83-47f7-84d2-f5692e6f68c0",
"input_id": "09a0a807-3f3f-4b66-af3b-f6f79e4f4d36",
"output_id": "7df32a66-6465-4677-bf5c-f2cf9b8ffdb5",
"status": "received",
"method": "POST",
"headers": {
"Content-Type": ["application/json"]
},
"raw_query": "source=stripe",
"extra_path": "/checkout",
"body": "{\"type\":\"checkout.session.completed\"}",
"ip_address": "203.0.113.10"
}
],
"has_more": false
}
The returned webhooks were in the received state — receiving them through /v1/events is what marks them delivered, so the same webhook is never handed out twice to a single consumer. When the queue is empty the response is simply { "logs": [], "has_more": false }.
Continuous polling
Keep calling the same URL. When has_more is true, the page filled to the requested limit and more webhooks are waiting — poll again immediately. When it is false, back off before polling again. A delay of 2–10 seconds is a good starting point for most consumers.
This Node.js example (Node 18+, no dependencies) drains the queue one webhook at a time and backs off when it is empty:
const ENDPOINT = 'https://my.webhookrelay.com/v1/events';
const BUCKET = process.env.BUCKET; // bucket name or ID
const TOKEN = process.env.RELAY_TOKEN; // API token from /tokens
async function poll() {
// Pull the oldest unsent webhooks (limit 1, max 100). Each one
// returned is marked delivered, so the next call returns the next
// batch and eventually an empty list once you're caught up.
const res = await fetch(`${ENDPOINT}?bucket=${BUCKET}&limit=1`, {
headers: { Authorization: `Bearer ${TOKEN}` }
});
if (!res.ok) {
console.error(`poll failed: ${res.status} ${await res.text()}`);
return setTimeout(poll, 5000);
}
const data = await res.json();
for (const log of data.logs) {
console.log(log.id, log.method, log.extra_path);
// Handle the webhook here. If you couldn't process it, report the
// real outcome with PUT /v1/logs/{log.id} (status code + body).
}
// has_more === true => more waiting, fetch again straight away.
setTimeout(poll, data.has_more ? 0 : 3000);
}
poll();
Query parameters
| Parameter | Required | Description |
|---|---|---|
bucket | Required on every request | Bucket ID or account-unique name to drain. There is no cursor, so the bucket must be supplied each call. |
output | Optional | Output ID filter. Use it when one consumer should drain events for a single destination. |
limit | Optional | Page size. Defaults to 1; maximum is 100. Each returned webhook is marked delivered, so a larger limit consumes a larger batch per poll. |
max_age | Optional | Lookback window for unsent webhooks, using Go duration syntax such as 1h, 24h, or 168h. Defaults to 24h. Send a larger value if a consumer may be offline long enough that older webhooks must still be drained. |
Response fields
| Field | Description |
|---|---|
logs | Array of webhook records, oldest first. Each one was just marked delivered. Empty once the queue is drained. |
has_more | true when the page filled to limit and at least one webhook was delivered — poll again immediately. false means back off. |
Common logs fields:
| Field | Description |
|---|---|
id | Webhook log ID. Use it for de-duplication and to report a different outcome with PUT /v1/logs/{id}. |
created_at | Unix timestamp when Webhook Relay received the webhook. |
updated_at | Unix timestamp for the latest delivery update. |
bucket_id, input_id, output_id | IDs for the bucket, public input endpoint, and destination output. |
status | Delivery status. Webhooks returned by /v1/events were in received — pulling them is the delivery. |
method, headers, raw_query, extra_path, body, ip_address | Original webhook request data. |
status_code, duration_ms, retries | Destination response status, delivery duration, and retry count — present only when a delivery to an output was attempted. |
ephemeral | true when the bucket is configured not to store request details. |
Delivery semantics
- Single consumer → at-most-once. Each webhook is handed out exactly once, then marked delivered, so one poller never sees the same webhook twice.
- Concurrent consumers → at-least-once. There is no per-record lease, so two clients polling the same bucket at the same moment can both receive the same webhook (the second mark is a harmless no-op). De-duplicate by webhook
idif a webhook can trigger a non-idempotent side effect. - Transient failures delay, not drop. If Webhook Relay cannot mark a webhook delivered (a rare storage write error), it leaves it
receivedand returns it again on a later poll. A webhook is never silently lost.
Reporting a different outcome
Receiving a webhook through /v1/events records it as delivered (sent). If your consumer could not process it, report the real outcome with PUT /v1/logs/{id}, supplying the destination status_code (and optionally a status and response_body):
curl -sS -X PUT -H "Authorization: Bearer $RELAY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status_code": 500}' \
"https://my.webhookrelay.com/v1/logs/9b31d6dc-6d14-4f83-90cb-0b402c02e3cc"
A webhook log is only editable for a short window after it was received (about a minute), so report the outcome promptly after pulling it.
Error handling
400 Bad Request— thebucketquery parameter is missing.401 Unauthorized— the access token or Basic auth credentials are missing or invalid.404 Not Found— the bucket does not exist or does not belong to the authenticated account.408 Request Timeout— the request was canceled before the page could be consumed; retry.429 Too Many Requests— the account rate limit was reached. Back off before retrying.500 Internal Server Error— a transient read error. Retry with backoff.503 Service Unavailable— polling is not enabled on that deployment. Use WebSockets or the webhook logs API as a fallback.
Polling checklist
- Send
bucket(ID or name) on every request, plus an optionaloutput. - Poll immediately while
has_moreistrue; back off when it isfalse. - Treat receiving a webhook as the acknowledgement — there is no cursor to persist.
- Report a different outcome with
PUT /v1/logs/{id}if your consumer failed to process a webhook. - De-duplicate by log
idif you run more than one consumer on the same bucket or trigger non-idempotent side effects.
