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#
A webhook must be added in Settings > Webhooks. See Set up and manage a webhook.
The secret of the webhook must be stored on your server. See View the webhook status and secret.
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 |
|---|---|
|
The time the delivery attempt was signed, in Unix seconds. |
|
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:
Read the
X-PS-Signatureheader. Split it on,, then split each part on the first=. Taketandv1. Reject the request if the header is missing,tis not a number, orv1is not 64 lowercase hexadecimal characters.Reject the request if
tis more than 5 minutes before or after the current time. This protects against replayed requests. Keep the clock of your server synchronized with NTP.Build the signed payload
<t>.<raw request body>from the raw body bytes.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.
Compare the result with
v1using 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 |
|
|
|
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:
Parse the body as JSON.
Check the event
id. If you already processed an event with the sameid, respond with200and skip it. Retries and replays reuse the sameid. See Duplicate events.Store the event and respond with a
2xxstatus 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-Signatureheader 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.