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.
- Set webhooks up in the dashboard: Webhooks.
- Or with the API: Webhooks API.
- Event bodies, field by field: Webhook events.
- Verification code: Webhook verification.
Events
| Event | Fires when |
|---|---|
message.created | A message is sent. |
message.updated | A message is edited. |
message.deleted | A message is deleted. |
mention.created | A message @mentions a user. One event per mentioned user. |
conversation.created | A conversation is created. |
conversation.updated | A conversation's fields change. |
member.added | A user is added to a conversation. |
member.removed | A user is removed from a conversation. |
call.started | A call or room starts. |
call.answered | The first person picks up a call. Rooms don't ring, so they never send this. |
call.finished | A 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": { }
}
| Field | Meaning |
|---|---|
id | The delivery id (a UUID). Also in X-Poolse-Event-Id. The same on every retry of this delivery. |
type | The event name. Also in X-Poolse-Event-Type. |
tenant_id | Your workspace id. |
created_at | When the delivery was created. |
data | The 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.
| Attempt | Sent |
|---|---|
| 1 | At once |
| 2 | 1 minute after attempt 1 fails |
| 3 | 5 minutes after attempt 2 fails |
| 4 | 30 minutes after attempt 3 fails |
| 5 | 2 hours after attempt 4 fails |
| 6 | 12 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.