Verifying signatures
Every request to a Webhook is signed with that Webhook’s signing secret. Check the signature before your endpoint acts on a request: a signature that matches proves the request came from us, and that its body was not changed on the way.
The signing secret is shown once, when you add the Webhook. Webhooks says where to find it, and how to replace it.
How a request is signed
Section titled “How a request is signed”The x-uup-signature header holds two parts, separated by a comma: t=…,v1=….
tis when the request was sent, as a Unix time in seconds. It is the only part of a request that changes when we send one message again.v1is the signature: an HMAC-SHA256 oft, a dot and the body, keyed with your signing secret, written in lower-case hex.
To check a request:
- Read
tandv1from the header. - Refuse the request when
tis more than 5 minutes away from your own clock. Someone who copies a request cannot send it to you again later. - Make the HMAC-SHA256 of
t, a dot and the body, with your signing secret as the key. - Compare it with
v1in constant time. Accept the request only when they match.
Use the body exactly as it arrived. Parsing the JSON and writing it out again changes its bytes, and the signature no longer matches.
Examples
Section titled “Examples”Each example reads the secret from UUP_SIGNING_SECRET, and answers 401 to a request whose signature does not match.
Node
import { createHmac, timingSafeEqual } from "node:crypto";
// Refuse a request signed more than 300 seconds ago.const MAX_AGE_SECONDS = 300;
/** * Whether a request came from uup and was not changed on the way. * * rawBody: the request body exactly as it arrived, before any JSON parsing. * signatureHeader: the value of the x-uup-signature header. * secret: your Webhook's signing secret. */export function verifyWebhook(rawBody, signatureHeader, secret) { const parts = Object.fromEntries( String(signatureHeader ?? "") .split(",") .map((part) => part.trim().split("=")), ); const t = parts.t; const v1 = parts.v1; if (!t || !v1 || !/^\d+$/.test(t)) return false;
const age = Math.floor(Date.now() / 1000) - Number(t); if (Math.abs(age) > MAX_AGE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(v1); return a.length === b.length && timingSafeEqual(a, b);}
// With Express, keep the raw body for this route://// app.post("/uup", express.raw({ type: "application/json" }), (req, res) => {// const body = req.body.toString("utf8");// if (!verifyWebhook(body, req.get("x-uup-signature"), process.env.UUP_SIGNING_SECRET)) {// return res.sendStatus(401);// }// const payload = JSON.parse(body);// // ... act on payload.message, and skip a payload.id you have already handled.// res.sendStatus(200);// });Python
import hashlibimport hmacimport time
# Refuse a request signed more than 300 seconds ago.MAX_AGE_SECONDS = 300
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool: """Whether a request came from uup and was not changed on the way.
raw_body: the request body exactly as it arrived, before any JSON parsing. signature_header: the value of the x-uup-signature header. secret: your Webhook's signing secret. """ parts = dict( part.strip().split("=", 1) for part in (signature_header or "").split(",") if "=" in part ) t = parts.get("t", "") v1 = parts.get("v1", "") if not t.isdigit() or not v1: return False
if abs(int(time.time()) - int(t)) > MAX_AGE_SECONDS: return False
signed = t.encode("utf-8") + b"." + raw_body expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1)
# With Flask:## @app.post("/uup")# def uup():# body = request.get_data()# if not verify_webhook(body, request.headers.get("x-uup-signature", ""), os.environ["UUP_SIGNING_SECRET"]):# abort(401)# payload = json.loads(body)# # ... act on payload["message"], and skip a payload["id"] you have already handled.# return "", 200Go
package webhooks
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os" "strconv" "strings" "time")
// Refuse a request signed more than 300 seconds ago.const maxAgeSeconds = 300
// VerifyWebhook reports whether a request came from uup and was not changed// on the way. body is the request body exactly as it arrived, header the// value of the x-uup-signature header, and secret your Webhook's signing secret.func VerifyWebhook(body []byte, header string, secret string) bool { parts := map[string]string{} for _, part := range strings.Split(header, ",") { if key, value, ok := strings.Cut(strings.TrimSpace(part), "="); ok { parts[key] = value } } t, v1 := parts["t"], parts["v1"] signedAt, err := strconv.ParseInt(t, 10, 64) if err != nil || v1 == "" { return false }
age := time.Now().Unix() - signedAt if age > maxAgeSeconds || age < -maxAgeSeconds { return false }
mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + "." + string(body))) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(v1))}
// Handle answers uup's requests.func Handle(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "unreadable body", http.StatusBadRequest) return } if !VerifyWebhook(body, r.Header.Get("x-uup-signature"), os.Getenv("UUP_SIGNING_SECRET")) { w.WriteHeader(http.StatusUnauthorized) return } // ... decode body, act on its message, and skip an id you have already handled. w.WriteHeader(http.StatusOK)}PHP
<?php
// Refuse a request signed more than 300 seconds ago.const MAX_AGE_SECONDS = 300;
/** * Whether a request came from uup and was not changed on the way. * * $body: the request body exactly as it arrived, before any JSON parsing. * $header: the value of the x-uup-signature header. * $secret: your Webhook's signing secret. */function verify_webhook(string $body, string $header, string $secret): bool{ $parts = []; foreach (explode(',', $header) as $part) { $pair = explode('=', trim($part), 2); if (count($pair) === 2) { $parts[$pair[0]] = $pair[1]; } } $t = $parts['t'] ?? ''; $v1 = $parts['v1'] ?? ''; if (!ctype_digit($t) || $v1 === '') { return false; }
if (abs(time() - (int) $t) > MAX_AGE_SECONDS) { return false; }
$expected = hash_hmac('sha256', $t . '.' . $body, $secret); return hash_equals($expected, $v1);}
$body = file_get_contents('php://input');$header = $_SERVER['HTTP_X_UUP_SIGNATURE'] ?? '';
if (!verify_webhook($body, $header, getenv('UUP_SIGNING_SECRET'))) { http_response_code(401); exit;}
$payload = json_decode($body, true);// ... act on $payload['message'], and skip a $payload['id'] you have already handled.http_response_code(200);When the signature does not match
Section titled “When the signature does not match”- Give your endpoint the newest secret. After New secret, the old one stops working at once.
- Check the raw body, not the parsed JSON.
- Check your server’s clock.
Your endpoint’s 401 counts toward turning the Webhook off, as Webhooks explains, so give it the right secret soon. Then choose Send test in the Webhook’s menu to see that it accepts a request.
The fields of the body you have just checked are listed in the Payload reference.