Polling Endpoints
A polling endpoint receives no webhooks. Hookbase keeps its messages, and your consumer fetches them with an HTTPS GET whenever it likes. This suits consumers that can't accept inbound traffic, such as services behind a firewall, batch jobs and desktop or on-prem software. Polling endpoints require the Starter plan or above.
Everything upstream works as it does for a webhook endpoint: subscriptions, event-type routing, label and payload filters, transforms, retention and the DLQ. Only the last step changes. Instead of Hookbase POSTing each message, your consumer pulls it.
Create one
In the dashboard, open Outbound → Endpoints → Create Endpoint and choose Polling (consumer pulls) as the delivery type. You can also create one through the API:
curl -X POST https://api.hookbase.app/api/organizations/$ORG_ID/webhook-endpoints \
-H "Authorization: Bearer $HOOKBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "applicationId": "'$APP_ID'", "endpointType": "polling" }'The response includes three values that are shown only once:
| Field | What it is |
|---|---|
pollUrl | https://api.hookbase.app/v1/poll/<endpoint id>, the URL your consumer polls. It is also the endpoint's url. |
pollToken | whpoll_…, the consumer's credential. It can pull from this one endpoint and nothing else. Hookbase stores only its hash. |
secret | The signing secret (whsec_…) used to verify messages, as with webhooks. Ed25519 endpoints return a publicKey instead. |
Subscribe the endpoint to event types as you would any other endpoint. A polling endpoint can't be switched to HTTP or a queue later, or the other way round, because the consumer's position belongs to it. Create a new endpoint instead.
Poll
GET {pollUrl}?cursor=<cursor>&limit=<1-100>
Authorization: Bearer whpoll_...curl "https://api.hookbase.app/v1/poll/$ENDPOINT_ID?limit=50" \
-H "Authorization: Bearer $POLL_TOKEN"{
"data": [
{
"id": "6f1c2a9e-5b8e-4d7a-9a51-1d0f3c2b7e44",
"eventType": "order.created",
"timestamp": "2026-10-18T14:03:11.204Z",
"payload": { "orderId": "ord_123", "total": 4200 },
"body": "{\"orderId\":\"ord_123\",\"total\":4200}",
"contentType": "application/json",
"headers": {
"content-type": "application/json",
"x-hookbase-event-type": "order.created",
"webhook-id": "6f1c2a9e-5b8e-4d7a-9a51-1d0f3c2b7e44",
"webhook-timestamp": "1760796191",
"webhook-signature": "v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4="
}
}
],
"nextCursor": "cDE6MQ",
"done": true
}| Field | Meaning |
|---|---|
data | Up to limit messages (default 50, max 100), oldest first. |
payload | The body parsed as JSON. It is null when the subscription's transform outputs something other than JSON. |
body | The exact string the signature covers. Verify against this, not a re-serialized payload. |
headers | The headers a webhook delivery would have carried, including the Standard Webhooks signature. |
error | Present only when a message can't be served: payload_not_found (its payload is gone), or transform_failed: …. body is then null. |
nextCursor | Pass it back on your next poll. It is opaque: store it as-is and don't parse it. |
done | true when nothing more is waiting right now. false means poll again straight away. |
Cursors and acknowledgement
Passing a cursor acknowledges every message up to it. Those messages are recorded as delivered, which shows as a successful attempt in the dashboard, and the endpoint's stored position moves there. So:
- Poll, process the page, and only then poll again with that page's
nextCursor. - If your consumer crashes before the next poll, nothing was acknowledged, and the same messages come back.
- A poll without a cursor resumes from the last acknowledged position. A new consumer, or one that lost its cursor, picks up where the previous one stopped.
Delivery is therefore at least once, as with webhooks. A message can be served more than once (after a crash, or to two consumers sharing a token). Deduplicate on id, which is also the webhook-id header.
Ordering is stable. Each message gets a position in the endpoint's sequence when a poll first reaches it, so a page never skips a message and never repeats one, even when events are published while you poll. Passing an older cursor re-reads from there (a rewind) without acknowledging anything again. A cursor past anything this endpoint has served gets 400.
Two consumers polling the same endpoint with the same cursor get the same page. To process in parallel, give each consumer its own endpoint subscribed to the same event types.
How often to poll
There is no long-poll: an empty page returns at once. When done is true, wait before the next poll. The response suggests an interval in a Retry-After header (5 seconds). When done is false, poll again immediately.
Each endpoint is rate-limited to 120 polls per minute. Over the limit you get 429 with Retry-After.
Verify the signature
Every message is signed when it is served, with the endpoint's signing secret, exactly like a webhook. The poll token proves the response came from Hookbase. The signature also proves each message wasn't altered after it left Hookbase, for example in a cache or a queue you forward it to. webhook-timestamp is the time the message was served, so the usual 5-minute tolerance applies to the poll, not to when the event happened (that is timestamp).
Node.js consumer
import { readFile, writeFile } from 'node:fs/promises';
import { Webhook } from 'standardwebhooks';
const POLL_URL = process.env.HOOKBASE_POLL_URL; // https://api.hookbase.app/v1/poll/<endpoint id>
const POLL_TOKEN = process.env.HOOKBASE_POLL_TOKEN; // whpoll_...
const wh = new Webhook(process.env.HOOKBASE_SIGNING_SECRET.replace(/^whsec_/, ''));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Keep the cursor somewhere durable. Without one, the poll resumes from the last acknowledged
// position anyway, so losing it only means re-reading the unacknowledged page.
let cursor = await readFile('.hookbase-cursor', 'utf8').catch(() => '');
for (;;) {
const url = new URL(POLL_URL);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${POLL_TOKEN}` } });
if (res.status === 429 || res.status >= 500) {
await sleep(Number(res.headers.get('retry-after') ?? 5) * 1000);
continue;
}
if (!res.ok) throw new Error(`poll failed: ${res.status} ${await res.text()}`);
const page = await res.json();
for (const msg of page.data) {
if (msg.error) { console.warn('skipping', msg.id, msg.error); continue; }
wh.verify(msg.body, msg.headers); // throws if the signature doesn't match
await handle(msg.eventType, msg.payload); // must be idempotent on msg.id
}
// Acknowledge this page by sending its cursor with the next poll.
cursor = page.nextCursor;
await writeFile('.hookbase-cursor', cursor);
if (page.done) await sleep(Number(res.headers.get('retry-after') ?? 5) * 1000);
}
async function handle(eventType, payload) {
console.log(eventType, payload);
}Messages nobody pulls
Messages wait for the plan's outbound retention period. A message still not pulled when that period ends moves to the DLQ with the reason Not pulled before expiry (poll_expired), and stays there for one more retention period. From the DLQ you can retry it, which puts it back in the endpoint's queue for the consumer.
The endpoint page shows the consumer's position: messages waiting, consumer lag (the age of the oldest unacknowledged message), and when the consumer last polled and last acknowledged.
Rotating the token
Rotate token on the endpoint page, or POST /api/organizations/:orgId/webhook-endpoints/:id/rotate-poll-token, issues a new whpoll_ token and returns it once. The old token stops working immediately. The consumer's position is kept, so the consumer only needs its Authorization header updated. Rotating the signing secret is separate and works as for webhooks.
Errors
| Status | When |
|---|---|
400 | limit outside 1–100, a cursor that isn't one of ours, or a cursor past what was served. |
401 | Missing or wrong token. The same answer is given whether or not the endpoint exists. |
403 | The endpoint or its application is disabled. |
429 | Over 120 polls per minute. Wait Retry-After seconds. |
Test sends and endpoint verification don't apply to a polling endpoint. To try it, send an event to a subscribed event type and poll for it.