Guides
Test M-Pesa payments in the Daraja sandbox
Build the whole flow against Safaricom's sandbox with test credentials — including the failure paths — before a shilling moves.
Test every failure path without a phone
Start here. You do not need a handset to test a cancellation, a wrong M-Pesa PIN, insufficient funds or a timeout. Do not try to make these outcomes by hand. paylod supplies a simulator that uses the real settlement path. The simulator makes the same payment row, the same result codes, and the same signed webhook that your production code receives. Only the handset is simulated.
Two calls:
- Create a simulated payment — use
paylod.simulate.collect()in the SDK, thesimulate_test_paymentMCP tool, orPOST /simulate/collectover REST with yourmp_test_…key. paylod sends no STK push. The call returns{ paymentId, status: "pending", outcomes }, andoutcomesis an array of{ id, label, status }. - Force the result — use
paylod.simulate.outcome(), thesimulate_outcomeMCP tool, orPOST /simulate/outcome. Give thatpaymentIdand oneoutcomeid.
outcome | Settles as | Result code |
|---|---|---|
approve | success | 0 |
user_cancelled | failed | 1032 |
timeout | failed | 1037 |
wrong_pin | failed | 2001 |
insufficient_funds | failed | 1 |
From the SDK
On Node, use this method. The method is one call. The method returns the same PaymentOutcome that your production code already displays. Thus you test the real code, and not special test code.
import { Paylod } from "@paylod/node";
const paylod = new Paylod(process.env.PAYLOD_TEST_KEY!); // mp_test_…
for (const outcome of paylod.simulate.outcomes) {
const result = await paylod.simulate.pay({
outcome,
amount: 10,
idempotencyKey: `sim-${outcome}`, // the simulator enforces the production rule
});
console.log(outcome, "→", result.status, result.retryable, result.message);
}
// approve → succeeded false Payment received — thank you!
// wrong_pin → failed true That M-Pesa PIN was incorrect. …
// insufficient_funds → failed true Your M-Pesa balance is too low. …
// user_cancelled → cancelled true Payment cancelled — you can try again …
// timeout → failed true The M-Pesa prompt expired before it was answered. …To test your handler, and not the handler of the SDK, make the client with { simulate: true }. collect() then creates a simulated payment, and sends no STK push. Your /api/pay route operates without a change. The SDK refuses an mp_live_… key locally, before it sends a request. The SDK reference gives the full details.
Over REST
# 1. create the pending simulated payment
curl -sX POST https://paylod.dev/functions/v1/simulate/collect \
-H "Authorization: Bearer $PAYLOD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"applicationId":"<your-app-id>","phone":"254708374149","amount":10}'
# → { "paymentId": "…", "status": "pending", "outcomes": [ { "id": "approve", … } ] }
# 2. force the outcome — your webhook fires, your poller settles, exactly as in production
curl -sX POST https://paylod.dev/functions/v1/simulate/outcome \
-H "Authorization: Bearer $PAYLOD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"paymentId":"<paymentId>","outcome":"user_cancelled"}'An AI agent with the paylod:payments.simulate scope can do the same with simulate_test_payment and simulate_outcome. Such an agent cannot touch real money. See the MCP server.
[!note] The simulator operates only in sandbox. The simulator is the fastest method to prove three things: your code shows outcome.message, your code offers a retry only when retryable is true, and your code never fulfils an order on a 1032.
Get sandbox credentials
Create an app on the Safaricom Daraja portal. Then open the Sandbox credentials of the app. You need four values:
| Field | Sandbox value |
|---|---|
| Consumer key | From the Daraja portal |
| Consumer secret | From the Daraja portal |
| Shortcode | 174379 (Safaricom's shared test paybill) |
| Passkey | From "Lipa na M-Pesa Online" in the portal |
Copy the four values into the Sandbox environment of your paylod application. Then create an mp_test_… key.
What sandbox does
Sandbox is the environment of Safaricom, and not a paylod simulation. The STK push is real. The STK push goes to Daraja, Daraja answers, and the callback comes back to the hosted receiver of paylod. This is the same behaviour as production. Only the money is not real, because nothing settles.
Thus each item that you build is the same in production: the 202, the paymentId, the webhook envelope, the signature and the result codes. You do not write a second integration.
Send a test payment
Set PAYLOD_API_KEY to the mp_test_… key. Nothing else changes.
import { Paylod } from "@paylod/node";
const paylod = new Paylod(process.env.PAYLOD_API_KEY!); // mp_test_… key
const outcome = await paylod.collectAndWait({
amount: 10,
phone: "254708374149", // Safaricom's published test MSISDN
accountReference: "TEST-1",
idempotencyKey: attempt.id,
});
console.log(outcome.paid ? outcome.receipt : outcome.message);Use a real number that you control, and you get a real prompt. No real money moves.
Test the failure paths
Most production incidents occur on a failure path that nobody tested. Before you go live, make sure that your code gives a correct result for each failure path below. Use the simulator above. The simulator is deterministic, needs no handset, and runs in your test suite. The handset column is only for a person who wants to see a real failure one time.
| Result code | Simulator outcome | On a real handset | What your code should do |
|---|---|---|---|
1032 | user_cancelled | Press Cancel on the prompt | Offer a retry. Do not fulfil the order |
1037 | timeout | Ignore the prompt until it expires | Retry, or use the poll method |
2001 | wrong_pin | Enter the wrong M-Pesa PIN | Ask the customer to try again |
1 | insufficient_funds | Send an amount above the test balance | Tell the customer that the balance is low |
The error reference gives every code with its cause and its correction.
Test your webhook locally
You need no tunnel. The CLI sends your live webhook events to localhost, and keeps the signature:
npx @paylod/cli listen --forward http://localhost:3000/webhookYour handler then verifies a real signature against a real payload, on your own computer. See Handle the result.
Next
Go live — replace the credentials, replace the key, and keep the code.