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_
ApprovalInstant, issued when you registerVerified email + Bitika approval
Money movedNone. No STK push, no SMS, no satsReal M-Pesa charge, real sats
Endpoints & payloadsIdenticalIdentical
Webhooks & WebSocketReal, signed, same payloadsReal, signed
Cost to youFreeFlat 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)    → fulfilled

Force a failure (like a test card)

Test your unhappy paths by choosing the phone number:

Phone ends withSimulatesFinal status
000001M-Pesa declinedfailed
000002M-Pesa paid, Lightning payout failedpayment_failed
anything elseSuccessfulfilled

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

    The user approves the STK push

    They enter their M-Pesa PIN on their phone. Nothing for you to do here.

  3. 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 declined

Endpoints

Everything an external key can call.

EndpointScopeWhat it does
POST /api/v1/auth/sms/sendotp:sendOptional. Only if you choose to OTP your own users — partners are exempt.
POST /api/v1/xwift/collectpayments:initiateCharge M-Pesa, settle sats to a Lightning address.
POST /api/v1/xwift/collect-invoicepayments:initiatePay a fixed-amount BOLT11 invoice.
POST /api/v1/xwift/collect-zero-invoicepayments:initiatePay a zero-amount BOLT11 invoice, priced in KES.
POST /api/v1/xwift/collect-lnurlpayments:initiatePay via LNURL.
POST /api/v1/xwift/collect-invoice-kespayments:initiatePay a BOLT11 invoice priced in KES.
GET /api/v1/transactions/code/:transactionCodepayments:readLook up one transaction by its code.
GET /api/v1/transactions/phone/:phonepayments:readList transactions for a phone number.
GET /api/v1/exchange/ratepayments:readQuote 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

EventFired whenStatus
transaction.updatedM-Pesa confirmed, Lightning payout in flightprocessing_payment
payment.completedSats delivered. The terminal success eventfulfilled
payment.failedM-Pesa declined, or the Lightning payout failedfailed / 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/webhooksRegister { url, events?, description? }. Returns the signing secret ONCE.
GET /api/v1/developers/webhooksList your webhooks.
POST /api/v1/developers/webhooks/:id/testFire a test event at your URL.
GET /api/v1/developers/webhooks/:id/deliveriesInspect delivery attempts and response codes.
DELETE /api/v1/developers/webhooks/:idRemove 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.

CodeMeaningWhat to do
400Bad request — amount out of range, malformed invoice/phoneCheck the payload against the reference.
401Missing or invalid API keyCheck the Authorization header. (Partners are not asked for an OTP.)
403Key lacks the scope, is revoked/inactive, or IP not allowedLive keys stay inactive until approved.
409Idempotency-Key replay with a different payloadUse a fresh UUID per logical payment.
429Rate limit exceededBack off and retry.
503Lightning 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