Developers
Webhooks Beta
Receive durable, signed notifications when contacts, conversations, messages, tags, or team membership change.
Open API referenceCreate an endpoint
- 01
Open Settings, select Developer, find Webhooks, then select Add endpoint.
- 02
Enter a Name and public HTTPS Endpoint URL, then choose at least one item under Events.
- 03
Select Add endpoint. You can also create one with
POST /api/v1/webhooks.
Sonny rejects credentials in URLs, localhost, private/link-local IP ranges, and DNS names that resolve to a non-public address.
Save the signing secret immediately
It starts with whsec_ and is shown once. Copy it into your secret manager before selecting I saved it.
Limit an endpoint to selected channels
In Settings → Developer, each endpoint can listen to all channels or only the channels you pick, both when you add it and when you edit it later. Endpoints created before channel scoping existed stay on all channels until you change them.
API and MCP clients set the same filter with sourceIds when creating or updating an endpoint. An empty array means all sources in the workspace. When IDs are present, conversation and message events are delivered only when their conversation belongs to one of the selected sources. Workspace-level events, such as contact or membership changes, are not sent to a source-filtered endpoint.
{
"name": "Product A agent",
"url": "https://agent.example.com/sonny",
"events": ["conversation.created", "message.created"],
"sourceIds": ["cm_source_id"]
}Stop delivering what your integration discards
An agent that replies through the API is woken by its own reply unless you say otherwise. Three optional filters narrow what an endpoint receives; all are off by default, so an existing endpoint is unchanged.
agentGroupIds- Only conversations owned by the listed agent groups. This follows ownership, not the channel a conversation arrived through, so a conversation from a shared inbox reaches the group that owns it. Conversations with no group are not delivered, and because groups are usually assigned after a conversation starts,
conversation.createdoften fires before there is a group to match. customerMessagesOnly- Only customer-authored messages. Skips replies from your team, internal notes, and anything sent through the API, MCP or the AI responder — including this endpoint's own replies.
excludedSenderIds- Skips messages sent by the listed teammates. Give an integration its own teammate account and exclude it to stop it waking on its own replies while still hearing when a human takes the conversation over — the signal an AI agent needs to stand down.
The message filters apply to message.* events only; event types remain the control for everything else. A filtered event is dropped before it becomes a delivery, so it costs you nothing and is never shown as a failure. The payload and apiVersion are unchanged either way.
Group a burst of messages into one delivery
A consumer that reads the whole thread when it wakes gains nothing from four separate deliveries in ten seconds. Set coalesceSeconds and the messages on one conversation are collected for that long, then sent as a single delivery. It is off by default; endpoints without it keep receiving every message on its own.
A grouped delivery arrives as conversation.activity carrying the conversation and every message id collected, so read the thread once rather than per message. This is the one setting that changes the shape of what you receive, which is why it is opt-in. Your other filters still apply — a message excluded by them never joins a group. Each conversation has its own window, and retries treat the group as a single delivery.
{
"type": "conversation.activity",
"data": {
"conversationId": "cm_conversation_id",
"messageIds": ["cm_first_message", "cm_second_message"]
}
}Endpoints behind bearer or API-key auth
If your receiver sits behind a gateway that requires a header, add it under Custom headers when you create or edit the endpoint, or send headers from the API. Values are encrypted at rest and never returned — a saved header comes back as a name only, and resubmitting that name on its own keeps the stored value. Send an empty array to remove every header.
Moving an endpoint to a different host clears the reuse: the saved values must be entered again, so a credential is never forwarded to a destination it was not issued for. Changing only the path keeps them.
Sonny's own headers win over yours, so a custom header can never replace sonny-signature, content-type, or the delivery identity headers. Prefer verifying the signature where you can: it authenticates every payload, while a static token only identifies the caller.
{
"name": "Gateway",
"url": "https://api.example.com/hooks/sonny",
"events": ["conversation.created"],
"headers": [{ "name": "Authorization", "value": "Bearer …" }]
}Security and reliability
- Signed raw body
- HMAC-SHA256 covers the Unix timestamp, a dot, and the untouched UTF-8 request body.
- Replay protection
- Reject timestamps more than five minutes in the past or future, even when the HMAC is valid.
- Durable retries
- Non-2xx responses retry after 1m, 5m, 30m, 2h, 6h. Attempt 6 is terminal.
Request contract
sonny-signature- t=<unix-seconds>,v1=<sha256-hex>
sonny-event- The event type, for fast routing.
sonny-delivery-id- A stable ID for idempotency and support.
user-agent- Sonny-Webhooks/1.0
{
"id": "cm_event_id",
"type": "message.created",
"apiVersion": "2026-07-15",
"createdAt": "2026-07-15T12:00:00.000Z",
"data": {
"conversationId": "cm_conversation_id",
"messageId": "cm_message_id"
}
}Verify the signature
Read the raw body first. Parsing JSON and serializing it again changes whitespace and makes a valid signature fail.
import { createHmac, timingSafeEqual } from "node:crypto";
const rawBody = await request.text(); // do not parse/re-serialize first
const signature = request.headers.get("sonny-signature");
const secret = process.env.SONNY_WEBHOOK_SECRET;
if (!signature || !secret) throw new Error("Missing webhook signature or secret");
const { t, v1 } = Object.fromEntries(signature.split(",").map(p => p.split("=")));
if (!/^\d+$/.test(t) || !/^[a-f0-9]{64}$/i.test(v1)) {
throw new Error("Malformed signature");
}
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) {
throw new Error("Stale webhook");
}
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))) {
throw new Error("Invalid signature");
}Event catalog
webhook.heartbeatSent every five minutes to enabled subscribers (including *), even without new messages. Uses the normal signed delivery/retry path and ignores conversation filters. Alert on missing or stale heartbeats; it does not scan for conversations awaiting a reply.
{"intervalSeconds":300,"nextExpectedAt":"2026-09-29T16:05:00.000Z"}contact.createdA contact was created.
{"contactId":"cm_contact_id"}contact.updatedA contact was updated or merged.
{"contactId":"cm_contact_id"}contact.deletedA contact was archived or merged into another contact. It can still be read with archived=true.
{"contactId":"cm_contact_id"}contact.erasedA contact was permanently erased (for example a GDPR erasure request) with all of its conversations, messages and attachments. It can no longer be read; delete any copies you keep.
{"contactId":"cm_contact_id"}conversation.createdA conversation was created.
{"conversationId":"cm_conversation_id"}conversation.updatedA conversation changed.
{"conversationId":"cm_conversation_id"}conversation.closedA conversation was closed.
{"conversationId":"cm_conversation_id"}conversation.deletedA conversation moved to Trash and left the public API.
{"conversationId":"cm_conversation_id"}message.createdA message or internal note was created. Context and a short message preview are included when available; internal note text is never included.
{"conversationId":"cm_conversation_id","messageId":"cm_message_id","context":{"sourceId":"cm_source_id","sourceName":"Shopstar Go","storeName":"Shiney Store","inboxId":"cm_inbox_id","inboxName":"Go support","agentGroupId":"cm_group_id","agentGroupName":"Support","identityVerified":true,"status":"open","lastRepliedAt":"2026-09-25T09:00:00.000Z"},"message":{"id":"cm_message_id","type":"incoming","senderType":"human","senderId":null,"senderName":"Shiney","textPreview":"Could you check my bill?","textTruncated":false}}message.updatedA sent reply was edited in the transcript. Delivered emails remain unchanged.
{"conversationId":"cm_conversation_id","messageId":"cm_message_id"}tag.createdA tag was created.
{"tagId":"cm_tag_id"}tag.updatedA tag was updated.
{"tagId":"cm_tag_id"}tag.deletedA tag was deleted.
{"tagId":"cm_tag_id"}member.invitedA workspace member was invited.
{"invitationId":"cm_invitation_id"}member.updatedA member role or status changed.
{"memberId":"cm_membership_id"}member.removedA member was removed.
{"memberId":"cm_membership_id"}invitation.acceptedA workspace invitation was accepted.
{"invitationId":"cm_invitation_id","memberId":"cm_membership_id"}invitation.cancelledA pending workspace invitation was cancelled.
{"invitationId":"cm_invitation_id"}conversation.activityMessages on one conversation, grouped into a single delivery. Includes a snapshot for each message when available. Sent instead of message.created to endpoints with a grouping window, never subscribed to directly.
{"conversationId":"cm_conversation_id","messageIds":["cm_message_id","cm_other_message_id"]}webhook.testA test event requested by an administrator.
{"message":"This is a test webhook from Sonny."}
Delivery behavior
- Return any 2xx status within 10 seconds to mark the delivery successful.
- Keep handlers idempotent. Use the event
idorsonny-delivery-idto ignore duplicates. - Redirects are not followed. Update the endpoint URL in Sonny instead.
- Response bodies are capped and only the first 4 KiB is retained for diagnosis.
- Select Test to send a
webhook.testevent. Select Deliveries to inspect the most recent 50 events. When a delivery reaches a terminal failure, select Retry to send it again.
Detect a silent feed
Add webhook.heartbeat to your endpoint’s events in Developer settings, or through update_webhook. Enabled subscribers (including *) receive a signed heartbeat every five minutes, even when no new messages arrive. Heartbeats ignore channel, team and message filters and contain no conversation data. They use the same delivery path and retries as messages. Check createdAt and nextExpectedAt so an old retry cannot look like a fresh heartbeat; allow for polling and network delays before alerting. A delivery backlog can delay or suppress heartbeats.
Use list_webhook_deliveries with webhooks:read to inspect attempts and failures. get_status checks database connectivity, not webhook delivery. Independently schedule list_conversations with status=open, awaitingReply=true, sort=waitingSince and direction=asc to find old unanswered threads even while the feed is quiet.
Enable Compact payloads in Developer settings or set compact=true on the endpoint to omit context labels while retaining routing IDs, sender, time and 200-character message previews. This also applies to grouped deliveries; internal note text remains excluded. Default payloads are unchanged apart from the added message timestamp. Edit your existing API key’s scopes to grant webhooks:read and contacts:read for delivery logs and direct contact lookups. Both scopes require an all-channel key and all-channel member access; a scoped key cannot be widened by adding permissions alone.
Related docs
- Public APIBeta
Triage and sync conversations, share customer context, read reports, and send files with scoped API keys.
- Inbox
Understand statuses, priorities, assignment, snoozing, bulk actions, and keyboard shortcuts.
- Contacts
Learn how contacts are created, managed, tagged, and merged in Sonny.
- Team & roles
Invite users, create teams, and understand owner, admin, agent, and viewer access.