v3.8
API Reference

MyroPay API v3.8

Complete payments infrastructure for Africa and beyond - send money, collect payments, hold funds in escrow, run payroll, and build full checkout experiences through a unified REST API.

Integration APIhttps://business.myropay.com/api/ Checkout APIhttps://checkout.myropay.com/api/ Sandbox APIhttps://sandbox.myropay.com/api/
Building a third-party integration (a plugin, marketplace, or another platform accepting payments through MyroPay)? You only ever need two things: a pk_live_... / sk_live_... key pair from business.myropay.com → API & Apps, and the whsec_... webhook secret generated alongside them. That's the entire integration surface - deposits, escrow, payouts, subscriptions, and invoicing all sit behind those two keys. See Authentication below.
app.myropay.com is the personal consumer wallet app - not an integration surface. It's where an individual manages their own MyroPay balance. Nothing there (login, personal PIN) is meant to be shared with, or referenced by, a third-party integration. If an integration guide or AI agent asks you to point at app.myropay.com to power a platform integration, that's a documentation bug - use the Business API instead.
All responses are JSON. Timestamps are UTC ISO 8601. Amounts are in the major currency unit (dollars, not cents). HTTPS is always required. Endpoints below live on business.myropay.com unless otherwise labeled.
Security

Authentication

There are two credentials in MyroPay. Only one of them is for building a third-party integration - the other is for a person managing their own account.

CredentialPrefixWho it's forWhere to get it
User sessionBearer eyJ...An individual logged into the personal or business app in their own browser/app sessionLogin flow (JWT, 15 min, refreshable)
Merchant App keyspk_live_... / sk_live_...Third-party integrations - plugins, marketplaces, social-commerce platforms, anything accepting payments on behalf of a MyroPay businessbusiness.myropay.com → API & Apps
If you're integrating MyroPay into another platform, use the Merchant App keys - nothing else. One sk_live_... key now authenticates every money-movement endpoint on this site: deposits, escrow, payouts, wallet transfers, invoices, and subscriptions. You don't need a separate credential per feature.
Sending a JWT
# User session - logged-in individual only
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
Tokens expire in 15 minutes. Call POST /auth.php?action=refresh with your refresh token to get a new pair. Tokens rotate on every refresh.
Sending an API key
# Merchant App secret key - this is what a third-party integration sends
X-API-Key: sk_live_...
Both sk_live_.../sk_test_... merchant app keys go in the same X-API-Key header - never Authorization, which is reserved for JWTs. MyroPay tells them apart by prefix, so you never need to specify which kind of key you're sending.
Scopes

Each Merchant App key can optionally be restricted to a list of scopes at creation (or left unscoped for full access to everything below). Unscoped is the default so a first integration isn't blocked by a permission it forgot to request.

ScopeGrants
charges:create / charges:readCreate and read deposits, checkout sessions, and payment links
payouts:create / payouts:readInitiate withdrawals and manage saved banks / read payout history
wallet:read / wallet:transferRead balances / send a P2P wallet transfer or fulfill a payment request
escrow:manageCreate, release, dispute, and respond to escrow
invoices:manageCreate, send, and manage invoices
subscriptions:create / subscriptions:readManage recurring plans and subscriptions
transactions:readRead transaction history
webhooks:manageCreate and manage webhook endpoints for this app
Neither key type can create, rotate, reveal, or delete other API keys, or manage team members - that always requires a real signed-in session on the dashboard. This is intentional: an API key that could mint other API keys would let a leaked key escalate itself.
Reference

Errors & Rate Limiting

HTTP CodeMeaningCommon cause
200SuccessAll good
400Bad RequestMissing or malformed fields
401UnauthorizedMissing, expired or invalid token
403ForbiddenKYC required, wrong account type, wrong PIN
404Not FoundResource not found or doesn't belong to you
409ConflictDuplicate idempotency key or already exists
429Rate LimitedToo many requests - see retry_after
503UnavailableDatabase momentarily unavailable
// All errors follow this shape
{ "success": false, "message": "Insufficient balance.", "retry_after": 42 }

Idempotency. Financial endpoints accept X-Idempotency-Key header. Send a UUID - repeat calls within 24h return the original response.

Rate limits. Login: 10/5 min · Register: 5/5 min · OTP resend: 3/5 min · General: 300/min.

Client Library

Checkout SDK

One script tag. Four integration modes. No gateway keys ever touch your frontend.

Install

Add to any HTML page. The global MyroPay object is available immediately after the script loads.

<script src="https://checkout.myropay.com/myropay.js"></script>
Option 1 - Modal popup (recommended)
MyroPay.checkout({
  publicKey:       'pk_live_...',
  amount:          5000,
  currency:        'NGN',
  orderRef:        'ORD-001',
  description:     'Premium plan',
  customer:        { email: 'buyer@email.com', name: 'John Doe' },
  escrow:          true,
  escrowCondition: 'Software delivered and tested',
  onSuccess:  (data) => console.log('Paid:', data),
  onClose:    ()     => console.log('Closed'),
  onError:    (msg)  => console.error(msg),
});
Option 2 - Server-side redirect session
This call creates a real charge session and must run on your server - it uses your secret key, which should never reach a browser.
const session = await MyroPay.createSession({
  secretKey: 'sk_live_...',
  amount: 15000, currency: 'NGN',
  orderRef: 'ORD-002',
  successUrl: 'https://yoursite.com/thanks',
  cancelUrl:  'https://yoursite.com/cart',
});
res.redirect(session.checkout_url);
Option 3 - Inline embed
const { destroy } = await MyroPay.inline({
  container: '#checkout-box',
  publicKey: 'pk_live_...',
  amount: 2500, currency: 'NGN',
});
Option 4 - Pre-built button
MyroPay.createButton({
  container: '#pay-here',
  label: 'Pay with MyroPay',
  publicKey: 'pk_live_...',
  amount: 5000, currency: 'NGN',
});
Verify a payment (always server-side)
const result = await MyroPay.verify(sessionId, 'sk_live_...');
if (result.status === 'completed' && result.amount === expectedAmount) {
  // fulfil order
}
Getting Started

Server SDKs

Official libraries for creating and verifying charges from your own backend - thin wrappers around the same hosted-checkout session API the JS Checkout SDK uses. Every method below maps 1:1 onto POST /api/sessions.php?action=create and GET /api/sessions.php?session_id=... on checkout.myropay.com.

Every SDK below uses your secret key. Run this code on your server only - never in a browser, mobile app bundle, or anywhere a user could view-source it.
Amounts are your currency's own whole units - 500000 for NGN means ₦500,000, not kobo. This differs from some other African payment APIs that expect subunits - double-check if you're porting integration code.
All five SDKs are now published and installable via their package managers (npm, PyPI, RubyGems, Packagist, Maven Central) - the install commands below are live, not placeholders.
Fixed in v3.3: createSession() and verify() have been removed from the Checkout SDK - both required a secret key from browser JS, which no header fix can make safe against a secret-key-only backend. Create and verify sessions from your own backend using a Server SDK instead. checkout() / inline() / createButton() were never affected.
Install
// npm install myropay-node
Create a charge
const myropay = require('myropay-node')('YOUR_SECRET_KEY');

const charge = await myropay.charges.create({
  email: "customer@email.com",
  amount: 500000,
  currency: "NGN", // optional - defaults to NGN; USD/GBP/EUR/GHS/KES/ZAR/CAD also accepted
});

res.redirect(charge.checkoutUrl);
Install
# pip install myropay
Create a charge
import myropay
myropay.api_key = 'YOUR_SECRET_KEY'

charge = myropay.Charge.create(
    email="customer@email.com",
    amount=500000
)

print(charge.checkout_url)
Install
// composer require myropay/myropay-php
Create a charge
require_once('vendor/autoload.php');
\Myropay\Myropay::setApiKey('YOUR_SECRET_KEY');

$charge = \Myropay\Charge::create([
    'email' => 'customer@email.com',
    'amount' => 500000
]);

header('Location: ' . $charge['checkout_url']);
Install
# gem install myropay
Create a charge
require 'myropay'
Myropay.api_key = 'YOUR_SECRET_KEY'

charge = Myropay::Charge.create(
    email: 'customer@email.com',
    amount: 500000
)
Install
<!-- Maven: pom.xml -->
<dependency>
  <groupId>com.myropay</groupId>
  <artifactId>myropay-java</artifactId>
  <version>1.0.0</version>
</dependency>

// Gradle
implementation 'com.myropay:myropay-java:1.0.0'
Create a charge
import com.myropay.Myropay;
import com.myropay.model.Charge;
import java.util.HashMap;
import java.util.Map;

Myropay.apiKey = "YOUR_SECRET_KEY";

Map<String, Object> params = new HashMap<>();
params.put("email", "customer@email.com");
params.put("amount", 500000);

Charge charge = Charge.create(params);
Playground - try a real test-mode charge

This calls the real POST /sessions.php?action=create endpoint in test mode using a demo merchant account - no card details, no real money, no account needed. It's the same request the Node.js snippet above makes, just run for you.


  
Integrations

E-commerce Plugins

Drop-in payment gateway extensions for the platforms merchants already run on. Each one redirects to hosted checkout, then confirms the order two independent ways - a fast return-redirect for UX, and a webhook as the authoritative source of truth - so a closed browser tab can never strand an order as unpaid.

PlatformStatusNotes
WooCommerceAvailableStandard WC_Payment_Gateway, HPOS-compatible
OpenCartAvailableOpenCart 3.0.x-3.x, installable via Extension Installer or manual copy
BigCommercePlannedRequires app-marketplace registration/review
MagentoPlannedMost involved build of the group - full module + admin DI config
WixPlannedRequires app-marketplace registration/review
Weebly / EcwidPlanned
Subscriptions aren't wired into either plugin yet - both handle one-time checkout only, pending the recurring-billing session type documented below.
Integrations

Mobile SDKs

Thin hosted-checkout wrappers for iOS and Android - no card data, secret keys, or payment logic lives on-device. Your own backend creates the session server-side (same X-API-Key call the server SDKs make); the mobile SDK just presents the resulting checkout_url in a native in-app browser and reports when it redirects back into your app.

iOS - Swift Package (ASWebAuthenticationSession)
MyroPayCheckout.shared.present(
    checkoutURL: checkoutURL,
    callbackURLScheme: "myshop-checkout",
    presentationContext: self
) { result in /* verify session_id against your backend */ }
Android - Kotlin module (Custom Tabs)
MyroPayCheckout.redirectListener = MyroPayRedirectListener { uri ->
    // verify uri.myroPaySessionId() against your backend
}
MyroPayCheckout.present(context, checkoutUrl)
Never call sessions.php directly from a mobile app - your secret key would ship inside the app bundle. Always create the session from your own backend first.
This is a hosted-checkout wrapper, not a native card-entry SDK. A "customer never leaves your app's native UI" experience would require card tokenization at entry and pulls your app into PCI scope - a separate, larger build.
Integrations

Subscriptions & Auto-billing

Recurring billing on top of Checkout Sessions. Create a plan (price + interval), subscribe a customer to it, and every billing cycle MyroPay generates an invoice and collects it according to the subscription's billing mode. All endpoints live on checkout.myropay.com/api/subscriptions.php (sandbox.myropay.com for test keys) and take a Merchant App secret key.

How it works, end to end
  1. You create a plan once (price, currency, interval, optional trial). Plans are immutable on price so nobody is ever re-priced behind their back.
  2. You subscribe a customer (subscribe). You get the subscription and, unless a trial applies, the first invoice with a checkout_url.
  3. The customer sets it up once. invoice: pays the link each cycle. wallet_auto: pays the first invoice from their MyroPay wallet with their PIN - that single PIN entry authorises capped automatic payments. card_token: pays the first card checkout, which saves the card.
  4. MyroPay bills every cycle automatically (a job runs hourly): a wallet is debited, a saved card is charged, or a new checkout link is issued. No PIN, no login, no action from you.
  5. The customer stays in control. They see every subscription in the MyroPay app, get a receipt for each charge, are warned before a charge their balance can't cover, and can switch auto-pay off or cancel in one tap.
  6. Failures are handled for you. A declined charge is retried on a fixed schedule and you are told about every attempt by webhook; only after the last attempt does the subscription cancel.
  7. The money lands in your wallet and you take it out with Payouts.
One billing cycle on a timeline (wallet_auto, monthly)
WhenWhat happensWho is told
7, 3 and 1 day(s) beforeIf the customer's wallet is still short for the charge (in their own currency), they get a reminder with the exact shortfall. Funded wallets get nothing. Each reminder is sent once per cycle.Customer (in-app)
Billing dateInvoice created and the wallet debited. Both wallets are locked, the debit can't exceed the authorised cap, and a repeated run can never charge the same invoice twice.Customer: receipt. You: subscription_invoice.created then subscription_invoice.paid
If short or declinedSubscription becomes past_due. Retries run on day 1, day 4 and day 9 after the billing date (1, 3 and 5 days after the previous attempt) - 4 attempts in total. A top-up at any point makes the next attempt succeed and returns the subscription to active.You: payment_failed with will_retry: true and next_retry_at. Customer: notified of each failure
After the 4th failureInvoice failed, subscription cancelled. The next cycle is not billed while a retry is still pending.You: payment_failed (will_retry: false) then subscription.cancelled
Endpoints
ActionScopeWhat it does
POST create-plansubscriptions:createCreate a plan: name, amount, currency, interval (daily|weekly|monthly|yearly), optional interval_count (1-365), trial_days (0-365), metadata
POST update-plansubscriptions:createChange name, metadata or active only. Price, currency and interval are immutable - create a new plan for new terms. Setting active:false blocks new signups; existing subscribers keep renewing on their original terms.
POST subscribesubscriptions:createSubscribe a customer. Returns the subscription and, unless a trial applies, the first invoice with a checkout_url. A customer can hold only one open subscription per plan (409) unless you send allow_duplicate: true.
POST cancelsubscriptions:createat_period_end: true keeps access until the paid period ends; otherwise cancels now, voids open invoices and revokes any saved card. Cancelling twice is safe (idempotent).
POST resumesubscriptions:createUndo a scheduled cancel-at-period-end. A subscription already cancelled cannot be resumed.
GET list-plans / retrieve-plansubscriptions:readPlans for this key's mode
GET listsubscriptions:readFilter with status, plan_id, customer_email; page with limit (max 100) / offset
GET retrieve / list-invoicessubscriptions:readOne subscription (by subscription_id) or its invoices. The subscription carries status, next_billing_at, trial_end, cancelled_at, wallet_authorized / autopay_revoked_at (wallet_auto) and has_saved_card (card_token); invoices add attempt_count, next_retry_at and last_error
Create a plan
POST /api/subscriptions.php?action=create-plan
{
  "name": "Pro Monthly",
  "amount": 15000,
  "currency": "NGN",
  "interval": "monthly",
  "trial_days": 7
}
Subscribe a customer
POST /api/subscriptions.php?action=subscribe
{
  "plan_id": "plan_live_...",
  "customer": { "email": "customer@email.com", "name": "Ada Obi" },
  "billing_mode": "invoice",
  "success_url": "https://yourshop.com/thanks",
  "cancel_url": "https://yourshop.com/pricing"
}
Billing modes
Billing modeHow each cycle is collectedCustomer action needed
invoice (default)A new hosted checkout session per cycle; works with every payment method (card, bank transfer, USSD, wallet). Open for 3 days.Pays every cycle
wallet_autoSilent debit from the customer's own MyroPay wallet, capped at the agreed plan price per cycle. Converted at the live FX rate if the wallet currency differs from the plan currency.Once: pay the first invoice from their wallet with their PIN. That payment is the authorisation - no PIN is asked again.
card_tokenSilent charge of a saved card (Flutterwave tokenization - MyroPay never sees card numbers).Once: pay the first (card-only) checkout. Requires consent.accepted: true on subscribe.
Subscription lifecycle
statusMeaningMoves to
trialingIn the free trial. Nothing is billed until trial_end (= current_period_end).active (first invoice issued at trial end)
activePaid up; renews at next_billing_at.past_due, cancelled
past_dueThe latest invoice is unpaid. Silent modes are retrying; invoice mode is waiting for the customer.active (paid), cancelled (retries exhausted)
cancelledTerminal. No more invoices or charges. A late payment is recorded but never reactivates it.-
Failed payments (dunning policy)
ModePolicy
wallet_auto, card_token (silent)4 attempts per invoice: on the billing date, then 1 day, 3 days and 5 days after the previous attempt. While retrying the subscription is past_due and each decline sends subscription_invoice.payment_failed with will_retry: true and the next_retry_at. If the 4th attempt fails the invoice is failed, the subscription is cancelled and you receive payment_failed (will_retry: false) followed by subscription.cancelled. A success at any attempt returns the subscription to active. The next cycle is not billed while a retry is pending.
invoiceThe checkout link stays open 3 days. If unpaid it expires and the invoice fails (reason: expired_unpaid); the subscription is past_due, and 3 consecutive unpaid invoices cancel it. An explicit card decline fails the invoice immediately.

reason values: insufficient_funds, exceeds_mandate, wallet_unavailable, fx_unavailable, card_unavailable, card_declined: ..., declined, expired_unpaid, error. Wallet owners are notified in-app on every failed attempt.

Auto-billing from a wallet (wallet_auto)
POST /api/subscriptions.php?action=subscribe
{
  "plan_id": "plan_live_...",
  "customer": { "email": "customer@email.com" },
  "billing_mode": "wallet_auto",
  "success_url": "https://yourshop.com/thanks"
}
  1. The response has authorization_required: true and an invoice.checkout_url for a wallet-only checkout. Send the customer there.
  2. They sign in to MyroPay and pay with their PIN. The account that pays becomes the debit source and you receive subscription.wallet_authorized and subscription_invoice.paid. You never choose whose wallet is debited - a wallet_user_id in the request is ignored.
  3. Every later cycle is debited automatically with no PIN or login. The authorisation is a mandate capped at the plan price you were shown (mandate_amount): MyroPay refuses any invoice above it or in another currency, and a plan's price can never be edited, so a charge can never exceed what the customer agreed to. The customer sees the subscription in their MyroPay app under Subscriptions, gets a receipt for every charge, is reminded 7, 3 and 1 day(s) before it is due if their balance is still short, and can turn auto-pay off or cancel at any time.
  4. Until authorisation completes, wallet_authorized on the subscription is false and nothing is debited. Trials work too: the authorisation checkout is issued at trial end.
Auto-charging a saved card (card_token)
POST /api/subscriptions.php?action=subscribe
{
  "plan_id": "plan_live_...",
  "customer": { "email": "customer@email.com", "name": "Ada Obi" },
  "billing_mode": "card_token",
  "consent": { "accepted": true },
  "success_url": "https://yourshop.com/thanks"
}
  1. The response contains an invoice with a checkout_url. Send the customer there - it is card-only, because only a card can be saved.
  2. When they pay you receive payment.success, subscription_card.attached (with card_brand and card_last4) and subscription_invoice.paid.
  3. Every following cycle is charged server-side on the retry policy above. Cancelling immediately revokes the saved card; at_period_end: true stops the next charge.
You are responsible for showing your customer clear terms about future automatic charges before setting consent.accepted - MyroPay records the timestamp, IP and user agent of the request. Customers who pay a card checkout by another method are not silently charged later.
What your customer sees and controls

Everything below is built into the MyroPay app (Subscriptions) - you don't build or host any of it.

Customer canDetail
See all their subscriptionsMerchant, plan, price and interval, status, next payment date, trial end, whether it is paid from the wallet / a saved card / by link, how much is due in the next 30 days, and a warning when the balance won't cover an upcoming charge. A subscription shows up when the customer's wallet pays it or when it was created for their verified account email.
Read their payment historyEvery invoice with its date, amount and status, including retries.
Turn off auto-payRevokes the wallet mandate immediately. The subscription continues as invoice billing - they get a payment link each cycle and pay it themselves. You receive subscription.autopay_stopped.
CancelEnds the subscription at the end of the period already paid (no further charges). You receive subscription.cancel_scheduled straight away, then subscription.cancelled with reason: cancel_at_period_end when the paid period ends.

Turning auto-pay off and cancelling need only a signed-in session, not the PIN, because they can only stop spending - starting or raising a payment always requires the PIN. A customer can never be charged more than the plan price they authorised, and you can never choose whose wallet is debited.

Getting paid

Each paid invoice credits your MyroPay wallet (converted to your wallet currency when the plan currency differs). Withdraw it with Payouts.

Sandbox

Subscriptions made with a sk_test_ key (sub_test_...) run the full lifecycle and fire every webhook but never move real money: silent charges succeed instantly without touching a wallet or card.

Errors
HTTPWhen
400Missing/invalid field (unknown billing_mode or currency, bad email, consent.accepted missing for card_token, attempt to change an immutable plan field)
404Plan or subscription not found, plan inactive, or the object belongs to the other mode (test vs live)
409Customer already has an open subscription to this plan; resume on a cancelled / non-scheduled subscription
429Rate limited (10 subscribe calls per customer per hour)
Scopes required on merchant apps: subscriptions:create (all writes), subscriptions:read (all GETs).
Core API

Auth Endpoints

POST/auth.php?action=registerPublic

Create a personal or business account. Returns access + refresh tokens immediately.

POST/auth.php?action=loginPublic

Authenticate with email + password. Include totp_code if 2FA enabled.

POST/auth.php?action=refreshPublic

Exchange refresh token for new access + refresh pair. Tokens rotate on every use.

GET/auth.php?action=meJWT

Returns authenticated user profile: balance, KYC status, pin_set, referral code.

POST/auth.php?action=verify-email-otpJWT

Verify email with 6-digit OTP. Body: { "otp": "123456" }

POST/auth.php?action=forgot-passwordPublic

Trigger password reset email. Always returns 200 to prevent enumeration.

POST/auth.php?action=reset-passwordPublic

Complete password reset. Body: { "token": "...", "password": "..." }

POST/auth.php?action=update-myrotagJWT

Change @myrotag. Max 3 changes per year. Body: { "myrotag": "newhandle" }

POST/auth.php?action=setup-2faJWT

Generate TOTP secret and QR code URI for authenticator app setup.

POST/auth.php?action=confirm-2faJWT

Activate 2FA. Returns 10 recovery codes - store immediately.

POST/auth.php?action=disable-2faJWT

Disable 2FA with current TOTP code. Body: { "code": "123456" }

POST/auth.php?action=logoutJWT

Revoke refresh token. Body: { "refresh_token": "..." }

Core API

Wallet & Transfers

GET/wallet.php?action=balanceJWT / Key

Returns balance, locked funds, currency, monthly in/out totals. Scope: wallet:read.

POST/wallet.php?action=transferJWT / Key

Send money to @myrotag, email, or phone. Requires PIN. Fee: 0.1%. Supports X-Idempotency-Key. Scope: wallet:transfer.

GET/wallet.php?action=lookup-user&q=@handleJWT / Key

Find user by @myrotag, email, or phone. Scope: wallet:read.

POST/wallet.php?action=requestJWT / Key

Request payment from another user. Scope: wallet:read.

POST/wallet.php?action=fulfill-requestJWT / Key

Pay a received payment request. Body: { "request_id": 14, "pin": "1234" }. Scope: wallet:transfer.

GET/wallet.php?action=historyJWT / Key

Paginated transaction history. Params: page, limit, type, q, from, to. Scope: wallet:read.

Core API

Deposits

Use action=init for all deposits. Gateway is selected automatically: Flutterwave hosted for all countries, or MyroPay Checkout as a fallback. Paystack was removed from the deposit path - third-party integrators should not pass method=paystack.
POST/deposit.php?action=initJWT / Key

Smart gateway init. Pass method to force: auto | flutterwave | checkout. Scope: charges:create.

POST/deposit.php?action=charge-card-flwJWT / Key

Flutterwave direct card charge - preserves your own card form UI. Returns auth mode: pin, otp, or redirect. Scope: charges:create.

POST/deposit.php?action=validate-charge-flwJWT / Key

Validate PIN or OTP for a pending FLW charge. Body: { "flw_ref": "...", "otp": "..." }. Scope: charges:create.

POST/deposit.php?action=verify-flutterwaveJWT / Key

Verify FLW payment after redirect. Body: { "tx_ref": "...", "transaction_id": "..." }. Scope: charges:create.

POST/deposit.php?action=flutterwave-webhookSig-verified

Server-to-server FLW event. Validated via VERIF-HASH header.

Core API

Payouts

A payout (withdrawal) moves money out of a MyroPay wallet to a bank account, a PayPal account or a mobile-money wallet. The wallet is debited immediately (amount + fee), the money is sent through the payment gateway, and the withdrawal then settles asynchronously: it either completes, or fails and the full amount is refunded automatically. Integrations use https://business.myropay.com/api/payout.php with a Merchant App secret key; the MyroPay apps call the same actions with a signed-in session.

How a withdrawal works
  1. Save the destination once with add-bank (up to 5 per account; numbers are encrypted at rest and only the last 4 digits are ever returned). Bank withdrawals can only go to a saved account - free-text account numbers are rejected.
  2. Call initiate. MyroPay checks authorisation, KYC, the daily limit and the balance, then in one atomic step debits amount + fee, creates the withdrawal as pending and records the wallet transaction. Two parallel requests can never spend the same balance.
  3. The payout is dispatched to the gateway for the chosen method (see Methods) and the withdrawal becomes processing.
  4. It settles. The gateway confirms in the background and MyroPay marks the withdrawal completed and sends payout.completed - or marks it failed, returns amount + fee to the wallet and sends payout.failed. You never have to poll, but history always shows the current status.
Endpoints
GET/payout.php?action=list-banks&country=NGJWT / Key

Supported banks and their bank_code, resolved through Flutterwave for every country. Scope: payouts:read.

GET/payout.php?action=resolve-accountJWT / Key

Look up the account holder name before saving. Params: account_number, bank_code. Nigeria only. Scope: payouts:read.

POST/payout.php?action=add-bankJWT / Key

Save a destination. type is bank (default), paypal (needs paypal_email) or wire (bank_name, account_number, swift of 8-11 characters). Bank accounts also need bank_name, account_number, account_name and, by corridor: sort_code for GBP, routing_number (+ account_type) for US USD, iban for EUR, otherwise bank_code. Optional nickname, currency, country_code. Max 5 saved accounts. Scope: payouts:create.

GET/payout.php?action=my-banksJWT / Key

List saved accounts. Account numbers are masked to the last 4 digits. Scope: payouts:read.

POST/payout.php?action=delete-bankJWT / Key

Remove a saved account (id). Withdrawals already in flight are unaffected. Scope: payouts:create.

POST/payout.php?action=initiateJWT / Key

Start a withdrawal. Body: method (bank | paypal | mobile_money), amount, details, and pin when signed in with a session. Send an X-Idempotency-Key header. Scope: payouts:create.

GET/payout.php?action=historyJWT / Key

The 50 most recent withdrawals, newest first: id, uuid, method, amount, fee, currency, status, created_at, processed_at. Scope: payouts:read.

Initiate a withdrawal
POST https://business.myropay.com/api/payout.php?action=initiate
X-API-Key: sk_live_...
X-Idempotency-Key: wd-2026-10-28-0001

{ "method": "bank", "amount": 50000, "details": { "bank_id": 12 } }

// PayPal:        { "method": "paypal", "amount": 100, "details": { "paypal_email": "me@example.com" } }
// Mobile money:  { "method": "mobile_money", "amount": 20000, "details": { "phone": "+2348012345678", "network": "MTN" } }

// 200
{ "success": true, "message": "Withdrawal initiated. Processing within 24h.",
  "transaction_id": "6f1c0c52-...", "new_balance": 149500.0 }

transaction_id is the withdrawal's uuid in history. Re-sending the same X-Idempotency-Key (for example after a dropped connection) returns the original result instead of creating a second withdrawal - always send one.

Authorisation, fees and limits
TopicRule
Who can withdrawOnly a KYC-verified account (403 otherwise).
AuthorisationA signed-in session must send the transaction pin. A Merchant App sk_live_ key does not - the key itself (scoped, IP allow-listed, rate limited) is the authorisation, so never store a PIN on your server. Needs the payouts:create scope.
Test keyssk_test_ keys cannot change live wallet data: any non-GET call returns 403. There is no sandbox payout - payouts always move real money, so test the money flows in the sandbox (checkout sessions, subscriptions, escrow) and call payouts with sk_live_.
FeeThe greater of 1.00 (in the wallet's currency) and 1% of the amount, charged on top: a 50,000 withdrawal debits 50,500. If the balance can't cover amount + fee the request fails with the exact figure.
Minimum1.00 in the wallet's currency.
Daily limitSet by the account's KYC level, measured in USD equivalent across all outgoing money that day (withdrawals and bill payments). If the exchange rate is unavailable the request is refused rather than guessed.
Rate limit20 initiate requests per minute per account (429 beyond that), on top of the Merchant App's overall per-minute limit.
Methods
methoddetailsHow it is sent
bankbank_id of a saved accountFlutterwave transfer, any supported country. The payout is made in the saved account's currency; if that differs from the wallet currency the same value is converted at the live FX rate (a NGN wallet paying a USD account never sends "50,000 USD"). If no rate is available the withdrawal is held for manual reconciliation instead of guessing. Completion arrives from Flutterwave.
paypalpaypal_emailPayPal Payouts, converted to the currency PayPal supports. PayPal confirms asynchronously: a successful item completes the withdrawal; failed, denied, returned, cancelled, blocked or refunded items fail it and refund the wallet; held or unclaimed items leave it processing and notify the owner, who may need to confirm or link a PayPal account. If Payouts isn't enabled on the account the withdrawal joins the manual queue below.
mobile_moneyphone, networkProcessed manually by MyroPay finance (within 24h). It stays processing until finance marks it completed or failed.

wire accounts can be saved, but international wire withdrawals are not available yet - initiate with method: "wire" returns 400 before any money is reserved.

Withdrawal status
statusMeaningNext
pendingCreated and funded; being handed to the gateway. Normally lasts moments.processing, failed
processingAccepted by the gateway (or queued for finance). The money has left the wallet and is on its way - not yet delivered.completed, failed
completedDelivered. Terminal. payout.completed was sent.-
failedRejected or returned. Amount + fee were refunded to the wallet (a refund transaction is recorded). Terminal. payout.failed was sent.-
When the outcome is unknown, MyroPay does not refund. If the gateway times out, or no exchange rate is available, the transfer may or may not have been created, so the withdrawal stays processing instead. MyroPay records the transfer reference before calling the gateway and re-checks open bank payouts with Flutterwave every 10 minutes: a confirmed success completes it, a confirmed failure refunds it, and anything still unclear is flagged to MyroPay finance rather than guessed. Refunds happen exactly once - a withdrawal can never be refunded twice, even if a webhook is delivered repeatedly or the check and the webhook arrive together.
Payout webhooks

Delivered in the standard signed envelope ({ "event", "data", "timestamp" }, see Webhooks). Each fires once, when the withdrawal really settles - not when it is queued.

EventdataWhen
payout.completedtransaction_id, amount, fee, currency, method, statusDelivery confirmed: by the Flutterwave webhook, by MyroPay's own periodic check with Flutterwave, by a PayPal item, or by finance for a manual payout.
payout.failedthe same fields, plus reasonThe payout failed (rejected by the gateway or the bank, returned by PayPal, never sent, or marked failed by finance); the full amount + fee is already back in the wallet when this arrives.

transaction_id is the withdrawal's uuid - the same id initiate returned as transaction_id and history lists as uuid - so match on it directly. Webhooks are delivered at least once: treat a repeated transaction_id + event as a duplicate.

Subscription revenue and payouts

Every paid subscription invoice - whether collected from a customer's wallet, a saved card or a checkout link - is credited to your MyroPay wallet (converted to your wallet currency if the plan currency differs) and appears as a transaction referencing the invoice id. Payouts are how you take that balance out; there is nothing subscription-specific to configure. Refunds of failed payouts and subscription credits are independent: a failed withdrawal only ever returns money to the wallet it left.

Errors
HTTPWhen
400Missing method/amount/details; unsupported method; wire withdrawal; no saved bank selected; saved account not found; insufficient balance (message includes the fee); daily limit exceeded; exchange rate unavailable for the limit check.
401Incorrect transaction PIN (sessions), or missing/invalid key.
403KYC not verified; key missing payouts:create; sk_test_ key used for a write; request IP not on the app's allow-list.
429Rate limit exceeded.
Core API

Transactions

GET/transactions.php?action=listJWT / Key

Paginated transaction list. Params: page, limit, type, q, from, to. Scope: transactions:read.

GET/transactions.php?action=detail&uuid={uuid}JWT / Key

Single transaction with full counterpart details. Scope: transactions:read.

GET/transactions.php?action=exportJWT / Key

Export up to 5,000 transactions. Params: from, to. Scope: transactions:read.

GET/transactions.php?action=summaryJWT / Key

Lifetime and monthly totals by type for dashboard stats. Scope: transactions:read.

Core API

Notifications

GET/notifications.php?action=listJWT

Paginated notifications with unread count. Param: page.

GET/notifications.php?action=unread-countJWT

Returns { "count": 4 } - fast endpoint for polling badge counts.

POST/notifications.php?action=mark-all-readJWT

Mark all notifications as read.

POST/notifications.php?action=mark-readJWT

Mark a single notification as read. Body: { "id": 42 }

Features

Escrow

Lock funds until delivery conditions are met. Either party can raise a dispute. MyroPay resolves within 48 hours. Fee: 1.5%.

Initiator creates + funds
→
Funds locked
→
Work delivered
→
Release or dispute
POST/escrow.php?action=createJWT / Key

Create and fund escrow. Amount + 1.5% fee debited and locked immediately. Requires PIN. Scope: escrow:manage.

POST/escrow.php?action=releaseJWT / Key

Release funds to beneficiary. Initiator only. Body: { "uuid": "...", "pin": "..." }. Scope: escrow:manage.

POST/escrow.php?action=disputeJWT / Key

Raise a dispute. Either party. Body: { "uuid": "...", "reason": "...", "evidence": "https://..." }. Scope: escrow:manage.

POST/escrow.php?action=dispute-respondJWT / Key

Submit response to open dispute. Body: { "dispute_uuid": "...", "response": "..." }. Scope: escrow:manage.

GET/escrow.php?action=listJWT / Key

All escrows where you are initiator or beneficiary.

GET/escrow.php?action=detail&uuid={uuid}JWT / Key

Full escrow details with counterpart info.

Compliance

KYC Verification

LevelLabelDaily limitRequirement
0Starter$200Email verified
1Standard$5,000Government ID + selfie
2Verified$50,000Address proof + BVN (NG)
GET/kyc.php?action=statusJWT

Current KYC level, document status per tier, daily limits.

POST/kyc.php?action=uploadJWT

Multipart form-data. Fields: document (file), doc_type, tier. Max 5MB. JPEG/PNG/PDF.

POST/kyc.php?action=submit-bvnJWT

Nigerian BVN verification. Hashed SHA-256 immediately - never stored plain.

Business

Invoices Business accounts

POST/business.php?section=invoices&action=createJWT / Key

Create invoice with line items and tax. Returns invoice number and payment link. Scope: invoices:manage.

POST/business.php?section=invoices&action=sendJWT / Key

Email invoice PDF to client. Changes status draft → sent. Scope: invoices:manage.

GET/business.php?section=invoices&action=listJWT / Key

All invoices. Filter by status: draft | sent | paid | overdue. Scope: invoices:manage.

GET/business.php?section=invoices&action=detail&uuid={uuid}JWT / Key

Full invoice with parsed line items array. Scope: invoices:manage.

Business

Payroll

POST/business.php?section=payroll&action=createJWT

Bulk transfer to multiple MyroPay users in one request. Recipients credited instantly. Requires PIN. API keys cannot call this section at all, regardless of scope - same self-escalation reasoning as API Keys/Team management.

GET/business.php?section=payroll&action=listJWT

Payroll batches with totals, recipient count, and completion status.

Business

Analytics

GET/analytics.php?action=summary&days=30JWT

KPI summary: revenue, spending, net, transaction count, invoice stats, payroll totals.

GET/analytics.php?action=volume-chart&days=30JWT

Daily inflow/outflow series for charting.

GET/analytics.php?action=top-sendersJWT

Top 10 users by amount sent to your account in last 90 days.

GET/analytics.php?action=transaction-types&days=30JWT

Breakdown by transaction type for pie/donut charts.

Platform

Checkout API

Checkout API Basehttps://checkout.myropay.com/api/
Sandbox API Base (sk_test_ / pk_test_ keys)https://sandbox.myropay.com/api/
POST/sessions.phpSecret Key

Create a checkout session. Body: { "amount", "currency", "order_ref"?, "description"?, "customer"?: { "email", "name" }, "metadata"?: {}, "payment_methods"?: [], "success_url"?, "cancel_url"?, "expires_in"? }. amount and currency (NGN/USD/GBP/EUR/GHS/KES/ZAR/CAD) are required. order_ref is your own reference: it is stored on the session and returned in the payment.success / payment.failed webhook. metadata is stored and echoed back in the same webhooks. expires_in is in minutes (default 1440). Returns session_id (the id to correlate webhooks with), checkout_url, amount, currency, status, expires_at.

GET/sessions.php?session_id={id}Secret Key

Verify a session. Always call server-side before fulfilling orders. Check status === "completed" and amount - this always reports the session's own currency, never a converted one (see below).

POST/sessions.php?action=pay-walletJWT

Pay a session with MyroPay wallet balance. Body: { "session_id": "...", "pin": "..." }

POST/sessions.php?action=pay-cardPublic

Pay a session via Flutterwave direct card charge. Returns auth mode for 3DS/OTP challenge. On success, includes a charged field ("NGN 41,850.00") when the session currency required conversion - see below.

POST/sessions.php?action=validate-cardPublic

Submit OTP/PIN for pending card payment. Body: { "flw_ref": "...", "otp": "...", "session_id": "..." }

Never put your secret key (sk_live_) in frontend JavaScript. Only your public key (pk_live_) is safe for the browser.
Multi-currency checkout

A session can be created in any of NGN/USD/GBP/EUR/GHS/KES/ZAR/CAD. You are always paid the full face value of what the session was created for - the conversion (when one happens) only affects what the buyer's card or wallet was actually charged, never what you receive or what GET /sessions.php reports.

MethodHow currency is handled
WalletBuyer's wallet is debited in their own wallet currency, converted from the session currency at MyroPay's admin-set rate.
CardNon-NGN sessions are actually charged to the card in NGN (converted at MyroPay's admin-set rate) - the buyer's statement shows an NGN transaction, disclosed on the checkout page before they enter card details. MyroPay absorbs the conversion; it's never passed to you as a fee.
Bank transferNGN sessions only - a real NUBAN account can't exist in another currency. Not offered at all for other currencies; card/wallet still work normally.
Building a receipt or confirmation page? Use amount/currency from GET /sessions.php for what the customer owes you - use the charged field from the pay-card response only if you want to also show what actually hit their card.
Platform

Testing & Sandbox

A checkout session created with an sk_test_/pk_test_ key (a merchant app with mode: "test" - see API Keys) never touches a real balance. Wallet pay, card charge, and escrow fund/release/dispute/auto-release all behave exactly as they do in live mode - same statuses, same webhooks, same guest escrow portal - the only difference is that users.balance and users.locked_balance are never written to. Every test-mode session and escrow is stamped with is_test: true in its API response, and the checkout page / escrow portal both show a persistent test-mode banner so it's never ambiguous which one you're looking at.

Card payments in test mode

Two ways this works, and it's automatic - nothing in your integration code changes between them:

If Flutterwave test keys are configuredIf not
Test-mode card charges hit Flutterwave's own sandbox - real OTP/3DS challenges, real declines, using Flutterwave's published test card numbers. This exercises your integration exactly the way a live charge would. A small built-in simulator stands in instead - no network call to Flutterwave at all. Covers the two outcomes you need to test integration success/failure handling; it does not simulate OTP/PIN/3DS.

The built-in simulator only works with pay-card (full card-detail entry - required for escrow anyway). pay-card-init's inline widget is Flutterwave's own hosted iframe, so it always requires real Flutterwave test keys, in test mode or live.

Card numberResult
4000000000000000Approved - session completes normally
4000000000000002Declined - insufficient funds
4000000000000010Declined - generic decline
4000000000000226Requires OTP - enter 123456 to approve, anything else declines
4000000000000341Requires card PIN - enter 1234 to approve, anything else declines
4000000000003220Requires 3DS - redirects to a simulated bank challenge page (Approve/Decline)
The OTP/PIN/3DS cards exercise the exact same code path a real Flutterwave challenge does - meta.authorization.mode, flw_ref, and (for 3DS) a real redirect-and-return-to-/callback round trip. Good for testing that your own webhook handler and success/failure pages hold up when a payment resolves asynchronously, not just on the happy path. Any other card number is rejected outright with a clear error - never treated as a real card, so there's no way to accidentally send live PAN data through a test integration.
Wallet payments in test mode

pay-wallet works in test mode too, with a real logged-in account and a real PIN - the debit/credit is just never actually applied to either party's balance. Useful for exercising the full checkout-to-webhook flow without needing test card numbers.

Escrow in test mode

Fully functional, including the buyer-facing portal (sandbox.myropay.com/escrow.html): fund, view, release, dispute, and auto-release all work identically to live mode. A disputed test escrow behaves exactly like a real one - release is blocked until resolved - good for testing your own dispute-handling logic before it matters.

Configuring real Flutterwave test keys

This is platform-level configuration (shared/config/config.php), set by whoever operates this MyroPay deployment - not something an individual merchant integration configures. From your Flutterwave dashboard: Settings → API → the "Test" tab (every Flutterwave account has both live and test key pairs). Set FLW_SECRET_KEY_TEST and FLW_ENCRYPTION_KEY_TEST (optionally FLW_PUBLIC_KEY_TEST) alongside the existing live-mode FLW_* variables. The simulator is a fallback for when these aren't set, not the recommended long-term setup for testing card payments seriously.

Platform

API Keys

A Merchant App is one pk_live_.../sk_live_... key pair. Create it once and it authenticates deposits, escrow, payouts, wallet, transactions, invoices, subscriptions, and checkout - this is the single integration surface for a third-party platform.

Pass webhook_url (and optionally webhook_events) when you create the app, and its whsec_... webhook secret is generated and returned in the same response as the key pair - a webhook is a property of the key that owns it, not a separate freestanding thing you set up later.
POST/business.php?section=apps&action=createJWT only

Body: { "app_name", "mode": "live"|"test", "scopes"?, "allowed_ips"?, "rate_limit_per_min"?, "webhook_url"?, "webhook_events"? }. Returns public_key, secret_key, webhook_id, webhook_secret - store the secret key and webhook secret immediately, neither is shown again. mode: "live" requires KYB verification; test keys are unrestricted.

GET/business.php?section=apps&action=listJWT only

Every app on the account with scopes, IP allow-list, and rate limit - secret keys are never included.

POST/business.php?section=apps&action=update-configJWT only

Update scopes / allowed_ips / rate_limit_per_min in one call. Requires PIN.

POST/business.php?section=apps&action=rotate-secretJWT only

Issues a new secret key immediately; the old one keeps working for grace_hours (default 24, max 72, set 0 to kill it instantly). Requires PIN.

POST/business.php?section=apps&action=revokeJWT only

Permanently destroys the key pair - cannot be undone. Requires PIN.

Every action on this page is JWT only - an API key can never create, rotate, reveal, or revoke another API key, even its own. That must always happen from a signed-in dashboard session, so a leaked key can't be used to mint itself broader access.
Platform

Webhook Management

Manage the webhook(s) attached to a specific app. See Webhook Events for the event catalogue and signature verification.

POST/business.php?section=webhooks&action=createJWT / Key

Body: { "app_id", "url", "events": [...] }. app_id is required - a webhook must belong to a specific app. Returns the whsec_... secret once. Scope: webhooks:manage.

GET/business.php?section=webhooks&action=list&app_id=JWT / Key

List webhooks; optionally filter to one app. Scope: webhooks:manage.

POST/business.php?section=webhooks&action=testJWT / Key

Sends a signed test.ping event to the endpoint and returns the HTTP status it responded with. Scope: webhooks:manage.

DELETE/business.php?section=webhooks&action=deleteJWT / Key

Removes a webhook endpoint. Scope: webhooks:manage.

Platform

Settings

POST/settings.php?action=update-profileJWT

Update name and phone. Body: { "first_name", "last_name", "phone" }

POST/settings.php?action=change-passwordJWT

Change password. Revokes all other sessions. Body: { "current_password", "new_password" }

POST/settings.php?action=set-pinJWT

Set or change 4-digit transaction PIN. Body: { "pin", "confirm_pin", "current_pin?" }

POST/settings.php?action=setJWT

Toggle preference. Body: { "key": "push_notifications", "value": 1 }

GET/settings.php?action=sessionsJWT

Active login sessions with device, IP, last used time.

POST/settings.php?action=revoke-all-sessionsJWT

Log out all other sessions. No body required.

Platform

Webhook Events

Every webhook secret starts with whsec_ and belongs to exactly one app - you get it once, in the same response as that app's pk_/sk_ pair (see API Keys). Store it as MYROPAY_WEBHOOK_SECRET next to MYROPAY_SECRET_KEY - see Environment Variables below for the full naming reference.

Verify a delivery
// PHP - always verify before processing
$rawBody = file_get_contents('php://input');
$sig     = $_SERVER['HTTP_X_MYROPAY_SIGNATURE'] ?? '';
// $_ENV['MYROPAY_WEBHOOK_SECRET'] - the whsec_... value returned when
// you created this app (or its webhook) under API Keys. Not the sk_/pk_
// key itself - a distinct secret, one per webhook endpoint.
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $_ENV['MYROPAY_WEBHOOK_SECRET']);
if (!hash_equals($expected, $sig)) {
    http_response_code(401); exit;
}
http_response_code(200); // ack fast, process async
$event = json_decode($rawBody, true);
EventTrigger
payment.successCheckout session paid and completed
payment.failedPayment attempt abandoned or declined
deposit.confirmedWallet deposit credited (any gateway)
transfer.receivedP2P transfer credited to wallet
payout.completedWithdrawal delivered - fires once, when it settles (not when it is queued). See Payouts
payout.failedWithdrawal failed - amount + fee already returned to the wallet
escrow.fundedEscrow created and funds locked
escrow.releasedInitiator releases to beneficiary
escrow.disputedEither party raises a dispute
escrow.resolvedAdmin resolves dispute
kyc.approvedKYC document approved
kyc.rejectedKYC document rejected
invoice.paidInvoice paid via checkout link
invoice.overdueInvoice past due date
payroll.completedPayroll batch fully processed
subscription.createdA subscription is created
subscription.cancelledCancelled immediately, at period end, or because payment retries were exhausted (reason: cancel_at_period_end | payment_failed | customer_cancelled)
subscription.resumedA scheduled cancel-at-period-end was undone
subscription.cancel_scheduledThe customer chose to cancel from their MyroPay app; it ends at ends_at (data: subscription_id, cancelled_by, ends_at)
subscription.autopay_stoppedThe customer turned off automatic wallet payment. The subscription is now billing_mode: invoice - expect a hosted checkout link per cycle instead of a silent debit
subscription.wallet_authorizedA customer authorised automatic wallet debits for a wallet_auto subscription (data: subscription_id, authorized_at)
subscription_invoice.createdA subscription billing cycle's invoice is generated
subscription_invoice.paidSubscription invoice paid (any billing mode)
subscription_card.attachedA card was saved for a card_token subscription (data: subscription_id, card_brand, card_last4)
subscription_invoice.payment_failedA charge attempt was declined or an invoice expired unpaid. Check will_retry / next_retry_at - this event fires on every failed attempt, not only the last
Payload shape

Every delivery is an HTTP POST with a JSON body wrapped in the same three-key envelope - it is not a flat object. Parse event first, then read the event's fields from data.

KeyTypeDescription
eventstringThe event name from the table above, e.g. payment.success
dataobjectThe event's payload - fields documented per event below
timestampintegerUnix time (seconds) when the delivery was created

Each request also carries Content-Type: application/json, User-Agent: MyroPay-Webhook/1.0 and X-MyroPay-Signature: sha256=<hex> (HMAC-SHA256 of the raw body, keyed with your whsec_ secret - compute it over the raw bytes, before any JSON parsing).

payment.success / payment.failed
data fieldTypeDescription
session_idstringThe session_id returned by POST /sessions.php?action=create (format cs_live_... / cs_test_...). This is the key to correlate a delivery back to the session you created.
order_refstring | nullThe order_ref you passed at creation, echoed back unchanged (null if you sent none). Use it for your own order id - it is not required to be unique.
statusstring"success" or "failed" - always matches the event name, so you can rely on either
amountnumberThe session's face amount in currency (major units, e.g. 15000.00 NGN) - what you asked to be paid, never a converted figure
currencystringThe session's currency
gatewaystringHow it was paid: flutterwave (card, bank transfer, USSD, mobile money), wallet or paypal
customer_emailstring | nullEmail on the session, if known
escrowbooleantrue if this payment is being held in escrow rather than settled
is_testbooleantrue for sessions created with a test key
metadataobjectThe metadata object you passed at creation, returned as-is ({} if none)
paid_atstringpayment.success only - ISO 8601 timestamp
paid_by_emailstringpayment.success via wallet only - the paying MyroPay account's email
gateway_statusstringpayment.failed only - the gateway's own failure status/reason
subscription_id, subscription_invoice_idstringPresent only when the session was created by a subscription billing cycle
{
  "event": "payment.success",
  "data": {
    "session_id": "cs_live_9f2c4e7a1b3d5f60718293a4b5c6d7e8f9a0b1c2",
    "order_ref": "ORD-9876",
    "status": "success",
    "amount": 15000.0,
    "currency": "NGN",
    "gateway": "flutterwave",
    "customer_email": "customer@example.com",
    "escrow": false,
    "is_test": false,
    "metadata": { "cart_id": "c_123" },
    "paid_at": "2026-09-28T10:15:32+01:00"
  },
  "timestamp": 1790586932
}
{
  "event": "payment.failed",
  "data": {
    "session_id": "cs_live_9f2c4e7a1b3d5f60718293a4b5c6d7e8f9a0b1c2",
    "order_ref": "ORD-9876",
    "status": "failed",
    "amount": 15000.0,
    "currency": "NGN",
    "gateway": "flutterwave",
    "customer_email": "customer@example.com",
    "escrow": false,
    "is_test": false,
    "metadata": { "cart_id": "c_123" },
    "gateway_status": "failed"
  },
  "timestamp": 1790586990
}
subscription_invoice.created / .paid / .payment_failed

Same envelope. data is not the payment.success shape - it describes the invoice, and the fields below are the same on all three events. status is created, paid or failed. When an invoice is paid through a checkout (invoice mode, or the first card_token cycle) you also receive payment.success for that session, with subscription_id and subscription_invoice_id set; silent renewals (wallet_auto, later card_token cycles) send only the subscription_invoice event.

data fieldDescription
invoice_id, subscription_id, plan_idIdentifiers
status, amount, currencyInvoice state and the plan price for this cycle
billing_modeinvoice, wallet_auto or card_token
customer_emailThe subscriber
checkout_session_idThe checkout session created for this invoice, or null for silent renewals
due_at, paid_atUTC datetimes; paid_at is null unless paid
attempt, max_attempts, will_retry, next_retry_at, subscription_status, reasonpayment_failed only - which attempt just failed, the cap (4 for silent modes, 3 for invoice mode), whether another attempt is scheduled and when, the subscription's new status (past_due or cancelled), and why (see Subscriptions → Failed payments)
{
  "event": "subscription_invoice.paid",
  "data": {
    "invoice_id": "inv_live_4d1e...",
    "subscription_id": "sub_live_7b3a...",
    "plan_id": "plan_live_2c9f...",
    "status": "paid",
    "amount": 15000.0,
    "currency": "NGN",
    "billing_mode": "card_token",
    "customer_email": "customer@example.com",
    "checkout_session_id": null,
    "due_at": "2026-10-28 10:00:04",
    "paid_at": "2026-10-28 10:00:06"
  },
  "timestamp": 1793178006
}
{
  "event": "subscription_invoice.payment_failed",
  "data": {
    "invoice_id": "inv_live_4d1e...",
    "subscription_id": "sub_live_7b3a...",
    "plan_id": "plan_live_2c9f...",
    "status": "failed",
    "amount": 15000.0,
    "currency": "NGN",
    "billing_mode": "card_token",
    "customer_email": "customer@example.com",
    "checkout_session_id": null,
    "due_at": "2026-10-28 10:00:04",
    "paid_at": null,
    "attempt": 1,
    "max_attempts": 4,
    "will_retry": true,
    "next_retry_at": "2026-10-29 10:00:09",
    "subscription_status": "past_due",
    "reason": "insufficient_funds"
  },
  "timestamp": 1793178009
}
Handling deliveries safely. Deliveries are retried until you return a 2xx, so the same event can arrive more than once - dedupe on event + data.session_id (or data.invoice_id). Decide the outcome from the event name (or data.status, which always agrees with it), and before fulfilling an order confirm with GET /sessions.php?session_id=... as described under Checkout.
Retry schedule. Failed deliveries retried: 1 min → 5 min → 30 min → 2 hours → 24 hours. Always respond with 200 immediately and process async.
Reference

Environment Variables

The exact variable names to use when wiring MyroPay into a codebase or briefing an AI coding agent. Copy this table verbatim - it's the single source of truth this whole site should agree with.

VariableValueNotes
MYROPAY_PUBLIC_KEYpk_live_... / pk_test_...Safe in frontend code. From API & Apps.
MYROPAY_SECRET_KEYsk_live_... / sk_test_...Server-side only. This is the one credential that authenticates everything - deposits, escrow, payouts, wallet, invoices, subscriptions, checkout.
MYROPAY_WEBHOOK_SECRETwhsec_...Server-side only. Generated together with the key pair above, or via Webhook Management. Used only to verify X-MyroPay-Signature.
There is no "platform PIN" and it should never be requested by, or given to, an integration. A transaction PIN belongs to a human account holder and is entered by that person inside the MyroPay app to authorize their own action - it is never a server-side credential, never an environment variable, and no legitimate integration flow asks for it. If something asks you to set a MYROPAY_PLATFORM_PIN or similar, that's wrong - stop and remove it.

There is also no separate "platform API key." MYROPAY_PLATFORM_API_KEY isn't a MyroPay concept - there's only MYROPAY_SECRET_KEY per Merchant App, scoped to the merchant that created it. If an AI agent invents a *_PLATFORM_* variable, it's guessing at a naming convention MyroPay doesn't have; point it back at this table.
Release Notes

Changelog

v3.8
Current Release
  • Subscriptions - security fix: wallet_auto no longer trusts a merchant-supplied wallet_user_id. The customer authorises by paying the first invoice from their own wallet with their PIN; only then can the wallet be debited. New event subscription.wallet_authorized; wallet_authorized field on the subscription. Existing wallet_auto subscriptions must re-authorise on their next cycle.
  • Subscriptions - retries: declined silent charges now retry (1, 3 and 5 days after the previous attempt, 4 attempts total) instead of waiting a full billing period. payment_failed gains max_attempts, will_retry and next_retry_at; invoices expose attempt_count, next_retry_at and last_error.
  • Subscriptions - new/changed: update-plan, resume, list filters and paging, duplicate-subscription guard (409), cross-currency wallet debits at live FX, sandbox subscriptions never move real wallet balances, late payments can no longer reactivate a cancelled subscription, cancelling voids open invoices and checkout links, deactivating a plan no longer halts existing subscribers.
  • Bill payments and virtual cards are live, powered by Flutterwave. Airtime, data, TV/cable and electricity in a redesigned bills page (Nigerian accounts only - the feature is hidden from other countries) and real USD/NGN Mastercard virtual cards, both in the MyroPay app. Nigeria-only features (bills, bank-transfer top-up, send to a Nigerian bank) no longer appear for other countries, and investing shows "coming soon" outside Nigeria unless the account has a BVN. Business dashboards do not offer cards or bills yet. Card numbers are never stored by MyroPay - they are fetched from the issuer and shown only after the owner enters their PIN. Payments that Flutterwave confirms late are settled automatically, and an unclear outcome is never refunded blindly: it is reconciled against Flutterwave first. These are app features; there is no public API for them.
  • Fixed: a new customer starting a subscription from a Subscribe payment button hit a server error because the signup function was missing. Fixed; free-trial plans can't be started from a button yet (use the subscribe API).
  • Payouts documented end to end: how a withdrawal is funded, dispatched and settled, methods and corridors, fee and limits, statuses, refund and "unknown outcome" rules, and what payout.completed / payout.failed mean. Behaviour changes that match the docs: sk_test_ keys are now refused (403) for any write on the live wallet APIs, including payouts; payout.completed no longer fires when a mobile-money or manual PayPal withdrawal is merely queued - only when it settles; method: "wire" withdrawals are rejected up front instead of failing after the funds are reserved; initiate is rate limited to 20 requests per minute.
  • Payout webhooks now carry the withdrawal id. payout.completed and payout.failed include transaction_id (the withdrawal uuid), amount, fee, currency, method, status (and reason on failures), identically from every path. A payout marked failed by finance now sends payout.failed too (it previously sent nothing). Bank payouts stuck in processing are re-checked with Flutterwave every 10 minutes and settled automatically, and a Flutterwave HTTP 5xx on a payout is now treated as an unknown outcome (held) instead of a rejection (which refunded while the transfer could still go out).
  • Subscriptions - customer control: customers now see every subscription in the MyroPay app (Subscriptions), with next charge date, whether their balance covers it, payment history, and one-tap Turn off auto-pay / Cancel (no PIN needed - those can only stop spending). Auto-pay is a capped mandate (mandate_amount) set once with a PIN; every charge sends a receipt, and low-balance reminders go out 7, 3 and 1 day(s) before a charge. New events: subscription.cancel_scheduled, subscription.autopay_stopped.
  • Fixed: bank-transfer account numbers. Virtual bank accounts for users, businesses and POS tills are now issued by Flutterwave (permanent NGN accounts, created instantly once the owner's BVN is verified). Inbound transfers to Paystack virtual accounts could not be matched to a wallet on MariaDB (JSON lookup); POS tills now each get their own virtual account customer instead of colliding on the business email; pending setups that never resolve can be retried after 20 minutes; the pay-page and checkout transfer endpoints are rate limited and no longer leave orphan rows on failure.
  • Fixed: notifications in the personal app stay on screen long enough to read (longer for errors, pause on hover/touch, tap to dismiss).
v3.7
Previous Release
  • New: no-code Payment Buttons. Create Pay, Escrow and Subscribe buttons in the Business dashboard (Payment Buttons) and embed them with data-button-id. No backend or secret key needed; the price is stored server-side. See Payment buttons.
  • Changed: the sandbox now has its own host. Test-mode keys (sk_test_ / pk_test_) and sessions (cs_test_) are served from https://sandbox.myropay.com and no longer from checkout.myropay.com, which only accepts live keys. Change your base URL to https://sandbox.myropay.com/api/ for testing; paths are identical. myropay.js and the payment buttons pick the right host from your key automatically. Bank transfer, mobile money and PayPal are not available in the sandbox (use card or wallet).
  • Fixed: MyroPay.checkout(), inline(), and createButton() - the three myropay.js integration paths that only ever need a publicKey - built a /pay?pk=...&amount=... URL that the checkout page never actually read (it only read ?session=). Every integration using them landed on "No checkout session found." New endpoint action=create-public (public-key-authenticated, rate-limited, no metadata, fixed 30-minute expiry) lets the checkout page mint its own session from those params, same-origin, so no secret key is ever needed in the browser.
  • Security fix: the v3.3 entry below says createSession() and verify() were removed from myropay.js because both took a secret key as a browser-JS argument. They weren't - this build actually removes them, replaced with stubs that throw a clear error pointing to a server SDK. If your integration was calling either client-side, move that call to a server SDK now (dev.myropay.com#server-sdks); it was already exposing your secret key.
  • New: Checkout sandbox. Until now, merchant_apps.mode ('test' vs 'live') only changed an id's prefix (cs_test_, sk_test_) - nothing else branched on it, so a test-mode integration moved real wallet balances, locked real escrow funds, and (for card payments) hit the live Flutterwave API. A test-mode session (sk_test_/pk_test_) now never touches users.balance or users.locked_balance, for every payment method - wallet, card, and escrow fund/release/dispute/auto-release all work exactly as in live mode (session status, webhooks, the guest escrow portal), just without moving real money. See Testing & Sandbox below.
  • Test-mode card charges: with real Flutterwave test API keys configured (FLW_SECRET_KEY_TEST / FLW_ENCRYPTION_KEY_TEST), they hit Flutterwave's own sandbox - real OTP/3DS/decline behavior, using Flutterwave's published test cards. Without them, a built-in simulator stands in instead, covering the full range of outcomes - instant approval, two decline reasons, OTP challenge, card-PIN challenge, and a 3DS redirect to a simulated bank challenge page - for the full-card-entry (pay-card) flow only. The Flutterwave-hosted inline widget (pay-card-init) requires real test keys either way, since there's no way to simulate Flutterwave's own hosted iframe.
  • The 3DS simulator is a real redirect round trip, not a stub: sandbox.myropay.com/sandbox-3ds.html shows a bank-style Approve/Decline challenge, then redirects back to the session's redirect_url exactly like Flutterwave's own hosted 3DS page would - the checkout page's existing post-redirect completion check needed no changes to support it.
  • Test-mode sessions and escrows are visibly marked: the checkout page and the guest escrow portal both show a persistent "TEST MODE" banner (session.is_test / escrow.is_test in the API response) so nobody mistakes one for a real payment in either direction.
  • Fix: flwCardCharge() referenced checkout_sessions.charge_fx_rate - a column no migration ever created (the same gap migration 030 already fixed once for charge_amount/charge_currency). On any database where it wasn't added by hand, every card charge failed with a generic 500. Migration 034 adds it - if you're self-hosting, run it before this card-payment code reaches production.
v3.6
  • Removed pre-launch: personal-wallet bank-transfer funding, virtual accounts (app, business, and POS), and Mono bank linking. Nigerian withdrawal still requires a name-matched bank account (verified via Flutterwave); other countries withdraw to PayPal. Neither of those was touched.
  • Checkout and Pay-page bank transfer is unaffected - it already runs on Flutterwave's own per-transaction virtual account, NGN-only, and is hidden entirely for non-Nigerian payers. Card (via Flutterwave) is available in every currency.
  • Bill Payments, Virtual Cards, PayPal as an incoming checkout method, and new interest-bearing savings (fixed-tier, Flex Ultra) are now Coming Soon - existing savings balances remain visible and withdrawable, and non-interest flexible/group savings goals are unaffected.
  • Paystack retired as a payment provider - Flutterwave is now the only active payment gateway. (It was already handling bank-list/account-resolution for withdrawals; this removes its one remaining use, virtual accounts.)
  • New: wallet funding reminder notifications for wallet_auto subscriptions, sent ~7 days ahead of the next charge when the wallet balance looks short.
  • Docs: Checkout SDK (myropay.js) moved from dev.myropay.com/myropay.js to checkout.myropay.com/myropay.js - it's the SDK for the checkout product, not documentation, and belongs hosted alongside it (same convention as checkout.myropay.com/buttons/mrp-button.js). The old URL 301-redirects to the new one, so existing integrations keep working, but please update your <script src> tag to the new URL going forward - the redirect is a transition aid, not a long-term guarantee.
  • Security fix: Payroll (/business.php?section=payroll&action=create) was documented as JWT-only but that was never actually enforced, and it also had no PIN requirement - the only money-movement endpoint in the API without one. Any API key for the account, of any scope, could run a full payroll batch (up to the entire balance, to arbitrary recipient myrotags/emails) with no PIN. Now blocks API-key auth entirely (matching the docs and the apps/team sections) and requires PIN like every other transfer.
v3.5
Previous Release
  • Fixed: Checkout sessions could always be created in any currency, but pay-card sent that currency straight to our card processor with no conversion - which only worked when it happened to match NGN. Non-NGN sessions now convert to NGN at MyroPay's admin-set rate for the actual card charge, the same way pay-wallet already correctly handled it. You're still paid the full session-currency face value either way - see the new Multi-currency checkout section.
  • The pay-card response now includes a charged field showing exactly what hit the buyer's card when a conversion happened, and the checkout page discloses the NGN charge to the buyer before they enter card details.
v3.4
  • Fixed: Merchant App secret keys (sk_live_/sk_test_) were only ever accepted by the Checkout API - every other endpoint (deposits, escrow, payouts, wallet, transactions, invoices) silently rejected them. A single sk_ key now authenticates the entire API surface, with scopes, IP allow-list, and rate limit enforced everywhere.
  • New scopes: invoices:manage, wallet:transfer.
  • Webhook secrets (whsec_...) are now generated together with a Merchant App's key pair instead of as a separate, unscoped step - pass webhook_url when creating the app. Standalone webhook creation now requires app_id.
  • API keys (merchant) can no longer create, rotate, reveal, or revoke other API keys, or manage team members - that always requires a real dashboard session now, closing a self-escalation gap.
  • Docs: clarified that app.myropay.com (personal wallet) is unrelated to building an integration, added the missing API Keys / Webhook Management pages, and added an Environment Variables reference.
v3.3
Previous Release
  • Checkout SDK (myropay.js): createSession() and verify() removed - both required a secret key in browser JS, which the current secret-key-only backend can't support safely from a client. Use a Server SDK instead. checkout()/inline()/createButton() unaffected.
v3.2
Previous Release
  • All 5 server SDKs (Node.js, Python, Java, Ruby, PHP) now actually published to their package registries via CI - install commands on this page are live
  • WooCommerce and OpenCart (3.0.x-3.x) payment gateway plugins released - dual return-redirect + webhook confirmation
  • Mobile SDKs released for iOS (Swift Package, ASWebAuthenticationSession) and Android (Kotlin, Custom Tabs) - hosted-checkout wrappers, no card data on-device
  • Subscriptions API released: plans, recurring invoices, wallet auto-billing, dunning/retry - fills in the previously-unused session_type: subscription value
  • New webhook events: subscription.created, subscription.cancelled, subscription_invoice.created, subscription_invoice.paid, subscription_invoice.payment_failed
  • Known issue flagged: Checkout SDK (myropay.js) sends the wrong auth header for createSession()/verify() - fix pending, see Server SDKs section
  • Planned next: BigCommerce, Magento, Wix, Weebly, Ecwid plugins; card tokenization for true auto-charge subscriptions
v3.1
Previous Release
  • Flutterwave Direct Charge API integrated - preserves custom card form UI, no modal redirect needed
  • Checkout SDK (myropay.js) published with 4 integration modes: modal, redirect, inline, button
  • Escrow now supported in Checkout sessions - escrow: true param locks funds on payment
  • Admin dashboard: dispute resolution, withdrawal queue, KYC review, FX rate management, audit log
  • Business analytics API: summary, volume chart, top senders, transaction type breakdown
  • Team management: invite by email, role-based access (admin/finance/developer/support)
  • Auto-save cron for savings goals - daily/weekly/monthly frequencies
  • Token refresh rotation - refresh tokens invalidated on each use
  • WebAuthn credential columns added to users table (implementation ready)
  • Rate limiting now DB-backed with sliding window per identifier+action
v3.0
Previous Release
  • Multi-subdomain architecture: app, business, checkout, admin, dev
  • Business account type with payroll, invoicing, merchant apps
  • Escrow system with dispute resolution workflow
  • Virtual cards with AES-256-CBC encryption at rest
  • Webhook delivery with exponential backoff retry
  • Mono bank linking integration for Nigerian accounts
  • Multi-currency wallet display