Signatures
Anyone can send a request to your endpoint’s URL. Check every request’s signature before you act on it: it proves the request is ours and that its body is as we sent it.
The PSP-Signature header
Section titled “The PSP-Signature header”Every request carries a PSP-Signature header:
PSP-Signature: t=1760000000,v1=0ea76beaa2f76da27cbc5947a9772534287156d02b13d0ebb0d290a593cb9ecdtis when we signed the request, in seconds since the Unix epoch. Each attempt is signed anew, so a retry has a newt.v1is the signature: HMAC-SHA256, in lowercase hex, oft, a.and the request’s raw body, with your endpoint’s signing secret, the wholewhsec_…string, as the key.- While you roll the secret in the portal (until its Webhooks page opens, your account manager rolls it for you), the newest old secret keeps signing until the time you chose has passed: the header then has one
v1for each secret in force, two at most. A request is ours if anyv1matches.
Verify a request
Section titled “Verify a request”- Take the body exactly as it came, before any JSON parsing: re-serialised JSON is other bytes.
- Split the header on
,, each part on its first=: keeptand everyv1. - Compute the HMAC-SHA256 of
t + "." + bodywith your secret, and compare it with eachv1in constant time. - Refuse a request whose
tis more than 5 minutes from your clock, so a captured request can’t be sent again later.
Answer a request that fails with 400 and don’t act on it. Each example below does the whole check and is tested against a delivery we sign:
package yourapp
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "strconv" "strings" "time")
// Tolerance is how old a delivery's timestamp may be: older ones are// refused, so a captured request can't be replayed later.const Tolerance = 5 * time.Minute
// Verify checks the PSP-Signature header of a webhook delivery against the// raw request body, exactly as received, and your endpoint's signing// secret (whsec_…). It returns nil when the body is ours and recent.func Verify(body []byte, header, secret string, now time.Time) error { var timestamp string var signatures []string for _, part := range strings.Split(header, ",") { key, value, ok := strings.Cut(strings.TrimSpace(part), "=") if !ok { continue } switch key { case "t": timestamp = value case "v1": signatures = append(signatures, value) } } t, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || len(signatures) == 0 { return errors.New("no timestamp or v1 signature in PSP-Signature") } if age := now.Sub(time.Unix(t, 0)); age > Tolerance || age < -Tolerance { return errors.New("timestamp outside the tolerance") } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(body) expected := mac.Sum(nil) // During a secret's roll the header has a v1 for each secret in force: // any one of them that matches is enough. for _, s := range signatures { got, err := hex.DecodeString(s) if err == nil && hmac.Equal(got, expected) { return nil } } return errors.New("no v1 signature matches")}import { Buffer } from "node:buffer";import crypto from "node:crypto";
// How old a delivery's timestamp may be, in seconds.const TOLERANCE = 300;
// Checks the PSP-Signature header of a webhook delivery against the raw// request body (a Buffer or string, exactly as received) and your// endpoint's signing secret (whsec_…). True when the body is ours and// recent.export function verify(body, header, secret, now = Math.floor(Date.now() / 1000)) { let timestamp = ""; const signatures = []; for (const part of header.split(",")) { const i = part.indexOf("="); if (i < 0) continue; const key = part.slice(0, i).trim(); const value = part.slice(i + 1).trim(); if (key === "t") timestamp = value; if (key === "v1") signatures.push(value); } if (!/^\d+$/.test(timestamp) || signatures.length === 0) return false; if (Math.abs(now - Number(timestamp)) > TOLERANCE) return false; const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest(); // During a secret's roll the header has a v1 for each secret in force: // any one of them that matches is enough. return signatures.some((s) => { const got = Buffer.from(s, "hex"); return got.length === expected.length && crypto.timingSafeEqual(got, expected); });}import hashlibimport hmacimport timefrom typing import Optional
# How old a delivery's timestamp may be, in seconds.TOLERANCE = 300
def verify(body: bytes, header: str, secret: str, now: Optional[int] = None) -> bool: """Checks the PSP-Signature header of a webhook delivery against the raw request body, exactly as received, and your endpoint's signing secret (whsec_...). True when the body is ours and recent.""" timestamp, signatures = "", [] for part in header.split(","): key, sep, value = part.strip().partition("=") if not sep: continue if key == "t": timestamp = value elif key == "v1": signatures.append(value) # ASCII digits only: isdigit() alone takes others, such as superscripts. if not (timestamp.isascii() and timestamp.isdigit()) or not signatures: return False now = int(time.time()) if now is None else now if abs(now - int(timestamp)) > TOLERANCE: return False expected = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).digest() # During a secret's roll the header has a v1 for each secret in force: # any one of them that matches is enough. for signature in signatures: try: got = bytes.fromhex(signature) except ValueError: continue # not hex: not a signature of ours if hmac.compare_digest(got, expected): return True return False<?php
// How old a delivery's timestamp may be, in seconds.const PSP_TOLERANCE = 300;
// Checks the PSP-Signature header of a webhook delivery against the raw// request body, exactly as received (file_get_contents('php://input')),// and your endpoint's signing secret (whsec_…). True when the body is// ours and recent.function psp_verify(string $body, string $header, string $secret, ?int $now = null): bool{ $timestamp = ''; $signatures = []; foreach (explode(',', $header) as $part) { $pair = explode('=', trim($part), 2); if (count($pair) !== 2) { continue; } if ($pair[0] === 't') { $timestamp = $pair[1]; } elseif ($pair[0] === 'v1') { $signatures[] = $pair[1]; } } if (!ctype_digit($timestamp) || count($signatures) === 0) { return false; } if (abs(($now ?? time()) - (int) $timestamp) > PSP_TOLERANCE) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret); // During a secret's roll the header has a v1 for each secret in force: // any one of them that matches is enough. foreach ($signatures as $signature) { if (hash_equals($expected, strtolower($signature))) { return true; } } return false;}import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.util.ArrayList;import java.util.HexFormat;import java.util.List;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
public final class PspSignature { // How old a delivery's timestamp may be, in seconds. static final long TOLERANCE = 300;
// Checks the PSP-Signature header of a webhook delivery against the raw // request body, exactly as received, and your endpoint's signing secret // (whsec_…). True when the body is ours and recent. public static boolean verify(byte[] body, String header, String secret, long now) throws Exception { String timestamp = ""; List<String> signatures = new ArrayList<>(); for (String part : header.split(",")) { String[] pair = part.trim().split("=", 2); if (pair.length != 2) continue; if (pair[0].equals("t")) timestamp = pair[1]; if (pair[0].equals("v1")) signatures.add(pair[1]); } // At most 18 digits: a timestamp that long fits a long, and a longer // one is no time of ours. if (!timestamp.matches("\\d{1,18}") || signatures.isEmpty()) return false; if (Math.abs(now - Long.parseLong(timestamp)) > TOLERANCE) return false; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); byte[] expected = mac.doFinal(body); // During a secret's roll the header has a v1 for each secret in force: // any one of them that matches is enough. for (String signature : signatures) { try { if (MessageDigest.isEqual(HexFormat.of().parseHex(signature), expected)) return true; } catch (IllegalArgumentException notHex) { // Not a signature of ours. } } return false; }
public static boolean verify(byte[] body, String header, String secret) throws Exception { return verify(body, header, secret, System.currentTimeMillis() / 1000); }}Check your code
Section titled “Check your code”This request is signed with whsec_8Kq3c2Vb7ZpXyN1mT4rLs6Hd9Jf0GwQe, and with an older secret in the same header, at t = 1760000000: your code should take it at that time, and refuse it with one byte of the body changed.
PSP-Signature: t=1760000000,v1=06309820443c3a8d292dcd2956f6151b52cf7aa4df2f212f6391b36eab245adf,v1=0ea76beaa2f76da27cbc5947a9772534287156d02b13d0ebb0d290a593cb9ecd{"id":"evt_in_01K7XAMPLE00000000000000","object":"event","type":"payment.succeeded","api_version":"2026-10-14.acacia","created":1760000000,"livemode":false,"account":"macc_01K7XAMPLE00000000000000","data":{"object":{"id":"pay_in_01K7XAMPLE00000000000000","object":"payment","amount":49900,"currency":"INR","status":"succeeded"},"previous_attributes":{"status":"processing"}},"request":null,"pending_webhooks":1}Keep the secret on your server only, never in a browser or an app. If it leaks, roll it at once, with no overlap: the old secret stops straight away. Until the portal’s Webhooks page opens, ask your account manager.