# Node SDK

@paylod/node is the official Node/TypeScript client: STK Push, status polling with backoff, signed webhook verification and offline error decoding, with zero runtime dependencies.

`@paylod/node` is the official Node/TypeScript client. It wraps the same HTTP API documented in the [API reference](/docs/api) and adds the parts every integration ends up writing anyway: phone-number normalisation, idempotency keys, a polling loop with backoff, webhook signature verification, and an offline result-code decoder.

It has **zero runtime dependencies** and works on Node 18+, Bun, Deno, Vercel, Cloudflare Workers and any other runtime with a global `fetch`.

```bash title="Install"
npm install @paylod/node
```

## Server-side only

The API key can move money. `@paylod/node` is a server library — call it from your backend, a serverless function, or an edge worker. Never from a browser or a mobile app.

> [!warn] A key shipped in client-side JavaScript is fully compromised: every visitor downloads it. If you need a browser checkout, have the browser call *your* server, and let your server call paylod. See [Secure integration](/docs/guides/security).

## Set up the client

One argument: your API key.

```ts title="paylod.ts"
import { Paylod } from "@paylod/node";

export const paylod = new Paylod(process.env.PAYLOD_API_KEY!);
```

That is the whole configuration. There is no base URL to pass, no OAuth token to fetch and refresh, and no callback URL to host. If you leave the argument off, the client reads `PAYLOD_API_KEY` from the environment itself:

```ts title="Also fine"
const paylod = new Paylod();
```

Either way it throws a `PaylodConfigError` immediately if no key is found, rather than handing you a client that fails on its first call.

Timeouts, retries and the rest have sensible defaults. You can override them, but you will rarely want to: see [Options](#options) at the bottom of this page.

## Collect a payment

```ts title="collect.ts"
const outcome = await paylod.collectAndWait({ amount: 100, phone: "0712345678" });

if (outcome.paid) {
  await fulfil(outcome.receipt);   // "UG1F3A1U7J"
} else {
  console.log(outcome.message);    // "That M-Pesa PIN was incorrect. Please try again …"
}
```

`collectAndWait()` sends the STK Push and polls until the payment settles. You get back a `PaymentOutcome`: a flat object with a customer-facing `message` already decoded and a `retryable` flag you can hang a button off.

A wrong PIN is a business outcome, not a crash, so it comes back as data rather than a thrown error.

Phone numbers are normalised for you. `0712345678`, `+254712345678`, `254712345678` and `712345678` all work.

## The outcome

Every method that settles a payment returns the same shape.

```ts title="PaymentOutcome"
{
  status: "succeeded" | "pending" | "cancelled" | "failed";
  message: string;        // customer-facing, already decoded. Render this.
  retryable: boolean;     // SAFE TO CHARGE AGAIN. Gate your retry button on this.
  paid: boolean;          // the one branch a backend needs
  receipt: string | null; // M-Pesa confirmation code; non-null exactly when `paid`
  code: string | null;    // raw result code, for your logs
  detail: DecodedError | null;
  payment: Payment;
}
```

Your entire UI:

```tsx title="Checkout.tsx"
<p>{outcome.message}</p>
{outcome.retryable && <button onClick={retry}>Try again</button>}
```

No `switch` on result codes. No error table in your app. If you ever write `if (outcome.code === "1032")` to decide what to *show* a human, use `message` instead. `code` and `detail` are for logs and support tooling.

> [!warn] `retryable` means **safe to charge again**, not "the user is allowed to press a button". A `pending` payment is never retryable: the STK prompt is still live on the handset, and re-collecting fires a second prompt that can double-charge the customer. Gate the retry button on `retryable` and this cannot happen to you.

## Methods

### `collect(params)`

Sends the STK Push and returns as soon as the prompt is on the handset. The payment is `pending`.

```ts title="collect"
const ack = await paylod.collect({
  amount: 100,                      // whole KES, 1–150000. M-Pesa rejects decimals.
  phone: "0712345678",
  accountReference: "INV-2041",     // optional, ≤ 12 chars — YOUR correlation id (see below)
  description: "Order #2041",       // optional, ≤ 64 chars — shown on the STK prompt
  metadata: { orderId: "2041" },    // optional, opaque; stored with the payment
});

ack.paymentId;          // "e69e5c00-…"
ack.checkoutRequestId;  // "ws_CO_…"
ack.idempotencyKey;     // generated for you unless you pass one
```

An `Idempotency-Key` is generated for every call, so a retry can never double-charge. Persist `ack.idempotencyKey` if you intend to retry the same charge later, and pass it back as `idempotencyKey`.

`amount`, `phone`, `accountReference` and `description` are validated locally before the request leaves your process, so a bad amount fails instantly in your own stack trace instead of coming back as a `422` a round-trip later.

`accountReference` is a **correlation id you choose**, not a message to the customer. It comes back as `accountRef` on `status()` and on the webhook, so it is how you tie a payment to your order. Safaricom only shows it to the payer on a **Paybill** (`CustomerPayBillOnline`), where it is the account number; on a **Till / Buy Goods** shortcode it is not displayed at all — the payer sees your business name and the amount. `description` is the field that reaches the STK prompt.

### `status(paymentId)`

Reads a payment. If it is still pending, paylod runs a live M-Pesa STK Query and settles it on the spot before answering.

```ts title="status"
const payment = await paylod.status(ack.paymentId);

payment.status;        // "pending" | "success" | "failed"
payment.mpesaReceipt;  // "UG1F3A1U7J" on success, otherwise null
payment.resultCode;    // 0 | 1032 | 2001 | …
payment.resultDesc;
```

### `check(paymentId)`

`status()`, already decoded. Returns a `PaymentOutcome` you can render.

```ts title="check"
const outcome = await paylod.check(ack.paymentId);

outcome.status;     // "pending" | "succeeded" | "cancelled" | "failed"
outcome.message;    // "Check your phone and enter your M-Pesa PIN to complete this payment."
outcome.retryable;  // false while pending
```

This is what a polling endpoint in your own app should return to your frontend.

### `wait(paymentId, options)`

Polls an existing payment until it settles, on a jittered backoff ramp (1s → 5s). Returns a `PaymentOutcome`.

```ts title="wait"
const outcome = await paylod.wait(ack.paymentId, {
  timeoutMs: 120_000,   // default. STK prompts expire around 60s; PIN entry takes 20–30s more.
  onPoll: (p) => console.log("still", p.status),
  signal: controller.signal,
});
```

`wait()` decides "has it settled?" from the result **code**, not the raw status field. Daraja reports `4999` on a payment it also marks `failed`, but that code means the prompt is still live. `wait()` keeps polling instead of reporting a failure for a payment that is about to succeed.

If the payment is **still pending** at the deadline, `wait()` throws a `PaylodTimeoutError`. That is not a failure, and it is deliberately not folded into `status: "failed"`. The customer may still be typing their PIN. Leave the order open.

### `collectAndWait(params, options)`

`collect()` followed by `wait()`. The one-liner most integrations want.

### `pendingOutcome(paymentId)`

The renderable form of a freshly sent prompt. `collect()` hands back an ack, but a prompt sitting on a handset is a pending payment, and your UI wants to render it like any other state.

```ts title="pendingOutcome"
import { pendingOutcome } from "@paylod/node";

const ack = await paylod.collect({ amount: 100, phone });
return pendingOutcome(ack.paymentId);   // same shape as check() and wait()
```

### `decodeError(resultCode, rawDesc?)`

Decodes an M-Pesa result code offline. No network call, no API key needed at call time.

```ts title="decodeError"
const err = paylod.decodeError(1032);

err.title;            // "Payment cancelled by the customer"
err.cause;            // "The customer received the STK prompt but pressed Cancel …"
err.fix;              // "Nothing is wrong with your setup — offer a clear retry …"
err.category;         // "customer"
err.retryable;        // true
err.customerMessage;  // "Payment cancelled — you can try again whenever you're ready."
```

These are byte-for-byte the strings paylod puts in the `decoded` object on a `payment.failed` webhook, so what you show a customer is the same either way. The whole catalogue is browsable in the [error reference](/docs/errors).

`decodeError` is also exported as a standalone function, along with the raw `ERROR_CATALOG`:

```ts title="Standalone"
import { decodeError, ERROR_CATALOG } from "@paylod/node";
```

### `4999` and `500.001.1001` are pending, not failed

Daraja's STK Query returns `4999` ("the transaction is still under processing") while the customer is still looking at the PIN prompt. paylod classifies it — and `500.001.1001` — as **pending**, so `status()` keeps reporting `pending` and `wait()` keeps polling.

If you ever decode one of those codes directly, `category` is `"pending"` and `retryable` is `false` — not because a retry is pointless, but because the first prompt is still live and a second push can double-charge the customer. Do not show a customer a failure on these codes, and do not re-collect. Keep polling.

## Webhooks (optional)

You do not need a webhook to take a payment. `collectAndWait()` polls for you, and `check()` lets your own frontend poll. That is a complete, supported integration, and it needs nothing but `PAYLOD_API_KEY`.

Add a webhook when you want your **server** told about a payment it is not currently waiting on: the customer closed the tab right after entering their PIN, your request timed out but the money landed anyway, or your process restarted. Polling tells the browser; a webhook tells your backend. For anything with an order to fulfil, you will eventually want both.

Set the signing secret when you do:

```bash title=".env"
PAYLOD_WEBHOOK_SECRET=whsec_...   # only if you consume webhooks
```

### Web `Request`/`Response` — Next.js, Hono, Remix, Workers, Bun, Deno

```ts title="app/api/webhooks/paylod/route.ts"
import { Paylod } from "@paylod/node";

const paylod = new Paylod();

export const POST = paylod.webhookHandler(async (event) => {
  if (event.type === "payment.success") {
    await fulfil(event.data.paymentId, event.data.mpesaReceipt);
  } else if (event.data.decoded) {
    await notify(event.data.decoded.customerMessage);
  }
});
```

`event.data.decoded` is `DecodedError | null` — populated on `payment.failed`, `null` on `payment.success`. Narrowing on `event.type` does not narrow it, so check it before reading it.

The signature is verified before your handler runs. A bad signature returns `400` and your handler is never called. If your handler throws, the route returns `500` so paylod retries the delivery — better a duplicate than a lost payment, so make your handler idempotent.

### Express

```ts title="server.ts"
app.post("/webhooks/paylod", paylod.webhook(async (event) => {
  if (event.type === "payment.success") await fulfil(event.data.paymentId);
}));
```

> [!warn] Mount this route **before** any global `express.json()`, or give it `express.raw({ type: "application/json" })`. Once a JSON parser has turned the body into an object the raw bytes are gone and the signature cannot be verified. The middleware detects this and returns a loud `400` rather than skipping verification.

### Verify it yourself

```ts title="verifyWebhook"
const event = paylod.verifyWebhook({
  payload: rawBody,                            // string | Buffer | Uint8Array — the RAW bytes
  signature: headers["paylod-signature"],
  toleranceSec: 300,                           // default
});
```

Throws `PaylodSignatureVerificationError` if the signature does not check out. The signature scheme is documented under [Webhooks](/docs/api/webhooks).

## Errors

Every error thrown by the client extends `PaylodError`.

| Class | When |
| --- | --- |
| `PaylodConfigError` | No API key, or no global `fetch`. Thrown from the constructor. |
| `PaylodInvalidRequestError` | Local validation failed — bad amount, unparseable phone. No request was sent. |
| `PaylodApiError` | paylod returned a non-2xx. Carries `.status` and the parsed body. |
| `PaylodConnectionError` | The request never got an answer (DNS, TLS, socket). Retried automatically. |
| `PaylodTimeoutError` | `wait()` hit its deadline with the payment still `pending`. Not a failure. |
| `PaylodSignatureVerificationError` | A webhook signature did not verify. |

Transient failures — network blips, `429`, `5xx` — are retried automatically with backoff. `400`, `401`, `404`, `409` and `422` are real answers and are thrown immediately.

## A complete example

A browser checkout, done safely: the page calls your server, your server calls paylod. The key never leaves the server.

```ts title="server.ts"
import { Paylod, pendingOutcome } from "@paylod/node";

const paylod = new Paylod(process.env.PAYLOD_API_KEY!);

// POST /api/pay → ring the phone.
// Takes an orderId, never an amount: the price is looked up here, so a payer
// with a proxy cannot discount their own order.
async function startPayment(orderId: string, phone: string) {
  const order = await getOrder(orderId);
  const ack = await paylod.collect({ amount: order.amount, phone });
  return pendingOutcome(ack.paymentId);
}

// GET /api/pay/:id → has it settled? Already decoded, ready to render.
async function readPayment(paymentId: string) {
  return paylod.check(paymentId);
}
```

Both functions return the same `PaymentOutcome`, so your frontend has one shape to render and no result codes to interpret.

That is the whole integration. The [paylod demo](https://github.com/paylod/demo) is a working version of exactly this, including the retry path and a verified webhook.

## Options

You will not normally need any of these. The defaults are the right answer.

```ts title="Escape hatches"
const paylod = new Paylod(process.env.PAYLOD_API_KEY!, {
  timeoutMs: 30_000,
  maxRetries: 2,
});
```

| Option | Default | What it is for |
| --- | --- | --- |
| `apiKey` | `process.env.PAYLOD_API_KEY` | Authentication. The only required value. |
| `timeoutMs` | `30_000` | Per HTTP request. |
| `maxRetries` | `2` | Transient failures only (network, `429`, `5xx`). |
| `webhookSecret` | `process.env.PAYLOD_WEBHOOK_SECRET` | Only if you consume webhooks. |
| `baseUrl` | `https://paylod.dev/functions/v1` | Baked in. Override only to self-host or to point at a stub in tests. |
| `fetch` | global `fetch` | Inject an instrumented or proxied fetch. |

| Environment variable | Used for |
| --- | --- |
| `PAYLOD_API_KEY` | Authentication. Required. |
| `PAYLOD_WEBHOOK_SECRET` | `verifyWebhook()`, `webhook()`, `webhookHandler()`. Optional. |
| `PAYLOD_BASE_URL` | Overriding the API base. Rarely needed. |

## Next

- Prefer the terminal? The [CLI](/docs/cli) sends the same STK Push in one command.
- The HTTP endpoints underneath: [API reference](/docs/api).
- Every result code, decoded: [error reference](/docs/errors).
