Integration guide · ~30 minutes

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 Hosted Checkout guide does all of this behind one redirect. Use Direct only when you want to own the checkout UI.

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:

NameTypeDescription
collect_otpCAC · EAB · SabaPay (OTP)An SMS code was sent. Show an input on your page, then POST /confirm with the code.
redirectWaafi · Card (CNP)The customer leaves your app and finishes on the provider's hosted page, then returns to your return_url.
push_approvalSabaPay (APP)The customer approves in their wallet app. Nothing to collect — poll or await the webhook.

The key difference to get right

Waafi must finish on Waafi's page — you never collect the phone or PIN yourself; you redirect and wait. CAC finishes on your page — you collect the SMS OTP and confirm. Wiring CAC like Waafi (or vice-versa) is the most common mistake.

The shape of every integration

  1. 1

    Get your keys

    Server uses sk_test_… / sk_live_…. A publishable pk_test_… is only needed if you confirm from the browser. Secret keys never leave your server.
  2. 2

    Create a PaymentIntent (server-side)

    One POST /v1/payment_intents with provider + amount + customer identifier. Always send an Idempotency-Key.
  3. 3

    Branch on next_action

    OTP → collect + confirm. Redirect → send the customer off and back. Push → wait.
  4. 4

    Confirm settlement with the webhook

    Treat payment_intent.succeeded as 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).

POST /v1/payment_intentsbash
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:

Response — depends on providerjson
// 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.

CAC — full flow (server)javascript
// 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.
EAB is the same shape — set 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.

Waafi — full flowjavascript
// 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

(1) Pass 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

Cards are acquired through Waafi's hosted page. Create the intent with 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).

SabaPay — APP (push) vs OTPjavascript
// 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).

GET /v1/payment_intents/{id}bash
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.

Verify the signaturejavascript
// 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

NameTypeDescription
caccollect_otpBare local MSISDN (77000000). OTP on your page → confirm. Single-attempt.
waafiredirectPass metadata.waafi_mode:"hpp". Finish on Waafi's hosted page; redirect + verify on return. No MSISDN/OTP on your side.
sabapaypush / OTPmetadata.auth_mode:"APP" = push to SabaMobile; "OTP" = SMS code → confirm.
eabcollect_otpWallet/account + SMS OTP → confirm. Same shape as CAC.
cardredirectVisa/Mastercard via Waafi: provider:"waafi" + metadata.waafi_mode:"card". Card entered on Waafi's page; redirect + verify. SAQ-A scope.
santimpayredirectEthiopia (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_.
Every provider flow, covered