paylod
Node SDK

Libraries & tools

Node SDK for M-Pesa: @paylod/node

.md

@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 that the API reference documents. The client also adds the parts that almost every integration must write:

  • phone-number normalisation
  • idempotency keys
  • a polling loop with backoff
  • webhook signature verification
  • an offline result-code decoder

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

This page is the Node reference. Node is the only client that paylod publishes today. The PHP, Python, Java and Kotlin clients are still in development, and so is the WooCommerce plugin. These clients will mirror this same surface; see SDKs & libraries. Until paylod releases them, call the HTTP API directly from those languages. Source: github.com/mosesmrima/paylod-sdk.

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 call it from a browser or a mobile app.

Never put an API key in client-side JavaScript. Every visitor downloads that key, and the key is then fully compromised. If you need a browser checkout, make the browser call your server, and let your server call paylod. See Secure integration.

Set up the client

One argument: your API key.

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 get and refresh, and no callback URL to host. If you omit the argument, the client reads PAYLOD_API_KEY from the environment:

Also fine
const paylod = new Paylod();

In both cases the client throws a PaylodConfigError immediately when it finds no key. It does not give you a client that fails on its first call.

Timeouts, retries and the other options have sensible defaults. You can override them, but you rarely need to. See Options at the end of this page.

Collect a payment

collect.ts
const outcome = await paylod.collectAndWait({
  amount: 100,
  phone: "0712345678",
  idempotencyKey: attempt.id,      // one key per payment attempt — a double-click cannot charge twice
});

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. This flat object holds a customer-facing message in decoded form, and a retryable flag for your retry button.

Pass `idempotencyKey`, and make one key for each payment attempt. Duplicates of one attempt collapse into one prompt and one charge: a double-clicked Pay button, a refreshed tab, or a redelivered job. If you omit the key, every call makes a new charge. Do not key on the order id or the product id. Such a key replays an old payment instead of a new one. A retry after a wrong PIN is a new charge and needs a new key. See Idempotency.

A wrong PIN is a business outcome, not a crash. The client returns it as data, and does not throw an error.

The client normalises phone numbers for you. 0712345678, +254712345678, 254712345678 and 712345678 all work.

The outcome

Every method that settles a payment returns the same shape.

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 whole UI:

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

Do not switch on result codes. Your app needs no error table. To decide what to show a person, use message and not code. The code and detail fields are for your records and for support tools.

Gate the retry button on retryable. retryable means safe to charge again. It does not mean that the customer is allowed to press a button. A pending payment is never retryable, because the STK prompt is still live on the handset. A second collect call sends a second prompt, which can charge the customer twice.

Methods

collect(params)

The client sends the STK Push and returns as soon as the prompt reaches the handset. The payment is pending.

collect
const ack = await paylod.collect({
  amount: 100,                      // whole KES, 1–150000. M-Pesa rejects decimals.
  phone: "0712345678",
  idempotencyKey: attempt.id,       // PASS THIS — one per payment attempt (see below)
  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;     // the key that was sent — yours, or a random one if you passed none

idempotencyKey collapses duplicate deliveries of one payment attempt into a single charge. Make one key for each attempt and store it. See Idempotency for what the key guarantees, what it does not guarantee, and what happens if you omit it.

The client validates amount, phone, accountReference and description locally, before the request leaves your process. A wrong amount fails immediately in your own stack trace, and not as a 422 one round trip later.

accountReference is a correlation id that you choose. It is not a message to the customer. It comes back as accountRef on the webhook, so you use it to tie a payment to your order. The status() read returns only id, status, mpesaReceipt, resultCode and resultDesc, and not accountRef. Safaricom shows accountReference to the customer only on a Paybill (CustomerPayBillOnline), where it is the account number. On a Till / Buy Goods shortcode Safaricom does not show it at all: the customer sees your business name and the amount.

description is the field that reaches the STK prompt. If you omit accountReference, it defaults to a short prefix of the paymentId. That default is still unique, and you can still trace it back to the payment.

accountReference is only a label. Your order id in that field does not remove any duplicate. idempotencyKey is the field that collapses duplicates of one attempt. Put your order id in accountReference, and a per-attempt key in idempotencyKey. The two fields do different jobs.

status(paymentId)

This method reads a payment. If the payment is still pending, paylod runs a live M-Pesa STK Query and settles the payment before it answers.

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)

This method reads a payment and decodes it. It returns a PaymentOutcome that you can show.

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

A polling endpoint in your own app should return this shape to your frontend.

wait(paymentId, options)

This method polls an existing payment until the payment settles. It uses a jittered backoff ramp (1s → 5s). It returns a PaymentOutcome.

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 that a payment settles from the result code, and not from the raw status field. Daraja reports 4999 on a payment that it also marks failed. That code means the prompt is still live. wait() continues to poll, and does not report a failure for a payment that can still succeed.

If the payment is still pending at the deadline, wait() throws a PaylodTimeoutError. That error is not a failure, and paylod deliberately does not fold it into status: "failed". The customer can still type the PIN. Leave the order open.

collectAndWait(params, options)

This method calls collect() and then wait(). Most integrations want this one call. It takes the same params as collect(), and this includes idempotencyKey. Pass one idempotencyKey for each payment attempt.

pendingOutcome(paymentId)

This function returns the form that your UI can show for a prompt that you just sent. collect() gives you an ack, but a prompt on a handset is a pending payment. Your UI shows that payment like any other state.

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

This method decodes an M-Pesa result code offline. It makes no network call, and it needs no API key at call time.

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 strings are byte-for-byte the strings that paylod puts in the decoded object on a payment.failed webhook. What you show a customer is therefore the same in both cases. You can browse the whole catalogue in the error reference.

paylod also exports decodeError as a standalone function, together with the raw ERROR_CATALOG:

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 still looks at the PIN prompt. paylod classifies 4999 and 500.001.1001 as pending. Therefore status() continues to report pending, and wait() continues to poll.

If you decode one of these two codes directly, category is "pending" and retryable is false. retryable is false because the first prompt is still live, and a second push can charge the customer twice. It is not false because a retry is pointless. Do not show a customer a failure on these codes, and do not collect again. Continue to poll.

Test your checkout without a phone

Payment bugs live in your failure paths, and until now a test of those paths needed a real handset and a wrong PIN. paylod.simulate removes the handset. It removes nothing else. You get a real payment row, the real Daraja result codes, and the real settlement path. paylod also sends a real signed webhook to your webhook endpoint. Only the handset is not real.

paylod.simulate requires an mp_test_… key. The client refuses a mp_live_… key locally, before it sends a request. A simulator must never touch production.

checkout.test.ts
import { Paylod } from "@paylod/node";

const paylod = new Paylod(process.env.PAYLOD_TEST_KEY!);   // mp_test_…

const outcome = await paylod.simulate.pay({ outcome: "wrong_pin" });

outcome.status;     // "failed"
outcome.message;    // "That M-Pesa PIN was incorrect. Please try again and enter the right PIN."
outcome.retryable;  // true — no money moved, so a fresh charge is safe

That is an ordinary PaymentOutcome. It is the same object that check() and wait() return. There is no "simulated" type and no special branch. The code that you test is therefore the code that runs in production.

The five outcomes

outcome is a typed union. A typo is therefore a compile error, and not a 422 that you find in CI.

outcomestatusResult coderetryable
approvesucceeded0false (it worked)
wrong_pinfailed2001true
insufficient_fundsfailed1true
user_cancelledcancelled1032true
timeoutfailed1037true

SIM_OUTCOMES (or paylod.simulate.outcomes) is the whole list. You can loop over it:

Drive every path
for (const outcome of paylod.simulate.outcomes) {
  const result = await paylod.simulate.pay({ outcome });
  expect(render(result)).toMatchSnapshot();
}

[!note] timeout here is Daraja's 1037, which means that M-Pesa could not reach the handset. That result is a settled failure. It is not PaylodTimeoutError. wait() throws PaylodTimeoutError when a payment is still pending at your deadline. An indeterminate payment is not a failed payment.

simulate.collect() and simulate.outcome()

Split the simulation in two parts, and put your code in the middle. The payment id is a real id, so your poller, your webhook route and your UI all run unchanged:

Testing your own code
const sim = await paylod.simulate.collect({ amount: 250 });

await readCheckout(sim.paymentId);   // your code — sees a genuinely pending payment
await paylod.simulate.outcome(sim.paymentId, "insufficient_funds");
const view = await readCheckout(sim.paymentId);   // your code — sees the settled failure

expect(view.message).toMatch(/balance is too low/);

A simulated payment stays pending until you force an outcome. A live prompt behaves the same way while a customer looks at it. You can settle a simulated payment only once. A second call gets a 409, the same as a real handset.

How to test your charge path: { simulate: true }

The steps above still do not exercise your own collect() call. Construct the client with simulate: true. Then collect() and collectAndWait() create a simulated payment instead of a real STK Push to a handset. Your /api/pay handler therefore runs completely unchanged:

Your handler, under test
const paylod = new Paylod(process.env.PAYLOD_TEST_KEY!, { simulate: true });

const view = await startCheckout(order.id, "0712345678", attemptId);   // your handler, verbatim
await paylod.simulate.outcome(view.paymentId!, "user_cancelled");      // no handset involved
expect((await readCheckout(view.paymentId!)).status).toBe("cancelled");

The constructor throws PaylodSandboxOnlyError if the key is not mp_test_…. This flag can therefore never point at production, even by accident.

[!note] The simulator obeys Idempotency-Key with the same semantics as production. The same key returns the same paymentId and creates no second payment. Concurrent duplicates collapse into one payment. A different key creates a new payment. A test that asserts "a double-click cannot charge twice" therefore tests the real behaviour, and not a stub of it.

Idempotency

An idempotency key names one payment attempt. Duplicate deliveries of that one attempt collapse into a single charge: a double-click, a refreshed tab, a job-queue redelivery, or an internal network retry. That is the guarantee. Understand it precisely, because it is narrower than "a reused key is always a safe retry".

One key per payment attempt
const attempt = await db.attempts.create({ orderId: order.id });   // a row per press of Pay
await paylod.collectAndWait({ amount, phone, idempotencyKey: attempt.id });

Pass a key per attempt — not per order, and not per product

The key must be stable across duplicates of one attempt and fresh for a genuinely new charge. An order id is stable, but it is not fresh. A product id is not fresh either, and many customers share it:

Key you passWhat happens
An id minted per payment attemptCorrect. Duplicates of that attempt collapse into one charge. A new attempt is a new charge.
Your order idThe customer types a wrong PIN, and you retry the same order. paylod then replays the failed first attempt and charges nobody. The order never gets paid.
A product id (or any value reused across purchases)Catastrophic. Every customer after the first one replays the first-ever payment for that product. paylod charges nobody after customer one.
crypto.randomUUID() per callThe same as no key: a double-click makes two keys, two prompts, and two charges.

One rule resolves all four rows. Make the key when a payment attempt starts. Store the key on that attempt. Never reuse it for a different charge. A retry after a wrong PIN, a cancelled prompt or a timeout is a new attempt, with a new row and a new key.

A missing key is a money bug. The SDK therefore writes a one-time console.warn when you call collect() or collectAndWait() without a key.

Do not silence that warning with idempotencyKey: crypto.randomUUID() or Date.now() at the call site. A key that changes on every call is exactly the same as no key at all. Such a key only hides the warning that tells you the customer is at risk. A random UUID is a good key, but you must make it once for each attempt and store it. Do not generate the key inside the call.

A concurrent double-click cannot double-charge

This part is unconditional. Send ten simultaneous requests with the same Idempotency-Key, and you get one payment and one STK push. paylod reserves the key before it calls the provider, so exactly one request wins. The other requests wait for the answer of the winner and then replay that answer. All ten calls come back with the same paymentId. The handset receives only one prompt.

The one case where the same key is not a safe retry

A request can die mid-flight against Daraja: after paylod hands the call to the provider, and before an answer comes back. That key is then spent. A retry under that key does not silently send the request again. It returns 409 with an indeterminate message:

A previous request with this Idempotency-Key was interrupted while the provider call was
in flight, so it may or may not have completed. We will not repeat it — that could charge
or pay twice. Check the payment/disbursement status; if nothing happened, retry with a NEW key.

A timeout is not evidence that the money did not move. It is the absence of evidence. paylod therefore refuses to guess. For money, at-most-once is better than at-least-once. paylod makes you check the status, rather than charge someone twice.

If you get the indeterminate `409`, do not retry with the same key. Read the payment status first, with paylod.check(paymentId), GET /status/:id, or your webhook endpoint. Then decide. If the payment did settle, you are done. If nothing happened, start a new attempt with a new key. A retry under the spent key returns the same 409 every time.

The rules

  • Same key + same body, already settled → paylod replays the original payment. You get the same paymentId and the same checkoutRequestId. There is no second prompt and no second debit.
  • Same key + same body, first request still in flight409 with a Retry-After. For a plain double-click, the SDK waits for the winner and gives you its response.
  • Same key + different body409, which the SDK shows as PaylodApiError with .isIdempotencyConflict === true. This is always a bug on your side: two different charges collided on one key. For example, you changed the amount but kept the key.
  • Same key, previous attempt interrupted against the provider409 indeterminate. Read the status, then retry with a new key. paylod never sends that request again.
  • Internal retries (network blip, 5xx, 429) reuse the same key automatically. That reuse is what makes a retry of a POST safe. If the interruption happened against Daraja, that retry returns the indeterminate 409 instead of a second charge.

If you did not pass a key, and you want to retry the same attempt later, store the key that you got back:

Persist the key with the attempt
const ack = await paylod.collect({ amount, phone });
await db.attempts.update(attempt.id, { idempotencyKey: ack.idempotencyKey });
// Retrying THAT attempt with THAT key collapses into the original payment.
// A genuinely new attempt gets a new key — reusing this one would replay the old result.

Do you call the HTTP API directly? Use the Idempotency-Key request header. It behaves in the same way.

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 paylod to tell your server about a payment that your server does not wait on. For example, the customer closed the tab right after PIN entry, your request timed out but the money landed, or your process restarted. Polling tells the browser, and a webhook tells your backend. If you have an order to fulfil, you will want both.

Set the signing secret when you add a webhook:

.env
PAYLOD_WEBHOOK_SECRET=whsec_...   # only if you consume webhooks

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

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. paylod populates it on payment.failed, and sets it to null on payment.success. A check on event.type does not narrow that field, so check the field before you read it.

paylod verifies the signature before your handler runs. A wrong signature returns 400, and paylod never calls your handler. If your handler throws, the route returns 500 and paylod retries the delivery. A duplicate is better than a lost payment, so make your handler idempotent.

Express

server.ts
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" }). A JSON parser turns the body into an object and destroys the raw bytes. paylod cannot verify the signature without the raw bytes. The middleware detects this problem and returns a 400, rather than skip the verification.

Verify it yourself

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

verifyWebhook() throws PaylodSignatureVerificationError if the signature does not verify. Webhooks documents the signature scheme.

Errors

Every error that the client throws extends PaylodError.

ClassWhen
PaylodConfigErrorNo API key, or no global fetch. The constructor throws it.
PaylodSandboxOnlyErrorA simulate call (or { simulate: true }) with a key that is not mp_test_…. The client throws it locally and sends no request. It extends PaylodConfigError.
PaylodInvalidRequestErrorLocal validation failed: a wrong amount, or a phone number that the client cannot parse. The client sent no request.
PaylodApiErrorpaylod returned a non-2xx status. It carries .status and the parsed body.
PaylodConnectionErrorThe request never got an answer (DNS, TLS, socket). The client retries it automatically.
PaylodTimeoutErrorwait() hit its deadline with the payment still pending. This is not a failure.
PaylodSignatureVerificationErrorA webhook signature did not verify.

The client retries transient failures automatically, with backoff: network blips, 429 and 5xx. 400, 401, 404, 409 and 422 are real answers, and the client throws them immediately.

A complete example

This is a safe browser checkout. The page calls your server, and your server calls paylod. The key never leaves the server.

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

  // One row per press of Pay — that row's id IS the idempotency key.
  // Double-click Pay and the second call replays the first payment instead of
  // sending a second STK prompt. Retry after a wrong PIN and you get a NEW
  // attempt, a NEW key, and a real second charge — which is what a retry means.
  // Keying on `order.id` would replay the FAILED attempt forever; keying on a
  // product id would replay the first customer's payment to everyone after them.
  const attempt = await db.attempts.create({ orderId: order.id });

  const ack = await paylod.collect({
    amount: order.amount,
    phone,
    idempotencyKey: attempt.id,
  });
  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. Your frontend therefore has one shape to show, and no result codes to interpret.

That is the whole integration. The paylod demo is a working version of exactly this integration. It includes the retry path and a verified webhook.

Options

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

Escape hatches
const paylod = new Paylod(process.env.PAYLOD_API_KEY!, {
  timeoutMs: 30_000,
  maxRetries: 2,
});
OptionDefaultWhat it is for
apiKeyprocess.env.PAYLOD_API_KEYAuthentication. The only required value.
timeoutMs30_000Per HTTP request.
maxRetries2Transient failures only (network, 429, 5xx).
webhookSecretprocess.env.PAYLOD_WEBHOOK_SECRETOnly if you consume webhooks.
baseUrlhttps://paylod.dev/functions/v1paylod sets this value. Override it only when you self-host, or when you point the client at a stub in tests.
fetchglobal fetchInject an instrumented or proxied fetch.
simulatefalseTests only. collect() creates a simulated payment instead of a real STK Push to a handset. It requires an mp_test_… key; otherwise the constructor throws. See Test your checkout without a phone.
Environment variableUsed for
PAYLOD_API_KEYAuthentication. Required.
PAYLOD_WEBHOOK_SECRETverifyWebhook(), webhook(), webhookHandler(). Optional.
PAYLOD_BASE_URLAn override of the API base URL. Rarely needed.

Next

  • Do you 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.