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

# Node SDK

> @drwintech/sandpay — complete reference

`@drwintech/sandpay` is the official TypeScript SDK for Node.js 20+. It wraps the HTTP API, normalizes errors, and exposes a helper for verifying webhook signatures.

<Tip>
  Prefer a ready-to-run starter? See [Stack Builder](/en/quickstart#stack-builder-recommended) — it generates a Next.js/Express/Hono project with `@drwintech/sandpay` already wired up and your keys pre-filled.
</Tip>

## Installation

```bash theme={null}
npm install @drwintech/sandpay
# or
pnpm add @drwintech/sandpay
```

## Import and constructor

```ts theme={null}
import { SandPay } from "@drwintech/sandpay";

const sp = new SandPay({
  apiKey: process.env.SANDPAY_API_KEY!,
  // baseUrl: "https://api.sandbox.sandpay.dev", // optional — default: https://api.sandpay.dev
});
```

| Option    | Type   | Default                   | Description                                              |
| --------- | ------ | ------------------------- | -------------------------------------------------------- |
| `apiKey`  | string | —                         | Required. Your `sp_sk_test_...` key.                     |
| `baseUrl` | string | `https://api.sandpay.dev` | API root URL. Override to point at a custom environment. |

## `payments.create(input)`

Creates a simulated payment.

```ts theme={null}
const tx = await sp.payments.create({
  amount: 25000,
  currency: "FCFA",
  operator: "orange",
  country: "CI",
  msisdn: "+22507123456",
  reference: "ORDER-2026-A1",
  scenario: "success", // optional — default: "success"
});
```

| Field         | Type                           | Required | Notes                                                |
| ------------- | ------------------------------ | -------- | ---------------------------------------------------- |
| `amount`      | number                         | yes      | Positive integer, smallest unit (FCFA = whole unit). |
| `currency`    | string                         | yes      | `FCFA`, `XOF`, `XAF`, `RWF`…                         |
| `operator`    | `mtn`/`orange`/`moov`/`airtel` | yes      | See [operator guides](/en/operators/mtn).            |
| `country`     | string (2 chars)               | yes      | ISO-3166. `CI`, `BJ`, `TG`, `RW`.                    |
| `msisdn`      | string                         | yes      | E.164 format (`+225…`).                              |
| `reference`   | string                         | yes      | Your internal reference (idempotency key).           |
| `description` | string                         | no       | Free-form description (max 200 characters).          |
| `scenario`    | `PaymentScenario`              | no       | See [Scenarios](/en/scenarios).                      |

## `payments.get(id)`

```ts theme={null}
const tx = await sp.payments.get("TX_8K3M9F");
```

Returns the same `Payment` object as `create`. Throws `SandPayApiError` with `status: 404` if the ID is unknown.

## `payments.list(params?)`

```ts theme={null}
const page = await sp.payments.list({
  limit: 50,
  country: "CI",
  operator: "orange",
  status: "SUCCESS",
  cursor: previousPage.nextCursor ?? undefined,
});

console.log(page.data.length, page.hasMore, page.nextCursor);
```

| Param      | Type              | Default | Notes                                             |
| ---------- | ----------------- | ------- | ------------------------------------------------- |
| `limit`    | number (1–100)    | 50      |                                                   |
| `country`  | string            | —       | 2-letter filter.                                  |
| `operator` | `PaymentOperator` | —       |                                                   |
| `status`   | `PaymentStatus`   | —       | See [scenarios](/en/scenarios) for all 11 values. |
| `cursor`   | string            | —       | Opaque cursor returned by the previous page.      |

## `webhooks.verify(header, payload, secret)`

Verifies the HMAC-SHA256 signature of a webhook. **`payload` must be the raw body**, exactly as received.

```ts theme={null}
const ok = sp.webhooks.verify(
  req.headers["x-sandpay-signature"] as string,
  rawBody,
  process.env.SANDPAY_WEBHOOK_SECRET!,
);
```

### Getting the `rawBody`

<CodeGroup>
  ```ts Next.js (App Router) theme={null}
  // app/api/webhooks/sandpay/route.ts
  export async function POST(req: Request) {
    const rawBody = await req.text();
    const ok = sp.webhooks.verify(
      req.headers.get("x-sandpay-signature") ?? "",
      rawBody,
      process.env.SANDPAY_WEBHOOK_SECRET!,
    );
    if (!ok) return new Response("invalid", { status: 401 });
    const event = JSON.parse(rawBody);
    // ...
    return Response.json({ received: true });
  }
  ```

  ```ts Express theme={null}
  import express from "express";
  app.post(
    "/webhooks/sandpay",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const rawBody = req.body.toString("utf8");
      const ok = sp.webhooks.verify(
        req.header("x-sandpay-signature") ?? "",
        rawBody,
        process.env.SANDPAY_WEBHOOK_SECRET!,
      );
      if (!ok) return res.status(401).end();
      const event = JSON.parse(rawBody);
      res.json({ received: true });
    },
  );
  ```

  ```ts Hono theme={null}
  app.post("/webhooks/sandpay", async (c) => {
    const rawBody = await c.req.text();
    const ok = sp.webhooks.verify(
      c.req.header("x-sandpay-signature") ?? "",
      rawBody,
      process.env.SANDPAY_WEBHOOK_SECRET!,
    );
    if (!ok) return c.text("invalid", 401);
    const event = JSON.parse(rawBody);
    return c.json({ received: true });
  });
  ```
</CodeGroup>

## `webhooks.parseEvent(rawBody)`

Parses an **already-verified** webhook body into a typed `WebhookPayload`. Call this after `verify(...)`.

```ts theme={null}
const rawBody = await req.text();
const signature = req.headers.get("x-sandpay-signature") ?? "";

if (!sp.webhooks.verify(signature, rawBody, process.env.SANDPAY_WEBHOOK_SECRET!)) {
  return new Response("invalid signature", { status: 401 });
}

const event = sp.webhooks.parseEvent(rawBody);
// event.event === "payment.completed"
// event.tx_id, event.status, event.amount, etc. — all typed
```

Throws an `Error` if:

* the body is not valid JSON (`Invalid JSON in webhook body`),
* the body is not a JSON object (`Webhook body must be a JSON object`),
* the event name is unknown (`Unknown webhook event: ...`),
* a required field is missing or has the wrong type (`Missing or invalid field: ...`).

Validation is intentionally minimal — it checks the shape just enough to provide safe downstream typing. For exhaustive validation, compose with zod.

### Exported types

* `WebhookEventName` — literal union of event names (`"payment.completed"` today).
* `WebhookPayload` — interface of the payload returned by `parseEvent`.

## `SandPayApiError`

Thrown on any non-2xx HTTP status.

```ts theme={null}
import { SandPay, SandPayApiError } from "@drwintech/sandpay";

try {
  await sp.payments.create({ /* ... */ });
} catch (err) {
  if (err instanceof SandPayApiError) {
    console.error(err.status, err.code, err.message, err.requestId);
    console.error(err.detail); // optional structured payload (per-field Zod errors)
  }
}
```

| Property    | Type    | Description                                          |
| ----------- | ------- | ---------------------------------------------------- |
| `status`    | number  | HTTP status (400, 401, 402, 404, 429, 5xx).          |
| `code`      | string  | Machine-readable code (see [Errors](/en/errors)).    |
| `message`   | string  | Human-readable message.                              |
| `detail`    | unknown | Optional structured payload.                         |
| `requestId` | string? | `X-Request-Id` response header (useful for support). |

## Source code

The SDK is open source: [github.com/htleclerc/sandpay](https://github.com/htleclerc/sandpay). PRs welcome.
