{"templateId":"markdown","versions":[{"version":"1.0","label":"v1.0","link":"/omni-api/overview/webhooks/signatures","default":true,"active":true,"folderId":"6bec560c"}],"sharedDataIds":{"sidebar":"sidebar-omni-api/@1.0/overview/sidebars.yaml"},"props":{"metadata":{"markdoc":{"tagList":[]},"type":"markdown"},"seo":{"title":"Webhook Signing","llmstxt":{"hide":false,"sections":[{"title":"Table of contents","includeFiles":["**/*"],"excludeFiles":[]}],"excludeFiles":[]}},"dynamicMarkdocComponents":[],"compilationErrors":[],"ast":{"$$mdtype":"Tag","name":"article","attributes":{},"children":[{"$$mdtype":"Tag","name":"Heading","attributes":{"level":1,"id":"webhook-signing","__idx":0},"children":["Webhook Signing"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Every webhook includes:"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"http","header":{"controls":{"copy":{}}},"source":"X-Percents-Signature: t=<epoch-milliseconds>,s=<hex-hmac-sha256>\n","lang":"http"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Percents computes HMAC-SHA256 over ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["<timestamp>.<raw request body>"]}," using the issuer's ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["sign_"]}," webhook signing token. Preserve the request body exactly as received; do not parse and reserialize JSON before verification."]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"verification-steps","__idx":1},"children":["Verification steps"]},{"$$mdtype":"Tag","name":"ol","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Capture the raw request body exactly as received."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Parse ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["t"]}," and ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["s"]}," from the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["X-Percents-Signature"]}," header."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Reject stale timestamps according to your replay policy."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Compute HMAC-SHA256 with the signing token over ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["<t>.<raw body>"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Compare signatures with a constant-time comparison."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Deduplicate the verified ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["webhookId"]}," before applying side effects."]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"typescript-nodejs","__idx":2},"children":["TypeScript (Node.js)"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"typescript","header":{"controls":{"copy":{}}},"source":"import { createHmac, timingSafeEqual } from 'node:crypto';\n\nexport function verifyPercentsSignature(input: {\n  header: string;\n  rawBody: Buffer;\n  signingToken: string;\n}): boolean {\n  const match = /^t=(\\d+),s=([a-f0-9]+)$/i.exec(input.header);\n  if (!match) {\n    return false;\n  }\n\n  const [, timestamp, receivedHex] = match;\n  const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), input.rawBody]);\n  const expected = createHmac('sha256', input.signingToken).update(signedPayload).digest();\n  const received = Buffer.from(receivedHex, 'hex');\n\n  return expected.length === received.length && timingSafeEqual(expected, received);\n}\n","lang":"typescript"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"java-17","__idx":3},"children":["Java 17+"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"java","header":{"controls":{"copy":{}}},"source":"import java.nio.charset.StandardCharsets;\nimport java.security.GeneralSecurityException;\nimport java.security.MessageDigest;\nimport java.util.HexFormat;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\n\npublic final class PercentsWebhookSignature {\n  private static final Pattern SIGNATURE = Pattern.compile(\"^t=(\\\\d+),s=([a-fA-F0-9]+)$\");\n\n  public static boolean verify(String header, byte[] rawBody, String signingToken)\n      throws GeneralSecurityException {\n    Matcher match = SIGNATURE.matcher(header);\n    if (!match.matches()) {\n      return false;\n    }\n\n    byte[] timestampPrefix = (match.group(1) + \".\").getBytes(StandardCharsets.UTF_8);\n    byte[] payload = new byte[timestampPrefix.length + rawBody.length];\n    System.arraycopy(timestampPrefix, 0, payload, 0, timestampPrefix.length);\n    System.arraycopy(rawBody, 0, payload, timestampPrefix.length, rawBody.length);\n\n    Mac mac = Mac.getInstance(\"HmacSHA256\");\n    mac.init(new SecretKeySpec(signingToken.getBytes(StandardCharsets.UTF_8), \"HmacSHA256\"));\n    byte[] expected = mac.doFinal(payload);\n\n    try {\n      byte[] received = HexFormat.of().parseHex(match.group(2));\n      return MessageDigest.isEqual(expected, received);\n    } catch (IllegalArgumentException exception) {\n      return false;\n    }\n  }\n}\n","lang":"java"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"go","__idx":4},"children":["Go"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"go","header":{"controls":{"copy":{}}},"source":"package percentswebhook\n\nimport (\n    \"crypto/hmac\"\n    \"crypto/sha256\"\n    \"encoding/hex\"\n    \"regexp\"\n)\n\nvar signaturePattern = regexp.MustCompile(`^t=(\\d+),s=([a-fA-F0-9]+)$`)\n\nfunc VerifyPercentsSignature(header string, rawBody []byte, signingToken string) bool {\n    match := signaturePattern.FindStringSubmatch(header)\n    if match == nil {\n        return false\n    }\n\n    payload := append([]byte(match[1]+\".\"), rawBody...)\n    mac := hmac.New(sha256.New, []byte(signingToken))\n    _, _ = mac.Write(payload)\n    expected := mac.Sum(nil)\n\n    received, err := hex.DecodeString(match[2])\n    if err != nil {\n        return false\n    }\n\n    return hmac.Equal(expected, received)\n}\n","lang":"go"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"python-3","__idx":5},"children":["Python 3"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"python","header":{"controls":{"copy":{}}},"source":"import hashlib\nimport hmac\nimport re\n\nSIGNATURE_PATTERN = re.compile(r\"^t=(\\d+),s=([a-fA-F0-9]+)$\")\n\n\ndef verify_percents_signature(header: str, raw_body: bytes, signing_token: str) -> bool:\n    match = SIGNATURE_PATTERN.fullmatch(header)\n    if match is None:\n        return False\n\n    timestamp, received_hex = match.groups()\n    payload = timestamp.encode(\"utf-8\") + b\".\" + raw_body\n    expected = hmac.new(\n        signing_token.encode(\"utf-8\"), payload, hashlib.sha256\n    ).digest()\n\n    try:\n        received = bytes.fromhex(received_hex)\n    except ValueError:\n        return False\n\n    return hmac.compare_digest(expected, received)\n","lang":"python"},"children":[]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Store the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["sign_"]}," token separately from the ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["api_"]}," API secret. Contact Percents to rotate it if exposed."]}]},"headings":[{"value":"Webhook Signing","id":"webhook-signing","depth":1},{"value":"Verification steps","id":"verification-steps","depth":2},{"value":"TypeScript (Node.js)","id":"typescript-nodejs","depth":2},{"value":"Java 17+","id":"java-17","depth":2},{"value":"Go","id":"go","depth":2},{"value":"Python 3","id":"python-3","depth":2}],"frontmatter":{"seo":{"title":"Webhook Signing"}},"lastModified":"2026-08-11T01:36:09.000Z","pagePropGetterError":{"message":"","name":""}},"slug":"/omni-api/overview/webhooks/signatures","userData":{"isAuthenticated":false,"teams":["anonymous"]},"isPublic":true}