Verify webhook signatures
Prove a webhook came from Phynxa and was not replayed, with working code in Node.js and Python.
What you will achieve
Your endpoint will accept only requests that Phynxa really sent, and refuse replays.
Prerequisites
- A Signing secret set on the webhook (see Webhooks). Without one, requests are not signed.
- Access to the raw request body, exactly as received, before any JSON parsing.
How signing works
Phynxa sends three headers when a secret is set:
X-Voice-Agent-Timestamp: the Unix time (seconds) when it was sent.X-Voice-Agent-Signature-V2: the lower-case hex HMAC-SHA256 of the string<timestamp>.<raw body>, keyed with your secret. Use this one.X-Voice-Agent-Signature: the hex HMAC-SHA256 of the raw body alone (older form, no replay protection).
To verify: rebuild the signature from your secret and compare it with the header in constant time, then reject the request if the timestamp is too old.
Warning. Sign the raw bytes you received. If you parse the JSON and write it out again, spacing and key order can change and the signature will not match.
Python
import hashlib
import hmac
import time
SECRET = "whsec_your_secret" # the Signing secret you set on the webhook
MAX_AGE_SECONDS = 300 # reject deliveries older than five minutes
def is_from_phynxa(raw_body: bytes, headers) -> bool:
timestamp = headers.get("X-Voice-Agent-Timestamp", "")
received = headers.get("X-Voice-Agent-Signature-V2", "")
if not timestamp.isdigit() or not received:
return False
if abs(time.time() - int(timestamp)) > MAX_AGE_SECONDS:
return False # too old, or the clock is wrong: possible replay
expected = hmac.new(
SECRET.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(received, expected)With Flask, pass request.get_data() as raw_body; with FastAPI, await request.body().
Node.js
import crypto from "node:crypto";
import express from "express";
const SECRET = "whsec_your_secret";
const MAX_AGE_SECONDS = 300;
const app = express();
// Keep the raw bytes: the signature covers them exactly.
app.post("/hooks/calls", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Voice-Agent-Timestamp") ?? "";
const received = req.get("X-Voice-Agent-Signature-V2") ?? "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.`)
.update(req.body) // a Buffer, because of express.raw
.digest("hex");
const ok =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)) &&
age <= MAX_AGE_SECONDS;
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.event, event.call_id);
res.sendStatus(200);
});
app.listen(3000);Testing
- Run your endpoint somewhere reachable from the internet.
- Set its address and secret on the webhook and save.
- Choose Send test. Phynxa signs the sample exactly like a real delivery.
- Change one character of the secret in your code and send again: verification should now fail.
Expected result
The correct secret accepts the test delivery; a wrong secret or a delayed request is rejected with 401.
Troubleshooting
- The signature never matches. You are signing a re-serialised body, using the wrong header (use
X-Voice-Agent-Signature-V2with the timestamp), or the secret differs. Re-type it on the webhook; the field shows Configured and cannot be read back. - Valid requests rejected as too old. Check your server's clock.
- A proxy changes the body. Make sure nothing between Phynxa and your code rewrites it.
Security best practices
- Keep the secret out of source control and rotate it by entering a new one on the webhook.
- Always compare in constant time.
- Reject missing or non-numeric timestamps.
- Answer quickly with 2xx and do heavy work afterwards, so Phynxa does not time out.
Related guides
Something here does not match what you see in the dashboard? Let us know.
