Webhooks

A webhook is a URL on your backend that poolse POSTs to when something happens in your workspace. Each POST is signed, so you can check it came from poolse, and retried if your server doesn't answer with a 2xx.

Events

EventFires when
message.createdA message is sent.
message.updatedA message is edited.
message.deletedA message is deleted.
mention.createdA message @mentions a user. One event per mentioned user.
conversation.createdA conversation is created.
conversation.updatedA conversation's fields change.
member.addedA user is added to a conversation.
member.removedA user is removed from a conversation.
call.startedA call or room starts.
call.answeredThe first person picks up a call. Rooms don't ring, so they never send this.
call.finishedA call or room ends.

A webhook subscribes to a list of these names. An empty list means every event, including events added later. There are no wildcards otherwise: names must match exactly. There is no webhook for read receipts or reactions.

To stop deliveries without losing the settings, set the webhook to inactive (is_active: false).

The delivery

poolse sends POST <your url> with these headers:

Content-Type: application/json
User-Agent: poolse-webhook/1
X-Poolse-Signature: t=1761566035,v1=5f2c…
X-Poolse-Event-Id: 0b6a3c1e-8f2d-4e7a-9c55-2d1f0e9a7b31
X-Poolse-Event-Type: message.created
X-Poolse-Delivery-Attempt: 1

and a JSON body with the same outer shape for every event:

{
  "id": "0b6a3c1e-8f2d-4e7a-9c55-2d1f0e9a7b31",
  "type": "message.created",
  "tenant_id": "65787825-2b4f-4f0e-9a8e-6c0f2a1d3b44",
  "created_at": "2026-10-27T12:33:55.123456Z",
  "data": { }
}
FieldMeaning
idThe delivery id (a UUID). Also in X-Poolse-Event-Id. The same on every retry of this delivery.
typeThe event name. Also in X-Poolse-Event-Type.
tenant_idYour workspace id.
created_atWhen the delivery was created.
dataThe event payload. See Webhook events.

Each webhook gets its own delivery, with its own id, for the same event. Deliveries are not guaranteed to arrive in order.

X-Poolse-Delivery-Attempt counts from 1 and goes up on each retry.

Signing

X-Poolse-Signature: t=<unix seconds>,v1=<signature>

v1 is HMAC-SHA256(secret, "<t>.<raw body>"), hex-encoded in lowercase. t is the time the attempt was signed, so each retry has a new t and a new signature.

The secret is shown once, when you create the webhook (signing_secret in the API response). There is no way to read it again or rotate it: to change it, create a new webhook and delete the old one.

Verify every request before you trust it, and reject old timestamps. Code for Node, Python and Go: Webhook verification.

Retries

poolse waits up to 10 seconds to connect and 10 seconds for your response. Any status outside 200–299 is a failure, including redirects (poolse does not follow them), as are timeouts and connection errors.

AttemptSent
1At once
21 minute after attempt 1 fails
35 minutes after attempt 2 fails
430 minutes after attempt 3 fails
52 hours after attempt 4 fails
612 hours after attempt 5 fails

The first 2xx marks the delivery delivered. If attempt 6 fails too, the delivery is marked failed and poolse stops.

Replay

You can send a delivery again from the dashboard (Replay) or with POST /v1/webhooks/:id/deliveries/:delivery_id/replay. A replay creates a new delivery with the same type and data, a new id and a new created_at, and runs through the same retry schedule.

Because the id is new, an idempotency check on id will treat a replay as a new event. That is usually what you want when you replay on purpose. If you need to spot replays, dedupe on something inside data, such as the message id.

Idempotency

Delivery is at least once. A delivery can arrive more than once, for example when your server processed it but timed out before answering. Store the id of each delivery you have processed and skip ones you have seen:

async function handle(req: Request) {
  const id = req.headers.get('x-poolse-event-id')!;
  if (await db.processedDeliveries.has(id)) return new Response('ok');
  await doTheWork(await req.json());
  await db.processedDeliveries.add(id);
  return new Response('ok');
}

Answer quickly. Do slow work after you have returned 200, or the 10-second timeout turns a success into a retry.

Quotas and limits

  • Free plan: at most 5 webhooks, and 1,000 deliveries per rolling 24 hours across the workspace. Past that, new events are not delivered and not queued. Pro has no cap.
  • Every plan: at most 1,000 events per minute are dispatched per workspace. Events over that are not delivered.

See Limits & quotas.

History

Deliveries, with your response status and body, are kept for 30 days and shown on the webhook's page in the dashboard. Deleting a webhook deletes its delivery history at once.

Common questions

Can the URL be plain HTTP? poolse accepts http:// and https:// URLs. Use HTTPS in production: the signature proves the request came from poolse, but only TLS keeps the payload, which includes message text, private on the way.

Can I add custom headers? No. Put an identifier in the URL's query string if you need one; the URL is called exactly as you saved it.

Can I get a webhook when a message is read? No. Read state is available over the WebSocket and the REST API.

Webhooks concepts · poolse docs