Skip to content
Last updated

Webhooks

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

Envelope

{
  "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.

t=<epoch_milliseconds>,s=<hex_hmac_sha256_signature>

Example:

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

TypeMeaning
qualified_authA card authorization event qualified. Effects are usually preview timing.
qualified_settlementA settled transaction qualified and posted. Effects are usually posted timing.
clawbackA 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.

{
  "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.

{
  "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.

{
  "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

FieldMeaning
appliedOfferIdThe offer that produced the effect when available.
amountMinorEffect amount in minor units.
reasonCodeearn, spend, earn_clawback, or spend_clawback.
timingpreview, 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 attemptNext attempt after
01 hour
12 hours
24 hours
312 hours
424 hours
572 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.