Verify signatures
Check that a request really came from Cueston and wasn't replayed.
Every request has a Cueston-Signature header:
Text
Cueston-Signature: t=1793021160,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis the Unix time the request was signed.v1is the hex HMAC-SHA256 oft + "." + rawBody, keyed with your endpoint's secret (the wholewhsec_…string, as UTF-8).
To verify:
- Take the body exactly as received, before parsing JSON.
- Compute the HMAC of
`${t}.${rawBody}`with your secret. - Compare it, in constant time, with each
v1value. One match is enough. - Reject timestamps more than 5 minutes from your clock, so an old request can't be replayed.
JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string or Buffer, before JSON parsing).
export function verifyCuestonSignature(header, rawBody, secret = process.env.CUESTON_WEBHOOK_SECRET) {
const items = header.split(",").map((part) => part.split("="));
const t = Number(items.find(([key]) => key === "t")?.[1]);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // reject replays older than 5 minutes
const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"));
return items.some(([key, value]) => key === "v1" && value?.length === 64 && timingSafeEqual(Buffer.from(value), expected));
}Python
import hashlib
import hmac
import os
import time
def verify_cueston_signature(header: str, raw_body: bytes) -> bool:
secret = os.environ["CUESTON_WEBHOOK_SECRET"].encode()
items = [p.split("=", 1) for p in header.split(",")]
t = next((v for k, v in items if k == "t"), None)
if not t or abs(time.time() - int(t)) > 300: # reject replays older than 5 minutes
return False
expected = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v, expected) for k, v in items if k == "v1")Ruby
require "openssl"
def verify_cueston_signature(header, raw_body)
items = header.split(",").map { |p| p.split("=", 2) }
t = items.find { |k, _| k == "t" }&.last
return false if t.nil? || (Time.now.to_i - t.to_i).abs > 300 # reject replays older than 5 minutes
expected = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("CUESTON_WEBHOOK_SECRET"), "#{t}.#{raw_body}")
items.any? { |k, v| k == "v1" && Rack::Utils.secure_compare(v, expected) }
endPHP
<?php
function verify_cueston_signature(string $header, string $rawBody): bool {
$t = null; $signatures = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
if ($k === 't') $t = (int) $v;
if ($k === 'v1') $signatures[] = $v;
}
if (!$t || abs(time() - $t) > 300) return false; // reject replays older than 5 minutes
$expected = hash_hmac('sha256', $t . '.' . $rawBody, getenv('CUESTON_WEBHOOK_SECRET'));
foreach ($signatures as $s) if (hash_equals($expected, $s)) return true;
return false;
}Rotating the secret
Rotate an endpoint's secret in Settings or with POST /api/webhooks/{id}/rotate-secret. The new secret is shown once. For the next 24 hours each request carries two v1 signatures, one with the new secret and one with the old, so your server verifies with either while you deploy the new one. Pass { "expirePrevious": true } to stop signing with the old secret right away.