Skip to main content

quo-webhooks

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.

Aller à l'installation

Informations de source

Dépôt
hookdeck/webhook-skills
Dernière activité de la source
23 septembre 2026 à 14:12
Langue détectée de SKILL.md
anglais
Étoiles
88
Forks
14

Options d'installation

Le prompt qui vérifie d'abord la source est sélectionné par défaut. Vous pouvez passer à une commande directe ou télécharger une copie locale.

Vérifiez les fichiers source

Lisez SKILL.md et les fichiers associés affichés par SkillsMP avant de décider de l'installer.

Explorateur de fichiers
21 fichiers

Affichage de SKILL.md

SKILL.md
Instructions source · Aperçu en lecture seule
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
Voir sur GitHub
Ce SKILL.md est tres volumineux, SkillsMP affiche donc ici seulement la premiere section. Voir sur GitHub