# Verify callback signatures Use the signing secret from registration to check that every event comes from us. 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 ```text 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 1. Base64url-decode the `signing_secret` from registration to get the 32-byte verification key. 2. Split `t` and `v1` out of `Chenvoya-Signature`. 3. Reject `t` when it differs from your clock by more than 5 minutes. 4. Compute HMAC-SHA256 with the verification key over `chenvoya.v1.`, `t`, a dot `.` and the original body bytes. 5. Write the result as lowercase hexadecimal and compare it with `v1` in constant time. 6. Then deduplicate on `event_id` in the body, and reject events whose `created_at` is more than 7 days and 5 minutes old or more than 5 minutes in the future. ## Sample code ```python 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) ``` ```javascript // 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)); } ``` ```go 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_id` and keep processed event IDs for at least 7 days and 5 minutes. - Keep the signing secret on your servers, like an API key. ## Next steps - [Receive callbacks](./api-docs-en-callbacks.md): After you register a callback address, we send it signed events when a task finishes and when its charge is settled. - [Receive a task completion notification](./api-docs-en-api-events-task-completed.md): Chenvoya sends task.completed when a task reaches succeeded, failed or cancelled.