Everything documented here is implemented and covered by integration tests against a real Postgres database. Where behaviour is partial or stubbed, it's marked inline rather than omitted.
- Base URL (dev):
http://127.0.0.1:3000 - Content type:
application/jsonon every request with a body - Machine-readable spec:
openapi.yaml— generate a typed client from it rather than hand-writing calls
There are two ways to authenticate. Browsers should use the cookie.
/signup and /login set an HttpOnly session cookie:
Set-Cookie: aframp_session=<jwt>; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400; Secure
Send subsequent requests with credentials: 'include' (or nothing at all if the frontend is served same-origin) and the browser attaches it for you. POST /logout clears it.
Do not store the JWT in localStorage. The token is still echoed in the response body for API clients, but a browser that copies it into localStorage hands the whole session to any XSS on the page — which is exactly what the HttpOnly cookie exists to prevent. Ignore the token field; read the login response only for user_id and merchant_id.
Non-browser clients send the JWT from the response body as a header:
Authorization: Bearer <token>
The header takes precedence when both are present.
Tokens are HS256, valid for 24 hours either way. Claims are sub (user id), merchant_id, iat, exp.
Two things worth building for up front:
merchant_idis nullable.AuthResponse.merchant_idand the JWT claim are both optional. Today signup always creates a merchant so it's always present, but the type allowsnull— an account without a merchant gets400from every merchant-scoped endpoint, not401. Don't assume non-null.- Expiry is silent. There's no refresh endpoint. When a token expires, calls start returning
401with{"error":"invalid or expired token","code":"INVALID_CREDENTIALS"}— treat any401on a previously-working call as "send the user back to login."
Browser origins must be allowlisted server-side via the CORS_ALLOWED_ORIGINS env var (comma-separated, defaults to http://localhost:3001). Allowed methods are GET/POST; allowed headers are Authorization and Content-Type. Credentials are enabled, so the origin list is never mirrored back — an origin that isn't listed fails preflight.
The supported deployment is same-origin: serve the frontend and this API behind one hostname (the reverse proxy routes /api/* here) and CORS stops applying at all. Cross-origin cookie auth additionally needs COOKIE_SAME_SITE=none, which lets the session ride cross-site requests and reintroduces CSRF as something you have to handle. With the default SameSite=Lax, a cross-origin frontend won't get the cookie sent at all and has to fall back to the bearer header.
Every error returns the same shape — a human-readable error string plus a stable, machine-readable code you can branch on:
{ "error": "insufficient available balance", "code": "INSUFFICIENT_BALANCE" }code is stable and never changes wording; error is written for humans. Match on code, never on the error string.
| Status | Meaning | Frontend handling |
|---|---|---|
400 |
Validation failed, or the account has no merchant | Show the error string; it's written for humans |
415 |
Content-Type isn't application/json on a POST/PUT with a body |
Send Content-Type: application/json and retry |
401 |
Missing, malformed, or expired token | Redirect to login |
404 |
Resource not found | — |
409 |
Email or phone already registered | Show on the signup form |
403 |
Authenticated, but not an admin (/admin/* only) |
Not applicable to merchant-facing routes |
429 |
OTP resent too soon, or too many times this hour | Show the wait; don't auto-retry |
502 |
Upstream payment provider failed | Transient — the error carries the provider's own message |
500 |
Internal error | Generic INTERNAL_ERROR; details stay in server logs |
| Code | Status | When it's returned |
|---|---|---|
INVALID_PARAMETERS |
400 |
A required field is missing/malformed (e.g. short password, bad account_number/bank_code) |
INVALID_AMOUNT |
400 |
Amount is not positive, or not a whole number of kobo |
INSUFFICIENT_BALANCE |
400 |
Withdrawal exceeds the available balance |
UNSUPPORTED_ASSET |
400 |
Withdrawal asset isn't cNGN |
EMAIL_TAKEN |
409 |
Signup email already registered (to a verified account) |
PHONE_TAKEN |
409 |
Signup phone already registered (to a verified account) |
INVALID_CREDENTIALS |
401 |
Wrong password or unknown email on login |
USER_NOT_FOUND |
404 |
Authenticated user no longer exists |
MERCHANT_NOT_FOUND |
400 |
Account has no merchant (visit onboarding) |
WALLET_NOT_FOUND |
400 |
No wallet yet, or none created before a payment-request call |
PAYMENT_REQUEST_NOT_FOUND |
404 |
Payment request id doesn't exist |
PAYOUT_FAILED |
502 |
Upstream payment provider rejected the payout |
FORBIDDEN |
403 |
Authenticated but not an admin, on an /admin/* route |
OTP_INVALID |
400 |
Wrong code submitted to /verify-otp |
OTP_EXPIRED |
400 |
Code submitted after its 10-minute window |
OTP_LOCKED |
400 |
5 wrong attempts on this challenge — request a new one |
OTP_CHALLENGE_NOT_FOUND |
404 |
Unknown or already-consumed challenge_id |
TOO_MANY_REQUESTS |
429 |
OTP resent inside the 60s cooldown, or 5th+ send this hour |
INTERNAL_ERROR |
500 |
Unexpected server error; generic message only |
Money is always integer stroops (i64), never a float. 1 unit = 10_000_000 stroops.
const toDisplay = (stroops) => (stroops / 10_000_000).toFixed(7);
const toStroops = (amount) => Math.round(amount * 10_000_000);Never use floating-point arithmetic to accumulate balances — convert for display only.
Timestamps are RFC 3339 / ISO 8601 UTC (2026-08-13T14:15:34.520195Z), parseable by new Date().
Ids are UUID v4 strings.
Liveness probe. No auth. Returns 204 No Content with an empty body.
Returns the literal string aframp (not JSON). Useful as a smoke test.
No auth. Does not create the account. Validates the credentials, sends an OTP to phone_number, and returns a challenge — the user + merchant only get inserted once POST /verify-otp succeeds with the right code. This means the same email can be re-submitted freely (it's the resend path) as long as no prior attempt for it was ever verified.
{ "email": "merchant@example.com", "password": "at-least-8-chars", "name": "Shop Name", "phone_number": "08011122233" }phone_number accepts any common Nigerian mobile format (0801..., 801..., +234801..., 234801...) and is normalized to E.164 before storage/sending.
200 →
{ "challenge_id": "b6b54b1e-...", "expires_in_secs": 600 }Errors: 400 if email is empty, password is under 8 characters, name is empty, or the phone doesn't parse. 409 (EMAIL_TAKEN/PHONE_TAKEN) if either is already registered to a verified account. 429 (TOO_MANY_REQUESTS) if resent within 60s of the last send, or 5 times inside an hour.
No auth. Verifies the password. If the account has a verified phone number, this returns a fresh OTP challenge (identical shape to /signup's) instead of a session — full two-factor, every login, no exception for a returning session. Only an account with no phone on file (impossible to create anymore; a relic of accounts made before this existed) logs in synchronously with the old one-step response.
{ "email": "merchant@example.com", "password": "at-least-8-chars" }200 → either { "challenge_id": "...", "expires_in_secs": 600 } (the normal case) or the full session response described under /verify-otp below (legacy accounts only).
Errors: 401 for both a wrong password and an unknown email — deliberately indistinguishable, so don't build a "no such account" message from it. 429 on the same resend rules as signup.
No auth. The only endpoint that ever issues a session, reached from either a signup or a login challenge.
{ "challenge_id": "b6b54b1e-...", "code": "482913" }200 →
{
"token": "eyJ0eXAiOiJKV1Qi...",
"user_id": "2c5e0ee2-7f87-4efb-b1c9-d7e1b3ee0eeb",
"merchant_id": "6a91d75c-8c41-4fa5-b10b-6eb8cda8ac0a"
}…with the same Set-Cookie: aframp_session=... as before. For a signup challenge, the user and merchant are created transactionally at this exact moment, not before.
Errors: 400 OTP_INVALID (wrong code — 5 wrong guesses and the challenge is dead, not just that attempt), OTP_EXPIRED (codes last 10 minutes), OTP_LOCKED (attempts exhausted — restart via /signup or /login for a new one). 404 OTP_CHALLENGE_NOT_FOUND for an unknown or already-consumed challenge_id.
No auth — a browser holding an expired or malformed session still needs to clear it. Returns 204 and a Set-Cookie that expires aframp_session immediately.
Note this clears the browser's session, it does not revoke the JWT: a token already copied elsewhere stays valid until it expires. There's no server-side revocation list yet.
Auth required. The signed-in user's profile. The JWT carries only ids, so call this after a reload to render anything human-readable without forcing a re-login.
200 →
{
"user_id": "2c5e0ee2-7f87-4efb-b1c9-d7e1b3ee0eeb",
"email": "merchant@example.com",
"name": "Shop Name",
"created_at": "2026-08-13T14:15:34.232320Z",
"merchant_id": "6a91d75c-8c41-4fa5-b10b-6eb8cda8ac0a",
"merchant_name": "Shop Name"
}merchant_id and merchant_name are null for an account with no merchant. The password hash is never serialized.
Auth required. Generates a real Stellar ed25519 keypair for the merchant. The private key is AES-256-GCM encrypted server-side and never leaves it.
{ "network": "stellar" }network is optional and defaults to "stellar".
200 →
{
"id": "18f4244e-8460-4af9-b268-28bc4b23b9ea",
"merchant_id": "6a91d75c-8c41-4fa5-b10b-6eb8cda8ac0a",
"address": "GDDTPSD7BWERBIKVYXJY4KMBVFCUKNGJB2CS3DWBUUO3IB2CV7BZ5WSR",
"network": "stellar",
"created_at": "2026-08-13T14:15:34.518727Z"
}Calling this repeatedly creates a new wallet each time. There's no idempotency guard.
GET /walletreturns the most recently created one, so a duplicate call silently changes where new payment requests point. Create a wallet once during onboarding and checkGET /walletfirst.
Auth required. The merchant's most recent wallet. Same shape as above.
Errors: 400 "no wallet created yet" if none exists — that's the signal to run onboarding, not an error to surface raw.
Auth required. The core POS action. Creates a request for a specific amount and returns a scannable payload.
{ "amount_stroops": 25000000, "asset": "XLM", "expires_in_secs": 900 }| Field | Required | Default | Notes |
|---|---|---|---|
amount_stroops |
yes | — | Must be > 0 |
asset |
no | "XLM" |
See the cNGN caveat below |
expires_in_secs |
no | 900 (15 min) |
Clamped to 60–86400 |
200 →
{
"id": "26b6e670-a8b1-471d-ab0f-773a9a318a6a",
"merchant_id": "6a91d75c-8c41-4fa5-b10b-6eb8cda8ac0a",
"address": "GDDTPSD7BWERBIKVYXJY4KMBVFCUKNGJB2CS3DWBUUO3IB2CV7BZ5WSR",
"network": "stellar",
"amount_stroops": 25000000,
"asset": "XLM",
"memo": "1f97c93409172a7d",
"status": "pending",
"expires_at": "2026-08-13T14:30:34.518727Z",
"created_at": "2026-08-13T14:15:34.520195Z",
"sep7_uri": "web+stellar:pay?destination=GDDT...&amount=2.5000000&memo=1f97c93409172a7d&memo_type=MEMO_TEXT"
}Render sep7_uri as the QR code. It's a SEP-0007 payment URI that Stellar wallets open natively. Generate the QR client-side (qrcode, react-qr-code) — the backend returns the string, not an image.
sep7_uriisnullfor cNGN. There's no real cNGN issuer address configured yet, and a guessed issuer would silently misdirect a customer's money. Handle the null case — don't render a broken QR. XLM works today.
The memo is what links a payment to this request. A customer paying without it still credits the merchant's balance, but the request stays pending forever. The SEP-7 URI includes it automatically; if you ever show manual payment instructions, the memo is mandatory.
Errors: 400 "create a wallet before generating payment requests" if the merchant has no wallet.
Auth required. The merchant's own requests, newest first. Scoped to the authenticated merchant — you cannot see another merchant's requests.
Query: ?limit= (default 50, clamped 1–200).
200 → array of the object above.
Pagination is limit-only — there's no cursor or offset, so you can't page beyond the most recent 200.
No auth — deliberately public, so a customer's device can read a request before paying.
200 → same object. 404 if the id doesn't exist.
Poll this to detect payment. Deposit detection runs on a timer (STELLAR_POLL_INTERVAL_SECS, default 60s), so a payment typically shows up within ~60s of confirming on-chain, not instantly. Poll every 3–5s and show a "waiting for payment" state; don't expect a sub-second flip.
status |
Meaning |
|---|---|
pending |
Not yet paid, not yet expired |
paid |
A memo-matched payment was detected and confirmed |
expired |
expires_at passed while still pending |
expired is computed at read time, so it's accurate the moment you fetch it. A request that expires and is then paid still flips to paid — expiry doesn't block correlation.
Auth required. One row per asset the merchant has ever held. Returns [] for a new merchant — not an error.
200 →
[
{
"merchant_id": "6a91d75c-8c41-4fa5-b10b-6eb8cda8ac0a",
"asset": "XLM",
"available": 100000000000,
"pending": 0,
"updated_at": "2026-08-13T14:15:34.520195Z"
}
]available is withdrawable; pending is detected but not yet confirmed. In practice pending is almost always 0 — deposits currently move to confirmed immediately (no confirmation-depth threshold yet).
Auth required. Detected incoming payments, newest first. Query: ?limit= (default 50, clamped 1–200).
200 →
[
{
"id": "f03857f3-b9e3-4bb4-99df-fdab11e69143",
"merchant_id": "6a91d75c-8c41-4fa5-b10b-6eb8cda8ac0a",
"wallet_id": "3087fc49-6b88-4778-b22d-410fef5e9915",
"wallet_address": "GDDTPSD7BWERBIKVYXJY4KMBVFCUKNGJB2CS3DWBUUO3IB2CV7BZ5WSR",
"tx_hash": "4b3dc2ebaa551509e55c00240222dff650e0013236471e170512ca992e2304dc",
"amount_stroops": 25000000,
"asset": "XLM",
"network": "stellar",
"status": "confirmed",
"confirmations": 0,
"created_at": "2026-08-13T13:54:20.123456Z",
"updated_at": "2026-08-13T13:54:20.987654Z"
}
]status is one of detected → verified → confirmed, or failed. tx_hash is a real Stellar hash — link it to an explorer (https://stellar.expert/explorer/testnet/tx/{tx_hash}).
confirmationsis currently always0— the confirmation-depth threshold isn't implemented. Don't display it as meaningful.
Auth required. Debits the merchant's balance and initiates a Nigerian bank payout via Paystack.
{ "amount_stroops": 500000000, "asset": "cNGN", "bank_code": "058", "account_number": "0123456789" }| Field | Required | Notes |
|---|---|---|
amount_stroops |
yes | Must be a whole multiple of 100000 (1 kobo) |
asset |
no | Defaults to cNGN; only cNGN is accepted |
bank_code |
yes | Paystack bank code, e.g. 058 GTBank, 999992 OPay |
account_number |
yes | Exactly 10 digits (NUBAN) |
200 → a withdrawal object with status, provider, provider_reference.
Validation errors (400): "insufficient available balance", "withdrawals are only supported for the cNGN asset", "amount_stroops must be a whole number of kobo", "positive amount_stroops, bank_code, and a 10-digit account_number are required".
Payouts do not currently complete. The Paystack integration is real and correct, but Aframp's Paystack balance is unfunded, so live calls return
502with "Your balance is not enough to fulfil this request." On failure the balance is automatically refunded and the withdrawal is recorded withstatus: "failed"and afailure_reason— no money or ledger record is lost. Treat502as "try later," not as data loss. Paystack's own minimum transfer is ₦50 =500000000stroops.
Auth required. Newest first. Query: ?limit= (default 50, clamped 1–200).
200 →
[
{
"id": "4d8513ce-1ba4-47d8-be93-f079f18a1c71",
"merchant_id": "6a91d75c-8c41-4fa5-b10b-6eb8cda8ac0a",
"amount_stroops": 500000000,
"asset": "cNGN",
"status": "failed",
"provider": null,
"provider_reference": null,
"bank_code": "999992",
"account_number": "8038714250",
"failure_reason": "Paystack error (HTTP 400 Bad Request): Your balance is not enough to fulfil this request",
"created_at": "2026-08-13T17:10:50.729251Z",
"updated_at": "2026-08-13T17:10:52.853489Z"
}
]status is pending, processing, completed, or failed. Show failure_reason on failed rows — it carries the provider's own wording.
The core merchant loop:
POST /payment-requestswith the amount → getidandsep7_uri- Render
sep7_urias a QR code; show the amount and a countdown toexpires_at - Poll
GET /payment-requests/{id}every 3–5s - On
status: "paid"→ show "Payment received"; on"expired"→ offer to regenerate
async function waitForPayment(id, { signal } = {}) {
while (!signal?.aborted) {
const res = await fetch(`${API}/payment-requests/${id}`, { signal });
if (!res.ok) throw new Error(`lookup failed: ${res.status}`);
const pr = await res.json();
if (pr.status !== 'pending') return pr; // 'paid' or 'expired'
await new Promise((r) => setTimeout(r, 4000));
}
}Note step 3 needs no auth token, so a customer-facing payment page can use it directly.
Worth knowing before you design around them:
- No websockets / SSE. Payment status is poll-only.
- No refresh tokens. A 24h expiry means a re-login, not a silent refresh.
- No token revocation.
POST /logoutclears the browser's cookie; it cannot invalidate a JWT that has already been copied somewhere else. - No rate limiting on the password check itself. OTP sends are throttled (60s cooldown, 5/hour per phone), but nothing yet stops repeated wrong-password guesses against
/loginbefore it ever gets to that step. - No cursor pagination.
limitonly, capped at 200. - No cancel/delete on payment requests. They can only expire naturally.
- No
PATCH/DELETEanywhere — and CORS only allowsGET/POST, so adding one needs a server change too. - cNGN QR codes, pending a real issuer address.
- Completed payouts, pending funding (see
PRD.md§9.1).