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/v1Overview
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 requests2. 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.
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 product3. 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.
<!-- 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.
# 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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxProducts
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
namerequiredstringDisplay name of the product
industry_typerequiredstringevents · flights · hotels · training · travel · other
price_centsrequiredintegerFull price in KES cents. Never floats.
deposit_percentrequiredintegerPercentage of price required as deposit (1–100)
max_reservationsintegerHard inventory ceiling. Enforced by us before STK push fires.
refund_policyrequiredobjectSee refund policy object below
metadataobjectOpaque key-value store. Pass event_id, venue, date — we store it, never interpret it.
Refund policy object
deadline_minutesrequiredintegerMinutes from reservation creation before it expires. 43200 = 30 days.
refundable_percentrequiredintegerPercent of total paid returned to customer on cancellation (0–94)
release_percentrequiredintegerPercent released to merchant on each payment. refundable + release must be < 100.
Reservations
A reservation represents a customer's financial commitment to a product. It moves through a well-defined status lifecycle.
Status lifecycle
pending_depositReservation created. STK push sent. Waiting for customer PIN.
reservedDeposit confirmed. Balance outstanding. Installments accepted.
paid_in_fullBalance cleared. Escrow released to merchant automatically.
cancelledCancelled. Refund issued per policy.
expiredDeadline passed before full payment. Held amount refunded.
Reservation response object
{
"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 confirmWebhooks
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.receivedFired on every confirmed payment — deposit and installments. Includes full escrow breakdown and percent_paid.
reservation.cancelledReservation cancelled. Refund issued per policy. Includes cancelled_by: customer or merchant.
reservation.expiredDeadline passed before full payment. Held amount refunded automatically. No human intervention needed.
Payload shape
{
"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
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}), 200express.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.
Errors
400Bad request
Missing or invalid fields. Check the error message for specifics.
401Unauthorized
Missing or invalid X-Api-Key header.
403Forbidden / sold out
Inventory exhausted. Check code: sold_out in response body.
404Not found
Resource does not exist or belongs to a different merchant.
409Conflict
Duplicate request. Check your idempotency key.
429Rate limited
Too many requests. Slow down and retry with exponential backoff.
500Server error
Our fault. Retry once. If it persists, contact support.
// 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