Accept M-Pesa payments from your terminal.
$ paylod collect --phone 254712345678 --amount 100
── collect ─────────────────────────────────────────────────
amount KES 100
phone 254712345678
mode test
✔ STK push sent — 254712345678 should be ringing now. 📲
✔ Customer approved the payment.
── result ──────────────────────────────────────────────────
Paid ✅ QGR7XK2M9P
amount KES 100
phone 254712345678
receipt QGR7XK2M9P
payment 3f8b2c14-9d5e-4a77-b1c3-6e0a9f2d4b81
paylod is a developer-experience layer on Safaricom's M-Pesa Daraja API. You do not need a backend. One call sends an STK push. paylod hosts the callback, refreshes the access token, decodes the result code, and sends you a signed webhook.
This CLI gives you all of these functions in your terminal. It also gives you a webhook forwarder that removes the need for a tunnel.
npx @paylod/cli errors 1032 # zero install, works right now
npm install -g @paylod/cli # or install it properlyThe package is @paylod/cli. The package installs the command paylod.
The CLI needs Node 20 or later. The CLI operates on Linux, macOS and Windows.
Run these commands on a server or on your own machine only. Your PAYLOD_API_KEY can move
money. Never ship the key in a browser bundle, a mobile application, or any other client.
paylod login # 1. browser opens, you approve
paylod apps create --name "My Shop" # 2. create an app (mints an API key)
paylod creds set # 3. paste your Daraja keys (masked prompts)
paylod collect --phone 254712345678 --amount 1 # 4. the phone rings
paylod listen --forward localhost:3000/webhook # 5. webhooks on localhost, no tunnelYou do not need Daraja credentials to start. Use these two commands:
paylod simulate --interactive # a "fake phone" — approve, wrong PIN, cancel, timeout…
paylod errors 1032 # decode any M-Pesa code, offline, no loginDaraja requires a public HTTPS URL for the callback. A local test therefore needs a tunnel.
You must also register the URL again after each restart. paylod hosts the callback and
normalizes the callback. listen then delivers the callback to localhost.
$ paylod listen --forward http://localhost:3000/webhook
── paylod listen ───────────────────────────────────────────
receiving on http://127.0.0.1:4242
forwarding to http://localhost:3000/webhook
signatures verified locally
Waiting for events… (Ctrl-C to stop)
3:13:39 PM --> payment.failed ✔ signature valid
amount KES 1,500
phone 254712345678
code 1032
why Payment cancelled by the customer
fix Nothing is wrong with your setup — offer a clear retry so the customer can try again.
payment 3f8b2c14-9d5e-4a77-b1c3-6e0a9f2d4b81
<-- [200] POST http://localhost:3000/webhook 13ms
The design follows stripe listen, with one improvement.
stripe listen opens an outbound WebSocket to a relay. Stripe pushes {body, headers} down
the WebSocket. The CLI then replays those bytes without a change at --forward-to. This design
needs no inbound port and no tunnel. The Stripe CLI never computes an HMAC. The Stripe
server makes the signature, and the CLI passes the signature through.
POST /webhook-secret gives the paylod CLI the actual signing secret of the endpoint.
Therefore paylod listen verifies the HMAC locally, and prints the result:
✔ signature valid
✖ signature invalid — HMAC mismatch — the signing secret does not match this payload
A signature check is a frequent source of errors. This output shows you the state of the signature check before you write your own verification code.
Two event sources supply one pipeline (verify → filter → print → forward → show response):
| Source | Status | What it does |
|---|---|---|
| relay (default) | The client is complete. The server route is NOT deployed. | An SSE stream from paylod. The stream needs a backend GET /listen/stream endpoint. That endpoint does not exist yet. See the next section. The CLI prints a clear warning, and it continues to operate. |
direct (--port) |
This source operates today. | The CLI binds a local receiver. Point a paylod webhook endpoint at the receiver, or POST to the receiver. This source uses the same verify path, forward path and print path. |
Security. The CLI never forwards an event with a signature that does not verify. The
local receiver is an open port on 127.0.0.1. Any program on your machine can POST to that
port. An unverified payload therefore reaches an application whose author expects a checked
event. Use --skip-verify to disable the check.
The relay is the one part that is not built. The relay needs a single endpoint. Verify the signature against the raw bytes. A re-serialised body does not reproduce the same bytes, and the signature check then fails.
GET /listen/stream?applicationId=<uuid>
Authorization: Bearer <OAuth access token> scope: paylod:webhooks.write
Accept: text/event-stream
→ text/event-stream, one frame per delivery:
data: {"body":"<raw JSON bytes exactly as signed>","headers":{"x-webhook-signature":"t=…,v1=…"}}
body must be the raw bytes that paylod signed. A second serialization of the JSON changes
the key order, and it breaks every signature. The file src/commands/listen.ts contains the
complete CLI part of the relay.
Every command accepts --json for a script, and --help.
| Command | What it does |
|---|---|
paylod collect --phone 2547… --amount 100 |
Send a real STK push. Show each event until the payment settles. |
paylod status <paymentId> [--watch] |
Read a payment. Decode the reason for a failure. |
paylod payments list [--status failed] |
Read the ledger. The list uses keyset pagination. |
paylod payments get <paymentId> |
Read one payment in full. |
paylod listen --forward <url> |
Forward a webhook to localhost. |
paylod simulate [--outcome …] [-i] |
Run the sandbox simulator. No Daraja call. No money. |
| Command | What it does |
|---|---|
paylod init |
Do the guided setup. Log in, select an application, and mint an API key. |
paylod login / logout / whoami |
Use OAuth 2.1 with PKCE and a loopback redirect. |
paylod orgs list|create|rename |
Manage organizations. |
paylod apps list|use|create|rename|delete |
Manage applications. |
paylod creds set |
Store or rotate Daraja credentials. The prompts are masked. |
paylod keys list|mint|revoke|use |
Manage merchant API keys. |
paylod webhooks list|add|toggle|delete|secret |
Manage webhook endpoints. |
| Command | What it does |
|---|---|
paylod errors <code> |
Decode an M-Pesa result code. No login is necessary. |
paylod errors --list |
Show the full catalog. |
paylod errors uses the own error catalog of paylod. paylod vendors the catalog from the
backend without a change. The explanation in your terminal is therefore byte-identical to the
decoded object in your webhook.
paylod has two auth planes. The CLI uses both planes:
- Merchant API keys (
mp_test_…/mp_live_…) are the runtime plane.collectandstatususe this plane. Your own server uses a merchant API key. - OAuth 2.1 is the management plane. The CLI uses an authorization code with PKCE and a
loopback redirect (RFC 8252) against
https://paylod.dev/oauth. The CLI registers the client dynamically.
paylod stores a token in this order of preference:
- The OS keychain (macOS Keychain, Windows Credential Manager or libsecret) through the
optional native
@napi-rs/keyring. This package is anoptionalDependencyfor a reason. An absent prebuilt binary for your platform must never stopnpx @paylod/cli. - A user-restricted file. paylod writes the file atomically.
ghandstripeuse the same fallback.
Only the refresh token goes to the keychain. The short-lived access token stays in the config file.
paylod tells you when it does not use the keychain, and it tells you the reason. A refresh
token in the macOS Keychain and a refresh token in a file do not have the same security
posture. Only you can decide whether the difference is important to you. paylod login and
paylod whoami both report the store in use. If the store is the file, both commands report
the reason.
| Platform | Path |
|---|---|
| Linux | $XDG_CONFIG_HOME/paylod or ~/.config/paylod |
| macOS | ~/Library/Application Support/paylod |
| Windows | %APPDATA%\paylod |
PAYLOD_CONFIG_DIR overrides all of these paths. paylod continues to use a ~/.config/paylod
directory from an older version. An upgrade therefore keeps your login.
| Platform | Mechanism |
|---|---|
| Linux / macOS | A 0600 file inside a 0700 directory. paylod then verifies the file with stat. |
| Windows | An explicit ACL (icacls /inheritance:r /grant:r <you>:F). paylod then verifies the ACL with a second read. |
Fixed in 0.2.0. Through 0.1.0 the CLI called
chmod(0600)on every platform, and the CLI called that call the guarantee. On Windowschmodis a no-op. The call does not throw an error, and the call does not restrict any user. The credential file holds OAuth tokens andmp_live_API keys that move real money. That file kept the inherited ACLs, and other users of the machine could read the file.paylod now applies the mechanism that operates correctly on the host, and paylod verifies the mechanism. If paylod cannot protect the file, paylod reports the failure on stderr. paylod does not claim a protection that paylod does not supply.
paylod whoamireports the real, newly measured state of the file.
Scopes. The consent screen of paylod sets the high-risk scopes (keys.mint,
credentials.write, payments.payout) to OFF by default. If you did not select a scope,
the CLI reports the exact scope:
✖ insufficient scope: paylod:keys.mint
Your session was not granted `paylod:keys.mint`. Run `paylod login` again and TICK
"keys.mint" on the consent screen — high-risk scopes default to OFF.
| Variable | Purpose |
|---|---|
PAYLOD_API_KEY |
The merchant API key. The CLI then does not read the config file. Use this variable in CI. |
PAYLOD_API_BASE |
Override the API base URL. The default is https://paylod.dev/functions/v1. |
PAYLOD_WEBHOOK_SECRET |
The signing secret for paylod listen. |
PAYLOD_CONFIG_DIR |
The location of the config. See "Where the credential file lives". |
PAYLOD_NO_KEYCHAIN=1 |
Never use the OS keychain. Use the credential file. |
PAYLOD_AS_ISSUER |
Override the OAuth authorization server. |
PAYLOD_SUPPRESS_PERMISSION_WARNING=1 |
Stop the "could not restrict your credential file" warning. Set this variable only after you read the warning and accept the risk. |
PAYLOD_DEBUG=1 |
Print stack traces. |
NO_COLOR |
Disable colour. |
See .env.example. paylod never commits a secret, never logs a secret, and never echoes a
secret.
A script can branch on these codes.
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Generic failure |
2 |
Incorrect usage or an invalid argument |
3 |
The payment failed. This result is a valid outcome, not an error. |
4 |
You are not authenticated |
5 |
The payment was still pending when the CLI stopped the wait |
130 |
The CLI was interrupted |
paylod collect -p 254712345678 -a 100 --json > result.json \
&& echo "paid: $(jq -r .mpesaReceipt result.json)" \
|| echo "not paid (exit $?)"First, install the dependencies. Then build the CLI and run it once:
npm install
npm run build # tsc → dist/
npm run typecheck
node dist/index.js errors 1032Use these commands during development:
npm run build # compile to dist/ (what ships — tests excluded)
npm test # compile tests to build/test/ and run them
npm run typecheck # type-check everything, including tests
npm run catalog:check # fail if the vendored Daraja catalog has driftedThe script scripts/vendor-daraja-catalog.mjs vendors src/lib/daraja-catalog.ts and
src/lib/daraja-error-codes.ts from the single source of truth in the paylod monorepo. Both
files carry a DO-NOT-EDIT banner.
This rule is important. retryable in that table means SAFE TO CHARGE AGAIN. It does not
mean that the customer may press a button. A hand-maintained copy of the table drifts from the
payment engine. Such a drift caused a customer-facing double charge two times. Through
0.1.0 the CLI carried a fifth, hand-written fork of the table, and that fork had already
drifted. An unknown result code decoded as retryable: true, which invites a second charge of
a payment with an unproved outcome. Result code 1037 also claimed that the handset of the
customer was unreachable. In most cases the customer only ignored the STK push.
node scripts/vendor-daraja-catalog.mjs # regenerate from the monorepo
node scripts/vendor-daraja-catalog.mjs --check # CI drift guard (exit 1 on drift)The script finds the monorepo at ../mpesa, or at the path in PAYLOD_MONOREPO. Without the
monorepo, --check skips the comparison. The generated files are committed, so a build of the
CLI never needs the monorepo.
MIT.