Every webhook includes:
X-Percents-Signature: t=<epoch-milliseconds>,s=<hex-hmac-sha256>Percents computes HMAC-SHA256 over <timestamp>.<raw request body> using the recipient entity's sign_ webhook signing token. File results are delivered to the webhook destination configured for your issuer's entity. Preserve the request body exactly as received; do not parse and reserialize JSON before verification.
- Capture the raw request body exactly as received.
- Parse
tandsfrom theX-Percents-Signatureheader. - Reject timestamps more than five minutes old or more than one minute in the future. Keep the receiver's clock synchronized; each retry is signed with a fresh delivery timestamp.
- Compute HMAC-SHA256 with the signing token over
<t>.<raw body>. - Compare signatures with a constant-time comparison.
- Deduplicate the verified
webhookIdbefore applying side effects.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyPercentsSignature(input: {
header: string;
rawBody: Buffer;
signingToken: string;
}): boolean {
const match = /^t=(\d+),s=([a-f0-9]{64})$/i.exec(input.header);
if (!match) {
return false;
}
const [, timestamp, receivedHex] = match;
const timestampMs = Number(timestamp);
const nowMs = Date.now();
if (!Number.isSafeInteger(timestampMs) || timestampMs < nowMs - 300_000 || timestampMs > nowMs + 60_000) {
return false;
}
const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), input.rawBody]);
const expected = createHmac('sha256', input.signingToken).update(signedPayload).digest();
const received = Buffer.from(receivedHex, 'hex');
return expected.length === received.length && timingSafeEqual(expected, received);
}import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class PercentsWebhookSignature {
private static final Pattern SIGNATURE = Pattern.compile("^t=(\\d+),s=([a-fA-F0-9]{64})$");
public static boolean verify(String header, byte[] rawBody, String signingToken)
throws GeneralSecurityException {
Matcher match = SIGNATURE.matcher(header);
if (!match.matches()) {
return false;
}
long timestampMs;
try {
timestampMs = Long.parseLong(match.group(1));
} catch (NumberFormatException exception) {
return false;
}
long nowMs = System.currentTimeMillis();
if (timestampMs < nowMs - 300_000 || timestampMs > nowMs + 60_000) {
return false;
}
byte[] timestampPrefix = (match.group(1) + ".").getBytes(StandardCharsets.UTF_8);
byte[] payload = new byte[timestampPrefix.length + rawBody.length];
System.arraycopy(timestampPrefix, 0, payload, 0, timestampPrefix.length);
System.arraycopy(rawBody, 0, payload, timestampPrefix.length, rawBody.length);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingToken.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal(payload);
try {
byte[] received = HexFormat.of().parseHex(match.group(2));
return MessageDigest.isEqual(expected, received);
} catch (IllegalArgumentException exception) {
return false;
}
}
}package percentswebhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
var signaturePattern = regexp.MustCompile(`^t=(\d+),s=([a-fA-F0-9]{64})$`)
func VerifyPercentsSignature(header string, rawBody []byte, signingToken string) bool {
match := signaturePattern.FindStringSubmatch(header)
if match == nil {
return false
}
timestampMs, err := strconv.ParseInt(match[1], 10, 64)
if err != nil {
return false
}
nowMs := time.Now().UnixMilli()
if timestampMs < nowMs-300_000 || timestampMs > nowMs+60_000 {
return false
}
payload := append([]byte(match[1]+"."), rawBody...)
mac := hmac.New(sha256.New, []byte(signingToken))
_, _ = mac.Write(payload)
expected := mac.Sum(nil)
received, err := hex.DecodeString(match[2])
if err != nil {
return false
}
return hmac.Equal(expected, received)
}import hashlib
import hmac
import re
import time
SIGNATURE_PATTERN = re.compile(r"^t=(\d+),s=([a-fA-F0-9]{64})$")
def verify_percents_signature(header: str, raw_body: bytes, signing_token: str) -> bool:
match = SIGNATURE_PATTERN.fullmatch(header)
if match is None:
return False
timestamp, received_hex = match.groups()
try:
timestamp_ms = int(timestamp)
except ValueError:
return False
now_ms = time.time_ns() // 1_000_000
if timestamp_ms < now_ms - 300_000 or timestamp_ms > now_ms + 60_000:
return False
payload = timestamp.encode("utf-8") + b"." + raw_body
expected = hmac.new(
signing_token.encode("utf-8"), payload, hashlib.sha256
).digest()
try:
received = bytes.fromhex(received_hex)
except ValueError:
return False
return hmac.compare_digest(expected, received)Store the sign_ token separately from the api_ API secret. Contact Percents to rotate it if exposed.