Verify callback signatures
Use the signing secret from registration to check that every event comes from us.
On this page
Every event carries a Chenvoya-Signature header. Verify the signature before you read the body; when verification fails, answer with a non-2xx status and drop the request.
Signature format
Chenvoya-Signature: t=1793167400,v1=<64 lowercase hexadecimal digits>t is the signing time in Unix seconds and v1 is a lowercase hexadecimal HMAC-SHA256.
Steps
- Base64url-decode the
signing_secretfrom registration to get the 32-byte verification key. - Split
tandv1out ofChenvoya-Signature. - Reject
twhen it differs from your clock by more than 5 minutes. - Compute HMAC-SHA256 with the verification key over
chenvoya.v1.,t, a dot.and the original body bytes. - Write the result as lowercase hexadecimal and compare it with
v1in constant time. - Then deduplicate on
event_idin the body, and reject events whosecreated_atis more than 7 days and 5 minutes old or more than 5 minutes in the future.
Sample code
import base64
import hashlib
import hmac
import time
def verify(signing_secret, header, body):
fields = {}
for item in header.split(","):
name, _, value = item.strip().partition("=")
fields[name] = value
t, v1 = fields.get("t", ""), fields.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
key = base64.urlsafe_b64decode(signing_secret + "=" * (-len(signing_secret) % 4))
signed = b"chenvoya.v1." + t.encode() + b"." + body
expected = hmac.new(key, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)// Needs Node.js 20 or later; save it as a .mjs file and run it.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(signingSecret, header, body) {
const fields = {};
for (const item of header.split(",")) {
const at = item.indexOf("=");
if (at > 0) fields[item.slice(0, at).trim()] = item.slice(at + 1);
}
const t = fields.t ?? "", v1 = fields.v1 ?? "";
if (!/^[0-9]+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const key = Buffer.from(signingSecret, "base64url");
const expected = createHmac("sha256", key).update("chenvoya.v1." + t + ".").update(body).digest("hex");
return v1.length === expected.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}package callback
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strconv"
"strings"
"time"
)
// Verify reports whether body carries a valid Chenvoya-Signature header.
func Verify(signingSecret, header string, body []byte) bool {
fields := map[string]string{}
for _, item := range strings.Split(header, ",") {
name, value, _ := strings.Cut(strings.TrimSpace(item), "=")
fields[name] = value
}
t, err := strconv.ParseInt(fields["t"], 10, 64)
if err != nil || t < 0 || time.Since(time.Unix(t, 0)).Abs() > 5*time.Minute {
return false
}
key, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(signingSecret, "="))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte("chenvoya.v1." + fields["t"] + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(fields["v1"]))
}Checklist
- Sign the original bytes you received; do not parse and re-encode the JSON first.
- Compare in constant time, never with plain string equality.
- Keep your server clock accurate, or the 5-minute window rejects good events.
- Deduplicate on
event_idand keep processed event IDs for at least 7 days and 5 minutes. - Keep the signing secret on your servers, like an API key.