| Base URL | {HOST}/api/dev |
|---|---|
| Auth | x-api-key header and a whitelisted client IP |
| Content-Type | application/json |
Getting access
Do this once from the business dashboard (JWT, ADMIN/STAFF, permission business-all) before any /dev call will succeed.
/api/auth/api-keysThe full secret is returned once. The frontend should prompt the user to copy it immediately. Later GET calls only return keyPrefix metadata.
{
"name": "Production server"
}{
"statusCode": 200,
"message": "API key generated successfully",
"data": {
"id": "c1d2e3f4-5678-90ab-cdef-1234567890ab",
"name": "Production server",
"keyPrefix": "loma_key_3f2a1c…",
"isActive": true,
"apiKey": "loma_key_3f2a1c9b8e7d6c5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1",
"replaced": false
}
}Store data.apiKey in your server secrets. Never ship it in a browser or mobile app.
Developer API requests are rejected unless the caller IP is on this list. Max 5 IPs.
/api/auth/api-keys/ip-whitelist{
"ipAddress": "203.0.113.10",
"label": "Production server"
}The IP is taken from cf-connecting-ip (Cloudflare), then x-forwarded-for, then the socket address. Whitelist the public IP your server egresses from.
/api/auth/api-keys/ip-whitelistLists entries.
/api/auth/api-keys/ip-whitelist?id=<id>Removes one.
If the key is valid but the IP is not listed:
{
"statusCode": 403,
"message": "Request IP is not whitelisted for this Developer API key. Add this IP from the dashboard."
}Missing or invalid key:
{
"statusCode": 401,
"message": "API key is missing"
}or "Invalid API key" / "API key has expired".
/api/auth/webhook-url{
"url": "https://your-app.com/webhooks/loma"
}Loma POSTs JSON events to this URL when wallets are credited or bank-transfer status changes. See section 4.
Response envelope
{
"statusCode": 200,
"message": "success",
"data": {}
}Offset lists also include meta (page, take, itemCount, pageCount, hasPreviousPage, hasNextPage).
| Status | Meaning |
|---|---|
| 400 | Validation |
| 401 | API key |
| 403 | IP not whitelisted |
| 404 | Not found |
| 409 | Duplicate customerReference |
| 422 | KYC / insufficient balance |
Endpoints
Every route below requires x-api-key and a whitelisted IP.
Creates a CUSTOMER_SUBACCOUNT with walletType: DYNAMIC. Incoming credits to this account settle to the business main wallet (minus fee) and fire dynamic_wallet.* webhooks.
/api/dev/customer/dynamic| Field | Required |
|---|---|
| firstName | yes |
| lastName | yes |
| yes — unique per business + wallet type | |
| bvn | no |
| nin | no |
| dateOfBirth | no (YYYY-MM-DD) |
| address | no |
| phoneNumber | no |
| city | no |
| state | no |
{
"firstName": "Chidi",
"lastName": "Eze",
"email": "chidi@example.com",
"bvn": "22198765432",
"nin": "98765432109",
"dateOfBirth": "1992-06-20",
"address": "14 Allen Avenue, Ikeja",
"phoneNumber": "08012345678",
"city": "Ikeja",
"state": "Lagos"
}{
"statusCode": 200,
"message": "Customer created and dynamic wallet assigned successfully",
"data": {
"accountName": "CHIDI EZE",
"accountNumber": "1108765432",
"walletType": "DYNAMIC",
"email": "chidi@example.com"
}
}Give accountNumber to the customer as their pay-in account.
Takes the same body as 3.1. The wallet stays on the customer account (STATIC), and credits fire static_wallet.credit.
/api/dev/customer/static| Field | Required |
|---|---|
| firstName | yes |
| lastName | yes |
| yes — unique per business + wallet type | |
| bvn | no |
| nin | no |
| dateOfBirth | no (YYYY-MM-DD) |
| address | no |
| phoneNumber | no |
| city | no |
| state | no |
{
"firstName": "Chidi",
"lastName": "Eze",
"email": "chidi@example.com",
"bvn": "22198765432",
"nin": "98765432109",
"dateOfBirth": "1992-06-20",
"address": "14 Allen Avenue, Ikeja",
"phoneNumber": "08012345678",
"city": "Ikeja",
"state": "Lagos"
}{
"statusCode": 200,
"message": "Customer created and static wallet assigned successfully",
"data": {
"accountName": "CHIDI EZE",
"accountNumber": "1108765433",
"walletType": "STATIC",
"email": "chidi@example.com"
}
}/api/dev/customer/listsQuery: searchTerm, walletType (STATIC | DYNAMIC), page (default 1), take (default 10), order (ASC | DESC)
/api/dev/customer/lists?page=1&take=10&walletType=DYNAMIC&searchTerm=chidi{
"statusCode": 200,
"message": "success",
"data": [
{
"id": "sa-1111-2222-3333-444455556666",
"accountName": "CHIDI EZE",
"accountNumber": "1108765432",
"accountType": "CUSTOMER_SUBACCOUNT",
"walletType": "DYNAMIC",
"status": "ACTIVE",
"firstName": "Chidi",
"lastName": "Eze",
"email": "chidi@example.com",
"createdAt": "2026-08-20T09:00:00.000Z"
}
],
"meta": {
"page": 1,
"take": 10,
"itemCount": 1,
"pageCount": 1,
"hasPreviousPage": false,
"hasNextPage": false
}
}/api/dev/customer/:id:id is the customer record UUID, not the account number.
{
"statusCode": 200,
"message": "success",
"data": {
"id": "sa-1111-2222-3333-444455556666",
"accountName": "CHIDI EZE",
"accountNumber": "1108765432",
"accountType": "CUSTOMER_SUBACCOUNT",
"walletType": "DYNAMIC",
"status": "ACTIVE",
"email": "chidi@example.com"
}
}404 if the id is not a customer account owned by this business.
Works for main, sub, and customer accounts you own.
/api/dev/accounts/balance{
"accountNumber": "1108765432"
}{
"statusCode": 200,
"message": "success",
"data": {
"bankName": "LomaBank",
"accountName": "CHIDI EZE",
"accountNumber": "1108765432",
"availableBalance": "250000.00",
"bookedBalance": "250000.00"
}
}/api/dev/wallets/deactivate{
"accountNumber": "1108765432",
"reason": "Customer requested freeze"
}reason is optional.
{
"statusCode": 200,
"message": "Wallet deactivated successfully",
"data": {
"accountNumber": "1108765432",
"accountName": "CHIDI EZE",
"status": "INACTIVE"
}
}/api/dev/wallets/activate{
"accountNumber": "1108765432"
}{
"statusCode": 200,
"message": "Wallet activated successfully",
"data": {
"accountNumber": "1108765432",
"accountName": "CHIDI EZE",
"status": "ACTIVE"
}
}/api/dev/wallets/tiertier: TIER_1 · TIER_2 · TIER_3
{
"accountNumber": "1108765432",
"tier": "TIER_3"
}{
"statusCode": 200,
"message": "Wallet tier upgraded successfully",
"data": {
"accountNumber": "1108765432",
"accountName": "CHIDI EZE",
"previousTier": "TIER_1",
"tier": "TIER_3"
}
}Use this before a bank transfer to get sortCode.
/api/dev/banks?search=access&page=1&take=50take max 400.
{
"statusCode": 200,
"message": "success",
"data": [
{
"id": "bank-uuid",
"name": "Access Bank",
"code": "000014"
}
],
"meta": {
"page": 1,
"take": 50,
"itemCount": 1,
"pageCount": 1,
"hasPreviousPage": false,
"hasNextPage": false
}
}code is the NIP sort code you send as sortCode on bank transfers.
Resolve the beneficiary name before POST /transfers/loma-to-bank.
/api/dev/banks/name-enquiry?sortCode=000014&accountNumber=0221234567{
"statusCode": 200,
"message": "success",
"data": {
"accountName": "CHIDI EZE",
"accountNumber": "0221234567"
}
}Resolve a Loma account name before POST /transfers/loma-to-loma.
/api/dev/wallets/name-enquiry?walletNo=1108765432{
"statusCode": 200,
"message": "success",
"data": {
"accountName": "CHIDI EZE",
"accountNumber": "1108765432",
"status": "ACTIVE"
}
}Immediate debit (no dashboard approval OTP). customerReference is your idempotency key — reuse is 409.
/api/dev/transfers/loma-to-loma| Field | Required | Notes |
|---|---|---|
| senderAccount | yes | Your main / sub / customer account number |
| receiverAccount | yes | Destination Loma account |
| amount | yes | Number, greater than 0 |
| customerReference | yes | Unique per business |
| narration | no | — |
{
"senderAccount": "1102345678",
"receiverAccount": "1108765432",
"amount": 25000,
"customerReference": "PAYOUT-20260827-0001",
"narration": "Customer settlement"
}{
"statusCode": 200,
"message": "Transaction successfully completed",
"data": {
"transactionReference": "LMA202608271234",
"lomaTransRef": "TP-REF-8899",
"customerReference": "PAYOUT-20260827-0001",
"amount": 25000,
"senderAccount": "1102345678",
"receiverAccount": "1108765432",
"receiverAccountName": "CHIDI EZE",
"status": "SUCCESS"
}
}Business KYC must be approved. Sender wallet must be ACTIVE.
Immediate (or pending) NIP debit. Status updates also arrive as wallet.bank_transfer webhooks — always validate those (section 5).
/api/dev/transfers/loma-to-bank| Field | Required | Notes |
|---|---|---|
| senderAccountNumber | yes | Your source account |
| beneficiaryAccountNumber | yes | — |
| beneficiaryAccountName | yes | From name enquiry |
| sortCode | yes | From /dev/banks |
| amount | yes | Number, minimum 10 |
| customerReference | yes | Unique per business |
| narration | no | — |
{
"senderAccountNumber": "1102345678",
"beneficiaryAccountNumber": "0221234567",
"beneficiaryAccountName": "Chidi Eze",
"sortCode": "000014",
"amount": 50000,
"customerReference": "VENDOR-20260827-0002",
"narration": "Vendor payout"
}{
"statusCode": 200,
"message": "Transaction successfully completed",
"data": {
"transactionReference": "LMA202608271299",
"lomaTransRef": "NIP-REF-4400",
"customerReference": "VENDOR-20260827-0002",
"amount": 50000,
"senderAccount": "1102345678",
"beneficiaryAccountNumber": "0221234567",
"beneficiaryAccountName": "Chidi Eze",
"beneficiaryBank": "Access Bank",
"sortCode": "000014",
"status": "SUCCESS"
}
}status can be SUCCESS or PENDING. If PENDING, wait for wallet.bank_transfer webhooks and validate each one.
/api/dev/transactions/historyQuery: accountNumber, transStatus (PENDING · AWAITING_APPROVAL · CANCELLED · SUCCESS · FAILED), transType (CREDIT · DEBIT), searchTerm, page, take, order
/api/dev/transactions/history?page=1&take=20&accountNumber=1102345678&transStatus=SUCCESS{
"statusCode": 200,
"message": "success",
"data": [
{
"id": "txn-uuid",
"sourceAccountNo": "0221234567",
"targetAccountNo": "1108765432",
"sourceAccountName": "CHIDI EZE",
"targetAccountName": "CHIDI EZE",
"transferType": "WALLET_TO_BANK",
"amount": "50000",
"total": "50000",
"transStatus": "SUCCESS",
"transType": "CREDIT",
"narration": "account funded",
"TransRef": "LMA202608261234",
"SessionId": "999988887777",
"createdAt": "2026-08-26T11:00:00.000Z"
}
],
"meta": {
"page": 1,
"take": 20,
"itemCount": 1,
"pageCount": 1,
"hasPreviousPage": false,
"hasNextPage": false
}
}Look up a transaction you own by TransRef or SessionId. Use the data.reference (or data.sessionId) from every webhook payload.
/api/dev/transactions/validate{
"reference": "999988887777"
}{
"statusCode": 200,
"message": "Transaction validated successfully",
"data": {
"id": "txn-uuid",
"amount": "50000",
"total": "50000",
"Transfee": "100",
"transStatus": "SUCCESS",
"transType": "CREDIT",
"transferType": "WALLET_TO_BANK",
"TransRef": "LMA202608261234",
"SessionId": "999988887777",
"sourceAccountNo": "0221234567",
"sourceAccountName": "CHIDI EZE",
"targetAccountNo": "1108765432",
"targetAccountName": "CHIDI EZE",
"narration": "account funded",
"createdAt": "2026-08-26T11:00:00.000Z"
}
}404 — Transaction not found for the provided reference. Treat the webhook as untrusted until this succeeds. See section 5 for the required flow.
Webhooks
When a webhook URL is saved, Loma POSTs JSON to it. Your endpoint must respond with HTTP 2xx within 15 seconds. Failed deliveries retry up to 5 times with exponential backoff.
POST https://your-app.com/webhooks/loma
Content-Type: application/json
User-Agent: LomaBank-Business-Webhook/1.0
X-Loma-Event: dynamic_wallet.credited
X-Loma-Reference: 999988887777X-Loma-Event matches payload.event. X-Loma-Reference is the value you pass to /dev/transactions/validate.
| event | When it fires | Typical data.reference |
|---|---|---|
| dynamic_wallet.credited | Loma-to-Loma credit into a dynamic customer wallet (then settled to your main wallet minus fee) | Incoming transfer reference |
| dynamic_wallet.account_funded | External bank (NIP) credit into a dynamic customer wallet | NIP sessionID |
| static_wallet.credit | Credit into a static customer / main / sub account (NIP or Loma-to-Loma) | sessionID (NIP) or transfer reference |
| wallet.bank_transfer | Status update on an outbound wallet → other-bank debit you initiated | Your transfer TransRef / customer reference |
Treat any other event as unknown. Do not credit a customer until validate succeeds.
Loma wallet paid a dynamic customer account. Gross lands on the dynamic wallet, fee is taken, net settles to your main account.
{
"event": "dynamic_wallet.credited",
"data": {
"amount": 10000,
"fee": 50,
"netAmount": 9950,
"reference": "W2W-REF-889900",
"settlementReference": "SETTLE-W2W-889900",
"narration": "Dynamic wallet credit — CHIDI EZE",
"walletType": "DYNAMIC",
"accountNumber": "1108765432",
"accountName": "CHIDI EZE",
"customerFirstName": "Chidi",
"customerLastName": "Eze",
"sourceAccountName": "ADA OKEKE",
"sourceAccountNumber": "1101111222",
"sourceBankSortCode": "090620",
"sourceBankName": "LomaBank",
"status": "SUCCESS",
"timestamp": "2026-08-27T10:15:00.000Z"
}
}Validate with { "reference": "W2W-REF-889900" }.
Someone paid the dynamic account from another Nigerian bank.
{
"event": "dynamic_wallet.account_funded",
"data": {
"amount": 50000,
"fee": 100,
"netAmount": 49900,
"reference": "999988887777",
"settlementReference": "SETTLE-NIP-999988887777",
"narration": "TRF FROM CHIDI EZE",
"walletType": "DYNAMIC",
"accountNumber": "1108765432",
"accountName": "CHIDI EZE",
"customerFirstName": "Chidi",
"customerLastName": "Eze",
"sourceAccountName": "CHIDI EZE",
"sourceAccountNumber": "0221234567",
"sourceBankSortCode": "000014",
"sourceBankName": "Access Bank",
"status": "SUCCESS",
"timestamp": "2026-08-27T10:20:00.000Z"
}
}Validate with { "reference": "999988887777" } (the NIP session id).
Credit stayed on a static / main / sub account (no dynamic settlement sweep).
{
"event": "static_wallet.credit",
"data": {
"amount": "25000",
"reference": "888877776666",
"narration": "account funded",
"walletType": "STATIC",
"accountNumber": "1102345678",
"accountName": "ACME PAYMENTS LIMITED",
"sourceAccountName": "CHIDI EZE",
"sourceAccountNumber": "0221234567",
"sourceBankSortCode": "000014",
"sourceBankName": "Access Bank",
"status": "SUCCESS",
"timestamp": "2026-08-27T10:25:00.000Z"
}
}{
"event": "static_wallet.credit",
"data": {
"amount": "15000",
"reference": "W2W-IN-554433",
"narration": "wallet credit",
"walletType": "STATIC",
"accountNumber": "1102345678",
"accountName": "ACME PAYMENTS LIMITED",
"sourceAccountName": "Ngozi Ade",
"sourceAccountNumber": "1109988776",
"sourceBankName": "LomaBank",
"status": "SUCCESS",
"timestamp": "2026-08-27T10:30:00.000Z"
}
}Validate with { "reference": "<data.reference>" }.
Status of a bank payout you started via /dev/transfers/loma-to-bank (or the dashboard). You can receive more than one event as the NIP status moves (PENDING → SUCCESS / FAILED).
{
"event": "wallet.bank_transfer",
"data": {
"amount": "50000",
"fee": "25",
"total": "50025",
"reference": "VENDOR-20260827-0002",
"sessionId": "NIP-SESSION-4400",
"narration": "Vendor payout",
"walletType": "STATIC",
"sourceAccountNumber": "1102345678",
"sourceAccountName": "ACME PAYMENTS LIMITED",
"targetAccountNumber": "0221234567",
"targetAccountName": "Chidi Eze",
"targetBankName": "Access Bank",
"status": "SUCCESS",
"transferType": "WALLET_TO_BANK",
"timestamp": "2026-08-27T10:40:00.000Z"
}
}Validate with { "reference": "VENDOR-20260827-0002" } or { "reference": "NIP-SESSION-4400" }. Only mark the payout complete when validate returns transStatus: "SUCCESS".
Always validate webhook transactions
A webhook POST is a notification, not proof. Replay, delay, or a forged body must not credit a customer or close a payout.
Required flow for every event in section 4
- 1Return
200quickly so Loma does not retry unnecessarily. Process asynchronously if needed. - 2Read
eventanddata.reference(for bank payouts also trydata.sessionId). - 3Call
POST /api/dev/transactions/validatewith that reference and yourx-api-key. - 4If validate is
404or the amounts / accounts do not match the webhook body, ignore the event. - 5If validate is
200, use the validatedata(amount,transStatus, accounts) as source of truth — not the raw webhook fields. - 6Store
TransRef/SessionIdso the same event is not applied twice.
Webhook received
→ POST /api/dev/transactions/validate { "reference": payload.data.reference }
→ 200 + transStatus SUCCESS → apply credit / close payout
→ 404 or mismatch → do nothing, log for reviewExample after a dynamic_wallet.account_funded webhook:
POST /api/dev/transactions/validate
x-api-key: loma_key_...
Content-Type: application/json
{ "reference": "999988887777" }Only then mark invoice INV-1001 paid for data.amount on data.targetAccountNo.
Do not trust data.status from the webhook alone for bank payouts. Wait for validate to show SUCCESS (or FAILED).
Typical integration sequences
- 1
POST /dev/customer/dynamic→ showaccountNumberto the payer. - 2Listen for
dynamic_wallet.creditedordynamic_wallet.account_funded. - 3Validate
data.reference. - 4Fulfil the order using validate
amount/transStatus.
- 1Share your static or main
accountNumber. - 2Listen for
static_wallet.credit. - 3Validate, then fulfil.
- 1
GET /dev/wallets/name-enquiry?walletNo=... - 2
POST /dev/transfers/loma-to-lomawith a uniquecustomerReference. - 3Treat
data.status === "SUCCESS"as complete. Optional: validatedata.transactionReference.
Endpoint index
| Method | Path | Use |
|---|---|---|
| POST | /api/dev/customer/dynamic | Create dynamic pay-in account |
| POST | /api/dev/customer/static | Create static customer account |
| GET | /api/dev/customer/lists | List customer accounts |
| GET | /api/dev/customer/:id | Get one customer account |
| POST | /api/dev/accounts/balance | Balance |
| PATCH | /api/dev/wallets/deactivate | Freeze |
| PATCH | /api/dev/wallets/activate | Unfreeze |
| PATCH | /api/dev/wallets/tier | Upgrade tier |
| GET | /api/dev/banks | Bank list |
| GET | /api/dev/banks/name-enquiry | NIP name enquiry |
| GET | /api/dev/wallets/name-enquiry | Loma name enquiry |
| POST | /api/dev/transfers/loma-to-loma | Wallet transfer |
| POST | /api/dev/transfers/loma-to-bank | Bank transfer |
| GET | /api/dev/transactions/history | History |
| POST | /api/dev/transactions/validate | Confirm a webhook transaction |
Setup (dashboard, JWT): POST/GET/DELETE /api/auth/api-keys, POST/GET/DELETE /api/auth/api-keys/ip-whitelist, POST/GET /api/auth/webhook-url.