⚡ Partner API

moneypings wallets for your platform

🏢 FA Global · moneypings · Partner integration 📅 Updated 5 October 2026 🔑 X-API-Key authentication

Open a moneypings balance for each of your customers, give them a bank account to be paid into, and get told the moment money lands. This page is everything you need to build against it: the calls, the exact payloads, the webhooks you will receive, and how we store it — so you can shape your own database to match.

Base URL
https://moneypings.com/api/partner
Auth
X-API-Key header
Money
NGN, integer kobo
Document
Updated 5 October 2026
Straight answer first

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

Live

Proven 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 onboarding

Your 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

Live

Wallets, ledger entries, collections and held totals are all callable now with your key.

Pay-in accounts over the API

Live

Step 3 below. Deployed 8 September 2026 and callable now — every endpoint on this page works against the live server today.

Webhooks to you

At onboarding

Give 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 yet

Read this one before you design anything. Today's bank rail issues accounts that expire in about five hours — see step 3.

The shape of it

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.

The path one payment takes You open a wallet and ask for a pay-in account. The payer sends money to that account at the bank. The bank tells moneypings, moneypings credits the wallet and sends you a webhook. You your app moneypings wallet + ledger Nigerian bank NIP transfer your customer pays 1 open wallet 2 mint account 3 pays the exact amount 4 signed notice 5 webhook to you balance += 50000 kobo
Steps 1 and 2 are your calls. Steps 4 and 5 happen without you asking. Between 3 and 5 is usually a few seconds.

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.

Groundwork

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.

Every request
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.

ServiceLets you
create_walletsOpen wallets, and ask for a pay-in account
check_balancesRead a wallet, its statement, and your held total
collectionsList payments that arrived, for reconciliation
webhooksReceive 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.


1

Confirm your key works

Start here. This call needs no setup and tells you what your key is allowed to do.

GET/api/partner/services
curl https://moneypings.com/api/partner/services \
  -H "X-API-Key: $MONEYPINGS_KEY"
200 — response
{
  "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.

2

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.

POST/api/partner/wallets
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"
  }'
201 — response
{
  "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.

POST/api/partner/wallets · bulk
{
  "wallets": [
    { "external_reference": "CUST-000124", "holder_name": "Chidi Nwosu" },
    { "external_reference": "CUST-000125", "holder_name": "Bisi Adeyemi" }
  ]
}
200 — response
{
  "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.

3

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.

POST/api/partner/wallets/{reference}/pay-in
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"
  }'
201 — response
{
  "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

FieldRequiredNotes
amountone of the twoMajor units as a string, e.g. "500.00"
amount_minorone of the twoInteger kobo, e.g. 50000. Preferred.
emailconditionalThe 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.

4

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

wallet.createdA wallet was opened.
collection.receivedA payment arrived. The bank says so; it is not yet money to act on.
wallet.creditedThe balance went up. This is the one to act on.
collection.confirmedThe payment is settled and final.
collection.reversedThe bank undid a payment. We take the credit back out.
wallet.debitedThe balance went down — a fee, a charge or a payout.
wallet.blockedThe wallet was put on hold. It still accepts money in.
wallet.activatedA hold was lifted.
wallet.closedThe wallet was closed. Only possible at a zero balance.
wallet.updatedDescriptive details changed.
webhook.testOnly when you ask for one with 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.

POSTyour endpoint · headers
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
body · wallet.credited
{
  "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"
    }
  }
}
body · collection.confirmed
{
  "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.

Node.js · Express
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);
});
Python · Flask
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 2xx to 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 failed instead, 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 (the whk_… 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.

GET/api/partner/webhooks/deliveries?status=failed
{
  "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
  }
}
POST/api/partner/webhooks/replay
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.

POST/api/partner/webhooks/test
curl -X POST https://moneypings.com/api/partner/webhooks/test \
  -H "X-API-Key: $MONEYPINGS_KEY"
200 — response
{
  "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
  }
}
what your endpoint receives · webhook.test
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.

5

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.

GET/api/partner/wallets/CUST-000123
curl https://moneypings.com/api/partner/wallets/CUST-000123 \
  -H "X-API-Key: $MONEYPINGS_KEY"
200 — response (abridged)
{
  "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=.

GET/api/partner/wallets/{reference}/entries
curl "https://moneypings.com/api/partner/wallets/CUST-000123/entries?limit=50" \
  -H "X-API-Key: $MONEYPINGS_KEY"
200 — response
{
  "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

GET/api/partner/wallet-balances
curl https://moneypings.com/api/partner/wallet-balances \
  -H "X-API-Key: $MONEYPINGS_KEY"
200 — response
{
  "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.

Operations

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.

GET/api/partner/collections
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"
200 — one collection
{
  "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

ParameterMeaning
statusreceived, confirmed, reversed or failed
merchant_referenceNarrow to one pay-in reference
from / toISO 8601 timestamps, on received_at
limit / skipPaging. 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.

Our side

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

FieldWhat it is
wallet_referenceOurs. WAL- plus 12 hex characters. Stable for the life of the wallet.
external_referenceYours. Unique per customer, per currency, within your account.
balance_minorInteger kobo. A cache of the ledger sum — see below.
currencyNGN today.
statusactive, blocked or closed.
holder_name / email / phoneDescriptive only. We hold no identity documents for your customers.
virtual_accountThe bank identity, when there is one. source tells you where the number came from: stock, api, manual or pending.
totalsRunning 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.

FieldWhat it is
directioncredit or debit.
typecollection, payout, fee, charge, adjustment, reversal, transfer.
amount_minorInteger kobo, always positive. Direction carries the sign.
balance_after_minorThe balance immediately after this entry.
sequence1, 2, 3… per wallet, assigned by the database. Gapless.
referenceUnique across the whole system. This is the idempotency key.
sourceWhat 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.

Your side

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 — the WAL-… value. Store it even though lookups accept your own reference; it makes support conversations one step shorter.
  • balance_minor as a signed 64-bit integer, plus currency. 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_reference from our entry.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 (the whk_… 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 reference we return (TOP-…), the account number, the expected amount and expires_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.

Design decisions you will meet

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.

Reference

Errors

Every response carries code and message. The message on a 400 is written to be shown to whoever made the call.

CodeMeansWhat to do
400Something in your request is wrong — a missing amount, a closed wallet, an unsupported currency.Read message. Do not retry unchanged.
401Missing or invalid X-API-Key.Check the header name and the whole key.
403Your key is suspended, your IP is not allowed, or that service is not enabled for you.Contact us. Retrying will not help.
404No such wallet, collection or delivery that belongs to you.Check the reference. See the note below.
429Over your hourly request limit.Back off. retry_after_seconds tells you how long.
500Our 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.

EndpointDoesNeeds
GET /healthLiveness—
GET /servicesWhat your key can doany key
POST /walletsOpen one wallet, or up to 500create_wallets
POST /wallets/{ref}/pay-inIssue an account to be paid intocreate_wallets
GET /walletsList your walletscheck_balances
GET /wallets/{ref}One wallet, by either referencecheck_balances
GET /wallets/{ref}/entriesThe statementcheck_balances
GET /wallet-balancesHeld total, by currencycheck_balances
GET /collectionsPayments that arrivedcollections
GET /collections/{ref}One payment, by our or the bank's referencecollections
GET /collection-accountsAccounts issued to youcollections
GET /webhooks/deliveriesWhat we tried to send youwebhooks
POST /webhooks/replay/{id}Re-send one deliverywebhooks
POST /webhooks/replayRe-send everything failed since a timewebhooks
POST /webhooks/testSend yourself a test event nowwebhooks

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 thisExpect
1GET /services200 naming your company and your services
2POST /wallets with a test reference201 and a WAL- reference
3The same call again, unchanged200, same reference, no second wallet
4POST /wallets/{ref}/pay-in for NGN 100201 with an account number and expires_at
5POST /webhooks/testdelivered: true, signed: true, and your endpoint accepted the signature
6Pay the step 4 amount from a Nigerian bank appThree webhooks within seconds
7Verify the signature on eachYour HMAC matches the header
8GET /wallets/{ref}balance_minor: 10000
9GET /wallets/{ref}/entriesOne entry, sequence: 1, credit, 10000
10GET /wallet-balancesNGN total matching your own sum
11POST /webhooks/replay/{id} for a delivery you already processedYour 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.