Percents sends webhooks when qualifying reward events occur. Webhooks are delivered with a stable envelope and a signature header.
{
"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.
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=9c1a1255a28407f25b6add5d3ec273b16da573853768aa7499ee1d53acb92e35The timestamp is Unix epoch time in milliseconds.
To verify a webhook signature:
- Read the raw request body string exactly as received, before JSON parsing.
- Split the
X-PERCENTS-SIGNATUREheader into thettimestamp andssignature values. - Build the signed value as
<timestamp>.<raw request body>. - Compute an HMAC-SHA256 hex digest using the issuer webhook signing token.
- Compare the computed digest with the signature from the header using a constant-time comparison.
- 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);
}| 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. |
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.
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"
}
]
}
}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.
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"
}
]
}
}| 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.
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.
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.