ObPay API Documentation
Base URL: https://k5nkqygnd0i42zb1n5c3.helloreaddy.com/functions/v1/obpay-api-no-jwt
X-API-Key: sk_live_YOUR_KEY header directly. Works with raw fetch, axios, curl, PHP, Python requests, or any HTTP client. No SDK needed. See Direct HTTP section in SDK Examples.Option B — Supabase SDK: Install @supabase/supabase-js (JS) or supabase (Python) and use functions.invoke(). The SDK handles JWT automatically.What You Need — 3 Things
Only 3 things needed to integrate. The first two are the same for every ObPay user — only your Secret Key is unique.
1API URL
...helloreaddy.com/functions/v1/obpay-api-no-jwtSame for everyone
2Public Key
sb_publishable...Supabase anon key — safe to commit
3Secret Key
sk_live_YOUR_KEYServer-side only — never expose
@supabase/supabase-js or supabase for Python), create a client with #1 + #2 (both public), then pass #3 as _api_key in every request body. The SDK handles JWT auth automatically. Raw HTTP / curl will return 401.createClient(url, publicKey). It's already public and safe to commit.sb_publishable_dFKnLJZfWkXfRVr9l97rEm5s8RweReFtThis key is already public (it ships in the ObPay website's browser code). Your Secret Key (from Dashboard → API Keys) is what actually controls access.
ObPay is a complete payment platform for mobile money collections and payouts across East Africa. It gives you a RESTful API, real-time webhooks, a merchant dashboard, and built-in wallet management — everything you need to accept and send mobile money payments.
Under the hood, ObPay routes transactions through trusted mobile money aggregators to reach MTN, Airtel, and bank networks. But you only need your ObPay API key — we handle the rest.
One platform, one API key. Get your API credentials from Dashboard → API Keys after completing KYC verification. All endpoints use the same base URL and authentication.
Supported Countries & Networks (MarzPay)
| Country | Currency | Networks |
|---|---|---|
| 🇺🇬Uganda | UGX | MTNAIRTEL |
| 🇰🇪Kenya | KES | SAFARICOMAIRTEL |
| 🇬🇭Ghana | GHS | MTNAIRTELTIGOVODAFONE |
| 🇨🇲Cameroon | XAF | MTNORANGE |
Quick Start — Request Format
curl "https://k5nkqygnd0i42zb1n5c3.helloreaddy.com/functions/v1/obpay-api-no-jwt" \
-X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_YOUR_KEY" \
-d '{
"_action": "collect",
"phoneNumber": "0777123456",
"amount": 50000,
"currency": "UGX",
"reference": "INVOICE123456789",
"description": "Payment for invoice"
}'Raw HTTP returns 401. The Supabase gateway requires JWT. Jump to SDK Examples for copy-paste-ready working code.
Authentication
All API requests require your ObPay Secret Key in the X-API-Key header.
X-API-Key: sk_live_YOUR_OBPAY_SECRET_KEY
Your API key is validated against ObPay's database. The key must be active and your account must be verified (KYC approved).
Available Endpoints
/collect/payout/balance/transaction/{ref}/healthCollect Money API
/obpay-api (with _action: collect)Receive payments from mobile money users. Funds are added to your ObPay wallet balance when the transaction is successful.
curl "https://k5nkqygnd0i42zb1n5c3.helloreaddy.com/functions/v1/obpay-api-no-jwt" \
-X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_YOUR_KEY" \
-d '{
"_action": "collect",
"phoneNumber": "0777123456",
"amount": 50000,
"currency": "UGX",
"reference": "INVOICE123456789",
"description": "Payment for invoice"
}'Note: Raw HTTP / curl calls return HTTP 401 due to Supabase JWT verification. This example shows the request format. For working code, see the SDK Examples section below which uses the Supabase client SDK.
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
_action | String | Required | Must be 'collect' for collection requests |
phoneNumber | String | Required | Customer's phone number (any format accepted) |
amount | Number | Required | Amount to collect in the specified currency |
currency | String | Required | Currency code (e.g., UGX, KES) |
reference | String | Optional | Unique reference ID (auto-generated if empty) |
description | String | Optional | Transaction description or reason |
network | String | Optional | For non-UGX currencies (e.g., SAFARICOM, AIRTEL) |
Success Response (200 OK)
{
"success": true,
"data": {
"reference": "INVOICE123456789",
"transaction_id": "uuid-here",
"status": "pending",
"amount": 50000,
"currency": "UGX",
"message": "Collection request sent. Awaiting customer confirmation."
}
}Send Money (Payout) API
/obpay-api (with _action: payout)Send money directly to mobile money users. Funds are deducted from your ObPay wallet balance.
curl "https://k5nkqygnd0i42zb1n5c3.helloreaddy.com/functions/v1/obpay-api-no-jwt" \
-X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_YOUR_KEY" \
-d '{
"_action": "payout",
"phoneNumber": "0777123456",
"amount": 50000,
"currency": "UGX",
"reference": "WITHDRAW123",
"description": "Salary payment"
}'Note: Raw HTTP / curl calls return HTTP 401. See the SDK Examples for working Supabase client code.
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
_action | String | Required | Must be 'payout' for send money requests |
phoneNumber | String | Required | Recipient's phone number |
amount | Number | Required | Amount to send |
currency | String | Required | Currency code (UGX, KES, etc.) |
reference | String | Optional | Unique reference (auto-generated if empty) |
description | String | Optional | Payment description |
network | String | Optional | Required for non-UGX currencies |
Success Response (200 OK)
{
"success": true,
"data": {
"reference": "WITHDRAW123",
"transaction_id": "uuid-here",
"status": "pending",
"amount": 50000,
"currency": "UGX",
"message": "Payout request submitted. Awaiting processing."
}
}Check Balance API
/obpay-api (with _action: balance)Check your ObPay wallet balance.
curl "https://k5nkqygnd0i42zb1n5c3.helloreaddy.com/functions/v1/obpay-api-no-jwt" \
-X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_YOUR_KEY" \
-d '{
"_action": "balance"
}'Note: Raw HTTP / curl calls return HTTP 401. See the SDK Examples for working Supabase client code.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
_action | String | Required | Must be 'balance' |
Success Response (200 OK)
{
"success": true,
"data": {
"available_balance": 1500000,
"pending_balance": 200000,
"currency": "UGX"
}
}Transaction Status API
/obpay-api (with _action: transaction-status)Check the status of any transaction using its reference ID.
curl "https://k5nkqygnd0i42zb1n5c3.helloreaddy.com/functions/v1/obpay-api-no-jwt" \
-X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_YOUR_KEY" \
-d '{
"_action": "transaction-status",
"reference": "INVOICE123"
}'Note: Raw HTTP / curl calls return HTTP 401. See the SDK Examples for working Supabase client code.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
_action | String | Required | Must be 'transaction-status' |
reference | String | Required | Your transaction reference |
Success Response (200 OK)
{
"success": true,
"data": {
"id": "uuid",
"reference": "INVOICE123456789",
"status": "success",
"amount": 50000,
"currency": "UGX",
"fee_amount": 2500,
"net_amount": 47500,
"customer_phone": "+256777123456",
"payment_method": "MTN",
"created_at": "2024-01-15 10:30:00",
"completed_at": "2024-01-15 10:32:15"
}
}Webhooks
Receive real-time notifications when a transaction status changes. ObPay sends an HTTP POST to your configured webhook URL with the transaction result. But more importantly, the payment provider sends webhooks to ObPay's webhook receiver — which automatically credits your wallet the moment a payment completes.
How Auto-Sync Works (Two Webhook Directions)
The payment provider sends webhooks to ObPay's receiver when payments complete. ObPay automatically updates the transaction status and credits your wallet. You must configure the webhook URL in your provider's dashboard for this to work.
ObPay forwards the event to your webhook URL (configured in Dashboard → Webhooks) so your own server is notified in real-time. This is optional but recommended for production.
Your webhook endpoint must return a 200 OK within 10 seconds. Failed deliveries are retried 3 times with a 30-second delay.
CRITICAL: Configure This Webhook URL in Your Provider
Without this, payments complete with the provider but NEVER show in your ObPay wallet. You MUST configure your payment provider to send webhooks to ObPay's webhook receiver URL.
Webhook URL
https://k5nkqygnd0i42zb1n5c3.helloreaddy.com/functions/v1/marz-webhookSteps:
1. Copy the URL above
2. Login to your payment provider dashboard
3. Go to Settings → Webhooks or Developer → Webhooks
4. Paste the URL as the webhook endpoint
5. Select events: all transaction events or specifically Success and Failed
This is the #1 reason payments don't show in ObPay. Configure this once and all future payments sync automatically.
Webhook Payload (ObPay → Your Server)
{
"event": "payment.success",
"data": {
"reference": "INV-123456789",
"transaction_id": "uuid",
"status": "success",
"gross_amount": 50000,
"fee_amount": 2500,
"net_amount": 47500,
"currency": "UGX",
"customer_phone": "+256777123456",
"payment_method": "MTN",
"completed_at": "2024-01-15 10:35:00"
},
"timestamp": "2024-01-15T10:35:00Z"
}What Happens When a Webhook Arrives
Provider processes the payment
Customer enters PIN on their phone. The provider confirms the transaction and sends a webhook to ObPay's receiver.
ObPay matches the transaction
The webhook receiver matches the incoming event to a pending transaction using provider_reference, merchant_reference, or phone+amount.
Wallet is credited instantly
On success: transaction status updated, fee deducted, wallet balance increased. On failure: marked failed, payout wallet refunded.
Your server is notified (if configured)
ObPay forwards the event to your webhook URL so you can update your order status, send confirmation emails, etc.
Signature Verification
Each webhook includes an X-ObPay-Signature header. Format: t=TIMESTAMP,v=HASH
const crypto = require('crypto');
function verifyWebhookSignature(payload, signatureHeader, webhookUrl, secret) {
const [tPart, vPart] = signatureHeader.split(',');
const timestamp = tPart.split('=')[1];
const received = vPart.split('=')[1];
const params = {
status: payload.status,
customer_reference: payload.customer_reference,
internal_reference: payload.internal_reference
};
const sorted = Object.keys(params).sort();
let str = webhookUrl + timestamp;
sorted.forEach(k => str += k + params[k]);
const expected = crypto
.createHmac('sha256', secret)
.update(str)
.digest('hex');
return received === expected;
}Polling, Sync & Status Checking
How external websites check transaction results — automatic sync via webhooks + real-time polling
How ObPay Syncs with Payment Providers
Two sync paths: (1) Webhooks push results in real-time — configure once, auto-syncs forever. (2) Status API pulls on-demand with v3.8+ auto-checking the provider for stale pending transactions.
Auto-Sync (Webhooks)
Real-timeThe payment provider sends webhooks to ObPay automatically when payments complete. ObPay credits your wallet instantly. Nothing to code on your side.
Requires: Paste the webhook URL into your provider dashboard once. See the Webhooks section for the URL.
Status Polling (API)
On-demandYour website calls ObPay's status API with a transaction reference. As of v3.8, pending transactions older than 30 seconds are auto-checked against the provider in real-time.
Best for: External websites that need to confirm payment status before delivering goods or services.
How Status Polling Works (v3.8+)
When your website calls the ObPay status API, here's what happens behind the scenes:
Your website sends request
POST to ObPay API with _action: 'transaction-status' and your transaction reference. Authenticated with your Secret Key.
ObPay looks up the transaction
Finds your transaction in the database and checks its current status.
Auto-check provider if stale (NEW v3.8)
If the transaction is still 'pending' and older than 30 seconds, ObPay automatically calls the provider to check the real status. This means even without webhooks configured, your status checks return accurate results.
Wallet auto-credited on success
If the provider confirms the payment went through, ObPay updates the transaction to 'success' and credits your wallet — all in one status check.
Response returned to your site
You get the final status: success, failed, or still pending. The response includes an 'auto_synced' flag so you know if the provider was checked live.
Status Check API Reference
Use _action: "transaction-status" with your transaction reference. Requires your Secret Key for authentication.
curl "https://k5nkqygnd0i42zb1n5c3.helloreaddy.com/functions/v1/obpay-api-no-jwt" \
-X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_YOUR_KEY" \
-d '{
"_action": "transaction-status",
"reference": "INVOICE123"
}'Note: Raw HTTP / curl calls return HTTP 401 due to Supabase JWT verification. Use the Supabase client SDK below for working code.
{
"success": true,
"data": {
"id": "uuid",
"reference": "INVOICE123",
"status": "success",
"amount": 50000,
"currency": "UGX",
"fee_amount": 2500,
"net_amount": 47500,
"customer_phone": "+256777123456",
"created_at": "2024-01-15T10:30:00Z",
"completed_at": "2024-01-15T10:32:15Z",
"auto_synced": true,
"previous_status": "pending"
}
}New in v3.8: When auto_synced: true appears, it means ObPay checked the provider live during your request and found the payment completed — your wallet was credited right then.
Complete Status Polling (JavaScript — Supabase SDK)
npm install @supabase/supabase-jsUses Supabase client SDK — handles JWT automatically. Works 100%.const { createClient } = require('@supabase/supabase-js');
const obpay = createClient(
'https://k5nkqygnd0i42zb1n5c3.helloreaddy.com',
'sb_publishable_dFKnLJZfWkXfRVr9l97rEm5s8RweReFt'
);
const OBPAY_KEY = process.env.OBPAY_SECRET_KEY || 'sk_live_YOUR_KEY';
// Check a single transaction — auto-syncs with provider if pending > 30s
async function checkStatus(reference) {
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: {
_api_key: OBPAY_KEY,
_action: 'transaction-status',
reference
}
});
if (error) throw new Error(error.message);
if (!data.success) throw new Error(data.error || 'Status check failed');
const txn = data.data;
return {
reference: txn.reference,
status: txn.status, // 'pending' | 'success' | 'failed'
amount: txn.amount,
fee: txn.fee_amount || 0,
netAmount: txn.net_amount || txn.amount,
wasAutoSynced: txn.auto_synced || false, // true = provider checked live
completedAt: txn.completed_at || null
};
}Complete Polling Loop — Wait for Payment Confirmation
This is the full pattern external websites use: initiate a payment, then poll until the result comes back. The v3.8 auto-sync means even if the provider's webhooks aren't configured, your poll will still get accurate results.
const { createClient } = require('@supabase/supabase-js');
const obpay = createClient(
'https://k5nkqygnd0i42zb1n5c3.helloreaddy.com',
'sb_publishable_dFKnLJZfWkXfRVr9l97rEm5s8RweReFt'
);
const OBPAY_KEY = process.env.OBPAY_SECRET_KEY;
// Step 1: Initiate collection
async function startPayment(phone, amount) {
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: {
_api_key: OBPAY_KEY,
_action: 'collect',
phoneNumber: phone,
amount: Number(amount),
currency: 'UGX',
reference: 'INV-' + Date.now().toString(36).toUpperCase(),
description: 'Payment for order'
}
});
if (error || !data.success) throw new Error(data.error || error.message);
return data.data.reference; // Save this reference
}
// Step 2: Poll until resolved
async function waitForPayment(reference, maxSeconds = 120) {
const start = Date.now();
const pollInterval = 4000; // 4 seconds
while (Date.now() - start < maxSeconds * 1000) {
await new Promise(r => setTimeout(r, pollInterval));
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: { _api_key: OBPAY_KEY, _action: 'transaction-status', reference }
});
if (error || !data?.success) continue; // transient error, keep polling
const txn = data.data;
if (txn.status === 'success') {
return {
confirmed: true,
reference: txn.reference,
amount: txn.amount,
fee: txn.fee_amount || 0,
netAmount: txn.net_amount || txn.amount,
autoSynced: txn.auto_synced || false
};
}
if (txn.status === 'failed') {
return { confirmed: false, error: 'Payment was declined or cancelled' };
}
// Still pending — continue polling (auto-sync runs on each check)
}
return { confirmed: false, error: 'Payment timed out' };
}
// Step 3: Full flow
async function processPayment(phone, amount) {
console.log('Initiating payment...');
const ref = await startPayment(phone, amount);
console.log('Transaction reference:', ref);
console.log('Waiting for customer to enter PIN...');
const result = await waitForPayment(ref, 120);
if (result.confirmed) {
console.log('Payment confirmed!');
console.log('Received:', result.netAmount, 'UGX (fee:', result.fee, 'UGX)');
// Deliver goods, update order status, send confirmation email, etc.
return { success: true, ...result };
} else {
console.log('Payment failed:', result.error);
// Prompt customer to retry
return { success: false, error: result.error };
}
}Status Polling (Python)
pip install supabasefrom supabase import create_client
import time
obpay = create_client(
"https://k5nkqygnd0i42zb1n5c3.helloreaddy.com",
"sb_publishable_dFKnLJZfWkXfRVr9l97rEm5s8RweReFt"
)
OBPAY_KEY = "sk_live_YOUR_KEY"
def check_status(reference: str):
"""Check transaction status — auto-syncs with provider for pending txns."""
resp = obpay.functions.invoke("obpay-api-no-jwt", {
"body": {
"_api_key": OBPAY_KEY,
"_action": "transaction-status",
"reference": reference
}
})
if not resp.get("success"):
raise Exception(resp.get("error", "Status check failed"))
return resp["data"]
def wait_for_payment(reference: str, max_seconds: int = 120):
"""Poll until payment succeeds or fails."""
deadline = time.time() + max_seconds
while time.time() < deadline:
time.sleep(4)
try:
txn = check_status(reference)
if txn["status"] == "success":
return {"confirmed": True, **txn}
if txn["status"] == "failed":
return {"confirmed": False, "error": "Payment declined"}
except Exception:
continue # transient error, keep polling
return {"confirmed": False, "error": "Timeout"}Best Practices for Status Checking
Poll every 4 seconds
Most mobile money payments confirm within 10-30 seconds. 4-second intervals balance speed with API load.
Set reasonable timeouts
60-120 seconds for collections, up to 5 minutes for payouts. Mobile money is fast but not instant.
Handle all three outcomes
Success = deliver goods. Failed = prompt retry. Timeout = show pending message with manual refresh option.
Don't stop on transient errors
Network blips happen. Use try/catch inside the poll loop — only stop when you get a definitive status.
Auto-sync is your safety net (v3.8+)
Even without webhooks configured, each status check auto-verifies with the provider for pending transactions older than 30 seconds.
Webhooks + Polling = best combo
Configure webhooks for instant updates, use polling as fallback. Webhooks handle 95% of cases instantly; polling catches the rest.
Bulk Sync: Check All Pending Transactions at Once
Use _action: "sync-all-pending" to check the payment provider for all your pending transactions in one call. Useful for reconciliation or after webhook outages.
const { data } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: { _api_key: OBPAY_KEY, _action: 'sync-all-pending' }
});
// Response: { synced: 10, updated: [{ id, reference, status }, ...], logs: [] }Error Codes
| Status | Error | Description |
|---|---|---|
| 400 | Missing required fields | A required parameter was not provided |
| 400 | Invalid amount | Amount is not a valid number |
| 400 | Reference already used | Reference ID is a duplicate |
| 400 | Reference must not contain spaces | Use hyphens or underscores instead |
| 400 | Reference must be at most 30 characters | Shorten your reference string |
| 400 | Network is required for non-UGX currencies | Add the network field for KES/GHS/XAF |
| 400 | Insufficient balance | Not enough funds in wallet |
| 401 | Authorization required | Missing API key header |
| 401 | Invalid API key | Key format or value is incorrect |
| 403 | Account number does not match API key owner | Account number mismatch |
| 403 | User account pending approval | KYC not yet approved |
| 403 | User account suspended | Account has been suspended |
| 404 | Transaction not found | No transaction with given reference |
| 429 | Too many requests | Rate limit exceeded |
| 500 | Internal server error | Server-side issue — retry with exponential backoff |
| 504 | Payment service timeout | Payment gateway timed out |
SDK Examples
Full code examples for integrating ObPay's API into your application.
X-API-Key: sk_live_YOUR_KEY — no SDK, no Supabase, works on any hosting. See the Direct HTTP tab below for copy-paste examples that work everywhere.npm install @supabase/supabase-jsThis uses the Supabase client SDK which handles JWT automatically — works 100%. Raw HTTP / axios calls return 401.Complete ObPay Client (Node.js — Supabase SDK)
const { createClient } = require('@supabase/supabase-js');
// Create Supabase client (URL + ObPay Public Key — both safe to commit)
const obpay = createClient(
'https://k5nkqygnd0i42zb1n5c3.helloreaddy.com',
'sb_publishable_dFKnLJZfWkXfRVr9l97rEm5s8RweReFt'
);
const OBPAY_SECRET_KEY = process.env.OBPAY_SECRET_KEY || 'sk_live_YOUR_KEY';
async function collect({ phone, amount, currency = 'UGX', reference, description = '' }) {
const ref = reference || 'INV' + Date.now().toString(36).toUpperCase();
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: {
_api_key: OBPAY_SECRET_KEY,
_action: 'collect',
phoneNumber: phone,
amount: Number(amount),
currency,
reference: ref.slice(0, 30),
description
}
});
if (error) throw new Error(error.message);
if (!data.success) throw new Error(data.error || 'Collection failed');
return data.data;
}
async function payout({ phone, amount, currency = 'UGX', reference, description = '' }) {
const ref = reference || 'PAY' + Date.now().toString(36).toUpperCase();
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: {
_api_key: OBPAY_SECRET_KEY,
_action: 'payout',
phoneNumber: phone,
amount: Number(amount),
currency,
reference: ref.slice(0, 30),
description
}
});
if (error) throw new Error(error.message);
if (!data.success) throw new Error(data.error || 'Payout failed');
return data.data;
}
async function getBalance() {
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: { _api_key: OBPAY_SECRET_KEY, _action: 'balance' }
});
if (error) throw new Error(error.message);
return data.data;
}
async function getTransactionStatus(reference) {
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: { _api_key: OBPAY_SECRET_KEY, _action: 'transaction-status', reference }
});
if (error) throw new Error(error.message);
return data.data;
}
async function waitForPayment(reference, maxWaitSeconds = 120) {
const start = Date.now();
while (Date.now() - start < maxWaitSeconds * 1000) {
await new Promise(r => setTimeout(r, 4000));
try {
const txn = await getTransactionStatus(reference);
if (txn.status === 'success') return { confirmed: true, ...txn };
if (txn.status === 'failed') return { confirmed: false, error: 'Payment declined' };
} catch (e) {}
}
return { confirmed: false, error: 'Timeout' };
}
module.exports = { collect, payout, getBalance, getTransactionStatus, waitForPayment };ObPay API Reference
Your gateway to mobile money — use ObPay's REST API to collect and send payments.
Architecture
Your API key never reaches the browser — all calls go through Supabase Edge Functions.
Base URL
https://k5nkqygnd0i42zb1n5c3.helloreaddy.com/functions/v1/obpay-api-no-jwt
Authentication
All API requests require your ObPay Secret Key. Use either header:
X-API-Key: sk_live_xxxxxxxxxxxxxxxx
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx
Platform Fee
ObPay deducts a platform fee from every successful collection before crediting your wallet. Default 5%. For a 50,000 UGX collection, you receive 47,500 UGX net.
Website Integration
Integrate ObPay payments into any website — from a simple button to a full checkout flow.
Payment Link
Share a link. No code needed.
Embed Button
Copy-paste HTML into your site.
API Integration
Full REST API for developers.
Option 1: Payment Link (Simplest)
Your payment page URL:
https://obpay.online/pay/YOUR_MERCHANT_SLUGFind your exact URL in Dashboard → API Keys.
Option 2: Embed a Payment Button
<a href="https://obpay.online/pay/YOUR_MERCHANT_SLUG"
target="_blank"
style="display:inline-flex;align-items:center;gap:8px;
padding:12px 24px;background:#10B981;color:white;
border-radius:8px;text-decoration:none;font-weight:600;
font-family:system-ui,sans-serif;font-size:14px;">
Pay with ObPay
</a>How ObPay + MarzPay Work Together
Behind the scenes — how ObPay uses MarzPay to reach mobile money networks across East Africa.
MarzPay API Charges (applied by MarzPay to the transaction)
| Type | Charge | Applied By |
|---|---|---|
| Collection (receive money) | Variable charge per transaction | MarzPay (see wallet.wearemarz.com dashboard) |
| Send Money (payout to others) | Variable charge per transaction | MarzPay (see wallet.wearemarz.com dashboard) |
| ObPay Platform Fee (collections) | 5% of collected amount | ObPay (before crediting your wallet) |
| ObPay Payout Fee | 500–70K: 2,000 UGX flat / 70K+–5M: 3% | ObPay (deducted from your wallet) |
| ObPay Withdrawal Fee | Same tiered pricing as payouts | ObPay (deducted from your wallet) |
Payment Flow
You Initiate a Collection
Your website sends a request to ObPay with the customer's phone number and amount.
ObPay Routes the Payment
ObPay's Edge Function securely forwards your request to the mobile money aggregator.
Customer Gets USSD Prompt
MTN or Airtel triggers a USSD prompt. Customer enters their PIN to approve.
ObPay Receives Confirmation
The aggregator sends a webhook back to ObPay with the result.
ObPay Updates Your Wallet
On success: fee is deducted and balance credited. On failure: marked failed.
You Get Notified
If webhooks are configured, ObPay forwards the event to your server.
Important Notes
Transactions are asynchronous
A collection returns 'pending'. Actual payment takes 30-90 seconds while customer enters PIN.
Platform fee is auto-deducted
ObPay deducts the platform fee before crediting your wallet. Default 5%.
Payouts require sufficient balance
Your available balance must cover the payout amount plus fees.
Failed transactions are not charged
If a collection fails, no fee is charged and no money moves.
Rate Limiting
Understand API rate limits and how to handle them gracefully.
| Endpoint | Method | Limit | Window |
|---|---|---|---|
collect | POST | 50 | 15 minutes |
payout | POST | 5 | 1 minute |
balance | POST | 50 | 15 minutes |
transaction-status | POST | 100 | 15 minutes |
health | GET | Unlimited | — |
Best Practices
Poll at reasonable intervals
For status checks, poll every 5-10 seconds, not every second.
Cache balance results
Cache balance for 30-60 seconds instead of calling on every page render.
Handle 429 gracefully
Respect the Retry-After header. Use exponential backoff with jitter.
Security Best Practices
Protect your API keys, validate webhooks, and secure your integration.
1. Never Expose Your Secret Key
DO — Server-side only
// ✅ GOOD: API key stored in environment variable
const OBPAY_KEY = process.env.OBPAY_SECRET_KEY;
app.post('/api/pay', async (req, res) => {
const result = await fetch(OBPAY_API, {
headers: { 'X-API-Key': OBPAY_KEY },
body: JSON.stringify({ _action: 'collect', ...req.body })
});
});DON'T — Client-side exposure
// ❌ BAD: Secret key exposed in browser — anyone can steal it const OBPAY_KEY = 'sk_live_abc123'; // Visible in browser DevTools
2. Validate Webhook Signatures
const crypto = require('crypto');
function verifyObPayWebhook(req) {
const signature = req.headers['x-obpay-signature'];
if (!signature) return false;
const [tPart, vPart] = signature.split(',');
const timestamp = tPart.split('=')[1];
const receivedHash = vPart.split('=')[1];
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) return false; // Replay attack prevention
const payload = JSON.stringify(req.body);
const expectedHash = crypto.createHmac('sha256', process.env.OBPAY_WEBHOOK_SECRET)
.update(timestamp + '.' + payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(receivedHash), Buffer.from(expectedHash));
}Security Checklist
Secret key stored in environment variables, never in code or git
All API calls made from backend server, never from browser JavaScript
Webhook signatures validated on every incoming event
HTTPS enforced on your webhook endpoint
API keys rotated every 90 days
Unique references used for every transaction (idempotency)
Webhook replay attacks prevented (timestamp check within 5 minutes)
Testing & Go-Live Checklist
Verify your integration before accepting real payments.
Phase 1: Sandbox Testing
Phase 2: Go-Live Checklist
KYC Verified
Your merchant account is approved and active
API Keys Secured
Secret keys stored in environment variables
Webhook Configured
Webhook URL set up with signature verification
Error Handling
All API calls wrapped in try/catch
Rate Limiting Handled
Exponential backoff implemented for 429
Idempotency
Unique references prevent duplicate charges
HTTPS Enforced
All endpoints use HTTPS
Logging
Transaction logs stored for debugging
Build a Payment Website with ObPay
Complete production-ready integration — backend API module, frontend checkout, webhooks, and automatic payment flow.
Architecture Overview
Browser
Server
Edge Function
Browser never touches ObPay directly — all API calls go through your backend.
Step 1: Backend API Module (Node.js — Supabase SDK)
const { createClient } = require('@supabase/supabase-js');
// Create Supabase client
const obpay = createClient(
'https://k5nkqygnd0i42zb1n5c3.helloreaddy.com',
'sb_publishable_dFKnLJZfWkXfRVr9l97rEm5s8RweReFt'
);
const OBPAY_SECRET_KEY = process.env.OBPAY_SECRET_KEY || 'sk_live_YOUR_KEY';
async function collect({ phone, amount, currency = 'UGX', reference, description = '' }) {
const ref = reference || 'INV' + Date.now().toString(36).toUpperCase();
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: { _api_key: OBPAY_SECRET_KEY, _action: 'collect', phoneNumber: phone, amount: Number(amount), currency, reference: ref.slice(0, 30), description }
});
if (error) throw new Error(error.message);
if (!data.success) throw new Error(data.error || 'Collection failed');
const result = data.data;
return { reference: result.reference, transactionId: result.transaction_id, status: result.status, amount: result.amount, fee: result.collection_fee || 0, netAmount: result.net_amount_to_receive || result.amount };
}
async function payout({ phone, amount, currency = 'UGX', reference, description = '' }) {
const ref = reference || 'PAY' + Date.now().toString(36).toUpperCase();
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: { _api_key: OBPAY_SECRET_KEY, _action: 'payout', phoneNumber: phone, amount: Number(amount), currency, reference: ref.slice(0, 30), description }
});
if (error) throw new Error(error.message);
if (!data.success) throw new Error(data.error || 'Payout failed');
const result = data.data;
return { reference: result.reference, transactionId: result.transaction_id, status: result.status, amount: result.amount, fee: result.fee_amount || 0 };
}
async function getBalance() {
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: { _api_key: OBPAY_SECRET_KEY, _action: 'balance' }
});
if (error) throw new Error(error.message);
const result = data.data;
return { available: result.available_balance || 0, pending: result.pending_balance || 0, currency: result.currency || 'UGX' };
}
async function getTransactionStatus(reference) {
if (!reference) throw new Error('reference is required');
const { data, error } = await obpay.functions.invoke('obpay-api-no-jwt', {
body: { _api_key: OBPAY_SECRET_KEY, _action: 'transaction-status', reference }
});
if (error) throw new Error(error.message);
const result = data.data;
return { reference: result.reference, status: result.status, amount: result.amount, fee: result.fee_amount || 0, netAmount: result.net_amount || result.amount };
}
async function waitForPayment(reference, maxWaitSeconds = 120) {
const start = Date.now();
while (Date.now() - start < maxWaitSeconds * 1000) {
await new Promise(r => setTimeout(r, 4000));
try {
const txn = await getTransactionStatus(reference);
if (txn.status === 'success') return { confirmed: true, reference: txn.reference, amount: txn.amount, fee: txn.fee, netAmount: txn.netAmount };
if (txn.status === 'failed') return { confirmed: false, error: 'Payment declined' };
} catch (e) {}
}
return { confirmed: false, error: 'Timeout' };
}
module.exports = { collect, payout, getBalance, getTransactionStatus, waitForPayment };Step 2: Express Server with API Routes
const express = require('express');
const cors = require('cors');
const obpay = require('./obpay-client');
const app = express();
app.use(cors()); app.use(express.json());
app.post('/api/pay', async (req, res) => {
try {
const { phone, amount, currency, description } = req.body;
if (!phone || !amount) return res.status(400).json({ error: 'phone and amount are required' });
const payment = await obpay.collect({ phone, amount: Number(amount), currency: currency || 'UGX', description });
res.json({ success: true, reference: payment.reference, status: 'pending', amount: payment.amount });
} catch (error) { res.status(500).json({ error: error.message }); }
});
app.get('/api/pay/status/:reference', async (req, res) => {
try { res.json({ success: true, ...(await obpay.getTransactionStatus(req.params.reference)) }); }
catch (error) { res.status(500).json({ error: error.message }); }
});
app.get('/api/balance', async (req, res) => {
try { res.json({ success: true, ...(await obpay.getBalance()) }); }
catch (error) { res.status(500).json({ error: error.message }); }
});
app.post('/api/webhooks/obpay', (req, res) => {
console.log('[Webhook]', req.body.event, req.body.data?.reference);
res.status(200).json({ received: true });
});
app.listen(process.env.PORT || 3000, () => console.log('ObPay Server running on port', process.env.PORT || 3000));Complete Project Structure
your-payment-website/
├── obpay-client.js # Payment module (Step 1)
├── server.js # Express server (Step 2)
├── checkout.html # Frontend checkout page
├── package.json # npm init && npm install express cors @supabase/supabase-js
└── .env # OBPAY_SECRET_KEY=sk_live_YOUR_KEY
# OBPAY_PUBLIC_KEY=sb_publishable_dFKnLJZfWkXfRVr9l97rEm5s8RweReFtReady to test?
Use the API Sandbox to make real test calls without moving money.