Libraries & tools
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 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.
npm install @paylod/nodeServer-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.
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.
Set up the client
One argument: your API key.
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:
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 at the bottom of this page.
Collect a payment
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.
{
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:
<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.
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.
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 oneAn 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.
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.
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 pendingThis 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.
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.
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.
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.
decodeError is also exported as a standalone function, along with the raw ERROR_CATALOG:
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:
PAYLOD_WEBHOOK_SECRET=whsec_... # only if you consume webhooksWeb Request/Response — Next.js, Hono, Remix, Workers, Bun, Deno
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
app.post("/webhooks/paylod", paylod.webhook(async (event) => {
if (event.type === "payment.success") await fulfil(event.data.paymentId);
}));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
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.
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.
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 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.
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 sends the same STK Push in one command.
- The HTTP endpoints underneath: API reference.
- Every result code, decoded: error reference.