API Reference

Ahadi Pay Documentation

Everything you need to integrate installment reservations with escrow protection into your platform. M-Pesa native. Webhook-driven. Under 30 minutes to first payment.

Base URL: https://api.loniwacapital.com/ahadipay/v1

Overview

Ahadi Pay is an escrow-backed installment reservation infrastructure. It sits between your checkout and payment collection — handling deposit collection, balance installments, automatic escrow splits, refunds, and webhook notifications.

Your platform owns everything domain-specific — events, tickets, customers, bookings. We own the financial layer — reservations, payments, and escrow. The two systems connect through two foreign keys: a product_id on your reservable item and a reservation_id on your booking record.

Merchant API

Create products, read reservations, configure webhooks

Public API

Widget calls — create reservations, pay installments

Webhooks

We push events to your server on every money movement

Quickstart

From zero to first payment in four steps.

1. Install dependencies

pip install requests

2. Register your reservable item

A reservable item in your system — a ticket type, a room category, a seat class — maps to a product on our side. You register it once when it is created, not at checkout time. The flow is always the same: save to your database first, call our API, then write the returned product_id back to your record. That ID is the only link between your system and ours.

The examples below use an event ticketing scenario — a ticket type for a jazz festival. The same pattern applies regardless of your industry. Swap industry_type and the metadata fields to match your domain.
import requests, os

def register_reservable_item(item_id: str, item_data: dict) -> dict:
    # Step 1 — Save to YOUR database first (reservable_product_id is null for now)
    db.execute("""
        INSERT INTO your_items (id, name, price, capacity, reservable_product_id)
        VALUES (%s, %s, %s, %s, NULL)
    """, [item_id, item_data["name"], item_data["price"], item_data["capacity"]])

    # Step 2 — Register with Ahadi Pay
    response = requests.post(
        "https://api.loniwacapital.com/ahadipay/v1/products",
        headers={"X-Api-Key": os.environ["AHADI_API_KEY"]},
        json={
            "name":             item_data["name"],
            "industry_type":    item_data["industry_type"],  # e.g. "events"
            "price_cents":      item_data["price"] * 100,
            "deposit_percent":  item_data["deposit_percent"],
            "max_reservations": item_data["capacity"],
            "refund_policy": {
                "deadline_minutes":   item_data["deadline_days"] * 1440,
                "refundable_percent": item_data["refundable_percent"],
                "release_percent":    item_data["release_percent"],
            },
            # metadata is yours — pass whatever your domain needs
            # we store it and return it, we never interpret it
            "metadata": item_data.get("metadata", {}),
        }
    )
    product = response.json()

    # Step 3 — Write product_id back to YOUR database
    db.execute("""
        UPDATE your_items SET reservable_product_id = %s WHERE id = %s
    """, [product["id"], item_id])

    return product

3. Add Ahadi Pay at checkout

Add Ahadi Pay as a payment option alongside your existing methods. When the customer selects it, initialise the widget with the product_id you stored in step 2 and the customer's details. The widget handles the rest.

html
<!-- Add to your checkout page -->
<script src="https://js.loniwacapital.com/ahadipay/v1/widget.js"></script>

Then call AhadiPay.open() when the customer selects Ahadi Pay:

AhadiPay.open({
  // Required — from your database (stored in step 2)
  product_id:   "prod_...",

  // Required — your internal customer identifier
  customer_ref: "cust_...",

  // Required — customer's M-Pesa registered phone number
  phone_number: "07XX XXX XXX",

  // Called when the customer completes the deposit payment
  on_success: function(reservation) {
    // reservation.id        — save this to your booking record
    // reservation.status    — "reserved"
    // reservation.percent_paid — e.g. 40 (deposit percent)
    saveBooking(reservation.id)
    showLockedTicket()
  },

  // Called if the customer cancels or an error occurs
  on_error: function(error) {
    showError(error.message)
  },
})

4. React to webhooks

After the customer pays, we send a payment.received event to your webhook URL. This is where you create the locked booking in your system. On full payment, flip it to delivered. On cancellation or expiry, we tell you that too.

See the Webhooks section for the full payload shape, signature verification, and code examples in all languages.

Authentication

All merchant API requests require an X-Api-Key header. Keys are prefixed to indicate mode.

http
# Test mode — safe to use in development
X-Api-Key: sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

# Live mode — real money moves. Server-side only, never in a browser.
X-Api-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
Live API keys must never appear in client-side code, browser bundles, or public repositories. Always call the Ahadi Pay API from your backend server.

Products

A product represents a reservable item — a ticket type, a flight seat class, a hotel room category. It holds the pricing and refund policy that governs all its reservations.

POST /products

Request body

namerequired
string

Display name of the product

industry_typerequired
string

events · flights · hotels · training · travel · other

price_centsrequired
integer

Full price in KES cents. Never floats.

deposit_percentrequired
integer

Percentage of price required as deposit (1–100)

max_reservations
integer

Hard inventory ceiling. Enforced by us before STK push fires.

refund_policyrequired
object

See refund policy object below

metadata
object

Opaque key-value store. Pass event_id, venue, date — we store it, never interpret it.

Refund policy object

deadline_minutesrequired
integer

Minutes from reservation creation before it expires. 43200 = 30 days.

refundable_percentrequired
integer

Percent of total paid returned to customer on cancellation (0–94)

release_percentrequired
integer

Percent released to merchant on each payment. refundable + release must be < 100.

The gap between 100, refundable_percent, and release_percent is Ahadi Pay's platform fee. Example: 80% refundable + 15% release = 5% platform fee.

Reservations

A reservation represents a customer's financial commitment to a product. It moves through a well-defined status lifecycle.

Status lifecycle

pending_deposit

Reservation created. STK push sent. Waiting for customer PIN.

reserved

Deposit confirmed. Balance outstanding. Installments accepted.

paid_in_full

Balance cleared. Escrow released to merchant automatically.

cancelled

Cancelled. Refund issued per policy.

expired

Deadline passed before full payment. Held amount refunded.

Reservation response object

json
{
  "id":                "res_9x2k1m",
  "product_id":        "prod_8f3k2jx",
  "customer_ref":      "cust_abc123",
  "customer_phone":    "254712345678",
  "status":            "reserved",
  "total_paid_cents":  320000,
  "balance_due_cents": 480000,
  "percent_paid":      40,
  "expires_at":        "2025-12-15T10:30:00+03:00",
  "escrow": {
    "held_cents":                  256000,
    "released_to_merchant_cents":   48000,
    "platform_fee_cents":           16000,
    "refunded_cents":                   0
  },
  "delivery": {
    "type":         null,
    "url":          null,
    "note":         null,
    "delivered_at": null
  }
}

Payments

Customers pay installments in any amount at any time, so long as the balance is cleared before the deadline. Each payment triggers an M-Pesa STK push and runs the escrow engine.

POST /public/reservations/:id/pay

# Customer pays KES 2,000 installment
response = requests.post(
    f"https://api.loniwacapital.com/ahadipay/v1/public/reservations/{reservation_id}/pay",
    headers={"X-Api-Key": os.environ["AHADI_API_KEY"]},
    json={
        "amount_cents":     200000,    # KES 2,000
        "payment_provider": "mpesa",
        "phone_number":     "0712345678",
    }
)
# status: "pending" — STK push sent
# Poll GET /public/reservations/{id} to confirm

Webhooks

We push events to your webhook URL on every significant money movement. Every payload is signed with HMAC-SHA256 using your webhook secret. Always verify the signature before processing. Return 200 immediately — do heavy processing asynchronously.

Events

payment.received

Fired on every confirmed payment — deposit and installments. Includes full escrow breakdown and percent_paid.

reservation.cancelled

Reservation cancelled. Refund issued per policy. Includes cancelled_by: customer or merchant.

reservation.expired

Deadline passed before full payment. Held amount refunded automatically. No human intervention needed.

Payload shape

json
{
  "event":             "payment.received",
  "reservation_id":    "res_9x2k1m",
  "status":            "reserved",
  "amount_cents":      200000,
  "total_paid_cents":  520000,
  "balance_due_cents": 280000,
  "percent_paid":      65,
  "escrow": {
    "held_cents":                  416000,
    "released_to_merchant_cents":   78000,
    "platform_fee_cents":           26000,
    "refunded_cents":                   0
  }
}

// Headers on every webhook:
// X-Ahadi-Signature: sha256=<hmac-sha256>
// X-Ahadi-Event:     payment.received
// X-Ahadi-Delivery:  <uuid>

Signature verification

Always verify the signature using a timing-safe comparison. Never use == or === for comparing HMAC signatures — they are vulnerable to timing attacks.
import hmac, hashlib, os
from flask import Blueprint, request, jsonify

webhook_bp = Blueprint("webhook", __name__)

@webhook_bp.route("/webhooks/ahadi", methods=["POST"])
def ahadi_webhook():
    signature = request.headers.get("X-Ahadi-Signature", "")
    raw_body  = request.get_data()

    expected = "sha256=" + hmac.new(
        os.environ["AHADI_WEBHOOK_SECRET"].encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    # Always use compare_digest — prevents timing attacks
    if not hmac.compare_digest(signature, expected):
        return jsonify({"error": "Invalid signature"}), 401

    payload = request.get_json()

    if payload["event"] == "payment.received":
        pct = payload["percent_paid"]
        if payload["status"] == "reserved" and pct < 100:
            # First payment — create locked ticket
            create_locked_ticket(payload["reservation_id"])
        if pct == 100:
            # Balance cleared — unlock ticket, send QR
            unlock_ticket(payload["reservation_id"])

    elif payload["event"] == "reservation.cancelled":
        cancel_ticket(payload["reservation_id"])
        notify_customer_refund(payload["reservation_id"])

    elif payload["event"] == "reservation.expired":
        expire_ticket(payload["reservation_id"])

    # Always return 200 quickly
    return jsonify({"received": True}), 200
Use raw request body for signature verification. Parsing JSON first changes the byte representation and will cause signature mismatches. In Express: express.raw() not express.json(). In Next.js: req.text() not req.json().

Customer wallet

The Ahadi Pay wallet gives customers a single view of all their active reservations across every merchant platform. When a customer pays a deposit, we create a shadow wallet account using their phone number. They claim it by verifying with OTP.

From the wallet, customers can pay installments directly without returning to your platform. When a payment is made from the wallet, you receive the same webhook as if your platform initiated it. No special handling needed on your side.

The wallet is optional for merchants. Your integration works exactly the same whether customers pay through your UI or through the Ahadi Pay wallet.

Errors

400

Bad request

Missing or invalid fields. Check the error message for specifics.

401

Unauthorized

Missing or invalid X-Api-Key header.

403

Forbidden / sold out

Inventory exhausted. Check code: sold_out in response body.

404

Not found

Resource does not exist or belongs to a different merchant.

409

Conflict

Duplicate request. Check your idempotency key.

429

Rate limited

Too many requests. Slow down and retry with exponential backoff.

500

Server error

Our fault. Retry once. If it persists, contact support.

json
// Error response shape
{
  "error": "Amount exceeds balance due",
  "code":  "invalid_amount"   // present on specific errors like sold_out
}

Ready to integrate?

Create an account, get test keys, and make your first reservation in minutes.

Get your API keys