Partner Integration
Direct Wallet Push
A complete integration guide for a billing platform, ecommerce site, or app collecting payments on behalf of InfinityPay merchants — from the first API call through to handling a failed payment.
How it fits together
Your system ──API key──▶ InfinityPay ──▶ Mobile money ──▶ Customer's wallet
▲ │
└────── webhook ───────┘You integrate with InfinityPay and nothing else. InfinityPay holds the provider relationships and reaches them from its own approved infrastructure. You never hold, see, or configure provider credentials.
A merchant's IP allowlist governs access to the InfinityPay API. It is unrelated to any provider-side allowlist, which covers InfinityPay's own servers and is not your concern.
What the merchant sets up first
- Creates an InfinityPay account and completes verification.
- Generates API credentials at
/portal/api-credentials. Sandbox keys work immediately; live keys become available once the account is approved, verified, and priced — there is no per-key approval step. - Gives you the secret key.
- Sets a webhook URL and generates a signing secret at
/portal/webhooks.
The webhook URL is not optional in practice
Authentication
Send the secret key as a bearer token, or as X-API-Key — both are accepted.
Authorization: Bearer sk_live_YOUR_SECRET_KEY_HEREsk_live_…moves real money.sk_test_…is sandbox — no provider call, no money.pk_…is a public identifier, not a credential. It cannot authenticate and is rejected if sent as a token.- The secret is shown once at creation and stored only as a hash. It cannot be recovered — rotate if lost.
- Server-side only. Never ship a secret key in a browser or mobile app.
There is no request signing today
Sending a push
/v1/collections/wallet-pushSend a payment prompt to the customer's phone.
API key (collections:write)curl -X POST https://api.infinitypay.me/v1/collections/wallet-push \
-H "Authorization: Bearer sk_live_YOUR_SECRET_KEY_HERE" \
-H "Idempotency-Key: 8f1c2d3e-4b5a-6c7d-8e9f-0a1b2c3d4e5f" \
-H "Content-Type: application/json" \
-d '{
"amount": "5000.00",
"currency": "TZS",
"phone": "+255712345678",
"customer_name": "Asha Mushi",
"reference": "INV-2026-000412",
"description": "March broadband subscription"
}'| Field | Required | Notes |
|---|---|---|
amount | Yes | Decimal string, e.g. "5000.00". |
phone | Yes | The customer's mobile money number. |
currency | No | Defaults to TZS. |
customer_name | No | Shown on the merchant's records. |
reference | No | Your order or invoice id. Echoed back on every response and webhook. |
description | No | Free text. |
merchant_id | No | You do not need this. Your API key already identifies the merchant. Accepted if sent, and rejected if it is not the key's own merchant. |
Idempotency-Key is a required header, not a body field.
{
"success": true,
"data": {
"collection_id": "3f2a8c1e-...",
"reference": "INV-2026-000412",
"status": "processing",
"message": "Payment prompt sent. Please approve on your phone."
}
}202 means the prompt was sent, not that payment succeeded
collection.success webhook, or poll GET /v1/collections/{collection_id}.Idempotency
Retry with the same Idempotency-Key and you get the original result back — no second prompt to the customer, no second payment attempt. Reusing a key with a different body is rejected rather than silently returning the wrong result.
Generate one key per payment attempt and reuse it for every retry of that attempt. Do not reuse it for a genuinely new payment.
Webhooks
Events relevant to this flow: collection.success, collection.failed, and collection.pending_review. Full details, signature verification examples and the retry schedule are on the Webhooks page.
{
"event": "collection.failed",
"merchant_code": "27048391",
"collection_id": "3f2a8c1e-...",
"reference": "INV-2026-000412",
"merchant_reference": "INV-2026-000412",
"amount": "5000.00",
"currency": "TZS",
"status": "failed",
"failure_reason_code": "user_cancelled",
"failure_reason_message": "The customer cancelled the payment.",
"failed_at": "2026-09-27T09:14:22Z",
"timestamp": "2026-09-27T09:14:22Z"
}Headers on every delivery
| Header | Purpose |
|---|---|
X-Infinity-Signature | HMAC-SHA256 hex digest of the exact raw body, keyed with the webhook secret. |
X-Infinity-Event | The event name, e.g. collection.success. |
X-Infinity-Delivery | Unique per event. Deduplicate on this — deliveries are at-least-once. |
X-Infinity-Timestamp | Unix seconds at send time, for rejecting very old replays. Not part of the signed material — verify the signature over the body alone. |
Verifying the signature
import hashlib
import hmac
import json
def handle_webhook(request):
raw = request.get_data() # raw BYTES, before any JSON parse
sent = request.headers.get("X-Infinity-Signature", "")
expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sent): # constant-time
return "", 401
payload = json.loads(raw)
if payload.get("test") or payload.get("sandbox"):
return "", 200 # acknowledge, do not fulfil
# Store it, return 200, then process. Do not process before responding.
enqueue_for_processing(payload)
return "", 200Sign the raw bytes you received. Parsing the JSON and re-serialising it changes the bytes and the digest will not match — this is the single most common integration mistake.
Retries
Any 2xx counts as delivered. Anything else — a non-2xx, a timeout, or an unreachable host — is retried up to 5 attempts, spaced immediately, then after 1, 5, 15 and 30 minutes. After the fifth the event is marked failed and is not retried again.
The timeout is 8 seconds, so return 200 as soon as you have stored the event and do your processing afterwards — a slow handler burns a retry. If your endpoint was down long enough to exhaust them, poll Transaction Status to catch up.
Three kinds of delivery — only one to act on
| Source | Marker | Fulfil? |
|---|---|---|
| Send Test Webhook | "test": true | No |
| Sandbox push | "sandbox": true | No |
| Live push | Neither field present | Yes |
A test delivery deliberately looks like a real success
event: collection.success so that your real handler is what gets exercised. A handler keying only on that field would fulfil a fake order — check test and sandbox explicitly.Testing without spending money
A sandbox key (sk_test_…) queues the same events a live push would, so you can exercise your real handler end to end for free. Use simulate_status on the create call to choose the outcome — successful, failed, pending_clearance or reversed.
{
"amount": "1000.00",
"phone": "+255712345678",
"reference": "TEST-001",
"simulate_status": "failed"
}Statuses
| Status | Meaning | What to do |
|---|---|---|
processing | Prompt sent, customer has not acted yet. | Wait for the webhook. |
successful | Paid, and the merchant wallet is credited. | Fulfil the order. |
failed | Terminal failure. | Read failure_reason_code. |
pending_clearance | Held for review. | Wait. Do not fulfil. |
reversed | Reversed after completing. | Reverse your fulfilment. |
Failure reason codes
failure_reason_code is a closed set, safe to switch on. failure_reason_message is a sentence you may show a user. Handle unknown codes gracefully — the set can grow.
| Code | Meaning |
|---|---|
user_cancelled | The customer cancelled the prompt. |
provider_declined | Declined by the payment provider. |
reversed | Reversed after it had completed. |
expired | Expired before the customer approved it. |
provider_unavailable | Provider unreachable. Retryable. |
unknown_provider_error | The cause could not be identified. |
insufficient_balance | Not enough balance in the wallet. |
wrong_pin | Wrong PIN, or authorization failed. |
timeout | Authorization timed out. |
IP allowlist (optional)
Off by default — a valid key works from any address. Enabled per key in the portal, it restricts that key to specific addresses; a request from anywhere else is rejected with a generic error that reveals nothing about the allowlist, and the rejection is recorded in the audit log. If you use it, add every egress address your servers can present, and update it when your infrastructure changes.
Go-live checklist
- Merchant account created, approved and verified.
- Live API key generated. Secret stored server-side only.
- Webhook URL set and signing secret generated.
- Send Test Webhook from the portal. Confirm your verifier accepts the signature.
- Sandbox run with
sk_test_…, including asimulate_status: "failed"case. - Optional: enable the IP allowlist and confirm a request still succeeds.
- One live push at the smallest workable amount, to a phone you control.
- Confirm the whole chain:
202→ webhook delivered → collectionsuccessful→ merchant wallet credited. - Confirm a deliberate failure — decline the prompt — arrives as
collection.failedwithfailure_reason_code: "user_cancelled".