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, session_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 or is revoked, 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 already registered | Show on the signup form |
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 |
PAYOUT_NOT_READY |
400 |
Payout provider account does not have sufficient funding |
PAYOUT_FAILED |
502 |
Upstream payment provider rejected the payout |
EMAIL_TAKEN |
409 |
Signup email already registered |
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 |
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. Creates a user and their merchant in one transaction.
{ "email": "merchant@example.com", "password": "at-least-8-chars", "name": "Shop Name" }200 →
{
"token": "eyJ0eXAiOiJKV1Qi...",
"user_id": "2c5e0ee2-7f87-4efb-b1c9-d7e1b3ee0eeb",
"merchant_id": "6a91d75c-8c41-4fa5-b10b-6eb8cda8ac0a"
}Errors: 400 if email is empty, password is under 8 characters, or name is empty. 409 if the email is already registered.
No auth. Same response shape as signup.
{ "email": "merchant@example.com", "password": "at-least-8-chars" }Errors: 401 for both a wrong password and an unknown email — deliberately indistinguishable, so don't build a "no such account" message from it.
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.
Server-side session revocation: When a bearer token is provided, the current session is revoked server-side. This invalidates the token for both cookie and bearer-authenticated clients. Revoked sessions are stored in the database and persist across restarts, so the token remains invalid even if the server restarts. Cookie-authenticated clients have their cookie cleared immediately. The JWT's 24-hour expiry acts as a backstop for sessions that are not explicitly revoked.
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_urifor cNGN depends on issuer configuration. WhenCNGN_ISSUER_ADDRESSis set to a valid verified Stellar address, cNGN payment requests include a SEP-0007 URI with the asset code and issuer. If unset or invalid,sep7_uriisnull— no guessing to avoid misdirecting customer funds. XLM always has a URI since it's the native asset.
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".
Payout readiness check. Before a withdrawal is debited from the merchant's balance, the server verifies that the configured payout provider (Paystack) has sufficient funding to process transfers. If the payout account is unfunded, the request returns
400withPAYOUT_NOT_READYand the merchant balance is NOT debited — no funds are lost and no withdrawal record is created. This prevents the dangerous condition where a merchant's balance is reduced but no payout completes.When payouts fail after balance is debited: If the readiness check passes but the actual payout to Paystack fails (e.g., network error, invalid bank details), the balance is automatically refunded and the withdrawal is recorded with
status: "failed"and afailure_reason— no money or ledger record is lost. This compensating refund is a separate atomic transaction, preserving an audit trail.Paystack's balance status: Aframp's Paystack account must be funded (NGN balance > 0) for payouts to complete. This depends on external settlement (Stage A in the PRD) — see the operations section below.
Current state: Paystack's Transfers API is integrated and live-tested, but Aframp's Paystack account has zero balance. Withdrawal requests are rejected upfront with PAYOUT_NOT_READY.
To enable payouts:
- Fund Aframp's Paystack account with NGN.
- Verify with a test transfer: call
POST /withdrawwith a small amount (≥ ₦50 =500000000stroops). - If successful, the
statuswill bependingor the provider-specific completion status. The merchant's balance is debited and the bank transfer is in flight. - Monitor
GET /withdrawalsto confirm completion.
Once Paystack account balance > 0, the readiness check will pass and withdrawals will proceed.
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": "payout not ready: Paystack account balance is zero or negative; transfers cannot be funded",
"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
/login. Nothing throttles password guessing yet. - 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).