This reference implementation creates a cardholder group, activates a sandbox merchant, ingests an earn transaction, waits for the earn webhook, ingests a spend transaction at the same merchant, and waits for the spend webhook.
The executable reference below uses TypeScript and Node.js because it also runs a local webhook receiver. The underlying API sequence is language-independent. For the corresponding raw HTTP request flow in TypeScript, Go, Java, and C#, see Code Examples. Pair that flow with the webhook receiver pattern for your framework and verify signatures before processing each event.
Before running it:
- Use an issuer sandbox token from Percents.
- Expose
http://localhost:8080/percents/webhookthrough a public HTTPS tunnel. - Ask Percents to configure that tunnel URL as the webhook URL for the sandbox issuer.
- Use a sandbox merchant configured for immediate availability and spend eligibility. If
PERCENTS_MERCHANT_IDis omitted, the script picks the first merchant returned by the sandbox API, preferring one withminDaysAfterEarnBeforeSpendAllowedset to0. - Sandbox data is subject to daily purging. Re-run the setup flow instead of reusing prior sandbox cardholder group, activation, balance, transaction, or webhook ids.
PERCENTS_AUTHORIZATION="token tok_...:api_test_secret" \
PERCENTS_WEBHOOK_SIGNING_TOKEN="sign_..." \
node sandbox-earn-spend-webhook.mjsThe following variants use the same sandbox sequence. They assume your application provides an HTTP client for the Percents API and a verified webhook receiver that exposes WaitForEffect. The complete TypeScript reference below includes those implementations; use it as the concrete request and signature-verification reference when adapting the flow to your framework.
const scenario = await createSandboxScenario({
authorization: process.env.PERCENTS_AUTHORIZATION!,
webhookSigningToken: process.env.PERCENTS_WEBHOOK_SIGNING_TOKEN,
});
const group = await scenario.registerCardholderGroup();
const merchant = await scenario.chooseSpendEligibleMerchant(group.id);
const offer = await scenario.chooseOffer(group.id, merchant.id);
await scenario.activateOffer(group.id, merchant.id, offer.id);
await scenario.ingestAuthorization({ amount: 2500, merchantId: merchant.id });
await scenario.ingestSettlement({ amount: 2500, merchantId: merchant.id, externalAuthId: scenario.lastAuthorizationId });
await scenario.waitForEffect({ type: 'qualified_settlement', reasonCode: 'earn' });
await scenario.assertCanSpendNow(group.id, merchant.id);
await scenario.ingestSettlement({ amount: 100, merchantId: merchant.id });
await scenario.waitForEffect({ type: 'qualified_settlement', reasonCode: 'spend' });// sandbox-earn-spend-webhook.mjs
import crypto from 'node:crypto';
import http from 'node:http';
const baseUrl = process.env.PERCENTS_BASE_URL || 'https://sandbox.percents.com';
const authorization = requiredEnv('PERCENTS_AUTHORIZATION');
const webhookSigningToken = process.env.PERCENTS_WEBHOOK_SIGNING_TOKEN;
const webhookPort = Number(process.env.PERCENTS_WEBHOOK_PORT || 8080);
const scenarioId = new Date().toISOString().replace(/\D/g, '').slice(0, 14);
const cardholderExternalId = `sandbox-cardholder-${scenarioId}`;
const cardExternalId = `sandbox-card-${scenarioId}`;
const receivedWebhooks = [];
const webhookWaiters = [];
const webhookServer = http.createServer((request, response) => {
if (request.method !== 'POST' || request.url !== '/percents/webhook') {
response.writeHead(404).end();
return;
}
collectRequestBody(request)
.then((rawBody) => {
const body = JSON.parse(rawBody);
const signature = request.headers['x-percents-signature'];
if (
webhookSigningToken &&
!verifyPercentsSignature({ rawBody, signature, token: webhookSigningToken })
) {
response.writeHead(401).end();
return;
}
receivedWebhooks.push(body);
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ received: true }));
resolveWebhookWaiters();
})
.catch((error) => {
console.error('Webhook handler failed', error);
response.writeHead(400).end();
});
});
await listen(webhookServer, webhookPort);
console.log(`Webhook receiver listening on http://localhost:${webhookPort}/percents/webhook`);
try {
const cardholderGroup = await api('POST', '/api/v2/chg', {
externalId: `sandbox-group-${scenarioId}`,
defaultLanguage: 'en',
active: true,
cardholders: [
{
externalId: cardholderExternalId,
active: true,
cards: [
{
externalId: cardExternalId,
last4: '4242',
bin: '411111',
network: 'visa',
physical: false,
billingZip: '10001',
active: true,
},
],
},
],
idempotencyKey: `setup-${scenarioId}`,
});
const chgId = cardholderGroup.id;
const merchant = await chooseMerchant(chgId);
const offer = await chooseOffer({ chgId, merchantId: merchant.id });
await api('PUT', `/api/v2/chg/${chgId}/merchant/${merchant.id}/activation`, {
presentedOfferId: offer.id,
source: 'issuer_host_app',
idempotencyKey: `activate-${scenarioId}`,
});
console.log(`Activated ${merchant.displayName || merchant.id} for ${chgId}`);
await ingestAuthorization({
id: `sandbox-auth-earn-${scenarioId}`,
amount: 2500,
merchantId: merchant.id,
createdAt: new Date().toISOString(),
});
await ingestSettlement({
id: `sandbox-txn-earn-${scenarioId}`,
externalAuthId: `sandbox-auth-earn-${scenarioId}`,
amount: 2500,
merchantId: merchant.id,
createdAt: new Date().toISOString(),
});
const earnWebhook = await waitForWebhookEffect({
type: 'qualified_settlement',
reasonCode: 'earn',
});
console.log('Received earn webhook', summarizeWebhook(earnWebhook));
const balances = await api('GET', `/api/v2/chg/${chgId}/balances`);
const merchantBalance = balances.find((balance) => balance.merchant.id === merchant.id);
if (!merchantBalance?.canSpendNow) {
const eligibility = await api(
'GET',
`/api/v2/chg/${chgId}/merchant/${merchant.id}/spend-eligibility`,
);
throw new Error(
`Merchant is not spend-eligible yet: ${eligibility.spendRestrictionReasons.join(', ')}`,
);
}
await ingestSettlement({
id: `sandbox-txn-spend-${scenarioId}`,
amount: 100,
merchantId: merchant.id,
createdAt: new Date().toISOString(),
});
const spendWebhook = await waitForWebhookEffect({
type: 'qualified_settlement',
reasonCode: 'spend',
});
console.log('Received spend webhook', summarizeWebhook(spendWebhook));
const finalBalances = await api('GET', `/api/v2/chg/${chgId}/balances`);
console.log(JSON.stringify(finalBalances, null, 2));
} finally {
webhookServer.close();
}
async function chooseMerchant(chgId) {
if (process.env.PERCENTS_MERCHANT_ID) {
return api('GET', `/api/v2/chg/${chgId}/merchant/${process.env.PERCENTS_MERCHANT_ID}`);
}
const merchants = await api('GET', `/api/v2/chg/${chgId}/merchants?page=1&pageSize=25`);
const merchant =
merchants.data.find((row) => row.minDaysAfterEarnBeforeSpendAllowed === 0) || merchants.data[0];
if (!merchant) {
throw new Error('No sandbox merchants were returned for this issuer');
}
return merchant;
}
async function chooseOffer({ chgId, merchantId }) {
if (process.env.PERCENTS_OFFER_ID) {
return api('GET', `/api/v2/chg/${chgId}/offer/${process.env.PERCENTS_OFFER_ID}`);
}
const offers = await api(
'GET',
`/api/v2/chg/${chgId}/merchant/${merchantId}/offers?page=1&pageSize=10`,
);
const offer = offers.data[0];
if (!offer) {
throw new Error(`No active sandbox offers were returned for merchant ${merchantId}`);
}
return offer;
}
async function ingestAuthorization(input) {
await api('POST', '/api/v1/incoming-auth', baseTransaction(input, { state: 'approved' }), [204]);
}
async function ingestSettlement(input) {
await api('POST', '/api/v1/incoming-txn', baseTransaction(input, { state: 'settled' }), [204]);
}
function baseTransaction(input, extraFields) {
return {
id: input.id,
amount: input.amount,
currency: 'usd',
merchantDescriptor: 'Sandbox Merchant',
mcc: '5814',
createdAt: input.createdAt,
cardLast4: '4242',
network: 'visa',
cardholderId: cardholderExternalId,
cardId: cardExternalId,
authMethod: 'online',
merchantId: input.merchantId,
externalAuthId: input.externalAuthId,
authTimestamp: input.createdAt,
...extraFields,
};
}
async function api(method, path, body, expectedStatuses = [200]) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
const parsedBody = text ? JSON.parse(text) : undefined;
if (!expectedStatuses.includes(response.status)) {
throw new Error(`${method} ${path} failed with ${response.status}: ${text}`);
}
return parsedBody;
}
function waitForWebhookEffect({ type, reasonCode, timeoutMs = 120000 }) {
const existingWebhook = findWebhookEffect({ type, reasonCode });
if (existingWebhook) {
return Promise.resolve(existingWebhook);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Timed out waiting for ${type} webhook with ${reasonCode} effect`));
}, timeoutMs);
webhookWaiters.push({
matches: () => findWebhookEffect({ type, reasonCode }),
resolve: (webhook) => {
clearTimeout(timeout);
resolve(webhook);
},
});
});
}
function findWebhookEffect({ type, reasonCode }) {
return receivedWebhooks.find(
(webhook) =>
webhook.type === type &&
webhook.data?.rewardEffects?.some((effect) => effect.reasonCode === reasonCode),
);
}
function resolveWebhookWaiters() {
for (const waiter of [...webhookWaiters]) {
const webhook = waiter.matches();
if (webhook) {
webhookWaiters.splice(webhookWaiters.indexOf(waiter), 1);
waiter.resolve(webhook);
}
}
}
function summarizeWebhook(webhook) {
return {
webhookId: webhook.webhookId,
type: webhook.type,
eventId: webhook.data.eventId,
effects: webhook.data.rewardEffects.map((effect) => ({
reasonCode: effect.reasonCode,
timing: effect.timing,
amountMinor: effect.amountMinor,
})),
};
}
function verifyPercentsSignature({ rawBody, signature, token }) {
if (typeof signature !== 'string') {
return false;
}
const [, timestampPart, signaturePart] = signature.match(/^t=(\d+),s=([a-f0-9]+)$/i) || [];
if (!timestampPart || !signaturePart) {
return false;
}
const expected = crypto
.createHmac('sha256', token)
.update(`${timestampPart}.${rawBody}`)
.digest('hex');
return timingSafeHexEqual(expected, signaturePart);
}
function timingSafeHexEqual(left, right) {
const leftBuffer = Buffer.from(left, 'hex');
const rightBuffer = Buffer.from(right, 'hex');
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function collectRequestBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('error', reject);
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
}
function listen(server, port) {
return new Promise((resolve) => server.listen(port, resolve));
}
function requiredEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}