Developer docs
Live on mainnet · no API key required

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.

1
Read the limits
GET /.well-known/lnurlp/254712345678 returns the minimum, the maximum, and the current BTC/KES rate
2
Ask for an invoice
The same URL with an amount, priced in sats or in Shillings, returns a BOLT11 invoice
3
Pay it
Any wallet, any node. Normal Lightning payment.
4
Shillings land in M-Pesa
Seconds later, with a real M-Pesa confirmation code you can fetch by payment hash

Everything is on one host, https://bitcoin.co.ke. Three endpoints, and you only need the first two.

EndpointWhat 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.
Auth and CORS

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.

One footnote for later

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.

InputResult
[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.

Why the strict list

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&currency=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.

GET https://bitcoin.co.ke/.well-known/lnurlp/{phone}

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"
}
FieldUnitMeaning
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.
Do not cache the bounds

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.

GET https://bitcoin.co.ke/.well-known/lnurlp/{phone}?amount={msat}

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.

FieldTypeWhat 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.
Invoices expire in 120 seconds

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.

This part is not a finalized spec

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&currency=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.

GET https://bitcoin.co.ke/.well-known/lnurlp/{phone}?amount={kes}&currency=KES

Request

# Deliver KES 100 to this number
curl "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?amount=100&currency=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
}
ModeYou specifyRecipient getsValidated against
?amount=N The sats you will pay, in msat The KES equivalent, minus the 1% fee minSendable / maxSendable
?amount=N&currency=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.

RequestInvoice amountDelivered to M-Pesa
?amount=100&currency=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.

Network fees

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.

Claimed numbers

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.

GET https://bitcoin.co.ke/api/payment/{paymentHash}

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

StatusWhat happenedWhat 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

FieldTypeNotes
paymentNetworkReceiptstringM-Pesa confirmation code. Absent until delivery succeeds.
payToAccountstringThe destination number, in 254 format
merchantNamestringThe M-Pesa registered name of the recipient, as returned by Safaricom
currencyAmountnumberKES the invoice was priced for
currencyAmountSentnumberKES actually sent, floored to whole Shillings
satsAmountnumberSats backing the payout, before fee
tandoFeesnumberThe 1%, in sats
totalSatsAmountnumberSats the invoice asked for, fee included
exchangeRatenumberBTC per Shilling at pricing time, so the inverse of a KES/BTC quote. Multiply by 1e11 for the msat-per-KES multiplier.
isPaidbooleanTrue once the Lightning side settled
completedAtISO dateWhen the M-Pesa payout confirmed
failureReasonstringPresent 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

UnclaimedClaimed
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.

Privacy by design

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&currency=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."}
ReasonCauseFix
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
Known rough edge

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

There is no testnet

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&currency=KES"
curl -s "https://bitcoin.co.ke/.well-known/lnurlp/254712345678?amount=999999&currency=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&currency=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 isPaid plus 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

LimitValueNotes
Minimum paymentKES 15About 135 sats. Read it live from the metadata.
Maximum paymentKES 50,000Drops below this when the M-Pesa float is low
Conversion fee1%On top in KES mode, deducted in sats mode
Invoice expiry120 secondsPriced against a live rate, so the window is short
DestinationSafaricom onlyOther Kenyan networks are not reachable yet
AuthenticationNonePublic 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

LUDWhat it isSupport
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.