OctoWiki

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):

  1. JWT (middleware.JWTAuthMiddleware) — Authorization: Bearer <token>, validated against signature + the auth_tokens DB row (not revoked, not expired), sets client_id in locals + types.TenantIDKey in context.
  2. 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

MethodPathHandler
GETcountries, countries/:idGetCountriesHandler (country.go:19)
GETcurrenciesGetCurrenciesHandler (currency.go:18)
GETwallets, wallets/:idGetWalletsHandler (wallet.go:49)
GETtransactions, transactions/:idListTransactionsHandler (transaction.go:44)

Vouchers — gated IsVouchersEnabled()

MethodPathHandler
GETcategories, categories/:id, subcategories, subcategories/:idcategory.go / subcategory.go
GETproducts, products/:idListProductsHandler (product.go:71)
POSTproducts/:id/chargesGetChargesHandler (charges.go:23)
POSTproducts/:id/availabilityGetProductAvailability (charges.go:254)
GETorders, orders/:idGetOrdersHandler (order.go:148)
POSTordersCreateVoucherOrder (create_voucher_order.go:528)
PATCHorders/:id/notification-emailUpdateVoucherOrderEmail (order.go:601)

Payouts & beneficiaries — gated IsPayoutsEnabled()

MethodPathHandler
GET/POSTpayouts, payouts/:idpayout.go:100/73/19
POSTpayouts/:id/cancelCancelPayoutHandler
GET/POST/PUT/DELETEbeneficiaries, beneficiaries/:idpayout.go:284/211

Top-ups — gated IsTopupsEnabled()

MethodPath
GETtopups/products, topups/products/:id, topups/products/:id/variants, topups/variants/:id
POSTtopups/lookup, topups/charges, topups/orders
GETtopups/orders, topups/orders/:id
PATCHtopups/orders/:id/notification-email

eSIM — gated IsEsimEnabled()

MethodPath
GETesim/products, esim/products/:id, esim/products/:id/variants, esim/variants/:id
POSTesim/charges, esim/orders
GETesim/orders, esim/orders/:id
PATCHesim/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:

PathHandlerAuth
POST /webhooks/vendor/:vendor_codeHandleVendorWebhookvendor lookup + signature/idempotency
POST /webhooks/orders/:vendor_codeHandleDirectTopUpOrderWebhookvendor_code path
POST /webhooks/direct-topups/:vendor_codeHandleDirectTopUpRechargeWebhookvendor_code path
POST /webhooks/esims/:vendor_code/:tokenHandleEsimWebhookURL :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 → bare OrderDetailsResponse. A bad/unparseable id returns 404 deliberately (not 400) to avoid id-probing.
  • GET /ordersbare array []OrderResponse + X-* pagination headers. Filter ?client_reference=<value> is exact-match — the reconciliation handle.

Charges & availability

  • POST /products/:id/chargesChargesResponse: 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_id is json:"-".
  • 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_reference is 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 internal reference_code (UUID) is server-minted and never exposed.
  • Payouts — idempotent replay. A repeated reference_id returns 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:

  1. Polling GET /orders/:id (or ?client_reference=) until status is terminal, or
  2. Webhooks — terminal transitions fire order.delivered/partially_delivered/failed/cancelled via the outbound webhook engine (registered through the portal, not /api/v1). See Notifications.

Conventions

ConventionReality
Versionsingle v1; no other versions
Moneyserialized as JSON float64 (not decimal strings), rounded in transforms — despite decimal internal storage
IDsvoucher/product/wallet/transaction are numeric uint64; payout & beneficiary ids are KSUID strings; internal reference_code is a UUID (never exposed)
client_referencewrite-and-echo (returned on responses, usable as a read filter) — not write-only
List bodiesbare arrays (vouchers) with X-* headers vs wrapped {..., pagination} (payouts)
Success codesorder create 200, payout create 201 — inconsistent
Hidden fieldsjson:"-" 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 logs POST /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

On this page