# Webhook Signing

Every webhook includes:

```http
X-Percents-Signature: t=<epoch-milliseconds>,s=<hex-hmac-sha256>
```

Percents computes HMAC-SHA256 over `<timestamp>.<raw request body>` using the issuer's `sign_` webhook signing token. 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 stale timestamps according to your replay policy.
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)

```typescript
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]+)$/i.exec(input.header);
  if (!match) {
    return false;
  }

  const [, timestamp, receivedHex] = match;
  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+

```java
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]+)$");

  public static boolean verify(String header, byte[] rawBody, String signingToken)
      throws GeneralSecurityException {
    Matcher match = SIGNATURE.matcher(header);
    if (!match.matches()) {
      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

```go
package percentswebhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "regexp"
)

var signaturePattern = regexp.MustCompile(`^t=(\d+),s=([a-fA-F0-9]+)$`)

func VerifyPercentsSignature(header string, rawBody []byte, signingToken string) bool {
    match := signaturePattern.FindStringSubmatch(header)
    if match == nil {
        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

```python
import hashlib
import hmac
import re

SIGNATURE_PATTERN = re.compile(r"^t=(\d+),s=([a-fA-F0-9]+)$")


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()
    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.