Skip to main content
The Quo webhook API delivers real-time events to an HTTPS endpoint you control. Use it to:
  • Update your CRM when an inbound SMS arrives (message.received).
  • Log calls in your system as soon as they end (call.completed), with AI summaries and transcripts following (call.summary.completed, call.transcript.completed).
  • Sync contact changes between Quo and your system (contact.updated).
If you want to ship a working integration first and read concepts second, jump to the Quickstart.
You can send a real, signed test delivery without writing any handler code using POST /webhooks/:id/events/test. See Send a test event.

Common envelope

Every webhook delivery uses the same top-level shape. The type field determines the schema of data.
{
  "id": "EV123",
  "apiVersion": "2026-03-30",
  "createdAt": "2026-04-13T12:00:00.000Z",
  "type": "call.summary.completed",
  "data": {
    "resource": {},
    "context": {},
    "links": { "quo": "https://my.quo.com/..." }
  }
}
FieldMeaning
idStable identifier for the event across retries and payload versions. Not a delivery identifier — for delivery-level dedupe, use the webhook-id header.
apiVersionPayload version recorded when the webhook was created.
createdAtWhen the underlying event occurred. The send time is in the webhook-timestamp header.
typeThe event name. Discriminates the schema of data.
data.resourcePrimary business object for the event.
data.contextSurrounding metadata (phone number, conversation, participants, contact lookup, sharing).
data.links.quoQuo app deep link, or null when none is available.
Treat all event, delivery, and resource ids as opaque strings.

Versioning policy

Webhooks follow the same dated versioning as the rest of the API, with one addition: a subscription pins its payload version at creation.
  • Pinning. Set Quo-Api-Version when calling POST /webhooks. That version is recorded for the subscription and used for every delivery it makes.
  • Stability. A webhook’s version never changes after creation. Existing deliveries keep the shape they were created with, even after newer versions ship.
  • Adopting a new version. Create a new subscription on the newer version. Deliveries to the existing subscription are unaffected.

Subscription rules

  • message.* and call.* events accept a resourceIds filter (phone number ids, or ["*"]).
  • contact.* events are workspace-wide. For contact-only webhooks, omit resourceIds.
  • Mixed subscriptions are supported. resourceIds filters the activity events above; contact events are always delivered workspace-wide.
  • A workspace can have at most 50 webhooks.

Delivery semantics

Idempotency

Quo will retry deliveries on any non-2xx response, so your endpoint must tolerate seeing the same delivery more than once. Use the webhook-id header as an idempotency key — it is stable across retries and distinct from the envelope id.
if (await store.has(req.header("webhook-id"))) return res.status(200).end()
await store.add(req.header("webhook-id"), { ttlSeconds: 60 * 60 * 28 })
Store processed ids for at least 28 hours - long enough to cover the full retry window.

Retries

Failed deliveries are retried automatically. Any 2xx response marks a delivery accepted; any non-2xx response triggers the next retry. After all retries fail, the delivery is marked failed and not retried further.
AttemptDelay from previousCumulative
1Immediate0
25 seconds5s
35 minutes5m 5s
430 minutes35m 5s
52 hours2h 35m 5s
65 hours7h 35m 5s
710 hours17h 35m 5s
810 hours27h 35m 5s
If every attempt fails, the delivery is marked failed after roughly 27 hours and 35 minutes from the initial attempt. You can also trigger a manual retry with POST /webhooks/:id/events/:eventId/retry.

Ordering

Delivery order is not guaranteed. Events can arrive out of order across event families and, occasionally, within a single resource — for example, a call.transcript.completed may arrive before the corresponding call.summary.completed. Design handlers to be order-independent:
  • Compare data.resource.updatedAt (or createdAt for terminal events) against your stored state and ignore stale events.
  • Don’t drive state machines from arrival order. Treat each event as a freshness check on the resource it references.
const incoming = event.data.resource
const stored = await db.contacts.get(incoming.id)
if (stored && stored.updatedAt >= incoming.updatedAt) return  // stale, ignore
await db.contacts.upsert(incoming)

Headers on every delivery

HeaderFormatPurpose
webhook-idstringIdempotency key. Stable across retries.
webhook-timestampunix secondsWhen Quo signed the request.
webhook-signaturev1,<base64> (space-separated entries)HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw-body}.
Always verify the signature before trusting the body. See Validate webhook signatures.

See also