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.
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.
| Credential | Prefix | Who it's for | Where to get it |
|---|---|---|---|
| User session | Bearer eyJ... | An individual logged into the personal or business app in their own browser/app session | Login flow (JWT, 15 min, refreshable) |
| Merchant App keys | pk_live_... / sk_live_... | Third-party integrations - plugins, marketplaces, social-commerce platforms, anything accepting payments on behalf of a MyroPay business | business.myropay.com → API & Apps |
# User session - logged-in individual only
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
# Merchant App secret key - this is what a third-party integration sends
X-API-Key: sk_live_...
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.
| Scope | Grants |
|---|---|
| charges:create / charges:read | Create and read deposits, checkout sessions, and payment links |
| payouts:create / payouts:read | Initiate withdrawals and manage saved banks / read payout history |
| wallet:read / wallet:transfer | Read balances / send a P2P wallet transfer or fulfill a payment request |
| escrow:manage | Create, release, dispute, and respond to escrow |
| invoices:manage | Create, send, and manage invoices |
| subscriptions:create / subscriptions:read | Manage recurring plans and subscriptions |
| transactions:read | Read transaction history |
| webhooks:manage | Create and manage webhook endpoints for this app |
Errors & Rate Limiting
| HTTP Code | Meaning | Common cause |
|---|---|---|
| 200 | Success | All good |
| 400 | Bad Request | Missing or malformed fields |
| 401 | Unauthorized | Missing, expired or invalid token |
| 403 | Forbidden | KYC required, wrong account type, wrong PIN |
| 404 | Not Found | Resource not found or doesn't belong to you |
| 409 | Conflict | Duplicate idempotency key or already exists |
| 429 | Rate Limited | Too many requests - see retry_after |
| 503 | Unavailable | Database 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.
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>
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),
});
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);
const { destroy } = await MyroPay.inline({
container: '#checkout-box',
publicKey: 'pk_live_...',
amount: 2500, currency: 'NGN',
});
MyroPay.createButton({
container: '#pay-here',
label: 'Pay with MyroPay',
publicKey: 'pk_live_...',
amount: 5000, currency: 'NGN',
});
const result = await MyroPay.verify(sessionId, 'sk_live_...');
if (result.status === 'completed' && result.amount === expectedAmount) {
// fulfil order
}
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.
// npm install myropay-node
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);
# pip install myropay
import myropay
myropay.api_key = 'YOUR_SECRET_KEY'
charge = myropay.Charge.create(
email="customer@email.com",
amount=500000
)
print(charge.checkout_url)
// composer require myropay/myropay-php
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']);
# gem install myropay
require 'myropay'
Myropay.api_key = 'YOUR_SECRET_KEY'
charge = Myropay::Charge.create(
email: 'customer@email.com',
amount: 500000
)
<!-- 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'
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);
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.
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.
| Platform | Status | Notes |
|---|---|---|
| WooCommerce | Available | Standard WC_Payment_Gateway, HPOS-compatible |
| OpenCart | Available | OpenCart 3.0.x-3.x, installable via Extension Installer or manual copy |
| BigCommerce | Planned | Requires app-marketplace registration/review |
| Magento | Planned | Most involved build of the group - full module + admin DI config |
| Wix | Planned | Requires app-marketplace registration/review |
| Weebly / Ecwid | Planned |
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.
MyroPayCheckout.shared.present(
checkoutURL: checkoutURL,
callbackURLScheme: "myshop-checkout",
presentationContext: self
) { result in /* verify session_id against your backend */ }
MyroPayCheckout.redirectListener = MyroPayRedirectListener { uri ->
// verify uri.myroPaySessionId() against your backend
}
MyroPayCheckout.present(context, checkoutUrl)
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.
- You create a plan once (price, currency, interval, optional trial). Plans are immutable on price so nobody is ever re-priced behind their back.
- You subscribe a customer (subscribe). You get the subscription and, unless a trial applies, the first invoice with a checkout_url.
- 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.
- 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.
- 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.
- 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.
- The money lands in your wallet and you take it out with Payouts.
| When | What happens | Who is told |
|---|---|---|
| 7, 3 and 1 day(s) before | If 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 date | Invoice 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 declined | Subscription 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 failure | Invoice failed, subscription cancelled. The next cycle is not billed while a retry is still pending. | You: payment_failed (will_retry: false) then subscription.cancelled |
| Action | Scope | What it does |
|---|---|---|
| POST create-plan | subscriptions:create | Create a plan: name, amount, currency, interval (daily|weekly|monthly|yearly), optional interval_count (1-365), trial_days (0-365), metadata |
| POST update-plan | subscriptions:create | Change 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 subscribe | subscriptions:create | Subscribe 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 cancel | subscriptions:create | at_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 resume | subscriptions:create | Undo a scheduled cancel-at-period-end. A subscription already cancelled cannot be resumed. |
| GET list-plans / retrieve-plan | subscriptions:read | Plans for this key's mode |
| GET list | subscriptions:read | Filter with status, plan_id, customer_email; page with limit (max 100) / offset |
| GET retrieve / list-invoices | subscriptions:read | One 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 |
POST /api/subscriptions.php?action=create-plan
{
"name": "Pro Monthly",
"amount": 15000,
"currency": "NGN",
"interval": "monthly",
"trial_days": 7
}
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 mode | How each cycle is collected | Customer 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_auto | Silent 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_token | Silent 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. |
| status | Meaning | Moves to |
|---|---|---|
| trialing | In the free trial. Nothing is billed until trial_end (= current_period_end). | active (first invoice issued at trial end) |
| active | Paid up; renews at next_billing_at. | past_due, cancelled |
| past_due | The latest invoice is unpaid. Silent modes are retrying; invoice mode is waiting for the customer. | active (paid), cancelled (retries exhausted) |
| cancelled | Terminal. No more invoices or charges. A late payment is recorded but never reactivates it. | - |
| Mode | Policy |
|---|---|
| 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. |
| invoice | The 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.
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"
}
- The response has authorization_required: true and an invoice.checkout_url for a wallet-only checkout. Send the customer there.
- 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.
- 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.
- 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.
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"
}
- The response contains an invoice with a checkout_url. Send the customer there - it is card-only, because only a card can be saved.
- When they pay you receive payment.success, subscription_card.attached (with card_brand and card_last4) and subscription_invoice.paid.
- 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.
Everything below is built into the MyroPay app (Subscriptions) - you don't build or host any of it.
| Customer can | Detail |
|---|---|
| See all their subscriptions | Merchant, 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 history | Every invoice with its date, amount and status, including retries. |
| Turn off auto-pay | Revokes 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. |
| Cancel | Ends 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.
Each paid invoice credits your MyroPay wallet (converted to your wallet currency when the plan currency differs). Withdraw it with Payouts.
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.
| HTTP | When |
|---|---|
| 400 | Missing/invalid field (unknown billing_mode or currency, bad email, consent.accepted missing for card_token, attempt to change an immutable plan field) |
| 404 | Plan or subscription not found, plan inactive, or the object belongs to the other mode (test vs live) |
| 409 | Customer already has an open subscription to this plan; resume on a cancelled / non-scheduled subscription |
| 429 | Rate limited (10 subscribe calls per customer per hour) |
Auth Endpoints
Create a personal or business account. Returns access + refresh tokens immediately.
Authenticate with email + password. Include totp_code if 2FA enabled.
Exchange refresh token for new access + refresh pair. Tokens rotate on every use.
Returns authenticated user profile: balance, KYC status, pin_set, referral code.
Verify email with 6-digit OTP. Body: { "otp": "123456" }
Trigger password reset email. Always returns 200 to prevent enumeration.
Complete password reset. Body: { "token": "...", "password": "..." }
Change @myrotag. Max 3 changes per year. Body: { "myrotag": "newhandle" }
Generate TOTP secret and QR code URI for authenticator app setup.
Activate 2FA. Returns 10 recovery codes - store immediately.
Disable 2FA with current TOTP code. Body: { "code": "123456" }
Revoke refresh token. Body: { "refresh_token": "..." }
Wallet & Transfers
Returns balance, locked funds, currency, monthly in/out totals. Scope: wallet:read.
Send money to @myrotag, email, or phone. Requires PIN. Fee: 0.1%. Supports X-Idempotency-Key. Scope: wallet:transfer.
Find user by @myrotag, email, or phone. Scope: wallet:read.
Request payment from another user. Scope: wallet:read.
Pay a received payment request. Body: { "request_id": 14, "pin": "1234" }. Scope: wallet:transfer.
Paginated transaction history. Params: page, limit, type, q, from, to. Scope: wallet:read.
Deposits
Smart gateway init. Pass method to force: auto | flutterwave | checkout. Scope: charges:create.
Flutterwave direct card charge - preserves your own card form UI. Returns auth mode: pin, otp, or redirect. Scope: charges:create.
Validate PIN or OTP for a pending FLW charge. Body: { "flw_ref": "...", "otp": "..." }. Scope: charges:create.
Verify FLW payment after redirect. Body: { "tx_ref": "...", "transaction_id": "..." }. Scope: charges:create.
Server-to-server FLW event. Validated via VERIF-HASH header.
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.
- 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.
- 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.
- The payout is dispatched to the gateway for the chosen method (see Methods) and the withdrawal becomes processing.
- 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.
Supported banks and their bank_code, resolved through Flutterwave for every country. Scope: payouts:read.
Look up the account holder name before saving. Params: account_number, bank_code. Nigeria only. Scope: payouts:read.
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.
List saved accounts. Account numbers are masked to the last 4 digits. Scope: payouts:read.
Remove a saved account (id). Withdrawals already in flight are unaffected. Scope: payouts:create.
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.
The 50 most recent withdrawals, newest first: id, uuid, method, amount, fee, currency, status, created_at, processed_at. Scope: payouts:read.
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.
| Topic | Rule |
|---|---|
| Who can withdraw | Only a KYC-verified account (403 otherwise). |
| Authorisation | A 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 keys | sk_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_. |
| Fee | The 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. |
| Minimum | 1.00 in the wallet's currency. |
| Daily limit | Set 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 limit | 20 initiate requests per minute per account (429 beyond that), on top of the Merchant App's overall per-minute limit. |
| method | details | How it is sent |
|---|---|---|
| bank | bank_id of a saved account | Flutterwave 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. |
| paypal | paypal_email | PayPal 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_money | phone, network | Processed 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.
| status | Meaning | Next |
|---|---|---|
| pending | Created and funded; being handed to the gateway. Normally lasts moments. | processing, failed |
| processing | Accepted by the gateway (or queued for finance). The money has left the wallet and is on its way - not yet delivered. | completed, failed |
| completed | Delivered. Terminal. payout.completed was sent. | - |
| failed | Rejected or returned. Amount + fee were refunded to the wallet (a refund transaction is recorded). Terminal. payout.failed was sent. | - |
Delivered in the standard signed envelope ({ "event", "data", "timestamp" }, see Webhooks). Each fires once, when the withdrawal really settles - not when it is queued.
| Event | data | When |
|---|---|---|
| payout.completed | transaction_id, amount, fee, currency, method, status | Delivery 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.failed | the same fields, plus reason | The 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.
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.
| HTTP | When |
|---|---|
| 400 | Missing 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. |
| 401 | Incorrect transaction PIN (sessions), or missing/invalid key. |
| 403 | KYC not verified; key missing payouts:create; sk_test_ key used for a write; request IP not on the app's allow-list. |
| 429 | Rate limit exceeded. |
Transactions
Paginated transaction list. Params: page, limit, type, q, from, to. Scope: transactions:read.
Single transaction with full counterpart details. Scope: transactions:read.
Export up to 5,000 transactions. Params: from, to. Scope: transactions:read.
Lifetime and monthly totals by type for dashboard stats. Scope: transactions:read.
Notifications
Paginated notifications with unread count. Param: page.
Returns { "count": 4 } - fast endpoint for polling badge counts.
Mark all notifications as read.
Mark a single notification as read. Body: { "id": 42 }
Escrow
Lock funds until delivery conditions are met. Either party can raise a dispute. MyroPay resolves within 48 hours. Fee: 1.5%.
Create and fund escrow. Amount + 1.5% fee debited and locked immediately. Requires PIN. Scope: escrow:manage.
Release funds to beneficiary. Initiator only. Body: { "uuid": "...", "pin": "..." }. Scope: escrow:manage.
Raise a dispute. Either party. Body: { "uuid": "...", "reason": "...", "evidence": "https://..." }. Scope: escrow:manage.
Submit response to open dispute. Body: { "dispute_uuid": "...", "response": "..." }. Scope: escrow:manage.
All escrows where you are initiator or beneficiary.
Full escrow details with counterpart info.
KYC Verification
| Level | Label | Daily limit | Requirement |
|---|---|---|---|
| 0 | Starter | $200 | Email verified |
| 1 | Standard | $5,000 | Government ID + selfie |
| 2 | Verified | $50,000 | Address proof + BVN (NG) |
Current KYC level, document status per tier, daily limits.
Multipart form-data. Fields: document (file), doc_type, tier. Max 5MB. JPEG/PNG/PDF.
Nigerian BVN verification. Hashed SHA-256 immediately - never stored plain.
Invoices Business accounts
Create invoice with line items and tax. Returns invoice number and payment link. Scope: invoices:manage.
Email invoice PDF to client. Changes status draft → sent. Scope: invoices:manage.
All invoices. Filter by status: draft | sent | paid | overdue. Scope: invoices:manage.
Full invoice with parsed line items array. Scope: invoices:manage.
Payroll
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.
Payroll batches with totals, recipient count, and completion status.
Analytics
KPI summary: revenue, spending, net, transaction count, invoice stats, payroll totals.
Daily inflow/outflow series for charting.
Top 10 users by amount sent to your account in last 90 days.
Breakdown by transaction type for pie/donut charts.
Checkout API
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.
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).
Pay a session with MyroPay wallet balance. Body: { "session_id": "...", "pin": "..." }
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.
Submit OTP/PIN for pending card payment. Body: { "flw_ref": "...", "otp": "...", "session_id": "..." }
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.
| Method | How currency is handled |
|---|---|
| Wallet | Buyer's wallet is debited in their own wallet currency, converted from the session currency at MyroPay's admin-set rate. |
| Card | Non-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 transfer | NGN sessions only - a real NUBAN account can't exist in another currency. Not offered at all for other currencies; card/wallet still work normally. |
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.
Two ways this works, and it's automatic - nothing in your integration code changes between them:
| If Flutterwave test keys are configured | If 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 number | Result |
|---|---|
| 4000000000000000 | Approved - session completes normally |
| 4000000000000002 | Declined - insufficient funds |
| 4000000000000010 | Declined - generic decline |
| 4000000000000226 | Requires OTP - enter 123456 to approve, anything else declines |
| 4000000000000341 | Requires card PIN - enter 1234 to approve, anything else declines |
| 4000000000003220 | Requires 3DS - redirects to a simulated bank challenge page (Approve/Decline) |
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.
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.
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.
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.
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.
Every app on the account with scopes, IP allow-list, and rate limit - secret keys are never included.
Update scopes / allowed_ips / rate_limit_per_min in one call. Requires PIN.
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.
Permanently destroys the key pair - cannot be undone. Requires PIN.
Webhook Management
Manage the webhook(s) attached to a specific app. See Webhook Events for the event catalogue and signature verification.
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.
List webhooks; optionally filter to one app. Scope: webhooks:manage.
Sends a signed test.ping event to the endpoint and returns the HTTP status it responded with. Scope: webhooks:manage.
Removes a webhook endpoint. Scope: webhooks:manage.
Settings
Update name and phone. Body: { "first_name", "last_name", "phone" }
Change password. Revokes all other sessions. Body: { "current_password", "new_password" }
Set or change 4-digit transaction PIN. Body: { "pin", "confirm_pin", "current_pin?" }
Toggle preference. Body: { "key": "push_notifications", "value": 1 }
Active login sessions with device, IP, last used time.
Log out all other sessions. No body required.
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.
// 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);
| Event | Trigger |
|---|---|
| payment.success | Checkout session paid and completed |
| payment.failed | Payment attempt abandoned or declined |
| deposit.confirmed | Wallet deposit credited (any gateway) |
| transfer.received | P2P transfer credited to wallet |
| payout.completed | Withdrawal delivered - fires once, when it settles (not when it is queued). See Payouts |
| payout.failed | Withdrawal failed - amount + fee already returned to the wallet |
| escrow.funded | Escrow created and funds locked |
| escrow.released | Initiator releases to beneficiary |
| escrow.disputed | Either party raises a dispute |
| escrow.resolved | Admin resolves dispute |
| kyc.approved | KYC document approved |
| kyc.rejected | KYC document rejected |
| invoice.paid | Invoice paid via checkout link |
| invoice.overdue | Invoice past due date |
| payroll.completed | Payroll batch fully processed |
| subscription.created | A subscription is created |
| subscription.cancelled | Cancelled immediately, at period end, or because payment retries were exhausted (reason: cancel_at_period_end | payment_failed | customer_cancelled) |
| subscription.resumed | A scheduled cancel-at-period-end was undone |
| subscription.cancel_scheduled | The customer chose to cancel from their MyroPay app; it ends at ends_at (data: subscription_id, cancelled_by, ends_at) |
| subscription.autopay_stopped | The 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_authorized | A customer authorised automatic wallet debits for a wallet_auto subscription (data: subscription_id, authorized_at) |
| subscription_invoice.created | A subscription billing cycle's invoice is generated |
| subscription_invoice.paid | Subscription invoice paid (any billing mode) |
| subscription_card.attached | A card was saved for a card_token subscription (data: subscription_id, card_brand, card_last4) |
| subscription_invoice.payment_failed | A 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 |
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.
| Key | Type | Description |
|---|---|---|
| event | string | The event name from the table above, e.g. payment.success |
| data | object | The event's payload - fields documented per event below |
| timestamp | integer | Unix 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).
| data field | Type | Description |
|---|---|---|
| session_id | string | The 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_ref | string | null | The 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. |
| status | string | "success" or "failed" - always matches the event name, so you can rely on either |
| amount | number | The session's face amount in currency (major units, e.g. 15000.00 NGN) - what you asked to be paid, never a converted figure |
| currency | string | The session's currency |
| gateway | string | How it was paid: flutterwave (card, bank transfer, USSD, mobile money), wallet or paypal |
| customer_email | string | null | Email on the session, if known |
| escrow | boolean | true if this payment is being held in escrow rather than settled |
| is_test | boolean | true for sessions created with a test key |
| metadata | object | The metadata object you passed at creation, returned as-is ({} if none) |
| paid_at | string | payment.success only - ISO 8601 timestamp |
| paid_by_email | string | payment.success via wallet only - the paying MyroPay account's email |
| gateway_status | string | payment.failed only - the gateway's own failure status/reason |
| subscription_id, subscription_invoice_id | string | Present 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
}
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 field | Description |
|---|---|
| invoice_id, subscription_id, plan_id | Identifiers |
| status, amount, currency | Invoice state and the plan price for this cycle |
| billing_mode | invoice, wallet_auto or card_token |
| customer_email | The subscriber |
| checkout_session_id | The checkout session created for this invoice, or null for silent renewals |
| due_at, paid_at | UTC datetimes; paid_at is null unless paid |
| attempt, max_attempts, will_retry, next_retry_at, subscription_status, reason | payment_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
}
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.
| Variable | Value | Notes |
|---|---|---|
| MYROPAY_PUBLIC_KEY | pk_live_... / pk_test_... | Safe in frontend code. From API & Apps. |
| MYROPAY_SECRET_KEY | sk_live_... / sk_test_... | Server-side only. This is the one credential that authenticates everything - deposits, escrow, payouts, wallet, invoices, subscriptions, checkout. |
| MYROPAY_WEBHOOK_SECRET | whsec_... | Server-side only. Generated together with the key pair above, or via Webhook Management. Used only to verify X-MyroPay-Signature. |
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.
Changelog
- 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- 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
- 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