Client API Reference (/api/v1)
The machine-facing REST API clients integrate against — every /api/v1 endpoint, request/response contracts, the two pagination conventions, the error envelope, idempotency semantics, and the async order model.
The /api/v1/* group is the machine-to-machine API: JWT bearer + IP whitelist, JSON in/out. This is a reference for the whole surface. Human-facing portal endpoints (/client/api/*, cookie auth) are a separate domain — summarized in Portal-only surfaces to prevent confusion.
Everything is wired in SetupRoutes (http/routes/routes.go:17); the group is created at routes.go:77:
api := app.Group("api/v1", jwtAuthMiddleware, ipWhitelistMiddleware)Feature-flag gating changes the surface
Voucher, payout, topup, and eSIM route blocks are each wrapped in if utils.FeatureFlags.Is<X>Enabled(). If a feature is disabled for a deploy, those routes are never registered and fall through to the API 404. Endpoint availability is deploy/flag-dependent — a 404 may mean "feature off," not "wrong path."
Auth for the group
Two chained middlewares (auth covered fully in Auth & Access Control):
- JWT (
middleware.JWTAuthMiddleware) —Authorization: Bearer <token>, validated against signature + theauth_tokensDB row (not revoked, not expired), setsclient_idin locals +types.TenantIDKeyin context. - IP whitelist (
middleware.IPWhitelistMiddleware) — runs after JWT. Allow-all-if-empty, fails open on DB error.
Handlers read the client id two different ways — c.Locals("client_id").(uint64) (payouts) and ctx.Value(types.TenantIDKey) (vouchers/charges). Same value, inconsistent access pattern.
Endpoint map
All paths below are prefixed /api/v1/. All require JWT + IP-whitelist; no per-route middleware is added inside the group.
Reference data & wallets
| Method | Path | Handler |
|---|---|---|
| GET | countries, countries/:id | GetCountriesHandler (country.go:19) |
| GET | currencies | GetCurrenciesHandler (currency.go:18) |
| GET | wallets, wallets/:id | GetWalletsHandler (wallet.go:49) |
| GET | transactions, transactions/:id | ListTransactionsHandler (transaction.go:44) |
Vouchers — gated IsVouchersEnabled()
| Method | Path | Handler |
|---|---|---|
| GET | categories, categories/:id, subcategories, subcategories/:id | category.go / subcategory.go |
| GET | products, products/:id | ListProductsHandler (product.go:71) |
| POST | products/:id/charges | GetChargesHandler (charges.go:23) |
| POST | products/:id/availability | GetProductAvailability (charges.go:254) |
| GET | orders, orders/:id | GetOrdersHandler (order.go:148) |
| POST | orders | CreateVoucherOrder (create_voucher_order.go:528) |
| PATCH | orders/:id/notification-email | UpdateVoucherOrderEmail (order.go:601) |
Payouts & beneficiaries — gated IsPayoutsEnabled()
| Method | Path | Handler |
|---|---|---|
| GET/POST | payouts, payouts/:id | payout.go:100/73/19 |
| POST | payouts/:id/cancel | CancelPayoutHandler |
| GET/POST/PUT/DELETE | beneficiaries, beneficiaries/:id | payout.go:284/211 |
Top-ups — gated IsTopupsEnabled()
| Method | Path |
|---|---|
| GET | topups/products, topups/products/:id, topups/products/:id/variants, topups/variants/:id |
| POST | topups/lookup, topups/charges, topups/orders |
| GET | topups/orders, topups/orders/:id |
| PATCH | topups/orders/:id/notification-email |
eSIM — gated IsEsimEnabled()
| Method | Path |
|---|---|
| GET | esim/products, esim/products/:id, esim/products/:id/variants, esim/variants/:id |
| POST | esim/charges, esim/orders |
| GET | esim/orders, esim/orders/:id |
| PATCH | esim/orders/:id/notification-email |
Inbound vendor webhooks hide inside the feature blocks
These are registered inside the voucher/topup/esim if blocks but on app root, NOT under /api/v1 and NOT behind JWT/IP-whitelist (vendor-authenticated instead). Easy to miss:
| Path | Handler | Auth |
|---|---|---|
POST /webhooks/vendor/:vendor_code | HandleVendorWebhook | vendor lookup + signature/idempotency |
POST /webhooks/orders/:vendor_code | HandleDirectTopUpOrderWebhook | vendor_code path |
POST /webhooks/direct-topups/:vendor_code | HandleDirectTopUpRechargeWebhook | vendor_code path |
POST /webhooks/esims/:vendor_code/:token | HandleEsimWebhook | URL :token vs webhook_path_token |
These are inbound (vendor → Octopus). See Notifications → inbound vs outbound.
Key contracts
Create voucher order — POST /api/v1/orders
Request entity.CreateVoucherOrderRequest (http/entity/voucher_order.go:23):
{
"product_id": 123, // required, > 0
"denomination": 50.0, // required, > 0, ≤ 1e9
"quantity": 1, // required, 1..10000 (also vs client BulkLimit; hard 5000 cap for non-link)
"wallet_id": 7, // optional
"client_reference": "po-42",// optional, printascii ≤255 — dedup key
"email": "buyer@x.com" // optional
}Flow: validate client → parse body → enforce BulkLimit → mint server-side reference_code (UUID, never exposed) → duplicate client_reference pre-check → 400 → resolve wallet (cross-currency OK) → compute charges (cached 5 min) → balance check → 400 "Insufficient funds" → DB tx: debit wallet + create transaction + insert order.
Response entity.CreateOrdersResponse at HTTP 200 (not 201). API callers get a bare object; portal callers (client_user in locals) get {success, data}. Fields: id, product_id, denomination, amount, discount, client_reference (echoed), status, optional vouchers[] (claim_url / card_number / pin_code / expires_at).
Read orders
GET /orders/:id→ bareOrderDetailsResponse. A bad/unparseable id returns 404 deliberately (not 400) to avoid id-probing.GET /orders→ bare array[]OrderResponse+X-*pagination headers. Filter?client_reference=<value>is exact-match — the reconciliation handle.
Charges & availability
POST /products/:id/charges→ChargesResponse:non_discounted_total,discount_amount,total_amount,discount,gst_amount?,total_payable,max_quantity,charges_details{source_currency, destination_currency, forex_rate?, conversion_fee?}.vendor_product_idisjson:"-".POST /products/:id/availability→{ "is_available": bool }.
Create payout — POST /api/v1/payouts
Request entity.CreatePayoutRequest (entity/payout.go:72): reference_id (required ≤255 — idempotency key), wallet_id, amount (>0), currency (ISO-4217 upper len=3), either beneficiary_id (KSUID) or inline beneficiary_email/first_name/last_name, optional beneficiary_country (ISO-3166 α2), beneficiary_phone (e164), beneficiary_identifiers, description, metadata, notify_beneficiary. Returns entity.Payout at HTTP 201; payout id is a KSUID string. See the Payouts caveats before relying on the pipeline.
Create topup — POST /api/v1/topups/orders
entity.CreateTopupOrderRequest (entity/topup.go:101): product_id, amount, input_data (product-specific fields validated against topup_product_input_fields), optional client_reference, wallet_id, category, redeem_voucher_code (fund from an existing delivered voucher). Response includes structured failure fields (failure_code, failure_reason, is_user_fixable).
Create eSIM — POST /api/v1/esim/orders
entity.CreateEsimOrderRequest (entity/esim.go:23): product_id, amount, quantity (normalized to 1), optional client_reference, wallet_id, category, redeem_voucher_code. Response: activation_code, iccid, activation_status, is_installed + failure fields. sub_status is deliberately not exposed.
Pagination — two coexisting conventions
The API has two different pagination styles. Flag this to integrators.
Pattern A — voucher/topup/eSIM lists (header-based, bare-array body). parseQueryParams (handler.go:58): limit (default 50, 1..10000), page (default 1), sort (JSON {"field","direction"}), plus filters. Headers via AddPaginationHeaders: X-Page, X-Per-Page, X-Total-Count, X-Total-Pages, X-Page-Size, X-Has-More.
Pattern B — payouts/beneficiaries (body-based). page / limit / sort_by / sort_order query params; a pagination object embedded in the JSON body. No X-* headers.
Error format
Central handler middleware.CustomErrorHandler (wired at main.go:480) maps *web.AppError onto the envelope; anything else becomes a 500. Envelope entity.ErrorResponse:
{
"error": {
"name": "ValidationException",
"code": "E_VALIDATION_FAILURE",
"message": "Input validation failed.",
"messages": {
"field_name": [{ "rule": "required", "field": "field_name", "message": "..." }]
}
}
}Canonical catalog in errors/errors.go: ValidationError 400/E_VALIDATION_FAILURE, NotFoundError 404, UnauthorizedAccessError 401, ResourceConflictError 409, TooManyRequestsError 429, InternalServerError 500. Builder methods (WithMessage/WithDetails) return copies — the canonical errors are package singletons.
Two inconsistencies to flag: (1) the /api catch-all 404 (routes.go:182) returns a different JSON shape ({error:{code,message,path,method}}) than the AppError envelope; (2) there are two near-duplicate validation errors (E_VALIDATION_FAILURE vs VALIDATION_FAILURE).
Idempotency & duplicate detection
Two different models — cross-resource inconsistency:
- Orders (voucher/topup/eSIM) — hard reject.
client_referenceis unique per(client_id, client_reference); a duplicate returns 400 "Duplicate client_reference" (a pre-check converts the Postgres 23505 unique-violation, which otherwise surfaced as a 500, into a clean 400). The internalreference_code(UUID) is server-minted and never exposed. - Payouts — idempotent replay. A repeated
reference_idreturns the existing payout ("Payout already exists") rather than erroring.
Async order model
Voucher orders with quantity > 5 and not a link order are async (isAsync := Quantity > 5 && !IsLink). On create the order commits PENDING/Initial with RetryAfter/RetryCount set and the handler returns 200 immediately; the every-minute cron runs the same processOrder (see Order Lifecycle). Client consumes completion by either:
- Polling
GET /orders/:id(or?client_reference=) untilstatusis terminal, or - Webhooks — terminal transitions fire
order.delivered/partially_delivered/failed/cancelledvia the outbound webhook engine (registered through the portal, not/api/v1). See Notifications.
Conventions
| Convention | Reality |
|---|---|
| Version | single v1; no other versions |
| Money | serialized as JSON float64 (not decimal strings), rounded in transforms — despite decimal internal storage |
| IDs | voucher/product/wallet/transaction are numeric uint64; payout & beneficiary ids are KSUID strings; internal reference_code is a UUID (never exposed) |
client_reference | write-and-echo (returned on responses, usable as a read filter) — not write-only |
| List bodies | bare arrays (vouchers) with X-* headers vs wrapped {..., pagination} (payouts) |
| Success codes | order create 200, payout create 201 — inconsistent |
| Hidden fields | json:"-" on vendor_product_id, variant_id, eSIM sub_status |
The postman/ directory exists but is empty — there is no committed collection to hand over. If integrators need one, it must be regenerated.
Portal-only surfaces
These are not /api/v1 — they live under /client/api/* with cookie auth (ClientCookieAuthMiddleware), some behind RequireClientPermission. Documented here only to disambiguate:
- Cart
/client/api/cart*(+/checkout), Exports/client/api/exports*(+/:id/data,/:id/download), Webhook registration/client/api/webhooks*(+/test,/rotate-secret), Frontend logsPOST /client/api/logs. - API keys, IP-whitelist, 2FA, passkeys, G2A self-service credentials, scheduled payouts — all portal-only.
- Payouts/beneficiaries also exist under the portal (two front doors to the same service).
Key files
- Routing:
http/routes/{routes,client}.go - Handlers:
http/handler/{create_voucher_order,order,product,charges,payout,topup,esim}.go - DTOs:
http/entity/{voucher_order,order,charges,payout,topup,esim,error}.go - Errors:
errors/errors.go,http/entity/error.go,middleware/error.go - Pagination:
database/models/models.go,http/handler/handler.go
Octopus (OCTO_ESIM) — federated eSIM
eSIM vendor adapter consuming another Octopus's /api/v1/esim — login-token auth, the airtight delivered=PROCESSING webhook rule, two-factor webhook security.
Error & Failure Codes
The three error systems — the HTTP AppError catalog (29 singletons), the Err* control-flow sentinels grouped by subsystem, and the client-facing FailureCode enum with vendor mappings — plus the duplicate frameworks, verbatim leaks, and dead gRPC block.