Skip to content
Last updated

Webhook Signing

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.

Verification steps

  1. Capture the raw request body exactly as received.
  2. Parse t and s from the X-Percents-Signature header.
  3. 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.
  4. Compute HMAC-SHA256 with the signing token over <t>.<raw body>.
  5. Compare signatures with a constant-time comparison.
  6. Deduplicate the verified webhookId before applying side effects.

TypeScript (Node.js)

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);
}

Java 17+

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;
    }
  }
}

Go

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)
}

Python 3

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.