Vendors & Integrations
The vendor adapter architecture, the full adapter inventory, order orchestration, inbound sales channels (Shopify, G2A), and OCTO federation.
Octopus talks to the outside world through vendor adapters under services/external_vendors/. There are three outbound product lines — voucher, direct-topup, eSIM — each with its own interface, factory, and adapters. Plus two inbound sales channels (Shopify, G2A) where Octopus is the server, and a payouts family.
This page covers vendor adapters and orchestration. For how vendor products are modelled, priced, made visible (blacklists), and kept in sync (catalog sync, wallet balance), see Products, Vendors & Catalog.
Never leak vendor topology to customers
Vendor brand names and the generic supplier vocabulary ("vendor", "upstream", "supplier", "submit to vendor", "vendor outage") must never appear in any client-facing surface — API fields, docs, emails, the client panel, the claim page, webhooks. Admin/ops surfaces only. Grep before shipping. See the Memory Appendix.
The two vendor concepts
"Grasshopper" is not separate from topup vendors — it's simply one voucher vendor (code='GH'). The real axis is product line + vendor.code.
The vendors table (database/models/vendor.go) carries:
code *string— the routing key every factory switches on.vendor_type:VOUCHER | DIRECT_TOPUP | BOTH | ESIM.- Orchestration flags:
is_async,has_webhook,has_reference_id_support,pre_fetch_enabled,has_api,is_bulk,is_active,max_quantity,p_limit,currency_code,balance.
Credentials live in the DB, not env
All vendor API credentials/config are rows in vendor_attributes (vendor_id, key, value). Factories map well-known keys (host, username, password, api_key, callback_url, timeout) onto a VendorConfig, and pass everything else through Extra/Credentials maps (e.g. NeoCurrency client_id/client_secret, Wupex merchant_code, OCTO self_base_url/webhook_secret). Exceptions: Shopify creds sit on the clients table (encrypted); G2A creds live in g2a_credentials (bcrypt).
Adapter inventory
Each factory switches on vendor.code. Codes are types.VendorCode constants in services/external_vendors/types/common.go.
| Code | Name | Line | Async / style | Adapter file |
|---|---|---|---|---|
TRS | The Reward Store | voucher | sync code delivery | vouchers/trs_vendor.go |
RUNA | Runa | voucher | async, link-based; Svix-signed webhook or poll | vouchers/runa_vendor.go |
WPX | Wupex | voucher (+topup) | poll | vouchers/wupex_vendor.go, direct_topup/wupex/ |
IRW | iRewardify | voucher | webhook (custom payload) | vouchers/irewardify_vendor.go |
EPIN | EpinForce | voucher | async, NO webhook — stateful shared cart, poll-only, XLSX/ZIP delivery | vouchers/epinforce_vendor.go |
GH | Grasshopper | voucher | CF-Worker redemption; Octopus mints code+PIN, sends SHA-256 hashes to GH | vouchers/grasshopper_vendor.go |
NEO | NeoCurrency | voucher | poll (lazy OAuth2 bearer) | vouchers/neo_vendor.go |
OCTO | Octopus Network (federated) | voucher | async + HMAC webhook + poll, login-token | vouchers/octopus_vendor.go |
SEAGM | SEAGM | topup | poll (HMAC-SHA256 signed) | direct_topup/seagm/seagm_vendor.go |
DTONE | DT One DVS | topup | async (webhook + poll) | direct_topup/dtone/dtone_vendor.go |
OCTO_TOPUP | Octopus Network (federated) | topup | login-token, self-loop guarded | direct_topup/octopus/octopus_vendor.go |
DTONE_ESIM | DT One DVS eSIM | esim | async (poll + install poll) | esim/dtone/dtone_esim_vendor.go |
OCTO_ESIM | Octopus Network eSIM | esim | login-token; webhook = poll-nudge only | esim/octopus/octopus_esim_vendor.go |
types/common.go declares many more voucher codes (LC, BNC, RZP, WOGI, NCTV, DIGC, VCHG, AQP, GLT, FLZ, WC, TRF) that are not implemented — the factory only handles the ones above. Treat them as reserved/planned. Don't add speculative branches for product types you don't actually receive (house rule).
Shared infra: vendorhttp/ (common HTTP client + config), octopuscommon/ (self-loop guard.go + login-token token_manager.go for the OCTO family), webhooks/ (Svix + HMAC verifiers), types/failure_codes.go (normalized failure taxonomy). payouts/ (Ledig, Merit) is a separate outbound-money family, not product vendors.
Order orchestration
Three paths, one shape: create → (poll / webhook) → complete, with state on the order/recharge/esim_order row's sub_status.
Vouchers — two sub-flows
Prefetch (async vendors, pre_fetch_enabled) — the platform pre-orders voucher stock ahead of demand and caches it. Driven by a formal state machine in jobs/prefetch_state_machine.go:
validTransitions is the authoritative graph. Retries use exponential backoff (2^n min, capped at 16); isRetryableError matches VENDOR_TIMEOUT/RATE_LIMIT strings. Terminal stage PUMPING pushes inventory to Valkey.
Live orders (not pre-stocked) — jobs/pending_order_retry_job.go → handler.ProcessOrderRetry(...) creates order items, assigns inventory, calls the vendor, updates delivery. Vendor calls go through services/external_vendors/manager.go (VendorManager), which resolves and caches the adapter.
State lives on order_items.status + order_items.sub_status. There's a recreate protocol: types.ErrShouldReCreate / FormattedOrderResponse.ShouldReCreate — when a vendor loses record of an order (terminal/404), the orchestrator clears vendor_order_id and re-CREATEs on the next retry while the Octopus order stays PENDING.
Topups
services/topup_order.go CreateTopupOrder: pick best vendor variant → debit client wallet (or burn a redeemed voucher via redeem_resource_id) → create recharge atomically → call the vendor. Completion polled by jobs/topup_order_retry_job.go (batch 50, max 10 retries).
eSIM
services/esim_order.go (~1220 lines). Completion driven by jobs/esim_order_retry_job.go (poll), jobs/esim_installation_poll_job.go (activation), jobs/esim_expiry_job.go.
Inbound webhooks
services/vendor_webhook_service.go ProcessWebhook(vendorID, req) routes by resource_type (order/recharge/payment). Each adapter normalises its native payload to types.NormalizedWebhookPayload. Signatures verified via webhooks/ (Svix or HMAC). Events persisted (idempotency via IdempotencyKey).
Notification discipline
Webhooks/emails fire only at terminal (status, sub_status) pairs — never on in-flight sub-statuses like VENDOR_ORDER_PENDING, even when the top-level status is FAILED/CANCELLED. See the Memory Appendix.
Inbound sales channels
These run the opposite direction — external systems buy from Octopus. Not in external_vendors/.
Shopify — services/shopify/
Octopus is a client polling Shopify. ClientFactory builds one Shopify GraphQL client per tenant using per-client creds on the clients table (ShopifyShopName, encrypted ShopifyAccessToken, ShopifyAPIVersion, ShopifyEnabled/SyncEnabled). Cron jobs pull and fulfil orders (fetch-shopify-orders, process-pending-shopify-orders, detect-shopify-cancellations), fulfilling through the internal voucher pipeline. There's a locked upgrade plan to Shopify API 2026-04 — see the Memory Appendix.
G2A — http/handler/g2a*.go
Octopus is the HTTP server and OAuth2 token issuer; the G2A marketplace calls in. GET /g2a/oauth/token issues bearer tokens (client_credentials; creds in g2a_credentials). Flow: reserve → order → pull codes. Reservations hold no codes — Octopus Cards (GH) are minted on confirm, not drawn from a finite pool; the g2a-reservation-expiry cron expires stale holds (30m TTL). Product mapping via g2a_product_mapping. See the Memory Appendix.
Shopify = Octopus is an outbound client (pull). G2A = Octopus is an inbound server (push). Both are multi-tenant / client-scoped.
OCTO federation (self-vendor)
Octopus can consume another Octopus instance's public /api/v1 as if it were a third-party supplier, across all three lines: OCTO (vouchers → /api/v1/orders), OCTO_TOPUP (/api/v1/topups), OCTO_ESIM (/api/v1/esim). Auth is a login flow (not a static key): POST /auth/login → short-lived tokens, managed by octopuscommon/token_manager.go. octopuscommon.SelfLoopGuard errors if a federated vendor's host resolves to this instance's own base URL, preventing an order recursing into itself. Full contract gotchas (bare-array lists, write-only client_reference, composite productID:variantID VendorVariantID, eSIM webhook omitting activation_code) are in the Memory Appendix.
Catalog sync & balances
- Catalog sync —
services/vendor_catalog_service.go; vendors stream products viaGetCatalogBatched. Crons:vendor-catalog-sync(daily 2 AM),vendor-catalog-cleanup(Sun 3 AM),vendor-catalog-retry(every min). - Balance sync —
vendor-balance-sync(every 15 min) pullsGetBalanceintovendors.balance/vendor_wallet. - Inventory pump —
inventory-pump(every 6h) orgo run main.go pump-inventorypushes stored voucher inventory into Valkey.
Vendor adapter shipping rule
Every new services/external_vendors/ adapter ships with a mocky-balboa route (incl. chaos hooks for 4xx/5xx/timeout/malformed/drop) plus unit + contract + service-level tests in the same PR. Every direct_topup mapper must populate MinAmount/MaxAmount/VendorDiscount on types.Product (DT One is the reference). See the Memory Appendix.
Cron Catalog
Every scheduled task — the actual cron string (source of truth), whether it's registered, what it drives, batch/timeout, and the two implemented-but-unregistered payout crons. Plus the stale-comment schedule traps.
Vendor Configuration
The vendor_attributes credential/config catalog — every attribute key each adapter reads, the shared keys, the vendors-table config columns, the onboarding checklist, and the fact that every vendor secret is stored plaintext.