Developers
Webhooks Beta
Receive durable, signed notifications when contacts, conversations, messages, tags, or team membership change.
Open API referenceCreate an endpoint
Go to Settings → Developer → Webhooks, or call POST /api/v1/webhooks. Give the endpoint a name, a public HTTPS URL, and at least one event. 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_, is shown once, and is stored by Sonny with AES-256-GCM authenticated encryption.
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-signaturet=<unix-seconds>,v1=<sha256-hex>
sonny-eventThe event type, for fast routing.
sonny-delivery-idA stable ID for idempotency and support.
user-agentSonny-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.createdA contact was created.
{"contactId":"cm_contact_id"}contact.updatedA contact was updated or merged.
{"contactId":"cm_contact_id"}contact.deletedA contact was deleted.
{"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.
{"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"}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.
- Use Test in Settings to queue
webhook.test. Inspect the last 50 deliveries and manually retry terminal failures from the same page.