Webhook events
A webhook is a URL on your backend that poolse POSTs to when
something happens in your workspace. Set webhooks up on the dashboard
(Webhooks) or with the
Webhooks API.
Each webhook subscribes to a list of events. An empty list subscribes to every event, including events added later.
Events
| Event | Fires when |
|---|---|
message.created | A message is sent: by a user, from a notification reply, or a call-log message poolse writes. |
message.updated | A message's text 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 | PATCH /v1/conversations/:id changes a conversation. |
member.added | A user is added to a conversation. |
member.removed | A user leaves or is removed from a conversation. |
call.started | A call is placed or a room opens. |
call.answered | The first person rung picks up. Calls only. |
call.finished | A call or room ends. |
There are no events for reactions, read receipts, typing, users, devices or settings.
Delivery
Envelope
Every delivery has the same outer shape:
{
"id": "0b9b8f0e-5d1e-4f43-9d0e-3b1f2c7a9e10",
"type": "message.created",
"tenant_id": "65787825-…",
"created_at": "2026-10-27T09:14:03.204511Z",
"data": { }
}
| Field | Type | Meaning |
|---|---|---|
id | uuid | The delivery id. It stays the same across automatic retries, so use it as your idempotency key. A manual replay creates a new delivery with a new id. |
type | string | The event name. |
tenant_id | uuid | Your workspace. |
created_at | ISO timestamp | When the delivery was created. |
data | object | The event payload, below. |
Headers
| Header | Value |
|---|---|
content-type | application/json |
user-agent | poolse-webhook/1 |
x-poolse-signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
x-poolse-event-id | The delivery id (same as id in the body). |
x-poolse-event-type | The event name. |
x-poolse-delivery-attempt | 1 on the first try, then 2, 3, … |
Signature
v1 is HMAC-SHA256(signing_secret, "<t>.<raw body>"), lowercase hex.
Compute it over the raw request bytes, compare in constant time, and
reject a t more than a few minutes old.
Webhook verification has code for
this in several languages. The signing secret is shown once, when you
create the webhook.
Responses, retries and timeouts
- Answer with any
2xxwithin 10 seconds. Anything else counts as a failure: another status, a timeout, a connection error. - A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. That is 6 attempts in all. After the last one it is marked failed.
- You can replay any delivery from the dashboard or with
POST /v1/webhooks/:id/deliveries/:delivery_id/replay. - Deliveries are sent independently and can arrive out of order.
Use timestamps in
data(inserted_at,edited_at,deleted_at,ended_at) to order them.
Limits
- Daily quota (Free plan): 1,000 deliveries per day. Events past the quota are dropped, not queued. See Limits & quotas.
- Burst limit (every plan): 1,000 events per minute per workspace. Events past it are dropped without a delivery record.
- An inactive webhook (
is_active: false) receives nothing, and missed events are not sent later when you turn it back on.
Message events
message.created
Fires after a message is stored. That includes:
- thread replies;
- replies sent from a notification (
POST /v1/notifications/reply); - the
systemcall-log message poolse writes when a call ends.
{
"id": "8d0e…",
"tenant_id": "6578…",
"conversation_id": "6f1c…",
"sender_id": "2b7e…",
"sender_external_id": "acme-alice",
"type": "text",
"body": "ship it",
"reply_to_id": null,
"thread_root_id": null,
"reply_count": 0,
"quoted_message_id": null,
"quoted_message": null,
"mentions": [],
"reactions": {},
"attachments": [],
"edited_at": null,
"deleted_at": null,
"sequence": 185,
"inserted_at": "2026-10-27T09:14:03.112233Z",
"updated_at": "2026-10-27T09:14:03.112233Z",
"preview": "ship it",
"participant_external_ids": ["acme-alice", "acme-bob", "acme-carol"]
}
These fields are the same as the message in the
realtime message:new event.
Two more are added for webhooks only:
| Field | Type | Meaning |
|---|---|---|
preview | string | null | A notification-ready line: the text, trimmed and cut to 80 characters plus …. For a message with attachments and no text it is Attachment or N attachments. |
participant_external_ids | string[] | The external_id of every member, sender included. For a system message, there is no sender. |
For a type: "system" message, body and preview are the call
record as JSON, and preview may be cut mid-JSON. Use call.finished
for calls instead.
message.updated
Fires after an edit. Same payload as message.created, with the new
body, a fresh preview and edited_at set.
message.deleted
{
"id": "8d0e…",
"conversation_id": "6f1c…",
"deleted_at": "2026-10-27T09:20:11.000000Z"
}
Deleting an already-deleted message fires nothing.
mention.created
One delivery per mentioned user, fired together with
message.created.
{
"message_id": "8d0e…",
"conversation_id": "6f1c…",
"sender_id": "2b7e…",
"mentioned_user_id": "9c41…"
}
All four are poolse ids. There are no external_ids here. Look them
up in the matching message.created (participant_external_ids) or
with the members API.
Conversation events
conversation.created
{
"id": "6f1c…",
"tenant_id": "6578…",
"type": "group",
"name": "Engineering",
"avatar_url": null,
"created_by_user_id": "2b7e…",
"member_limit": null,
"custom_data": {},
"settings": {},
"last_message_at": null,
"last_message_preview": null,
"last_sequence": 0,
"unread_count": null,
"inserted_at": "2026-10-27T09:00:00.000000Z",
"updated_at": "2026-10-27T09:00:00.000000Z",
"member_external_ids": []
}
member_external_ids is always [] here, and unread_count is always
null. The members follow as member.added events. The creator is the
exception: they become owner without a member.added. They are
created_by_user_id.
Asking for a direct conversation that already exists returns the existing one and fires nothing.
conversation.updated
Fires when PATCH /v1/conversations/:id saves a change. Same payload
as conversation.created, with the new values.
It does not fire when a message is sent, so last_message_* here
is whatever it was at the time of the update.
member.added
{
"id": "1f2a…",
"tenant_id": "6578…",
"conversation_id": "6f1c…",
"user_id": "9c41…",
"external_id": "acme-bob",
"role": "member",
"joined_at": "2026-10-27T09:00:00.000000Z",
"muted_until": null,
"last_read_message_id": null,
"last_read_sequence": null,
"last_read_at": null,
"inserted_at": "2026-10-27T09:00:00.000000Z",
"updated_at": "2026-10-27T09:00:00.000000Z"
}
The membership. id is the membership's own id, not the user's.
Adding someone who is already a member fires nothing.
member.removed
{ "conversation_id": "6f1c…", "user_id": "9c41…", "external_id": "acme-bob" }
Fires both when an admin removes a member and when a member leaves.
Call events
Every call event carries these fields. People appear as
{ "id", "external_id" }.
| Field | Type | Meaning |
|---|---|---|
id | uuid | The call id. Matches Call history. |
conversation_id | uuid | Where the call is. |
kind | "call" | "room" | A call rings the other members. A room rings nobody. |
media | "audio" | "video" | |
started_at | ISO timestamp | null | |
started_by | person | null | Who placed the call or opened the room. |
call.started
Fires when a call is placed or a room opens.
{
"id": "4a69…",
"conversation_id": "6f1c…",
"kind": "call",
"media": "video",
"started_at": "2026-10-27T09:30:00.000Z",
"started_by": { "id": "2b7e…", "external_id": "acme-alice" },
"invited": [
{ "id": "9c41…", "external_id": "acme-bob" },
{ "id": "a7d2…", "external_id": "acme-carol" }
]
}
invited is everyone rung: all the other members when the call was
placed. It is [] for a room. People rung in later ("Add people") do
not fire another event. They appear in call.finished.
When two members place a call at the same moment, the second one
joins the first call. Only one call.started fires.
call.answered
Fires once, when the first person rung picks up. Never fires for a room.
{
"id": "4a69…",
"conversation_id": "6f1c…",
"kind": "call",
"media": "video",
"started_at": "2026-10-27T09:30:00.000Z",
"started_by": { "id": "2b7e…", "external_id": "acme-alice" },
"answered_by": { "id": "9c41…", "external_id": "acme-bob" },
"answered_at": "2026-10-27T09:30:05.000Z"
}
call.finished
Fires once, when the call or room has ended.
{
"id": "4a69…",
"conversation_id": "6f1c…",
"kind": "call",
"media": "video",
"started_at": "2026-10-27T09:30:00.000Z",
"started_by": { "id": "2b7e…", "external_id": "acme-alice" },
"answered_at": "2026-10-27T09:30:05.000Z",
"ended_at": "2026-10-27T09:34:25.000Z",
"duration_ms": 260000,
"reason": "finished",
"participants": [
{ "id": "9c41…", "external_id": "acme-bob", "status": "joined" },
{ "id": "a7d2…", "external_id": "acme-carol", "status": "missed" }
]
}
| Field | Type | Meaning |
|---|---|---|
answered_at | ISO timestamp | null | First pick-up; null if nobody answered. For a room, when someone other than the person who opened it first joined. |
ended_at | ISO timestamp | null | When it ended. |
duration_ms | integer | null | Connected time. For a call, from answered_at to ended_at; null if nobody answered. For a room, from started_at. |
reason | string | How it ended. See below. |
participants | person[] | Everyone rung (also people added later) and everyone who joined, each with a status. The caller is not in this list. They are started_by. |
reason:
| Value | Meaning |
|---|---|
finished | It ran and ended normally: the last people left, or someone hung up and left one person alone. |
cancelled | The caller hung up while it was still ringing. |
no_answer | Nobody answered before the ring time ran out. |
declined_all | Everyone rung declined before anyone answered. |
busy_all | Everyone rung was already on another call. |
participants[].status:
| Value | Meaning |
|---|---|
joined | Was in the call at some point. This wins over a decline on another device. |
declined | Declined the ring. |
busy | Their phone refused the ring because they were on another call. |
missed | Was rung and never answered. |
The same outcome is stored in Call history and written into the conversation as a call-log message.