> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sandpay.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Upgrade & (re)install

> A repo-agnostic runbook to install, reinstall, or upgrade a SandPay integration from scratch — without any assumptions about your codebase.

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 `@drwintech/sandpay`, or a hand-rolled client in Deno / Python / Go via raw
HTTP).

<Info>
  **SandPay is the source of truth.** Pin your behaviour to what `GET /v1/meta`
  reports and what the [Integration Changelog](/en/integration-changelog) says —
  never to a hardcoded assumption. This runbook stays valid across versions
  because every version-specific detail comes from those two places.
</Info>

## Which track are you on?

<CardGroup cols={2}>
  <Card title="Track A — Upgrade" icon="arrow-up">
    You already have a working integration on an older API version and want to
    move to the current one (adopt refunds/disbursements, etc.).
  </Card>

  <Card title="Track B — Install / reinstall" icon="rotate">
    You're integrating from scratch, or tearing out the old integration and
    redoing it cleanly.
  </Card>
</CardGroup>

***

## Track A — Upgrade an existing integration

<Steps>
  <Step title="Take stock: current vs target version">
    Ask the instance what it supports. The version it reports is your **target**:

    ```bash theme={null}
    curl $SANDPAY_BASE_URL/api/v1/meta
    # hosted: curl https://api.sandpay.dev/v1/meta
    ```

    ```json theme={null}
    { "api_version": "2026-05-30", "capabilities": ["payments","refunds","disbursements","webhooks","commission_split", "..."], "min_sdk_version": "0.2.0" }
    ```

    Then read the [Integration Changelog](/en/integration-changelog) from your
    current version down to the target — note every **"Action required?"** line.
  </Step>

  <Step title="Bump the client to ≥ min_sdk_version">
    * **Node:** `npm i @drwintech/sandpay@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](/en/integration-guide)).
  </Step>

  <Step title="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`.
  </Step>

  <Step title="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](#step-6-webhook-receiver) below. Everything else is
    additive and needs no change.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Tip>
  **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.
</Tip>

***

## Track B — Install (or reinstall) from scratch

<Warning>
  **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.
</Warning>

### 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)

<Warning>
  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**.
</Warning>

```bash theme={null}
# 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

```bash theme={null}
npm i @drwintech/sandpay@latest
```

```ts theme={null}
import { SandPay } from "@drwintech/sandpay";
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

```bash theme={null}
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)

```ts theme={null}
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" | … }
```

```bash theme={null}
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](/en/integration-guide#3-status-vocabulary).

### Step 5 — Get final status (poll + webhook)

```ts theme={null}
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](/en/integration-guide#4-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:

```ts theme={null}
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](/en/integration-guide#6-webhooks).

### Step 7 — Payouts (optional: refunds & disbursements)

Only if `capabilities` includes them (gate at runtime):

```ts theme={null}
// 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](/en/integration-guide#5-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](/en/integration-guide#6-webhooks).
* Run SandPay's Inngest dev server **or** rely on polling (Step 5) — see
  [Inngest local vs cloud](/en/integration-faq#why-isn-t-my-webhook-firing-in-local-dev).

### 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

<CardGroup cols={2}>
  <Card title="Integration Guide" icon="book" href="/integration-guide">
    The full, authoritative reference.
  </Card>

  <Card title="Integration Changelog" icon="timeline" href="/integration-changelog">
    What changed per version — and what to do about it.
  </Card>

  <Card title="Integration FAQ" icon="circle-question" href="/integration-faq">
    Common pitfalls + tips.
  </Card>

  <Card title="Quickstart" icon="rocket" href="/quickstart">
    First payment in 10 minutes.
  </Card>
</CardGroup>
