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
Twelve 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[] |
message.entities_extracted | CommSync reads a bill, an invoice, a receipt, a subscription event, a shipment, a reservation, a deadline, a phone number, an address, or a person out of a message | thread (id, subject), message (ids and sentAt, no body), entities[] |
contact.merged | CommSync merges two contacts into one | contact (with identities[]), mergedContactIds[] |
message.entities_extracted never carries an account security event (a
password reset, a new sign-in, a connected app). A message with only
security events sends no event. When the subject of a security
message holds a code or reads like a security alert, thread.subject reads
"[security message withheld]". Each entity carries its typed payload,
amountMinor in cents as a string, currency, dueAt, and occursAt.
See the sample.
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) |
agent_conversation.analyzed | CommSync analyzed an ended agent conversation | conversation (outcome, counts, reply times, cost), analysis (title, summary, mood, success score, goal results, captured fields) |
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.
agent_conversation.analyzed fires after CommSync analyzes an ended agent
conversation, and again after each re-run. It carries the outcome, the
counts, each goal result with its reason, and each captured field. Send it to
a CRM to log a lead, or to a sheet to track how the agent does. A field
marked sensitive is never sent. The event carries no message text and no
evidence quotes. One conversation can hold an SMS thread and an email
thread of the same contact. You receive the event only when you have access
to every thread of it. See the
sample.
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.
agent_conversation.analyzed reaches a scoped endpoint only when the scope
holds every line of every thread of the conversation.
What fires and what does not
Lines you hid still fire. An endpoint follows the lines you have access to, not the lines in your own inbox. When you hide a line from your inbox, its events still reach your endpoints. To stop them, limit the endpoint to specific lines.
History imports fire no events. A first mailbox sync, a history
backfill, and a phone history import add old messages without events. Only
messages that arrive live fire message.received or message.sent.
Calls and voicemails fire no events. The events cover messages only.
One email thread can belong to two lines. A teammate's mailbox can also receive a reply in your thread. An endpoint limited to either line then gets the events of that thread.
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.
The header can carry more than one v1= part. For 24 hours after you
rotate the secret, CommSync signs with the new secret and with the previous
one (see Rotate the secret). Accept the request when
any v1= part matches your secret. The samples below do this.
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 pairs = header.split(',').map(p => p.split('='));
const t = Number(pairs.find(([k]) => k === 't')?.[1]);
const signatures = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!Number.isFinite(t) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return signatures.some(sig => {
const b = Buffer.from(sig, 'hex');
return b.length === expected.length && timingSafeEqual(expected, b);
});
}Rotate the secret
Rotate the secret when you think someone else has it. Call
rotate_webhook_secret, or use Rotate secret in Settings → Webhooks.
The new secret shows once.
For 24 hours after the rotation, every request carries two v1=
signatures: the first with the previous secret, the last with the new
secret. A receiver that accepts any matching v1= part, as the samples
above do, continues to pass while you deploy the new secret. A receiver
that reads only the last v1= part checks the new secret, so give it the
new secret at once. After 24 hours, CommSync signs with the new secret
only.
If you rotate again within the 24 hours, the secret you retire becomes the previous secret. The older one stops at once.
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. - Redirects: CommSync does not follow a redirect. A
3xxresponse is a failed attempt, so the delivery retries. - 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 |
After the fifth retry fails, CommSync marks the delivery dead. You can send
it again with resend_webhook_delivery. A resend answers 429
(WEBHOOK_RESEND_BACKLOG) while the endpoint has 25 or more deliveries from
the last hour that still wait or retry. Let them finish, then resend.
CommSync keeps the delivery log for a fixed time. It deletes a delivered
row after 30 days, and a failed or dead row after 90 days. After that,
list_webhook_deliveries does not show the row, and you cannot resend it.
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. An endpoint URL can have at most 2,048 characters. CommSync blocks private, loopback, link-local, and other reserved targets (SSRF defense). It checks the address again when it connects, and it does not follow redirects. It scopes events to your user — you only ever receive your own. Payloads contain message text; redact them if you log deliveries.