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™ and that were not changed on the way.

Before you begin#

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™ requests.

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)

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.

  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.

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