- name
- quo-webhooks
- description
- Receive and verify Quo webhooks (quo.com — the business phone / VoIP platform formerly known as OpenPhone). Use when setting up a Quo webhook handler, debugging signature verification, or handling call and message events like message.received, call.completed, call.summary.completed, call.transcript.completed, contact.updated or task.created. Quo has TWO signature schemes: the current versioned API (Quo-Api-Version 2026-03-30) uses Standard Webhooks / Svix-style webhook-id + webhook-timestamp + webhook-signature headers, while the legacy v1 API still sends the OpenPhone-era openphone-signature header (hmac;1;timestamp;signature). Not Quoter (CPQ, MD5 `hash` form field), not Quora, not Twilio.
- license
- MIT
- metadata
- {"author":"hookdeck","version":"0.1.0","repository":"https://github.com/hookdeck/webhook-skills"}
# Quo Webhooks
Quo (quo.com) is a business phone / VoIP platform. It sends webhooks for calls,
messages, contacts and tasks.
> **Quo is the platform formerly known as OpenPhone.** The support docs say so
> verbatim: "Send real-time notifications of Quo, formerly OpenPhone, events to
> your applications". The rebrand is why the legacy signature header is still
> called `openphone-signature`, and why older community material talks about
> OpenPhone webhooks. API host is `https://api.quo.com`; docs live at
> `www.quo.com/docs` and `support.quo.com`.
> **Not [Quoter](https://github.com/hookdeck/webhook-skills/tree/main/skills/quoter-webhooks).**
> Quoter (help.quoter.com) is an unrelated CPQ / sales-quoting company with an
> **MD5-based `hash` form field** (not a header). Quo uses **HMAC-SHA256**. Two different
> companies, two entirely different signing schemes. Also not Quora, not the
> Quo card/loyalty app, and not Twilio.
## When to Use This Skill
- How do I receive Quo (OpenPhone) webhooks?
- How do I verify a Quo webhook signature?
- Why is my `webhook-signature` / `openphone-signature` verification failing?
- How do I handle `message.received`, `call.completed`, or
`call.summary.completed` events?
- What is the difference between `data.resource` and `data.object` in a Quo
payload?
- How do I deduplicate Quo webhook deliveries?
- Does Quo send a validation / challenge request when I add an endpoint?
## Two Generations, Two Signature Schemes
**This is the first thing to get right.** Which scheme an endpoint receives is
decided by how the webhook subscription was created, not by anything you
configure on your server. A handler that may receive both must implement both.
| | **Scheme A — current** | **Scheme B — legacy** |
|---|---|---|
| Created via | `POST /webhooks` with `Quo-Api-Version: 2026-03-30` | legacy `/v1/webhooks/messages`, `/v1/webhooks/calls`, … |
| Header(s) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `openphone-signature` (single header) |
| Header format | `webhook-signature: v1,<base64> v1,<base64>` (**space**-separated) | `hmac;1;1639710054089;mw1K4fv…=` (**semicolon**-separated, 4 fields) |
| Signed content | `{webhook-id}.{webhook-timestamp}.{raw-body}` | `{timestamp}.{raw-body}` |
| Timestamp unit | UNIX **seconds** | UNIX **milliseconds** (inferred — see below) |
| Secret | `key` from `POST /webhooks`, `whsec_<base64>` | app → webhook → ⋯ → "Reveal signing secret", bare base64 |
| Envelope | `data.resource` / `data.context` / `data.links` | `data.object` |
| `apiVersion` | `"2026-03-30"` | `"v2"` (`"v3"` for AI events) |
Both are **HMAC-SHA256 with a standard base64 digest** (not base64url, not hex),
and both sign the **raw, unparsed request body bytes**.
## Verification (core)
Both schemes base64-decode their secret to raw key bytes before use, and both
concatenate a prefix onto the **raw body bytes** — never onto a re-serialized
JSON string.
```javascript
const crypto = require('crypto');
// Scheme A — headers webhook-id / webhook-timestamp / webhook-signature.
// Key is `whsec_<base64>`: strip the prefix, then base64-DECODE the remainder.
function verifyQuo(rawBody, { id, timestamp, signature }, key) {
if (!id || !timestamp || !signature || !key) return false; // fail closed
const ts = Number(timestamp); // UNIX SECONDS
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const secret = Buffer.from(key.replace(/^whsec_/, ''), 'base64');
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), Buffer.from(rawBody)]);
const expected = crypto.createHmac('sha256', secret).update(signed).digest('base64');
// SPACE-separated `v1,<sig>` entries; accept if ANY v1 entry matches.
return signature.split(' ').some((entry) => {
const [version, sig] = entry.trim().split(',');
if (version !== 'v1' || !sig) return false;
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b); // length guard first
});
}
```
```javascript
// Scheme B — legacy `openphone-signature: hmac;1;<ms-timestamp>;<base64sig>`.
// Secret is bare base64 (no whsec_ prefix) from the Quo app.
function verifyQuoLegacy(rawBody, header, signingSecret) {
if (!header || !signingSecret) return false;
const key = Buffer.from(signingSecret, 'base64');
// COMMA-separated today only in theory; the docs reserve it for future multi-sig.
return header.split(',').some((part) => {
const [scheme, version, timestamp, sig] = part.trim().split(';');
if (scheme !== 'hmac' || version !== '1' || !timestamp || !sig) return false;
const signed = Buffer.concat([Buffer.from(`${timestamp}.`), Buffer.from(rawBody)]);
const expected = crypto.createHmac('sha256', key).update(signed).digest('base64');
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
```
> **For complete handlers with tests**, see [examples/express/](examples/express/), [examples/nextjs/](examples/nextjs/), [examples/fastapi/](examples/fastapi/).
### Why manual HMAC and not an SDK
**Quo publishes no SDK.** Its docs recommend [Svix](https://www.npmjs.com/package/svix)
(`npm i svix` / `pip install svix`) for Scheme A, and Svix does work there
unchanged — it accepts the `webhook-*` headers and the `whsec_` key as-is. Use
it if you like; [references/verification.md](references/verification.md) shows how.
Svix **cannot** verify Scheme B — different header, different format, different
signed content, different timestamp unit. Since a complete Quo handler has to
implement the legacy path by hand anyway, this skill's examples do both by hand
so one consistent crypto path covers both generations, with no dependency and
no ESM/Node-version constraints.
## Gotchas That Actually Bite
**The `whsec_` prefix is not part of the key.** `createHmac('sha256', 'whsec_abc…')`
is a bug — only the Svix SDK accepts the prefixed form. Strip `whsec_`, then
**base64-decode** what remains to get the raw key bytes.
**The legacy secret is base64 too.** Decode it before use. It is *not* your Quo
API key.
**Legacy timestamps are milliseconds.** The documented example value is
`1639710054089` — 13 digits. A replay check that treats that as seconds puts
every delivery ~52,000 years in the future and silently drops all traffic.
Quo's docs never state the unit in words; ms is inferred from the example, so
this skill's examples detect 13-digit vs 10-digit values rather than hardcoding
either. Scheme A's `webhook-timestamp` really is seconds — the docs' own
tolerance example uses `5 * 60`.
**The docs' two legacy samples disagree about the body, and it matters.** The
Node sample signs `timestamp + '.' + JSON.stringify(req.body)`; the Python
sample signs `timestamp.encode() + b'.' + request.data`. `request.data` is the
raw body; `JSON.stringify(req.body)` is a re-serialization that only agrees
because Quo happens to send compact JSON. **Always use the raw body** — it is
the safe superset, and the prose backs it ("Remove all whitespace and newlines
from JSON payload before concatenation"). Reserializing breaks the moment a
proxy reformats or a parser reorders anything.
**The docs' legacy Node sample corrupts non-ASCII keys.** It does
`Buffer.from(signingKey, 'base64').toString('binary')` and passes that latin1
**string** into `createHmac`; Node then re-encodes it as UTF-8, mangling every
key byte ≥ 0x80. Pass the **Buffer** directly — `Buffer.from(signingKey, 'base64')`
— which matches the Python sample's `base64.b64decode` and is byte-identical for
the ASCII-decoding keys Quo issues (the docs' own example key decodes to
`GfK3j4lXA5ZrRu64ofat50srGzoIHHUX`). This skill deliberately does not reproduce
the `.toString('binary')` form.
**Separators differ between the schemes.** Scheme A splits the header on
**spaces** then commas. Scheme B splits on **commas** (reserved for future
multi-signature) then semicolons. Swapping them silently fails.
**`timingSafeEqual` throws on length mismatch.** Guard the lengths first, or
wrap in `try`/`catch`. An uncaught throw becomes a 500, which Quo retries.
## No Handshake, No IP Allowlist
There is **no challenge/echo/validation request**. Quo never asks your endpoint
to prove itself before it starts sending.
Quo's **"Send Test Request"** button (and `POST /webhooks/:id/events/test`)
sends a normal, fully-signed sample payload of a chosen event type. It is an
ordinary signed delivery, not a special envelope — **there is no `webhook.test`
event type and no unsigned ping**. Do not write a branch for one.
**No source-IP allowlist is documented.** Don't invent one; the HMAC is the
credential.
## Envelope
**Scheme A (`apiVersion: "2026-03-30"`):**
```json
{
"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/..." }
}
}
```
**Scheme B (legacy, `apiVersion: "v2"` or `"v3"`):** same top-level keys, but a
single `data.object` instead of `resource` / `context` / `links`:
```json
{
"id": "EVc67ec998b35c41d388af50799aeeba3e",
"object": "event",
"apiVersion": "v2",
"createdAt": "2022-01-23T16:55:52.557Z",
"type": "message.received",
"data": { "object": {
"id": "AC24a8...", "object": "message",
"from": "+14155550100", "to": "+13105550199",
"direction": "incoming", "body": "Hello", "media": [],
"status": "received", "createdAt": "2022-01-23T16:55:52.420Z",
"userId": "USu5AsEHuQ", "phoneNumberId": "PNtoDbDhuz",
"conversationId": "CN78ba0373683c48fd8fd96bc836c51f79"
} }
}
```
Field names differ between generations: legacy uses `body` / `from` / `to`;
2026-03-30 uses `resource.text` and `context.senderIdentifier` /
`context.recipientIdentifiers`. **Branch on `apiVersion`** (or on the presence
of `data.resource` vs `data.object`) if your endpoint may receive both.
**The top-level `id` is the EVENT id, not the delivery id.** Every endpoint subscribed to
that event receives the *same* `id`. Deduplicate on the **`webhook-id` header**,
which is unique per delivery and stable across retries. Store processed ids for
**at least 28 hours** to cover the full retry window.
**`unavailable` means UNKNOWN, not empty.** `context.contacts.lookupStatus` is
`matched` | `none` | `unavailable`; `context.participants.resolution` is
`available` | `unavailable`. Handlers routinely misread `unavailable` as "no
contacts" and wrongly conclude a caller is unknown. `none` means genuinely no
match; `unavailable` means Quo couldn't look it up.
## Event Types
The full list from the 2026-03-30 payload reference — each of these has its own
documented schema and example:
| Family | Events |
|---|---|
| Message | `message.received`, `message.delivered`, `message.failed`, `message.undelivered` |
| Call | `call.ringing`, `call.menu.selected`, `call.answered`, `call.completed`, `call.forwarded`, `call.missed` |
| Call AI / media | `call.recording.completed`, `call.summary.completed`, `call.transcript.completed`, `call.voicemail.completed` |
| Contact | `contact.updated`, `contact.deleted` |
| 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` |
**Legacy aliases.** The support-docs list is a subset of the above **plus two
differently-named task events**: it documents `task.due_date_changed` and
`task.due_date_removed` (underscored) where the versioned reference has
`task.duedate.updated` and `task.duedate.removed`. It also lacks
`message.failed`, `message.undelivered`, `call.menu.selected`, `call.answered`,
`call.forwarded`, `call.missed`, `call.voicemail.completed` and `task.assigned`.
Treat the versioned reference as authoritative for new integrations, but keep
the underscored aliases in your dispatch table if you have legacy webhooks.
**One more, hedged:** the create-webhook endpoint's `events` enum additionally
accepts `integration.created`, `integration.updated` and `integration.deleted`.
They have **no documented payload** in the event payload reference, so this
skill does not describe their shape. Log and ignore unless you've observed one.
## Subscriptions
عرض على GitHub