For frontend and mobile developers. This document covers every active endpoint, the exact flows to implement, and what to expect in every response.
https://monieremit-api.up.railway.app
All endpoints are prefixed with /api/v1.
Every response shares the same envelope, regardless of success or failure.
Success
{
"apiObject": "Auth",
"code": 200,
"status": "success",
"message": "Success.",
"result": { ... }
}
Failure
{
"apiObject": "Auth",
"code": 400,
"status": "failure",
"message": "Human-readable error description",
"error": { ... },
"result": {}
}
Validation failure — error contains Zod's flattened field errors:
{
"apiObject": "Auth",
"code": 400,
"status": "failure",
"message": "Validation error",
"error": {
"fieldErrors": { "phone": ["Invalid phone number"] },
"formErrors": []
},
"result": {}
}
| Field | Always present | Notes |
|---|---|---|
apiObject |
yes | Identifies the resource type — Auth, Customer, Disbursement, Admin, etc. |
code |
yes | Mirrors the HTTP status code |
status |
yes | "success" or "failure" |
message |
yes | Human-readable description. Defaults to "Success." on success. |
result |
yes | Payload on success, {} on failure |
error |
failure only | Additional error detail (e.g. field-level validation errors) |
BigInt note: Any field named
amountKobooramountcomes back as a string (e.g."250000"), not a number. Parse it as needed on the client. All amounts are in kobo (1 NGN = 100 kobo).
Protected endpoints require a Bearer token in the Authorization header:
Authorization: Bearer <accessToken>
Access tokens expire after 15 minutes. When they expire, use the refresh token to get a new pair (see Refresh Token).
Suggested mapping to keep endpoint strings out of your components. Set BASE_API_ENDPOINT from your environment config and import API_ENDPOINTS wherever you make requests.
export const BASE_API_ENDPOINT = "https://monieremit-api.up.railway.app/api/v1"
// Routes that share the same URL (GET + POST, or GET + PUT + DELETE) use a single
// entry named after the resource — not the verb. For /:id patterns use ById.
export const API_ENDPOINTS = {
Auth: {
Register: `${BASE_API_ENDPOINT}/auth/register`,
SendEmailOtp: `${BASE_API_ENDPOINT}/auth/otp/email/send`,
VerifyEmailOtp: `${BASE_API_ENDPOINT}/auth/otp/email/verify`,
SendPhoneOtp: `${BASE_API_ENDPOINT}/auth/otp/phone/send`,
VerifyPhoneOtp: `${BASE_API_ENDPOINT}/auth/otp/phone/verify`,
VerifyBvn: `${BASE_API_ENDPOINT}/auth/verify-bvn`,
Login: `${BASE_API_ENDPOINT}/auth/login`,
RefreshToken: `${BASE_API_ENDPOINT}/auth/refresh-token`,
},
Customer: {
CreateAccount: `${BASE_API_ENDPOINT}/customers/create-account`,
Me: `${BASE_API_ENDPOINT}/customers/me`,
Balance: `${BASE_API_ENDPOINT}/customers/balance`,
Transactions: `${BASE_API_ENDPOINT}/customers/transactions`,
},
Disbursement: {
Plan: `${BASE_API_ENDPOINT}/disbursements/plan`, // POST (upsert) + GET
History: `${BASE_API_ENDPOINT}/disbursements/history`,
},
Admin: {
Customers: `${BASE_API_ENDPOINT}/admin/customers`,
CustomerById: (id: string) => `${BASE_API_ENDPOINT}/admin/customers/${id}`,
FreezeCustomer: (id: string) => `${BASE_API_ENDPOINT}/admin/customers/${id}/freeze`,
UnfreezeCustomer: (id: string) => `${BASE_API_ENDPOINT}/admin/customers/${id}/unfreeze`,
Disbursements: `${BASE_API_ENDPOINT}/admin/disbursements`,
FailedJobs: `${BASE_API_ENDPOINT}/admin/jobs/failed`,
},
}
New users go through a 4-step pipeline. Email ownership (OTP) is confirmed before any KYC (BVN) lookup is triggered. Step 4 requires the access token issued in step 3.
POST /api/v1/auth/register
Body
{
"firstName": "Adaeze",
"lastName": "Okafor",
"phone": "08012345678",
"password": "securepassword",
"dateOfBirth": "1995-06-20",
"gender": "female",
"address": "12 Marina Road, Lagos",
"email": "adaeze@example.com",
"otherNames": "Grace"
}
| Field | Type | Required | Notes |
|---|---|---|---|
firstName |
string | yes | |
lastName |
string | yes | |
phone |
string | yes | Nigerian phone number |
password |
string | yes | Min 8 characters |
dateOfBirth |
string | yes | YYYY-MM-DD |
gender |
string | yes | "male" or "female" |
address |
string | yes | Min 5 characters |
email |
string | no | |
otherNames |
string | no |
Success response 201
{
"status": "success",
"result": {
"id": "3f2a1b4c-...",
"firstName": "Adaeze",
"lastName": "Okafor",
"phone": "08012345678",
"email": "adaeze@example.com",
"emailVerified": false,
"phoneVerified": false,
"customer": { "id": "8e7d6c5b-...", "customerStatus": "unverified" }
}
}
Save
id(the accountId) - you need it in step 2.
POST /api/v1/auth/otp/email/send
Body
{
"accountId": "3f2a1b4c-..."
}
An OTP is sent to the account's email address. OTP expires after 10 minutes.
A parallel
POST /api/v1/auth/otp/phone/sendendpoint exists (real SMS via Termii, setsphoneVerified) but isn't part of the active flow yet — only email verification is required today.
Success response 200 — no data to store; nothing is returned to identify this OTP, since verify doesn't need one.
POST /api/v1/auth/otp/email/verify
Body
{
"accountId": "3f2a1b4c-...",
"pin": "481923"
}
No otp id is required — the server matches pin against the most recent unexpired, unused OTP sent to this account's email.
Sets Account.emailVerified = true and Customer.customerStatus = "verified".
Success response 200
{
"status": "success",
"result": {
"accessToken": "eyJhbGci...",
"refreshToken": "eyJhbGci..."
}
}
Store both tokens. Use
accessTokenin theAuthorizationheader for step 4 and all subsequent requests.
POST /api/v1/auth/verify-bvn
Requires Authorization: Bearer <accessToken> from step 3.
No customerId in the body - the server reads it from your token.
Body
{
"bvn": "22345678901"
}
The BVN is looked up against the Qore/NIBSS registry. If the BVN is already linked to another account, this step returns an error.
Success response 200
{
"status": "success",
"result": {
"firstName": "ADAEZE",
"lastName": "OKAFOR",
"phone": "08012345678"
}
}
You can show the returned name on-screen to let the user confirm their identity before proceeding to account creation.
POST /api/v1/auth/login
Body
{
"phone": "08012345678",
"password": "securepassword"
}
Success response 200
{
"status": "success",
"result": {
"accessToken": "eyJhbGci...",
"refreshToken": "eyJhbGci..."
}
}
POST /api/v1/auth/refresh-token
Call this when you get a 401 on any protected endpoint. The old refresh token is invalidated and a new pair is issued.
Body
{
"refreshToken": "eyJhbGci..."
}
Success response 200
{
"status": "success",
"result": {
"accessToken": "eyJhbGci...",
"refreshToken": "eyJhbGci..."
}
}
Update both tokens in storage. The old refresh token is single-use.
All endpoints below require Authorization: Bearer <accessToken>.
POST /api/v1/customers/create-account
Call this once after the user completes registration. It provisions a real MFB-backed account via Qore. The account starts with PND (Post No Debit) active - the user cannot withdraw until their first deposit is confirmed.
No body required.
Success response 201
{
"status": "success",
"result": {
"accountNumber": "0123456789"
}
}
Error 400 if account already exists for this customer.
GET /api/v1/customers/me
Success response 200
{
"status": "success",
"result": {
"id": "3f2a1b4c-...",
"firstName": "Adaeze",
"lastName": "Okafor",
"phone": "08012345678",
"email": "adaeze@example.com",
"emailVerified": true,
"phoneVerified": false,
"qoreAccountNumber": "0123456789",
"customerStatus": "active",
"kycStatus": "bvn_verified",
"pndActive": false,
"createdAt": "2025-05-29T10:00:00.000Z",
"balance": 500000
}
}
balanceis in kobo (NGN 5,000.00 in this example). It is a live poll from Qore and isundefinedif the account has not been created yet.
GET /api/v1/customers/balance
Live poll from Qore. Do not call this on every screen render - cache it client-side and refresh on user action.
Success response 200
{
"status": "success",
"result": {
"balanceKobo": 500000
}
}
GET /api/v1/customers/transactions
Live poll from Qore. Returns raw Qore transaction objects. Field names follow Qore's schema.
Success response 200
{
"status": "success",
"result": [ ... ]
}
All endpoints below require Authorization: Bearer <accessToken>.
A disbursement plan is a fixed-term pot: the customer commits a total amount up front (totalAmountKobo) to be paid out to their own bank account in equal instalments over durationDays, on a daily/weekly/monthly schedule. The per-cycle amount is derived server-side (totalAmountKobo / number of cycles), not supplied by the client.
GET /api/v1/disbursements/plan/quote?amountKobo=250000
Returns { depositFee, disbursementFee, totalFeesKobo } — disbursementFee is the real NIP fee charged each payout cycle; depositFee is an unverified estimate of what the customer's own bank may charge to fund their wallet.
POST /api/v1/disbursements/plan
Calling this again updates the existing plan (upsert). The plan does not activate until the wallet balance covers totalAmountKobo — the customer must fund their wallet first.
Body
{
"totalAmountKobo": 2400000,
"durationDays": 30,
"frequency": "daily",
"withdrawalAccountNumber": "0987654321",
"withdrawalBankCode": "058",
"scheduledTime": "09:00",
"scheduledTimezone": "Africa/Lagos"
}
| Field | Type | Required | Notes |
|---|---|---|---|
totalAmountKobo |
integer | yes | Total pot for the whole plan, in kobo |
durationDays |
integer | yes | Total plan length in days |
frequency |
string | yes | "daily", "weekly", or "monthly" |
weekDay |
string | only if frequency is weekly |
monday..sunday — server computes the next occurrence |
withdrawalAccountNumber |
string | yes | Destination account (the customer's own bank account) |
withdrawalBankCode |
string | yes | CBN bank code, e.g. "058" for GTBank |
withdrawalAccountName |
string | no | Verified automatically via name enquiry if omitted |
scheduledTime |
string | no | HH:MM (24h). Time of day to run |
scheduledTimezone |
string | no | IANA timezone, defaults to UTC |
Updating an existing plan (PATCH /api/v1/disbursements/plan, same body) requires an OTP: call POST /api/v1/customers/otp/request first, then include otpPinId/otpCode.
Success response 200
{
"status": "success",
"result": {
"id": "abc123-...",
"customerId": "3f2a1b4c-...",
"amount": "80000",
"totalAmountKobo": "2400000",
"durationDays": 30,
"frequency": "daily",
"nextDueAt": "2025-06-01T09:00:00.000Z",
"withdrawalAccountNumber": "0987654321",
"withdrawalBankCode": "058",
"withdrawalBlocked": false,
"borrowCount": 0,
"active": true
}
}
amount(the derived per-cycle amount) andtotalAmountKoboare returned as strings.
GET /api/v1/disbursements/plan
Success response 200 - same shape as the create response.
Error 404 if no plan has been created yet.
GET /api/v1/disbursements/history
Returns Transaction rows with purpose: "disbursement" for this customer, newest first — the same model used for deposits, manual transfers, and Extra Cash, just filtered by purpose.
Success response 200
{
"status": "success",
"result": [
{
"id": "9c1f2a3b-...",
"customerId": "3f2a1b4c-...",
"planId": "abc123-...",
"type": "transfer",
"purpose": "disbursement",
"amountKobo": "80000",
"feeKobo": "1075",
"status": "confirmed",
"retrievalReference": "ADAE1K8F3Q",
"transferReference": "ADAE1K8F5R",
"failureReason": null,
"retryCount": 0,
"createdAt": "2025-06-01T09:00:05.000Z",
"updatedAt": "2025-06-01T09:00:08.000Z"
}
]
}
| Status | Meaning |
|---|---|
pending |
In progress, or awaiting reconciliation (polled every 5 min) |
confirmed |
Successfully transferred to destination account |
failed |
Failed after retries, see failureReason. If the wallet was already debited when the transfer failed, it is reversed automatically — see plan_depleted/disbursement_failed notifications |
GET /api/v1/disbursements/plan/borrow/quote
POST /api/v1/disbursements/plan/borrow
Advances the plan's current per-cycle amount right now, no fee beyond the standard NIP fee already included in every disbursement. Doing this skips the next scheduled payout (the schedule advances exactly as a normal successful cycle would).
The quote returns { eligible, amountKobo, reason? }. Ineligible when the plan isn't active, this would be its final period, the customer already borrowed or got paid this period, or the plan has hit its lifetime cap of 3 borrows (reason explains which).
GET /api/v1/disbursements/plan/extra-cash/quote
POST /api/v1/disbursements/plan/extra-cash { "amountKobo": 50000 }
Withdraws from the unused vault balance ahead of schedule, charging a flat 4% early-withdrawal fee on top of the standard NIP fee. The quote returns { vaultBalanceKobo, reservedKobo, maxWithdrawalKobo, feeRate } — reservedKobo covers remaining scheduled payouts plus their NIP fees, and maxWithdrawalKobo is the resulting ceiling (enforced server-side regardless of what the client requests). Rate-limited to 3 per day and 5 per plan lifetime.
These endpoints have no auth guard in the current build - add an admin auth middleware before shipping to production.
GET /api/v1/admin/customers
Returns all customers. bvnHash and passwordHash are omitted.
POST /api/v1/admin/customers/:id/freeze
Freezes the Qore account and sets customerStatus to suspended. The customer cannot log in after this.
Success response 200
{ "status": "success", "result": { "frozen": true } }
POST /api/v1/admin/customers/:id/unfreeze
Unfreezes the Qore account and sets customerStatus back to active.
GET /api/v1/admin/disbursements
All disbursement logs across all customers, newest first.
GET /api/v1/admin/jobs/failed
All disbursement logs with status: "failed". Use this as your ops escalation queue.
These are the exact string values returned and accepted by the API.
customerStatus
unverified - just registeredverified - email OTP confirmed (Account.emailVerified)active - Qore account createdsuspended - frozen by adminkycStatus
pending - initialbvn_verified - BVN step passedfully_verified - reserved (NIN, not yet active)DisbursementPlan frequency
weeklymonthlyTransaction status
pendingconfirmedfailedThere is no separate disbursement log table. Every money movement — deposits, manual transfers, scheduled disbursements, Borrow, Extra Cash — is a row in a single
Transactionmodel, distinguished bytype(credit/debit/transfer) andpurpose(deposit/manual_transfer/disbursement/early_withdrawal).
| Scenario | Status | Message |
|---|---|---|
| Missing or expired access token | 401 |
"Authentication required" |
| Suspended account | 403 |
"Account suspended" |
| BVN already registered | 400 |
"BVN already associated with another account" |
| Expired OTP | 400 |
"OTP expired or invalid" |
| Wrong OTP | 400 |
"Incorrect OTP" |
| Phone already registered | 400 |
"Phone number already registered" |
| No bank account yet | 400 |
"No bank account found" |
| No disbursement plan | 404 |
"No disbursement plan found" |
| Server/Qore error | 500 |
Descriptive message |
1. Registration screen
POST /auth/register -> save id (accountId)
2. OTP screen
POST /auth/otp/email/send -> (nothing to save)
POST /auth/otp/email/verify -> save accessToken + refreshToken
3. BVN screen (now authenticated)
POST /auth/verify-bvn -> show confirmed name to user
4. Home screen
POST /customers/create-account (once, on first login after BVN verified)
GET /customers/me
5. Balance screen
GET /customers/balance
6. Transactions screen
GET /customers/transactions
7. Disbursement setup screen
GET /disbursements/plan/quote (preview fees)
POST /disbursements/plan
GET /disbursements/plan (to show current plan)
8. Disbursement history screen
GET /disbursements/history
9. Borrow / Extra Cash (from Home)
GET /disbursements/plan/borrow/quote or /plan/extra-cash/quote
POST /disbursements/plan/borrow or /plan/extra-cash
The Swagger UI lists every endpoint with full request/response schemas and a live "Try it out" interface — useful for manual testing without a separate HTTP client.
If you're importing the API into Postman or generating a typed client (e.g. with openapi-typescript), grab the OpenAPI JSON spec directly.
This guide is also served as a rendered HTML page at /api/docs/guide, so you can share a link instead of the raw markdown file.