Route-verified reference
Every registered endpoint, grouped by domain
Generated from docs/API_CONTRACT.md and then walked route-file by route-file
against internal/api/routes/*.go to confirm the contract matches what's
actually mounted. Discrepancies are called out inline, not hidden.
Conventions & envelope
- All routes are versioned under
/api/v1, except GET /health and the two provider webhooks (/webhooks/payments/:provider, mounted at /api/v1/webhooks/cards/sandbox), which are public and signature-verified inline instead of JWT-gated.
- Money amounts are strings (decimal), never JSON numbers. Timestamps are RFC3339 UTC.
- Success envelope:
{success: true, data, meta?}. Error envelope: {success: false, error: {code, message, details?}}.
- Money-moving
POSTs accept an Idempotency-Key header — see below.
Three response shapes coexist in the current codebase
Newer handlers (Convert, card/partner/NAQD admin endpoints, top-up variants) use the
shared apperr.Respond helper, which produces exactly the envelope above. Older
handlers (P2P transfer, QR, bill-pay, NFC, and some wallet reads) use a separate
utils.Success/Error helper whose success shape is {success, message, data}
— no meta key — and whose error codes are hand-typed strings
(e.g. MISSING_RECIPIENT) that are not in the shared
apperr vocabulary below. Both are wire-compatible with clients that just check
success, but don't assume every error code you see on the wire appears in the
table below.
Error code vocabulary
Defined once in internal/core/apperr. All 16 contract codes are implemented with no extras and no omissions.
| Code | HTTP status | Meaning |
UNAUTHORIZED | 401 | Missing/invalid/expired credentials |
FORBIDDEN | 403 | Authenticated, but not allowed (RBAC, self-approval block, KYC gate) |
NOT_FOUND | 404 | Resource doesn't exist or isn't yours |
VALIDATION_ERROR | 422 | Request shape/content invalid |
INSUFFICIENT_BALANCE | 400 | Ledger overdraft guard tripped (ErrOverdraft) |
QUOTE_EXPIRED | 410 | FX/partner quote TTL passed before execution |
PIN_REQUIRED | 400 | No transaction PIN set yet |
PIN_INVALID | 401 | Wrong PIN (attempt counted) |
PIN_LOCKED | 423 | 3 failed PIN attempts — 15 minute lock |
KYC_REQUIRED | 403 | Action needs a higher KYC tier |
LIMIT_EXCEEDED | 429 | Per-txn/daily/monthly tier limit hit (cards, partners) |
IDEMPOTENT_REPLAY | 200 | Idempotency-Key matched a prior request — original response replayed |
RAIL_UNAVAILABLE | 503 | Provider/rail not configured (e.g. local_bank without gateway keys) |
ORACLE_STALE | 503 | No live, cached, or manual silver price available |
DUPLICATE | 409 | e.g. PIN already set, concurrent idempotent retry still in-flight |
RATE_LIMITED | 429 | Redis fixed-window limiter tripped (100 req/min per client+path) |
INTERNAL | 500 | Unhandled server error |
Idempotency semantics
middleware.Idempotency(redis) is attached per-route (a third handler argument), not globally, to specific money-moving POSTs only.
Keyidempotency:<userID|anon>:<path>:<Idempotency-Key>
LockRedis SetNX, 24h TTL
First callRuns the handler; on any response <500, caches {status, body}
ReplayReturns the cached response verbatim, with meta.idempotent_replay = true
Concurrent retryA second request with the same key while the first is still in-flight gets 409 DUPLICATE
On handler errorThe lock is released without poisoning it — a failed attempt can be retried with the same key
Auth & sessions
| Method | Path | Auth | Notes |
| POST | /auth/register | public | Creates account + default wallets, publishes user.registered |
| POST | /auth/login | public | Branches to 2FA temp-token flow if enabled |
| POST | /auth/refresh | public | Rotates refresh token — old jti invalidated immediately |
| POST | /auth/verify-email / /verify-phone | public | 6-digit OTP, 15 min TTL |
| POST | /auth/2fa/verify | public | Consumes the 5-minute login temp-token; backup code or TOTP |
| POST | /auth/forgot-password / /reset-password | public | Reset revokes all sessions and tokens on success |
| POST | /auth/resend-verification | public | — |
| POST | /auth/logout | JWT | Blacklists access token, revokes bound session |
| POST | /auth/logout-all | JWT | Revokes every session + refresh token for the user |
| POST | /auth/2fa/enable | JWT | Generates TOTP secret + 8 backup codes; not enabled until first verify |
| GET | /auth/verify | JWT | Token introspection |
| GET | /auth/sessions | JWT | current flag derived from the caller's own SessionID claim |
| DELETE | /auth/sessions/:id | JWT | Revoke one session, ownership-checked |
| GET | /auth/login-history | JWT | Paginated, includes failed attempts |
| POST | /auth/pin | JWT | {pin, password} — 409 DUPLICATE if already set |
| PUT | /auth/pin | JWT | {current_pin, new_pin} |
Wallet
| Method | Path | Idem. | Notes |
| GET | /wallet/wallets | – | Auto-creates default wallets if missing |
| GET | /wallet/balances | – | All currencies + KYC-tier limits |
| GET | /wallet/balance/:currency | – | Available / locked / total |
| POST | /wallet/create | – | MVR / USD / NAQD / SOL wallets |
| POST | /wallet/topup/mvr · /card · /crypto · /apple-pay · /google-pay | ✓ | All five route through SettlementEngine — see Data Flows → Top-up |
| POST | /wallet/withdraw | ✓ | Debit-now; bank-side settlement is still a time.Sleep-simulated goroutine, not the sweeper pattern — see Known limitations |
| GET | /wallet/transactions | – | History |
| POST | /wallet/convert | ✓ | {quote_id, pin} — executes a persisted FX quote; QUOTE_EXPIRED / INSUFFICIENT_BALANCE |
| POST | /wallet/add-card | – | Saved payment method, not a MyNEX-issued card |
| DELETE | /wallet/remove-card/:id | – | — |
Payment
| Method | Path | Idem. | Notes |
| POST | /payment/transfer/p2p | ✓ | PIN-gated; see Data Flows → P2P |
| POST | /payment/qr/generate | – | Static or dynamic payment QR |
| POST | /payment/qr/scan | ✓ | Pay via scanned QR |
| POST | /payment/bill-pay | ✓ | Biller payment via MVR gateway |
| POST | /payment/request | – | Create a payment request |
| GET | /payment/history | – | Paginated |
| POST | /payment/recurring/create | – | Standing order; PIN verified once at creation, not on each execution |
| POST | /payment/apple-pay/process · /google-pay/process · /nfc/process | ✓ | — |
| GET | /payment/methods | – | Saved methods |
| POST | /payment/apple-pay/validate-session | – | Real Apple merchant validation |
| GET | /payment/google-pay/config | – | Returns STRIPE_PUBLISHABLE_KEY, no longer hardcoded |
| POST | /webhooks/payments/:provider | n/a | webhook — public, signature-verified per-provider, mounted outside /api/v1 |
KYC
| Method | Path | Notes |
| POST | /kyc/submit | Multipart; publishes kyc.submitted; kicks off a fire-and-forget simulated verification goroutine |
| GET | /kyc/status | Current tier + status |
| POST | /kyc/upload-document | Uploads to S3 |
| GET | /kyc/documents | — |
| PUT | /kyc/update | — |
FX
| Method | Path | Auth | Notes |
| GET | /fx/rates | public | {boards: {official: [...], effective: [...]}} |
| POST | /fx/quote | JWT | 30s TTL, persisted so /wallet/convert always executes the exact quoted rate |
Contract gap
FXRateHistory rows are written on every admin rate update, but there is no
GET endpoint anywhere to read that history, despite API_CONTRACT.md mentioning
"…(finance), history" for the admin FX section.
User & Notifications
| Method | Path | Notes |
| GET | /user/profile | — |
| PUT | /user/profile | Email changes require confirmation; phone changes flip IsPhoneVerified=false with no re-verify flow yet |
| POST | /user/profile/confirm-email | Not in API_CONTRACT.md — added to complete the email-change flow the contract implies but never specified an endpoint for. {code} |
| GET | /notifications | Paginated in-app notifications |
| POST | /notifications/read | {ids} |
| POST | /notifications/read-all | — |
NAQD
| Method | Path | Auth | Notes |
| GET | /naqd/price | public | Current NAQD/USD price |
| GET | /naqd/reserves | public | Live ledger-sourced reserve snapshot |
| GET | /naqd/supply | public | Circulating supply |
| GET | /naqd/oracle/silver-price · /oracle/price-history · /oracle/sources | public | — |
| POST | /naqd/mint | JWT ✓ | Async — see Data Flows → NAQD mint |
| POST | /naqd/burn | JWT ✓ | Fee = NAQD_BURN_FEE_BPS (50bps) |
| POST | /naqd/transfer | JWT ✓ | NAQD P2P |
| POST | /naqd/withdraw-onchain | JWT ✓ | Self-custody — see Data Flows |
| GET | /naqd/operations / /operations/:id | JWT | Status polling |
| GET | /naqd/transactions | JWT | Thin alias over operation listing |
| GET | /naqd/blockchain/chains · /balance/:chain/:address · /tx/:chain/:txHash | JWT | Nested here rather than top-level /blockchain/* — the standalone mount exists in code but is never called from routes.go |
Cards
| Method | Path | Idem. | Notes |
| GET | /cards | – | List |
| POST | /cards | ✓ | {type, currency, shipping_address?, pin} — virtual = active immediately with one-time reveal token |
| GET | /cards/:id | – | Detail incl. shipping state machine |
| POST | /cards/:id/reveal | – | One-time full PAN/CVV reveal, TTL CARD_REVEAL_TOKEN_TTL_SECONDS (300s) |
| POST | /cards/:id/activate | ✓ | Accepts shipped or delivered status |
| POST | /cards/:id/freeze / /unfreeze | ✓ | Instant |
| PUT | /cards/:id/limits | – | Bounded by KYC-tier ceilings |
| POST | /cards/:id/pin | ✓ | Card PIN, distinct from the account transaction PIN |
| DELETE | /cards/:id | – | Terminate |
| GET | /cards/:id/transactions | – | Auth/clearing history |
| POST | /webhooks/cards/sandbox | n/a | webhook — public, HMAC X-Signature |
Partners
| Method | Path | Auth | Notes |
| GET | /partners/rails | public | e.g. tng-my, MYR, ewallet_reload |
| POST | /partners/quote | JWT | 60s TTL — own PartnerQuote model, not fx.FXQuote |
| POST | /partners/execute | JWT ✓ | See Data Flows → TnG reload |
| GET | /partners/operations / /operations/:id | JWT | — |
Admin
All under /admin, gated by AdminAuthMiddleware with a required role from the five-tier hierarchy (support < finance < compliance < admin < super_admin) — see Security → RBAC for the full route/role matrix. Selected highlights below; the complete matrix lives on the Security page to avoid duplicating it.
| Method | Path | Min role | Notes |
| POST | /admin/login | public | bcrypt password check, not Argon2id |
| GET | /admin/dashboard/stats | support | Live bug: queries KYC.status, but the real column is verification_status — this query errors as written |
| GET | /admin/dashboard/charts/:type | support | currency_breakdown is a declared chart type with no handler case — falls through to an error |
| PUT | /admin/users/:id/status | admin | Publishes user.updated |
| POST | /admin/kyc/:id/review | compliance | Publishes kyc.approved/kyc.rejected |
| PUT | /admin/withdrawals/:id/approve | finance | Publishes txn.completed/txn.failed |
| POST | /admin/wallets/:id/adjust | finance | Creates pending adjustment — no money moves yet |
| POST | /admin/adjustments/:id/approve | admin | Must be a different admin than the requester — 403 otherwise |
| GET | /admin/naqd/overview | admin | Supply vs. reserve, collateral ratio, oracle status |
| POST | /admin/naqd/operations/:id/approve / /reject | admin | Maker-checker for large mint/burn |
| POST | /admin/naqd/oracle/refresh | admin | Contract says POST /admin/oracle/refresh — actual mounted path is /admin/naqd/oracle/refresh |
| PUT | /admin/naqd/oracle/manual-rate | admin | Fallback rate — not mentioned in API_CONTRACT.md |
| GET | /admin/cards / /cards/requests | compliance | Issuance queue; status filter only, no user/type filter |
| POST | /admin/cards/:id/approve | compliance | Atomically requested→approved→printed in one call (sandbox has no print house to delay for) |
| GET | /admin/partners/settlement | finance | Per-(rail, currency) balances read directly from the ledger |
| GET | /admin/audit-logs | admin | Filterable by admin/entity/action/date range |
Admin WebSocket + event catalog
EndpointGET /api/v1/admin/ws?token=<jwt> WS — token in the query string (browsers can't set custom headers on a WS handshake)
AuthWSUpgradeMiddleware: blacklist check → ValidateAccessToken → active AdminUser lookup, before the upgrade completes
Wire shape{"type": "...", "at": "<RFC3339>", "data": {...}}
SubscribeClient may send {"type":"subscribe","channels":[...]}; unset/empty = all types (default)
Keepalive54s server ping
Every event the contract lists now has a real emitter (verified by grepping all 26 events.Publish call sites across the codebase). Six more are emitted but only documented in docs/wiring/platform.md, not in API_CONTRACT.md itself — included below for completeness.
| Event | In contract? | Emitted by | Payload |
user.registered | ✓ | auth-service Register | {user_id, email, uuid} |
kyc.submitted | ✓ | kyc-service SubmitKYC | {user_id, kyc_id, document_type} |
txn.created | ✓ | payment-service settlement engine (Authorize) | {user_id, transaction_id, reference, type, payment_method, status, amount, currency} |
txn.completed / txn.failed | ✓ | payment-service settlement engine + admin withdrawal approval | same shape as above |
withdrawal.requested | ✓ | wallet-service Withdraw | {user_id, transaction_id, wallet_id, amount, currency} |
naqd.minted / naqd.burned | ✓ | stablecoin worker finishOK (withdraw_onchain folds into naqd.burned) | {operation_id, user_id, kind, naqd_amount, chain, tx_hash} |
card.requested | ✓ | card-service IssueCard (physical only) | {card_id, card_uuid, user_id, type, currency} |
partner.operation | ✓ | partner-service (Execute / complete / refund transitions) | {operation_id, user_id, rail_id, status, partner_ref, debit_currency, debit_amount} |
system.alert | ✓ | 5 sites: oracle degrade (×3), stablecoin worker reversal, admin wallet adjustment approval | {source, severity, reason, ...} |
stats.tick | ✓ | admin-service runStatsTicker, every 5s | {total_users, pending_kyc, transactions_today, volume_today} |
session.created | additive | auth-service issueTokens (register/login/2FA-verify) | {session_id, user_id, device, ip} |
session.revoked | additive | auth-service logout/logout-all/revoke-session, admin force-logout | {session_id, user_id} or {user_id, all:true} |
user.updated | additive | admin UpdateUserStatus | {user_id, status} |
kyc.approved / kyc.rejected / kyc.updated | additive | admin ApproveKYC | {user_id, status} |
card.activated | not documented | card-service (virtual issuance, physical activation) | {card_id, card_uuid, user_id} |