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`,
SendOtp: `${BASE_API_ENDPOINT}/auth/send-otp`,
VerifyOtp: `${BASE_API_ENDPOINT}/auth/verify-otp`,
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. Phone 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": {
"customerId": "3f2a1b4c-..."
}
}
Save
customerId- you need it in step 3.
POST /api/v1/auth/send-otp
Body
{
"phone": "08012345678"
}
An OTP is sent to the phone via Termii SMS. OTP expires after 10 minutes.
Success response 200
{
"status": "success",
"result": {
"pinId": "c8dcd048-..."
}
}
Save
pinId- you need it in step 3.
POST /api/v1/auth/verify-otp
Body
{
"customerId": "3f2a1b4c-...",
"pinId": "c8dcd048-...",
"pin": "481923"
}
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",
"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 defines a recurring automatic transfer from the customer's MonieRemit account to their personal bank account.
POST /api/v1/disbursements/plan
Calling this again updates the existing plan (upsert). The plan is activated immediately.
Body
{
"amount": 250000,
"frequency": "monthly",
"nextDueAt": "2025-06-01T00:00:00.000Z",
"withdrawalAccountNumber": "0987654321",
"withdrawalBankCode": "058"
}
| Field | Type | Required | Notes |
|---|---|---|---|
amount |
integer | yes | In kobo |
frequency |
string | yes | "weekly" or "monthly" |
nextDueAt |
string | yes | ISO 8601 datetime of first/next execution |
withdrawalAccountNumber |
string | yes | Destination account |
withdrawalBankCode |
string | yes | CBN bank code, e.g. "058" for GTBank |
Success response 200
{
"status": "success",
"result": {
"id": "abc123-...",
"customerId": "3f2a1b4c-...",
"amount": "250000",
"frequency": "monthly",
"nextDueAt": "2025-06-01T00:00:00.000Z",
"withdrawalAccountNumber": "0987654321",
"withdrawalBankCode": "058",
"active": true
}
}
amountis returned as a string.
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
Success response 200
{
"status": "success",
"result": [
{
"id": "log-id-...",
"customerId": "3f2a1b4c-...",
"planId": "abc123-...",
"amountKobo": "250000",
"status": "confirmed",
"debitReference": "ADAE1K8F3Q",
"transferReference": "ADAE1K8F5R",
"failureReason": null,
"attemptCount": 1,
"createdAt": "2025-06-01T00:00:05.000Z",
"updatedAt": "2025-06-01T00:00:08.000Z"
}
]
}
| Status | Meaning |
|---|---|
pending |
In progress or queued |
confirmed |
Successfully transferred to destination account |
failed |
Failed after 3 attempts, see failureReason |
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 - OTP confirmedactive - Qore account createdsuspended - frozen by adminkycStatus
pending - initialbvn_verified - BVN step passedfully_verified - reserved (NIN, not yet active)DisbursementPlan frequency
weeklymonthlyDisbursementLog / MonieTransaction status
pendingconfirmedfailed| 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 customerId
2. OTP screen
POST /auth/send-otp -> save pinId
POST /auth/verify-otp -> 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
POST /disbursements/plan
GET /disbursements/plan (to show current plan)
8. Disbursement history screen
GET /disbursements/history
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.