Bitika API
The whole thing, before you sign up
Every endpoint, the sandbox, webhooks and error handling — all documented here, in the open. Read it, decide, and only then create an account. The only thing an account gets you is the key itself.
Base URLhttps://bitikaserver.up.railway.app
Authenticate with Authorization: Bearer bk_… or X-API-Key. No request signing, no OAuth. Always send an Idempotency-Key (a UUID per payment) so a retry never double-charges.
Sandbox: build the whole integration for free
A test key is issued the moment you register. No approval from us, no money, no SMS — but a real transaction, a real state machine, and real signed webhooks.
| bk_test_ (sandbox) | bk_live_ | |
|---|---|---|
| Approval | Instant, issued when you register | Verified email + Bitika approval |
| Money moved | None. No STK push, no SMS, no sats | Real M-Pesa charge, real sats |
| Endpoints & payloads | Identical | Identical |
| Webhooks & WebSocket | Real, signed, same payloads | Real, signed |
| Cost to you | Free | Flat 3% fee per transaction |
Sandbox transaction codes are prefixed SBX-. You do not need an OTP — but if you choose to send one, it is always 123456.
# One call. No OTP — your key already identifies you.
# In the sandbox nothing is charged, no SMS is sent, no sats move.
curl -X POST https://bitikaserver.up.railway.app/api/v1/xwift/collect \
-H 'Authorization: Bearer bk_test_xxxxxxxxxxxx' \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"amount": "100",
"phone": "254712345678",
"lightningAddress": "name@blink.sv"
}'
# → { "transaction_code": "SBX-1A2B3C4D", "status": "processing" }
# Your webhook then receives, for real:
# transaction.updated (~1.5s) → processing_payment
# payment.completed (~4s) → fulfilledForce a failure (like a test card)
Test your unhappy paths by choosing the phone number:
| Phone ends with | Simulates | Final status |
|---|---|---|
| 000001 | M-Pesa declined | failed |
| 000002 | M-Pesa paid, Lightning payout failed | payment_failed |
| anything else | Success | fulfilled |
Auth & keys
An API key is the whole authentication story. No request signing, no OAuth.
You do not OTP your users
Your key identifies you — an approved, email-verified developer. You already own the relationship with your customer, and you are the accountable party for the charges you initiate. So we do not ask you to SMS a one-time code to your own user. Just call /xwift/collect.
(verificationCode is still accepted if you want that extra step. Bitika's own anonymous web checkout still uses one, because there the payer is a stranger to us.)
Scopes
Self-serve keys carry payments:initiate, payments:read and otp:send. A call outside your scopes returns 403.
IP allowlist — optional, and usually skip it
You can lock a key to specific source IPs. It is off by default and we recommend leaving it off unless you genuinely have a fixed egress IP. It protects against one narrow thing — a stolen key being replayed from somewhere else — and it breaks loudly (403) the moment your egress IP changes.
Turn it on if…
- Your calls leave from a stable, known egress IP (a fixed VPS, or a NAT gateway with a reserved IP).
- You hold a live key and want a stolen key to be unusable from anywhere else.
Leave it empty if…
- You run on serverless or autoscaling infrastructure (Vercel, Lambda, Cloud Run, Heroku, most containers) — the egress IP changes without warning.
- You do not know your outbound IP. If you have to guess, leave it empty.
Your key is protected by more than an IP anyway: it is hashed at rest, scoped, rate-limited, rotatable, and revocable the moment you suspect a leak. If a key leaks, rotate it — that is the real fix.
The payment flow
One call, then you listen. Identical in sandbox and live.
- 1
Initiate the collection
POST /api/v1/xwift/collect with the amount, phone and Lightning destination. No OTP — your key already identifies you. You get back a transaction_code and status: "processing".
- 2
The user approves the STK push
They enter their M-Pesa PIN on their phone. Nothing for you to do here.
- 3
Listen for the result
Bitika charges M-Pesa, pays Lightning, and tells you — via webhook or WebSocket — as it moves through the state machine.
The state machine
processing ──▶ processing_payment ──▶ fulfilled ✅ sats delivered
│ │
│ └──────────────▶ payment_failed ⚠️ M-Pesa took payment,
│ Lightning payout failed
└───────────────────────────────────▶ failed ❌ M-Pesa declinedEndpoints
Everything an external key can call.
| Endpoint | Scope | What it does |
|---|---|---|
| POST /api/v1/auth/sms/send | otp:send | Optional. Only if you choose to OTP your own users — partners are exempt. |
| POST /api/v1/xwift/collect | payments:initiate | Charge M-Pesa, settle sats to a Lightning address. |
| POST /api/v1/xwift/collect-invoice | payments:initiate | Pay a fixed-amount BOLT11 invoice. |
| POST /api/v1/xwift/collect-zero-invoice | payments:initiate | Pay a zero-amount BOLT11 invoice, priced in KES. |
| POST /api/v1/xwift/collect-lnurl | payments:initiate | Pay via LNURL. |
| POST /api/v1/xwift/collect-invoice-kes | payments:initiate | Pay a BOLT11 invoice priced in KES. |
| GET /api/v1/transactions/code/:transactionCode | payments:read | Look up one transaction by its code. |
| GET /api/v1/transactions/phone/:phone | payments:read | List transactions for a phone number. |
| GET /api/v1/exchange/rate | payments:read | Quote sats for a KES amount, before you charge. |
Status discovery
Three ways to know what happened. Use webhooks in production; poll as a backstop.
Webhooks (recommended)
We POST a signed event to your URL on every status change. No polling, no missed updates.
Polling
GET /api/v1/transactions/code/:transactionCode returns the current status. Good as a reconciliation backstop.
WebSocket
Socket.IO: subscribe to transaction_code:<code> and receive transaction + paymentStatus events live. Great for a UI.
Webhooks
Register a URL, get a signing secret, verify every event. Sandbox keys receive real, signed webhooks — so you can build and test this fully before going live.
Events
| Event | Fired when | Status |
|---|---|---|
| transaction.updated | M-Pesa confirmed, Lightning payout in flight | processing_payment |
| payment.completed | Sats delivered. The terminal success event | fulfilled |
| payment.failed | M-Pesa declined, or the Lightning payout failed | failed / payment_failed |
Payload
{
"id": "evt_9f2c...",
"event": "payment.completed",
"created": "2026-07-14T09:31:02.114Z",
"data": {
"transaction_code": "SBX-1A2B3C4D",
"status": "fulfilled",
"amount_kes": 100,
"sats": 1212,
"lightning_address": "you@blink.sv",
"payment_hash": "b3f1...",
"mpesa_receipt": "SBX8A2F19",
"decline_reason": null,
"created_at": "2026-07-14T09:30:58.001Z",
"updated_at": "2026-07-14T09:31:02.100Z"
}
}Verify the signature
Every delivery carries X-Bitika-Event and X-Bitika-Signature: t=<ts>,v1=<hmac>. We sign "<timestamp>.<raw body>" with HMAC-SHA256 so signatures can't be replayed. Verify against the raw body, before any JSON parsing.
import crypto from "crypto";
// Bitika signs "<timestamp>.<raw body>" with your webhook secret (HMAC-SHA256).
// Header: X-Bitika-Signature: t=1783625207,v1=9f86d081...
export function verifyBitika(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=") as [string, string]),
);
const { t, v1 } = parts;
// Reject replays (5-minute window).
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
// Constant-time compare — never use ===
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Managing webhooks
These use your developer session JWT (from POST /api/v1/developers/login), not the API key.
| POST /api/v1/developers/webhooks | Register { url, events?, description? }. Returns the signing secret ONCE. |
| GET /api/v1/developers/webhooks | List your webhooks. |
| POST /api/v1/developers/webhooks/:id/test | Fire a test event at your URL. |
| GET /api/v1/developers/webhooks/:id/deliveries | Inspect delivery attempts and response codes. |
| DELETE /api/v1/developers/webhooks/:id | Remove a webhook. |
Deliveries retry with backoff and are deduplicated per (webhook, transaction, event) — so you get each event exactly once, even though it can be emitted from several code paths. Return a 2xx quickly; do your work asynchronously.
Errors
What can come back, and what to do about it.
| Code | Meaning | What to do |
|---|---|---|
| 400 | Bad request — amount out of range, malformed invoice/phone | Check the payload against the reference. |
| 401 | Missing or invalid API key | Check the Authorization header. (Partners are not asked for an OTP.) |
| 403 | Key lacks the scope, is revoked/inactive, or IP not allowed | Live keys stay inactive until approved. |
| 409 | Idempotency-Key replay with a different payload | Use a fresh UUID per logical payment. |
| 429 | Rate limit exceeded | Back off and retry. |
| 503 | Lightning liquidity temporarily short (reason: liquidity) | Recoverable — retry shortly. Not your bug. |
Limits & fees. Minimum KES 10, maximum KES 10,000 per transaction. A flat 3% fee applies on live conversions. The sandbox is free.
That's the whole API. Now grab a key.
Registering takes a minute and gives you a sandbox key instantly. No approval, no card, nothing to lose.
Get your sandbox key