M-Pesa's STK Push sends a payment prompt to a customer's phone. A successful initiation response means the request was accepted; it does not mean the customer paid. The authoritative result arrives later through the callback. A reliable integration therefore needs to handle a lost response, a replayed callback, or a retry without turning one intended payment into two.
How STK Push actually completes a payment
Two independent things happen, and confusing them is the root of most double-charging bugs. First, your server asks Daraja to send a prompt to the customer's phone. Daraja replies immediately with a CheckoutRequestID. That reply means the prompt was sent. It does not mean the customer paid. Second, minutes or seconds later, Safaricom POSTs the real outcome to the callback URL you registered: a result code, the amount, the M-Pesa receipt number.
So a payment is not paid when the API call succeeds. It is paid when the callback says ResultCode 0. Everything below follows from taking that seriously.
Create the record before you call the gateway
A safer booking flow creates a payment row in a PENDING state with the amount due, calls STK Push, and stores the CheckoutRequestID on that row. A definite provider rejection can be recorded as a failure. If the request may have reached the provider but the response is lost, keep the attempt pending and reconcile it before asking the customer to try again. The record preserves what was attempted and which booking it belongs to.
const payment = await db.payment.create({
data: { bookingId, amountKes: balance, method: "MPESA", status: "PENDING" },
});
const result = await initiateStkPush({
phone, // validate and normalize before this line
amountKes: balance, // send the whole-shilling amount expected by the API
accountReference: booking.reference,
transactionDesc: "Course fee",
});
await db.payment.update({
where: { id: payment.id },
data: { gatewayRef: result.checkoutRequestId }, // callback lookup key
});Make the callback idempotent, and always acknowledge it
Callbacks may be delivered again, so handle them idempotently. Look up the payment by its CheckoutRequestID and check its state before applying a result. If the payment is no longer PENDING, acknowledge the repeat without applying it again. If no payment matches, acknowledge the unknown reference and log it for investigation rather than creating a payment from untrusted callback data.
const payment = await db.payment.findFirst({
where: { gatewayRef: result.checkoutRequestId, method: "MPESA" },
});
if (!payment) return Response.json({ ok: true }); // ack, nothing to apply
if (payment.status !== "PENDING") return Response.json({ ok: true }); // already applied
if (result.success) {
await db.payment.update({ where: { id: payment.id },
data: { status: "PAID", reference: result.mpesaReceiptNumber } });
} else {
await db.payment.update({ where: { id: payment.id },
data: { status: "FAILED", notes: result.resultDesc } });
}Never mark a payment PAID just because a callback arrived. Verify the callback against the pending record, including the amount and currency where the provider supplies them, before confirming the booking. Send mismatches to review rather than marking them paid.
Timeouts: the case where you honestly do not know
The most dangerous moment in the whole flow is a timeout after you sent the push request. Did Daraja receive it? Did the customer get prompted? You cannot know from your side. The wrong response is to automatically retry, because if the first prompt did arrive, the customer now sees two prompts and may pay both. The right response has three parts.
- Keep the reference. The payment row and its CheckoutRequestID stay queryable, so the attempt is never an orphan.
- When an initiation's outcome is uncertain, leave the attempt pending, keep its reference, and tell staff to check the existing attempt before starting another one.
- Give the uncertainty a human path. Keep the reference available, and settle the record only after the provider's status is known.
A retry is safe only when it refers to the same business operation. Use a stable idempotency key, enforce its uniqueness on the server, and return the existing attempt when that key is seen again. A new key should mean a deliberate new operation, not an automatic response to a timeout.
Reconciliation is a feature, not an apology
Some payments need a human decision: the provider reports success but the related order or receipt could not be completed, or the gateway record disagrees with the application. Give the operation a review state with a reason, preserve its reference, and make the next action explicit. Verify the amount, currency, and destination before settlement. Do not silently discard the discrepancy or assume an automatic refund is safe.
Store the provider response with the payment record, subject to the system's retention and privacy rules. When a transaction needs investigation, the operator should be able to compare the callback, the internal state, and the business record without relying on an email thread.
The short version
- The API response means prompted, not paid. The callback is the only truth.
- Write the PENDING record before calling Daraja; store the CheckoutRequestID on it.
- Callbacks are idempotent by payment status, and unknown callbacks are acknowledged, not erroring.
- Timeouts leave the reference queryable and never trigger an automatic re-prompt.
- Retries are deduplicated by a server-enforced request key, not by hope.
- Anything that cannot be settled automatically goes to review with a reason, and the raw gateway payload is kept.
None of this is exotic. It is ordinary engineering discipline applied to the payment rail your customers actually use. If you are buying rather than building, ask a vendor exactly these questions. The answers tell you whether they have taken money over M-Pesa in production or only read the docs.