Developers

Webhooks Beta

Receive durable, signed notifications when contacts, conversations, messages, tags, or team membership change.

Open API reference

Create an endpoint

  1. 01

    Open Settings, select Developer, find Webhooks, then select Add endpoint.

  2. 02

    Enter a Name and public HTTPS Endpoint URL, then choose at least one item under Events.

  3. 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.

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

contact.created

A contact was created.

{"contactId":"cm_contact_id"}
contact.updated

A contact was updated or merged.

{"contactId":"cm_contact_id"}
contact.deleted

A contact was deleted.

{"contactId":"cm_contact_id"}
conversation.created

A conversation was created.

{"conversationId":"cm_conversation_id"}
conversation.updated

A conversation changed.

{"conversationId":"cm_conversation_id"}
conversation.closed

A conversation was closed.

{"conversationId":"cm_conversation_id"}
conversation.deleted

A conversation moved to Trash and left the public API.

{"conversationId":"cm_conversation_id"}
message.created

A message or internal note was created.

{"conversationId":"cm_conversation_id","messageId":"cm_message_id"}
tag.created

A tag was created.

{"tagId":"cm_tag_id"}
tag.updated

A tag was updated.

{"tagId":"cm_tag_id"}
tag.deleted

A tag was deleted.

{"tagId":"cm_tag_id"}
member.invited

A workspace member was invited.

{"invitationId":"cm_invitation_id"}
member.updated

A member role or status changed.

{"memberId":"cm_membership_id"}
member.removed

A member was removed.

{"memberId":"cm_membership_id"}
invitation.accepted

A workspace invitation was accepted.

{"invitationId":"cm_invitation_id","memberId":"cm_membership_id"}
invitation.cancelled

A pending workspace invitation was cancelled.

{"invitationId":"cm_invitation_id"}
webhook.test

A 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 id or sonny-delivery-id to 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.test event. Select Deliveries to inspect the most recent 50 events. When a delivery reaches a terminal failure, select Retry to send it again.

Related docs