Build your payment
connection.
Turn a mobile money confirmation into a payment event your billing system can act on.
Customers pay your receiving number. Your dedicated Android phone forwards the confirmation. ISP Billing Pay matches it to a payment intent and sends your server a signed webhook.
The gateway records and matches payment notifications. Money goes directly to your mobile money account. Your billing system manages packages, usernames, devices and internet access.
https://ispbillingpay.com/v1Use your deployment's HTTPS origin if self-hosting. Examples contain illustrative account details and placeholder secrets. Send JSON objects with Content-Type: application/json; request bodies must be no larger than 64 KiB.
Start here
From setup to a paid intent
- Prepare your webhook. Implement the registration challenge and signed event handler on a public HTTPS endpoint.
- Register your merchant. Save the API key and webhook secret on your server.
- Connect the receiving phone. Create a listener, install the Android app, and verify its connection.
- Create an intent at checkout. Send the payer number, exact package amount and your account reference. Display the receiving number and registered name returned by the API.
- Handle the payment event. Verify the signature, persist the event, and activate the package once. Keep connection failures available for recovery.
Built-in sender lists cover Uganda (256) and Ghana (233). Other networks can be configured with provider: "other" and their actual sender names, including in countries without a built-in list. Deployment country restrictions still apply; Kenya is excluded by the default configuration. Check GET /v1/me and validate your receipt format before enabling automatic activation. Provider names identify message formats, not commercial partnerships.
Authentication
Merchant requests use a server-side bearer key. Phones use a separate device key. Never place either credential in a customer-facing page, checkout app or public repository.
| Credential | Where it belongs | How it is used |
|---|---|---|
| Merchant API key | Your billing server | Authorization: Bearer sk_live_… |
| Device key | One receiving phone | X-Device-Key: … |
| Webhook secret | Your event handler | Verify the event signature locally. |
API and device keys are returned when issued and stored as hashes by the gateway. Store their full values securely; they cannot be retrieved later. The webhook signing secret is separate and must remain available to both servers.
/v1/meReturns merchant details, configured currency, webhook URL, available providers and active pay_to accounts. The provider list includes other for custom sender setup; this option is not a guarantee that an untested receipt format will parse.
curl https://ispbillingpay.com/v1/me \
-H "Authorization: Bearer $ISP_PAY_API_KEY"01 / Merchant setup
Register your merchant
/v1/merchants/registerInitial registration does not require a payment-provider API key. A challenge verifies control of your webhook. Generate a random, single-use nonce on your server and store it as pending before sending this request.
{
"name": "Example Networks",
"country": "uganda",
"dial_code": "256",
"currency": "UGX",
"webhook_url": "https://billing.example.com/payments/webhook",
"nonce": "c70fca624ab244339767da0684020858"
}Send currency as a three-letter code such as UGX or GHS. The example nonce is illustrative; generate a new one each time and keep it at most 64 characters. During registration, the gateway sends the following to your webhook with X-Gateway-Event: challenge:
{
"type": "challenge",
"challenge": "<gateway-generated-random-value>",
"nonce": "<the-pending-nonce-you-sent>"
}Check that the nonce matches a registration your server initiated and has not already consumed. Only then echo the exact challenge with HTTP 200:
{"challenge":"<the-exact-received-challenge>"}This setup challenge is not a signed payment event. Do not echo every challenge blindly. Successful initial registration returns HTTP 201:
{
"merchant_id": "mer_…",
"api_key": "sk_live_…",
"webhook_secret": "whsec_…",
"reissued": false
}Re-registering an existing webhook requires its merchant's current bearer key and a successful challenge. Without that key the API returns HTTP 409 with merchant_exists. Successful reissue returns HTTP 200 and reissued: true, revokes prior API keys and changes the webhook secret. Update both together. A challenge alone is not lost-key recovery; contact your deployment administrator if the key is unavailable.
02 / Payment source
Connect a listener phone
/v1/devicesChoose a provider code returned by GET /v1/me. Enter the number and registered account name your customers will pay.
{
"provider": "mtn_ug",
"receiving_number": "+256770000001",
"receiving_name": "EXAMPLE NETWORKS",
"label": "Main collection phone"
}The response contains device and a one-time device_key. Paste that key into the listener app and use the full message endpoint below. Keep the phone powered, online and able to receive SMS on the payment SIM. Grant the requested SMS and background-operation permissions.
https://ispbillingpay.com/v1/device/messagesConnect a network outside the built-in list
Register with your local dial code and currency, then create the device with provider: "other". Supply extra_senders as a comma-separated list of the sender names shown on genuine receiving-account messages. The following fictional example assumes a merchant registered with dial code 260 and currency ZMW:
{
"provider": "other",
"receiving_number": "+260970000001",
"receiving_name": "EXAMPLE NETWORKS",
"label": "Local collection phone",
"extra_senders": "ExampleMoney"
}Replace the example sender and account details. Add the same sender names to the Android app's allowed-sender list; creating the device does not update the phone automatically. Ordinary numeric senders are not accepted as network aliases.
Custom senders use the current English receipt parser. The merchant's currency code is recognized when it appears in the message; a sender name alone cannot teach the parser another language or receipt layout. Test actual credits, reversals, non-payment messages, transaction IDs, payer numbers and amounts end to end. If a format is unsupported, arrange parser support and review payments manually until validation succeeds.
| Endpoint | Purpose |
|---|---|
GET /v1/devices | List phones, last contact, app version and health. |
POST /v1/devices/{id}/rotate | Issue a new device key; update the phone. |
POST /v1/devices/{id}/revoke | Stop accepting this phone's credential. |
Heartbeats and health
The app calls POST /v1/device/messages with X-Device-Key. With no message text, the request updates last contact and returns {"ok":true,"result":"pong"}.
{"ping":1,"version":"1.0"}Message submissions carry from, text, sentStamp (Unix milliseconds) and version. Results are recorded, duplicate, ignored or reversal. HTTP 200 means the submission was handled, not that a customer was connected.
Health is never until first contact, online after contact within 30 minutes, quiet after that, or revoked. This is a contact indicator, not a guarantee that every payment SMS arrived. Messages already captured while offline remain queued for upload.
03 / Checkout
Create a payment intent
/v1/intentsCreate an intent from your server after the customer chooses a package. It connects a payer number and amount to an account reference in your billing system.
{
"amount": 1000,
"payer_phone": "+256770000002",
"payer_name": "Alex",
"reference": "hotspot-user-1042",
"metadata": {"package_id":"daily-1000"}
}| Field | What to send |
|---|---|
amount · required | A positive amount in major currency units, with at most two decimal places. 1000 means UGX 1,000 for a UGX merchant. |
payer_phone · required | The full number sending the money, normalized using the merchant dial code. |
reference · required | Your stable customer or account identifier, up to 100 UTF-8 bytes. Keep MAC addresses separate. |
payer_name · optional | A payer name used as an additional consistency check, up to 100 UTF-8 bytes. |
metadata · optional | Package or order details returned with the intent, up to 16 KiB of encoded JSON. Avoid secrets and unnecessary personal data. |
Currency comes from merchant registration. There is no per-intent currency override. Intents stay open for 24 hours. The response contains intent and pay_to, an array of receiving numbers, registered names and provider labels. Display these at checkout with a copy button for the receiving number.
Use reference for the stable account and the returned intent.id for this purchase. Repeating the same still-open reference, number and amount returns the existing intent. This is limited reuse of a waiting intent, not general request idempotency. Save the ID and read it on reload. A qualifying unmatched payment received within the previous 24 hours can be linked at intent creation; always inspect the returned status.
/v1/intents/{id}Returns {"intent":{…}} with status waiting, paid or expired. A paid intent includes payment. Use this server-side lookup to reconcile a delayed webhook.
Automatic matching requires the same merchant, payer number, amount and currency. Different references competing for the same payment are held for review. Amount alone never identifies the customer. A paid intent does not prove a successful router login; track activation separately.
04 / Payment events
Receive webhooks
Each event is stored before delivery. Verify the signature, durably record the ID and payload, then return a 2xx status. Run activation from your queue so a slow router does not hold the webhook connection open.
| Event | Payload | Your next step |
|---|---|---|
payment.matched | data.intent, including its payment | Validate the account and package; schedule activation once. |
payment.unmatched | data.payment and data.known_references | Show the unassociated payment or apply your renewal policy. |
payment.reversed | data.payment with reversed: true | Reconcile the original credit under your reversal policy. |
{
"id": "evt_…",
"type": "payment.matched",
"created": 1789900000,
"data": {
"intent": {
"id": "pi_…", "status": "paid",
"reference": "hotspot-user-1042",
"amount": 1000, "currency": "UGX",
"payment": {"id":"pay_…","reversed":false}
}
}
}Retries are part of the integration
Timeouts and non-2xx responses queue retries after 1 minute, 5 minutes, 15 minutes, 1 hour, 4 hours, and then three daily intervals. The deployment must run server/bin/deliver-webhooks.php every minute for scheduled retries to execute. Requests time out after 10 seconds.
Events can arrive more than once or out of order. Deduplicate by event.id and make the package-credit operation unique by payment ID too. A reversal is a separate event about an existing payment; do not discard it as a duplicate credit. Reconcile delivery failures through the payment and intent APIs.
If a recognized reversal arrives before its original receipt, the gateway reserves that transaction identity with reversed: true. When the credit arrives, it fills the receipt details and emits payment.reversed; it never creates a paid intent. A reversal without a readable transaction ID still requires manual reconciliation.
Verify the signature
Read X-Gateway-Signature: t=…,v1=…. Compute HMAC SHA-256 of the timestamp, a literal dot and the unchanged raw request body, using the entire whsec_… secret as the key.
This helper verifies signed payment events. Handle registration challenges separately using the pending nonce; add your transactional event-storage and queue handler after verification.
function verifyGatewayEvent(string $rawBody, string $header,
string $secret): array
{
if ($secret === '' || !preg_match(
'/^t=([0-9]{1,12}),v1=([a-f0-9]{64})$/D', $header, $parts
)) {
throw new RuntimeException('Invalid signature header');
}
$timestamp = (int) $parts[1];
$expected = hash_hmac('sha256', $parts[1] . '.' . $rawBody, $secret);
if (abs(time() - $timestamp) > 300 ||
!hash_equals($expected, $parts[2])) {
throw new RuntimeException('Signature verification failed');
}
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($event) || empty($event['id']) || empty($event['type'])) {
throw new RuntimeException('Invalid event');
}
return $event;
}
$event = verifyGatewayEvent(
file_get_contents('php://input'),
$_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '',
getenv('ISP_PAY_WEBHOOK_SECRET') ?: ''
);
// Persist event.id with a UNIQUE constraint and enqueue processing
// in the same database transaction. A duplicate event is a no-op.
// Return 2xx only after that transaction commits.Reject failed verification without processing the event. Keep server clocks synchronized. The five-minute window applies to the delivery timestamp, not the event's creation time: a valid retry can carry an older event. A valid signature authenticates the gateway event; it does not turn an SMS into a network-signed settlement record.
Payments, review and renewals
/v1/payments?status=unmatchedReturns {"payments":[…]}, newest first, up to 200 records. Use status=matched, status=unmatched or omit the filter. Records include transaction ID, provider, amount, currency, payer details, reference, hold reason, reversal flag and original message. Restrict access to trusted staff and servers.
Unmatched payments include known_references: accounts previously linked to this payer number. These help your renewal logic but do not prove current account ownership. Choose the package using your account and price data.
/v1/payments/{id}/assign{"reference":"hotspot-user-1042","rule":"manual"}Use rule: "remembered" for a renewal resolved by your server's policy. The response contains payment. Assignment records a reference; it does not activate a router, create a paid intent or emit a new payment.matched webhook. Persist and process the successful response once. Assignment to a different reference after matching is rejected.
| Hold reason | What to check |
|---|---|
no_waiting_intent | No eligible checkout is waiting for this payer and amount. |
known_payer_no_intent | A previous account is known; review a possible renewal. |
no_number | The receipt has no usable payer number. |
ambiguous_intents | Different references are waiting for this number, amount and currency. Review the intended account. |
number_held_by_another_reference | The number is known against another reference and the name did not confirm the new one. |
unread | A payment-like message could not be fully parsed. Review it before crediting. |
Different transaction IDs remain separate payments, even for identical amounts and times. Repeated submissions of the same merchant, provider, receiving account and transaction ID are deduplicated.
“I paid, but I'm not connected.”
Keep recovery in your checkout. A MAC-address change is an account-access problem, not a new purchase. Start with the customer's existing intent and payment status.
/v1/intents/{id}/claim{
"transaction_id": "EXAMPLE123456",
"payer_ip": "203.0.113.20"
}Your server sends the customer's transaction ID and the client IP it observed. The gateway must have received the payment within the preceding seven days. Claims check amount, currency, payer number, reversal status and existing association. An invented code never creates a payment. Ambiguous IDs and receipts without a payer number require review; a name alone is insufficient.
An unused receipt requires an unexpired waiting intent. A payment associated with another account cannot be moved by claiming it. If a payment is already linked to an intent for the same reference, a repeat claim returns that original intent after payer and amount checks, without another credit. A claim cannot replace a different payment already attached to the intent.
Payments assigned through /payments/{id}/assign have no linked intent. Recover those through your billing system's saved assignment and payment record; the claim endpoint does not create a replacement intent for them.
Verify the customer, retrieve the paid username, check live sessions and reconnect the current device under your device policy. Preserve remaining package time or data. The gateway does not inspect MAC addresses, query routers or decide that an idle account belongs to a claimant.
Set payer_ip to a valid client IP observed by your trusted server, not a browser-supplied value. If omitted, the gateway uses the request's source IP, which can be your billing server's shared address. Five failed attempts in ten minutes trigger a limit for that merchant and IP. Apply customer/session limits too and avoid exposing account details in public errors. An unreachable router means session state is unknown, not that the account is unused.
Errors & troubleshooting
Handle the machine-readable code and show an appropriate message to the customer.
{"error":{"code":"bad_payer_phone","message":"Enter the full number the money will be sent from."}}| HTTP | Typical codes | Action |
|---|---|---|
| 400 | invalid_json, bad_request, bad_currency, https_required | Send a valid JSON object and correct the fields or endpoint configuration. |
| 401 | unauthorized, unknown_key | Check credential scope and revocation. |
| 403 | country_not_supported | Check deployment country availability. |
| 404 | not_found, intent_not_found | Check the route, merchant and saved object ID. |
| 409 | already_matched, already_used, merchant_exists | Check the existing association or supply the existing merchant's key for reissue. |
| 413 | request_too_large | Reduce the request body to 64 KiB or less. |
| 422 | challenge_failed, sender_required, no_device, amount_mismatch, payment_not_found | Follow the validation message. A custom network requires its actual sender name in extra_senders. |
| 429 | too_many_attempts | Back off before retrying; do not loop on claims. |
| 500 | server_error | Reconcile the saved intent or payment before retrying a write. |
Find the stage that needs attention
- No receipt: check the SIM, listener connection, sender configuration and phone queue.
- Unassociated receipt: inspect number, amount, currency and hold reason; offer Claim payment or review.
- Paid but offline: inspect your activation queue, username, router response and session state.
- Missing webhook: inspect signature errors, endpoint responses and the retry worker; reconcile through the API.
Use the support guide for integration assistance. Share object IDs and sanitized errors, never full keys or webhook secrets.
Before your first live customer
Make the full journey reliable
- Use HTTPS, isolate secrets and verify every payment webhook.
- Validate real receipt formats for each configured provider and receiving account.
- Check that repeated notifications cannot add another package.
- Test phone and webhook outages, a reversal and a failed router login.
- Keep unassociated payments, failed activations and claims visible to staff.
- Agree privacy, reconciliation, support and refund responsibilities.