Verifying webhook signatures¶
Prove that a request to your endpoint really came from Gerege, that its body was not changed on the way, and that it is not an old request played again. With an SDK it is one line; without one, copy one of the 25-line functions below.
- Time: 15 minutes (including checking against the test vectors).
- You need: the webhook secret (
whsec_…, see Overview) and access to your endpoint's raw request body.
Never fulfil anything on an unverified webhook
Anybody can POST JSON to your URL. If the signature does not match, or the header is missing, answer 400 and
do nothing.
1. Headers¶
| Header | Value |
|---|---|
Gerege-Signature |
t=<unix seconds>,v1=<hex>[,v1=<hex>] |
OpenPay-Signature |
Exactly the same value as Gerege-Signature (the OMAS standard's name) |
Gerege-Event-Id · OpenPay-Event-Id |
The event's id (evt_…), the same as the body's id |
Content-Type |
application/json |
User-Agent |
GeregeApp-Webhooks/1 |
Read Gerege-Signature, falling back to OpenPay-Signature. For example:
Gerege-Signature: t=1792659600,v1=01ecc504ae234412067d656d62f44e6358f4d0871b7dc6c63a66885fc25b5f50,v1=e4a4a3fe050cfd0b6a23062c60cd28882e497aebe6639600345e55adbaf0cc57
t— when this attempt was sent. Every retry has a newtand is signed afresh; the body never changes.v1— one per secret in force for that endpoint. Normally one; for 24 hours after a secret rotation, two (the new one first, then the old). Every endpoint has its own secret — one event sent to two endpoints carries two different signatures (Overview).- Ignore keys you do not know (
v0,v2, …); spaces after the commas are tolerated.
2. The algorithm¶
signed_payload = t + "." + raw_body
expected = lowercase_hex( HMAC-SHA256( key = webhook_secret, message = signed_payload ) )
- Take
tand everyv1from the header. Refuse iftis missing or not a number, or there is nov1. - Refuse unless
|now − t| ≤ 300seconds (both directions, the bound included) — replay protection. Keep your server's clock on NTP. - Compute
expectedwith each secret you accept. The key is the whole string including thewhsec_prefix, as UTF-8 bytes (do not base64-decode it). - Accept if any
v1equals anyexpectedunder a constant-time comparison. Never==orstrcmp. - Only then parse the body as JSON and deduplicate on
id(Idempotent handler).
Details that matter:
- The raw body. Re-serialising what your framework parsed (
JSON.stringify,json.dumps) will not match: Gerege's sender writes<,>and&as\u003c,\u003e,\u0026, and the key order and spacing are not yours to reproduce. Take the body as bytes. - Lower-case hex. The platform always writes lower case; the reference verifier refuses an upper-case
v1— so should you. - The list of secrets you accept: normally
[current], during a rotation[new, old].
3. Code¶
With an SDK:
import express from "express";
import { verifyWebhook, signatureFromHeaders, GeregeWebhookError } from "@gerege/miniapp-node";
const secrets = process.env.GEREGE_WEBHOOK_SECRETS!.split(","); // during a rotation "new,old"
app.post("/webhooks/gerege", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = verifyWebhook(req.body, signatureFromHeaders(req.headers), secrets);
queue.add(event); // dedupe on id, then do the work
res.sendStatus(204);
} catch (err) {
if (err instanceof GeregeWebhookError) return res.sendStatus(400); // err.reason
throw err;
}
});
import miniapp "github.com/gerege-systems/app-gerege-nexus/sdk/go"
secrets := strings.Split(os.Getenv("GEREGE_WEBHOOK_SECRETS"), ",")
http.HandleFunc("POST /webhooks/gerege", func(w http.ResponseWriter, r *http.Request) {
ev, _, err := miniapp.VerifyRequest(r, secrets, time.Now())
if err != nil {
http.Error(w, "invalid webhook", http.StatusBadRequest)
return
}
enqueue(ev) // dedupe on ev.ID
w.WriteHeader(http.StatusNoContent)
})
Without an SDK — each function below decides all 20 cases of webhook.verification in sdk/testdata/vectors.json
correctly (Node, Go and Python were run against them; PHP follows the same logic):
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: Buffer (express.raw), header: Gerege-Signature, secrets: string[]
export function verifyGerege(rawBody, header, secrets, now = Math.floor(Date.now() / 1000), tolerance = 300) {
let t = -1;
const sigs = [];
for (const part of String(header ?? "").split(",")) {
const p = part.trim();
const i = p.indexOf("=");
const key = i < 0 ? p : p.slice(0, i);
const value = i < 0 ? "" : p.slice(i + 1);
if (key === "t" && /^[0-9]+$/.test(value)) t = Number(value);
else if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) sigs.push(Buffer.from(value, "hex"));
}
if (t < 0 || sigs.length === 0) return false;
if (Math.abs(now - t) > tolerance) return false;
let ok = false;
for (const secret of secrets) {
const want = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
for (const sig of sigs) ok = timingSafeEqual(sig, want) || ok; // no early exit
}
return ok;
}
func Verify(body []byte, header string, secrets []string, now time.Time) bool {
t := int64(-1)
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
switch k {
case "t":
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n >= 0 {
t = n
}
case "v1":
sigs = append(sigs, v)
}
}
if t < 0 || len(sigs) == 0 {
return false
}
if d := now.Sub(time.Unix(t, 0)); d > 5*time.Minute || d < -5*time.Minute {
return false
}
ok := false
for _, s := range secrets {
m := hmac.New(sha256.New, []byte(s))
fmt.Fprintf(m, "%d.", t)
m.Write(body)
want := []byte(hex.EncodeToString(m.Sum(nil)))
for _, sig := range sigs {
ok = hmac.Equal([]byte(sig), want) || ok
}
}
return ok
}
import hashlib, hmac, re, time
def verify(raw_body: bytes, header: str, secrets: list[str], now: int | None = None, tolerance: int = 300) -> bool:
t, sigs = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and re.fullmatch(r"[0-9]+", value):
t = int(value)
elif key == "v1":
sigs.append(value.encode())
if t is None or not sigs:
return False
now = int(time.time()) if now is None else now
if abs(now - t) > tolerance:
return False
ok = False
for secret in secrets:
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest().encode()
for sig in sigs:
ok = hmac.compare_digest(sig, want) or ok
return ok
# Flask: verify(request.get_data(), request.headers.get("Gerege-Signature", ""), secrets)
# Django: verify(request.body, request.headers.get("Gerege-Signature", ""), secrets)
<?php
function gerege_verify(string $rawBody, string $header, array $secrets, ?int $now = null, int $tolerance = 300): bool
{
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$t = (int) $value;
} elseif ($key === 'v1') {
$sigs[] = $value;
}
}
if ($t === null || !$sigs) {
return false;
}
$now ??= time();
if (abs($now - $t) > $tolerance) {
return false;
}
$ok = false;
foreach ($secrets as $secret) {
$want = hash_hmac('sha256', $t . '.' . $rawBody, $secret); // lower-case hex
foreach ($sigs as $sig) {
$ok = hash_equals($want, $sig) || $ok;
}
}
return $ok;
}
$raw = file_get_contents('php://input'); // not $_POST — the raw body
$header = $_SERVER['HTTP_GEREGE_SIGNATURE'] ?? ($_SERVER['HTTP_OPENPAY_SIGNATURE'] ?? '');
if (!gerege_verify($raw, $header, explode(',', getenv('GEREGE_WEBHOOK_SECRETS')))) {
http_response_code(400);
exit;
}
Java and C# — Other languages.
4. A worked example¶
The first vector in sdk/testdata/vectors.json (payment_intent.succeeded, one secret). The secret is a test
value, not a real one:
| Value | |
|---|---|
| secret | whsec_R2VyZWdlLXZlY3Rvci1jdXJyZW50LXNlY3JldC0wMDAwMDE |
t |
1792659600 (2026-10-22T09:00:00Z) |
signed_payload |
1792659600. + the body (exactly the bytes in the file, with \u003croaming \u0026 data\u003e) |
v1 |
aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821 |
| header | t=1792659600,v1=aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821 |
Checking it in a shell (body.json holds the vector's body string, without a trailing newline):
printf '%s' "1792659600.$(cat body.json)" \
| openssl dgst -sha256 -hmac 'whsec_R2VyZWdlLXZlY3Rvci1jdXJyZW50LXNlY3JldC0wMDAwMDE' -hex
# … aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821
5. Test vectors¶
vectors.json is generated from the platform's own signing code (never edited by hand) and ships with the SDKs. It
has two parts:
webhook.signing[]— each event's real body, its secret(s),t, thesigned_payloadand the expectedsignaturesandheader. Use it to check your own signing function (for simulating webhooks in your tests).webhook.verification[]—{name, secrets, header, body, now, valid}: 20 cases — the window's edges (exactly ±300 s valid, ±301 invalid), a changed body, a re-encoded body, a wrong secret, the twov1of a rotation (a receiver with only the old or only the new secret), spaces after commas, unknown keys, upper-case hex (invalid), nov1, not, a non-numerict, a signature over anothert, an empty header.
Run your implementation against all of them:
import json
cases = json.load(open("vectors.json"))["webhook"]["verification"]
for c in cases:
got = verify(c["body"].encode(), c["header"], c["secrets"], c["now"])
assert got == c["valid"], c["name"]
For now the file is distributed with the SDKs (private until they are published) — ask Support if you need it, or start from the worked example above.