Integration guide · ~15 minutes

Integrate Checkout, A to Z.

The simplest way to get paid with MerasPay. You create a session on your server, send the customer to a Meras-hosted page, and they pay with any Djiboutian rail — Waafi, CAC, SabaPay, EAB, or card. No PCI scope, no provider SDKs. This is the whole thing, start to finish.

ℹ

When to use Hosted Checkout

You want the fastest path to live and you're fine redirecting the customer to a Meras page. If instead you need the payment UI to live entirely inside your own app, use the Direct integration guide.

How it works

Three moving parts: your server (creates the session + listens for the webhook), the customer's browser (redirected to Meras and back), and MerasPay (hosts the payment page + drives the rail).

The round triptext
  your server                 MerasPay                 customer
  ───────────                 ────────                 ────────
  1. POST /checkout/sessions ─────────►
       ◄──────────── { url, id }
  2. redirect browser ───────────────────────────────► opens hosted page
                                          picks rail, pays ◄──┘
  3.            payment_intent.succeeded  ◄─── webhook
  4. browser returns to success_url ◄──────────────────
  5. you verify status, fulfil the order

Step by step

  1. 1

    Get your API keys

    Sign up at merchant.merashub.com, then Settings → Keys. You get a secret key per environment: sk_test_… (sandbox) and sk_live_… (live). The secret key is used server-side only — never ship it to the browser.
  2. 2

    Create a checkout session (server-side)

    Call POST /v1/checkout/sessions with the amount, currency, and the two URLs the customer returns to. You get back a hosted url.
  3. 3

    Redirect the customer to the url

    Send the browser to session.url. MerasPay renders the branded page; the customer picks a rail and pays.
  4. 4

    Confirm with the webhook (source of truth)

    Listen for payment_intent.succeeded. This is the only reliable signal a payment completed — never trust the redirect alone.
  5. 5

    Go live

    Swap sk_test_ → sk_live_. Same code, real money.

1 · Create the session

Amounts are in the currency's major unit — for DJF, a 50 DJF charge is "amount": 50.

POST /v1/checkout/sessionsbash
curl -X POST https://api.merashub.com/v1/checkout/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: order_42" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 50,
    "currency": "DJF",
    "success_url": "https://yoursite.example/orders/42/done",
    "cancel_url":  "https://yoursite.example/orders/42",
    "description": "Order #42 — 2x espresso"
  }'
201 Createdjson
{
  "id": "cs_01HZ8AYJX3D8M7QK0E5V4WJ9K",
  "url": "https://checkout.merashub.com/c/cs_01HZ8AYJX3D8M7QK0E5V4WJ9K",
  "status": "open",
  "amount_minor": 50,
  "currency": "DJF",
  "expires_at": "2026-06-13T19:15:30Z"
}
NameTypeDescription
amount
required
integerAmount to charge, in major units (DJF is whole francs).
currency
required
ISO 4217"DJF" today; "USD"/"ETB" on the corridor.
success_url
required
urlCustomer returns here on success. We append ?session_id=cs_…
cancel_url
required
urlCustomer returns here if they back out.
allowed_methodsarrayOptional. Omit (recommended) to offer all your live rails — wallets, cards, and the Ethiopia corridor. If you set it, list rail ids: "waafi" (also enables Visa/Mastercard, which settle on the Waafi HPP), "cac", "sabapay", "eab", "santimpay" (Ethiopia). There is no "visa"/"mastercard" id — a list without "waafi" hides cards.
descriptionstringOrder summary shown on the payment page.
metadatamapFree-form key/value — round-trips on the webhook.
ℹ

Always send an Idempotency-Key

Same key within 24h returns the same session instead of creating a duplicate — safe to retry on a dropped connection, never a double-charge.

2 · Send the customer to the page

Redirect the browser to session.url. That's it — MerasPay handles the rail picker, the OTP / push-approval / hosted-bank step, retries, and the success screen.

Node / Expressjavascript
app.post('/checkout', async (req, res) => {
  const r = await fetch('https://api.merashub.com/v1/checkout/sessions', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + process.env.MERAS_SECRET_KEY,
      'Idempotency-Key': 'order_' + req.body.orderId,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount: 50,
      currency: 'DJF',
      success_url: 'https://yoursite.example/orders/42/done',
      cancel_url:  'https://yoursite.example/orders/42',
      description: 'Order #42',
    }),
  });
  const session = await r.json();
  res.redirect(303, session.url);   // ← off to the hosted page
});

3 · Confirm with a webhook

The redirect back to success_urlmeans "the customer finished" — not "the money settled." Treat the payment_intent.succeededwebhook as the source of truth, and verify the HMAC signature so you know it's really us.

Verify + handle the webhookjavascript
import crypto from 'crypto';

// We POST each event with these headers:
//   MerasPay-Event-Id:    evt_...
//   MerasPay-Event-Type:  payment_intent.succeeded
//   MerasPay-Signature:   t=<unix>,v1=<hex>        (HMAC-SHA256)
// The signature is computed over "{t}.{event_id}.{raw 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);                // event.data is the payment intent
  }
  res.json({ received: true });                // 2xx within 5s or we retry
});
⚠

Idempotent handlers

We retry until you return 2xx, so the same event can arrive more than once. De-dupe on the MerasPay-Event-Id header (also in event.id) before acting. Any metadata you set on the session round-trips on event.data.metadata.

4 · The return page

On success_url we append ?session_id=cs_…. Fetch the session to show the right state immediately (the webhook may still be in flight).

GET /v1/checkout/sessions/{id}bash
curl https://api.merashub.com/v1/checkout/sessions/cs_01HZ8AYJX3D8M7QK0E5V4WJ9K \
  -H "Authorization: Bearer sk_test_..."
# → { "status": "complete", "amount_minor": 50, "currency": "DJF", ... }

Test it in sandbox

NameTypeDescription
Wallet OTPCAC / SabaPayOn the sandbox OTP screen, enter 123456 to succeed. Any other code is declined.
PhoneanyUse any Djibouti number, e.g. +25377000000. No real SMS is sent in sandbox.
CardCNPUse 4111 1111 1111 1111, any future expiry, CVV 123.
WaafiHPPSandbox auto-approves the hosted Waafi page after a short delay.

Go-live checklist

  • ✓KYC approved in the merchant portal (Settings → Business).
  • ✓At least one live provider connected (Settings → Providers).
  • ✓Webhook endpoint registered + signature verification working.
  • ✓Swapped sk_test_ for sk_live_ in your server env.
  • ✓success_url / cancel_url point at production routes.
That's the whole integration