# Verify webhook requests

Every webhook request is signed with the secret of your webhook. Verify the
signature before you process an event, so that your server accepts only
requests that come from phone.systems(TM) and that were not changed on the way.

## Before you begin

- A webhook must be added in **Settings > Webhooks**. See [Set up and
  manage a webhook](set-up-and-manage-webhooks.html).
- The secret of the webhook must be stored on your server. See [View the
  webhook status and secret](set-up-and-manage-webhooks.html#ps3-webhooks-status).
- Your server must read the raw request body. Parsing and re-serializing the
  JSON changes the bytes and breaks the signature.

## How requests are signed

Each request has an `X-PS-Signature` header in this format:

```
X-PS-Signature: t=1790847297,v1=f7b25eab66fb2113b8ec6f313a2ee18d8a7c4fa47cf9e9dc0993f5758b17e906
```

| Part | Description |
| --- | --- |
| `t` | The time the delivery attempt was signed, in Unix seconds. |
| `v1` | The HMAC-SHA256 of the signed payload, as a lowercase hexadecimal string. The key is the webhook secret. |

The signed payload is the value of `t`, a period (`.`), and the raw request
body, exactly as received:

```
<t>.<raw request body>
```

Each delivery attempt is signed again, so a retried request has a new `t`
and a new `v1`.

## Step 1: Verify the signature

For every request:

1. Read the `X-PS-Signature` header. Split it on `,`, then split each
   part on the first `=`. Take `t` and `v1`. Reject the request if the
   header is missing, `t` is not a number, or `v1` is not 64 lowercase
   hexadecimal characters.
2. Reject the request if `t` is more than 5 minutes before or after the
   current time. This protects against replayed requests. Keep the clock of
   your server synchronized with NTP.
3. Build the signed payload `<t>.<raw request body>` from the raw body bytes.
4. Compute the HMAC-SHA256 of the signed payload with the webhook secret as
   the key, and encode it as lowercase hexadecimal. Use the secret as text,
   exactly as shown on the **Webhooks** page. Do not base64-decode it.
5. Compare the result with `v1` using a constant-time comparison. If they do
   not match, reject the request.

Use one of the following functions, or implement the same steps in your
language. Both were tested against real phone.systems(TM) requests.

Python

Requires Python 3.10 or later and no extra packages.

```
 1import hashlib
 2import hmac
 3import re
 4import time
 5
 6TOLERANCE_SECONDS = 300
 7TIMESTAMP = re.compile(r"[0-9]{1,12}")
 8SIGNATURE = re.compile(r"[0-9a-f]{64}")
 9
10
11def verify_signature(secret: str, raw_body: bytes, signature_header: str,
12                     now: float | None = None) -> bool:
13    """Return True if the X-PS-Signature header matches the raw request body."""
14    fields = [part.strip().partition("=") for part in signature_header.split(",")]
15    timestamp = next((value for key, _, value in fields if key == "t"), "")
16    signatures = [value for key, _, value in fields
17                  if key == "v1" and SIGNATURE.fullmatch(value)]
18    if not TIMESTAMP.fullmatch(timestamp) or not signatures:
19        return False
20
21    if now is None:
22        now = time.time()
23    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
24        return False
25
26    signed_payload = timestamp.encode() + b"." + raw_body
27    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
28    return any(hmac.compare_digest(expected, signature) for signature in signatures)
```


Node.js

Uses the built-in `crypto` module.

```
 1const crypto = require("crypto");
 2
 3const TOLERANCE_SECONDS = 300;
 4const TIMESTAMP = /^[0-9]{1,12}$/;
 5const SIGNATURE = /^[0-9a-f]{64}$/;
 6
 7function verifySignature(secret, rawBody, signatureHeader, now = Date.now() / 1000) {
 8  const fields = String(signatureHeader).split(",").map((part) => {
 9    const index = part.indexOf("=");
10    return index === -1 ? [part.trim(), ""] : [part.slice(0, index).trim(), part.slice(index + 1)];
11  });
12  const timestamp = fields.find(([key]) => key === "t")?.[1] ?? "";
13  const signatures = fields
14    .filter(([key, value]) => key === "v1" && SIGNATURE.test(value))
15    .map(([, value]) => value);
16  if (!TIMESTAMP.test(timestamp) || signatures.length === 0) return false;
17  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
18
19  const expected = crypto
20    .createHmac("sha256", secret)
21    .update(`${timestamp}.`)
22    .update(rawBody)
23    .digest("hex");
24
25  return signatures.some((signature) =>
26    crypto.timingSafeEqual(Buffer.from(signature, "ascii"), Buffer.from(expected, "ascii"))
27  );
28}
29
30module.exports = { verifySignature };
```

### Test your implementation

Check your code with these example values before you connect it to your
webhook. With them, your function must return a valid result. Changing any
character of the body, the timestamp, or the secret must make it fail.

| Input | Example value |
| --- | --- |
| Secret | `example_secret_do_not_use_7f3a9c1e5b2d8f4a6c0e9b1d3f5a7c9e1b` |
| `X-PS-Signature` | `t=1790847297,v1=f7b25eab66fb2113b8ec6f313a2ee18d8a7c4fa47cf9e9dc0993f5758b17e906` |
| Raw body | The JSON below, as one line with no spaces between elements. |

```
1{"id":"d75df7ee-5c1f-7a9f-b213-5cb13ccc38b7","type":"call.started","ts":"2026-10-01T09:34:57.221Z","global_tag":"faf24793-bbd2-4ef0-a94c-6dcc2215207b","data":{"catch_up":false,"reason":null,"time_start":"2026-10-01T09:34:57Z","time_connect":null,"time_end":null,"src_type":"user","src_number":"15551230001","src_name":"Alex Smith","dst_type":null,"dst_number":"15551230002","end_reason":null,"disconnect_code":null,"disconnect_reason":null}}
```

The example timestamp is in the past, so pass it as the current time when you
test, for example `now=1790847298` in Python or `1790847298` as the
fourth argument in Node.js. Otherwise the 5-minute check rejects it.

## Step 2: Handle the request

After the signature is valid:

1. Parse the body as JSON.
2. Check the event `id`. If you already processed an event with the same
   `id`, respond with `200` and skip it. Retries and replays reuse the same
   `id`. See [Duplicate events](webhook-delivery-and-troubleshooting.html#ps3-webhooks-duplicates).
3. Store the event and respond with a `2xx` status code within 10 seconds.
   Process the event after you respond, for example from a queue, so that slow
   processing does not cause timeouts and retries.

If the signature is invalid, respond with `401` and do not process the event.

The following examples read the raw body and call the verification function
from step 1. The secret is read from the `PS_WEBHOOK_SECRET` environment
variable.

Python (Flask)

```
 1import json
 2import os
 3
 4from flask import Flask, request
 5
 6from verify import verify_signature  # the function from step 1
 7
 8app = Flask(__name__)
 9SECRET = os.environ["PS_WEBHOOK_SECRET"]
10
11
12@app.post("/webhook")
13def webhook():
14    raw_body = request.get_data()  # raw bytes, before JSON parsing
15    signature = request.headers.get("X-PS-Signature", "")
16    if not verify_signature(SECRET, raw_body, signature):
17        return "", 401
18
19    event = json.loads(raw_body)
20    # Skip the event if event["id"] was already processed.
21    # Otherwise store it and process it asynchronously.
22    return "", 200
```


Node.js (Express)

```
 1const express = require("express");
 2const { verifySignature } = require("./verify"); // the function from step 1
 3
 4const app = express();
 5const SECRET = process.env.PS_WEBHOOK_SECRET;
 6
 7// express.raw keeps the body as a Buffer, so the signature can be checked.
 8app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
 9  const signature = req.get("X-PS-Signature") || "";
10  if (!verifySignature(SECRET, req.body, signature)) {
11    return res.sendStatus(401);
12  }
13
14  const event = JSON.parse(req.body);
15  // Skip the event if event.id was already processed.
16  // Otherwise store it and process it asynchronously.
17  res.sendStatus(200);
18});
19
20app.listen(3000);
```

### When the signature does not match

Every request fails, and the body is parsed first

The body was parsed and encoded again before the check, for example by
`express.json()` or `json.dumps(request.json)`.

Verify the signature on the raw body bytes.

Every request fails, and the body is read raw

The secret was base64-decoded, or copied with a space or a line break.

Use the secret as text, exactly as shown on the **Webhooks** page.

Requests fail since the webhook was added again

The new webhook has a new secret.

Copy the new secret from the **Webhooks** page.

Only some requests fail

The body was read as text in an encoding other than UTF-8. Names and
transcripts can contain non-ASCII characters.

Verify the bytes, or decode them as UTF-8.

Requests are rejected as too old

The clock of your server is wrong.

Synchronize the clock with NTP.

Requests fail behind a proxy

A proxy or firewall changes the body or its line endings.

Pass the body to your application unchanged.

Important

- Do not log the secret or the `X-PS-Signature` header together with the
  raw body.
- Use HTTPS on your server, and turn on **Skip certificate verification**
  only for a server with a self-signed, expired, or mismatched certificate.
- Payloads can contain personal data, such as phone numbers, caller names,
  transcripts, and summaries. Store and retain them according to your
  privacy and data protection requirements.

## Related resources

- [Call Webhooks](../webhooks.html) - Understand how call webhooks work and
  their limitations.
- [Set up and manage a webhook](set-up-and-manage-webhooks.html) - Add the
  webhook and copy its secret.
- [Webhook event reference](webhook-event-reference.html) - Look up the
  headers, the envelope, and the fields of every event.
- [Webhook delivery and troubleshooting](webhook-delivery-and-troubleshooting.html) - Fix signature and delivery
  problems.
