CommSyncdocs
Open app
Developers

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.

For developers

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

Push-based event delivery

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

EventFires whenConcise payload
message.receivedAn inbound SMS or email arrives in CommSyncthread, person, labels, message
message.sentCommSync hands an outbound message to the carrier or mail serversame as message.received
message.status_changedA message status flips (for example SENT → DELIVERED, SENT → FAILED)the above plus previousStatus, currentStatus
thread.createdCommSync creates a new thread (first message from an unknown partner)thread, person (or orphan), labels, message
email.openedSomeone opens an email you sent with read receipts onsame 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

EventFires whenConcise payload
attachment.extractedA document's text layer is ready (PDF, image OCR, office doc, .ics)the thread envelope plus attachment, calendarEvents[]
message.entities_extractedCommSync 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 messagethread (id, subject), message (ids and sentAt, no body), entities[]
contact.mergedCommSync merges two contacts into onecontact (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

EventFires whenConcise payload
email_account.health_changedA mailbox fails to sync, or it recoversemailAccount, health (status, errorKind, error, since)
agent_turn.awaiting_approvalA DRAFT-mode agent drafted a reply that needs approvalthe thread envelope plus agentTurn
agent_turn.sentAn agent replied to a contact on its ownthe thread envelope plus agentTurn (incl. messageId)
agent_conversation.analyzedCommSync analyzed an ended agent conversationconversation (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 2xx quickly. 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 3xx response is a failed attempt, so the delivery retries.
  • Idempotency: dedupe on event.id (evt_*). Retries reuse the id.
  • Auto-disable: the endpoint flips to disabled after 100 consecutive failures or 24 h of continuous failure.
  • Re-enable with update_webhook (status active), which also resets the failure counter.
AttemptDelay before
11 min
25 min
330 min
42 h
512 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).

TierMax endpoints per user
Starter1
Pro3
Power10

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.