Introduction#
izzipay is the payment layer for MIQ apps. Your app accepts mobile money and cards by talking to izzipay, never to a network directly. You write one integration, and behind it sit MTN MoMo, Telecel Cash, AT Money and cards, with a fallback that keeps checkout open when a provider is having a bad day.
Every payment follows the same two steps. Your server creates a payment intent with the amount, which is the trusted part. Then the buyer completes that intent in the browser using a token that can do nothing except pay that one intent. No secret key and no card details ever reach the buyer.
Base address
All samples use https://izzipaygh.com as the base — the live address for the API, webhooks, and hosted checkout.
How it works#
Three moving parts, in order:
- 1
Your server creates an intent — A server side call with your secret key sets the amount and your order reference. izzipay hands back a client token and a checkout URL.
- 2
The buyer pays — Using the token, the buyer picks a network and approves on their phone, or pays by card on a hosted page. One field, no redirect to learn.
- 3
You confirm the result — izzipay sends your app a signed webhook the moment the money lands and records the entry in an append only ledger. That event is your truth.
Quickstart#
Here is the whole thing, start to finish. Four steps and you have taken a payment.
1. Create a merchant account and get your keys
Register at /dashboard/register. You get test keys instantly: a secret key (sk_test_…) for your server and a publishable key (pk_test_…) for the browser. Store the secret as an environment variable like IZZIPAY_SECRET_KEY and treat it like a password. Live keys (sk_live_… / pk_live_…) unlock automatically once we verify your business, so you can build and test everything first.
2. Create a payment intent on your server
Send the amount in pesewas and your own order reference. You receive a clientToken for the browser and a checkoutUrl you can redirect to instead.
// On YOUR server. Your secret key stays here, never in the browser.
const res = await fetch("https://izzipaygh.com/api/v1/intents", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.IZZIPAY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: "12000", // 120.00 GHS, written in pesewas
currency: "GHS",
reference: order.id, // your own order id
customer: { email: order.email, phone: order.phone },
}),
});
const { clientToken, checkoutUrl } = await res.json();
// Send clientToken to the page, or just redirect to checkoutUrl.3. Add the checkout to your page
Load the script once, then add a button that carries the token. When the buyer clicks it, izzipay opens, takes the payment, and sends them to your success page.
<!-- Load the script once, anywhere on the page -->
<script src="https://izzipaygh.com/izzipay.js"></script>
<!-- The token comes from the intent you created on the server -->
<button
data-izzipay-token="pi_AbC123"
data-izzipay-success-url="/thank-you"
>
Pay GH₵120.00
</button>4. Confirm the payment with a webhook
The browser result is only for showing the buyer a friendly screen. Release the goods when the signed charge.succeeded webhook arrives. The webhooks section shows you how to verify and handle it.
That is a full payment
Intent on the server, checkout in the browser, webhook to confirm. Everything else in these docs is detail on top of these four steps.
Authentication#
izzipay issues two keys per mode, plus a one-time token per payment. The secret key (sk_…) authenticates your server; the publishable key (pk_…) is safe in the browser; and each payment gets a short-lived client token. Everything comes in test and live modes — test keys run against the sandbox and never move real money.
Secret API key
Your secret key authenticates server side calls such as creating intents and reading charges. Send it as a bearer token. Never put it in browser code, a mobile app, or a public repository.
Authorization: Bearer sk_live_8a21f…Client token
The clientToken you get back from an intent is safe for the browser. It can only act on that single intent, so even if someone copies it they can do nothing but pay the amount you already set.
Idempotency
When you start a charge directly with POST /api/v1/charges, send an Idempotency-Key header. Repeat the same key and you always get the same charge back, so a retry after a dropped connection never bills twice.
Payment intents#
An intent is a promise to collect a fixed amount. You create it on your server so the price is trusted, then the buyer fulfils it. Create one with POST /api/v1/intents.
curl -X POST https://izzipaygh.com/api/v1/intents \
-H "Authorization: Bearer $IZZIPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": "12000",
"currency": "GHS",
"reference": "order_8842",
"customer": { "email": "ama@example.com", "phone": "0551234567" },
"metadata": { "cart": "8842" }
}'Request fields
| Field | Type | Notes |
|---|---|---|
| amount | string | Required. Integer pesewas. 12000 means 120.00 GHS. |
| currency | string | Required. GHS for now. |
| reference | string | Optional. Your own order id, echoed back on the webhook. |
| customer | object | Optional. email and phone, used to prefill the checkout. |
| metadata | object | Optional. Any key values you want returned with the charge. |
Response
{
"id": "clx9a2…",
"clientToken": "pi_AbC123…", // safe for the browser
"checkoutUrl": "https://izzipaygh.com/pay/i/pi_AbC123…",
"amountPesewas": "12000",
"currency": "GHS",
"reference": "order_8842",
"status": "OPEN",
"expiresAt": "2026-06-21T12:30:00.000Z"
}Hand clientToken to the browser, or send the buyer straight to checkoutUrl. Keep id on your side to look the charge up later.
Embed the checkout#
Once you have a client token there are three ways to collect the payment. They all use the same single field mobile money flow. Pick the one that fits how much control you want.
Option A. Popup button
The simplest path. Load the script and either use a data attribute button or call izzipay.checkout yourself. The popup handles the rest and calls you back on success or close.
<script src="https://izzipaygh.com/izzipay.js"></script>
<button onclick="payNow()">Pay GH₵120.00</button>
<script>
function payNow() {
izzipay.checkout({
token: "pi_AbC123",
onSuccess: () => { window.location = "/thank-you"; },
onClose: () => { /* the buyer dismissed the window */ },
});
}
</script>Option B. Inline frame
Embed the checkout right inside your page. The frame resizes itself to its content, so it sits naturally in your layout on a phone or a desktop.
<div id="pay"></div>
<script src="https://izzipaygh.com/izzipay.js"></script>
<script>
izzipay.inline("#pay", {
token: "pi_AbC123",
onSuccess: () => location.assign("/thank-you"),
});
</script>Option C. Headless
Build your own field and button, then call the public token scoped endpoints straight from the browser. They have CORS enabled. Read the methods, start the payment, then poll for the result.
const BASE = "https://izzipaygh.com";
const token = "pi_AbC123";
// 1. Read what to show. This reflects the live admin configuration.
const intent = await (await fetch(`${BASE}/api/public/intents/${token}`)).json();
// intent.methods => { networks: ["MTN", "TELECEL", "AT"], card: true }
// 2. Start the payment after the buyer picks a network and types a number.
const pay = await (await fetch(`${BASE}/api/public/intents/${token}/pay`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method: "MOMO", network: "MTN", phone: "0551234567" }),
})).json();
// Cards and the fallback hand back a page to open.
if (pay.authorizationUrl) {
location.href = pay.authorizationUrl;
} else {
// Mobile money: the prompt is now on the buyer's phone. Poll for the result.
const tick = setInterval(async () => {
const s = await (await fetch(`${BASE}/api/public/intents/${token}/status`)).json();
if (s.charge?.status === "SUCCEEDED") { clearInterval(tick); /* show success */ }
if (["FAILED", "EXPIRED"].includes(s.charge?.status)) { clearInterval(tick); /* let them retry */ }
}, 3000);
}Polling is for the screen, not the books
The status you poll is perfect for showing a spinner and a tick. Do not mark an order as paid from it. Use the signed webhook for that.
Confirm with webhooks#
A browser can be closed, refreshed, or lied to. The trustworthy signal that a payment succeeded is the webhook izzipay sends your server. Set your webhook URL and copy your signing secret from the admin portal under Apps.
What you receive
izzipay posts a small JSON body and three headers. The event type also rides in the X-Izzipay-Event header so you can route quickly.
// Headers
// X-Izzipay-Event: charge.succeeded
// X-Izzipay-Timestamp: 1718971800000
// X-Izzipay-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015...
{
"type": "charge.succeeded",
"charge": {
"id": "chg_8f2…",
"status": "SUCCEEDED",
"amountPesewas": "12000",
"refundedPesewas": "0",
"currency": "GHS",
"reference": "order_8842",
"providerReference": "mtn_77213"
}
}You will see charge.succeeded and charge.failed most often, plus charge.expired, charge.refunded and charge.partially_refunded when they apply.
Verify the signature
izzipay signs every webhook so you know it is genuine. The signature is sha256=HMAC(secret, timestamp + "." + rawBody), sent in X-Izzipay-Signature with the timestamp in X-Izzipay-Timestamp. Compute the same value and compare it in a way that resists timing attacks.
import crypto from "node:crypto";
// Read the RAW body. Do not parse it before you verify the signature.
app.post("/webhooks/izzipay", express.raw({ type: "*/*" }), (req, res) => {
const secret = process.env.IZZIPAY_WEBHOOK_SECRET; // from Admin, Apps
const timestamp = req.header("X-Izzipay-Timestamp");
const signature = req.header("X-Izzipay-Signature");
const body = req.body.toString("utf8");
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
const ok =
signature &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(body);
if (event.type === "charge.succeeded") {
// Mark order `event.charge.reference` as paid. This is the source of truth.
}
res.sendStatus(200); // reply 2xx or izzipay will retry
});Read the raw body
Verify against the exact bytes you received. If a framework parses the JSON first and your server re serializes it, the signature will not match. Reach for the raw body, then parse after the check passes.
Reply with any 2xx status to acknowledge. If your endpoint is down or replies with an error, izzipay retries with backoff over several hours, so a brief outage never loses a notification. You can also confirm at any time by reading GET /api/v1/charges/:id with your secret key.
API reference#
The full surface is small on purpose. Server side endpoints use your secret key. Public endpoints use the client token and are safe to call from the browser.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/v1/intents | Secret key | Create a payment intent. |
| POST | /api/v1/charges | Secret key | Start a charge directly. Needs an Idempotency-Key header. |
| GET | /api/v1/charges/:id | Secret key | Read the current state of a charge. |
| POST | /api/v1/refunds | Secret key | Refund a charge in full or in part. |
| POST | /api/v1/payouts | Secret key | Send money out to a mobile money wallet. |
| GET | /api/public/intents/:token | Client token | Browser view of an intent and its methods. |
| POST | /api/public/intents/:token/pay | Client token | Buyer starts the payment. |
| GET | /api/public/intents/:token/status | Client token | Poll the live status of the charge. |
Money is always integer pesewas
Amounts come back as strings of pesewas so values stay exact. Format for display on the client, for example 12000 becomes GH₵120.00.
Testing#
izzipay ships a test mode so you can run the whole flow before a single real cedi moves. Create a test API key in the admin portal, then use the sandbox mobile money numbers listed there to force each outcome.
Drive an approval and watch the webhook reach your endpoint.
Drive a decline and confirm the buyer can retry with a card.
Drive a timeout and check your screen recovers gracefully.
Point your test webhook at a tunnel such as a local forwarding tool while you develop, then swap in your real URL before you launch.
Errors#
Every error comes back with the right HTTP status and a small, predictable body. Read the code for branching and show message to yourself in logs, not to buyers.
{
"error": {
"code": "INTENT_NOT_FOUND",
"message": "Unknown or expired link"
}
}| Code | Status | When it happens |
|---|---|---|
| UNAUTHORIZED | 401 | The API key or client token is missing, wrong, or revoked. |
| RATE_LIMITED | 429 | Too many requests in a short window. Read the Retry-After header. |
| CHARGE_INVALID_INPUT | 400 | A field is missing or malformed, or the Idempotency-Key header is absent. |
| INTENT_NOT_FOUND | 404 | The client token is unknown or the intent has already expired. |
| INTENT_CLOSED | 409 | The intent is no longer open, so a new payment cannot start on it. |
| INTERNAL | 500 | Something failed on our side. The request is safe to retry. |
Going live#
Run through this list before you flip the switch.
Swap your test API key for a live key and keep it server side only.
Attach your custom domain and update the base address in your code.
Enable the payment methods you want in the admin portal under Providers.
Set your live webhook URL and verify the signature on every event.
Confirm reconciliation is running so your ledger and providers agree.
Send one small live payment end to end and watch it settle.
FAQ#
Still stuck on something?
Send us the request id from your logs and a short description. We answer fast.
Contact the team