# Webhooks

Percents sends webhooks when qualifying reward events occur. Webhooks are delivered with a stable envelope and a signature header.

## Envelope

```json
{
  "webhookId": "evt_77777777-7777-4777-8777-777777777777",
  "type": "qualified_settlement",
  "data": {
    "eventId": "evt_88888888-8888-4888-8888-888888888888",
    "eventType": "qualified_settlement",
    "occurredAt": "2026-07-01T18:45:20.000Z",
    "chgId": "chg_22222222-2222-4222-8222-222222222222",
    "merchantId": "mp_33333333-3333-4333-8333-333333333333",
    "currency": "usd",
    "sourceTransactionId": "txn_55555555-5555-4555-8555-555555555555",
    "rewardEffects": [
      {
        "appliedOfferId": "mpo_44444444-4444-4444-8444-444444444444",
        "amountMinor": 1250,
        "reasonCode": "earn",
        "timing": "posted"
      }
    ]
  }
}
```

Process each `webhookId` once and make replay handling idempotent.

## Webhook Signatures

Percents signs every webhook so issuers can verify that the request originated from Percents before processing the body.

Each issuer receives a webhook signing token. Signing tokens use the `sign_` prefix, are only viewable once, and should be stored securely by the issuer. If a signing token is exposed, request token rotation from Percents and store the replacement token when it is shown.

The signature is sent in the `X-PERCENTS-SIGNATURE` header.

```text
t=<epoch_milliseconds>,s=<hex_hmac_sha256_signature>
```

Example:

```text
t=1723493048949,s=9c1a1255a28407f25b6add5d3ec273b16da573853768aa7499ee1d53acb92e35
```

The timestamp is Unix epoch time in milliseconds.

To verify a webhook signature:

1. Read the raw request body string exactly as received, before JSON parsing.
2. Split the `X-PERCENTS-SIGNATURE` header into the `t` timestamp and `s` signature values.
3. Build the signed value as `<timestamp>.<raw request body>`.
4. Compute an HMAC-SHA256 hex digest using the issuer webhook signing token.
5. Compare the computed digest with the signature from the header using a constant-time comparison.
6. Only process the webhook after the signature is valid.


```typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyPercentsWebhookSignature({
  header,
  rawBody,
  signingToken,
}: {
  header: string | undefined;
  rawBody: string;
  signingToken: string;
}) {
  if (!header) {
    return false;
  }

  const [, timestamp, signature] = header.match(/^t=(\d+),s=([a-f0-9]+)$/i) || [];
  if (!timestamp || !signature) {
    return false;
  }

  const expectedSignature = createHmac('sha256', signingToken)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const expected = Buffer.from(expectedSignature, 'hex');
  const received = Buffer.from(signature, 'hex');

  return expected.length === received.length && timingSafeEqual(expected, received);
}
```

## Event Types

| Type | Meaning |
|  --- | --- |
| `qualified_auth` | A card authorization event qualified. Effects are usually `preview` timing. |
| `qualified_settlement` | A settled transaction qualified and posted. Effects are usually `posted` timing. |
| `clawback` | A previously posted effect was reversed. Effects use `reversed` timing and clawback reason codes. |


## Example Webhooks

These examples show the public envelope that is delivered to issuer webhook endpoints. Amounts are positive minor-unit values; the `reasonCode` and `timing` fields describe how the value should be interpreted.

### `qualified_auth`

Sent when a card authorization event qualifies for a reward. Preview effects can be used to inform the issuer experience, but they are not settled liability.

```json
{
  "webhookId": "evt_11111111-1111-4111-8111-111111111111",
  "type": "qualified_auth",
  "data": {
    "eventId": "evt_21111111-1111-4111-8111-111111111111",
    "eventType": "qualified_auth",
    "occurredAt": "2026-07-01T18:42:11.000Z",
    "chgId": "chg_22222222-2222-4222-8222-222222222222",
    "merchantId": "mp_33333333-3333-4333-8333-333333333333",
    "currency": "usd",
    "sourceAuthId": "auth_55555555-5555-4555-8555-555555555555",
    "rewardEffects": [
      {
        "appliedOfferId": "mpo_44444444-4444-4444-8444-444444444444",
        "amountMinor": 250,
        "reasonCode": "earn",
        "timing": "preview"
      }
    ]
  }
}
```

### `qualified_settlement`

Sent when a settled transaction posts reward effects. A single settlement can include both branded balance spend and newly earned value.

```json
{
  "webhookId": "evt_12222222-2222-4222-8222-222222222222",
  "type": "qualified_settlement",
  "data": {
    "eventId": "evt_22222222-2222-4222-8222-222222222222",
    "eventType": "qualified_settlement",
    "occurredAt": "2026-07-01T18:45:20.000Z",
    "chgId": "chg_22222222-2222-4222-8222-222222222222",
    "merchantId": "mp_33333333-3333-4333-8333-333333333333",
    "currency": "usd",
    "sourceAuthId": "auth_55555555-5555-4555-8555-555555555555",
    "sourceTransactionId": "txn_66666666-6666-4666-8666-666666666666",
    "rewardEffects": [
      {
        "appliedOfferId": "mpo_44444444-4444-4444-8444-444444444444",
        "amountMinor": 400,
        "reasonCode": "spend",
        "timing": "posted"
      },
      {
        "appliedOfferId": "mpo_44444444-4444-4444-8444-444444444444",
        "amountMinor": 130,
        "reasonCode": "earn",
        "timing": "posted"
      }
    ]
  }
}
```

For branded balance, posted spend effects should be represented as an statement credit on the cardholder account in the issuer platform.

### `clawback`

Sent when a previously posted reward effect is reversed. Work with the Percents account manager to validate refund flows.

```json
{
  "webhookId": "evt_13333333-3333-4333-8333-333333333333",
  "type": "clawback",
  "data": {
    "eventId": "evt_23333333-3333-4333-8333-333333333333",
    "eventType": "clawback",
    "occurredAt": "2026-07-02T10:15:00.000Z",
    "chgId": "chg_22222222-2222-4222-8222-222222222222",
    "merchantId": "mp_33333333-3333-4333-8333-333333333333",
    "currency": "usd",
    "sourceTransactionId": "txn_77777777-7777-4777-8777-777777777777",
    "rewardEffects": [
      {
        "appliedOfferId": "mpo_44444444-4444-4444-8444-444444444444",
        "amountMinor": 130,
        "reasonCode": "earn_clawback",
        "timing": "reversed"
      },
      {
        "appliedOfferId": "mpo_44444444-4444-4444-8444-444444444444",
        "amountMinor": 400,
        "reasonCode": "spend_clawback",
        "timing": "reversed"
      }
    ]
  }
}
```

## Reward Effects

| Field | Meaning |
|  --- | --- |
| `appliedOfferId` | The offer that produced the effect when available. |
| `amountMinor` | Effect amount in minor units. |
| `reasonCode` | `earn`, `spend`, `earn_clawback`, or `spend_clawback`. |
| `timing` | `preview`, `posted`, or `reversed`. |


Preview effects should inform the issuer experience but should not be treated as settled liability. Posted effects are final. Reversed effects undo prior posted value.

## Suppression

Percents may suppress a webhook when processing determines that no partner-visible event should be delivered. A suppressed webhook is not delivered.

Common suppression cases include no qualifying settlement, a duplicate or idempotent replay with no new posted effect, or a configured workflow that should not notify the issuer for a particular event.

## Retries And Backoff

Percents attempts delivery immediately. If the endpoint does not return a successful HTTP response, the event is retried up to `5` times by default.

Retry schedule:

| Retry attempt | Next attempt after |
|  --- | --- |
| 0 | 1 hour |
| 1 | 2 hours |
| 2 | 4 hours |
| 3 | 12 hours |
| 4 | 24 hours |
| 5 | 72 hours |


After retries are exhausted, delivery is not retried automatically. Use `webhookId`, `type`, and the ids in `data` to reconcile with activity and transaction detail endpoints.