Skip to main content
This is the procedure to follow from inside your own app — whether you’re integrating SandPay for the first time, reinstalling it cleanly, or upgrading an existing integration to the current API version. It assumes no prior code; every step is generic and works for any stack (a Node app with @sandpay/node, or a hand-rolled client in Deno / Python / Go via raw HTTP).
SandPay is the source of truth. Pin your behaviour to what GET /v1/meta reports and what the Integration Changelog says — never to a hardcoded assumption. This runbook stays valid across versions because every version-specific detail comes from those two places.

Which track are you on?

Track A — Upgrade

You already have a working integration on an older API version and want to move to the current one (adopt refunds/disbursements, etc.).

Track B — Install / reinstall

You’re integrating from scratch, or tearing out the old integration and redoing it cleanly.

Track A — Upgrade an existing integration

1

Take stock: current vs target version

Ask the instance what it supports. The version it reports is your target:
curl $SANDPAY_BASE_URL/api/v1/meta
# hosted: curl https://api.sandpay.dev/v1/meta
{ "api_version": "2026-05-30", "capabilities": ["payments","refunds","disbursements","webhooks","commission_split", "..."], "min_sdk_version": "0.2.0" }
Then read the Integration Changelog from your current version down to the target — note every “Action required?” line.
2

Bump the client to ≥ min_sdk_version

  • Node: npm i @sandpay/node@latest (must be ≥ min_sdk_version).
  • Hand-rolled client (Deno/Python/Go): mirror the new methods/fields manually (the new endpoints + response fields are in the changelog and the Integration Guide).
3

Migrate the database — only if you self-host SandPay

Against the hosted api.sandpay.dev: nothing to do. If you run your own instance: corepack pnpm --filter sandpay-app run db:migrate.
4

Apply each 'Action required' change

Do exactly what the changelog entries say. As of 2026-05-30 the only one that affects existing code is: branch your webhook handler on event (so refunds/disbursements aren’t mistaken for collections) — see Webhook receiver below. Everything else is additive and needs no change.
5

Verify + redeploy

Re-run your integration tests, redeploy, then confirm GET /api/v1/meta (or the X-SandPay-Api-Version header on any /v1/* response) reports the version you expect.
Rollback is easy because v1 is additive: if something misbehaves, pin your SDK back to the previous version and redeploy — the older calls still work against the newer instance. Then retry the upgrade.

Track B — Install (or reinstall) from scratch

Reinstalling? Drop stale assumptions first. If you’re redoing an integration written against an old version, delete any code that hardcoded “SandPay can’t do payouts” or treated every webhook as a collection — both are wrong as of 2026-05-30. Start from the steps below.

Step 0 — Prerequisites

  • A SandPay account + an API key (sp_sk_test_…).
  • At least one operator environment configured in the dashboard (Settings → Countries & operators), e.g. MTN / RW / RWF.
  • A webhook signing secret (shown in the dashboard webhook settings).

Step 1 — Environment variables (server-side only)

The API key is a server secret. Never ship it to a browser, mobile app, or any client bundle. All SandPay calls go client → your backend → SandPay.
# Strip any trailing slash from the base URL.
SANDPAY_BASE_URL=https://api.sandpay.dev      # or your tunnel/self-hosted URL
SANDPAY_API_KEY=sp_sk_test_xxxxxxxx
SANDPAY_WEBHOOK_SECRET=whsec_xxxxxxxx

Step 2 — Install the client

npm i @sandpay/node@latest
import { SandPay } from "@sandpay/node";
const sandpay = new SandPay({
  apiKey: process.env.SANDPAY_API_KEY!,
  baseUrl: process.env.SANDPAY_BASE_URL, // optional; defaults to api.sandpay.dev
});
No Node? Use raw HTTP. Every call is Authorization: Bearer $SANDPAY_API_KEY against ${SANDPAY_BASE_URL}/api/v1/… (hosted: /v1/…).

Step 3 — Smoke-test connectivity + capabilities

curl $SANDPAY_BASE_URL/api/v1/meta
Confirm api_version, min_sdk_version, and that capabilities lists what you plan to use (payments, and refunds/disbursements if you need payouts). Feature-detect against this rather than assuming.

Step 4 — Create a payment (collection: customer → merchant)

const tx = await sandpay.payments.create({
  amount: 25000, currency: "RWF",
  operator: "mtn", country: "RW",
  msisdn: "+250788000001",
  reference: "ORDER-2026-0001", // your idempotency key
});
// → { id: "TX_…", status: "PENDING" | "SUCCESS" | … }
curl -X POST $SANDPAY_BASE_URL/api/v1/payments \
  -H "Authorization: Bearer $SANDPAY_API_KEY" -H "Content-Type: application/json" \
  -d '{"amount":25000,"currency":"RWF","operator":"mtn","country":"RW","msisdn":"+250788000001","reference":"ORDER-2026-0001"}'
Persist the returned id (your provider_ref). Map all 11 statuses; only PENDING is non-terminal. USER_CANCELLED is terminal (don’t poll forever). See Status vocabulary.

Step 5 — Get final status (poll + webhook)

const p = await sandpay.payments.get("TX_…");   // GET /v1/payments/{id}
Use both: poll GET /v1/payments/{id} (~every 4s) as the resilient fallback, and the webhook (below) as the instant push. Reconcile your merchant ledger on net_amount, not amount (operator commission). See Commission & settlement.

Step 6 — Webhook receiver

A small endpoint on your backend that SandPay POSTs to. Configure its URL in the dashboard. It must:
  1. Verify the HMAC signature in constant time, fail closed if the secret is missing. The header is X-SandPay-Signature: sha256=<hex> over the raw body.
  2. Be deployed without JWT auth (authenticity comes from the signature).
  3. Match the transaction by tx_id (SandPay does not echo your reference).
  4. Branch on event — this is the one thing that bites people:
switch (payload.event) {
  case "payment.completed":      // a collection reached a terminal status
    settleOrder(payload.tx_id, payload.status, payload.net_amount);
    break;
  case "payment.refunded":       // a refund (payload.parent_tx_id = the original)
    recordRefund(payload.parent_tx_id, payload.tx_id, payload.status);
    break;
  case "disbursement.completed": // a free-form payout
    recordPayout(payload.tx_id, payload.status);
    break;
}
Each event carries the terminal status — check it, not just the event name. See Webhooks.

Step 7 — Payouts (optional: refunds & disbursements)

Only if capabilities includes them (gate at runtime):
// Refund all/part of a SUCCESS collection (merchant → original payer)
await sandpay.payments.refund("TX_…", { amount: 500 }); // omit amount = full
// Free-form payout to an msisdn
await sandpay.disbursements.create({ amount: 5000, currency: "RWF", operator: "mtn", country: "RW", msisdn: "+250788000001" });
No commission is taken on a payout. See Refunds & disbursements.

Step 8 — Local development

Your webhook URL must be publicly reachable, and SandPay dispatches webhooks via Inngest. So locally:
  • Expose your backend with a stable tunnel (ngrok reserved domain) and set SANDPAY_BASE_URL/webhook URL once. See the tunnel setup.
  • Run SandPay’s Inngest dev server or rely on polling (Step 5) — see Inngest local vs cloud.

Step 9 — Final checklist

  • SANDPAY_* in server env only; base URL has no trailing slash.
  • Operator env enabled in the dashboard.
  • Collection create → store id; all 11 statuses mapped; USER_CANCELLED terminal.
  • Status resolved by polling + webhook; ledger reconciled on net_amount.
  • Webhook: --no-verify-jwt, constant-time HMAC, match by tx_id, branch on event.
  • Webhook URL + secret set in the dashboard (identical secret both sides).
  • Payouts gated on capabilities (if used).
  • Local dev: tunnel + (Inngest dev server or polling).
  • GET /v1/meta reports the expected version after deploy.

See also

Integration Guide

The full, authoritative reference.

Integration Changelog

What changed per version — and what to do about it.

Integration FAQ

Common pitfalls + tips.

Quickstart

First payment in 10 minutes.