Webhooks
Push-based event delivery — CommSync POSTs signed payloads to your URL the instant a message lands, so agents react in real time instead of polling.
Webhooks invert the polling model. Rather than ask, on a timer, "anything new?", CommSync posts a signed JSON payload to a URL you register the moment an event fires. That payload usually carries enough context to act without a callback. They pair naturally with the MCP server: webhooks for "wake me when something happens," MCP for "do something now."
Machine-readable companions
/docs/webhooks.txt (prose for agents),
/docs/webhooks.json (spec), and sample
payloads at /docs/webhooks/samples/<event>.<mode>.json.
Events
Ten events are available. Each delivers in one of two modes: concise
(the essentials) or verbose (adds thread history, identities, attachments, and
email headers).
Conversation events
| Event | Fires when | Concise payload |
|---|---|---|
message.received | An inbound SMS or email arrives in CommSync | thread, person, labels, message |
message.sent | CommSync hands an outbound message to the carrier or mail server | same as message.received |
message.status_changed | A message status flips (for example SENT → DELIVERED, SENT → FAILED) | the above plus previousStatus, currentStatus |
thread.created | CommSync creates a new thread (first message from an unknown partner) | thread, person (or orphan), labels, message |
email.opened | Someone opens an email you sent with read receipts on | same as message.received plus open (firstOpenedAt, lastOpenedAt, openCount) |
message.received and message.sent are the two halves of a conversation.
Subscribe to both if you mirror threads into a CRM. You will not have to
filter the delivery-status churn that message.status_changed carries.
email.opened fires once per distinct open. A tracked email that nobody
opens never fires it. open.openCount is 1 on the first open — filter on
it if you only want first opens. The guards that protect the in-app ticks
run before the event exists: refetch bursts, scanner fetches right after
the send, and known security gateways never fire it. A missing event is not
proof that nobody read the email — some mail apps block remote images.
Content and contact events
| Event | Fires when | Concise payload |
|---|---|---|
attachment.extracted | A document's text layer is ready (PDF, image OCR, office doc, .ics) | the thread envelope plus attachment, calendarEvents[] |
contact.merged | CommSync merges two contacts into one | contact (with identities[]), mergedContactIds[] |
Act on documents here, not on message.received
message.received fires the moment mail lands — before CommSync fetches
the attachment bytes or extracts their text with OCR. If your integration
reads documents, wait for attachment.extracted, which fires once the
text is actually available. attachment carries textLength and
hasText rather than the text itself, since it can be megabytes. Fetch
the body with read_attachment over MCP when you need it.
contact.merged prevents dangling references
A merge deletes the merged-away contacts. If you store CommSync contact ids, this is your only chance to remap them onto the survivor. Otherwise, those ids point at contacts that no longer exist.
Operational events
| Event | Fires when | Concise payload |
|---|---|---|
email_account.health_changed | A mailbox fails to sync, or it recovers | emailAccount, health (status, errorKind, error, since) |
agent_turn.awaiting_approval | A DRAFT-mode agent drafted a reply that needs approval | the thread envelope plus agentTurn |
agent_turn.sent | An agent replied to a contact on its own | the thread envelope plus agentTurn (incl. messageId) |
A mailbox whose credentials expire stops mail delivery silently — nothing
looks broken from the inbox. email_account.health_changed is the alarm: it
fires once on the way down (status: "unhealthy", with errorKind that
tells you whether it is AUTH, NETWORK, RATE_LIMIT, …) and once on the
way back up (status: "recovered"). It does not repeat while the
mailbox stays broken.
agent_turn.awaiting_approval is what makes a DRAFT-mode agent usable
when no one watches the approval queue — route it to Slack and approve
from there. agent_turn.sent is the oversight trail for AUTO/SUPERVISED
agents: every message an AI sent under your name.
In verbose mode, every thread-shaped event also includes
thread.recentMessages[], person.identities[], and labelConfidence. Events
that carry a specific message — everything except agent_turn.awaiting_approval
— additionally get attachments[] (with pre-signed download URLs) and
emailHeaders (for email). agent_turn.awaiting_approval has no message yet,
so those two fields are absent from it.
Delivery order is not guaranteed
When a new conversation starts, CommSync emits thread.created and
message.received for the same message. It emits thread.created
first, but it dispatches and retries the two independently. They can
arrive in either order, and a retry can reorder them long after the fact.
Do not build a handler that assumes it has already seen the thread. Both
payloads carry the full thread object, so either one is enough to act
on.
Channel scoping and non-conversation events
contact.merged has no line anchor, so an endpoint scoped to specific
channels never receives it — scoped endpoints fail closed.
email_account.health_changed does have one (the mailbox itself), so a
scoped endpoint receives it only for mailboxes in its own scope.
The envelope
Every delivery wraps event data in the same envelope. Use id as your
idempotency key — CommSync never delivers the same id twice.
{
"id": "evt_<32 hex chars>",
"type": "message.received",
"createdAt": "2026-05-21T09:30:00.000Z",
"endpointId": "<WebhookEndpoint.id>",
"mode": "concise",
"data": { }
}Verify the signature
Every request carries an X-CommSync-Signature: t=<unix-seconds>,v1=<hex> header.
The signature is HMAC-SHA256(secret, "<t>.<rawBody>"). Reject anything where
|now − t| > 300s, and compare with a timing-safe equal.
Verify the raw body
Compute the HMAC over the raw request bytes, before any JSON parsing. Whitespace changes from re-serialization will break the signature.
import { createHmac, timingSafeEqual } from 'crypto';
export function verify(secret, rawBody, header, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}When you rotate_webhook_secret, the previous secret stays valid for 24 hours.
During rollover, try the new secret first and fall back to the previous one.
Delivery & retries
- Timeout: 10 seconds per attempt.
- Return a
2xxquickly. Do slow work off the request path — a slow handler times out and triggers retries. - Success: any HTTP
2xx. - Idempotency: dedupe on
event.id(evt_*). Retries reuse the id. - Auto-disable: the endpoint flips to
disabledafter 100 consecutive failures or 24 h of continuous failure. - Re-enable with
update_webhook(statusactive), which also resets the failure counter.
| Attempt | Delay before |
|---|---|
| 1 | 1 min |
| 2 | 5 min |
| 3 | 30 min |
| 4 | 2 h |
| 5 | 12 h |
Tier caps
The number of endpoints you can register depends on your plan (see Billing).
| Tier | Max endpoints per user |
|---|---|
| Starter | 1 |
| Pro | 3 |
| Power | 10 |
Channel scoping
By default an endpoint receives events from all channels — every connected line, and that includes lines you connect later. You can fine-tune any endpoint down to specific lines instead. Pick the email addresses and phone numbers it must cover; CommSync only delivers events whose conversation lives on one of them. CommSync filters out everything else before delivery — it never reaches your URL.
- All channels (default) follows your live channel access automatically.
- Specific lines is a fixed allowlist.
- CommSync does not add lines connected later automatically.
- A line that gets deleted simply goes quiet: no more events fire for it.
- The Settings UI flags it so you can prune the selection.
Configure it in Settings → Webhooks (each endpoint shows an All channels
or N lines badge), or pass channelScope when registering or updating via
MCP or REST:
{
"channelScope": {
"allChannels": false,
"channels": [
{ "channelType": "EMAIL_ACCOUNT", "channelId": "<EmailAccount.id>" },
{ "channelType": "PHONE_NUMBER", "channelId": "<PhoneNumber.id>" }
]
}
}Channel ids come from list_email_accounts or list_phone_numbers (MCP)
or the Settings UI. A scoped selection must contain at least one line;
CommSync migrated pre-existing endpoints as allChannels: true, so nothing
changed for them.
Register an endpoint
Open Settings → Webhooks → Add endpoint, choose your events, mode, and
channel scope, and copy the one-time signing secret. Best for a quick manual
setup.
A chatbot in five steps
Register
register_webhook(url, ["message.received"], mode: "verbose") and store the
returned secret.
Receive & verify
On each POST, read the raw body and verify X-CommSync-Signature.
Acknowledge fast
Return 200 within 10 s, then hand off to your own async processor.
Decide
Inspect data.message and data.thread.recentMessages to choose a reply.
Respond
Call send_sms or send_email via MCP with the threadId
from the payload.
Security
Endpoints must be HTTPS in production. CommSync blocks private, loopback, and link-local targets (SSRF defense), and it scopes events to your user — you only ever receive your own. Payloads contain message text; redact them if you log deliveries.