# Sandbox Earn/Spend Script

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](/percents-api/example-integrations/code-examples#full-http-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/webhook` through 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_ID` is omitted, the script picks the first merchant returned by the sandbox API, preferring one with `minDaysAfterEarnBeforeSpendAllowed` set to `0`.
- 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.


```bash
PERCENTS_AUTHORIZATION="token tok_...:api_test_secret" \
PERCENTS_WEBHOOK_SIGNING_TOKEN="sign_..." \
node sandbox-earn-spend-webhook.mjs
```

## Language Variants

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

```typescript TypeScript
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' });
```

```go Go
scenario, err := NewSandboxScenario(SandboxScenarioConfig{
	Authorization:        os.Getenv("PERCENTS_AUTHORIZATION"),
	WebhookSigningToken: os.Getenv("PERCENTS_WEBHOOK_SIGNING_TOKEN"),
})
if err != nil {
	return err
}

group, err := scenario.RegisterCardholderGroup(ctx)
if err != nil {
	return err
}
merchant, err := scenario.ChooseSpendEligibleMerchant(ctx, group.ID)
if err != nil {
	return err
}
offer, err := scenario.ChooseOffer(ctx, group.ID, merchant.ID)
if err != nil {
	return err
}

if err := scenario.ActivateOffer(ctx, group.ID, merchant.ID, offer.ID); err != nil {
	return err
}
authorizationID, err := scenario.IngestAuthorization(ctx, 2500, merchant.ID)
if err != nil {
	return err
}
if _, err := scenario.IngestSettlement(ctx, 2500, merchant.ID, authorizationID); err != nil {
	return err
}
if _, err := scenario.WaitForEffect(ctx, "qualified_settlement", "earn"); err != nil {
	return err
}

if err := scenario.AssertCanSpendNow(ctx, group.ID, merchant.ID); err != nil {
	return err
}
if _, err := scenario.IngestSettlement(ctx, 100, merchant.ID, ""); err != nil {
	return err
}
_, err = scenario.WaitForEffect(ctx, "qualified_settlement", "spend")
return err
```

```java Java
SandboxScenario scenario = SandboxScenario.create(
    System.getenv("PERCENTS_AUTHORIZATION"),
    System.getenv("PERCENTS_WEBHOOK_SIGNING_TOKEN")
);

CardholderGroup group = scenario.registerCardholderGroup();
Merchant merchant = scenario.chooseSpendEligibleMerchant(group.id());
Offer offer = scenario.chooseOffer(group.id(), merchant.id());

scenario.activateOffer(group.id(), merchant.id(), offer.id());
String authorizationId = scenario.ingestAuthorization(2500, merchant.id());
scenario.ingestSettlement(2500, merchant.id(), authorizationId);
scenario.waitForEffect("qualified_settlement", "earn");

scenario.assertCanSpendNow(group.id(), merchant.id());
scenario.ingestSettlement(100, merchant.id(), null);
scenario.waitForEffect("qualified_settlement", "spend");
```

```csharp C#
var scenario = await SandboxScenario.CreateAsync(
    authorization: Environment.GetEnvironmentVariable("PERCENTS_AUTHORIZATION")!,
    webhookSigningToken: Environment.GetEnvironmentVariable("PERCENTS_WEBHOOK_SIGNING_TOKEN"));

var group = await scenario.RegisterCardholderGroupAsync();
var merchant = await scenario.ChooseSpendEligibleMerchantAsync(group.Id);
var offer = await scenario.ChooseOfferAsync(group.Id, merchant.Id);

await scenario.ActivateOfferAsync(group.Id, merchant.Id, offer.Id);
var authorizationId = await scenario.IngestAuthorizationAsync(amount: 2500, merchant.Id);
await scenario.IngestSettlementAsync(amount: 2500, merchant.Id, authorizationId);
await scenario.WaitForEffectAsync(type: "qualified_settlement", reasonCode: "earn");

await scenario.AssertCanSpendNowAsync(group.Id, merchant.Id);
await scenario.IngestSettlementAsync(amount: 100, merchant.Id);
await scenario.WaitForEffectAsync(type: "qualified_settlement", reasonCode: "spend");
```

## TypeScript / Node.js Reference Script

```javascript
// 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;
}
```