Skip to content

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
TokenMeaning
tUnix epoch seconds at which the delivery was prepared.
v1One 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

  1. Read the raw request body as bytes — do not parse, re-serialise, or whitespace-normalise it.
  2. Read Paylera-Signature and split into t and v1 values.
  3. Compute expected = HMAC_SHA256(secret, "{t}." + raw_body).
  4. For each v1, compare in constant time to expected. Accept if any match.
  5. Reject if |now - t| > 5 minutes (replay protection).
  6. Reject if no v1 matches.

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.body is 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-secret

Returns 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.