API Reference

Webhooks

Infinity Africa notifies your server the moment a collection resolves, a payout completes, or an invoice gets paid — so you don't have to poll. Configure your endpoint once; every event after that is pushed to you.

Configuring your endpoint

Set your webhook URL and choose which events to subscribe to from the Merchant Portal's Webhooks page — generate a signing secret there too (shown once, like an API key), and use the page's Send Test Webhook button to confirm your endpoint is reachable before going live. The same page shows the status of your most recent delivery attempt.

GET/v1/merchant/webhook-config

Read your current webhook URL, subscribed events, and whether a secret is set.

MERCHANT_ADMIN, MERCHANT_STAFF
PATCH/v1/merchant/webhook-config

Set the URL/events, and optionally regenerate the signing secret (returned once, in the response).

MERCHANT_ADMIN, DEVELOPER
POST/v1/merchant/webhook-config/test

Send a signed sample payload to your configured URL right now and report the result.

MERCHANT_ADMIN, DEVELOPER
GET/v1/merchant/webhook-events

List your own outbound delivery queue.

MERCHANT_ADMIN, MERCHANT_STAFF

Event types

The events below are the ones your integration should actually handle. A few additional names are reserved in the schema for features on the roadmap (refunds, chargebacks, scheduled sweeps) — you'll see them in the enum, but nothing emits them yet.

EventFires when
collection.successA collection reached successful — the merchant wallet was credited. The only event safe to mark an order paid from.
collection.failedA push or QR collection was declined, rejected, or timed out.
collection.pending_reviewHeld before crediting — the payer's phone matched the merchant's own registered phone (self-payment/"own till" risk). Requires Super Admin review.
collection.reversedA previously successful collection was reversed by the provider after settlement — the wallet credit was clawed back.
disbursement.successA payout was delivered.
disbursement.failedA payout was declined; its balance reservation was reversed.
disbursement.reversedA previously successful payout was reversed by the provider after settlement.
payment_link.paidA payment link (including one generated from an invoice) was paid.
payment_link.payment_reversedA payment link's PAID status was reopened because its collection was reversed.
invoice.paidAn invoice reached PAID.
collection.pendingReserved for a future intermediate collection state.(reserved)
collection.processingReserved — a push was sent or a QR/token was generated; not emitted as its own event yet (visible via GET/refresh-status instead).(reserved)
collection.cancelledReserved — no code path sets a collection to cancelled yet.(reserved)
invoice.overdueReserved for a scheduled past-due sweep.(reserved)
payment_link.createdReserved.(reserved)
payment_link.expiredReserved for a scheduled expiry sweep.(reserved)
refund.succeededReserved — refunds aren't issued yet.(reserved)
refund.failedReserved.(reserved)
chargeback.openedReserved.(reserved)
chargeback.resolvedReserved.(reserved)

Status lifecycle

The status field on a collection webhook (and on GET /v1/collections/{id}) is always one of these seven values:

StatusMeaning
createdCollection created (an Infinity Payment Page with no method chosen yet). Payment has not started.
processingA prompt was sent or a QR/token was generated. The customer has not yet approved anything.
pending_clearanceThe provider signaled completion, but a review step still applies before funds become available (currently: self-payment/"own till" risk review). Not yet safe to treat as paid.
successfulFinal, safe, completed state. The merchant wallet was credited. The only status safe to mark an order paid from.
failedThe attempt did not complete. Not payable, not credited.
cancelledThe attempt was cancelled. Not payable, not credited.
reversedWas successful, then clawed back by the provider after settlement. Not credited — treat exactly like a failed payment.
warning

Mark an order paid only on collection.successful

Never mark an order paid from a wallet-push/Selcom Pesa prompt being sent, a QR/token being generated, or a resultcode of 000 on the initial push response — all of those only mean the provider accepted the request, not that the customer paid. Wait for collection.successful (webhook) or poll GET /v1/collections/{id} until status is successful.

Payload shape

json — POST to your webhook_url
{
  "event": "collection.successful",
  "merchant_code": "27048391",
  "collection_id": "col_xxxxx",
  "transaction_id": "txn_xxxxx",
  "reference": "ORDER-4821",
  "merchant_reference": "ORDER-4821",
  "amount": 50000,
  "fee": 750,
  "net_amount": 49250,
  "currency": "TZS",
  "status": "successful",
  "timestamp": "2026-08-23T10:00:00+03:00"
}

merchant_code is your Merchant ID — identification only, never a secret and never usable as an API key. reference and merchant_reference are the same value — kept as two keys so existing integrations parsing reference keep working.

fee/net_amount are only present once a fee has actually been calculated (i.e. from collection.successful/collection.reversed onward) — never fabricated for an event where no fee exists yet.

Verifying a delivery

Every delivery is signed with your merchant's webhook secret, sent as X-Infinity-Signature: an HMAC-SHA256 hex digest of the exact raw request body. Recompute it and compare — don't trust a delivery that doesn't match, and use a constant-time comparison to avoid leaking timing information.

python
import hashlib
import hmac

def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
javascript — Node.js
const crypto = require("crypto");

function verifySignature(rawBody, signature, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature || "", "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express example — read the raw body BEFORE any JSON-parsing middleware
// runs, since the signature is computed over the exact raw bytes:
app.post(
  "/api/infinity/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("X-Infinity-Signature");
    if (!verifySignature(req.body, signature, process.env.INFINITY_WEBHOOK_SECRET)) {
      return res.status(401).send("invalid signature");
    }
    const event = JSON.parse(req.body);
    // ... handle event.event / event.status / event.collection_id
    res.status(200).send("ok");
  },
);

Retries

Every event is recorded to your delivery queue the moment it happens. Automatic retry-with-backoff delivery is on the roadmap but not live yet — for now, use Send Test Webhook on the Portal's Webhooks page to confirm your endpoint responds correctly, and Transaction Status to poll as a fallback. Respond quickly to real deliveries once retries ship — do your processing asynchronously after returning 200, rather than making Infinity Africa wait on it.

info

Design for at-least-once delivery

Once automatic retries are live, treat every delivery as at-least-once, not exactly-once. Key your own processing off the resource ID inside payload (e.g. collection_id) and make handling that ID idempotent now, so a duplicate delivery is a safe no-op later.