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.result from 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=9c4b1f0e3d2a7b6c5e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d
  • t — 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

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Split the header on , and each part on the first = to get t and v1.
  3. Reject the request if t is more than 5 minutes from your server's clock.
  4. Compute HMAC-SHA256 of t + "." + raw_body with your secret, as hex.
  5. Compare it to v1 with a constant-time comparison. If they differ, respond 401 and 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 "", 200

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