Skip to main content
This page is the source of truth for how the SandPay integration contract evolves. Every entry is dated, carries an API version, and tells you plainly whether action is required on your side.
SandPay is the source of truth. Your app (Zana or any other consumer) is a client. When something changes here, update your integration to match — don’t fork the contract.

How to stay current (the update mode)

SandPay surfaces its version at runtime so you never have to guess what an instance supports:

GET /v1/meta

Unauthenticated. Returns the API version, supported capabilities, the minimum recommended SDK version, and a link back to this page.

X-SandPay-Api-Version

Every /v1/* response carries this header — so any call already tells you which contract you’re talking to.
curl https://api.sandpay.dev/v1/meta
# or, against a local/self-hosted instance:
curl $SANDPAY_BASE_URL/api/v1/meta
{
  "api_version": "2026-05-31",
  "capabilities": ["payments", "refunds", "disbursements", "webhooks", "test_clients", "multi_environment", "commission_split", "order_linkage", "reference_format"],
  "min_sdk_version": "0.2.0",
  "changelog_url": "https://docs.sandpay.dev/integration-changelog"
}
Feature-detect instead of assuming. Before calling a newer endpoint, check the capability is present — your code then works against older instances too:
const { capabilities } = await sandpay.meta();
if (capabilities.includes("disbursements")) {
  await sandpay.disbursements.create({ /* … */ });
}

The upgrade procedure (every time an entry says “action required”)

1

Read the entry

Find the latest dated entry below and its Action required? line.
2

Bump the SDK

npm i @sandpay/node@latest (≥ the min_sdk_version from /v1/meta). If you hand-rolled a client (e.g. a Deno/Supabase Edge function), mirror the new methods/fields manually.
3

Migrate a self-hosted instance

If you run your own SandPay instance, apply DB migrations: corepack pnpm --filter sandpay-app run db:migrate. (Not needed against the hosted api.sandpay.dev.)
4

Apply the code change

Do whatever the entry’s Action required? line says (e.g. branch your webhook handler on event). Additive-only entries need nothing.
5

Verify

Re-run your integration tests against the new version. GET /v1/meta should report the version you expect.
Compatibility policy. Within v1, changes are additive — new endpoints, new optional fields, new webhook event names. Existing fields and the meaning of existing values never change under v1. A breaking change would ship under a new major path (v2) with this page calling it out and a deprecation window on v1.

Changelog

2026-05-31 — Canonical reference format api_version: 2026-05-31

A recommended shape for the reference idempotency key. Additive.
  • New convention: reference follows ORIGIN-OP-TS-CODE (e.g. ZANA-PAY-20260531221015-8E2884A47B1C) — ORIGIN = your app slug (SP for SandPay), OPPAY (collection) / REF (refund) / DIS (disbursement) / ABO (subscription), TS = compact UTC datetime YYYYMMDDHHMMSS (sortable), CODE = 12-char uppercase hex (random for a fresh attempt, or derived from a stable id — with a stable TS — for idempotent retries). Capability: reference_format. Parsing is lenient — pre-TS refs still recognized.
  • Changed (additive): when you omit reference on a payout, SandPay’s auto-generated default now uses this format (SP-REF-… / SP-DIS-…, a refund inheriting its parent’s ORIGIN) instead of the old RFND_… / DISB_…. Only affects callers that don’t send their own reference.
  • SDK: buildReference(), referenceForType(), deriveRefCode(), generateRefCode(), parseReference(), isCanonicalReference() exported from @sandpay/node 0.2.1.
Action required: none. reference is still your idempotency key and any string still works — this is a recommended convention, not an enforced one. Adopting it makes the dashboard consistent and references self-describing. It’s independent of order_ref (the grouping key) — keep sending that too. See Integration Guide §5 → Reference format.

2026-05-30 — Refunds & disbursements api_version: 2026-05-30

The outbound money direction (merchant → customer). Additive.
  • New: POST /v1/payments/{id}/refund — refund all/part of a SUCCESS collection (linked via parentTxId).
  • New: POST /v1/disbursements — free-form payout to an msisdn.
  • New: transactions carry type (collection | refund | disbursement) and parentTxId; filter with GET /v1/payments?type=….
  • New webhook events: payment.refunded, disbursement.completed (each carries the terminal status). Collections still fire payment.completed unchanged.
  • New: GET /v1/meta + X-SandPay-Api-Version header (this update mode).
  • New (order linkage): optional order_ref + order_url on POST /v1/payments (and /v1/disbursements). order_ref is the grouping key that ties a collection, its refunds, and any recurring charges together; a refund inherits its parent’s. Both are echoed on Payment (orderRef/orderUrl) and the webhook (order_ref/order_url). The dashboard gains an Orders page (group by order) + an order filter on History. Capability: order_linkage. Optional is_url_protected (boolean) marks order_url as permission-protected → a ”⚠ protected URL” hint in the dashboard.
  • SDK: sandpay.payments.refund(), sandpay.disbursements.*, sandpay.meta(), PaymentInput.order_ref/order_url. Min SDK: 0.2.0.
Action required (webhook consumers): branch on event. Once you adopt refunds/disbursements, your endpoint will receive payment.refunded / disbursement.completed in addition to payment.completed. Switch on the event (or type) field before treating a delivery as a collection settlement, so a refund isn’t mistaken for a new payment. If you never call the payout endpoints, no payout webhooks fire and no action is needed yet — but adding the branch now is the safe move. See Integration Guide §5.
See §5 of the Integration Guide.

2026-05-15 — Configurable commission split

  • New (optional) fields on Payment + the webhook: commission, netAmount, customerTotal, merchantAbsorptionPct, merchantShare, customerShare, commissionMode.
  • Per-environment commission rate (commission_bps) + merchant-absorption %.
Action required: reconcile your merchant ledger against netAmount, not amount. With the default config (merchant absorbs 100%) customerTotal still equals amount, so existing reconciliation that read amount is not wrong — but netAmount is the correct field. See Integration Guide §4.

2026-05-01 — Native operator raw payload

  • New (optional) field raw on Payment + the webhook — the operator-native response shape (synthesized in sandbox with _simulated: true; verbatim in production). Additive — no action required.

2026-04-20 — List & retrieve payments

  • New: GET /v1/payments (cursor pagination) and GET /v1/payments/{id}. Additive — no action required.

See also

Upgrade & (re)install

Step-by-step runbook to install, reinstall, or upgrade from your app.

Integration Guide

The full, authoritative integration reference.

Integration FAQ

Common pitfalls + tips.