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