AtomAfrica · Economic Ownership Infrastructure
Build your own ownership app on AtomOwn
The AtomOwn API lets developers and fintechs run a fully branded, white-label economic-ownership network: properties, wallets, unit ownership, a secondary market and payouts: on shared, ledger-backed infrastructure. One REST API, JSON everywhere, opaque bearer tokens.
Every request is scoped to your network (a “developer” tenant). Your customers register against your network, hold units of your listed properties, and move value inside a double-entry ledger that AtomAfrica reconciles. You keep your brand, customers and data; AtomAfrica keeps the money correct.
- Multi-tenant & isolated: data is row-scoped to your developer network; customers never cross networks.
- Ledger-backed: balances are derived from an append-only double-entry ledger, not a mutable number.
- White-label by config: theme, branding and enabled features come from
/app/configat runtime, so your app re-skins with no rebuild.
Quickstart
A minimal integration is four calls. Replace API with your AtomOwn base URL and acme-homes with your network's slug.
- Pull your branding & enabled features from
GET /app/config. - Register or log a customer in: you receive a bearer token.
- Show listings from
GET /public/propertiesand let the customer fund their wallet. - Buy packets with
POST /purchases(PIN-gated).
# 1: branding + feature flags for your network
curl "$API/api/v1/app/config?developer=acme-homes"
# 2: log a customer in (tenant-bound by `developer`)
curl -X POST "$API/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"developer":"acme-homes","email":"ada@example.com","password":"secret123"}'
# → { "ok": true, "data": { "token": "ao_live_9f2c…", "user": { … } } }
# 3: browse listings
curl "$API/api/v1/public/properties?developer=acme-homes"
# 4: buy ₦50,000 of packets (needs the customer's 6-digit PIN)
curl -X POST "$API/api/v1/purchases" \
-H "Authorization: Bearer ao_live_9f2c…" \
-H "Content-Type: application/json" \
-d '{"property_id":12,"amount":50000,"pin":"246813"}'
const API = "https://your-atomown-host";
const dev = "acme-homes";
// 2: login
const { data } = await fetch(`${API}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ developer: dev, email: "ada@example.com", password: "secret123" }),
}).then(r => r.json());
const token = data.token;
// 4: buy packets by amount
const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
const buy = await fetch(`${API}/api/v1/purchases`, {
method: "POST", headers: auth,
body: JSON.stringify({ property_id: 12, amount: 50000, pin: "246813" }),
}).then(r => r.json());
Base URL & versioning
All endpoints live under a single versioned prefix. The version only changes for breaking changes; additive fields are not breaking.
| Base path | /api/v1 |
| Content type | application/json for request bodies and all responses (CSV export is the only exception). |
| Transport | HTTPS only in production. Reject non-TLS traffic. |
Verify connectivity any time with GET /api/v1/ping.
Authentication
Two things identify a request: which network it belongs to (the tenant) and which user is making it.
1 · Tenant binding: the developer key
Your network is identified by its slug (or subdomain). Public endpoints take it as a query parameter; auth endpoints take it in the body. After login it is baked into the token, so authenticated calls don't repeat it.
| Where | How you pass the network |
|---|---|
| Public discovery (config, listings) | ?developer=acme-homes |
| Register / login | "developer": "acme-homes" in the JSON body |
| Authenticated calls | Nothing: the token already carries the tenant & user |
2 · Bearer tokens
A successful register or login returns an opaque token (not a JWT: treat it as a secret string). Send it on every authenticated call:
Authorization: Bearer ao_live_9f2c8b1e…
Tokens are session-scoped and expire (default 24h). A revoked, expired or malformed token returns 401 invalid_token. Call POST /auth/logout to revoke immediately.
3 · The transaction PIN
Every value-moving action (buy, withdraw, transfer, liquidate) requires the customer's 6-digit PIN in the request body, independent of the login password. Set it with POST /pin/set. A wrong PIN returns 401 with a pin_* code and is rate-limited.
Requests & responses
Envelope
Every response is wrapped in a stable envelope. Check ok first.
{
"ok": true,
"data": { "...": "endpoint payload" },
"meta": { "...": "present only on some list endpoints" }
}{
"ok": false,
"error": {
"code": "insufficient_funds",
"message": "Not enough wallet balance.",
"details": { "...": "optional" }
}
}Money
All amounts in JSON are Naira (₦) decimals: e.g. 25000.50. Internally AtomOwn stores integer kobo and derives balances from the ledger, so amounts are always exact. Send amounts as plain numbers (naira); never send kobo.
Pagination
List endpoints that can grow accept ?page (1-based) and ?per (page size, default 25, max 100) and return a pagination object:
"pagination": { "page": 2, "per": 25, "total": 340, "pages": 14 }Headers you'll send
| Header | When |
|---|---|
| Authorization: Bearer … | Every authenticated call |
| Content-Type: application/json | Every request with a body |
| Accept: application/json | Recommended on all calls |
POSTs, wait for the response before retrying, and reconcile with GET /transactions if a call times out.Errors
HTTP status conveys the class; the error.code string is the stable value to branch on. Common codes:
| Status | code | Meaning |
|---|---|---|
| 400 | developer_required | Network slug missing on a public/auth call |
| 401 | invalid_credentials | Wrong email/password |
| 401 | invalid_token | Token missing, expired or revoked |
| 401 | pin_invalid | Wrong transaction PIN |
| 402 | insufficient_funds | Wallet balance too low for the action |
| 403 | customer_only / forbidden | Wrong account type or missing permission |
| 403 | email_not_verified | Account registered but email not verified: call /auth/verify-email |
| 404 | developer_not_found | No active network for that slug |
| 409 | email_taken | Email already registered on this network |
| 422 | validation | Missing/invalid fields (see message) |
| 429 | rate_limited | Too many requests: see Rate limits |
Rate limits
Sensitive endpoints are throttled per IP. When exceeded you get 429 with a Retry-After header (seconds). Back off and retry after that window.
| Endpoint | Limit |
|---|---|
| POST /auth/login | 10 / 60s |
| POST /auth/register | 5 / 60s |
| POST /pin/verify | 8 / 60s |
| POST /auth/verify-email | 8 / 60s |
| POST /auth/resend-verification | 3 / 60s |
Discovery
Health check. Confirms the API surface and returns the running version.
{ "ok": true, "data": { "service": "atomown-api", "version": "v1",
"ci": "4.7.4", "env": "production", "time": "2026-09-14T09:12:03+01:00" } }The app bootstrap. Returns your network's theme, branding, editable content pages and the set of enabled features: so a white-label app renders the right skin and only the modules you turned on, with no rebuild.
| Query | Type | Description | |
|---|---|---|---|
| developer | string | required | Your network slug or subdomain |
{ "ok": true, "data": {
"developer": { "id": 11, "name": "Acme Homes", "slug": "acme-homes" },
"theme": { "key": "theme-2", "name": "Editorial" },
"branding": { "primary": "#0F7A68", "logo": "https://…/logo.png", "app_name": "Acme Own" },
"content": { "about": "…", "contact": "…", "privacy": "…", "terms": "…" },
"features": ["wallet", "secondary_market", "installments", "referral", "atomcredit", "manual_funding", "email_verification_required"],
"loans": { "enabled": true, "bank_name": "First Trust Bank" },
"fees": {
"withdrawal": { "model": "flat", "pct": 0, "flat": 10000, "min": null, "max": null },
"transfer": { "developer": { "model": "percentage", "pct": 1.0, "flat": 0 },
"network": { "model": "percentage", "pct": 1.5, "flat": 0 } },
"secondary_market": { "developer": { "model": "percentage", "pct": 2.0, "flat": 0 },
"network": { "model": "percentage", "pct": 1.5, "flat": 0 } },
"liquidation": { "developer": { "model": "percentage", "pct": 5.0, "flat": 0 },
"network": { "model": "percentage", "pct": 1.5, "flat": 0 } }
}
} }
// fees.*.developer is null when the developer has not configured that fee.
// fees.*.network is null when no atomafrica_transaction rule exists in charge_rules.
// Flat amounts are in kobo; percentages are a plain number (1.5 = 1.5%).The public catalogue: active listings for your network, no login required. Use /public/properties/{id} for one listing.
| Query | Type | Description | |
|---|---|---|---|
| developer | string | required | Your network slug or subdomain |
{ "ok": true, "data": { "properties": [{
"id": 12, "name": "Palm Estate",
"category": "residential", "sub_category": "residential",
"property_category_id": 3,
"location": "12 Admiralty Way, Lekki Phase 1", "state": "Lagos",
"latitude": 6.4281, "longitude": 3.4219,
"total_value": 10000000, "unit_type": "flat",
"packets_per_unit": 100, "packet_price": 10000, "base_packet_price": 10000,
"amount_per_unit": 1000000, "base_amount_per_unit": 1000000,
"total_units": 10, "available_units": 6.2,
"total_packets": 1000, "available_packets": 620,
"has_returns": true, "listing_status": "active", "cover": null, "images": []
}] } }A property is made of units of its unit_type (a Flat/Plot/…). Each unit is packets_per_unit packets; a packet (packet_price) is the smallest, minimum-purchasable share. So packets_per_unit = amount_per_unit ÷ packet_price and total_packets = packets_per_unit × total_units. packet_price is the live price; base_packet_price is the original. category/sub_category is the property type (land · residential · commercial · industrial); property_category_id points to the developer's own custom category (e.g. "Phase 1"). state is the Nigerian state. description may contain rich HTML: sanitize before rendering. latitude/longitude are optional map coordinates.
property_discoveryActive listings for the signed-in customer's developer network. Supports optional filters to power the discover page's search and tabs. All filters are combined with AND logic. Returns the same property shape as the public catalogue (see above) including state, sub_category, and property_category_id.
| Query | Type | Description | |
|---|---|---|---|
| property_category_id | integer | optional | Filter to one developer-defined category tab (id from GET /property-categories) |
| state | string | optional | Exact match on the 36 Nigerian states / FCT, e.g. Lagos |
| sub_category | string | optional | land · residential · commercial · industrial |
| name | string | optional | Partial name search (case-insensitive contains) |
| description | string | optional | Partial description search |
| location | string | optional | Partial location/address search |
curl "$API/api/v1/properties?state=Lagos&sub_category=residential" \ -H "Authorization: Bearer $TOKEN"
const params = new URLSearchParams({ state: "Lagos", sub_category: "residential" });
const { data } = await fetch(`${API}/api/v1/properties?${params}`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());{ "ok": true, "data": { "properties": [{ /* property shape — see Public listings */ }] } }property_discoveryReturns the developer's custom property categories in display order. Use this to build tabs on the discover page: show tabs only when categories.length > 1; when there is only the default category, suppress the tab bar. Each tab passes property_category_id to GET /properties.
curl "$API/api/v1/property-categories" \ -H "Authorization: Bearer $TOKEN"
const { data } = await fetch(`${API}/api/v1/property-categories`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());{ "ok": true, "data": { "categories": [
{ "id": 3, "name": "Properties", "is_default": true, "position": 1 },
{ "id": 1, "name": "Phase 1", "is_default": false, "position": 2 },
{ "id": 2, "name": "Phase 2", "is_default": false, "position": 3 }
] } }The is_default category is the one automatically created when a developer posts their first property. Its name is editable but it cannot be deleted. A developer can have at most 3 categories.
Identity & security
Create a customer on your network. Issues a bearer token, a referral code and a welcome notification. Rate-limited 5/60s. When the network has email_verification_required enabled, no token is issued at registration: instead a 6-digit OTP is emailed and the customer must complete POST /auth/verify-email before they can log in.
| Body | Type | Description | |
|---|---|---|---|
| developer | string | required | Your network slug |
| name | string | required | Full name |
| string | required | Unique per network | |
| password | string | required | Min 6 characters |
| pin | string | required | 6 digits: the transaction PIN |
| phone | string | optional | Contact number |
| referral_code | string | optional | Attributes the signup to a referrer |
curl -X POST "$API/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{"developer":"acme-homes","name":"Ada Okoro","email":"ada@example.com",
"password":"secret123","pin":"246813"}'
await fetch(`${API}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ developer: "acme-homes", name: "Ada Okoro",
email: "ada@example.com", password: "secret123", pin: "246813" }),
});{ "ok": true, "data": {
"token": "ao_live_9f2c8b1e…",
"user": { "id": 402, "type": "customer", "name": "Ada Okoro",
"email": "ada@example.com", "developer_id": 11,
"roles": ["customer"], "permissions": [] }
} }{ "ok": true, "data": {
"email_verification_required": true,
"email": "ada@example.com"
} }
// No token is issued. A 6-digit OTP (60-min TTL) is emailed to the address.
// Redirect the user to your verification screen and call POST /auth/verify-email.Exchange credentials for a bearer token. Include developer to scope a customer login to your network. Rate-limited 10/60s. When email_verification_required is enabled, customers with an unverified email receive 403 email_not_verified: redirect them to the verification screen to complete POST /auth/verify-email.
| Body | Type | Description | |
|---|---|---|---|
| string | required | Account email | |
| password | string | required | Account password |
| developer | string | optional | Network slug (required for customer logins) |
{ "ok": true, "data": { "token": "ao_live_9f2c8b1e…", "user": { "id": 402, "type": "customer", … } } }{ "ok": false, "error": { "code": "email_not_verified",
"message": "Please verify your email address before logging in.",
"details": { "email": "ada@example.com" } } }Verify a customer's email address using the 6-digit OTP that was emailed after registration. On success, the email is marked verified and a bearer token is issued so the customer is immediately logged in. Rate-limited 8/60s. Returns already_verified if the address is already confirmed.
| Body | Type | Description | |
|---|---|---|---|
| developer | string | required | Your network slug |
| string | required | The address being verified | |
| code | string | required | The 6-digit OTP from the verification email |
{ "ok": true, "data": {
"token": "ao_live_9f2c8b1e…",
"user": { "id": 402, "type": "customer", "name": "Ada Okoro",
"email": "ada@example.com", "email_verified": true }
} }| Error code | Status | Meaning |
|---|---|---|
| otp_invalid | 401 | Code is wrong or expired |
| otp_max_attempts | 401 | 5 wrong attempts: request a new code with /auth/resend-verification |
| already_verified | 200 | Email is already verified (returns a token so the user can proceed) |
Re-send the email verification OTP. A 60-second cooldown applies between sends (returns cooldown if called too soon). The previous unused code is invalidated and a new 6-digit code with a 60-minute TTL is issued. Rate-limited 3/60s.
| Body | Type | Description | |
|---|---|---|---|
| developer | string | required | Your network slug |
| string | required | The address to re-send to |
{ "ok": true, "data": { "sent": true } }
// Always returns { sent: true } regardless of whether the email exists,
// to prevent address enumeration. On cooldown (60s) sent is still true.The authenticated user, their roles and permission keys. Use it to rehydrate a session and to gate admin UI.
{ "ok": true, "data": { "user": { "id": 402, "type": "customer", "name": "Ada Okoro",
"email": "ada@example.com", "developer_id": 11, "roles": ["customer"], "permissions": [] } } }Revoke the current token immediately.
{ "ok": true, "data": { "loggedOut": true } }Set or change the 6-digit transaction PIN. POST /pin/verify checks a PIN without moving money (rate-limited 8/60s): useful to pre-validate before a purchase screen.
| Body | Type | Description | |
|---|---|---|---|
| pin | string | required | Exactly 6 digits |
Submit identity (BVN + ID) for review; read status with GET /api/v1/kyc/status. Your developer admin approves/declines via the Developer Admin API.
| Body | Type | Description | |
|---|---|---|---|
| bvn | string | required | Bank Verification Number |
| id_type | string | required | e.g. nin, passport, drivers_license |
| id_number | string | required | Document number |
Register the customer's payout bank account. Add-once and immutable (a safeguard for withdrawals); read it with GET /api/v1/bank-account.
| Body | Type | Description | |
|---|---|---|---|
| bank_code | string | required | Bank / institution code |
| account_number | string | required | NUBAN account number |
| account_name | string | required | Account holder name |
Wallet & purchases
The customer's spendable wallet balance, derived from the ledger.
{ "ok": true, "data": { "wallet": { "balance": 152000.00, "currency": "NGN" } } }Fund the wallet (settle your PSP first, then record the funding here). The customer is credited in full, then the AtomAfrica deposit charge is deducted (customer nets amount − charge); the charge goes to AtomAfrica. Nothing touches the developer wallet. Returns the credited amount, charge and new balance.
| Body | Type | Description | |
|---|---|---|---|
| amount | number | required | Naira to fund (e.g. 25000) |
{ "ok": true, "data": { "reference": "DEP-3461A1B3", "amount": 25000.00,
"deposit_fee": 50.00, "credited": 24950.00, "balance": 177000.00 } }Buy packets of a listing. Pass a naira amount (recommended: packets are computed at the live packet_price and may be fractional) or an explicit packets quantity. The minimum purchase is one packet; less returns minimum_one_packet (422). Debits the wallet, allocates packets and writes the ledger atomically. Also fails with insufficient_funds (402) or a pin_* code (401).
| Body | Type | Description | |
|---|---|---|---|
| property_id | number | required | Listing to buy into |
| amount | number | amount or packets | Naira to spend (e.g. 15000 → 1.5 packets at ₦10,000/packet) |
| packets | number | amount or packets | Explicit packet quantity (may be fractional) |
| pin | string | required | 6-digit transaction PIN |
curl -X POST "$API/api/v1/purchases" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"property_id":12,"amount":15000,"pin":"246813"}'
await fetch(`${API}/api/v1/purchases`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ property_id: 12, amount: 15000, pin: "246813" }),
}).then(r => r.json());{ "ok": true, "data": { "reference": "PUR-0637A42D", "packets": 1.5,
"packet_price": 10000.00, "ownership": "1.5 packets", "cost": 15000.00,
"atomafrica_fee": 225.00, "property": "Palm Estate" } }The customer's holdings across properties. Each holding reports packets owned plus the ownership broken into the unit language: whole_units + remainder_packets and a ready-made ownership label (e.g. "1 Flat and 20 packets"): with current value re-priced at the live packet price.
{ "ok": true, "data": { "holdings": [{
"property": "Palm Estate", "unit_type": "flat", "packets_per_unit": 100,
"packets": 120, "units": 1.2, "whole_units": 1, "remainder_packets": 20,
"ownership": "1 Flat and 20 packets", "packet_price": 10000.00,
"acquisition_value": 1200000.00, "current_value": 1200000.00
}] } }The customer's own wallet history (deposits, purchases, transfers, payouts). Fetch one receipt with GET /api/v1/transactions/{reference}. Use this to reconcile after a timed-out POST.
Buy progressively: open a plan for a property, list plans with GET /installment-plans, and pay an instalment with POST /installment-plans/{id}/pay (PIN-gated). Units vest as the plan is paid down. The unit price is locked for the life of the plan.
| Body | Type | Description | |
|---|---|---|---|
| property_id | number | required | Property to buy into |
| total_amount | number | required | Total naira to invest across all payments |
| tenor | integer | required | Number of payments (minimum 2) |
| frequency | string | required | daily · weekly · monthly |
| pin | string | required | 6-digit transaction PIN |
{ "ok": true, "data": { "plan": {
"id": 17, "property": "Palm Estate", "status": "active",
"total_amount": 120000.00, "tenor": 6, "frequency": "monthly",
"installment_amount": 20000.00, "locked_unit_price": 10000.00,
"paid_count": 0, "next_due_at": "2026-11-08" } } }Returns the developer's active bank accounts for manual wallet funding. Only available when the manual_funding feature is enabled on the network. Show these to the customer so they can make a direct bank transfer and then record it with POST /wallet/topup.
{ "ok": true, "data": { "manual_funding_enabled": true,
"accounts": [
{ "bank_name": "First Bank", "account_number": "3012345678",
"account_name": "Acme Homes Ltd", "is_active": true }
] } }Public record-verification lookup. Given a property reference, returns the property's details, encumbrance position (whether it carries an active loan), and packet-level status — without exposing any owner personal information. Use this to let a customer, lawyer or bank confirm a record is real at own.atomafrica.tech/n/{slug}/verify.
| Query | Type | Description | |
|---|---|---|---|
| ref | string | required | Property reference or slug to look up |
{ "ok": true, "data": { "property": {
"name": "Palm Estate", "location": "Lekki, Lagos", "status": "active",
"encumbered": false, "encumbrance_note": null,
"packets": [{ "ref": "PKT-00042", "status": "owned", "encumbered": false }]
} } }Moving ownership
Request a payout to the customer's registered bank account. The amount + charge are debited and held. It's a two-step release: the developer admin confirms (amount + charge move to AtomAfrica), then the super admin releases the payout to the beneficiary (status → paid). A decline at either step refunds the customer in full. List the customer's requests with GET /withdrawals.
| Body | Type | Description | |
|---|---|---|---|
| amount | number | required | Naira to withdraw |
| pin | string | required | 6-digit transaction PIN |
{ "ok": true, "data": { "reference": "WD-7DD1C6B4", "status": "pending", "amount": 40000.00 } }Check whether a recipient exists on this network before creating a transfer. Returns their name, KYC status, and whether they are the caller. Transfer is only allowed when kyc_verified = true and is_self = false.
| Body | Type | Description | |
|---|---|---|---|
| string | required | Email to look up |
| Response field | Type | Notes |
|---|---|---|
| recipient.name | string | Display name |
| recipient.email | string | |
| recipient.kyc_status | string | verified | pending | not_submitted |
| recipient.kyc_verified | boolean | true only when KYC is fully verified: transfer is enabled |
| recipient.is_self | boolean | true when the recipient is the caller: transfer blocked |
Send packets to another customer on your network. Two types: Gift (recipient pays nothing, sender pays fees) and Trade (you set a price per packet; recipient pays that agreed amount to you on acceptance; sender still pays dev fee + network fee). The recipient must accept or reject (POST /transfers/{id}/accept · /reject); list with GET /transfers.
| Body | Type | Description | |
|---|---|---|---|
| recipient_email | string | required | Recipient's email: must be verified first via /verify-recipient |
| property_id | number | required | Which property holding to transfer from |
| packets | number | required | Packets to send (may be fractional) |
| transfer_type | string | optional | gift (default) or trade |
| price_per_packet | number | trade only | Agreed price in naira per packet; required when transfer_type = trade |
| pin | string | required | Sender's 6-digit transaction PIN |
The peer-to-peer secondary market for your network. Browse open listings, then:
| Method | Path | Purpose |
|---|---|---|
| POST | /market | List your packets for resale (packets, price_per_packet, pin) |
| POST | /market/{id}/buy | Buy a listing (PIN-gated) |
| POST | /market/{id}/cancel | Cancel your listing |
| GET | /market/{id}/messages | Negotiation thread |
| POST | /market/{id}/messages | Post a negotiation message |
Sell packets back to the developer at the buy-back rate. Preview the payout first with POST /liquidations/preview; the request then enters your developer-admin approval queue. List with GET /liquidations.
| Body | Type | Description | |
|---|---|---|---|
| property_id | number | required | Holding to sell back |
| packets | number | required | Packets to liquidate (may be fractional) |
| pin | string | required | 6-digit transaction PIN (create only) |
Submit a bid offer expressing buying interest to all other holders of the same property. Your name is shared with recipients; your contact details are never included. Each holder gets an email; the developer also gets a copy to broker any resulting deal via Transfer or Secondary Market. Requires bid_offers to be enabled on the network.
| Body | Type | Description | |
|---|---|---|---|
| property_id | number | required | Property you own packets in |
| message | string | optional | Short message (max 500 chars) shown to recipients alongside your name |
curl -X POST "$API/api/v1/bid-offers" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"property_id":12,"message":"Looking to acquire more packets in Palm Estate."}'
const { data } = await fetch(`${API}/api/v1/bid-offers`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ property_id: 12, message: "Looking to acquire more packets in Palm Estate." }),
}).then(r => r.json());{ "ok": true, "data": { "bid_offer": {
"id": 3, "property_id": 12, "property_name": "Palm Estate",
"status": "active", "recipient_count": 4
} } }| Code | Error key | Meaning |
|---|---|---|
| 403 | forbidden | You hold no active packets in this property |
| 409 | duplicate | You already have an active bid offer on this property |
List your own bid offers with GET /bid-offers — returns id, property_name, message, status, recipient_count, created_at. Withdraw an active bid with DELETE /bid-offers/{id}.
Engagement
The customer's watchlist. Add or remove a property with POST /favorites/{propertyId} and DELETE /favorites/{propertyId}.
The customer's referral code, stats and the active program terms. Your developer admin configures the program via the Developer Admin API.
In-app inbox. Mark read with POST /notifications/read (all) or POST /notifications/{id}/read. Register a device for push with POST /push-tokens (token, platform).
Support threads. Open a ticket, list with GET /contact, read one with GET /contact/{id}, and reply with POST /contact/{id}/reply. The channel (customer→developer, or developer→AtomAfrica) is decided by the caller's role.
Fetch the current terms & conditions for a specific feature before the customer performs an action. The developer's custom copy is returned if they have set one; otherwise the platform default applies. The {{developer_name}} variable is already resolved server-side. Versions increment each time the developer saves a new copy — use version to detect when the customer needs to re-accept.
| Path param | Valid values |
|---|---|
| key | transfer · bid_offer · secondary_market · property_listing · installment_plan · buy_packets |
curl "$API/api/v1/terms/transfer" \ -H "Authorization: Bearer $TOKEN"
const { data } = await fetch(`${API}/api/v1/terms/transfer`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());{ "ok": true, "data": { "terms": {
"feature_key": "transfer",
"title": "Transfer Terms & Conditions",
"content": "By proceeding with this transfer, you acknowledge …",
"version": 1
} } }Once the customer accepts, record it server-side with POST /terms/{key}/accept (no body required). List all acceptances for the current user with GET /terms/accepted — returns [{ feature_key, version, accepted_at }]. The mobile SDK uses these together with a localStorage version cache so the modal is only shown when the T&C version changes.
List the documents attached to a property listing. Only returned to authenticated customers. The developer uploads documents (C of O, survey plans, brochures, etc.) via the admin portal — use masked/redacted copies before attaching.
{ "ok": true, "data": { "documents": [{
"id": 5, "label": "Certificate of Occupancy",
"url": "https://…/uploads/properties/12/docs/coo.pdf",
"original_name": "coo-masked.pdf", "mime_type": "application/pdf"
}] } }AtomCredit
AtomCredit lets customers borrow against their property portfolio as an equity release (borrow against existing packet holdings) or purchase finance (borrow to co-fund a new property purchase). The feature is gated: check atomcredit.enabled in /app/config. When disabled all AtomCredit endpoints return 403 feature_disabled.
Eligibility check
Check whether the customer can apply and retrieve the current terms for the requested product type. Always call this before showing the application screen so your UI can display the bank name, applicable limits and rate.
| Query | Type | Description | |
|---|---|---|---|
| product_type | string | optional | equity_release (default) or purchase_finance |
| property_id | integer | optional | Required for purchase_finance: the property the customer intends to buy into |
| amount | number | optional | If supplied, checks whether this specific principal is within the applicable limits |
{ "ok": true, "data": {
"eligible": true,
"product_type": "equity_release",
"bank_name": "First Trust Bank",
"interest_rate": 18.0,
"tenor_range": { "min_months": 3, "max_months": 24 },
"max_ltv_pct": 30.0,
"portfolio_value": 2500000.00,
"max_principal": 750000.00,
"min_principal": 50000.00,
"terms_summary": "18% p.a. reducing balance · 3–24 months"
} }{ "ok": true, "data": { "eligible": false, "reason": "no_portfolio" } }
// reason: no_bank_linked | no_portfolio | active_facility_exists | kyc_requiredSubmit credit request
Submit a credit request. The request enters the developer admin queue (submitted) and provisional ownership locks are placed on the relevant packets. A sign_deadline applies (default 7 days): the customer must digitally sign the offer deed before this expires.
| Body | Type | Description | |
|---|---|---|---|
| product_type | string | required | equity_release or purchase_finance |
| property_id | integer | required | Target property (equity release: one you hold; purchase finance: a listed property) |
| tenor_months | integer | required | Repayment tenor in months (within bank's configured range) |
| requested_principal | number | optional* | Loan amount in naira — required for equity release |
| contribution_amount | number | optional* | Customer's upfront equity contribution — required for purchase finance |
| consent_method | string | required | How informed consent was recorded: app_checkbox |
curl -X POST "$API/api/v1/credit-requests" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"product_type":"equity_release","property_id":12,"tenor_months":12,"requested_principal":500000,"consent_method":"app_checkbox"}'
const { data } = await fetch(`${API}/api/v1/credit-requests`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
product_type: "equity_release", property_id: 12,
tenor_months: 12, requested_principal: 500000, consent_method: "app_checkbox"
}),
}).then(r => r.json());{ "ok": true, "data": { "credit_request": {
"id": 7, "status": "submitted", "product_type": "equity_release",
"requested_principal": 500000.00, "tenor_months": 12,
"expires_at": "2026-10-09T10:00:00", "created_at": "2026-10-02T10:00:00"
} } }| Error code | Status | Meaning |
|---|---|---|
| active_facility_exists | 409 | Customer already has an active AtomCredit facility |
| active_request_exists | 409 | A pending credit request already exists |
| no_portfolio | 422 | No qualifying holdings for equity release |
| amount_exceeds_ltv | 422 | Principal exceeds portfolio × max LTV% |
| tenor_out_of_range | 422 | Tenor is outside the bank's allowed range |
| property_not_found | 404 | Property does not exist on this network |
Credit request history
The customer's own credit request history across all statuses. Read a single request with GET /credit-requests/{id} — the single-request response also includes document_version (latest deed) and document_versions (all versions, including the release deed generated after discharge). Fetch the deed HTML with GET /credit-requests/{id}/deed?doc_version_id={dvId}.
{ "ok": true, "data": {
"credit_requests": [{
"id": 7, "product_type": "equity_release", "status": "with_bank",
"requested_principal": 500000.00, "tenor_months": 12,
"expires_at": "2026-10-09T10:00:00", "created_at": "2026-10-02T10:00:00"
}],
"pagination": { "page": 1, "per": 15, "total": 1, "pages": 1 }
} }
// status lifecycle: submitted → consents_pending → with_bank → approved
// → pending_signing → (facility created) | declined | cancelled | expiredCancel a pending credit request. Only allowed while the request is in submitted, consents_pending, or with_bank status. Cancellation releases all provisional ownership locks immediately.
{ "ok": true, "data": { "credit_request": { "id": 7, "status": "cancelled" } } }Electronic deed signing: the customer reads and signs the offer deed in-app. The request must be in consents_pending status (developer has confirmed and deed is ready). PIN-gated. Once all parties (customer, developer, bank) have signed, the facility is created and the bank notified to disburse. Fetch the deed HTML first with GET /credit-requests/{id}/deed.
| Body | Type | Description | |
|---|---|---|---|
| pin | string | required | Customer's 6-digit transaction PIN |
| signatory_name | string | required | Full legal name of the signatory as it appears on the deed |
| role | string | required | Role/title of the signatory (e.g. "Customer") |
| capacity | string | optional | Capacity in which signing (e.g. "Beneficial Owner") |
| witness_name | string | optional | Full name of the witness to the signature |
| witness_address | string | optional | Address of the witness |
{ "ok": true, "data": {
"signature": { "id": 22, "party": "customer", "signatory_name": "Ada Okoro",
"method": "electronic", "signed_at": "2026-10-05T14:22:00" },
"execution": null
} }
// execution: non-null (facility object) once all 3 parties have signedFacilities
List all AtomCredit facilities for the authenticated customer across all statuses. Read a single facility with GET /facilities/{id}.
{ "ok": true, "data": {
"facilities": [{
"id": 3, "product_type": "equity_release", "status": "active",
"principal": 500000.00, "outstanding_principal": 450000.00,
"interest_rate": 18.0, "tenor_months": 12, "monthly_installment": 45833.33,
"repayment_day_of_month": 5, "loan_ends_at": "2027-09-15",
"disbursed_at": "2026-09-15T09:00:00",
"locked_packets": 4, "locked_percentage": 80.0
}],
"pagination": { "page": 1, "per": 15, "total": 1, "pages": 1 }
} }
// status: pending_disbursement | pending_allocation | active | in_arrears | enforcement | closed_repaidFull repayment schedule for a facility: each instalment's due date, expected amount, and cleared status.
{ "ok": true, "data": {
"facility_id": 3, "principal": 500000.00, "total_interest": 90000.00,
"repayments": [
{ "id": 11, "instalment_number": 1, "due_date": "2026-10-15",
"expected_amount": 49166.67, "status": "cleared",
"cleared_at": "2026-10-14T11:00:00" },
{ "id": 12, "instalment_number": 2, "due_date": "2026-11-15",
"expected_amount": 49166.67, "status": "pending", "cleared_at": null }
]
} }Lifecycle notifications for a facility: disbursement confirmation, repayment reminders, arrears warnings, enforcement notices, etc.
{ "ok": true, "data": {
"notifications": [
{ "id": 55, "type": "facility_disbursed",
"title": "Facility disbursed",
"body": "Your AtomCredit facility of ₦500,000 has been disbursed.",
"sent_at": "2026-09-15T09:05:00", "read_at": "2026-09-15T09:12:00" }
]
} }Enforcement cases
List enforcement cases opened against the customer's facilities. A case is created when the bank serves a default notice. Read a single case with GET /enforcement-cases/{id}.
{ "ok": true, "data": {
"enforcement_cases": [{
"id": 1, "facility_id": 3, "status": "cure_running",
"opened_at": "2026-10-01T08:00:00",
"cure_period_ends_at": "2026-10-22T08:00:00",
"outstanding_at_notice": 98333.34
}]
} }
// status: notice_served | cure_running | enforcement_notice | listed | offer_accepted | settled | allocated | closedDeveloper Admin API
Same base, same envelope, same bearer auth: but these endpoints act on your whole network and require a developer-admin account. Only developer admins can call them; staff and agents are blocked. A call from any other account type returns 403. Your permission keys are visible on the user object from /me.
/api/v1/admin/*. Sign in with your developer-admin account from the admin portal, then drive these programmatically with its bearer token.| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /admin/properties | properties.manage | List / create / update listings (unit_type, number_of_units, amount_per_unit, packet_price, state, property_category_id, latitude, longitude), estates, allocations, images. Response includes state, sub_category (alias for category), property_category_id. |
| POST | /admin/properties/{id}/returns | returns.post | Distribute a return to owners: per_packet rate × packets held |
| GET | /admin/customers | customers.view | List customers · detail (balance + wallet history) · CSV import |
| POST | /admin/customers/{id}/fund | customers.assign | Developer admin funds a customer (PIN): debits developer, credits customer net, charge → AtomAfrica |
| POST | /admin/notifications/send | customers.view | Broadcast to one / all / a KYC segment |
| GET | /admin/kyc | kyc.review | Review, approve or decline KYC |
| POST | /admin/withdrawals/{id}/approve | withdrawals.approve | Developer confirms a pending payout; super admin releases an approved one (sees beneficiary bank). /decline reverses. Also liquidations. |
| GET | /admin/wallet | wallet.developer.move | Network main wallet · top-up · withdraw |
| GET | /admin/staff | staff.manage | List / create / activate staff & agents; assign roles (PATCH /role); view payment history |
| GET | /admin/roles · /admin/permissions | staff.manage | System & developer custom roles (full CRUD); list developer-group permissions |
| POST | /admin/commissions · /admin/agent-commission | staff.manage | Manual commission / salary payment to staff; get/set agent attribution commission rate |
| GET | /admin/staff-withdrawals · /admin/my/* | withdrawals.approve | Staff & agents request bank payouts; developer approves / declines from queue |
| GET | /admin/features | features.toggle | Toggle app modules for your network |
| POST | /admin/referral-program | referral.manage | Configure the referral program |
| POST | /admin/email-templates | email.templates | Edit branded email templates |
| GET | /admin/transactions · /admin/audit | reports.view | Network-wide ledger & audit log (CSV export) |
| GET | /admin/charges | charges.developer | View/edit your developer-layer fees |
| GET | /admin/bid-offers | properties.manage | List customer bid offers; close (withdraw) one with PATCH /admin/bid-offers/{id} |
| GET | /admin/terms | branding.manage | List all six feature T&C slots (with custom flag); save with POST /admin/terms/{key}, reset to default with DELETE /admin/terms/{key}. Keys: transfer · bid_offer · secondary_market · property_listing · installment_plan · buy_packets |
| GET | /admin/properties/{id}/documents | properties.manage | List property documents; upload with POST (multipart: label + document); delete with DELETE /admin/properties/{id}/documents/{docId} |
| GET | /admin/property-categories | properties.manage | List developer's categories (max 3); create (POST), rename (PATCH /:id), delete (DELETE /:id — non-default only, properties reassigned to default) |
| GET | /admin/allocations | properties.manage | Plot allocation management: create (POST /admin/properties/{id}/allocate), list all/by-property/pending-docs, dissolve, approve document request, confirm doc issued. Supports co-ownership (allocation shares). |
List endpoints accept ?page/?per and, where noted, ?q search and ?format=csv export.
Credit request queue
customers.viewList all AtomCredit requests on your network. Filter by status to see the ones awaiting your review (submitted). Paginated. Read a single request with GET /admin/credit-requests/{id}.
| Query | Type | Description | |
|---|---|---|---|
| status | string | optional | submitted · consents_pending · with_bank · approved · pending_signing · declined · cancelled · expired |
| product_type | string | optional | equity_release or purchase_finance |
| page / per | integer | optional | Pagination (default page 1, per 20, max 100) |
curl "$API/api/v1/admin/credit-requests?status=submitted" \ -H "Authorization: Bearer $TOKEN"
const { data } = await fetch(`${API}/api/v1/admin/credit-requests?status=submitted`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());{ "ok": true, "data": {
"credit_requests": [{
"id": 7, "product_type": "equity_release", "status": "submitted",
"customer": { "id": 9, "name": "Ada Okoro", "kyc_status": "approved" },
"requested_principal": 500000.00, "tenor_months": 12,
"expires_at": "2026-10-09T10:00:00", "created_at": "2026-10-02T10:00:00"
}],
"pagination": { "page": 1, "per": 20, "total": 1, "pages": 1 }
} }customers.viewMaker step. First developer admin confirms the request passes internal checks. Status moves submitted → consents_pending. A different developer admin must then call approve-confirm (checker step) before the request reaches the bank. Provisional locks placed at submission remain in effect.
| Body | Type | Description | |
|---|---|---|---|
| note | string | optional | Internal note attached to the audit trail |
curl -X POST "$API/api/v1/admin/credit-requests/7/confirm" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"note":"KYC verified, holdings confirmed."}'
await fetch(`${API}/api/v1/admin/credit-requests/7/confirm`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ note: "KYC verified, holdings confirmed." }),
}).then(r => r.json());{ "ok": true, "data": { "credit_request": { "id": 7, "status": "consents_pending" } } }| Code | Error key | Meaning |
|---|---|---|
| 409 | not_found_or_wrong_status | Request is not in submitted state |
customers.viewChecker step. A different developer admin (not the one who called confirm) approves the confirmation and forwards the request to the bank. Status moves consents_pending → with_bank. Returns maker_checker_violation if the same admin calls both steps.
{ "ok": true, "data": { "credit_request": { "id": 7, "status": "with_bank" } } }| Code | Error key | Meaning |
|---|---|---|
| 409 | not_confirmed_yet | Maker step has not been completed |
| 403 | maker_checker_violation | Same admin cannot perform both maker and checker steps |
customers.viewDecline the request at the developer level. Status moves to declined and all provisional ownership locks are released. The customer is notified.
| Body | Type | Description | |
|---|---|---|---|
| reason | string | optional | Decline reason shown to the customer |
{ "ok": true, "data": { "credit_request": { "id": 7, "status": "declined" } } }customers.viewCounter-sign the offer deed on behalf of the developer. Request must be in pending_signing status. PIN-gated. Once both the customer and the developer have signed, the facility is created automatically.
| Body | Type | Description | |
|---|---|---|---|
| pin | string | required | Developer admin's 6-digit transaction PIN |
{ "ok": true, "data": {
"credit_request": { "id": 7, "status": "pending_signing" },
"facility_created": true,
"facility_id": 3
} }Facilities
customers.viewList all active or historical AtomCredit facilities on your network. Filter by status or customer_id. Read a single facility with GET /admin/facilities/{id}.
| Query | Type | Description | |
|---|---|---|---|
| status | string | optional | pending_disbursement · pending_allocation · active · in_arrears · enforcement · closed_repaid |
| customer_id | integer | optional | Filter by customer |
{ "ok": true, "data": {
"facilities": [{
"id": 3, "product_type": "equity_release", "status": "active",
"customer": { "id": 9, "name": "Ada Okoro" },
"principal": 500000.00, "outstanding_principal": 450000.00,
"interest_rate": 18.0, "tenor_months": 12,
"disbursed_at": "2026-09-15T09:00:00", "loan_ends_at": "2027-09-15"
}],
"pagination": { "page": 1, "per": 20, "total": 1, "pages": 1 }
} }AtomCredit reports
reports.viewAll properties on your network that are currently under an AtomCredit charge (have one or more active facilities), with the total locked value and number of active facilities per property.
{ "ok": true, "data": {
"charges": [{
"property_id": 12, "property_name": "Lekki Gardens Phase 1",
"active_facilities": 3, "total_principal": 1500000.00,
"locked_packets": 12
}]
} }reports.viewAll purchase finance facilities where the developer has confirmed the customer's equity contribution, with confirmation dates and amounts.
{ "ok": true, "data": {
"confirmations": [{
"facility_id": 3, "customer_name": "Ada Okoro", "property_name": "Lekki Gardens Phase 1",
"contribution_amount": 200000.00, "confirmed_at": "2026-09-14T10:00:00"
}]
} }reports.viewAll unit allocations made under purchase finance facilities, including the unit reference, customer, and allocation date.
{ "ok": true, "data": {
"allocations": [{
"facility_id": 3, "unit_id": 88, "unit_reference": "LG-B2-P14",
"customer_name": "Ada Okoro", "property_name": "Lekki Gardens Phase 1",
"allocated_at": "2026-09-15T08:30:00"
}]
} }Customers
customers.viewPaginated customer list for your network. Filter by q (name/email/phone) or kyc_status.
curl "$API/api/v1/admin/customers?q=ada&kyc_status=approved" \ -H "Authorization: Bearer $TOKEN"
const { data } = await fetch(`${API}/api/v1/admin/customers?q=ada`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());{ "ok": true, "data": {
"customers": [
{ "id": 9, "user_id": 14, "name": "Ada Okoro", "email": "ada@example.com",
"phone": "08012345678", "kyc_status": "approved",
"acquisition_channel": "app", "created_at": "2026-08-01T10:00:00" }
],
"pagination": { "page": 1, "per": 20, "total": 1, "pages": 1 }
} }customers.viewCreate a customer directly (no self-registration). A temporary password is generated and emailed. Returns the customer record and the temporary password (shown once).
| Body | Type | Description | |
|---|---|---|---|
| name | string | required | Full name |
| string | required | Login email | |
| phone | string | required | Mobile number |
curl -X POST "$API/api/v1/admin/customers" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Ada Okoro","email":"ada@example.com","phone":"08012345678"}'
const { data } = await fetch(`${API}/api/v1/admin/customers`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ada Okoro", email: "ada@example.com", phone: "08012345678" }),
}).then(r => r.json());{ "ok": true, "data": {
"customer": { "id": 9, "name": "Ada Okoro", "email": "ada@example.com", "phone": "08012345678" },
"temp_password": "7f3a9c12!84"
} }Developer wallet
wallet.developer.moveCurrent balance and ledger entries for your network's main wallet. The same wallet is debited when you fund customers or pay commissions.
curl "$API/api/v1/admin/wallet" \ -H "Authorization: Bearer $TOKEN"
const { data } = await fetch(`${API}/api/v1/admin/wallet`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());{ "ok": true, "data": {
"balance": 480000.00,
"entries": [
{ "direction": "credit", "amount": 500000.00, "balance_after": 480000.00,
"description": "Developer wallet top-up (DEV-DEP-7A3F)", "at": "2026-09-01T09:00:00" }
]
} }wallet.developer.moveFund your network wallet (PIN-gated). A deposit fee is deducted and routed to AtomAfrica. Returns the reference, new balance and fee charged.
| Body | Type | Description | |
|---|---|---|---|
| amount | number | required | Amount in naira (must be > 0) |
| pin | string | required | Your 6-digit transaction PIN |
curl -X POST "$API/api/v1/admin/wallet/topup" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"amount":500000,"pin":"123456"}'
const { data } = await fetch(`${API}/api/v1/admin/wallet/topup`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ amount: 500000, pin: "123456" }),
}).then(r => r.json());{ "ok": true, "data": {
"reference": "DEV-DEP-7A3F2C1B",
"balance": 480000.00,
"deposit_fee": 2500.00
} }wallet.developer.moveWithdraw from your network wallet (PIN-gated). Requires a payout bank account to be registered. The request enters the approval queue; a super admin releases it.
| Body | Type | Description | |
|---|---|---|---|
| amount | number | required | Amount in naira |
| pin | string | required | Your 6-digit transaction PIN |
curl -X POST "$API/api/v1/admin/wallet/withdraw" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"amount":100000,"pin":"123456"}'
const { data } = await fetch(`${API}/api/v1/admin/wallet/withdraw`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ amount: 100000, pin: "123456" }),
}).then(r => r.json());{ "ok": true, "data": { "reference": "DEV-WD-B4C1A902", "status": "pending" } }| Code | Error key | Meaning |
|---|---|---|
| 402 | insufficient_funds | Balance is less than the requested amount |
| 409 | bank_required | No payout bank account registered |
Team (staff & agents)
Staff are internal accounts granted permissions via roles. Agents are sales representatives who earn commission on attributed customer purchases. Both are created by the developer admin and scoped to the network.
staff.managePaginated list of all staff and agents in the network. Returns role_id alongside the role name for use in assignment UI.
{ "ok": true, "data": {
"team": [{
"id": 5, "name": "Emeka Obi", "email": "emeka@corp.com", "phone": "+2348012345678",
"type": "staff", "status": "active",
"role": "Accounting", "role_id": 3,
"created_at": "2026-10-01T08:00:00"
}],
"pagination": { "page": 1, "per": 20, "total": 1, "pages": 1 }
} }staff.manageCreate a staff member or agent. A one-time temporary password and PIN are returned in the response — share them with the new member directly. For staff, supply a role_id; agents always receive the system agent role.
| Body | Type | Description | |
|---|---|---|---|
| name | string | required | Full name |
| string | required | Must be globally unique across the entire platform — no two non-customer accounts (developer admin, staff, agent, bank staff, bank admin) may share an email address | |
| type | string | required | staff or agent |
| phone | string | optional | Phone number |
| role_id | integer | optional* | Required when type = staff. Must be a system role or a custom role owned by this developer. Ignored for agents. |
curl -X POST "$API/api/v1/admin/staff" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Emeka Obi","email":"emeka@corp.com","type":"staff","role_id":3}'
const { data } = await fetch(`${API}/api/v1/admin/staff`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: "Emeka Obi", email: "emeka@corp.com", type: "staff", role_id: 3 }),
}).then(r => r.json());{ "ok": true, "data": {
"user": { "id": 5, "name": "Emeka Obi", "email": "emeka@corp.com", "type": "staff" },
"temp_password": "a3f8b2!47",
"temp_pin": "293847"
} }| Code | Error key | Meaning |
|---|---|---|
| 409 | email_taken | That email is already in use by another admin, staff, or agent account on the platform |
staff.manageUpdate a team member's status.
| Body | Type | Description | |
|---|---|---|---|
| status | string | required | active · inactive · suspended |
staff.manageReassign a staff member to a different role. Replaces the previous role assignment. Only applies to staff type, not agents.
| Body | Type | Description | |
|---|---|---|---|
| role_id | integer | required | ID of the target role (system or developer-owned) |
curl -X PATCH "$API/api/v1/admin/staff/5/role" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"role_id":4}'
await fetch(`${API}/api/v1/admin/staff/5/role`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ role_id: 4 }),
}).then(r => r.json());{ "ok": true, "data": { "assigned": true, "role": "Finance", "role_id": 4 } }Paying staff, agents & partners
staff.manageManually credit a staff member, agent, or API partner's wallet directly from the developer wallet. No AtomAfrica charge on internal payouts. Use kind: "salary" for regular payments and kind: "commission" for performance bonuses.
staff and agent, pass the user's id (from GET /admin/staff). For partner, pass the partner row id (from GET /admin/partners), not the partner's portal user id.| Body | Type | Description | |
|---|---|---|---|
| recipient_id | integer | required | User ID (staff/agent) or partner row ID (partner) |
| recipient_type | string | required | staff · agent · partner |
| amount | number | required | Amount in naira (must be > 0) |
| kind | string | required | commission or salary |
curl -X POST "$API/api/v1/admin/commissions" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"recipient_id":5,"recipient_type":"staff","amount":50000,"kind":"salary"}'
await fetch(`${API}/api/v1/admin/commissions`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ recipient_id: 5, recipient_type: "staff", amount: 50000, kind: "salary" }),
}).then(r => r.json());{ "ok": true, "data": { "reference": "SAL-8A3F1C…", "amount": 50000.00,
"to": "staff", "recipient": "Emeka Obi", "kind": "salary" } }| Code | Error key | Meaning |
|---|---|---|
| 402 | insufficient_developer_funds | Developer wallet balance too low |
| 422 | commission_error | Recipient not found in this network, or invalid type/kind |
staff.manageWallet balance and last 50 commission/salary entries for a staff member.
{ "ok": true, "data": {
"name": "Emeka Obi", "balance": 150000.00,
"payments": [
{ "reference": "COMM-8A3F…", "kind": "salary", "amount": 50000.00, "created_at": "2026-10-01" }
]
} }Roles & permissions
System roles (staff, agent, developer_admin) are read-only. Developer admins can create custom roles with any subset of developer-group permissions. Every staff member must be assigned a role; agents always use the system agent role.
staff.manageAll roles visible to this developer: system roles (shared across networks) plus custom roles created by this developer. Each role includes its id, permission key array, and system flag.
{ "ok": true, "data": {
"roles": [
{ "id": 1, "key": "developer_admin", "name": "Developer Admin", "is_system": true,
"permissions": ["properties.manage","customers.view","staff.manage","…"] },
{ "id": 3, "key": "custom_13_accounting", "name": "Accounting", "is_system": false,
"permissions": ["earnings.view","reports.view"] }
]
} }staff.manageFull catalogue of developer-group permissions — the universe of keys you can include in a custom role.
{ "ok": true, "data": {
"permissions": [
{ "id": 4, "key": "earnings.view", "description": "View own earnings and commission history" },
{ "id": 7, "key": "properties.manage", "description": "Create and update property listings" }
]
} }staff.manageCreate a custom role for this developer network. The key is auto-generated as custom_{devId}_{slug}.
| Body | Type | Description | |
|---|---|---|---|
| name | string | required | Human-readable role name, e.g. "Accounting" |
| permission_ids | integer[] | optional | Permission IDs from GET /admin/permissions |
curl -X POST "$API/api/v1/admin/roles" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Accounting","permission_ids":[4,7]}'
const { data } = await fetch(`${API}/api/v1/admin/roles`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: "Accounting", permission_ids: [4, 7] }),
}).then(r => r.json());{ "ok": true, "data": { "id": 3, "key": "custom_13_accounting", "name": "Accounting" } }| Code | Error key | Meaning |
|---|---|---|
| 409 | duplicate | A role with that name already exists for this developer |
staff.manageUpdate a custom role's name and/or permissions. Passing permission_ids replaces the full permission set. System roles cannot be updated.
| Body | Type | Description | |
|---|---|---|---|
| name | string | optional | New display name |
| permission_ids | integer[] | optional | Replaces the current permission set entirely |
staff.manageDelete a custom developer role. Fails if any staff member is currently assigned to it — reassign them first.
| Code | Error key | Meaning |
|---|---|---|
| 409 | has_members | One or more staff members are assigned to this role |
staff.manageAssign a staff member to this role, replacing their previous assignment. Alternative to PATCH /admin/staff/{id}/role — use whichever direction suits your UI.
| Body | Type | Description | |
|---|---|---|---|
| staff_id | integer | required | User ID of the staff member |
Agent commission
When a customer registers with an agent's referral code, they are permanently attributed to that agent. On every subsequent purchase the agent earns a percentage of the sale, credited automatically to their wallet. Rates can be set globally or per-property.
staff.manageGlobal commission rate and any property-specific overrides.
{ "ok": true, "data": {
"global_rate": 2.5,
"rules": [
{ "property_id": 12, "property_name": "Sunset Estate", "rate_percent": 4.0 }
]
} }staff.manageSet or remove agent commission rates. Two scopes:
- Global rate — omit
property_id. Applies to all purchases unless a property override exists. - Property override — include
property_id. Takes precedence over the global rate for purchases of that property only. Pass"rate_percent": nullto remove the override and fall back to the global rate.
| Body | Type | Description | |
|---|---|---|---|
| rate_percent | number | null | required | Commission % of the purchase amount (e.g. 2.5). Pass null together with a property_id to remove that property's override. |
| property_id | integer | optional | Omit to set the global rate; supply to set or remove a per-property override. |
# Global rate
curl -X POST "$API/api/v1/admin/agent-commission" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"rate_percent":2.5}'
# Property override
curl -X POST "$API/api/v1/admin/agent-commission" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"rate_percent":4.0,"property_id":12}'
# Remove property override (reverts to global rate)
curl -X POST "$API/api/v1/admin/agent-commission" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"rate_percent":null,"property_id":12}'
// Set global rate
await fetch(`${API}/api/v1/admin/agent-commission`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ rate_percent: 2.5 }),
}).then(r => r.json());
// Property override
await fetch(`${API}/api/v1/admin/agent-commission`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ rate_percent: 4.0, property_id: 12 }),
}).then(r => r.json());
// Remove property override → falls back to global rate
await fetch(`${API}/api/v1/admin/agent-commission`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ rate_percent: null, property_id: 12 }),
}).then(r => r.json());{ "ok": true, "data": { "saved": true } }{ "ok": true, "data": { "deleted": true } }earnings.viewAn agent's own attribution stats — attributed customer count, total commission earned, and recent history. Called by the agent's own session.
{ "ok": true, "data": {
"attributed_customers": 14, "total_earned": 87500.00,
"recent": [
{ "amount": 5000.00, "property": "Sunset Estate", "created_at": "2026-10-05" }
]
} }Staff, agent & partner payouts
Staff and agents save a bank account once and request withdrawals from their wallet; partners supply bank details per-request (no stored account). The developer reviews all pending requests in one queue and approves or declines each; the physical bank transfer is made manually outside the platform.
Staff / agent — own payouts
earnings.viewReturn the staff/agent's saved bank account details, or null if none saved yet.
{ "ok": true, "data": {
"bank_account": { "bank_name": "Access Bank", "account_number": "0123456789", "account_name": "EMEKA OBI" }
} }earnings.viewSave or update personal bank account details. One account per user; updates replace the existing record.
| Body | Type | Description | |
|---|---|---|---|
| bank_name | string | required | Bank name |
| account_number | string | required | 10-digit NUBAN account number |
| account_name | string | required | Name as it appears on the account |
earnings.viewRequest a withdrawal from the staff/agent's wallet. Min ₦100. Only one pending request at a time is allowed. Funds are not moved until the developer approves.
| Body | Type | Description | |
|---|---|---|---|
| amount | number | required | Amount in naira (min ₦100) |
{ "ok": true, "data": { "reference": "SWR-F3A1C8…", "status": "pending", "amount": 50000.00 } }| Code | Error key | Meaning |
|---|---|---|
| 409 | pending_exists | A withdrawal is already pending — wait for it to be processed |
| 402 | insufficient_funds | Wallet balance below the requested amount |
| 409 | bank_required | No bank account saved yet |
earnings.viewList the staff/agent's own withdrawal requests and their current status.
{ "ok": true, "data": {
"requests": [
{ "id": 2, "reference": "SWR-F3A1…", "amount": 50000.00,
"status": "pending", "created_at": "2026-10-09T14:00:00" }
]
} }Developer — approval queue
withdrawals.approveList all staff and agent withdrawal requests for this network. Filter with ?status=pending.
{ "ok": true, "data": {
"requests": [{
"id": 2, "user_name": "Emeka Obi", "user_type": "staff",
"amount": 50000.00, "bank_name": "Access Bank",
"account_number": "0123456789", "account_name": "EMEKA OBI",
"status": "pending", "created_at": "2026-10-09T14:00:00"
}]
} }withdrawals.approveApprove a pending withdrawal request. Immediately debits the staff/agent's wallet and marks the request approved. Complete the bank transfer manually.
{ "ok": true, "data": { "approved": true, "reference": "SWR-F3A1…" } }withdrawals.approveDecline a pending withdrawal. No wallet movement; optionally provide a reason.
| Body | Type | Description | |
|---|---|---|---|
| note | string | optional | Reason shown to the staff/agent |
API Partners
API Partners are third-party platforms (fintechs, agents, bill-payment apps) that sell your properties to their own customers via an API key. Each partner earns a configurable commission on sales. The developer creates and manages partners; partners access a dedicated portal (same login page) and a REST integration API.
partners.manageList all partners for this developer. Super admin sees all partners across networks with a developer_name field.
{ "ok": true, "data": { "partners": [{
"id": 3, "name": "PayFast NG", "contact_email": "dev@payfast.ng",
"status": "active", "funding_model": "trust",
"commission_rate": 2.5, "commission_trigger": "purchase",
"reconciliation_balance": 125000.00,
"customer_count": 18, "total_commission": 37500.00,
"created_at": "2026-10-01T09:00:00"
}] } }partners.manageCreate a new API partner. This generates an API key for third-party integration AND a portal login (email + temporary password) in one call. Save all three credentials on first display — they cannot be retrieved again.
| Body | Type | Description | |
|---|---|---|---|
| name | string | required | Partner display name |
| contact_email | string | required | Portal login email — must be globally unique across all portal users (staff, agents, developers, partners, banks) |
| commission_rate | number | required | Commission percentage (e.g. 2.5 for 2.5%) |
| commission_trigger | string | required | purchase · each_installment · conversion |
| funding_model | string | required | trust or debit |
{ "ok": true, "data": {
"partner": { "id": 3, "name": "PayFast NG", "status": "active",
"funding_model": "trust", "commission_rate": 2.5 },
"api_key": "pk_live_4f8c2b1d…",
"portal_email": "dev@payfast.ng",
"portal_password": "a3f9c1e0b2d5f8a4"
} }| Code | Error key | Meaning |
|---|---|---|
| 409 | email_taken | Email already in use by another portal user |
| 403 | forbidden | Super admin cannot create partners |
partners.manageUpdate partner settings. All fields are optional — send only what changes. Switching funding_model takes effect immediately on the next fund call; existing balances are unaffected.
| Body | Type | Description | |
|---|---|---|---|
| name | string | optional | New display name |
| contact_email | string | optional | New portal login email (must remain globally unique) |
| commission_rate | number | optional | New commission percentage |
| commission_trigger | string | optional | purchase · each_installment · conversion |
| funding_model | string | optional | trust or debit |
| status | string | optional | active or inactive |
partners.manageInvalidate the current API key and generate a new one. Requires the developer's admin PIN. The old key stops working immediately.
| Body | Type | Description | |
|---|---|---|---|
| pin | string | required | 6-digit admin transaction PIN |
{ "ok": true, "data": { "api_key": "pk_live_9c3e1a2f…" } }partners.manageTrust model only. Mark the outstanding reconciliation balance as settled (partner has remitted the funds externally). Resets reconciliation_balance to zero and logs the event with the amount cleared. Call this after the partner pays you by bank transfer or other means.
| Body | Type | Description | |
|---|---|---|---|
| note | string | optional | Reconciliation note (e.g. "Ref: GTB 20261015") |
{ "ok": true, "data": {
"reconciled_naira": 125000.00,
"message": "Reconciliation complete. Balance reset to ₦0."
} }| Code | Error key | Meaning |
|---|---|---|
| 422 | not_trust_model | Partner is on the debit model — no reconciliation needed |
| 422 | nothing_to_reconcile | Balance is already zero |
Bid offers
properties.manageList bid offers submitted by customers on your network's properties. Filter by status to see active bids. The customer's name is included; contact details are not surfaced here either — the developer brokers the deal directly.
| Query | Type | Description | |
|---|---|---|---|
| status | string | optional | active (default) or withdrawn — omit for all |
| page / per | integer | optional | Pagination (default page 1, per 25) |
curl "$API/api/v1/admin/bid-offers?status=active" \ -H "Authorization: Bearer $TOKEN"
const { data } = await fetch(`${API}/api/v1/admin/bid-offers?status=active`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());{ "ok": true, "data": {
"bid_offers": [{
"id": 3, "property_id": 12, "property_name": "Palm Estate",
"customer_id": 42, "customer_name": "Ada Okoro",
"message": "Looking to acquire more packets.",
"status": "active", "created_at": "2026-10-13 09:14:00"
}],
"count": 1,
"pagination": { "page": 1, "per": 25, "total": 1, "pages": 1 }
} }Close (withdraw) a bid offer on behalf of the customer with PATCH /admin/bid-offers/{id} (empty body). Returns { "closed": true }.
Feature Terms & Conditions
branding.manageList all six feature T&C slots with their current content, version and whether a custom copy is active. If is_custom is false, the platform default is in use. The variables array lists substitution tokens available in content (e.g. {{developer_name}}).
curl "$API/api/v1/admin/terms" \ -H "Authorization: Bearer $TOKEN"
const { data } = await fetch(`${API}/api/v1/admin/terms`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());{ "ok": true, "data": {
"terms": [{
"feature_key": "transfer", "title": "Transfer Terms & Conditions",
"content": "By proceeding with this transfer …",
"version": 1, "is_custom": false, "updated_at": null
}, { "feature_key": "bid_offer", … }, { "feature_key": "secondary_market", … },
{ "feature_key": "property_listing", … }, { "feature_key": "installment_plan", … },
{ "feature_key": "buy_packets", … }],
"variables": [{ "name": "{{developer_name}}", "description": "Your company name as set in branding settings" }]
} }Save a custom T&C for a feature with POST /admin/terms/{key} — body: { title, content }. The version is incremented automatically; customers who previously accepted will see the updated modal on their next action. Reset to the platform default with DELETE /admin/terms/{key}.
| Path param | Valid values |
|---|---|
| key | transfer · bid_offer · secondary_market · property_listing · installment_plan · buy_packets |
Property documents
properties.manageList, upload and delete documents attached to a property listing. Documents are visible to authenticated customers via GET /properties/{id}/documents. Only attach masked or redacted copies — do not expose raw title documents.
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/properties/{id}/documents | List documents (id, label, url, original_name, file_size, created_at) |
| POST | /admin/properties/{id}/documents | Upload a document — multipart/form-data: label (text) + document (file). Accepted: PDF, JPEG, PNG, WEBP, DOC, DOCX · max 10 MB |
| DELETE | /admin/properties/{id}/documents/{docId} | Delete a document (also removes the file from disk) |
curl -X POST "$API/api/v1/admin/properties/12/documents" \ -H "Authorization: Bearer $TOKEN" \ -F "label=Certificate of Occupancy" \ -F "document=@/path/to/coo-masked.pdf"
const fd = new FormData();
fd.append("label", "Certificate of Occupancy");
fd.append("document", fileInput.files[0]);
await fetch(`${API}/api/v1/admin/properties/12/documents`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
}).then(r => r.json());{ "ok": true, "data": { "document": {
"id": 5, "label": "Certificate of Occupancy",
"url": "https://…/uploads/properties/12/docs/coo-masked.pdf",
"original_name": "coo-masked.pdf", "file_size": 204800,
"mime_type": "application/pdf", "created_at": "2026-10-13T10:00:00"
} } }Property categories
properties.manageManage developer-scoped categories that group listings into tabs on the customer discover page. A developer can have at most 3 categories. The first is the default — created automatically when the developer posts their first property (named "Properties" unless renamed). The default category cannot be deleted; the other two can (their properties are reassigned to the default). All category names are editable with PATCH.
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/property-categories | List all categories in position order |
| POST | /admin/property-categories | Create a category — body: { name }. Fails with max_categories if already at 3 |
| PATCH | /admin/property-categories/{id} | Rename — body: { name }. Works on the default category too |
| DELETE | /admin/property-categories/{id} | Delete a non-default category; its properties move to the default |
curl "$API/api/v1/admin/property-categories" \
-H "Authorization: Bearer $TOKEN"
curl -X POST "$API/api/v1/admin/property-categories" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Phase 2"}'
curl -X PATCH "$API/api/v1/admin/property-categories/3" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Residential"}'
// List
const { data } = await fetch(`${API}/api/v1/admin/property-categories`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());
// Create
await fetch(`${API}/api/v1/admin/property-categories`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: "Phase 2" }),
}).then(r => r.json());{ "ok": true, "data": { "categories": [
{ "id": 3, "name": "Properties", "is_default": true, "position": 1 },
{ "id": 1, "name": "Phase 1", "is_default": false, "position": 2 }
] } }{ "ok": true, "data": { "category": { "id": 2, "name": "Phase 2", "is_default": false, "position": 2 } } }When a property is created without a property_category_id, it is assigned to the default category automatically. Pass property_category_id in POST /admin/properties or PATCH /admin/properties/{id} to assign or reassign a listing.
Allocations
properties.manageAssign a physical plot number to a customer's packet holding. Allocations model the physical unit behind the fractional ownership: a plot number, a unit fraction (how much of one full unit the allocation represents), and optionally co-ownership (multiple customers share one plot). The minimum fraction is 0.5 — sub-half-unit allocations are not supported. Co-owners get individual allocation_shares rows.
| Method | Path | Purpose |
|---|---|---|
| POST | /admin/properties/{id}/allocate | Create allocation — assigns a plot to a customer |
| GET | /admin/properties/{id}/allocations | List allocations for a property |
| GET | /admin/allocations | List all allocations (filterable by ?property_id= and ?status=) |
| GET | /admin/allocations/pending-docs | List allocations with pending document requests |
| GET | /admin/allocations/{id} | Single allocation detail with all shares |
| PATCH | /admin/allocations/{id}/dissolve | Dissolve an allocation (only while status=active and before any doc request) |
| PATCH | /admin/allocations/{id}/approve-doc | Approve a doc request — set pickup location, status → doc_pending_pickup |
| PATCH | /admin/allocations/{id}/confirm-doc | Confirm physical document was handed to customer — status → doc_issued (permanent lock) |
| Body (POST allocate) | Type | Description | |
|---|---|---|---|
| customer_email or customer_id | string / int | required | Customer to assign the plot to |
| plot_number | string | required | Physical plot identifier, unique per property (e.g. A12) |
| unit_fraction | number | optional | Fraction of a full unit (default 1.0, minimum 0.5, rounded to 0.5 increments) |
curl -X POST "$API/api/v1/admin/properties/12/allocate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"customer_email":"ada@example.com","plot_number":"A12","unit_fraction":1.0}'
const { data } = await fetch(`${API}/api/v1/admin/properties/12/allocate`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ customer_email: "ada@example.com", plot_number: "A12", unit_fraction: 1.0 }),
}).then(r => r.json());{ "ok": true, "data": { "allocation": {
"id": 1, "property_id": 12, "plot_number": "A12",
"unit_fraction": 1.0, "total_packets": 100, "status": "active",
"shares": [{ "customer_id": 42, "customer_name": "Ada Okoro", "packets": 100 }]
} } }Status machine: active → customer requests document → doc_requested → developer approves with pickup location → doc_pending_pickup → developer confirms handover → doc_issued (permanent). dissolved is a terminal state set by the developer from active only. Doc-issued packets are locked: they cannot be transferred, sold on secondary market, or used as loan collateral.
Co-ownership: if a plot's total packets are split across more than one customer (each has their own share), no single customer owns 100% and therefore no one can request the physical document until they consolidate. can_request_doc on the customer allocation response signals eligibility.
Document request (customer-facing): POST /allocations/{id}/doc-request · DELETE /allocations/{id}/doc-request (cancel). See Property documents for the customer-side listing.
Partner API
The Partner API lets third-party platforms (fintechs, agents, bill-payment apps) sell your properties to their own customer base, credit customer wallets, and trigger purchases — all under your developer brand. Partners are created by you (the developer) and earn configurable commissions.
There are two integration modes for partners:
| Mode | Auth header | Use case |
|---|---|---|
| Third-party API | X-Partner-Key: pk_live_… | Server-to-server integration: enroll customers, fund wallets, trigger purchases programmatically from the partner's own backend |
| Partner portal | Authorization: Bearer … | Web dashboard login (same URL as staff/developer portal): view customers, commissions, wallet, and request withdrawals |
/api/v1/partner/* endpoints use X-Partner-Key. All /api/v1/admin/partner-portal/* endpoints use a Bearer token obtained from the standard POST /auth/login (omit the developer field for portal users).Enroll customer
Register a new customer on the developer's network via the partner. Returns a unique ref that identifies this customer in all subsequent partner calls. The partner's commission is attributed to sales this customer makes.
| Body | Type | Description | |
|---|---|---|---|
| name | string | required | Full name |
| string | required | Customer email | |
| phone | string | optional | Phone number |
| password | string | optional | Initial password. If omitted a random one is generated. |
curl -X POST "$API/api/v1/partner/customers" \
-H "X-Partner-Key: pk_live_4f8c2b1d…" \
-H "Content-Type: application/json" \
-d '{"name":"Ada Okoro","email":"ada@example.com","phone":"08012345678"}'
await fetch(`${API}/api/v1/partner/customers`, {
method: "POST",
headers: { "X-Partner-Key": "pk_live_4f8c2b1d…", "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ada Okoro", email: "ada@example.com", phone: "08012345678" }),
}).then(r => r.json());{ "ok": true, "data": {
"customer": { "ref": "USR-A1B2C3", "name": "Ada Okoro", "email": "ada@example.com" }
} }Fund customer wallet
Credit a customer's wallet with funds collected by the partner. Behaviour depends on the funding model set for this partner:
- Trust model: customer wallet is credited immediately. The amount is added to the partner's
reconciliation_balance— the running total the partner owes the developer. The developer reconciles this manually when the partner remits. - Debit model: the same amount is first debited from the partner's own wallet and then credited to the customer. The call is rejected if the partner wallet has insufficient balance.
| Body | Type | Description | |
|---|---|---|---|
| amount | number | required | Amount in naira |
| reference | string | optional | Your idempotency reference — repeated calls with the same reference are no-ops |
| note | string | optional | Memo shown in the customer's transaction history |
curl -X POST "$API/api/v1/partner/customers/USR-A1B2C3/wallet/fund" \
-H "X-Partner-Key: pk_live_4f8c2b1d…" \
-H "Content-Type: application/json" \
-d '{"amount":50000,"reference":"PF-TXN-00123","note":"Payment for Palm Estate"}'
await fetch(`${API}/api/v1/partner/customers/USR-A1B2C3/wallet/fund`, {
method: "POST",
headers: { "X-Partner-Key": "pk_live_4f8c2b1d…", "Content-Type": "application/json" },
body: JSON.stringify({ amount: 50000, reference: "PF-TXN-00123" }),
}).then(r => r.json());{ "ok": true, "data": {
"funded": 50000.00, "customer_balance": 80000.00,
"funding_model": "trust",
"reconciliation_balance": 125000.00
} }| Code | Error key | Meaning |
|---|---|---|
| 402 | insufficient_funds | Debit model: partner wallet balance too low |
| 409 | duplicate_reference | This reference was already processed |
Purchase & installments
Partners can trigger purchases and installment plans on behalf of enrolled customers. The customer's wallet must have sufficient balance first (use fund wallet). Commission is attributed to the partner according to the commission_trigger setting.
| Method | Path | Purpose |
|---|---|---|
| POST | /partner/customers/{ref}/purchase | Buy packets — body: { property_id, amount, pin } (customer's PIN) |
| POST | /partner/customers/{ref}/installments | Subscribe to an installment plan — body: { property_id, installment_amount, frequency, tenor_count, pin } |
| GET | /partner/customers/{ref}/installments | List the customer's active installment plans |
| GET | /partner/customers/{ref}/installments/{planId} | Show a single plan with payment schedule |
| POST | /partner/customers/{ref}/installments/{planId}/pay | Pay one installment — body: { amount, pin } |
Customer portfolio & transactions
| Method | Path | Purpose |
|---|---|---|
| GET | /partner/customers/{ref}/portfolio | All holdings for the customer (units, packets, value) |
| GET | /partner/customers/{ref}/portfolio/{holdingId} | Single holding detail |
| GET | /partner/customers/{ref}/transactions | Customer ledger — ?page&per |
| GET | /partner/customers/{ref} | Customer profile (name, email, status, wallet balance) |
| GET | /partner/customers | All customers enrolled by this partner — paginated |
| GET | /partner/properties | Active properties on the developer's network |
| GET | /partner/properties/{id} | Single property detail |
Partner wallet & commissions
Partner's own wallet: balance, total commission earned, and pending withdrawal amount.
{ "ok": true, "data": {
"wallet": {
"balance": 37500.00,
"total_commission_earned": 42000.00,
"pending_withdrawal": 0.00,
"funding_model": "trust",
"reconciliation_balance": 125000.00
}
} }Full ledger for the partner's wallet. Filterable.
| Query | Type | Description |
|---|---|---|
| type | string | all (default) · commission · topup · withdrawal · fund_out |
| direction | string | all (default) · credit · debit |
| from / to | string | Date range — YYYY-MM-DD |
| page / per | integer | Pagination (default per 25) |
{ "ok": true, "data": {
"transactions": [{
"date": "2026-10-14T10:22:00", "description": "Commission — COM-9F2C…",
"txn_type": "commission", "direction": "credit",
"amount_naira": 2500.00, "balance_after": 37500.00
}],
"pagination": { "page": 1, "per": 25, "total": 12, "pages": 1 }
} }Request a withdrawal from the partner's wallet. Bank details are supplied per-request (no stored bank account for partners). The developer approves from the same withdrawal queue as staff/agent withdrawals.
| Body | Type | Description | |
|---|---|---|---|
| amount | number | required | Amount in naira |
| bank_code | string | required | 3-digit CBN bank code (e.g. 058) |
| account_number | string | required | 10-digit NUBAN |
| account_name | string | required | Account name |
{ "ok": true, "data": {
"withdrawal": { "reference": "PWR-3A1C8F…", "amount": 30000.00, "status": "pending" }
} }List all past withdrawal requests for this partner with status and bank details.
Commission earn history — each entry shows the customer, base transaction amount, rate applied, and commission credited to the partner wallet.
Partner portal (web dashboard)
Partners log into the admin portal at the same URL as staff and developers. The portal recognises the partner account type and shows a restricted view: dashboard, customers, commissions, and wallet. All portal endpoints use a standard Bearer token from POST /auth/login (no developer field needed).
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/partner-portal/me | Full partner profile: developer name, funding model, wallet balance, commission totals, customer count |
| GET | /admin/partner-portal/wallet | Wallet summary: balance, total earned, pending withdrawal, reconciliation balance |
| GET | /admin/partner-portal/wallet/transactions | Ledger — same filters as the API key version (type, direction, from, to) |
| GET | /admin/partner-portal/wallet/withdrawals | Withdrawal request history |
| POST | /admin/partner-portal/wallet/fund | Self-service top-up: body { amount_naira, reference, note }. Credited immediately (trust). Idempotent on reference. |
| POST | /admin/partner-portal/withdraw | Request withdrawal — body: { amount_naira, bank_code, account_number, account_name } |
| GET | /admin/partner-portal/commissions | Commission earn history — paginated |
| GET | /admin/partner-portal/customers | Customers enrolled by this partner — paginated |
Bank Admin API
Banks that want to manage AtomCredit loans from their own systems: rather than the AtomOwn admin portal: use these endpoints. Auth is the same bearer-token model: sign in with the bank admin credentials issued at onboarding, then use the token on every call. All routes are under /api/v1/admin/bank/*.
bank_id is resolved from the token: never passed as a parameter. Bank admin tokens carry only bank.* permissions; they cannot reach any developer-admin or customer routes.Exchange bank admin credentials for a bearer token. Do not pass a developer field: bank admins are not tenant-scoped.
curl -X POST "$API/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"emeka@firsttrustbank.ng","password":"BkA3F8c1!"}'
const { data } = await fetch(`${API}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "emeka@firsttrustbank.ng", password: "BkA3F8c1!" }),
}).then(r => r.json());
const token = data.token;{ "ok": true, "data": { "token": "ao_live_7e4a…", "user": { "id": 7, "type": "bank_admin",
"name": "Emeka Obi", "email": "emeka@firsttrustbank.ng", "bank_id": 1 } } }Parameters
List all parameter versions for your bank ordered by effective_from DESC. The version currently in effect is the most recent one where today falls within effective_from … effective_until. New credit requests always use the currently effective version. Existing facilities carry a frozen snapshot of the version that was active at approval — they are unaffected by later changes.
{ "ok": true, "data": {
"versions": [{
"id": 2, "bank_id": 1, "version": 2,
"effective_from": "2026-09-01", "effective_until": null,
"interest_rate": "18.0000",
"tenor_options_months": "6,12,24,36",
"advance_cap": "30.0000",
"default_fee_pct": "5.0000",
"net_realisation_factor": "0.8500",
"cure_period_days": 30,
"resale_period_days": 90,
"settlement_period_days": 14,
"listing_price_floor_factor": "0.7500",
"release_step_pct": "1.0000",
"request_lock_expiry_hours": 168,
"allocation_window_hours": 72,
"realisation_cost_fixed": 0,
"created_by": 5, "created_at": "2026-09-01T08:00:00"
}]
} }
// tenor_options_months: comma-separated list of allowed tenor choices shown to customers
// advance_cap: max LTV % (e.g. "30.0000" = 30% of unit value)
// default_fee_pct: penalty % added to outstanding balance on enforcement notice
// net_realisation_factor: expected recovery fraction on forced sale (used in risk calc)
// release_step_pct: minimum % increment for progressive lock release after each repaymentCreate a new parameter version. It takes effect from effective_from and supersedes previous versions for any new credit request submitted on or after that date. All fields are required.
| Body | Type | Description | |
|---|---|---|---|
| effective_from | date | required | Date (YYYY-MM-DD) from which this version applies |
| effective_until | date | optional | Expiry date — leave null for open-ended |
| interest_rate | decimal | required | Annual interest rate % (e.g. "18.0000") |
| tenor_options_months | string | required | Comma-separated allowed tenor values shown to customers (e.g. "6,12,24,36") |
| advance_cap | decimal | required | Maximum LTV % — principal cannot exceed this fraction of unit value (e.g. "30.0000") |
| default_fee_pct | decimal | required | Penalty % added to outstanding balance when enforcement notice is served |
| net_realisation_factor | decimal | required | Assumed recovery fraction on forced sale (e.g. "0.8500" = 85%) |
| cure_period_days | integer | required | Days the borrower has to cure arrears after default notice before escalation |
| resale_period_days | integer | required | Maximum days allowed to sell the collateral once enforcement notice is served |
| settlement_period_days | integer | required | Days to complete settlement after sale proceeds are received |
| listing_price_floor_factor | decimal | required | Minimum listing price as a fraction of last known unit value (e.g. "0.7500") |
| release_step_pct | decimal | required | Minimum lock-release increment per repayment cycle (e.g. "1.0000" = 1%) |
| request_lock_expiry_hours | integer | required | Hours before an un-acted-upon credit request expires (e.g. 168 = 7 days) |
| allocation_window_hours | integer | required | Hours after disbursement that the developer has to allocate the unit (purchase finance) |
| realisation_cost_fixed | integer | required | Fixed cost (kobo) charged against sale proceeds for enforcement expenses. Use 0 if none. |
curl -X POST "$API/api/v1/admin/bank/parameters" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"effective_from":"2026-10-01","interest_rate":"18.0000",
"tenor_options_months":"6,12,24,36","advance_cap":"30.0000",
"default_fee_pct":"5.0000","net_realisation_factor":"0.8500",
"cure_period_days":30,"resale_period_days":90,"settlement_period_days":14,
"listing_price_floor_factor":"0.7500","release_step_pct":"1.0000",
"request_lock_expiry_hours":168,"allocation_window_hours":72,
"realisation_cost_fixed":0
}'
await fetch(`${API}/api/v1/admin/bank/parameters`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
effective_from: "2026-10-01", interest_rate: "18.0000",
tenor_options_months: "6,12,24,36", advance_cap: "30.0000",
default_fee_pct: "5.0000", net_realisation_factor: "0.8500",
cure_period_days: 30, resale_period_days: 90, settlement_period_days: 14,
listing_price_floor_factor: "0.7500", release_step_pct: "1.0000",
request_lock_expiry_hours: 168, allocation_window_hours: 72,
realisation_cost_fixed: 0,
}),
}).then(r => r.json());{ "ok": true, "data": { "version": { "id": 3, "version": 3,
"effective_from": "2026-10-01", "interest_rate": "18.0000",
"tenor_options_months": "6,12,24,36", "advance_cap": "30.0000",
"created_by": 5, "created_at": "2026-10-01T08:00:00" } } }Update a parameter version. effective_until can always be changed (use this to retire a version early). All other fields can only be changed while no facility has been approved against this version — once a facility references it the terms are frozen.
| Body | Type | Description | |
|---|---|---|---|
| effective_until | date | optional | Expiry date — pass null to remove the expiry |
| interest_rate · tenor_options_months · advance_cap · … | various | optional | Any other parameter field — only accepted if the version has never been used by a facility (in_use: false) |
{ "ok": true, "data": { "version": { "id": 3, "interest_rate": "18.0000", … }, "in_use": false } }
// in_use: true means non-effective_until fields were ignored (version is locked)Credit request queue
Credit requests forwarded to this bank by developer admins. Filter by status to see requests awaiting decision (with_bank). Paginated. Use GET /admin/bank/credit-requests/{id} for full detail.
| Query | Type | Description | |
|---|---|---|---|
| status | string | optional | with_bank (awaiting decision) · approved (deed pending signatures). Only these two values are accepted; other values are ignored. |
| product_type | string | optional | equity_release or purchase_finance |
| page / per | integer | optional | Pagination (default page 1, per 20) |
curl "$API/api/v1/admin/bank/credit-requests?status=with_bank" \ -H "Authorization: Bearer $TOKEN"
const { data } = await fetch(`${API}/api/v1/admin/bank/credit-requests?status=with_bank`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());{ "ok": true, "data": {
"credit_requests": [{
"id": 7, "product_type": "equity_release", "status": "with_bank",
"requested_principal": 500000.00, "tenor_months": 12,
"customer": { "name": "Ada Okoro", "kyc_status": "approved" },
"created_at": "2026-10-02T10:00:00"
}],
"pagination": { "page": 1, "per": 20, "total": 1, "pages": 1 }
} }Full detail: customer KYC, live property holdings and portfolio value at current prices, requested terms, and the applicable parameter version.
{ "ok": true, "data": {
"credit_request": { "id": 7, "status": "with_bank", "product_type": "equity_release",
"requested_principal": 500000.00, "tenor_months": 12, "interest_rate": 18.0 },
"customer": { "name": "Ada Okoro", "email": "ada@example.com",
"kyc_status": "approved", "dob": "1990-03-15" },
"holdings": [
{ "property": "Lekki Gardens Phase 1", "unit_type": "plot",
"packets": 5, "current_value": 1250000.00,
"free_packets": 5, "locked_packets": 0 }
],
"live_portfolio_value": 1250000.00,
"max_eligible_principal": 375000.00
} }Approve the credit request and generate the offer deed. Status moves with_bank → approved. The deed is stored; both parties must sign before a facility is created. You may override the principal or tenor within your parameter limits.
| Body | Type | Description | |
|---|---|---|---|
| approved_principal | number | optional | Override the approved principal (must be ≤ requested and within LTV) |
| approved_tenor_months | integer | optional | Override the approved tenor |
| note | string | optional | Approval note shown to the customer |
curl -X POST "$API/api/v1/admin/bank/credit-requests/7/approve" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"approved_principal":450000,"approved_tenor_months":12}'
await fetch(`${API}/api/v1/admin/bank/credit-requests/7/approve`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ approved_principal: 450000, approved_tenor_months: 12 }),
}).then(r => r.json());{ "ok": true, "data": {
"credit_request": { "id": 7, "status": "approved",
"approved_principal": 450000.00, "approved_tenor_months": 12,
"decided_by_user_id": 5, "decided_at": "2026-10-03T11:15:00" }
} }Decline the credit request at the bank level. Status moves to declined; provisional ownership locks are released. The customer is notified with your reason.
| Body | Type | Description | |
|---|---|---|---|
| reason | string | optional | Decline reason shown to the customer and developer |
{ "ok": true, "data": { "credit_request": { "id": 7, "status": "declined",
"decided_by_user_id": 5, "decided_at": "2026-10-03T11:20:00",
"decision_reason": "Insufficient equity coverage." } } }Request additional information from the developer or customer. Status moves with_bank → info_requested. The request returns to your queue once the developer re-confirms.
| Body | Type | Description | |
|---|---|---|---|
| note | string | required | Specific information required (shown to developer and customer) |
{ "ok": true, "data": { "credit_request": { "id": 7, "status": "info_requested" } } }Counter-sign the offer deed on behalf of the bank. Status moves to pending_signing and both the customer and developer admin are notified to sign. Once all three parties have signed, the facility is created automatically.
| Body | Type | Description | |
|---|---|---|---|
| pin | string | required | Bank admin's 6-digit transaction PIN |
{ "ok": true, "data": {
"credit_request": { "id": 7, "status": "pending_signing" },
"message": "Bank signature recorded. Awaiting customer and developer signatures."
} }Portfolio (facilities)
All AtomCredit facilities the bank has disbursed, across all statuses. Filter by status or product_type. Read a single facility with GET /admin/bank/facilities/{id}.
| Query | Type | Description | |
|---|---|---|---|
| status | string | optional | pending_disbursement · pending_allocation · active · in_arrears · enforcement · closed_repaid |
| product_type | string | optional | equity_release or purchase_finance |
| page / per | integer | optional | Pagination |
{ "ok": true, "data": {
"facilities": [{
"id": 3, "product_type": "equity_release", "status": "active",
"customer": { "id": 9, "name": "Ada Okoro" },
"principal": 500000.00, "outstanding_principal": 450000.00,
"interest_rate": 18.0, "tenor_months": 12, "repayment_day_of_month": 5,
"monthly_installment": 45833.33, "loan_ends_at": "2027-09-15",
"disbursed_at": "2026-09-15T09:00:00", "bank_reference": "BNK-2026-003"
}],
"pagination": { "page": 1, "per": 20, "total": 1, "pages": 1 }
} }Record disbursement of the facility. Equity release: debits AtomCredit fee from bank wallet, disburses loan principal to customer wallet, facility → active. Purchase finance: debits loan fee + principal from bank wallet, auto-deducts customer contribution from customer wallet, executes the property purchase (investment, lock, inventory, platform fee, agent commission), facility → active. Everything happens atomically.
| Body | Type | Description | |
|---|---|---|---|
| repayment_day_of_month | integer | optional | Day of the month repayments fall due (1–28). Defaults to the calendar day of disbursement if omitted. |
curl -X POST "$API/api/v1/admin/bank/facilities/3/disburse" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"repayment_day_of_month":5}'
await fetch(`${API}/api/v1/admin/bank/facilities/3/disburse`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ repayment_day_of_month: 5 }),
}).then(r => r.json());{ "ok": true, "data": {
"facility": { "id": 3, "status": "active", "disbursed_at": "2026-09-15T09:00:00" },
"atomcredit_fee": 4500.00, "fee_rate_pct": 1.0
} }| Error code | Status | Meaning |
|---|---|---|
| wrong_status | 409 | Facility is not in pending_disbursement state |
| insufficient_funds | 422 | Bank wallet balance is below the AtomCredit fee |
Repayments & facility lifecycle
Retrieve the full repayment schedule for a facility, including each instalment's status, expected amount, and cleared date.
{ "ok": true, "data": {
"facility_id": 3, "principal": 450000.00,
"repayments": [
{ "id": 11, "amount": 44250.00, "is_cleared": true,
"cleared_at": "2026-10-04T14:00:00", "source": "manual",
"bank_reference": "TRF-001", "idempotency_key": "REP-FAC3-OCT26",
"required_lock_pct_after": 91.67, "released_pct": 8.33,
"release_applied_at": "2026-10-04T14:01:00",
"is_reversed": false, "reversed_at": null, "reversal_reason": null },
{ "id": 12, "amount": 44250.00, "is_cleared": false,
"cleared_at": null, "source": "manual",
"bank_reference": null, "idempotency_key": "REP-FAC3-NOV26",
"required_lock_pct_after": 83.33, "released_pct": null,
"release_applied_at": null, "is_reversed": false }
]
} }Report that the customer has made a payment. Creates a repayment record (uncleared by default). Use the /clear action once you confirm the funds arrived, or pass is_cleared: true to mark it cleared immediately. idempotency_key prevents duplicate entries on retry.
| Body | Type | Description | |
|---|---|---|---|
| amount | number | required | Amount received in naira |
| idempotency_key | string | required | Unique key to prevent duplicate records on retry (e.g. "REP-FAC3-NOV26") |
| bank_reference | string | optional | Your internal payment / transfer reference |
| source | string | optional | Payment channel (e.g. "manual", "auto_debit") |
| is_cleared | boolean | optional | Mark cleared immediately (default false) |
{ "ok": true, "data": { "repayment": {
"id": 13, "amount": 44250.00, "is_cleared": false,
"idempotency_key": "REP-FAC3-NOV26", "bank_reference": null, "source": "manual"
} } }| Error code | Status | Meaning |
|---|---|---|
| amount_exceeds_outstanding | 422 | Amount exceeds the outstanding principal balance |
Confirm (clear) a repayment. Marks it cleared and releases the proportional locked percentage from the customer's holdings. The release object shows what percentage was released and whether it was applied immediately or suspended.
{ "ok": true, "data": {
"repayment": { "id": 12, "is_cleared": true, "cleared_at": "2026-11-04T15:00:00" },
"release": {
"required_lock_pct_after": 75.0,
"released_pct": 8.33,
"applied": true,
"suspended": false
}
} }Reverse a previously cleared repayment (e.g. a returned cheque). Re-locks the released percentage and returns the instalment to pending.
| Body | Type | Description | |
|---|---|---|---|
| reason | string | required | Reason for reversal (recorded in audit log and shown on the repayment record) |
{ "ok": true, "data": { "repayment": { "id": 12, "status": "pending", "reversed": true } } }Discharge a facility (customer has repaid in full). Releases 100% of locked holdings, moves status to closed_repaid, and generates a release deed documenting the discharge. The bank must explicitly call this endpoint; there is no automatic discharge. Always call this after confirming full repayment.
| Body | Type | Description | |
|---|---|---|---|
| note | string | optional | Discharge note (audit log) |
{ "ok": true, "data": {
"facility": { "id": 3, "status": "closed_repaid" },
"lock_state": "released",
"release_deed": {
"doc_version_id": 2, "doc_ref": "deed-release-fac3",
"content_hash": "abc123…", "document_path": "deeds/release/fac3.html"
}
} }Mark a facility as in arrears after missed or partial payments. Status moves active → in_arrears. Sends the customer an arrears notification.
| Body | Type | Description | |
|---|---|---|---|
| note | string | optional | Arrears note shown to the customer |
{ "ok": true, "data": { "facility": { "id": 3, "status": "in_arrears" } } }Restore an in-arrears facility to active after the customer has caught up on missed payments.
{ "ok": true, "data": { "facility": { "id": 3, "status": "active" } } }Open an enforcement case for a facility in active or in_arrears status. Creates an enforcement_case record in notice_served state. The customer receives a default notice notification.
| Body | Type | Description | |
|---|---|---|---|
| note | string | optional | Notice text visible to the customer |
{ "ok": true, "data": {
"facility": { "id": 3 },
"enforcement_case": { "id": 1, "status": "notice_served" }
} }Enforcement cases
All enforcement cases for the bank's facilities. Filter by status and/or facility_id. Read a single case with GET /admin/bank/enforcement-cases/{id}.
| Query | Type | Description | |
|---|---|---|---|
| status | string | optional | notice_served · cure_running · enforcement_notice · listed · offer_accepted · settled · allocated · closed |
| facility_id | integer | optional | Filter cases for a specific facility (used on the facility detail page) |
{ "ok": true, "data": {
"enforcement_cases": [{
"id": 1, "facility_id": 3, "status": "cure_running",
"customer": { "name": "Ada Okoro" },
"outstanding_at_notice": 88500.00, "default_fee": 4425.00,
"enforcement_portion_pct": 80.0, "opened_at": "2026-10-01T08:00:00",
"cure_period_ends_at": "2026-10-22T08:00:00"
}],
"count": 1
} }Give the customer a cure period to repay the arrears. Status moves notice_served → cure_running. The cure_period_ends_at is calculated as cure_days from today (default 21 days).
| Body | Type | Description | |
|---|---|---|---|
| cure_days | integer | optional | Number of days for the cure window (default 21) |
| note | string | optional | Message to the customer about the cure requirements |
{ "ok": true, "data": {
"enforcement_case": { "id": 1, "status": "cure_running",
"cure_period_ends_at": "2026-10-22T08:00:00" }
} }Record that the customer paid the arrears. Closes the enforcement case (settled), moves the facility back to active, and applies a consolidated lock release for all cleared repayments since the case was opened. Can be called from either notice_served or cure_running status.
{ "ok": true, "data": {
"enforcement_case": { "id": 1, "status": "settled" },
"facility": { "id": 3, "status": "active" }
} }Escalate from cure period to formal enforcement. Status moves → enforcement_notice. The customer receives a formal notice; the bank may then list the collateral for sale.
| Body | Type | Description | |
|---|---|---|---|
| note | string | optional | Notice text (shown to customer and recorded in audit log) |
{ "ok": true, "data": { "enforcement_case": { "id": 1, "status": "enforcement_notice" } } }Record a customer-raised dispute against an enforcement case. The dispute is appended to the audit log as a case.disputed event. The enforcement case status is not changed — the bank must then review and decide how to proceed.
| Body | Type | Description | |
|---|---|---|---|
| note | string | required | Nature of the customer's dispute (recorded verbatim in the audit log) |
{ "ok": true, "data": { "enforcement_case": { "id": 1, "status": "enforcement_notice" },
"dispute_recorded": true } }Wallet
Read-only view of the bank's AtomCredit wallet. The AtomCredit platform fee is debited from here on each disbursement.
{ "ok": true, "data": {
"wallet": { "balance": 4800000.00, "total_funded": 5000000.00, "total_withdrawn": 200000.00 },
"statement": [
{ "id": 99, "type": "credit", "amount": 5000000.00,
"description": "Initial wallet credit", "created_at": "2026-09-01 09:00:00" },
{ "id": 101, "type": "debit", "amount": 4500.00,
"description": "AtomCredit fee: facility #3", "created_at": "2026-09-15 09:30:00" }
]
} }Reports
Portfolio summary: total active facilities by product type, outstanding balance, collected repayments, and AtomCredit fees paid to date.
{ "ok": true, "data": {
"active_facilities": 12,
"total_outstanding": 5400000.00,
"equity_release": { "count": 8, "outstanding": 3600000.00 },
"purchase_finance": { "count": 4, "outstanding": 1800000.00 },
"total_repayments_cleared": 1200000.00,
"total_atomcredit_fees": 54000.00
} }All facilities currently in in_arrears or enforcement status, with the arrears amount and number of missed payments.
{ "ok": true, "data": {
"arrears": [{
"facility_id": 3, "customer_name": "Ada Okoro", "status": "in_arrears",
"principal": 450000.00, "arrears_amount": 88500.00,
"missed_payments": 2, "last_cleared_at": "2026-09-04T14:00:00"
}]
} }All open enforcement cases with their current status, cure deadlines, and collateral summary.
{ "ok": true, "data": {
"enforcement_cases": [{
"id": 1, "facility_id": 3, "status": "cure_running",
"customer_name": "Ada Okoro", "outstanding_at_notice": 88500.00,
"cure_period_ends_at": "2026-10-22T08:00:00",
"enforcement_portion_pct": 80.0
}]
} }Collateral realisation history: facilities resolved through enforcement, with proceeds recovered and any residual balance.
{ "ok": true, "data": {
"realisations": [{
"enforcement_case_id": 1, "facility_id": 3, "customer_name": "Ada Okoro",
"outstanding_at_enforcement": 350000.00,
"proceeds_recovered": 380000.00, "residual": 30000.00,
"resolved_at": "2026-11-10T10:00:00"
}]
} }Team (staff)
Requires bank.staff.manage permission (assigned to the bank_admin role by default). All operations are scoped to the authenticated bank — the bank_id is resolved from the token.
bank.staff.manageList all staff members for this bank. Paginated (?page/?per). Returns name, email, phone, type, role, status.
{ "ok": true, "data": {
"count": 2,
"pagination": { "page": 1, "per": 25, "total": 2, "pages": 1 },
"team": [
{ "id": 12, "name": "Zainab Musa", "email": "z.musa@bank.ng", "phone": null,
"type": "staff", "status": "active", "role": "Loan Officer", "role_id": 8,
"created_at": "2026-10-10T09:00:00" }
]
} }bank.staff.manageCreate a bank staff member. A temporary password and PIN are generated and returned once — share them with the new member. An optional role_id assigns a role; if omitted the system staff role is used.
| Body field | Type | Description | |
|---|---|---|---|
| name | string | required | Full name |
| string | required | Login email — must be unique within this bank | |
| phone | string | optional | Phone number |
| role_id | integer | optional | ID from GET /admin/bank/roles; falls back to system staff role |
{ "ok": true, "data": {
"user": { "id": 12, "name": "Zainab Musa", "email": "z.musa@bank.ng", "type": "staff" },
"temp_password": "4f8c2b1d!47",
"temp_pin": "083142"
} }bank.staff.manageActivate, deactivate, or suspend a staff member. Body: { "status": "active" | "inactive" | "suspended" }.
bank.staff.manageReassign a staff member's role. Body: { "role_id": 8 }. The role must belong to this bank or be a system role.
Roles & permissions
System roles are built-in. Create custom bank roles to grant specific bank.* permissions to your staff.
bank.staff.manageList system roles (relevant to bank staff) plus any custom roles created for this bank. Each role includes its assigned permission keys.
{ "ok": true, "data": { "roles": [
{ "id": 2, "key": "staff", "name": "Staff", "is_system": true,
"permissions": [] },
{ "id": 9, "key": "bank_1_loan_officer", "name": "Loan Officer",
"is_system": false, "permissions": ["bank.loans.view","bank.loans.manage"] }
] } }bank.staff.manageList all available bank.* permissions that can be assigned to custom roles.
{ "ok": true, "data": { "permissions": [
{ "id": 18, "key": "bank.loans.view", "description": "View loan applications" },
{ "id": 19, "key": "bank.loans.manage", "description": "Review, approve and decline loans" },
{ "id": 20, "key": "bank.wallet.view", "description": "View bank wallet balance and history" },
{ "id": 21, "key": "bank.wallet.move", "description": "Fund and withdraw from bank wallet" },
{ "id": 22, "key": "bank.settings.manage", "description": "Manage bank LTV ratio and interest rate" },
{ "id": 23, "key": "bank.staff.manage", "description": "Manage bank staff and roles" }
] } }bank.staff.manageCreate a custom bank role. Body: { "name": "Loan Officer", "permission_ids": [18, 19] }. Only bank.* permissions are accepted.
{ "ok": true, "data": { "id": 9, "key": "bank_1_loan_officer", "name": "Loan Officer" } }bank.staff.manageRename a custom role and/or replace its permission set. Body: { "name": "Senior Loan Officer", "permission_ids": [18, 19, 20] }. System roles cannot be edited.
bank.staff.manageDelete a custom bank role. Fails with 409 has_members if any staff are still assigned to it — reassign them first.
bank.staff.manageAssign a staff member to a role (replaces any existing role). Body: { "staff_id": 12 }.
AtomOwn API