Documentation

Developers

Webhooks Beta

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

Open API reference

Create 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-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.
  • Use Test in Settings to queue webhook.test. Inspect the last 50 deliveries and manually retry terminal failures from the same page.