Skip to content

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 new t and 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 ) )
  1. Take t and every v1 from the header. Refuse if t is missing or not a number, or there is no v1.
  2. Refuse unless |now − t| ≤ 300 seconds (both directions, the bound included) — replay protection. Keep your server's clock on NTP.
  3. Compute expected with each secret you accept. The key is the whole string including the whsec_ prefix, as UTF-8 bytes (do not base64-decode it).
  4. Accept if any v1 equals any expected under a constant-time comparison. Never == or strcmp.
  5. 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, the signed_payload and the expected signatures and header. 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 two v1 of a rotation (a receiver with only the old or only the new secret), spaces after commas, unknown keys, upper-case hex (invalid), no v1, no t, a non-numeric t, a signature over another t, 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.