Every webhook Pure CallerID sends is signed. Checking the signature tells you two things:
- The request really came from Pure CallerID.
- The payload was not changed after we sent it.
Each webhook also has a unique message ID and the time we first tried to send it. Use these to ignore duplicates and to see how late a retry is.
We use the open Standard Webhooks format, so you can use one of its free libraries or write the check yourself in a few lines.
Find your signing secret
Each workspace has one signing secret, shared by every webhook in that workspace.
- Open Workspace settings for the workspace.
- Find the Webhook signing secret section.
- Click Reveal, then click the copy icon.
The secret looks like whsec_ followed by a long random string. Store it as securely as you would a password, for example in an environment variable or a secrets manager. Never put it in client-side code or source control.
Only Owners and Admins can view or rotate the secret.
Webhook headers
These headers come with every webhook:
| Header | Example | Description |
|---|---|---|
webhook-id |
msg_3f9c2a7e1b8d4c6f9a0e5d2b7c1f8a3e |
Unique ID for this message. It is the same on every retry, so use it to spot duplicates. |
webhook-timestamp |
1730131200 |
When this attempt was sent, in Unix seconds (UTC). Use it to reject old or replayed requests. |
webhook-signature |
v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= |
The signature. See below for how to check it. |
Content-Type |
application/json |
The body is always JSON. |
Webhooks set up under Workspace settings → Webhooks also include:
| Header | Example | Description |
|---|---|---|
X-Event-Origin |
purecallerid.app |
Always purecallerid.app. |
X-Event-Type |
InboundCall |
The event that triggered the webhook. |
X-Retry-Attempt |
2 |
Which attempt this is. 1 is the first send. |
X-Retry-Limit |
5 |
The most attempts we will make. |
X-Retry-Original-Timestamp |
1730131170 |
When we first tried to send this message, in Unix seconds (UTC). It is the same on every retry. Subtract it from webhook-timestamp to see how long the message was delayed. |
If you set a Basic, Bearer or custom header on the webhook, we send that as well.
Callback URLs on campaign lists: if you set a
webhookUrlon a campaign list through the API, that callback is signed the same way and includeswebhook-id,webhook-timestampandwebhook-signature. It does not include theX-Event-*orX-Retry-*headers, and it is not retried.
How the signature is built
-
Build the signed content by joining the message ID, the timestamp and the raw request body with periods:
{webhook-id}.{webhook-timestamp}.{raw body} - Take your signing secret, remove the
whsec_prefix and base64-decode the rest. The result is the key. - Compute an HMAC-SHA256 of the signed content with that key, then base64-encode the result.
- Compare your value with the part of
webhook-signatureafterv1,.
The webhook-signature header can hold several signatures separated by spaces, for example v1,abc= v1,def=. The request is valid if any of them matches. Today we send one, but build your check to accept a list so it keeps working if that changes.
Important
- Use the raw body, byte for byte. Read the request body as raw bytes or a string before any JSON parsing. If you parse the JSON and convert it back to text, spacing and key order can change and the signature will not match.
-
Use a constant-time comparison, such as
hmac.compare_digestin Python orcrypto.timingSafeEqualin Node.js, instead of==. -
Check the timestamp. Reject a request if
webhook-timestampis more than 5 minutes from your server's current time. This blocks someone from replaying an old captured request. Each retry gets a newwebhook-timestampand signature, so retries still pass this check. UseX-Retry-Original-Timestampto see when the message was first sent. -
Ignore duplicates. Keep the
webhook-idvalues you have already processed. If one comes again, return200without processing it a second time.
Code examples
Using a Standard Webhooks library (recommended)
Official libraries exist for Node.js, Python, Go, Java, PHP, Ruby, C#, Rust and more. See github.com/standard-webhooks/standard-webhooks. They check the signature and the timestamp for you.
Node.js (npm install standardwebhooks)
import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const wh = new Webhook(process.env.PCID_WEBHOOK_SECRET); // "whsec_..."
// express.raw keeps the body as the exact bytes we sent
app.post("/pcid-webhook", express.raw({ type: "application/json" }), (req, res) => {
try {
const payload = wh.verify(req.body, req.headers);
// payload is the parsed JSON; req.headers["webhook-id"] is the message ID
res.sendStatus(200);
} catch (err) {
res.sendStatus(401);
}
});Python (pip install standardwebhooks)
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook
app = Flask(__name__)
wh = Webhook(os.environ["PCID_WEBHOOK_SECRET"]) # "whsec_..."
@app.post("/pcid-webhook")
def pcid_webhook():
try:
payload = wh.verify(request.get_data(), dict(request.headers))
except Exception:
return "", 401
return "", 200Checking it yourself (no library)
Node.js
const crypto = require("crypto");
function verifyPcidWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
return signatures.split(" ").some((s) => {
const [version, value] = s.split(",");
if (version !== "v1" || !value) return false;
const received = Buffer.from(value, "base64");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}Python
import base64, hashlib, hmac, time
def verify_pcid_webhook(secret: str, headers, raw_body: bytes) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures):
return False
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
version == "v1" and hmac.compare_digest(value, expected)
for version, _, value in (s.partition(",") for s in signatures.split(" "))
) for version, _, value in (s.partition(",") for s in signatures.split(" ")) )Test your code
Run your check on these values. Turn off the timestamp check while you do, because this timestamp is from 2021. Your code should report valid.
| Value | |
|---|---|
| Secret | |
webhook-id |
msg_p5jXN8AQM9LWM0D4loKWxJek |
webhook-timestamp |
1614265330 |
| Body | {"test": 2432232314} |
webhook-signature |
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
Change any single character of the body, ID or timestamp, and your code should report invalid.
Retries
If Retry on failure is turned on for a webhook and your endpoint does not respond with a success code, we try again. Success means a 2xx status or any code you added under positive response codes. There are up to 5 attempts in total:
| Attempt | Sent |
|---|---|
| 1 | Right away |
| 2 | 30 seconds after attempt 1 fails |
| 3 | 2 minutes after attempt 2 fails |
| 4 | 8 minutes after attempt 3 fails |
| 5 | 12 minutes after attempt 4 fails |
The last retry is about 23 minutes after the first attempt, or up to about 25 minutes if your endpoint is timing out.
Every retry has the same webhook-id, X-Retry-Original-Timestamp and body. It has a new webhook-timestamp and webhook-signature.
Respond within 20 seconds; after that, the attempt counts as failed. If processing takes longer, return 200 right away and finish the work in the background.
If a webhook keeps failing for more than 60 minutes, we disable it automatically and notify your workspace. You can turn it back on from Workspace settings → Webhooks.
Rotating your signing secret
If you think your secret has been exposed, rotate it:
- Open Workspace settings → Webhook signing secret.
- Click Rotate and type
ROTATEto confirm. - Copy the new secret and update every endpoint that checks it.
Rotating takes effect immediately. All future webhooks are signed with the new secret, including retries of messages already in progress. The old secret stops working right away. Until your endpoints are updated, they will reject our webhooks. Plan a rotation for a time when you can update your systems quickly.
Troubleshooting
Signature never matches
- Make sure you are using the raw body and not JSON that was parsed and converted back to text. Many frameworks parse JSON automatically, so turn that off for the webhook route.
- Make sure you are using the secret for the same workspace that the webhook belongs to.
- Make sure you remove
whsec_and base64-decode the rest. Using the secret text directly as the key gives a different result. - If you rotated the secret recently, check that your endpoint has the new one.
Timestamp check fails
- Make sure your server clock is correct. Use NTP.
-
webhook-timestampis in seconds, not milliseconds.
I received the same event twice
- That is expected after a timeout or a non-success response. Use
webhook-idto process each message only once.