Developer docs
MaripaPay API Reference
One provider-agnostic REST API for collecting payments. Base URL https://api.maripaypay.com. Version v1.
Overview
MaripaPay sits between your system and payment providers. You call one API; MaripaPay talks to the underlying gateway (Pesapal and others). Test mode uses a built-in simulator — no gateway account required — and never moves real money.
- Order — what is owed: amount, currency, your reference.
- Payment — an attempt to collect an order through a provider.
- Checkout session / payment link — a hosted page where the payer pays.
- Webhook event — a signed notification of a payment/refund state change.
Authentication
Send your secret key as a bearer token. Keys are environment-scoped: sk_test_… can only touch test data, sk_live_… only live data. Create and rotate keys in the dashboard (Developers → API keys). Publishable keys (pk_…) are for browser contexts only.
curl https://api.maripaypay.com/api/v1/account \
-H "Authorization: Bearer sk_test_your_key"Scopes limit each key: orders:create, payments:create, refunds:create, webhooks:manage, reports:read, …
Idempotency
Every unsafe write (POST /orders, /payments, /refunds) requires an Idempotency-Key header — a unique string you generate per logical operation (8–255 chars, a UUID is ideal). Replaying the same key + body returns the stored response; the same key with a different body returns 409.
curl -X POST https://api.maripaypay.com/api/v1/payments \
-H "Authorization: Bearer sk_test_…" \
-H "Idempotency-Key: 7c1f0e2a-9c4b-4a1d-8f5e-2b6d1c3a4e5f" \
-H "Content-Type: application/json" \
-d '{ "amount": 500000, "currency": "UGX", "reference": "invoice-42" }'Orders
Create an order up front, or let POST /payments create one inline from amount + currency.
POST /api/v1/orders
{
"amount": 500000, // minor units — UGX has no subunit, so this is UGX 500,000
"currency": "UGX",
"description": "Term 1 fees",
"reference": "invoice-42", // your id — unique per merchant
"customer": { "email": "parent@example.com", "name": "A. Parent" },
"metadata": { "student_id": "stu_123", "term_id": "t1" }
}Amounts are always exact integers in minor units, and currency is always explicit. Never send a float.
Payments
Create a payment for an order (or inline). The response tells you what happened:
POST /api/v1/payments
{ "order_id": "ord_…" } // or: amount + currency
→ 201
{
"id": "pay_…",
"object": "payment",
"amount": 500000, "currency": "UGX",
"status": "completed", // created | pending | processing | completed | failed | ...
"order_id": "ord_…",
"provider": "simulator",
"checkout_url": null // set when the payer must be redirected
}Test amounts (last two digits, minor units) drive the simulator: …00 completes, …01 is declined, …02 stays pending, …03 insufficient funds, …11 a provider outage.
Poll GET /api/v1/payments/{id} or — better — rely on webhooks.
Hosted checkout
Create a session and redirect the payer to its url. MaripaPay owns the payment page; the browser is never trusted for amount or merchant identity.
POST /api/v1/checkout_sessions
{ "amount": 500000, "currency": "UGX",
"success_url": "https://yoursite.com/thanks",
"cancel_url": "https://yoursite.com/cart" }
→ { "id": "cs_…", "url": "https://pay.maripaypay.com/checkout/<token>",
"qr_url": "https://api.maripaypay.com/qr/checkout/<token>.png",
"expires_at": "…" }On completion the payer is sent to success_url; you also receive a payment.completed webhook.
Payment links & buttons
A reusable link that opens a hosted checkout. Omit amount to let the payer enter it.
POST /api/v1/payment_links
{ "currency": "UGX", "amount": 15000, "description": "Donation" }
→ { "id": "plink_…", "url": "https://pay.maripaypay.com/p/<token>",
"qr_url": "https://api.maripaypay.com/qr/link/<token>.png" }Drop-in button — the token is a public capability, not a credential:
<div data-maripaypay-link="<token>" data-maripaypay-label="Pay UGX 15,000"></div>
<script src="https://api.maripaypay.com/embed/button.js" async></script>Webhooks
Register an endpoint (dashboard → Webhooks). MaripaPay POSTs JSON events and signs each request:
MaripaPay-Signature: t=1717000000,v1=<hex hmac-sha256>
MaripaPay-Event-Id: evt_…
MaripaPay-Event-Type: payment.completedVerify by recomputing HMAC_SHA256(secret, "{t}.{raw body}"), comparing in constant time, and rejecting timestamps older than five minutes.
import crypto from "node:crypto"
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map(p => p.split("=")))
if (Math.abs(Date.now()/1000 - Number(t)) > 300) return false
const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex")
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}Events: payment.created · pending · processing · completed · failed · cancelled · expired · refunded, refund.created · completed · failed. Delivery retries with exponential backoff for ~24h; see the delivery log in the dashboard. Make your handler idempotent.
Refunds
POST /api/v1/refunds
{ "payment_id": "pay_…", "amount": 100000, "reason": "customer request" }
// omit "amount" for a full refundA refund can never exceed the captured amount (enforced in the API and the database). Partial refunds move the payment to partially_refunded; the last one to refunded.
Errors
Errors use a stable shape with a machine code and a request id for support:
{ "error": { "code": "REFUND_EXCEEDS_PAYMENT",
"message": "Refund of 200000 exceeds the 100000 still refundable on this payment.",
"request_id": "req_…" } }Common codes: INVALID_API_KEY, INSUFFICIENT_SCOPE, IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_CONFLICT, VALIDATION_ERROR, INVALID_PAYMENT_STATE, RATE_LIMITED, PROVIDER_UNAVAILABLE.
Integrating another system
MaripaPay stays generic. Your system keeps owning its own records (invoices, balances, allocations); MaripaPay owns the payment, provider reference, status, webhooks, refunds and reconciliation. Connect the two with reference and metadata:
- Your system computes what a customer owes and creates a payment:
POST /paymentswithreference: "invoice-42"andmetadata: { invoice_id, customer_id }. - Return the
checkout_url(or a payment link) to the customer. - MaripaPay verifies the provider result and sends a signed
payment.completedwebhook. - Your webhook handler verifies the signature, reads
data.object.metadata.invoice_id, marks the invoice paid and updates the balance — idempotently, keyed on the payment id. - Missed webhook? A reconciliation sweep re-delivers it, or query
GET /payments?reference=invoice-42.
A school fees system, an events platform, or a shop all integrate the same way — only the metadata differs.

