# 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](https://www.quo.com) (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:

```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/..." }
  }
}

```

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.

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

```

`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`

```javascript
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.

```python
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](/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 `Buffer` to `createHmac` rather than converting it to a latin1 string first. Quo's own legacy Node sample does the conversion, which corrupts every key byte at or above `0x80`.
* 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.updatedAt` against 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}/events` to inspect what Quo sent and what your endpoint returned, and `POST /webhooks/{webhookId}/events/{deliveryId}/retry` to 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Quo webhooks reliably in minutes.