DocumentationFundamentals

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.

Beta Polling webhooks are currently a beta feature. If you run into unexpected behavior, please report it to support@webhookrelay.com.

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:

  1. Selects up to limit of the oldest webhooks still in the received state for your bucket.
  2. Marks each one delivered (receivedsent) before writing the response. The mark is synchronous, so the next poll will not return it again.
  3. Returns the consumed webhooks in the logs array, 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

ParameterRequiredDescription
bucketRequired on every requestBucket ID or account-unique name to drain. There is no cursor, so the bucket must be supplied each call.
outputOptionalOutput ID filter. Use it when one consumer should drain events for a single destination.
limitOptionalPage size. Defaults to 1; maximum is 100. Each returned webhook is marked delivered, so a larger limit consumes a larger batch per poll.
max_ageOptionalLookback 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

FieldDescription
logsArray of webhook records, oldest first. Each one was just marked delivered. Empty once the queue is drained.
has_moretrue when the page filled to limit and at least one webhook was delivered — poll again immediately. false means back off.

Common logs fields:

FieldDescription
idWebhook log ID. Use it for de-duplication and to report a different outcome with PUT /v1/logs/{id}.
created_atUnix timestamp when Webhook Relay received the webhook.
updated_atUnix timestamp for the latest delivery update.
bucket_id, input_id, output_idIDs for the bucket, public input endpoint, and destination output.
statusDelivery status. Webhooks returned by /v1/events were in received — pulling them is the delivery.
method, headers, raw_query, extra_path, body, ip_addressOriginal webhook request data.
status_code, duration_ms, retriesDestination response status, delivery duration, and retry count — present only when a delivery to an output was attempted.
ephemeraltrue 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 id if 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 received and 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 — the bucket query 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 optional output.
  • Poll immediately while has_more is true; back off when it is false.
  • 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 id if you run more than one consumer on the same bucket or trigger non-idempotent side effects.
Did this page help you?