paylod
Accept a payment

Guides

Accept an M-Pesa payment with an STK Push

.md

Collect money from a customer with an M-Pesa STK Push: what to send, what comes back, and what the customer sees.

Charge a customer. Your server sets the amount. The customer's handset shows a prompt, the customer enters the M-Pesa PIN, and the money settles to your till.

Send the push

Always pass `idempotencyKey`. Create one idempotency key for each payment attempt. A double-clicked Pay button, a refreshed tab and a retried job are duplicates of one attempt. Duplicates of one attempt then become one prompt and one charge. If you do not pass idempotencyKey, each call is a new charge: two clicks give two prompts and two debits. Do not use the order id or the product id as the idempotency key. A used key replays an old payment, and does not make a new payment. See Idempotency.

import { Paylod } from "@paylod/node";

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

const outcome = await paylod.collectAndWait({
  amount: 1500,                     // whole KES, from YOUR order record
  phone: "0712345678",
  idempotencyKey: attempt.id,       // one key per payment attempt. A double-click cannot charge twice.
  accountReference: "INV-2041",     // YOUR correlation id — comes back on the result
  description: "Order #2041",
  metadata: { orderId: "2041" },    // opaque, stored with the payment
});

if (outcome.paid) {
  await fulfil("2041", outcome.receipt);
} else {
  await notify(outcome.message);
}

collectAndWait() sends the STK push. Then collectAndWait() polls until the payment settles. You need only one condition: outcome.paid. If outcome.paid is false, outcome.message is a decoded sentence that you can show to the customer.

Do you want the paymentId immediately? Use collect(). collect() returns when the prompt is on the handset. See Handle the result.

What M-Pesa requires

  • Whole shillings. amount is an integer from 1 to 150,000. M-Pesa rejects decimals.
  • A Kenyan number. 0712…, 254712… and +254712… all work. The SDK puts them into a standard format.
  • One prompt at a time. You cannot send a second prompt to a customer who already has a prompt.

The maximum length of accountReference is 12 characters. The maximum length of description is 64 characters. The SDK checks both fields locally, before the request leaves your process.

What accountReference is

accountReference is a correlation id that you select, for example an invoice number or an order number. paylod stores accountReference on the payment. paylod then returns the value as accountRef on the webhook. Thus you use accountReference to connect an M-Pesa payment to the order in your own database. The GET /status/:id read returns only id, status, mpesaReceipt, resultCode and resultDesc.

Never put text that the customer must read into accountReference. The description field supplies the text that the prompt shows. Also, never put a secret into accountReference, because a payer on a paybill can see the value.

accountReference is not a message to the customer. Safaricom shows accountReference only on a paybill (CustomerPayBillOnline). On a paybill, accountReference is the account number that the payer pays to. On a till shortcode (CustomerBuyGoodsOnline), Safaricom does not show accountReference. The payer sees only your business name and the amount. Most small merchants use a till. Thus you must assume that the customer never reads accountReference.

If you omit accountReference, paylod uses a short prefix of the paymentId as the default value. The default value is unique, and it connects the payment to your M-Pesa statement.

accountReference is a label, and not a lock. If you put your order id into accountReference, a second charge can still occur. accountReference only marks the payment. `idempotencyKey` is the field that collapses duplicates. The two fields have different functions: put the order id into accountReference, and put a key for each attempt into idempotencyKey.

Idempotency: one key per payment attempt

A customer double-clicks Pay, and your handler runs two times. Without an idempotency key, the customer gets two STK prompts and two debits. Then the customer asks you for a refund and an explanation.

Create a key when the attempt starts, and two debits cannot occur:

const attempt = await db.attempts.create({ orderId: order.id });   // one row per press of Pay

await paylod.collectAndWait({
  amount: order.amount,
  phone,
  idempotencyKey: attempt.id,   // ← the whole fix
});

The two requests carry the same key. paylod reserves the key before paylod calls Daraja. Thus only one request reaches Daraja, and the other request replays the first answer. Both requests get the same paymentId and the same checkoutRequestId. The handset shows only one prompt. This behaviour also applies when the two requests arrive in the same millisecond: ten concurrent calls with one key make one payment and one STK push.

What the customer doesWith a per-attempt idempotencyKeyWithout a key
Double-clicks Pay1 prompt, 1 charge2 prompts, 2 charges
Refreshes the tab and re-submits1 prompt, 1 charge2 prompts, 2 charges
Your job queue retries the handler1 prompt, 1 charge2 prompts, 2 charges

If you omit the key, the SDK gives you a warning one time. A missing key is a money defect.

Per attempt — not per order, not per product

The key must be the same for all duplicates of one attempt. The key must also be new for a new charge. Two usual short methods do not obey these two rules:

  • The order id is the same for all duplicates, but the order id is never new. The customer enters a wrong M-Pesa PIN. You then retry the order with the same key. paylod replays the failed first attempt, and does not charge the customer. Thus the order is never paid.
  • A product id, or any other value that you use again for a different purchase, is worse. The second customer buys the same product and uses the key that the first customer already spent. paylod then replays the payment of the first customer. paylod charges no customer after the first customer.

A retry after a wrong M-Pesa PIN, a cancelled prompt or a timeout is a new attempt. A new attempt needs a new row and a new key.

Do not stop the SDK warning with idempotencyKey: crypto.randomUUID() at the call site. A key that you generate again on each call has the same effect as no key. Such a key only hides the warning that tells you the customer is exposed. A random UUID is a good key. But you must create the UUID one time for each attempt and store it. Do not generate a new UUID inside the call.

When the same key is not a safe retry

If you use a key again with a different body, for example a different amount, paylod returns 409. A 409 of this type is always a defect in your code: two different charges use one key.

One condition looks like a network error, but you must not retry it. A request can stop after paylod sent the call to Daraja and before an answer came back. That key is then spent. If you retry the spent key, paylod returns 409 with an indeterminate message, and sends no second STK push. The outcome of such a payment is unknown, and a timeout is not proof that the money did not move. Thus paylod does not guess: for money, at-most-once is better than at-least-once.

After an indeterminate `409`, do not retry with the same key. First read the payment status with paylod.check(paymentId) or GET /status/:id. Then decide. If the payment settled, fulfil the order. If no payment occurred, open a new attempt with a new key. A retry with the spent key returns the same 409.

You cannot always supply a key at the start. Then store the generated key with the attempt before you retry:

// No key at the start: ask the SDK to generate one, then store what it used.
const ack = await paylod.collect({
  amount: 1500,
  phone: "0712345678",
  unsafeGeneratedIdempotencyKey: true,
});
await db.attempts.update(attempt.id, { idempotencyKey: ack.idempotencyKey });

// Retrying THAT attempt: same key, same body → the original payment, no second prompt.
await paylod.collect({
  amount: 1500,
  phone: "0712345678",
  idempotencyKey: ack.idempotencyKey,
});
// A genuinely new attempt gets a NEW key. Reusing this one would replay the old result.

Never take the amount from the client

Always take the amount from your own order record, on your own server. A browser or a mobile app can hold both the API key and the amount. A payer can then change the request and pay less. This is the only method that lets a payer pay you less, and you can prevent it. See Secure integration.

Next

Handle the result describes webhooks. It also tells you what to check before you fulfil an order.