What is ready today
The money pipeline is real and has moved real money through moneypings. Some of the integration surface around it is still being finished, and it is more useful to you to know which is which than to find out mid-build.
Taking money in
LiveProven on 3 September 2026 with real payments. Two credits arrived, were verified, recorded and posted to a customer balance that read exactly NGN 500.00.
Your API key
At onboardingYour live key is created when your account is set up, with the permissions listed below. It is sent separately — never by the same channel as this page.
Reading balances & statements
LiveWallets, ledger entries, collections and held totals are all callable now with your key.
Pay-in accounts over the API
LiveStep 3 below. Deployed 8 September 2026 and callable now — every endpoint on this page works against the live server today.
Webhooks to you
At onboardingGive us one HTTPS URL and we set it on your account. Your signing secret is sent separately, never by the same channel as this page. Then send yourself a test event whenever you like — see step 4.
Permanent account numbers
Not yetRead this one before you design anything. Today's bank rail issues accounts that expire in about five hours — see step 3.
How it works
You hold the relationship with your customer. We hold the balance and the bank plumbing. You never send us your customer's identity documents — under this model their KYC is yours, and we only ever see a reference you choose.
One wallet belongs to one of your customers, and you name it with your own identifier. We give it back a reference of ours that never changes. From then on either identifier works everywhere in the API.
Before you start
Authentication
Every call carries your key in an X-API-Key header. There is no OAuth
step, no token exchange and no expiry.
X-API-Key: sk_live_xxxxxxxx… Content-Type: application/json
About your key
It is stored here as a bcrypt hash and cannot be read back by anyone, including us. If it is lost we issue a new one and the old one stops working immediately. Treat it exactly like a password: server-side only, never in a mobile app or browser, never in a git repository.
Your permissions
A moneypings API key is enabled for these four services. A call to a service that is not on your key returns 403.
| Service | Lets you |
|---|---|
create_wallets | Open wallets, and ask for a pay-in account |
check_balances | Read a wallet, its statement, and your held total |
collections | List payments that arrived, for reconciliation |
webhooks | Receive events, read the delivery log, replay |
Money is always integer kobo
Every amount we store and compare is a whole number of kobo, never a decimal.
50000 means NGN 500.00. Responses also carry a
balance or amount string for display — use it to show
a figure to a person, and use the _minor integer for anything you compute,
compare or store.
Why this matters
In every mainstream language 0.1 + 0.2 does not equal 0.3.
A balance someone releases goods against does not get to be approximately right, so
there are no decimals anywhere in our ledger. Please keep none in yours.
Rate limit
1,000 requests an hour unless we have agreed a different figure with you. Going over returns 429 with
retry_after_seconds. Tell us if you need more — it is one setting.
Confirm your key works
Start here. This call needs no setup and tells you what your key is allowed to do.
curl https://moneypings.com/api/partner/services \ -H "X-API-Key: $MONEYPINGS_KEY"
{
"code": 200,
"message": "Allowed services for this API key",
"data": {
"partner": "Your Company",
"environment": "live",
"allowed_services": [
"create_wallets",
"check_balances",
"collections",
"webhooks"
],
"rate_limit": { "requests_per_hour": 1000 }
}
}
There is also GET /api/partner/health, which needs no key at all and is
the right thing to poll if you want a liveness check.
Open a wallet for a customer
One wallet per customer. The only field we require is
external_reference — your identifier for that
customer. Everything else is descriptive and helps your support team recognise the
row later.
curl -X POST https://moneypings.com/api/partner/wallets \
-H "X-API-Key: $MONEYPINGS_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_reference": "CUST-000123",
"holder_name": "Ada Obi",
"holder_email": "ada.obi@example.com",
"holder_phone": "+2348012345678",
"currency": "NGN"
}'
{
"code": 201,
"message": "Wallet created",
"data": {
"wallet_reference": "WAL-D0C47A86FC0D",
"external_reference": "CUST-000123",
"holder_name": "Ada Obi",
"holder_email": "ada.obi@example.com",
"holder_phone": "+2348012345678",
"currency": "NGN",
"status": "active",
"status_reason": null,
"balance_minor": 0,
"balance": "0.00",
"virtual_account": {
"rail": "moneypings",
"account_number": null,
"account_name": null,
"bank_name": null,
"bank_code": null,
"issued_at": null,
"source": "pending"
},
"totals": {
"credited_minor": 0, "debited_minor": 0,
"fees_minor": 0, "charges_minor": 0, "entry_count": 0
},
"last_credit_at": null,
"last_debit_at": null,
"created_at": "2026-09-08T14:26:08.275Z",
"updated_at": "2026-09-08T14:26:08.275Z"
}
}
Safe to retry
Calling this twice with the same external_reference does not create a
second wallet. The second call returns 200 with
"message": "Wallet already exists" and the same
wallet_reference. So a retry after a timeout is always safe — you
never need to check first.
Opening many at once
Send a wallets array instead, up to 500 per call. The response separates
what was created from what already existed and what failed, so a partial success is
never silent.
{
"wallets": [
{ "external_reference": "CUST-000124", "holder_name": "Chidi Nwosu" },
{ "external_reference": "CUST-000125", "holder_name": "Bisi Adeyemi" }
]
}
{
"code": 200,
"message": "Issued 2 wallet(s)",
"data": {
"created": [ { "wallet_reference": "WAL-...", ... } ],
"existing": [],
"failed": [],
"counts": { "created": 2, "existing": 0, "failed": 0 }
}
}
Notice what is null
A brand-new wallet has account_number: null and
source: "pending". That is correct and expected: the ledger opens first,
and the bank identity is attached separately in step 3. Do not treat a null account
number as a failure.
Get an account they can pay into
Read this before you design your database
Our current Nigerian rail issues virtual accounts that are tied to one expected payment and expire in about five hours. They are not permanent customer account numbers. So you must ask for a fresh account each time a customer wants to top up, show it to them, and let it lapse — never store one against the customer and re-display it tomorrow.
Permanent per-customer NUBANs are supported by the platform, but they need a bank
that hands over a block of numbers in advance. That relationship is not in place
yet. When it is, the same wallet keeps the same reference and simply gains a
permanent virtual_account — no migration on your side.
The amount is required, because the bank mints the account against an expected payment. If your customer pays a different amount, they are still credited with whatever actually arrived — the figure is what the bank needs in order to issue the number at all.
curl -X POST \
https://moneypings.com/api/partner/wallets/WAL-D0C47A86FC0D/pay-in \
-H "X-API-Key: $MONEYPINGS_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": "500.00",
"email": "ada.obi@example.com"
}'
{
"code": 201,
"message": "Pay-in account issued",
"data": {
"wallet_reference": "WAL-D0C47A86FC0D",
"account_number": "9638297991",
"account_name": "FA Global Collections",
"bank_name": "PROVIDUS BANK",
"amount_minor": 50000,
"amount": "500.00",
"currency": "NGN",
"expires_at": "2026-09-08T19:26:08.921Z",
"reference": "TOP-WAL-D0C47A86FC0D-566B8EAE",
"notice": "Pay the exact amount to this account before it expires.
It is a one-off account for this payment, not a permanent
account number. The beneficiary name shown by the payer's
bank is set by the acquiring bank and may read differently
from the name above."
}
}
Fields you can send
| Field | Required | Notes |
|---|---|---|
amount | one of the two | Major units as a string, e.g. "500.00" |
amount_minor | one of the two | Integer kobo, e.g. 50000. Preferred. |
email | conditional | The bank requires a payer email. Falls back to the wallet's holder_email; if neither exists the call is refused. |
account_name is the collection name we display, not your customer's
name — the account is still uniquely tied to this one wallet and this one
payment. The name your customer's own bank shows them at the confirmation step is
set by the receiving bank and can read differently again; the notice
field says so, and it is worth repeating in your own checkout copy.
The webhook you receive
When money lands we call your endpoint. You give us one HTTPS URL and we send every event to it. Every delivery is signed, and every delivery is recorded on our side whether it succeeded or not.
Events
POST /webhooks/test. No money moved — answer 2xx.Order is not guaranteed
One payment usually produces three events in quick succession —
collection.received, wallet.credited, then
collection.confirmed. They are sent separately and can arrive out of
order or be retried independently. Key your processing on the identifiers in the
body, never on arrival order.
What a delivery looks like
Every body has the same four keys, and the event-specific detail lives in data.
Content-Type: application/json X-Webhook-Event: wallet.credited X-Webhook-Id: whk_8bd4dfeaee6253396cf3035c77653b4f X-Webhook-Timestamp: 2026-09-08T14:26:08.972Z X-Webhook-Signature: e9f3c7d6fecaed022b9fdf643e508daa89526f0daa6446cd6b81f00e076972c1
{
"event": "wallet.credited",
"id": "whk_8bd4dfeaee6253396cf3035c77653b4f",
"timestamp": "2026-09-08T14:26:08.972Z",
"data": {
"wallet_reference": "WAL-D0C47A86FC0D",
"external_reference": "CUST-000123",
"currency": "NGN",
"status": "active",
"balance_minor": 50000,
"balance": "500.00",
"virtual_account": {
"account_number": null,
"account_name": null,
"bank_name": null,
"bank_code": null
},
"entry": {
"reference": "col:6aa01b002847cb15f57b485a",
"direction": "credit",
"type": "collection",
"amount_minor": 50000,
"amount": "500.00",
"balance_after_minor": 50000,
"narration": "FROM NIP/ [provider]-FA Global/9999992609031442...",
"created_at": "2026-09-08T14:26:08.967Z"
}
}
}
{
"event": "collection.confirmed",
"id": "whk_9d931e3280fcfd7feec424f912238dae",
"timestamp": "2026-09-08T14:26:08.977Z",
"data": {
"reference": "COL-20260908-1AC9A162D5",
"status": "confirmed",
"amount": "500.00",
"amount_minor": 50000,
"currency": "NGN",
"merchant_reference": "TOP-WAL-D0C47A86FC0D-566B8EAE",
"payer": {
"account_name": null,
"account_number": "******3334",
"bank_name": "NIP VIRTUAL BANK",
"narration": "FROM NIP/ [provider]-FA Global/9999992609031442..."
},
"received_at": "2026-09-08T14:26:08.930Z",
"confirmed_at": "2026-09-08T14:26:08.952Z"
}
}
The payer is not verified
Everything under payer is what the bank told us and nothing more. The
account number is masked by the rail, and the name is often absent. It is useful for
support and reconciliation. It is not an identity check, and must not be used as one.
Verifying the signature
X-Webhook-Signature is an HMAC-SHA256, in lowercase hex, of the
exact bytes of the request body, using the webhook secret we give you.
Hash the raw body before any JSON parsing — parsing and re-serialising can reorder
keys or change spacing, and a signature checked over different bytes than were signed
is not a check.
const express = require("express");
const crypto = require("crypto");
const app = express();
const SECRET = process.env.MONEYPINGS_WEBHOOK_SECRET;
// express.raw, NOT express.json — we must hash the bytes as sent.
app.post("/hooks/moneypings", express.raw({ type: "*/*" }), (req, res) => {
const sent = req.header("X-Webhook-Signature") || "";
const mine = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
// Constant time: a plain === leaks timing information.
const a = Buffer.from(sent, "utf8");
const b = Buffer.from(mine, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: "bad signature" });
}
const evt = JSON.parse(req.body.toString("utf8"));
// Answer FAST, then do the work. We time out after 10 seconds.
res.status(200).json({ ok: true });
// evt.id is unique per delivery. Store it and ignore repeats.
enqueue(evt);
});
import hmac, hashlib, os
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = os.environ["MONEYPINGS_WEBHOOK_SECRET"].encode()
@app.post("/hooks/moneypings")
def moneypings_hook():
raw = request.get_data() # bytes, exactly as sent
mine = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
sent = request.headers.get("X-Webhook-Signature", "")
if not hmac.compare_digest(mine, sent):
return jsonify(error="bad signature"), 401
event = request.get_json(force=True)
enqueue(event) # do the work off the request
return jsonify(ok=True), 200
Rules for your endpoint
- Answer within 10 seconds. We give up on the connection after that and treat it as a failure.
- Answer
2xxto accept. Anything else is a failure and we retry. - We retry 5 times, after roughly 1 second, 5 seconds, 30 seconds, 2 minutes and 5 minutes — about 8 minutes of cover in total.
- A restart on our side does not lose a retry. If our server restarts while a delivery is waiting, it is picked up again within about 20 minutes. One stranded for more than a day is marked
failedinstead, so the replay below can fetch it. - Expect the same event twice. A retry after your server already succeeded but the response was lost is normal. Store
id(thewhk_…value) and ignore one you have seen. - Do the work after you answer. Acknowledge, queue, process. Holding the connection open while you write to your database is how you end up with retries you did not need.
When your server was down
You do not need to open a support ticket. You can read what we tried to send and ask for it again.
{
"code": 200,
"data": {
"deliveries": [
{
"webhook_id": "whk_9d931e3280fcfd7feec424f912238dae",
"event": "collection.confirmed",
"status": "delivered",
"attempts": 1,
"response_status": 200,
"last_error": null,
"delivered_at": "2026-09-08T14:26:08.984Z",
"createdAt": "2026-09-08T14:26:08.977Z"
}
],
"total": 4, "limit": 50, "skip": 0
}
}
curl -X POST https://moneypings.com/api/partner/webhooks/replay \
-H "X-API-Key: $MONEYPINGS_KEY" \
-H "Content-Type: application/json" \
-d '{ "since": "2026-09-08T00:00:00Z" }'
# 202 { "message": "Replay queued", "data": { "queued": 3, ... } }
# Re-sends everything that FAILED since that time, up to 200.
# Defaults to the last 24 hours. A replay reproduces the original
# body, timestamp and X-Webhook-Id exactly — so your duplicate
# check recognises it.
To re-send one specific delivery instead:
POST /api/partner/webhooks/replay/{webhook_id}.
Send yourself a test event
You do not have to wait for a real payment to find out whether your endpoint and your
signature check work. This sends one webhook.test event to your URL now,
signed exactly like every other delivery, and tells you how your endpoint answered.
No money moves. It is tried once, never retried, and bulk replay skips it.
curl -X POST https://moneypings.com/api/partner/webhooks/test \ -H "X-API-Key: $MONEYPINGS_KEY"
{
"code": 200,
"message": "Test event delivered",
"data": {
"webhook_id": "whk_8a15f4e0efaa5357c4a83e0fb5df51b2",
"event": "webhook.test",
"target_url": "https://your-endpoint/hooks/moneypings",
"sent_at": "2026-09-11T13:40:00.686Z",
"delivered": true,
"response_status": 200,
"response_time_ms": 2,
"response_body": "{\"received\":true}",
"signed": true,
"error": null,
"note": null
}
}
X-Webhook-Event: webhook.test
X-Webhook-Id: whk_8a15f4e0efaa5357c4a83e0fb5df51b2
X-Webhook-Timestamp: 2026-09-11T13:40:00.686Z
X-Webhook-Signature: c7c8ca943b0a5d45e4be65b0690aa5542e5cecef14d355b940030b4adf3d49b0
{
"event": "webhook.test",
"id": "whk_8a15f4e0efaa5357c4a83e0fb5df51b2",
"timestamp": "2026-09-11T13:40:00.686Z",
"data": {
"test": true,
"message": "Test event requested with POST /api/partner/webhooks/test. No money moved. Answer with any 2xx."
}
}
When your endpoint refuses, delivered is false and you get back
its response_status, the first 300 characters of its
response_body, and error — so a failing signature check
shows up as, for example, 401 with {"error":"bad signature"}.
signed: false means we have not issued your signing secret yet.
Read the balance and the statement
Webhooks are the fast path. These endpoints are the record, and they are what you should trust when the two ever disagree.
One wallet
Look a wallet up by our wallet_reference or by your own
external_reference — the same route accepts either, so you never
have to store ours if you would rather not.
curl https://moneypings.com/api/partner/wallets/CUST-000123 \ -H "X-API-Key: $MONEYPINGS_KEY"
{
"code": 200,
"data": {
"wallet_reference": "WAL-D0C47A86FC0D",
"external_reference": "CUST-000123",
"currency": "NGN",
"balance_minor": 50000,
"balance": "500.00",
"status": "active",
"totals": {
"credited_minor": 50000,
"debited_minor": 0,
"fees_minor": 0,
"charges_minor": 0,
"entry_count": 1
},
"last_credit_at": "2026-09-08T14:26:08.962Z",
"last_debit_at": null
}
}
The statement
Every movement on the wallet, newest first. Filter with
?direction=credit or ?type=fee, page with
?limit= and ?skip=.
curl "https://moneypings.com/api/partner/wallets/CUST-000123/entries?limit=50" \ -H "X-API-Key: $MONEYPINGS_KEY"
{
"code": 200,
"data": {
"wallet": { ... the wallet, as above ... },
"entries": [
{
"reference": "col:6aa01b002847cb15f57b485a",
"wallet_reference": "WAL-D0C47A86FC0D",
"direction": "credit",
"type": "collection",
"amount_minor": 50000,
"amount": "500.00",
"currency": "NGN",
"balance_after_minor": 50000,
"balance_after": "500.00",
"sequence": 1,
"source": {
"kind": "collection",
"reference": "COL-20260908-1AC9A162D5"
},
"narration": "FROM NIP/ [provider]-FA Global/9999992609031442...",
"created_at": "2026-09-08T14:26:08.967Z"
}
],
"total": 1, "limit": 50, "skip": 0
}
}
sequence starts at 1 and is assigned by the database, one per wallet,
with no gaps. If you ever see 1, 2, 4 then entry 3 existed and is missing — which
is exactly what an append-only ledger should let you notice. balance_after_minor
lets you render a running statement without re-summing anything.
Everything you hold
curl https://moneypings.com/api/partner/wallet-balances \ -H "X-API-Key: $MONEYPINGS_KEY"
{
"code": 200,
"message": "Wallet balances",
"data": [
{ "currency": "NGN", "balance_minor": 50000, "balance": "500.00", "wallets": 1 }
]
}
Use this as your daily control total: it must equal the sum of the balances in your own database. If it does not, one of us has a bug and it is worth finding on the day rather than in a month.
Reconciling, daily
Webhooks can be lost, and a partner who only listens to webhooks has no way of noticing one they never received. So please reconcile against the record once a day. It is two calls.
curl "https://moneypings.com/api/partner/collections\ ?from=2026-09-08T00:00:00Z&to=2026-09-09T00:00:00Z&status=confirmed" \ -H "X-API-Key: $MONEYPINGS_KEY"
{
"reference": "COL-20260908-1AC9A162D5",
"rail": "moneypings",
"amount_minor": 50000,
"amount": "500.00",
"currency": "NGN",
"status": "confirmed",
"merchant_reference": "TOP-WAL-D0C47A86FC0D-566B8EAE",
"payer": { "account_number": "******3334", "bank_name": "NIP VIRTUAL BANK", ... },
"received_at": "2026-09-08T14:26:08.930Z",
"confirmed_at": "2026-09-08T14:26:08.952Z",
"reversed_at": null,
"fee": { "amount_minor": 0, "fee_type": "none" }
}
Query parameters
| Parameter | Meaning |
|---|---|
status | received, confirmed, reversed or failed |
merchant_reference | Narrow to one pay-in reference |
from / to | ISO 8601 timestamps, on received_at |
limit / skip | Paging. Default 50, maximum 200. |
Money credited to a wallet is held in that balance and is never also paid out by any
other route on our side. Two designs cannot both be right about the same moneypings funds, and a
payment that has reached a wallet is settled by that fact alone — so
status: "confirmed" plus a matching statement line is the whole of what
you need to agree with us.
Then compare against GET /api/partner/webhooks/deliveries for the same
window. A collection we hold that produced no delivery you processed is exactly the
case worth catching.
How we store it
Four collections carry the money. Knowing their shape makes the payloads above obvious and tells you what we can answer questions about later.
virtualwallets — one customer's balance
| Field | What it is |
|---|---|
wallet_reference | Ours. WAL- plus 12 hex characters. Stable for the life of the wallet. |
external_reference | Yours. Unique per customer, per currency, within your account. |
balance_minor | Integer kobo. A cache of the ledger sum — see below. |
currency | NGN today. |
status | active, blocked or closed. |
holder_name / email / phone | Descriptive only. We hold no identity documents for your customers. |
virtual_account | The bank identity, when there is one. source tells you where the number came from: stock, api, manual or pending. |
totals | Running counters — credited, debited, fees, charges, entry count. |
walletentries — the ledger, append-only
Nothing here is ever edited or deleted. A mistake is corrected by posting the opposite
entry, which is why reversal and adjustment are entry types.
The database itself refuses an update or a delete on this collection — it is not
a convention, it is enforced.
| Field | What it is |
|---|---|
direction | credit or debit. |
type | collection, payout, fee, charge, adjustment, reversal, transfer. |
amount_minor | Integer kobo, always positive. Direction carries the sign. |
balance_after_minor | The balance immediately after this entry. |
sequence | 1, 2, 3… per wallet, assigned by the database. Gapless. |
reference | Unique across the whole system. This is the idempotency key. |
source | What caused it — kind, and our reference for the payment behind it. |
collections — one payment that arrived
The financial record of an inbound credit, kept whether or not any webhook was ever delivered. Unique on the rail and its reference for the payment, which is what makes a redelivered bank notification collapse onto one record instead of paying twice.
status moves received → confirmed. They are
deliberately different things: received means the bank says a credit landed,
which is fast and occasionally reversed; confirmed means we hold cleared value.
Only the second is money to act on.
collectionaccounts — the account money was paid into
The mapping from an account number back to a wallet. purpose is
topup for the one-off accounts described in step 3, or
permanent for a number a customer keeps. expires_at is set on
the one-off kind.
What you should store
You do not need to mirror us. You need enough to answer three questions without calling the API: whose money is this, have I already processed this, and does my total agree with theirs.
Against each customer
moneypings_wallet_reference— theWAL-…value. Store it even though lookups accept your own reference; it makes support conversations one step shorter.balance_minoras a signed 64-bit integer, pluscurrency. Never a float, never a decimal string you later parse.wallet_status— so you can stop a withdrawal against a blocked wallet without a round trip.
A ledger table of your own
entry_referencefrom ourentry.reference, with a unique index. This single constraint is what makes a repeated webhook harmless.sequence,direction,type,amount_minor,balance_after_minor,created_at.- Rebuild a customer's balance from this table on demand. If it ever disagrees with the balance you cached, your ledger is right — the same rule we follow.
A record of every delivery
webhook_id(thewhk_…value), with a unique index, plus the raw body and when you processed it.- Keeping the raw body costs almost nothing and is the difference between diagnosing a dispute and guessing at one.
Pay-in attempts
- The
referencewe return (TOP-…), the account number, the expected amount andexpires_at. - Mark the row expired when the time passes. Never show a lapsed account number to a customer again.
The one index that matters most
If you take one thing from this page: put a unique index on the ledger entry reference. Every duplicate-payment bug in this class of system is the same bug, and that constraint is what makes it impossible rather than unlikely.
Rules that protect the money
A blocked wallet still accepts money in
It refuses money out. This looks inconsistent and is deliberate: money arriving at a bank account cannot be refused, so a wallet that rejected inbound value would simply lose it. Blocking stops the holder moving money; it does not stop the bank.
The balance is a cache; the ledger is the truth
balance_minor must always equal the sum of the entries, and a test asserts
it. If the two ever disagree, the entries are right. Build your side the same way and
the two systems stay comparable.
A balance can never go below zero
Enforced by the database on the same atomic update that moves the balance, not by a check that can be raced.
Every movement has a unique reference
Derived from whatever caused it, not randomly generated. A redelivered notification or a double-clicked button collides on a unique index instead of paying twice.
A payment we cannot place is never dropped
If a credit arrives that we cannot match to an account, it is recorded in full — with the untouched bank payload and every identifier we tried — and raised loudly. The money has still arrived, and a record we can investigate later beats a log line nobody reads.
Errors
Every response carries code and message. The message on a
400 is written to be shown to whoever made the call.
| Code | Means | What to do |
|---|---|---|
| 400 | Something in your request is wrong — a missing amount, a closed wallet, an unsupported currency. | Read message. Do not retry unchanged. |
| 401 | Missing or invalid X-API-Key. | Check the header name and the whole key. |
| 403 | Your key is suspended, your IP is not allowed, or that service is not enabled for you. | Contact us. Retrying will not help. |
| 404 | No such wallet, collection or delivery that belongs to you. | Check the reference. See the note below. |
| 429 | Over your hourly request limit. | Back off. retry_after_seconds tells you how long. |
| 500 | Our fault. | Retry with backoff, then tell us the time and the reference. |
Why 404 and not 403
Every wallet route is scoped to your account before the lookup runs. A reference
belonging to someone else returns 404, identical to one that does not
exist — so the API cannot be used to discover whether another partner's
reference is real. A reference in a URL is a request, never proof of ownership.
Endpoint reference
All paths are relative to https://moneypings.com/api/partner.
| Endpoint | Does | Needs |
|---|---|---|
GET /health | Liveness | — |
GET /services | What your key can do | any key |
POST /wallets | Open one wallet, or up to 500 | create_wallets |
POST /wallets/{ref}/pay-in | Issue an account to be paid into | create_wallets |
GET /wallets | List your wallets | check_balances |
GET /wallets/{ref} | One wallet, by either reference | check_balances |
GET /wallets/{ref}/entries | The statement | check_balances |
GET /wallet-balances | Held total, by currency | check_balances |
GET /collections | Payments that arrived | collections |
GET /collections/{ref} | One payment, by our or the bank's reference | collections |
GET /collection-accounts | Accounts issued to you | collections |
GET /webhooks/deliveries | What we tried to send you | webhooks |
POST /webhooks/replay/{id} | Re-send one delivery | webhooks |
POST /webhooks/replay | Re-send everything failed since a time | webhooks |
POST /webhooks/test | Send yourself a test event now | webhooks |
Your test checklist
Work down this list against the live server. Every step works today. Step 5 proves your endpoint and your signature check without any money; step 6 is the only one that moves money.
| # | Do this | Expect |
|---|---|---|
| 1 | GET /services | 200 naming your company and your services |
| 2 | POST /wallets with a test reference | 201 and a WAL- reference |
| 3 | The same call again, unchanged | 200, same reference, no second wallet |
| 4 | POST /wallets/{ref}/pay-in for NGN 100 | 201 with an account number and expires_at |
| 5 | POST /webhooks/test | delivered: true, signed: true, and your endpoint accepted the signature |
| 6 | Pay the step 4 amount from a Nigerian bank app | Three webhooks within seconds |
| 7 | Verify the signature on each | Your HMAC matches the header |
| 8 | GET /wallets/{ref} | balance_minor: 10000 |
| 9 | GET /wallets/{ref}/entries | One entry, sequence: 1, credit, 10000 |
| 10 | GET /wallet-balances | NGN total matching your own sum |
| 11 | POST /webhooks/replay/{id} for a delivery you already processed | Your duplicate check ignores it, balance unchanged |
Test with real, small money
There is no sandbox for the Nigerian rail. The proving run behind this document used NGN 100 and NGN 400 of real money, and that is the right way to do it: a test environment cannot prove a bank will do what you expect. Use small amounts and keep the references.
Open item on our side: connect a bank that issues permanent account numbers.
Every example on this page was captured from a real run against the real API on
8 and 11 September 2026 — the requests, the responses and the
webhook bodies are real, with the company name and the customer references replaced by
placeholders, and the test event's target_url, which pointed at our own
test endpoint. Questions to your usual FA Global contact.