Verifying signatures
Every webhook delivery includes a Paylera-Signature header. Verify it
against the signing secret you got when you registered the endpoint.
Reject the request if verification fails.
The header
Paylera-Signature: t=1736179200,v1=2dab…1c3a,v1=8f4a…b2e7| Token | Meaning |
|---|---|
t | Unix epoch seconds at which the delivery was prepared. |
v1 | One or more HMAC-SHA-256 signatures over t.{raw body} using a current signing secret. |
Multiple v1 entries support secret rotation: during a rotation
window, both the old and new secret produce signatures. Verify against
each known secret; accept if any match.
The recipe
- Read the raw request body as bytes — do not parse, re-serialise, or whitespace-normalise it.
- Read
Paylera-Signatureand split intotandv1values. - Compute
expected = HMAC_SHA256(secret, "{t}." + raw_body). - For each
v1, compare in constant time toexpected. Accept if any match. - Reject if
|now - t| > 5 minutes(replay protection). - Reject if no
v1matches.
Python (no dependencies beyond stdlib)
import hmac, hashlib, time
def verify(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts["t"]) if abs(time.time() - t) > tolerance: return False expected = hmac.new( secret.encode("utf-8"), f"{t}.".encode("utf-8") + payload, hashlib.sha256, ).hexdigest() sigs = [v for k, v in (p.split("=", 1) for p in header.split(",")) if k == "v1"] return any(hmac.compare_digest(expected, s) for s in sigs)Node / TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(payload: Buffer, header: string, secret: string, toleranceSec = 300): boolean { const parts = Object.fromEntries( header.split(",").map((p) => p.split("=", 2) as [string, string]), ); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret) .update(`${t}.`) .update(payload) .digest();
const sigs = header .split(",") .filter((p) => p.startsWith("v1=")) .map((p) => Buffer.from(p.slice(3), "hex"));
return sigs.some( (s) => s.length === expected.length && timingSafeEqual(s, expected), );}Go
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "math" "strings" "time")
func Verify(payload []byte, header, secret string, tolerance time.Duration) bool { parts := map[string][]string{} for _, p := range strings.Split(header, ",") { kv := strings.SplitN(p, "=", 2) if len(kv) == 2 { parts[kv[0]] = append(parts[kv[0]], kv[1]) } } t, err := time.Parse("1136239445", parts["t"][0]) if err != nil { return false } if math.Abs(time.Since(t).Seconds()) > tolerance.Seconds() { return false }
h := hmac.New(sha256.New, []byte(secret)) h.Write([]byte(parts["t"][0] + ".")) h.Write(payload) expected := h.Sum(nil)
for _, sig := range parts["v1"] { b, err := hex.DecodeString(sig) if err != nil { continue } if hmac.Equal(b, expected) { return true } } return false}C# / .NET
using System.Security.Cryptography;using System.Text;
public static bool Verify(byte[] payload, string header, string secret, TimeSpan tolerance){ var parts = header.Split(',') .Select(p => p.Split('=', 2)) .Where(kv => kv.Length == 2) .ToLookup(kv => kv[0], kv => kv[1]);
if (!long.TryParse(parts["t"].FirstOrDefault(), out var t)) return false; var ts = DateTimeOffset.FromUnixTimeSeconds(t); if ((DateTimeOffset.UtcNow - ts).Duration() > tolerance) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var prefix = Encoding.UTF8.GetBytes($"{t}."); hmac.TransformBlock(prefix, 0, prefix.Length, null, 0); hmac.TransformFinalBlock(payload, 0, payload.Length); var expected = hmac.Hash!;
foreach (var sig in parts["v1"]) { var bytes = Convert.FromHexString(sig); if (CryptographicOperations.FixedTimeEquals(bytes, expected)) return true; } return false;}Why a raw body matters
JSON parsers reorder keys, normalise whitespace, and lose precision on large numbers. Any of those changes the bytes the HMAC ran over, and the signature won’t match. Many web frameworks parse JSON before your handler sees it — make sure you’re getting the original bytes:
- Express:
app.use(express.raw({ type: "application/json" })). - Fastify: register a custom content-type parser that hands you the buffer.
- Django:
request.bodyis the raw bytes. - ASP.NET:
await request.Body.ReadAsync(...)before any model binding; or use a custom binder.
Rotating the secret
POST /v1/admin/webhook-endpoints/{id}/rotate-secretReturns the new secret. Both the old and new secrets sign deliveries for 24 hours; after that, only the new one. Update your handler to verify against both during the rotation window.
What to do on verification failure
- Reject with 4xx, not 5xx — Paylera will not retry a 4xx. (5xx triggers the retry schedule.)
- Don’t process the body. Treat the request as if it never happened.
- Log and alert. A burst of failures may signal a leaked secret or a misconfigured endpoint.