Webhooks
Verifying signatures
When a Send results step has a signing secret, every request carries an X-Ubiq-Signature header. Check it before you trust the data — it proves the request came from Daffy Voice, wasn't changed on the way, and isn't an old request being replayed.
What is signed
call.resultfrom a workflow's Send results step — only if you entered a Signing secret in the step. Without a secret, the header is not sent.- The AI employee end-of-call and prefetch webhooks are not signed. There is nothing to verify; protect those URLs with a hard-to-guess path and HTTPS.
- Rows sent to a Google Sheet through the Apps Script are not verified by the script.
The header
Header
X-Ubiq-Signature: t=1791625380,v1=9c4b1f0e3d2a7b6c5e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0dt— Unix time in seconds when Daffy Voice signed the request.v1— HMAC-SHA256 as lowercase hex.
The signature is computed over the timestamp, a dot, and the raw request body:
Algorithm
v1 = hex( HMAC_SHA256( key = signing_secret, message = t + "." + raw_body ) )The key is your signing secret exactly as you typed it, as UTF-8 bytes. The body is the JSON exactly as sent — UTF-8, so names in other scripts arrive as-is rather than escaped.
How to verify
- Read the raw request body as bytes, before any JSON parsing.
- Split the header on
,and each part on the first=to gettandv1. - Reject the request if
tis more than 5 minutes from your server's clock. - Compute HMAC-SHA256 of
t + "." + raw_bodywith your secret, as hex. - Compare it to
v1with a constant-time comparison. If they differ, respond401and stop.
Use the raw body
Don't re-serialise parsed JSON to check the signature — key order, spacing and escaping change, and the check fails. In Express use
express.raw() on this route (not express.json()); in Flask use request.get_data().Node.js (Express)
verify.mjs
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.UBIQ_SIGNING_SECRET; // the text you typed in "Signing secret"
const TOLERANCE_SEC = 300;
function verifyUbiqSignature(rawBody, header) {
const parts = Object.fromEntries(
(header || "").split(",").map((kv) => {
const i = kv.indexOf("=");
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
const v1 = parts.v1 || "";
if (!Number.isInteger(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.`)
.update(rawBody) // the exact bytes received — not re-serialised JSON
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// express.raw keeps the body as a Buffer so the signature can be checked.
app.post("/ubiq/results", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyUbiqSignature(req.body, req.get("X-Ubiq-Signature"))) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.event, event.contact.phone, event.result.outcome);
res.sendStatus(200);
});
app.listen(3000);Python (Flask)
verify.py
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["UBIQ_SIGNING_SECRET"].encode() # the text you typed in "Signing secret"
TOLERANCE_SEC = 300
def verify_ubiq_signature(raw_body: bytes, header: str | None) -> bool:
try:
parts = dict(p.strip().split("=", 1) for p in (header or "").split(","))
t, v1 = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SEC:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
app = Flask(__name__)
@app.post("/ubiq/results")
def ubiq_results():
raw = request.get_data() # the exact bytes received
if not verify_ubiq_signature(raw, request.headers.get("X-Ubiq-Signature")):
abort(401)
event = json.loads(raw)
print(event["event"], event["contact"]["phone"], event["result"]["outcome"])
return "", 200Test your verifier
Press Send a test in the Send results step to receive a real, signed sample. Or sign a request yourself with openssl:
sign-and-send.sh
# Send yourself a correctly signed request to test your verifier.
SECRET='my-signing-secret'
BODY='{"event":"call.result","test":true,"contact":{"phone":"+919800000000"},"result":{"outcome":"Promise to pay"}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -i -X POST "https://your-server.example.com/ubiq/results" \
-H "Content-Type: application/json" \
-H "X-Ubiq-Signature: t=$T,v1=$SIG" \
--data "$BODY"Good practice
- Use a long random secret, e.g. the output of
openssl rand -hex 32. Keep it in an environment variable, not in code. - Retries reuse the same signature and timestamp. All attempts finish within about a minute, so a 5-minute tolerance accepts them — see Errors & retries.
- To rotate the secret, update your server to accept both the old and new secret, change it in the step, then remove the old one.
- Keep your server's clock in sync (NTP), or valid requests will fail the timestamp check.