Agent skill

Quo Webhooks Skill

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.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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. 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 — currentScheme B — legacy
Created viaPOST /webhooks with Quo-Api-Version: 2026-03-30legacy /v1/webhooks/messages, /v1/webhooks/calls, …
Header(s)webhook-id, webhook-timestamp, webhook-signatureopenphone-signature (single header)
Header formatwebhook-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 unitUNIX secondsUNIX milliseconds (inferred — see below)
Secretkey from POST /webhooks, whsec_<base64>app → webhook → ⋯ → "Reveal signing secret", bare base64
Envelopedata.resource / data.context / data.linksdata.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.

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
  });
}
// 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/nextjs/, examples/fastapi/.

Why manual HMAC and not an SDK

Quo publishes no SDK. Its docs recommend 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 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"):

{
  "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:

{
  "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:

FamilyEvents
Messagemessage.received, message.delivered, message.failed, message.undelivered
Callcall.ringing, call.menu.selected, call.answered, call.completed, call.forwarded, call.missed
Call AI / mediacall.recording.completed, call.summary.completed, call.transcript.completed, call.voicemail.completed
Contactcontact.updated, contact.deleted
Tasktask.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.
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

# 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

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 — Both envelope generations, every event type, unavailable vs none, deduplication, retry schedule, ordering
  • references/setup.md — Creating webhooks in the app and via the API, getting each scheme's secret, rotation, Send Test Request
  • references/verification.md — Both schemes byte by byte, the Svix option, the docs' own contradictions, debugging failures

Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Sep 25, 2026

View on GitHub →