Direct integration, A to Z.
Keep the whole payment experience inside your own app. You create a PaymentIntent, then handle whatever the chosen rail needs next. Two shapes cover every provider: OTP-on-your-page (CAC, EAB) and finish-on-the-provider-page (Waafi, card). Both, with full code, are below.
Prefer zero UI work?
The model: one create call, then a next_action
Every payment is a PaymentIntent. You create it with the provider + amount; the response tells you what has to happen next. There are exactly three next_action shapes, and each provider uses one of them:
| Name | Type | Description |
|---|---|---|
collect_otp | CAC · EAB · SabaPay (OTP) | An SMS code was sent. Show an input on your page, then POST /confirm with the code. |
redirect | Waafi · Card (CNP) | The customer leaves your app and finishes on the provider's hosted page, then returns to your return_url. |
push_approval | SabaPay (APP) | The customer approves in their wallet app. Nothing to collect — poll or await the webhook. |
The key difference to get right
The shape of every integration
- 1
Get your keys
Server usessk_test_…/sk_live_…. A publishablepk_test_…is only needed if you confirm from the browser. Secret keys never leave your server. - 2
Create a PaymentIntent (server-side)
OnePOST /v1/payment_intentswith provider + amount + customer identifier. Always send anIdempotency-Key. - 3
Branch on next_action
OTP → collect + confirm. Redirect → send the customer off and back. Push → wait. - 4
Confirm settlement with the webhook
Treatpayment_intent.succeededas the source of truth, then go live.
1 · Create the PaymentIntent
Same call for every provider — only the provider value (and what you collect up front) changes. Amounts are major units (DJF is whole francs, so 50 DJF is "amount": 50).
curl -X POST https://api.merashub.com/v1/payment_intents \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: order_42" \
-H "Content-Type: application/json" \
-d '{
"amount": 50,
"currency": "DJF",
"provider": "cac", // cac | waafi | sabapay | eab | santimpay (card → waafi + waafi_mode:"card")
"customer_msisdn": "77000000", // wallet rails only
"return_url": "https://yoursite.example/orders/42/return",
"description": "Order #42"
}'The status + next_action.type in the response tell you which flow to run:
// provider: "cac" → collect the OTP on your page
{ "id": "pi_01H...", "status": "requires_action",
"next_action": { "type": "collect_otp", "otp_length": 6 } }
// provider "waafi" + metadata.waafi_mode "hpp" → wallet on Waafi's page
{ "id": "pi_01H...", "status": "requires_action",
"next_action": { "type": "redirect",
"redirect_url": "https://hpp.waafipay.com/pay/AB12..." } }
// provider "waafi" + metadata.waafi_mode "card" → Visa/MC on Waafi's page
{ "id": "pi_01H...", "status": "requires_action",
"next_action": { "type": "redirect",
"redirect_url": "https://hpp.waafipay.com/pay/CD34..." } }
// provider "santimpay" (Ethiopia/ETB) → redirect to SantimPay's page
{ "id": "pi_01H...", "status": "requires_action",
"next_action": { "type": "redirect",
"redirect_url": "https://checkout.santimpay.com/..." } }
// provider "sabapay" + metadata.auth_mode "APP" → approve in the app
{ "id": "pi_01H...", "status": "requires_action",
"next_action": { "type": "push_approval" } }2 · CAC + EAB — OTP on your page
The customer gets an SMS code. You show an input and confirm it. CAC wants the bare local number (77000000, no +253). These rails are single-attempt: if the code is wrong, that request is dead — create a new PaymentIntent to send a fresh code.
// 1) create — provider "cac", bare MSISDN
const create = await fetch('https://api.merashub.com/v1/payment_intents', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.MERAS_SECRET_KEY,
'Idempotency-Key': 'order_42',
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 50, currency: 'DJF',
provider: 'cac',
customer_msisdn: '77000000', // bare local number
description: 'Order #42',
}),
});
const intent = await create.json();
// intent.next_action.type === 'collect_otp' → show an OTP input to the customer
// 2) confirm — once the customer types the SMS code
const confirm = await fetch(
'https://api.merashub.com/v1/payment_intents/' + intent.id + '/confirm',
{
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.MERAS_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ otp: '123456' }), // sandbox: 123456 succeeds
},
);
const result = await confirm.json();
if (result.status === 'succeeded') fulfilOrder(intent.id);
// wrong code → 400 "otp invalid": the request is consumed, start a new intent.provider: "eab" and pass the wallet/account identifier. The confirm step is identical.3 · Waafi + Card — finish on the provider page
Waafi runs entirely on Waafi's hosted page.You don't collect the phone or PIN — you create the intent, redirect the customer to redirect_url, and they complete everything there. When they finish, Waafi sends them back to your return_url and fires the webhook. Cards run the same way — pass metadata.waafi_mode: "card"and the customer enters their Visa/Mastercard on Waafi's hosted page.
// 1) create — provider "waafi". No MSISDN/PIN needed from you.
const create = await fetch('https://api.merashub.com/v1/payment_intents', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.MERAS_SECRET_KEY,
'Idempotency-Key': 'order_42',
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 50, currency: 'DJF',
provider: 'waafi',
metadata: { waafi_mode: 'hpp' }, // ← REQUIRED for the hosted-page flow
return_url: 'https://yoursite.example/orders/42/return',
description: 'Order #42',
}),
});
const intent = await create.json();
// 2) redirect — send the customer to Waafi's hosted page.
// They enter their phone + PIN THERE, not in your app.
if (intent.next_action?.type === 'redirect') {
res.redirect(303, intent.next_action.redirect_url);
}
// 3) return — Waafi sends the customer back to return_url when done.
// Don't trust the return alone; verify the real status:
app.get('/orders/42/return', async (req, res) => {
const r = await fetch(
'https://api.merashub.com/v1/payment_intents/' + intentId,
{ headers: { 'Authorization': 'Bearer ' + process.env.MERAS_SECRET_KEY } },
);
const fresh = await r.json();
res.render(fresh.status === 'succeeded' ? 'thank-you' : 'try-again');
});Two things to get right for Waafi
metadata.waafi_mode = "hpp" on create — without it Waafi defaults to a push-approval flow instead of the hosted page. (2)Don't collect the MSISDN or an OTP yourself — that all happens on Waafi's page. Your only job is to redirect and verify on return.Card (Visa / Mastercard) — same redirect, SAQ-A scope
provider: "waafi" and metadata.waafi_mode: "card" (instead of "hpp"), then redirect to redirect_urlexactly like the wallet flow above. The customer keys their card on Waafi's PCI-compliant page — raw card data never touches your server (PCI SAQ-A), and 3-D Secure is handled there when the issuer requires it.4 · SabaPay — push or OTP
SabaPay supports both. Pick with metadata.auth_mode on create: APP sends a push to the SabaMobile app (nothing to collect — wait), OTP sends an SMS code (confirm it exactly like CAC).
// APP push — customer approves in SabaMobile; you poll / await webhook.
await createIntent({ provider: 'sabapay', amount: 50, currency: 'DJF',
customer_msisdn: '77000000',
metadata: { auth_mode: 'APP' } });
// next_action.type === 'push_approval' → poll GET /payment_intents/{id}
// OTP — SMS code, then confirm (same as CAC).
const i = await createIntent({ provider: 'sabapay', amount: 50, currency: 'DJF',
customer_msisdn: '77000000',
metadata: { auth_mode: 'OTP' } });
// next_action.type === 'collect_otp'
await confirmIntent(i.id, { otp: '123456' });5 · Waiting on a push approval
For push_approvalthere's nothing to collect — poll the intent until it's terminal, or just rely on the webhook (next section).
curl https://api.merashub.com/v1/payment_intents/pi_01H... \ -H "Authorization: Bearer sk_test_..." # poll every 2s → requires_action → … → succeeded | canceled
6 · Confirm with the webhook
Whatever the rail, payment_intent.succeeded is the source of truth. Verify the HMAC signature over the raw body and de-dupe on event.id.
// Headers on every delivery:
// MerasPay-Event-Id: evt_...
// MerasPay-Event-Type: payment_intent.succeeded
// MerasPay-Signature: t=<unix>,v1=<hex> — HMAC-SHA256 over "{t}.{event_id}.{body}"
app.post('/webhooks/meras', express.raw({ type: '*/*' }), (req, res) => {
const header = req.headers['meraspay-signature'] || ''; // keys are lowercased
const eventId = req.headers['meraspay-event-id'] || '';
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const signed = parts.t + '.' + eventId + '.' + req.body; // body = raw bytes
const expected = crypto
.createHmac('sha256', process.env.MERAS_WEBHOOK_SECRET)
.update(signed)
.digest('hex');
if (!parts.v1 ||
!crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))) {
return res.status(400).end();
}
const event = JSON.parse(req.body); // { id, type, data, ... }
if (event.type === 'payment_intent.succeeded') fulfilOrder(event.data.id);
res.json({ received: true }); // 2xx within 5s or we retry
});Provider cheat-sheet
| Name | Type | Description |
|---|---|---|
cac | collect_otp | Bare local MSISDN (77000000). OTP on your page → confirm. Single-attempt. |
waafi | redirect | Pass metadata.waafi_mode:"hpp". Finish on Waafi's hosted page; redirect + verify on return. No MSISDN/OTP on your side. |
sabapay | push / OTP | metadata.auth_mode:"APP" = push to SabaMobile; "OTP" = SMS code → confirm. |
eab | collect_otp | Wallet/account + SMS OTP → confirm. Same shape as CAC. |
card | redirect | Visa/Mastercard via Waafi: provider:"waafi" + metadata.waafi_mode:"card". Card entered on Waafi's page; redirect + verify. SAQ-A scope. |
santimpay | redirect | Ethiopia (ETB). Hosted redirect by default — customer picks Telebirr/CBE on SantimPay's page. metadata.santim_mode:"direct" + santim_payment_method for a direct charge. Verify on return/webhook. |
Go-live checklist
- ✓KYC approved + at least one live provider connected.
- ✓Idempotency-Key sent on every create.
- ✓OTP rails (CAC, EAB): OTP input + confirm; fresh intent on a wrong code.
- ✓Redirect rails (Waafi, card): redirect to redirect_url, verify on return — no phone/OTP screen.
- ✓Push rail (SabaPay APP): poll or await the webhook.
- ✓Webhook endpoint verified + idempotent (de-dupe on event.id).
- ✓Swapped sk_test_ → sk_live_.