Guide to Quo Webhooks: Features and Best Practices
Quo webhooks tell your systems what is happening on your business phone lines: a customer texts back, a call completes, an AI summary or transcript finishes rendering, a contact record changes, or a task moves. If you are building on Quo (formerly known as OpenPhone), webhooks are how call and messaging activity reaches your CRM, your helpdesk, or your own services in near real time instead of through polling.
This guide covers how Quo webhooks work, the two signature schemes a single endpoint can receive, the event types across messages, calls, contacts and tasks, and the best practices for production.
What are Quo webhooks?
A Quo webhook is an HTTP POST to an endpoint you own, sent when something happens in your workspace. You subscribe a webhook to one or more event types, and activity events can be filtered to specific phone numbers.
Every delivery is signed with HMAC-SHA256 over the raw request body, with a standard base64 digest. The complication is that Quo runs two generations of the webhook product at once, and they sign differently. Webhooks created through the versioned API with a Quo-Api-Version: 2026-03-30 header use the Standard Webhooks style webhook-id, webhook-timestamp and webhook-signature triple. Webhooks created earlier, or through the legacy /v1 endpoints, still send the OpenPhone-era openphone-signature header with a different format, different signed content, and a different timestamp unit.
Which scheme an endpoint receives is decided by how the subscription was created, not by anything you configure on your server. Existing webhooks do not migrate themselves, so an endpoint that serves both old and new subscriptions has to implement both paths.
Quo webhook features
| Feature | Details |
|---|---|
| Configuration | POST /webhooks with a Quo-Api-Version: 2026-03-30 header, or the in-app webhook UI |
| Signature header | webhook-id, webhook-timestamp and webhook-signature on the current generation; openphone-signature on the legacy generation |
| Scheme | HMAC-SHA256 with a standard base64 digest over the raw body in both generations. The current one is Standard Webhooks compatible; the legacy one is a Quo-specific format |
| Signed content | {webhook-id}.{webhook-timestamp}.{raw body} on the current generation, {timestamp}.{raw body} on the legacy one |
| Secret | Returned as data.key from POST /webhooks with a whsec_ prefix, or revealed in the app as bare base64 for legacy webhooks |
| Replay window | webhook-timestamp is UNIX seconds and Quo's own example uses a five minute tolerance. Legacy timestamps are milliseconds |
| Response deadline | 10 seconds |
| Retries | 8 attempts: immediate, then +5s, +5m, +30m, +2h, +5h, +10h, +10h, giving up roughly 27 hours 35 minutes after the first |
| SDK | None. Quo publishes no SDK |
| Delivery | At-least-once, and ordering is not guaranteed, including within a single resource |
| Limits | A maximum of 50 webhooks per workspace |
There is no verification handshake. Quo does not send a challenge or echo request when you register an endpoint, and there is no unsigned ping to branch on. No source IP allowlist is documented either, so the HMAC is the credential.
Common events
The versioned payload reference documents 28 event types. The discriminator is the top-level type field.
| Family | Events | Fires when |
|---|---|---|
| Message | message.received, message.delivered, message.failed, message.undelivered | A text arrives, is accepted by the carrier, fails to send, or is not delivered |
| Call | call.ringing, call.answered, call.completed, call.missed, call.forwarded, call.menu.selected | A call moves through its lifecycle, or a caller picks an IVR menu option |
| Call AI and media | call.recording.completed, call.transcript.completed, call.summary.completed, call.voicemail.completed | A recording, transcript, AI summary or voicemail has finished processing |
| Contact | contact.updated, contact.deleted | A contact record changes or is removed. Always workspace-wide |
| Task | task.created, task.updated, task.deleted, task.completed, task.reopened, task.assigned, task.unassigned, task.overdue, task.linked, task.unlinked, task.duedate.updated, task.duedate.removed | A task is created, edited, reassigned, linked to a resource, or passes its due date |
Legacy webhooks send a subset of that list, plus two differently named task events: task.due_date_changed and task.due_date_removed, underscored where the versioned reference has task.duedate.updated and task.duedate.removed. Keep both spellings in your dispatch table or due-date changes vanish silently from legacy subscriptions.
The create-webhook endpoint's events enum also accepts integration.created, integration.updated and integration.deleted, but those have no documented payload in the event reference. Log them and move on rather than guessing their shape.
The current envelope nests the event data under three keys:
{
"id": "EV123",
"apiVersion": "2026-03-30",
"createdAt": "2026-04-13T12:00:00.000Z",
"type": "call.summary.completed",
"data": {
"resource": {},
"context": { "orgId": "OR123" },
"links": { "quo": "https://my.quo.com/..." }
}
}
Legacy deliveries carry the same top-level keys with apiVersion set to "v2" (or "v3" for AI events) and a single data.object in place of resource, context and links. Field names differ too: legacy messages use body, from and to, where the 2026-03-30 shape uses resource.text alongside context.senderIdentifier and context.recipientIdentifiers. Branch on apiVersion, or on whether data.resource or data.object is present.
Setting up Quo webhooks
For the current generation, create the webhook through the API. Note that the versioned management endpoints carry no /v1 path prefix; the version travels in the Quo-Api-Version header, and /v1/... paths belong to the legacy generation.
curl -X POST https://api.quo.com/webhooks \
-H "Authorization: $QUO_API_KEY" \
-H "Quo-Api-Version: 2026-03-30" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/webhooks/quo",
"events": ["message.received", "call.completed"],
"resourceIds": ["*"],
"label": "Production handler"
}'
resourceIds filters activity events to specific phone number ids (PN...) and defaults to ["*"]. Contact events ignore it and are always workspace-wide.
The signing secret comes back on the 201 as data.key, prefixed whsec_. Store it immediately and exactly as returned: Quo's documentation says only to save it, without stating whether it can be re-read, so treat it as create-time-only. If you lose it, rotate with POST /webhooks/{webhookId}/rotate rather than guessing.
For a webhook created in the app instead, open its details page, click the ellipses, and select Reveal signing secret. That value is bare base64 with no prefix, and it is not your Quo API key. The API key authenticates you calling Quo; the signing secret verifies Quo calling you.
Securing Quo webhooks
Both schemes compute HMAC-SHA256 over the raw, unparsed request body bytes and base64-encode the digest as standard base64, not base64url and not hex. Both also base64-decode their secret into raw key bytes before use. What differs is the header, the prefix concatenated onto the body, the separator, and the timestamp unit, and none of those differences are interchangeable.
The current scheme: webhook-id, webhook-timestamp, webhook-signature
const crypto = require('crypto');
// key is QUO_WEBHOOK_KEY, the `whsec_<base64>` value exactly as stored.
function verifyQuoSignature(rawBody, headers, key, maxAgeSeconds = 300) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signature = headers['webhook-signature'];
if (!id || !timestamp || !signature || !key) return false; // fail closed
// webhook-timestamp is UNIX SECONDS.
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Math.floor(Date.now() / 1000) - ts) > maxAgeSeconds) {
return false;
}
// The whsec_ prefix is not part of the key. Strip it, then base64-DECODE
// the remainder. Only the Svix SDK accepts the prefixed form.
const secret = Buffer.from(String(key).replace(/^whsec_/, ''), 'base64');
// Concatenate onto the RAW BODY BYTES, never onto re-serialised JSON.
const signedContent = Buffer.concat([
Buffer.from(`${id}.${timestamp}.`, 'utf8'),
Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8'),
]);
const expected = crypto.createHmac('sha256', secret).update(signedContent).digest('base64');
// webhook-signature is a SPACE-separated list of `v1,<sig>` entries. Accept
// any match so a rotation overlap keeps verifying.
return String(signature).split(' ').some((entry) => {
const [version, sig] = entry.trim().split(',');
if (version !== 'v1' || !sig) return false;
const a = Buffer.from(sig);
const b = Buffer.from(expected);
// Length first: timingSafeEqual throws on a mismatch, and an uncaught
// throw becomes a 500 that Quo retries eight times.
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
Because this scheme is Standard Webhooks compatible, npm i svix or pip install svix works here as well, taking the whsec_ value as-is. Svix cannot verify the legacy scheme, so a handler exposed to both generations is usually simpler with one hand-written crypto path.
The legacy scheme: openphone-signature
The header value is four semicolon-separated fields, hmac;1;{timestamp};{signature}, with commas reserved for future multi-signature use. The signed content has two parts rather than three, and the timestamp is milliseconds.
import base64
import hashlib
import hmac
import time
def verify_quo_legacy_signature(raw_body: bytes, header: str, signing_secret: str,
max_age_seconds: int = 300) -> bool:
"""raw_body is the RAW, unparsed request body. signing_secret is bare base64."""
if not header or not signing_secret:
return False # fail closed
key = base64.b64decode(signing_secret)
# Scheme A splits on spaces; this one splits on COMMAS. Swapping them fails silently.
for part in header.split(","):
fields = part.strip().split(";")
if len(fields) != 4:
continue
scheme, version, timestamp, provided = fields
if scheme != "hmac" or version != "1" or not timestamp or not provided:
continue
if not _is_fresh(timestamp, max_age_seconds):
continue
# TWO parts here, with no webhook id.
signed_content = f"{timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(
hmac.new(key, signed_content, hashlib.sha256).digest()
).decode("ascii")
if hmac.compare_digest(provided, expected):
return True
return False
def _is_fresh(timestamp: str, max_age_seconds: int) -> bool:
"""Legacy timestamps are UNIX MILLISECONDS. Quo's documented example value is
1639710054089, thirteen digits, but the unit is never stated in words, so detect
it by digit count rather than hardcoding a divisor."""
try:
n = int(timestamp)
except (TypeError, ValueError):
return False
if n <= 0:
return False
ms = n if len(str(n)) >= 12 else n * 1000
return abs(time.time() * 1000 - ms) <= max_age_seconds * 1000
Quo's own legacy Node sample signs JSON.stringify(req.body) while its Python sample signs request.data. Follow the Python one. The re-serialised form only agrees because Quo happens to send compact JSON, and it breaks the moment a proxy reformats the payload or a parser reorders a key.
Make Quo webhooks production-ready. Hookdeck Event Gateway verifies both Quo signature schemes at the edge, acknowledges inside the 10 second budget, deduplicates, and durably queues every call and message event.
Quo webhook limitations and pain points
One endpoint, two signature schemes
The Problem: A handler written against Quo's current documentation rejects every delivery from a webhook that predates the versioned API, and the failure looks identical to a wrong secret.
Why It Happens: Webhooks created before you adopted Quo-Api-Version: 2026-03-30, or through the legacy /v1 endpoints, keep sending the openphone-signature header indefinitely. Nothing migrates them, and nothing on your server selects a scheme.
Workarounds:
- Detect the generation from the headers before parsing anything, and dispatch to the matching verifier.
- Audit your workspace's webhooks and note which generation each one uses, so you know whether the legacy path is dead code or load-bearing.
- Recreate legacy webhooks through the versioned API if you can afford the secret change. The current generation has more events, richer context, and a per-delivery id.
How Hookdeck Can Help: Hookdeck verifies each scheme at the edge and forwards a uniform, already-authenticated request, so your service stops branching on which generation sent it.
The secret is not the string you were handed
The Problem: Passing whsec_... straight into an HMAC function produces a digest that never matches. So does using the legacy secret without base64-decoding it.
Why It Happens: The whsec_ prefix is a label, not key material, and both secrets are base64 representations of raw bytes. Only the Svix SDK accepts the prefixed form, which makes the mistake easy to carry over from a Svix example into hand-written code.
Workarounds:
- Strip
whsec_, then base64-decode the remainder, and use the resulting bytes as the key. - In Node, pass the
BuffertocreateHmacrather than converting it to a latin1 string first. Quo's own legacy Node sample does the conversion, which corrupts every key byte at or above0x80. - Store the secret exactly as issued and do the stripping in code, so rotation stays a copy-paste.
How Hookdeck Can Help: Hookdeck holds the signing secret and performs verification for you, so the encoding is configured once rather than reimplemented per service.
Deliveries arrive out of order, including within one resource
The Problem: A state machine driven by arrival order corrupts its own records. A call.transcript.completed can land before the call.summary.completed for the same call, and a task.updated can overtake the task.created that preceded it.
Why It Happens: Quo does not guarantee ordering, and explicitly not within a single resource. Retries make it worse: a delivery that failed once rejoins the stream hours later.
Workarounds:
- Compare
data.resource.updatedAtagainst your stored state and drop anything older. - Treat each event as a statement about a resource rather than a step in a sequence, and refetch from the API when you need the authoritative current state.
How Hookdeck Can Help: Hookdeck's delivery history shows the real arrival order and lets you replay a specific event, which turns an ordering bug into something you can reproduce.
The envelope changed shape between generations
The Problem: Code that reads event.data.object sees undefined on every current-generation delivery, and code that reads event.data.resource sees undefined on every legacy one. Neither case throws, so both go on to process an empty object and write a blank record.
Why It Happens: The 2026-03-30 envelope splits the payload into data.resource, data.context and data.links. The legacy envelope has a single data.object. Field names diverged with it, so body became resource.text and from became context.senderIdentifier.
Workarounds:
- Normalise both envelopes into one internal shape immediately after verification, and write the rest of your handler against that.
- Assert that the normalised resource is non-empty, so a shape mismatch fails loudly instead of writing blank records.
How Hookdeck Can Help: A Hookdeck transformation can normalise both envelopes before they reach your service, keeping the compatibility shim out of your application code.
Ten seconds to answer, then roughly 27 hours to recover
The Problem: A handler that does its work synchronously drifts past the response deadline under load. Quo retries eight times and then stops, and the events it gave up on are gone.
Why It Happens: The response budget is 10 seconds, and the retry schedule runs immediate, +5s, +5m, +30m, +2h, +5h, +10h, +10h. That is roughly 27 hours 35 minutes from the first attempt to the last, which is generous, but it is a hard end rather than a pause.
Workarounds:
- Verify, enqueue, return 2xx, and do the real work afterwards.
- Alert on sustained non-2xx responses. A dependency outage that outlasts the retry window is silent data loss, not a visible error.
- Use
GET /webhooks/{webhookId}/eventsto inspect what Quo sent and what your endpoint returned, andPOST /webhooks/{webhookId}/events/{deliveryId}/retryto replay a specific delivery.
How Hookdeck Can Help: Hookdeck accepts and persists the delivery within Quo's budget regardless of your service's state, then retries against your endpoint on a schedule you control, so a long outage costs you latency rather than events.
Best practices
Verify against the raw body, before you parse
Both schemes sign the exact bytes Quo sent. Capture the raw body first, verify, and only then parse. In Express that means express.raw({ type: 'application/json' }) on the webhook route, with no JSON body parser mounted ahead of it. Quo's documentation is explicit that middleware which parses or rewrites the body first will break verification.
Deduplicate on the webhook-id header, not the envelope id
The top-level id in the payload identifies the event, and every endpoint subscribed to that event receives the same value. The webhook-id header is unique per delivery and stable across retries, which is what an idempotency key needs to be. Legacy deliveries have no such header, so fall back to the envelope id there. Retain processed keys for at least 28 hours to cover the full retry window. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Return 2xx as soon as the signature checks out and hand the work to a queue. The deadline is 10 seconds, and every non-2xx response starts a retry sequence that can run for more than a day. See why to process webhooks asynchronously.
Implement both schemes if any legacy webhook still exists
Branch on the headers, not on configuration, and fail closed when a delivery arrives for a scheme whose secret is not set. Rejecting it is correct; accepting it unverified is not. Return a 5xx for a missing secret and a 4xx for a bad signature, so a misconfiguration is distinguishable from an attack in your logs.
Read unavailable as unknown, not empty
context.contacts.lookupStatus is matched, none or unavailable, and context.participants.resolution is available or unavailable. In both cases unavailable means Quo could not perform the lookup. Only none means it looked and found nothing. Treating the two as equivalent turns a transient lookup failure into a confident and wrong conclusion that a caller is a stranger.
Accept either secret during a rotation
POST /webhooks/{webhookId}/rotate issues a new signing secret, and deliveries in flight were signed with the old one. Deploy the new secret first, and verify against the previous key when the current one fails. The current scheme's signature header is a list precisely so this overlap works.
Conclusion
Quo signs with HMAC-SHA256 and a standard base64 digest over the raw body in both of its generations, but everything around that is different: the current API sends the Standard Webhooks webhook-id, webhook-timestamp and webhook-signature triple over {id}.{timestamp}.{body} with second-precision timestamps, while legacy webhooks still send openphone-signature over {timestamp}.{body} with milliseconds. Decode the secret before using it, deduplicate on the webhook-id header rather than the shared event id, and never rely on arrival order.
Hookdeck Event Gateway verifies both schemes, acknowledges inside Quo's 10 second budget, deduplicates, and durably queues every call and message event, so your service processes each one once and on its own schedule.
Get started with Hookdeck for free and handle Quo webhooks reliably in minutes.