Developers

M-Pesa and USDT payments API for Kenya

One POST returns a checkout URL. Send the buyer there and E-Sarif handles the M-Pesa STK push, the on-chain watching and the confirmation, then tells your server what happened with a signed webhook. No M-Pesa callback plumbing, no node to run, no wallet to manage.

  • Early access
  • Idempotent by design
  • Signed webhooks
  • Test mode

The shape of it

Two endpoints and a webhook

The whole integration is smaller than most payment APIs. You create a session, you redirect, you handle one webhook. Everything else, including refunds and listing, is built on the same object.

E-Sarif Checkout API flow: your server creates a checkout session, you redirect the buyer to the returned checkout URL, the buyer pays by M-Pesa or stablecoin, E-Sarif delivers a signed webhook, and your server fulfils the order.01POST a checkout session02Redirect to checkout_url03Buyer pays by M-Pesa or USDT04Signed webhook delivered05Your server fulfils the order
E-Sarif Checkout API flow: your server creates a checkout session, you redirect the buyer to the returned checkout URL, the buyer pays by M-Pesa or stablecoin, E-Sarif delivers a signed webhook, and your server fulfils the order.
Create a checkout session
curl -X POST https://api.e-sarif.com/api/v1/checkout/sessions \
  -H "Authorization: Bearer esk_live_xxx" \
  -H "Idempotency-Key: 8f14e45f-ea6d-4b1f-9a2e-0c1d2e3f4a5b" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 4500.00,
    "currency": "KES",
    "settlement_currency": "USDT",
    "reference": "order_1042",
    "description": "Order #1042",
    "customer": { "email": "jane@example.com", "phone": "254712345678" },
    "payment_methods": ["MPESA", "CRYPTO"],
    "mode": "redirect",
    "success_url": "https://shop.co.ke/checkout/success?order=1042",
    "cancel_url": "https://shop.co.ke/cart",
    "expires_in": 1800,
    "metadata": { "order_id": "1042" }
  }'
201 response
{
  "success": true,
  "data": {
    "id": "cs_live_a1b2c3d4e5",
    "object": "checkout.session",
    "status": "OPEN",
    "payment_status": "UNPAID",
    "amount": "4500.00",
    "currency": "KES",
    "settlement_currency": "USDT",
    "settlement_amount": "34.85",
    "reference": "order_1042",
    "checkout_url": "https://pay.e-sarif.com/c/cs_live_a1b2c3d4e5",
    "expires_at": "2026-09-07T12:30:00Z",
    "created_at": "2026-09-07T12:00:00Z"
  }
}

Authentication

Two key classes, and why it matters

The split exists so that nothing in a browser can ever move money. If a publishable key leaks, the worst an attacker can do is create sessions on domains you allowed.

E-Sarif Checkout API key classes and scopes
CredentialWhat it can do
Publishable key, epk_live_ or epk_test_Safe in a browser, a mobile app or a plugin front end. It can create a checkout session and read the status of its own session. Nothing else. Rate limited per IP and restricted to your allowed domains by an Origin check.
Secret key, esk_live_ or esk_test_Server only. Everything the key scopes allow: sessions, refunds, listing. Never render it into a page, never log it in full, and redact it to its prefix if you must log it at all.
Scopescheckout:write, checkout:read, refunds:write and refunds:read. Issue the narrowest set the integration actually needs.
Test versus liveEnvironment is derived from the key body, never sent by the client. Test keys hit the same endpoints, but no real STK push and no on-chain watch happen. A test payment confirms after around five seconds.

Endpoints

The API surface

E-Sarif Checkout API endpoints
EndpointPurpose
POST /api/v1/checkout/sessionsCreate a session. Requires an Idempotency-Key. Returns the checkout_url.
preferred_chain, on session createOptional. Omit it and the checkout page offers Solana, BNB Chain and Base. Set it to any supported network to offer that one instead. An unsupported value returns unsupported_chain.
GET /api/v1/checkout/sessions/:idRead one session, including status and payment_status.
GET /api/v1/checkout/sessionsList and filter sessions by reference or status, cursor paginated.
POST /api/v1/checkout/sessions/:id/expireForce-close an open session. Idempotent.
POST /api/v1/refundsCreate a full or partial refund. Requires an Idempotency-Key.
GET /api/v1/refunds/:idRead one refund and its status.

Integration

Six steps to a working integration

  1. 01

    Issue a test key pair

    Create a merchant account and generate esk_test_ and epk_test_ keys. Everything below works identically in test mode, with simulated confirmations, so you never need live money to build.

  2. 02

    Create a session from your server

    POST to the sessions endpoint with the amount, currency, your own reference and a return URL. Always send an Idempotency-Key derived from your order id and attempt number so a retry cannot double-charge.

  3. 03

    Send the buyer to checkout_url

    Redirect to the checkout_url in the response, or open it in an iframe if you created the session with mode set to embedded. E-Sarif renders the payment UI, triggers the STK push and watches the chain.

  4. 04

    Handle the webhook as your source of truth

    Verify the signature, reject anything older than five minutes, deduplicate on the event id, then act on checkout.session.completed. Never mark an order paid because the buyer arrived at your success URL.

  5. 05

    Return the buyer to your success page

    Treat the return purely as user experience. Show a pending state if your webhook has not arrived yet; it usually will have, but the ordering is not guaranteed.

  6. 06

    Implement refunds before you launch, not after

    The first refund request always arrives sooner than expected. Wire the refunds endpoint while the code is still fresh in your head.

Webhooks

Treat the webhook as the truth

The single most common way to lose money on a payment integration is to mark an order paid because the buyer arrived at your success URL. A buyer can reach that URL by editing it. Only the webhook is signed.

E-Sarif Checkout webhook events
EventWhen it fires
checkout.session.createdA session was opened. Useful for abandonment analytics, not for fulfilment.
checkout.session.completedThe payment landed. This is the event that marks an order paid.
checkout.session.expiredThe buyer never paid and the window closed.
refund.createdA refund was accepted and is on its way.
refund.completedThe refund reached the customer.
refund.failedThe refund did not go through and needs attention.
Verify a webhook, Node
import crypto from "node:crypto";

// Header format: X-ESarif-Signature: t=1757246400,v1=<hex>
export function verify(rawBody, headers, secret) {
  const parts = Object.fromEntries(
    headers["x-esarif-signature"].split(",").map((p) => p.split("="))
  );

  // 1. Reject replays older than five minutes.
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (age > 300) return false;

  // 2. Recompute the signature over "{timestamp}.{raw body}".
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  // 3. Compare in constant time.
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) {
    return false;
  }

  // 4. Dedupe on the event id before acting on the payload.
  return headers["x-esarif-event-id"];
}

Every delivery carries three headers you need: the event id for deduplication, the unix timestamp, and the signature in a timestamp-and-v1 format. Verify over the raw body bytes, not over a re-serialised object, or the hash will not match.

Refunds

Full and partial refunds

Omit the amount for a full refund. Refunds go back the way the money came in, over M-Pesa B2C or on-chain, and the total refunded can never exceed what was captured. Refunds above your account threshold, or that would overdraw your balance, wait for approval before they move.

Create a partial refund
curl -X POST https://api.e-sarif.com/api/v1/refunds \
  -H "Authorization: Bearer esk_live_xxx" \
  -H "Idempotency-Key: 2b8f1c44-3d6a-4f9e-8c11-7a5b3e9d0f22" \
  -H "Content-Type: application/json" \
  -d '{
    "checkout_session_id": "cs_live_a1b2c3d4e5",
    "amount": 1500.00,
    "reason": "requested_by_customer",
    "note": "Partial refund, one item out of stock"
  }'

Errors

One error shape, everywhere

Branch on the code, show the message, and log the request id. Support can find any single request from that id alone.

Error response
{
  "success": false,
  "error": {
    "type": "invalid_request_error",
    "code": "amount_too_small",
    "message": "Amount must be at least 10 KES",
    "param": "amount",
    "request_id": "req_xxx"
  }
}

FAQ

Developer questions, answered

How do I integrate M-Pesa payments into my own application?

Create a checkout session from your server with a POST, then redirect the buyer to the checkout_url that comes back. E-Sarif handles the M-Pesa STK push, the polling and the confirmation, and tells your server the outcome with a signed webhook. You do not implement the STK push or the callback handling yourself.

What is a checkout session?

It is the single primitive behind every E-Sarif integration. A session holds the amount, the currency, your reference, the settlement currency, the enabled payment methods and the return URLs, and it carries a status and a payment status. Plugins, SDKs, payment links and the hosted page all create the same object.

Which languages have an SDK?

PHP and Node for server-side use, plus a browser SDK for opening a session that your server has already created. Any language that can make an HTTPS request can use the REST API directly, since the whole flow is two endpoints and a webhook.

How do I stop a customer being charged twice?

Send an Idempotency-Key on every request that moves money, and derive it deterministically from your order id and attempt number. A retry with the same key and the same body replays the original response verbatim. The same key with a different body is rejected, and a request still in flight is rejected as in progress, so a double-clicked Pay button cannot create two charges.

How do I verify a webhook signature?

Compute an HMAC-SHA256 over the string made of the timestamp, a dot, and the raw request body, using your webhook secret, and compare it in constant time against the v1 value in the signature header. Reject any delivery whose timestamp is more than five minutes old, and deduplicate on the event id before you act on the payload.

What happens if my webhook endpoint is down?

Deliveries are retried with exponential backoff, eight attempts over roughly twenty-four hours. Failed deliveries are visible in your merchant portal and can be replayed individually once your endpoint is healthy again.

Do I need to use the hosted checkout page?

It is the fastest path and it keeps the payment UI out of your codebase. If you want the payment inside your own page, create the session with mode set to embedded and open it in an iframe. Either way the session, the webhook and the refund flow are identical.

Which networks does the crypto method cover?

USDT and USDC on Tron, Solana, Base, BNB Chain, Ethereum, Polygon, Arbitrum, Optimism and Avalanche. Two lists matter here. If you create a session without naming a network, the checkout page offers Solana, BNB Chain and Base, picked for low fees and fast finality. Set preferred_chain to any other supported network and the buyer is offered that one, which is how you serve a customer holding USDT on Tron. An unsupported value is rejected with an unsupported_chain error on the preferred_chain parameter.

How are API errors shaped?

Uniformly, on every endpoint: a type, a machine-readable code, a human message, the offending parameter where relevant, and a request_id. Log the request_id. It is the fastest way to get a specific answer from support.

Can I use test mode without a real M-Pesa number?

Yes. Test keys hit the same endpoints but nothing real happens: no STK push is sent and no chain is watched. The payment confirms automatically after around five seconds so you can exercise the full webhook path in your own test suite.

Other integrations

Back to the E-Sarif Checkout overview

One POST.
One checkout URL.

Create a merchant account, issue a test key pair, and run a full simulated payment through your own code in an afternoon.