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

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

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[]
contact.mergedCommSync merges two contacts into onecontact (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

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)

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 2xx quickly. 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 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

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