Polling webhooks with /v1/events
Learn how to poll webhook events with the Webhook Relay /v1/events API. Use cursor-based polling to consume webhook deliveries, webhook logs, and event data from your application.
Overview
The /v1/events API lets your application poll 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 webhook polling API.
Polling webhooks works with a cursor. The first request defines the scope you want to consume, usually a bucket, and Webhook Relay returns an opaque next_cursor. Store that cursor and send it on the next request to continue from the last webhook delivery you processed.
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 need a recoverable webhook events feed where the cursor can be persisted.
- You want to pull webhook logs and delivery results into your own queue, database, or event processor.
Use WebSockets when your application needs a continuously connected, push-based stream.
Endpoint
GET https://my.webhookrelay.com/v1/events
Create an access token key and secret from the tokens page. For curl, pass the key and secret with Basic authentication:
export RELAY_KEY=your-token-key
export RELAY_SECRET=your-token-secret
export BUCKET_ID=your-bucket-id
You can also use a valid bearer token in the Authorization header when your integration already uses bearer authentication for the Webhook Relay API.
First poll
The first request must include a bucket query parameter. This creates a cursor bound to your account and bucket. You can also include output to poll webhook deliveries for one output only.
curl -sS -u "$RELAY_KEY:$RELAY_SECRET" \
"https://my.webhookrelay.com/v1/events?bucket=$BUCKET_ID&limit=100&max_age=24h"
Example response:
{
"logs": [
{
"id": "9b31d6dc-6d14-4f83-90cb-0b402c02e3cc",
"created_at": 1717243200,
"updated_at": 1717243202,
"bucket_id": "2cf96f7f-7a83-47f7-84d2-f5692e6f68c0",
"input_id": "09a0a807-3f3f-4b66-af3b-f6f79e4f4d36",
"output_id": "7df32a66-6465-4677-bf5c-f2cf9b8ffdb5",
"status": "sent",
"method": "POST",
"headers": {
"Content-Type": ["application/json"]
},
"raw_query": "source=stripe",
"extra_path": "/checkout",
"body": "{\"type\":\"checkout.session.completed\"}",
"status_code": 200,
"duration_ms": 82,
"retries": 0
}
],
"next_cursor": "CgwI...",
"has_more": false
}
If there are no webhook events yet, the response still includes next_cursor. Save it and use it for the next poll.
Continue polling
Pass the returned next_cursor back as cursor. After the first request, you can omit bucket and output because the cursor already contains that scope.
export CURSOR="CgwI..."
curl -G -sS -u "$RELAY_KEY:$RELAY_SECRET" \
--data-urlencode "cursor=$CURSOR" \
--data-urlencode "max_age=24h" \
--data-urlencode "limit=100" \
https://my.webhookrelay.com/v1/events
When has_more is true, request the next page immediately. When has_more is false, wait before polling again. A delay of 2 to 10 seconds is a good starting point for most webhook consumers.
For reliable processing, process the returned logs first, then persist next_cursor. If the consumer crashes before saving the cursor, it may read the same page again, so downstream systems should de-duplicate by the webhook log id when exactly-once side effects matter.
Query parameters
| Parameter | Required | Description |
|---|---|---|
bucket | Required on the first request | Bucket ID to poll. Later requests can omit it because the cursor carries the bucket scope. |
output | Optional | Output ID filter. Use this when one consumer should process events for a single destination. |
cursor | Optional | Opaque resume cursor returned as next_cursor from the previous response. Treat it as a string and do not parse it. |
limit | Optional | Page size. Defaults to 100; maximum is 500. |
max_age | Optional | Lookback safety window, using Go duration syntax such as 1h, 24h, or 168h. Defaults to 24h. |
The cursor is bound to the account, bucket, and optional output used when it was created. If you need to change bucket or output scope, start a new polling sequence without a cursor.
max_age applies on cursor requests too. If a consumer may be offline for more than 24 hours, send a larger max_age value on every request so the polling API can still scan the older partitions that contain unread webhook events.
Response fields
| Field | Description |
|---|---|
logs | Array of webhook log records. Each item contains the webhook request data and delivery status. |
next_cursor | Cursor to store and pass on the next request. It is returned even when logs is empty. |
has_more | true when another page is likely ready immediately. |
Common logs fields:
| Field | Description |
|---|---|
id | Webhook log ID. Use this for de-duplication. |
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 such as received, sent, failed, stalled, or rejected. |
method, headers, raw_query, extra_path, body | Original webhook request data. |
status_code, duration_ms, retries | Destination response status, delivery duration, and retry count when a delivery was attempted. |
ephemeral | true when the bucket is configured not to store request details. |
JavaScript polling example
This Node.js example polls webhook events, processes each log, stores the cursor in memory, and backs off when the page is empty. In production, save cursor to durable storage after processing each page.
const endpoint = 'https://my.webhookrelay.com/v1/events';
const bucket = process.env.BUCKET_ID;
const key = process.env.RELAY_KEY;
const secret = process.env.RELAY_SECRET;
let cursor = process.env.RELAY_CURSOR || '';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function authHeader() {
return 'Basic ' + Buffer.from(`${key}:${secret}`).toString('base64');
}
async function pollOnce() {
const params = new URLSearchParams({ limit: '100', max_age: '24h' });
if (cursor) {
params.set('cursor', cursor);
} else {
params.set('bucket', bucket);
}
const res = await fetch(`${endpoint}?${params.toString()}`, {
headers: {
Authorization: authHeader()
}
});
if (!res.ok) {
throw new Error(`events poll failed: ${res.status} ${await res.text()}`);
}
const page = await res.json();
for (const log of page.logs) {
console.log(`${log.id} ${log.method} ${log.extra_path} ${log.status}`);
// Process the webhook event here.
}
cursor = page.next_cursor;
return page.has_more;
}
async function run() {
for (;;) {
const hasMore = await pollOnce();
if (!hasMore) {
await sleep(5000);
}
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
Error handling
400 Bad Requestmeans the first request is missingbucket, the cursor is invalid, or the cursor does not match the supplied bucket or output.401 Unauthorizedmeans the access token, Basic auth credentials, or bearer token is missing or invalid.404 Not Foundmeans the bucket does not exist or does not belong to the authenticated account.429 Too Many Requestsmeans the account rate limit was reached. Back off before retrying.503 Service Unavailablemeans webhook event polling is not enabled on that deployment. Use WebSockets or the webhook logs API as a fallback.
Polling checklist
- Start with
bucketand optionaloutput. - Save every
next_cursor. - Poll immediately while
has_moreistrue. - Back off when
has_moreisfalse. - De-duplicate by log
idif a webhook event can trigger a non-idempotent side effect. - Start a new cursor when you want to change bucket, output, or initial lookback window.
