# 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](/webhooks/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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/quo-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/quo-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/quo-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/quo-webhooks/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

* `message.*` and `call.*` take a `resourceIds` filter of phone-number ids
  (`PN…`) or `["*"]` for all. Omitted defaults to `["*"]`.
* `contact.*` is always workspace-wide — `resourceIds` doesn't apply.
* A webhook needs at least one event.
* Max 50 webhooks per workspace.

```bash
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"
  }'

```

The 201 response wraps everything in a top-level `data` object, so the
`whsec_…` signing secret is `data.key`. Store it
immediately. Quo's docs say only "Save the `key` field from the response" and
don't say whether it can be re-read later, so treat it as create-time-only; if
you lose it, rotate with `POST /webhooks/{webhookId}/rotate`.

Note the versioned management endpoints have no `/v1` prefix — the version
travels in the `Quo-Api-Version` header. `/v1/...` paths are the legacy
generation.

## Delivery and Retries

* Answer 2xx within 10 seconds. Verify, enqueue, return — do the work after.
* Retries on any non-2xx: 8 attempts — immediate, +5s, +5m, +30m, +2h, +5h,
  +10h, +10h — giving up roughly 27h35m after the first attempt.
* Ordering is not guaranteed, including within a single resource. A
  `call.transcript.completed` can land before the matching
  `call.summary.completed`. Don't drive a state machine off arrival order —
  compare `data.resource.updatedAt` against stored state and drop stale events.

## Environment Variables

```bash
# Scheme A (current, Quo-Api-Version: 2026-03-30).
# The `key` field from POST /webhooks. Includes the whsec_ prefix — store it
# exactly as returned; the code strips and base64-decodes it.
QUO_WEBHOOK_KEY=whsec_c2VjcmV0

# Scheme B (legacy v1 webhooks only). From the Quo app: webhook details -> the
# ellipses (...) -> "Reveal signing secret". Bare base64, NO whsec_ prefix.
# This is NOT your Quo API key.
QUO_LEGACY_SIGNING_SECRET=R2ZLM2o0bFhBNVpyUnU2NG9mYXQ1MHNyR3pvSUhIVVg=

```

Set only the one(s) your webhooks actually use. The examples fail closed: if a
delivery arrives for a scheme whose secret isn't configured, they reject it
rather than accepting it unverified.

## Local Development

```bash
npx hookdeck-cli listen 3000 quo --path /webhooks/quo

```

No account required — the CLI creates a guest account on first run and gives you
a public HTTPS URL plus a web UI for inspecting requests. Paste the printed URL
into the webhook's URL field in Quo (or into `POST /webhooks`), then use Send
Test Request to trigger a real, signed delivery.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/quo-webhooks/references/overview.md) — Both envelope generations, every event type, `unavailable` vs `none`, deduplication, retry schedule, ordering
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/quo-webhooks/references/setup.md) — Creating webhooks in the app and via the API, getting each scheme's secret, rotation, Send Test Request
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/quo-webhooks/references/verification.md) — Both schemes byte by byte, the Svix option, the docs' own contradictions, debugging failures