bitcoin.co.ke · Lightning to M-Pesa
Pay any Kenyan phone number over Lightning
Every Safaricom number is a Lightning address. Pay it like you would pay any Lightning address, and the recipient gets Kenyan Shillings in their M-Pesa wallet seconds later. No account, no API key, no onboarding call. It is plain LNURL-pay, so if your code already pays Lightning addresses it already works.
00What this is
An ordinary LNURL-pay server living at
bitcoin.co.ke/.well-known/lnurlp/<phone>, where the username is a Kenyan
mobile number. It speaks
LUD-16,
LUD-06, plus a
currency extension for Shilling-denominated amounts, so any
Lightning address client already talks to it.
Four steps, two of which are just Lightning.
Everything is on one host, https://bitcoin.co.ke. Three endpoints, and you only need the first two.
| Endpoint | What it does |
|---|---|
| GET /.well-known/lnurlp/{phone} | Limits and rate, then invoices. Step 1 and step 2. |
| GET /api/payment/{paymentHash} | Payment and payout status, including the M-Pesa code. Step 3. |
| GET /receipt/{paymentHash} | The same thing as a page a human can read. Receipt link. |
Every endpoint on this page is public, unauthenticated, and returns
Access-Control-Allow-Origin: *, so browser code can call it directly.
Rate limiting is applied at the edge, so batch politely.
A number whose owner has claimed it points at their own Bitcoin wallet instead of M-Pesa. You pay it with exactly the same code, so you can ignore this until you care about the differences in limits and receipts, covered in Claimed numbers.
01Address format
The username part of the address is a Kenyan mobile number in international format,
with no + and no spaces.
| Input | Result |
|---|---|
| [email protected] | Canonical form. Use this one. |
| [email protected] | Local format, normalized to 254712345678 for you |
| [email protected] | Rejected. Strip the plus first. |
| [email protected] | Not a phone number, so it redirects to our BTCPay Server for static addresses |
Only Safaricom ranges resolve today, because M-Pesa delivery is the point. The accepted prefixes after the 254 country code are 70x through 79x, 11x, and 14x, each followed by six digits for twelve digits total. A number outside those ranges gets the non-phone redirect, not a payment.
The regex fails closed on purpose. A number we accept but M-Pesa cannot reach becomes a refund, so new ranges are added one block at a time as Safaricom releases them. If a valid Safaricom number is being rejected, tell us and we will add the block.
02Quickstart
Three calls, end to end. Read the limits, ask for an invoice priced in Shillings, pay it with any Lightning wallet, then poll for the M-Pesa receipt.
# 1. What can this address accept right now? curl "https://bitcoin.co.ke/.well-known/lnurlp/254712345678" # 2. Ask for an invoice that delivers KES 100 to that number curl "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?amount=100¤cy=KES" # 3. Pay the returned BOLT11, then poll with its payment hash curl "https://bitcoin.co.ke/api/payment/<paymentHash>"
If you already have a Lightning address client, step 1 and 2 are what it does natively. Point it at [email protected] and it will work with no changes at all. Step 3 is the only bitcoin.co.ke specific part, and it is optional.
03Step 1: get the current bounds
Call the address with no query string. You get a standard LUD-06 payRequest
object, extended with the currency extension's currency block that describes the same limits
in Shillings.
Request
curl "https://bitcoin.co.ke/.well-known/lnurlp/254712345678"
Response 200
{
"callback": "https://bitcoin.co.ke/.well-known/lnurlp/254712345678",
"maxSendable": 446622000,
"minSendable": 135000,
"metadata": "[[\"text/plain\",\"Payment to Kenyan phone [email protected]\"],[\"text/identifier\",\"[email protected]\"]]",
"currency": {
"code": "KES",
"name": "Kenyan Shillings",
"symbol": "KSh",
"decimals": 0,
"minSendable": 15,
"maxSendable": 50000,
"multiplier": 8932
},
"tag": "payRequest"
}
| Field | Unit | Meaning |
|---|---|---|
| minSendable | millisatoshi | Floor for a sats-denominated request. 135,000 msat is 135 sats in the sample. |
| maxSendable | millisatoshi | Ceiling for a sats-denominated request. |
| currency.minSendable | KES | Floor when you send currency=KES. Currently 15. |
| currency.maxSendable | KES | Ceiling when you send currency=KES. Currently 50,000. |
| currency.multiplier | msat per KES | The BTC/KES rate at the moment you asked, expressed as millisatoshi per Shilling. 8,932 msat is about 8.9 sats per Shilling. |
| callback | URL | Where to ask for the invoice in step 2. Always use the value returned, do not hardcode it. |
maxSendable is not a constant. It is the lower of our configured ceiling and
the M-Pesa float we can actually pay out right now, so it moves during the day, and the
rate behind multiplier moves with the market. Re-read the metadata each
time you are about to build a payment rather than storing the numbers.
04Step 2: get an invoice
Call the same URL again with an amount. With no currency parameter
the amount is in millisatoshi, exactly as LUD-06 specifies.
Request
# 500,000 msat = 500 sats curl "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?amount=500000"
Response 200
{
"pr": "lnbc5u1p5q8xvmpp5k2j...xq8lqqqqqgqqyg",
"routes": [],
"successAction": {
"tag": "url",
"description": "View your M-Pesa receipt",
"url": "https://bitcoin.co.ke/receipt/880633ee6872e3e7a8d9df184c5656c8f077b0280136b206288054252dc619c1"
}
}
The payment request is the Lightning invoice. It arrives in the field named
pr, which is what the spec calls it, and it is the only field you have to act
on: pay it with any Lightning enabled Bitcoin wallet or node. The payment hash in the
successAction URL is the same hash as the one inside the invoice, so you can
equally decode the BOLT11 and take it from there.
| Field | Type | What it is |
|---|---|---|
| pr | string | The payment request, or BOLT11 invoice. This is the Lightning invoice you pay with a Lightning enabled Bitcoin wallet. |
| routes | array | Always empty. LUD-06 requires the key, nothing uses it. |
| successAction | object | Where to send the payer after settlement. See the receipt link. |
| converted | number | Only present with currency=KES. The Shillings the invoice was priced for. |
The window is deliberately short because the amount is priced against a live FX rate. Request the invoice at the moment you are ready to pay it, and if it expires, ask for a new one rather than retrying the old one. An unpaid invoice costs nothing and reserves nothing permanently.
05Pricing in Shillings instead of sats
Add currency=KES and the amount is read as Shillings. This is
the more natural mode for most integrations here: you say what the recipient should
receive, and we compute the sats required to deliver exactly that many Shillings.
Fiat-denominated amounts have no ratified LUD. The closest proposal is
LUD-22, Currencies in payRequest,
open since 2023 and still unmerged, and there are
competing
drafts. Our implementation follows an
earlier revision of that proposal, so it does not match the current draft on the wire: we
return a single currency object rather than a currencies array,
we take ?amount=100¤cy=KES rather than
?amount=100.KES, and our converted is a plain number rather than
an object.
Practical upshot: code against what this page documents, not against a draft LUD. A wallet built strictly to the current LUD-22 draft will not get a KES-denominated invoice from us, it will fall back to paying in sats, which still works. We will track the proposal once one of them is ratified.
Request
# Deliver KES 100 to this number curl "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?amount=100¤cy=KES"
Response 200
{
"pr": "lnbc9020n1p5q8xvmpp5j7f...xq8lqqqqqgqqyg",
"routes": [],
"successAction": {
"tag": "url",
"description": "View your M-Pesa receipt",
"url": "https://bitcoin.co.ke/receipt/880633ee6872e3e7a8d9df184c5656c8f077b0280136b206288054252dc619c1"
},
"converted": 100
}
| Mode | You specify | Recipient gets | Validated against |
|---|---|---|---|
| ?amount=N | The sats you will pay, in msat | The KES equivalent, minus the 1% fee | minSendable / maxSendable |
| ?amount=N¤cy=KES | The Shillings to deliver | Exactly that, in Shillings | currency.minSendable / currency.maxSendable |
Pick KES mode when you are pricing something in Shillings, such as a bill, a salary, or a
cart total. Pick msat mode when you are spending a fixed amount of Bitcoin and do not mind
what it lands as. The converted field in the response echoes the KES the
invoice was priced for, so you can show it back to the payer before they confirm.
06Fees and rounding
There is a flat 1% fee on the conversion. Where it lands depends on which mode you used, and it is worth getting right before you reconcile anything.
| Request | Invoice amount | Delivered to M-Pesa |
|---|---|---|
| ?amount=100¤cy=KES | KES 100 in sats, plus 1% | KES 100 |
| ?amount=1000000 (1,000 sats) | 1,000 sats exactly | KES value of 990 sats |
In KES mode the payer covers the fee on top, so the recipient gets the round number you asked for. In sats mode the invoice is exactly what you asked for, so the fee comes out of the delivered value. The KES payout is floored to a whole Shilling, since M-Pesa does not move cents.
Lightning routing fees are yours as the payer, as on any Lightning payment, and are separate from the 1%. There is no M-Pesa fee charged to the recipient, and no fee to receive.
07The receipt link (LUD-09)
Every invoice we mint for an M-Pesa payout comes back with a LUD-09 successAction of
type url. It points at a page that shows the real M-Pesa receipt for the
payout, keyed by the Lightning payment hash.
{
"successAction": {
"tag": "url",
"description": "View your M-Pesa receipt",
"url": "https://bitcoin.co.ke/receipt/<paymentHash>"
}
}
If you are building a wallet, show it the way LUD-09 says: display the
description after the payment settles and let the user open the
url. The page polls in the background and fills in the M-Pesa confirmation
code as soon as the payout lands, so it is safe to open immediately.
If you are building a server integration you may prefer to skip the page and poll the JSON API in the next section, which is exactly what that page does under the hood. The URL is also a perfectly good thing to hand a customer in an email or an SMS, since it is stable and needs no login.
No successAction is returned when the number is claimed. There is no M-Pesa
leg to receipt, and the destination wallet may return a successAction of its
own, which we pass through untouched.
08Step 3: poll for the M-Pesa receipt
One GET, keyed by the 64 character hex payment hash. It returns the same data the receipt page renders, in JSON.
Request
curl "https://bitcoin.co.ke/api/payment/880633ee6872e3e7a8d9df184c5656c8f077b0280136b206288054252dc619c1"
Response 200, payout delivered
{
"success": true,
"transaction": {
"status": "completed",
"isPaid": true,
"paymentNetworkReceipt": "TIA7QK4M2P",
"payToAccount": "254712345678",
"merchantName": "JANE W MWANGI",
"currencyAmount": 100,
"currencyAmountSent": 100,
"baseCurrency": "KES",
"satsAmount": 893,
"tandoFees": 8.93,
"totalSatsAmount": 901.93,
"exchangeRate": 0.00000008932,
"paymentHash": "880633ee6872e3e7a8d9df184c5656c8f077b0280136b206288054252dc619c1",
"dateTime": "2026-09-22T18:41:07.512Z",
"completedAt": "2026-09-22T18:41:19.860Z"
}
}
Response 404, no such hash
{
"success": false,
"error": {
"errorCode": "transaction-not-found",
"message": "Transaction not found"
}
}
Reading the status
| Status | What happened | What to do |
|---|---|---|
| pending | Invoice issued, sats not received yet | Keep polling, or stop if your invoice expired |
| received | Sats captured, M-Pesa payout in flight | Keep polling, normally seconds |
| completed | Shillings delivered, receipt code issued | Done. Read paymentNetworkReceipt. |
| failed | Payout terminally failed, with failureReason set | Stop polling and contact us with the payment hash for a refund |
Treat a payment as settled only when status is completed,
isPaid is true, and paymentNetworkReceipt is present. That last
field is the M-Pesa confirmation code, the same ten character code the recipient sees in
their SMS, and it is the only proof the Shillings actually moved.
Field reference
| Field | Type | Notes |
|---|---|---|
| paymentNetworkReceipt | string | M-Pesa confirmation code. Absent until delivery succeeds. |
| payToAccount | string | The destination number, in 254 format |
| merchantName | string | The M-Pesa registered name of the recipient, as returned by Safaricom |
| currencyAmount | number | KES the invoice was priced for |
| currencyAmountSent | number | KES actually sent, floored to whole Shillings |
| satsAmount | number | Sats backing the payout, before fee |
| tandoFees | number | The 1%, in sats |
| totalSatsAmount | number | Sats the invoice asked for, fee included |
| exchangeRate | number | BTC per Shilling at pricing time, so the inverse of a KES/BTC quote. Multiply by 1e11 for the msat-per-KES multiplier. |
| isPaid | boolean | True once the Lightning side settled |
| completedAt | ISO date | When the M-Pesa payout confirmed |
| failureReason | string | Present only on failed. Raw payout status. |
A polling loop that behaves
async function waitForReceipt(paymentHash, { intervalMs = 3000, timeoutMs = 120000 } = {}) { const url = `https://bitcoin.co.ke/api/payment/${paymentHash}`; const deadline = Date.now() + timeoutMs; let notFoundStreak = 0; while (Date.now() < deadline) { const res = await fetch(url); if (res.status === 404) { // The row is written moments after the invoice is issued. // Give it a few tries before calling it unknown. if (++notFoundStreak > 7) throw new Error("Unknown payment hash"); } else if (res.ok) { const { transaction: tx } = await res.json(); if (tx.status === "completed" && tx.isPaid && tx.paymentNetworkReceipt) { return tx; // tx.paymentNetworkReceipt is the M-Pesa code } if (tx.status === "failed") { throw new Error(`Payout failed: ${tx.failureReason}`); } } await new Promise(r => setTimeout(r, intervalMs)); } throw new Error("Timed out waiting for the M-Pesa receipt"); }
Three seconds is the cadence our own receipt page uses, and two minutes is a generous ceiling. Most payouts confirm within seconds of the Lightning payment. A payment that is still received after two minutes is not lost, it is delayed on the M-Pesa side, so surface it as pending rather than failed and keep the payment hash.
09Claimed numbers
A Kenyan can claim their own number at bitcoin.co.ke/claim-lightning-address and point it at any Lightning address they control. After that, their number stops being an M-Pesa payout and becomes an alias for their own wallet. The address string does not change, so as a payer you do nothing differently.
What changes for you
| Unclaimed | Claimed | |
|---|---|---|
| Recipient gets | Shillings over M-Pesa | Bitcoin in their own wallet |
| Amount limits | Ours, 15 to 50,000 KES | The destination wallet's, whatever those are |
| currency=KES | Supported | Only if the destination implements the same currency extension |
| Comments, zaps | Not offered | Passed through if the destination offers them |
| Proof of payment | Yes, both M-Pesa receipt and Lightning preimage. | Only the Lightning preimage proof of payment. |
| Who holds the sats | We do, briefly, to fund the payout | Nobody. Sender to recipient, directly. |
The limits point is the one that bites integrations. A claimed number backed by a wallet
with a 5,000,000 sat ceiling will advertise exactly that, which is far above our KES
ceiling, and a number backed by a custodial wallet with a 1,000 sat floor will advertise
that instead. This is another reason to read minSendable and
maxSendable per request rather than hardcoding the Shilling range.
Telling the two apart
You usually do not need to, but the metadata response tells you. A claimed number returns a
callback carrying an opaque fwd parameter, and no
currency block unless the destination sent one.
# Claimed: note the fwd token on the callback { "callback": "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?fwd=aHR0cHM6Ly9...", "minSendable": 1000, "maxSendable": 500000000, "metadata": "[[\"text/plain\",\"Payment to Kenyan phone [email protected]\"],[\"text/identifier\",\"[email protected]\"]]", "commentAllowed": 200, "tag": "payRequest" }
Follow the callback exactly as returned, fwd token and all, and
append your amount to it. Any extra parameters you send, such as
comment or nostr, are forwarded to the destination untouched, and
its invoice comes back to you unchanged.
The metadata we return for a claimed number never names the destination wallet. Payers
see the phone number identity only, which is the whole point of the alias. Do not try to
decode the fwd token to identify a recipient, and do not store it, since it
changes whenever the destination's callback changes.
10Errors
LNURL errors follow LUD-01: a JSON body with status set to
ERROR and a human-readable reason. Parse the body,
do not switch on the HTTP status code.
curl "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?amount=5¤cy=KES" {"status":"ERROR","reason":"Amount below minimum. Min 15 KES."}
curl "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?amount=1000" {"status":"ERROR","reason":"Amount below minimum. Min 135000 msat."}
| Reason | Cause | Fix |
|---|---|---|
| Amount below minimum... | Under the floor for the mode you used | Re-read the metadata and raise the amount |
| Amount above maximum... | Over the ceiling, or over the M-Pesa float available right now | Re-read the metadata and split or lower the amount |
| Invalid amount | amount was not a number | Send msat as an integer, KES as a plain decimal |
| Could not validate amount, try again | The bounds service did not answer, so we refused rather than guess | Retry shortly. No invoice was issued. |
| Failed to create invoice | The Lightning node could not mint the invoice | Retry. Nothing was charged. |
| Destination wallet did not respond | Claimed number whose wallet is down or slow | Retry later, or tell the recipient their wallet is unreachable |
| No claim found for this number | A fwd callback for a number that is no longer claimed | Start again from the metadata call |
When a request is refused because it exceeds the M-Pesa float available at that moment, the reason can come back as a generic internal error instead of the specific message. The payment is refused either way and nothing is charged. Treat any error from the callback as "no invoice was issued", re-read the metadata, and try a smaller amount.
11Testing
This runs on Bitcoin mainnet and real M-Pesa rails. Every invoice you pay moves real money to a real phone. Test with your own number and small amounts.
Free to call, all day
Metadata reads cost nothing and issue nothing. Build and debug your whole integration against step 1, since that is where the bounds, the rate, and the claimed versus M-Pesa branch all show up.
# Safe to run as often as you like curl -s "https://bitcoin.co.ke/.well-known/lnurlp/254712345678" | jq . # Error paths are free too: these are refused before anything is minted curl -s "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?amount=1¤cy=KES" curl -s "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?amount=999999¤cy=KES" # Unknown hash, to shape your polling code against a real 404 curl -s "https://bitcoin.co.ke/api/payment/$(printf '0%.0s' {1..64})"
The smallest real end to end test
KES 15 is the floor, which is roughly 135 sats at current rates. Send it to a number you control and watch the whole chain work.
# 1. Mint a 15 KES invoice to your own number curl -s "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?amount=15¤cy=KES" | jq -r .pr # 2. Pay it from any wallet or node within 120 seconds lncli payinvoice <the pr value> # 3. Poll until the M-Pesa code appears curl -s "https://bitcoin.co.ke/api/payment/<paymentHash>" \ | jq '.transaction | {status, isPaid, paymentNetworkReceipt, currencyAmountSent}'
Expect the M-Pesa SMS on the destination handset at about the same moment
status flips to completed. If the SMS arrives and the
API still says received, give it a few more seconds, since the
confirmation reaches us just after the recipient.
Wallet compatibility check
If you are testing from a wallet rather than code, paste [email protected] into its send field. Anything that supports Lightning addresses will resolve it. Wallets that also implement the currency extension will offer you a KES amount field, and wallets that implement LUD-09 will show the receipt link after paying. We keep a public tracker of which wallets support what at tando-me/bitcoin-wallet-compatibility, so check there before blaming your code.
Checklist before you go live
- You re-read the metadata immediately before every invoice request, and never cache bounds.
- You handle a LUD-01 error body arriving with an HTTP 200.
- You treat a 404 from the status API as "not yet", up to a handful of tries.
- You only mark a payment complete on completed plus
isPaidplus a receipt code. - You store the payment hash against your own order, since it is the key to every lookup and any support conversation.
- You handle a claimed number advertising limits nothing like our KES range.
12Limits and gotchas
| Limit | Value | Notes |
|---|---|---|
| Minimum payment | KES 15 | About 135 sats. Read it live from the metadata. |
| Maximum payment | KES 50,000 | Drops below this when the M-Pesa float is low |
| Conversion fee | 1% | On top in KES mode, deducted in sats mode |
| Invoice expiry | 120 seconds | Priced against a live rate, so the window is short |
| Destination | Safaricom only | Other Kenyan networks are not reachable yet |
| Authentication | None | Public endpoints, edge rate limiting |
Things that surprise people
- The ceiling moves. It is the lesser of our configured maximum and the Shillings we can actually pay out at that moment, so a 50,000 quote at breakfast can be lower by lunchtime.
- The rate moves between your two calls. We re-price and re-validate at invoice time, so an amount that passed the metadata check can still be refused seconds later. Handle the error rather than assuming step 1 guarantees step 2.
-
Errors come back with HTTP 200. That is LUD-01, not a bug. Check the
body for
status: "ERROR"on every response. - The status API is keyed by payment hash only. There is no listing endpoint and no lookup by phone number, by design. Keep your hashes.
- A number can be claimed between your calls. Rare, but it means the callback you were given can behave differently from the metadata you read. Re-reading the metadata per payment handles this too.
-
Comments are not supported on the M-Pesa flow. We do not advertise
commentAllowed, so a comment you send is ignored rather than attached to the M-Pesa payout.
13Spec support
| LUD | What it is | Support |
|---|---|---|
| LUD-01 | Error responses as JSON with HTTP 200 | Yes, on both flows |
| LUD-06 | payRequest base flow | Yes. Bounds are enforced at invoice time, not just advertised. |
| LUD-09 | successAction after payment | Yes, url type pointing at the M-Pesa receipt. Unclaimed flow only. |
| LUD-12 | Comments attached to a payment | Claimed numbers only, passed through from the destination |
| LUD-16 | Lightning addresses, the user@domain form | Yes, with the phone number as the username |
| LUD-21 | verify endpoint for checking payment state | Not implemented. Use the status endpoint instead. |
| LUD-22 draft | Fiat-denominated amounts through currency | An earlier revision of it, for KES. Unratified, and our wire format differs from the current draft. |
| NIP-57 | Nostr zaps | Claimed numbers only, if the destination advertises allowsNostr |
Specs live at github.com/lnurl/luds if you want the letter of any of the above.