MyNEX Docs / API Reference
/api/v1
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.

CodeHTTP statusMeaning
UNAUTHORIZED401Missing/invalid/expired credentials
FORBIDDEN403Authenticated, but not allowed (RBAC, self-approval block, KYC gate)
NOT_FOUND404Resource doesn't exist or isn't yours
VALIDATION_ERROR422Request shape/content invalid
INSUFFICIENT_BALANCE400Ledger overdraft guard tripped (ErrOverdraft)
QUOTE_EXPIRED410FX/partner quote TTL passed before execution
PIN_REQUIRED400No transaction PIN set yet
PIN_INVALID401Wrong PIN (attempt counted)
PIN_LOCKED4233 failed PIN attempts — 15 minute lock
KYC_REQUIRED403Action needs a higher KYC tier
LIMIT_EXCEEDED429Per-txn/daily/monthly tier limit hit (cards, partners)
IDEMPOTENT_REPLAY200Idempotency-Key matched a prior request — original response replayed
RAIL_UNAVAILABLE503Provider/rail not configured (e.g. local_bank without gateway keys)
ORACLE_STALE503No live, cached, or manual silver price available
DUPLICATE409e.g. PIN already set, concurrent idempotent retry still in-flight
RATE_LIMITED429Redis fixed-window limiter tripped (100 req/min per client+path)
INTERNAL500Unhandled server error

Idempotency semantics

middleware.Idempotency(redis) is attached per-route (a third handler argument), not globally, to specific money-moving POSTs only.

Key
idempotency:<userID|anon>:<path>:<Idempotency-Key>
Lock
Redis SetNX, 24h TTL
First call
Runs the handler; on any response <500, caches {status, body}
Replay
Returns the cached response verbatim, with meta.idempotent_replay = true
Concurrent retry
A second request with the same key while the first is still in-flight gets 409 DUPLICATE
On handler error
The lock is released without poisoning it — a failed attempt can be retried with the same key

Auth & sessions

MethodPathAuthNotes
POST/auth/registerpublicCreates account + default wallets, publishes user.registered
POST/auth/loginpublicBranches to 2FA temp-token flow if enabled
POST/auth/refreshpublicRotates refresh token — old jti invalidated immediately
POST/auth/verify-email / /verify-phonepublic6-digit OTP, 15 min TTL
POST/auth/2fa/verifypublicConsumes the 5-minute login temp-token; backup code or TOTP
POST/auth/forgot-password / /reset-passwordpublicReset revokes all sessions and tokens on success
POST/auth/resend-verificationpublic
POST/auth/logoutJWTBlacklists access token, revokes bound session
POST/auth/logout-allJWTRevokes every session + refresh token for the user
POST/auth/2fa/enableJWTGenerates TOTP secret + 8 backup codes; not enabled until first verify
GET/auth/verifyJWTToken introspection
GET/auth/sessionsJWTcurrent flag derived from the caller's own SessionID claim
DELETE/auth/sessions/:idJWTRevoke one session, ownership-checked
GET/auth/login-historyJWTPaginated, includes failed attempts
POST/auth/pinJWT{pin, password} — 409 DUPLICATE if already set
PUT/auth/pinJWT{current_pin, new_pin}

Wallet

MethodPathIdem.Notes
GET/wallet/walletsAuto-creates default wallets if missing
GET/wallet/balancesAll currencies + KYC-tier limits
GET/wallet/balance/:currencyAvailable / locked / total
POST/wallet/createMVR / USD / NAQD / SOL wallets
POST/wallet/topup/mvr · /card · /crypto · /apple-pay · /google-payAll five route through SettlementEngine — see Data Flows → Top-up
POST/wallet/withdrawDebit-now; bank-side settlement is still a time.Sleep-simulated goroutine, not the sweeper pattern — see Known limitations
GET/wallet/transactionsHistory
POST/wallet/convert{quote_id, pin} — executes a persisted FX quote; QUOTE_EXPIRED / INSUFFICIENT_BALANCE
POST/wallet/add-cardSaved payment method, not a MyNEX-issued card
DELETE/wallet/remove-card/:id

Payment

MethodPathIdem.Notes
POST/payment/transfer/p2pPIN-gated; see Data Flows → P2P
POST/payment/qr/generateStatic or dynamic payment QR
POST/payment/qr/scanPay via scanned QR
POST/payment/bill-payBiller payment via MVR gateway
POST/payment/requestCreate a payment request
GET/payment/historyPaginated
POST/payment/recurring/createStanding order; PIN verified once at creation, not on each execution
POST/payment/apple-pay/process · /google-pay/process · /nfc/process
GET/payment/methodsSaved methods
POST/payment/apple-pay/validate-sessionReal Apple merchant validation
GET/payment/google-pay/configReturns STRIPE_PUBLISHABLE_KEY, no longer hardcoded
POST/webhooks/payments/:providern/awebhook — public, signature-verified per-provider, mounted outside /api/v1

KYC

MethodPathNotes
POST/kyc/submitMultipart; publishes kyc.submitted; kicks off a fire-and-forget simulated verification goroutine
GET/kyc/statusCurrent tier + status
POST/kyc/upload-documentUploads to S3
GET/kyc/documents
PUT/kyc/update

FX

MethodPathAuthNotes
GET/fx/ratespublic{boards: {official: [...], effective: [...]}}
POST/fx/quoteJWT30s 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

MethodPathNotes
GET/user/profile
PUT/user/profileEmail changes require confirmation; phone changes flip IsPhoneVerified=false with no re-verify flow yet
POST/user/profile/confirm-emailNot in API_CONTRACT.md — added to complete the email-change flow the contract implies but never specified an endpoint for. {code}
GET/notificationsPaginated in-app notifications
POST/notifications/read{ids}
POST/notifications/read-all

NAQD

MethodPathAuthNotes
GET/naqd/pricepublicCurrent NAQD/USD price
GET/naqd/reservespublicLive ledger-sourced reserve snapshot
GET/naqd/supplypublicCirculating supply
GET/naqd/oracle/silver-price · /oracle/price-history · /oracle/sourcespublic
POST/naqd/mintJWTAsync — see Data Flows → NAQD mint
POST/naqd/burnJWTFee = NAQD_BURN_FEE_BPS (50bps)
POST/naqd/transferJWTNAQD P2P
POST/naqd/withdraw-onchainJWTSelf-custody — see Data Flows
GET/naqd/operations / /operations/:idJWTStatus polling
GET/naqd/transactionsJWTThin alias over operation listing
GET/naqd/blockchain/chains · /balance/:chain/:address · /tx/:chain/:txHashJWTNested here rather than top-level /blockchain/* — the standalone mount exists in code but is never called from routes.go

Cards

MethodPathIdem.Notes
GET/cardsList
POST/cards{type, currency, shipping_address?, pin} — virtual = active immediately with one-time reveal token
GET/cards/:idDetail incl. shipping state machine
POST/cards/:id/revealOne-time full PAN/CVV reveal, TTL CARD_REVEAL_TOKEN_TTL_SECONDS (300s)
POST/cards/:id/activateAccepts shipped or delivered status
POST/cards/:id/freeze / /unfreezeInstant
PUT/cards/:id/limitsBounded by KYC-tier ceilings
POST/cards/:id/pinCard PIN, distinct from the account transaction PIN
DELETE/cards/:idTerminate
GET/cards/:id/transactionsAuth/clearing history
POST/webhooks/cards/sandboxn/awebhook — public, HMAC X-Signature

Partners

MethodPathAuthNotes
GET/partners/railspublice.g. tng-my, MYR, ewallet_reload
POST/partners/quoteJWT60s TTL — own PartnerQuote model, not fx.FXQuote
POST/partners/executeJWTSee Data Flows → TnG reload
GET/partners/operations / /operations/:idJWT

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.

MethodPathMin roleNotes
POST/admin/loginpublicbcrypt password check, not Argon2id
GET/admin/dashboard/statssupportLive bug: queries KYC.status, but the real column is verification_status — this query errors as written
GET/admin/dashboard/charts/:typesupportcurrency_breakdown is a declared chart type with no handler case — falls through to an error
PUT/admin/users/:id/statusadminPublishes user.updated
POST/admin/kyc/:id/reviewcompliancePublishes kyc.approved/kyc.rejected
PUT/admin/withdrawals/:id/approvefinancePublishes txn.completed/txn.failed
POST/admin/wallets/:id/adjustfinanceCreates pending adjustment — no money moves yet
POST/admin/adjustments/:id/approveadminMust be a different admin than the requester — 403 otherwise
GET/admin/naqd/overviewadminSupply vs. reserve, collateral ratio, oracle status
POST/admin/naqd/operations/:id/approve / /rejectadminMaker-checker for large mint/burn
POST/admin/naqd/oracle/refreshadminContract says POST /admin/oracle/refresh — actual mounted path is /admin/naqd/oracle/refresh
PUT/admin/naqd/oracle/manual-rateadminFallback rate — not mentioned in API_CONTRACT.md
GET/admin/cards / /cards/requestscomplianceIssuance queue; status filter only, no user/type filter
POST/admin/cards/:id/approvecomplianceAtomically requested→approved→printed in one call (sandbox has no print house to delay for)
GET/admin/partners/settlementfinancePer-(rail, currency) balances read directly from the ledger
GET/admin/audit-logsadminFilterable by admin/entity/action/date range

Admin WebSocket + event catalog

Endpoint
GET /api/v1/admin/ws?token=<jwt> WS — token in the query string (browsers can't set custom headers on a WS handshake)
Auth
WSUpgradeMiddleware: blacklist check → ValidateAccessToken → active AdminUser lookup, before the upgrade completes
Wire shape
{"type": "...", "at": "<RFC3339>", "data": {...}}
Subscribe
Client may send {"type":"subscribe","channels":[...]}; unset/empty = all types (default)
Keepalive
54s 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.

EventIn contract?Emitted byPayload
user.registeredauth-service Register{user_id, email, uuid}
kyc.submittedkyc-service SubmitKYC{user_id, kyc_id, document_type}
txn.createdpayment-service settlement engine (Authorize){user_id, transaction_id, reference, type, payment_method, status, amount, currency}
txn.completed / txn.failedpayment-service settlement engine + admin withdrawal approvalsame shape as above
withdrawal.requestedwallet-service Withdraw{user_id, transaction_id, wallet_id, amount, currency}
naqd.minted / naqd.burnedstablecoin worker finishOK (withdraw_onchain folds into naqd.burned){operation_id, user_id, kind, naqd_amount, chain, tx_hash}
card.requestedcard-service IssueCard (physical only){card_id, card_uuid, user_id, type, currency}
partner.operationpartner-service (Execute / complete / refund transitions){operation_id, user_id, rail_id, status, partner_ref, debit_currency, debit_amount}
system.alert5 sites: oracle degrade (×3), stablecoin worker reversal, admin wallet adjustment approval{source, severity, reason, ...}
stats.tickadmin-service runStatsTicker, every 5s{total_users, pending_kyc, transactions_today, volume_today}
session.createdadditiveauth-service issueTokens (register/login/2FA-verify){session_id, user_id, device, ip}
session.revokedadditiveauth-service logout/logout-all/revoke-session, admin force-logout{session_id, user_id} or {user_id, all:true}
user.updatedadditiveadmin UpdateUserStatus{user_id, status}
kyc.approved / kyc.rejected / kyc.updatedadditiveadmin ApproveKYC{user_id, status}
card.activatednot documentedcard-service (virtual issuance, physical activation){card_id, card_uuid, user_id}
Frontend note

Every event above reaches a connected admin dashboard over the wire. Only system.alert currently drives dedicated UI (a toast). See Data Flows → Admin WebSocket event flow and Admin Console → WebSocket live layer for the rest of that story.