MonieRemit API Guide

For frontend and mobile developers. This document covers every active endpoint, the exact flows to implement, and what to expect in every response.


Base URL

https://monieremit-api.up.railway.app

All endpoints are prefixed with /api/v1.


Response Format

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 failureerror 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 amountKobo or amount comes 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).


Authentication

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).


Endpoint Constants

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`,
  },
}

Registration Flow

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.

Step 1 - Register

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.


Step 2 - Send Email OTP

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/send endpoint exists (real SMS via Termii, sets phoneVerified) 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.


Step 3 - Verify Email OTP

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 accessToken in the Authorization header for step 4 and all subsequent requests.


Step 4 - Verify BVN

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.


Login

POST /api/v1/auth/login

Body

{
  "phone": "08012345678",
  "password": "securepassword"
}

Success response 200

{
  "status": "success",
  "result": {
    "accessToken": "eyJhbGci...",
    "refreshToken": "eyJhbGci..."
  }
}

Refresh Token

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.


Customer Endpoints

All endpoints below require Authorization: Bearer <accessToken>.


Create Bank Account

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 My Profile

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
  }
}

balance is in kobo (NGN 5,000.00 in this example). It is a live poll from Qore and is undefined if the account has not been created yet.


Get Balance

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 Transactions

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": [ ... ]
}

Disbursement Endpoints

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.


Preview Plan Fees

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.


Create or Update Plan

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) and totalAmountKobo are returned as strings.


Get Plan

GET /api/v1/disbursements/plan

Success response 200 - same shape as the create response.

Error 404 if no plan has been created yet.


Get Disbursement History

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

Borrow (advance next period's payout)

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).


Extra Cash (early vault withdrawal)

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.


Admin Endpoints

These endpoints have no auth guard in the current build - add an admin auth middleware before shipping to production.


List All Customers

GET /api/v1/admin/customers

Returns all customers. bvnHash and passwordHash are omitted.


Freeze Customer

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 } }

Unfreeze Customer

POST /api/v1/admin/customers/:id/unfreeze

Unfreezes the Qore account and sets customerStatus back to active.


List All Disbursements

GET /api/v1/admin/disbursements

All disbursement logs across all customers, newest first.


Failed Jobs

GET /api/v1/admin/jobs/failed

All disbursement logs with status: "failed". Use this as your ops escalation queue.


Enum Reference

These are the exact string values returned and accepted by the API.

customerStatus

kycStatus

DisbursementPlan frequency

Transaction status

There is no separate disbursement log table. Every money movement — deposits, manual transfers, scheduled disbursements, Borrow, Extra Cash — is a row in a single Transaction model, distinguished by type (credit/debit/transfer) and purpose (deposit/manual_transfer/disbursement/early_withdrawal).


Common Errors

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

Typical User Journey (Mobile)

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

Interactive Docs

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.