{"templateId":"markdown","versions":[{"version":"2.0","label":"v2.0","link":"/percents-api/example-integrations/sandbox-earn-spend","default":true,"active":true,"folderId":"27d36c3a"}],"sharedDataIds":{"sidebar":"sidebar-sidebars.yaml"},"props":{"metadata":{"markdoc":{"tagList":["code-group"]},"type":"markdown"},"seo":{"title":"Sandbox Earn/Spend Script","llmstxt":{"hide":false,"sections":[{"title":"Table of contents","includeFiles":["**/*"],"excludeFiles":[]}],"excludeFiles":[]}},"dynamicMarkdocComponents":[],"compilationErrors":[],"ast":{"$$mdtype":"Tag","name":"article","attributes":{},"children":[{"$$mdtype":"Tag","name":"Heading","attributes":{"level":1,"id":"sandbox-earnspend-script","__idx":0},"children":["Sandbox Earn/Spend Script"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["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."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["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 ",{"$$mdtype":"Tag","name":"MarkdownLink","attributes":{"href":"/percents-api/example-integrations/code-examples#full-http-examples"},"children":["Code Examples"]},". Pair that flow with the webhook receiver pattern for your framework and verify signatures before processing each event."]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["Before running it:"]},{"$$mdtype":"Tag","name":"ul","attributes":{},"children":[{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Use an issuer sandbox token from Percents."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Expose ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["http://localhost:8080/percents/webhook"]}," through a public HTTPS tunnel."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Ask Percents to configure that tunnel URL as the webhook URL for the sandbox issuer."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["Use a sandbox merchant configured for immediate availability and spend eligibility. If ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["PERCENTS_MERCHANT_ID"]}," is omitted, the script picks the first merchant returned by the sandbox API, preferring one with ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["minDaysAfterEarnBeforeSpendAllowed"]}," set to ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["0"]},"."]},{"$$mdtype":"Tag","name":"li","attributes":{},"children":["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."]}]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"bash","header":{"controls":{"copy":{}}},"source":"PERCENTS_AUTHORIZATION=\"token tok_...:api_test_secret\" \\\nPERCENTS_WEBHOOK_SIGNING_TOKEN=\"sign_...\" \\\nnode sandbox-earn-spend-webhook.mjs\n","lang":"bash"},"children":[]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"language-variants","__idx":1},"children":["Language Variants"]},{"$$mdtype":"Tag","name":"p","attributes":{},"children":["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 ",{"$$mdtype":"Tag","name":"code","attributes":{},"children":["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."]},{"$$mdtype":"Tag","name":"CodeGroup","attributes":{"mode":"tabs"},"children":[{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"typescript","data-title":"TypeScript","header":{"title":"TypeScript","controls":{"copy":{}}},"source":"const scenario = await createSandboxScenario({\n  authorization: process.env.PERCENTS_AUTHORIZATION!,\n  webhookSigningToken: process.env.PERCENTS_WEBHOOK_SIGNING_TOKEN,\n});\n\nconst group = await scenario.registerCardholderGroup();\nconst merchant = await scenario.chooseSpendEligibleMerchant(group.id);\nconst offer = await scenario.chooseOffer(group.id, merchant.id);\n\nawait scenario.activateOffer(group.id, merchant.id, offer.id);\nawait scenario.ingestAuthorization({ amount: 2500, merchantId: merchant.id });\nawait scenario.ingestSettlement({ amount: 2500, merchantId: merchant.id, externalAuthId: scenario.lastAuthorizationId });\nawait scenario.waitForEffect({ type: 'qualified_settlement', reasonCode: 'earn' });\n\nawait scenario.assertCanSpendNow(group.id, merchant.id);\nawait scenario.ingestSettlement({ amount: 100, merchantId: merchant.id });\nawait scenario.waitForEffect({ type: 'qualified_settlement', reasonCode: 'spend' });\n","lang":"typescript"},"children":[]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"go","data-title":"Go","header":{"title":"Go","controls":{"copy":{}}},"source":"scenario, err := NewSandboxScenario(SandboxScenarioConfig{\n\tAuthorization:        os.Getenv(\"PERCENTS_AUTHORIZATION\"),\n\tWebhookSigningToken: os.Getenv(\"PERCENTS_WEBHOOK_SIGNING_TOKEN\"),\n})\nif err != nil {\n\treturn err\n}\n\ngroup, err := scenario.RegisterCardholderGroup(ctx)\nif err != nil {\n\treturn err\n}\nmerchant, err := scenario.ChooseSpendEligibleMerchant(ctx, group.ID)\nif err != nil {\n\treturn err\n}\noffer, err := scenario.ChooseOffer(ctx, group.ID, merchant.ID)\nif err != nil {\n\treturn err\n}\n\nif err := scenario.ActivateOffer(ctx, group.ID, merchant.ID, offer.ID); err != nil {\n\treturn err\n}\nauthorizationID, err := scenario.IngestAuthorization(ctx, 2500, merchant.ID)\nif err != nil {\n\treturn err\n}\nif _, err := scenario.IngestSettlement(ctx, 2500, merchant.ID, authorizationID); err != nil {\n\treturn err\n}\nif _, err := scenario.WaitForEffect(ctx, \"qualified_settlement\", \"earn\"); err != nil {\n\treturn err\n}\n\nif err := scenario.AssertCanSpendNow(ctx, group.ID, merchant.ID); err != nil {\n\treturn err\n}\nif _, err := scenario.IngestSettlement(ctx, 100, merchant.ID, \"\"); err != nil {\n\treturn err\n}\n_, err = scenario.WaitForEffect(ctx, \"qualified_settlement\", \"spend\")\nreturn err\n","lang":"go"},"children":[]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"java","data-title":"Java","header":{"title":"Java","controls":{"copy":{}}},"source":"SandboxScenario scenario = SandboxScenario.create(\n    System.getenv(\"PERCENTS_AUTHORIZATION\"),\n    System.getenv(\"PERCENTS_WEBHOOK_SIGNING_TOKEN\")\n);\n\nCardholderGroup group = scenario.registerCardholderGroup();\nMerchant merchant = scenario.chooseSpendEligibleMerchant(group.id());\nOffer offer = scenario.chooseOffer(group.id(), merchant.id());\n\nscenario.activateOffer(group.id(), merchant.id(), offer.id());\nString authorizationId = scenario.ingestAuthorization(2500, merchant.id());\nscenario.ingestSettlement(2500, merchant.id(), authorizationId);\nscenario.waitForEffect(\"qualified_settlement\", \"earn\");\n\nscenario.assertCanSpendNow(group.id(), merchant.id());\nscenario.ingestSettlement(100, merchant.id(), null);\nscenario.waitForEffect(\"qualified_settlement\", \"spend\");\n","lang":"java"},"children":[]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"csharp","data-title":"C#","header":{"title":"C#","controls":{"copy":{}}},"source":"var scenario = await SandboxScenario.CreateAsync(\n    authorization: Environment.GetEnvironmentVariable(\"PERCENTS_AUTHORIZATION\")!,\n    webhookSigningToken: Environment.GetEnvironmentVariable(\"PERCENTS_WEBHOOK_SIGNING_TOKEN\"));\n\nvar group = await scenario.RegisterCardholderGroupAsync();\nvar merchant = await scenario.ChooseSpendEligibleMerchantAsync(group.Id);\nvar offer = await scenario.ChooseOfferAsync(group.Id, merchant.Id);\n\nawait scenario.ActivateOfferAsync(group.Id, merchant.Id, offer.Id);\nvar authorizationId = await scenario.IngestAuthorizationAsync(amount: 2500, merchant.Id);\nawait scenario.IngestSettlementAsync(amount: 2500, merchant.Id, authorizationId);\nawait scenario.WaitForEffectAsync(type: \"qualified_settlement\", reasonCode: \"earn\");\n\nawait scenario.AssertCanSpendNowAsync(group.Id, merchant.Id);\nawait scenario.IngestSettlementAsync(amount: 100, merchant.Id);\nawait scenario.WaitForEffectAsync(type: \"qualified_settlement\", reasonCode: \"spend\");\n","lang":"csharp"},"children":[]}]},{"$$mdtype":"Tag","name":"Heading","attributes":{"level":2,"id":"typescript--nodejs-reference-script","__idx":2},"children":["TypeScript / Node.js Reference Script"]},{"$$mdtype":"Tag","name":"CodeBlock","attributes":{"data-language":"javascript","header":{"controls":{"copy":{}}},"source":"// sandbox-earn-spend-webhook.mjs\nimport crypto from 'node:crypto';\nimport http from 'node:http';\n\nconst baseUrl = process.env.PERCENTS_BASE_URL || 'https://sandbox.percents.com';\nconst authorization = requiredEnv('PERCENTS_AUTHORIZATION');\nconst webhookSigningToken = process.env.PERCENTS_WEBHOOK_SIGNING_TOKEN;\nconst webhookPort = Number(process.env.PERCENTS_WEBHOOK_PORT || 8080);\nconst scenarioId = new Date().toISOString().replace(/\\D/g, '').slice(0, 14);\n\nconst cardholderExternalId = `sandbox-cardholder-${scenarioId}`;\nconst cardExternalId = `sandbox-card-${scenarioId}`;\nconst receivedWebhooks = [];\nconst webhookWaiters = [];\n\nconst webhookServer = http.createServer((request, response) => {\n  if (request.method !== 'POST' || request.url !== '/percents/webhook') {\n    response.writeHead(404).end();\n    return;\n  }\n\n  collectRequestBody(request)\n    .then((rawBody) => {\n      const body = JSON.parse(rawBody);\n      const signature = request.headers['x-percents-signature'];\n\n      if (\n        webhookSigningToken &&\n        !verifyPercentsSignature({ rawBody, signature, token: webhookSigningToken })\n      ) {\n        response.writeHead(401).end();\n        return;\n      }\n\n      receivedWebhooks.push(body);\n      response.writeHead(200, { 'Content-Type': 'application/json' });\n      response.end(JSON.stringify({ received: true }));\n      resolveWebhookWaiters();\n    })\n    .catch((error) => {\n      console.error('Webhook handler failed', error);\n      response.writeHead(400).end();\n    });\n});\n\nawait listen(webhookServer, webhookPort);\nconsole.log(`Webhook receiver listening on http://localhost:${webhookPort}/percents/webhook`);\n\ntry {\n  const cardholderGroup = await api('POST', '/api/v2/chg', {\n    externalId: `sandbox-group-${scenarioId}`,\n    defaultLanguage: 'en',\n    active: true,\n    cardholders: [\n      {\n        externalId: cardholderExternalId,\n        active: true,\n        cards: [\n          {\n            externalId: cardExternalId,\n            last4: '4242',\n            bin: '411111',\n            network: 'visa',\n            physical: false,\n            billingZip: '10001',\n            active: true,\n          },\n        ],\n      },\n    ],\n    idempotencyKey: `setup-${scenarioId}`,\n  });\n\n  const chgId = cardholderGroup.id;\n  const merchant = await chooseMerchant(chgId);\n  const offer = await chooseOffer({ chgId, merchantId: merchant.id });\n\n  await api('PUT', `/api/v2/chg/${chgId}/merchant/${merchant.id}/activation`, {\n    presentedOfferId: offer.id,\n    source: 'issuer_host_app',\n    idempotencyKey: `activate-${scenarioId}`,\n  });\n\n  console.log(`Activated ${merchant.displayName || merchant.id} for ${chgId}`);\n\n  await ingestAuthorization({\n    id: `sandbox-auth-earn-${scenarioId}`,\n    amount: 2500,\n    merchantId: merchant.id,\n    createdAt: new Date().toISOString(),\n  });\n\n  await ingestSettlement({\n    id: `sandbox-txn-earn-${scenarioId}`,\n    externalAuthId: `sandbox-auth-earn-${scenarioId}`,\n    amount: 2500,\n    merchantId: merchant.id,\n    createdAt: new Date().toISOString(),\n  });\n\n  const earnWebhook = await waitForWebhookEffect({\n    type: 'qualified_settlement',\n    reasonCode: 'earn',\n  });\n  console.log('Received earn webhook', summarizeWebhook(earnWebhook));\n\n  const balances = await api('GET', `/api/v2/chg/${chgId}/balances`);\n  const merchantBalance = balances.find((balance) => balance.merchant.id === merchant.id);\n\n  if (!merchantBalance?.canSpendNow) {\n    const eligibility = await api(\n      'GET',\n      `/api/v2/chg/${chgId}/merchant/${merchant.id}/spend-eligibility`,\n    );\n    throw new Error(\n      `Merchant is not spend-eligible yet: ${eligibility.spendRestrictionReasons.join(', ')}`,\n    );\n  }\n\n  await ingestSettlement({\n    id: `sandbox-txn-spend-${scenarioId}`,\n    amount: 100,\n    merchantId: merchant.id,\n    createdAt: new Date().toISOString(),\n  });\n\n  const spendWebhook = await waitForWebhookEffect({\n    type: 'qualified_settlement',\n    reasonCode: 'spend',\n  });\n  console.log('Received spend webhook', summarizeWebhook(spendWebhook));\n\n  const finalBalances = await api('GET', `/api/v2/chg/${chgId}/balances`);\n  console.log(JSON.stringify(finalBalances, null, 2));\n} finally {\n  webhookServer.close();\n}\n\nasync function chooseMerchant(chgId) {\n  if (process.env.PERCENTS_MERCHANT_ID) {\n    return api('GET', `/api/v2/chg/${chgId}/merchant/${process.env.PERCENTS_MERCHANT_ID}`);\n  }\n\n  const merchants = await api('GET', `/api/v2/chg/${chgId}/merchants?page=1&pageSize=25`);\n  const merchant =\n    merchants.data.find((row) => row.minDaysAfterEarnBeforeSpendAllowed === 0) || merchants.data[0];\n\n  if (!merchant) {\n    throw new Error('No sandbox merchants were returned for this issuer');\n  }\n\n  return merchant;\n}\n\nasync function chooseOffer({ chgId, merchantId }) {\n  if (process.env.PERCENTS_OFFER_ID) {\n    return api('GET', `/api/v2/chg/${chgId}/offer/${process.env.PERCENTS_OFFER_ID}`);\n  }\n\n  const offers = await api(\n    'GET',\n    `/api/v2/chg/${chgId}/merchant/${merchantId}/offers?page=1&pageSize=10`,\n  );\n  const offer = offers.data[0];\n\n  if (!offer) {\n    throw new Error(`No active sandbox offers were returned for merchant ${merchantId}`);\n  }\n\n  return offer;\n}\n\nasync function ingestAuthorization(input) {\n  await api('POST', '/api/v1/incoming-auth', baseTransaction(input, { state: 'approved' }), [204]);\n}\n\nasync function ingestSettlement(input) {\n  await api('POST', '/api/v1/incoming-txn', baseTransaction(input, { state: 'settled' }), [204]);\n}\n\nfunction baseTransaction(input, extraFields) {\n  return {\n    id: input.id,\n    amount: input.amount,\n    currency: 'usd',\n    merchantDescriptor: 'Sandbox Merchant',\n    mcc: '5814',\n    createdAt: input.createdAt,\n    cardLast4: '4242',\n    network: 'visa',\n    cardholderId: cardholderExternalId,\n    cardId: cardExternalId,\n    authMethod: 'online',\n    merchantId: input.merchantId,\n    externalAuthId: input.externalAuthId,\n    authTimestamp: input.createdAt,\n    ...extraFields,\n  };\n}\n\nasync function api(method, path, body, expectedStatuses = [200]) {\n  const response = await fetch(`${baseUrl}${path}`, {\n    method,\n    headers: {\n      Authorization: authorization,\n      'Content-Type': 'application/json',\n    },\n    body: body === undefined ? undefined : JSON.stringify(body),\n  });\n\n  const text = await response.text();\n  const parsedBody = text ? JSON.parse(text) : undefined;\n\n  if (!expectedStatuses.includes(response.status)) {\n    throw new Error(`${method} ${path} failed with ${response.status}: ${text}`);\n  }\n\n  return parsedBody;\n}\n\nfunction waitForWebhookEffect({ type, reasonCode, timeoutMs = 120000 }) {\n  const existingWebhook = findWebhookEffect({ type, reasonCode });\n  if (existingWebhook) {\n    return Promise.resolve(existingWebhook);\n  }\n\n  return new Promise((resolve, reject) => {\n    const timeout = setTimeout(() => {\n      reject(new Error(`Timed out waiting for ${type} webhook with ${reasonCode} effect`));\n    }, timeoutMs);\n\n    webhookWaiters.push({\n      matches: () => findWebhookEffect({ type, reasonCode }),\n      resolve: (webhook) => {\n        clearTimeout(timeout);\n        resolve(webhook);\n      },\n    });\n  });\n}\n\nfunction findWebhookEffect({ type, reasonCode }) {\n  return receivedWebhooks.find(\n    (webhook) =>\n      webhook.type === type &&\n      webhook.data?.rewardEffects?.some((effect) => effect.reasonCode === reasonCode),\n  );\n}\n\nfunction resolveWebhookWaiters() {\n  for (const waiter of [...webhookWaiters]) {\n    const webhook = waiter.matches();\n    if (webhook) {\n      webhookWaiters.splice(webhookWaiters.indexOf(waiter), 1);\n      waiter.resolve(webhook);\n    }\n  }\n}\n\nfunction summarizeWebhook(webhook) {\n  return {\n    webhookId: webhook.webhookId,\n    type: webhook.type,\n    eventId: webhook.data.eventId,\n    effects: webhook.data.rewardEffects.map((effect) => ({\n      reasonCode: effect.reasonCode,\n      timing: effect.timing,\n      amountMinor: effect.amountMinor,\n    })),\n  };\n}\n\nfunction verifyPercentsSignature({ rawBody, signature, token }) {\n  if (typeof signature !== 'string') {\n    return false;\n  }\n\n  const [, timestampPart, signaturePart] = signature.match(/^t=(\\d+),s=([a-f0-9]+)$/i) || [];\n  if (!timestampPart || !signaturePart) {\n    return false;\n  }\n\n  const expected = crypto\n    .createHmac('sha256', token)\n    .update(`${timestampPart}.${rawBody}`)\n    .digest('hex');\n\n  return timingSafeHexEqual(expected, signaturePart);\n}\n\nfunction timingSafeHexEqual(left, right) {\n  const leftBuffer = Buffer.from(left, 'hex');\n  const rightBuffer = Buffer.from(right, 'hex');\n  return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);\n}\n\nfunction collectRequestBody(request) {\n  return new Promise((resolve, reject) => {\n    const chunks = [];\n    request.on('data', (chunk) => chunks.push(chunk));\n    request.on('error', reject);\n    request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));\n  });\n}\n\nfunction listen(server, port) {\n  return new Promise((resolve) => server.listen(port, resolve));\n}\n\nfunction requiredEnv(name) {\n  const value = process.env[name];\n  if (!value) {\n    throw new Error(`${name} is required`);\n  }\n  return value;\n}\n","lang":"javascript"},"children":[]}]},"headings":[{"value":"Sandbox Earn/Spend Script","id":"sandbox-earnspend-script","depth":1},{"value":"Language Variants","id":"language-variants","depth":2},{"value":"TypeScript / Node.js Reference Script","id":"typescript--nodejs-reference-script","depth":2}],"frontmatter":{"seo":{"title":"Sandbox Earn/Spend Script"}},"lastModified":"2026-07-21T18:22:26.000Z","pagePropGetterError":{"message":"","name":""}},"slug":"/percents-api/example-integrations/sandbox-earn-spend","userData":{"isAuthenticated":false,"teams":["anonymous"]},"isPublic":true}