> ## Documentation Index
> Fetch the complete documentation index at: https://www.quo.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Validate legacy webhook signatures

> Verify the OpenPhone-Signature header on legacy webhooks created in the Quo app or with the v1 API.

> [!WARNING]
>
> **This page is for legacy webhooks only.** Legacy webhooks are being retired. If your deliveries include `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers, you have a new webhook. Use [Validate webhook signatures](https://www.quo.com/docs/2026-03-30/webhooks-signature-validation.md) instead. To move off legacy webhooks, follow the [migration guide](https://www.quo.com/docs/2026-03-30/webhooks-migration-guide.md).

## Overview

Legacy webhooks are the webhooks created in **Settings → Webhooks** in the Quo app before the new webhook system launched, or with the `/v1/webhooks` endpoints. Legacy webhooks are not the same thing as version v1 of the API.

To tell which kind of webhook you have, check the headers on a delivery. Quo signs each legacy delivery with one header, `openphone-signature`. Legacy deliveries do not include the `webhook-id`, `webhook-timestamp`, or `webhook-signature` headers.

The **Send test request** option for a legacy webhook uses the same signing key and header format as live deliveries.

## Signature format

```text theme={null}
openphone-signature: hmac;1;1639710054089;mw1K4fvh5m9XzsGon4C5N3KvL0bkmPZSAyb/9Vms2Qo=
```

The header has four fields, separated by semicolons:

| Field | Value |
| - | - |
| Scheme | Always `hmac` |
| Version | Always `1` |
| Timestamp | Unix time in **milliseconds** when Quo signed the request |
| Signature | Base64-encoded HMAC-SHA256 digest |

The signature is HMAC-SHA256 over `{timestamp}.{raw-body}`. The key is your signing key, decoded from base64.

## Get your signing key

1. In the Quo app, open the webhook's details page.
2. Click the ellipsis (**⋯**) at the top right.
3. Select **Reveal signing secret**.
4. Copy the base64-encoded key and store it as a secret, such as an environment variable.

## Verify a request

To verify a delivery:

1. Split the `openphone-signature` header on `;` and read the timestamp and signature.
2. Reject the request if the timestamp is more than five minutes from the current time.
3. Build the signed data: the timestamp, a `.`, then the raw request body.
4. Compute an HMAC-SHA256 digest of the signed data with the base64-decoded signing key, and encode it as base64.
5. Compare your digest with the signature from the header. Accept the request only if they match.

> [!NOTE]
>
> Use the raw request body bytes. If your framework parses the JSON body before you verify it, re-serializing it can change the bytes and make verification fail.

Respond with a `2xx` status code within 10 seconds. Quo retries a delivery when your endpoint returns any other status code or does not respond in time.

## Example handlers

```javascript Node.js theme={null}
const crypto = require('node:crypto')
const express = require('express')

const app = express()

// The signing key from "Reveal signing secret" in the Quo app.
const signingKey = Buffer.from(process.env.QUO_LEGACY_SIGNING_KEY ?? '', 'base64')
const toleranceMs = 5 * 60 * 1000

// Keep the raw body so the signed bytes are exactly what Quo sent.
app.post('/webhooks/quo', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('openphone-signature') ?? ''
  const [scheme, version, timestamp, providedDigest] = header.split(';')

  if (scheme !== 'hmac' || version !== '1' || !timestamp || !providedDigest) {
    return res.status(401).send('Invalid signature header')
  }

  if (Math.abs(Date.now() - Number(timestamp)) > toleranceMs) {
    return res.status(401).send('Stale timestamp')
  }

  const signedData = Buffer.concat([Buffer.from(`${timestamp}.`), req.body])
  const computedDigest = crypto.createHmac('sha256', signingKey).update(signedData).digest('base64')

  const provided = Buffer.from(providedDigest)
  const computed = Buffer.from(computedDigest)

  if (provided.length !== computed.length || !crypto.timingSafeEqual(provided, computed)) {
    return res.status(401).send('Invalid signature')
  }

  const event = JSON.parse(req.body.toString('utf8'))
  // Process the event here.

  res.sendStatus(200)
})

app.listen(8000)
```

```python Python theme={null}
import base64
import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)

# The signing key from "Reveal signing secret" in the Quo app.
SIGNING_KEY = base64.b64decode(os.environ.get('QUO_LEGACY_SIGNING_KEY', ''))
TOLERANCE_MS = 5 * 60 * 1000


@app.route('/webhooks/quo', methods=['POST'])
def handle_webhook():
    fields = request.headers.get('openphone-signature', '').split(';')
    if len(fields) != 4 or fields[0] != 'hmac' or fields[1] != '1':
        return 'Invalid signature header', 401

    timestamp, provided_digest = fields[2], fields[3]

    if not timestamp.isdigit() or abs(time.time() * 1000 - int(timestamp)) > TOLERANCE_MS:
        return 'Stale timestamp', 401

    # request.get_data() returns the raw body bytes.
    signed_data = timestamp.encode() + b'.' + request.get_data()
    digest = hmac.new(SIGNING_KEY, signed_data, hashlib.sha256).digest()
    computed_digest = base64.b64encode(digest).decode()

    if not hmac.compare_digest(provided_digest, computed_digest):
        return 'Invalid signature', 401

    event = request.get_json()
    # Process the event here.

    return '', 200


if __name__ == '__main__':
    app.run(port=8000)
```

## Troubleshooting

> [accordion] Every request fails verification
>
> Check that you decode the signing key from base64 before you use it as the HMAC key, and that you sign the raw body rather than a re-serialized copy. Also check that you copied the key from the same webhook that sends the requests.

> [accordion] Requests fail the timestamp check
>
> The timestamp is in milliseconds. If you compare it with a clock in seconds, every request looks stale.

> [accordion] New webhooks fail verification
>
> New webhooks, including replacements made with **Duplicate as new webhook**, use different headers and a `whsec_` key. Verify them with [Validate webhook signatures](https://www.quo.com/docs/2026-03-30/webhooks-signature-validation.md).
