---
title: "Polling webhooks with /v1/events | WebhookRelay"
meta:
  "og:description": "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."
  "og:title": "Polling webhooks with /v1/events"
  description: "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."
---

![Stripes](https://webhookrelay.com/docs/webhooks/polling-webhooks/images/stripes.svg)

Documentation

**Fundamentals**

# **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](#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**](https://webhookrelay.com/docs/webhooks/polling-webhooks//mailto:support@webhookrelay.com?Subject=Polling%20webhooks%20beta%20issue).

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](https://webhookrelay.com/docs/webhooks/polling-webhooks/docs/webhooks/websocket-server/) when your application needs a continuously connected, push-based stream.

## [How the drain queue works](#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** (`received` → `sent`) 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}`](#reporting-a-different-outcome) — see below.

## [Endpoint](#endpoint)

```
GET https://my.webhookrelay.com/v1/events
```

Create an access token from the [tokens page](https://my.webhookrelay.com/tokens) 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](#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](#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](#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](#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](#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](#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](#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](https://webhookrelay.com/docs/webhooks/polling-webhooks/docs/webhooks/websocket-server/) or the webhook logs API as a fallback.

## [Polling checklist](#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?