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

EventFires when
message.createdA message is sent: by a user, from a notification reply, or a call-log message poolse writes.
message.updatedA message's text is edited.
message.deletedA message is deleted.
mention.createdA message mentions a user. One event per mentioned user.
conversation.createdA conversation is created.
conversation.updatedPATCH /v1/conversations/:id changes a conversation.
member.addedA user is added to a conversation.
member.removedA user leaves or is removed from a conversation.
call.startedA call is placed or a room opens.
call.answeredThe first person rung picks up. Calls only.
call.finishedA 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": { }
}
FieldTypeMeaning
iduuidThe 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.
typestringThe event name.
tenant_iduuidYour workspace.
created_atISO timestampWhen the delivery was created.
dataobjectThe event payload, below.

Headers

HeaderValue
content-typeapplication/json
user-agentpoolse-webhook/1
x-poolse-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
x-poolse-event-idThe delivery id (same as id in the body).
x-poolse-event-typeThe event name.
x-poolse-delivery-attempt1 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 2xx within 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 system call-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:

FieldTypeMeaning
previewstring | nullA 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_idsstring[]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" }.

FieldTypeMeaning
iduuidThe call id. Matches Call history.
conversation_iduuidWhere the call is.
kind"call" | "room"A call rings the other members. A room rings nobody.
media"audio" | "video"
started_atISO timestamp | null
started_byperson | nullWho 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" }
  ]
}
FieldTypeMeaning
answered_atISO timestamp | nullFirst pick-up; null if nobody answered. For a room, when someone other than the person who opened it first joined.
ended_atISO timestamp | nullWhen it ended.
duration_msinteger | nullConnected time. For a call, from answered_at to ended_at; null if nobody answered. For a room, from started_at.
reasonstringHow it ended. See below.
participantsperson[]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:

ValueMeaning
finishedIt ran and ended normally: the last people left, or someone hung up and left one person alone.
cancelledThe caller hung up while it was still ringing.
no_answerNobody answered before the ring time ran out.
declined_allEveryone rung declined before anyone answered.
busy_allEveryone rung was already on another call.

participants[].status:

ValueMeaning
joinedWas in the call at some point. This wins over a decline on another device.
declinedDeclined the ring.
busyTheir phone refused the ring because they were on another call.
missedWas rung and never answered.

The same outcome is stored in Call history and written into the conversation as a call-log message.

Webhook events · Events · poolse docs