OctoWiki

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.

Vendor credentials and per-vendor config live in the DB, not env — the vendor_attributes table (one key/value row per vendor). Each adapter reads specific keys. This is the catalog of what to fill in per vendor. See Vendors for the adapter architecture and Vendor Adapters for per-vendor behavior.

Every vendor secret is stored plaintext

vendor_attributes is (vendor_id, key, value TEXT, created_by)no is_secret flag, no encryption, no timestamps (vendor_attributes.go, migration 20250822000041). Every password / api_key / secret_key / webhook_secret / token below is stored in cleartext and returned/edited in cleartext by the admin CRUD. The separate payout_provider_attributes table has an is_secret column, but the payout factory ignores it. This compounds the committed-secrets findings — the DB itself holds every vendor credential unencrypted.

Data model

  • vendors (models/vendor.go:60): code (adapter selector), name, vendor_type (VOUCHER/DIRECT_TOPUP/ESIM/BOTH), is_active, is_bulk, p_limit, balance+fetched_at, has_webhook, has_api, is_async, pre_fetch_enabled, max_quantity, is_link_enabled, has_reference_id_support, currency_code. (Naming drift: the repo selects is_link_enabled while the migration named the column is_gv_link_enabled.)
  • vendor_attributes (models/vendor_attributes.go): vendor_id, key, value TEXT, created_by, UNIQUE(vendor_id, key). Upsert on conflict.

Three config-loading shapes (the same attributes flatten differently per line):

LineStructKey routing
VoucherVendorConfig{Host, Username, Password, APIKey, Timeout, Delay, Extra}well-known host/username/password/api_key/timeout/delay; everything else → Extra
Top-up / eSIMvendorhttp.VendorConfig{APIBaseURL, Credentials map, WebhookURL, …}host→APIBaseURL; callback_url|webhook_url→WebhookURL; rest → Credentials[…]
PayoutProviderConfig{Host, APIKey, APISecret, Extra}api_url|base_url|host→Host; api_key|api_token|token→APIKey; api_secret|secret→APISecret; rest → Extra

Loading never errors — parse failures fall back to defaults (a vendor with zero attributes gets a bogus https://api.example.com host on the voucher line).

Per-vendor attribute keys

R = required (constructor errors if missing); O = optional; 🔑 = sensitive (plaintext).

Voucher line

VendorKeys
TRShost O*, username 🔑O*, password 🔑O* — constructor validates none; Basic auth pulled at call time
RUNAhost R, api_key 🔑R (X-Api-Key), webhook_secret 🔑O (Svix), timeout O(30); environment O (dead)
WPXhost R, api_key 🔑R (x-api-key), merchant_code R (from Extra, falls back to username)
IRWhost O, username 🔑R (email), password 🔑R
EPINhost O, api_key 🔑R (bearer)
GHhost R, api_key 🔑R (X-Auth-Token), timeout O(30)
NEOhost R, client_id 🔑R, client_secret 🔑R, email 🔑R, password 🔑R, prepaid_client_id O
OCTOhost R, username 🔑R, password 🔑R, webhook_secret 🔑O (HMAC), self_base_url O (else APP_BASE_URL)

Top-up line

VendorKeys
SEAGMhost R, uid 🔑R, secret_key 🔑R (request signing)
DTONEhost R, username 🔑R, password 🔑R, service_id O(1), callback_url|webhook_url O
WPXhost R, api_key 🔑R, merchant_code R (from Credentials)
OCTO_TOPUPhost R, username 🔑R, password 🔑R, webhook_secret 🔑O, self_base_url O

eSIM line

VendorKeys
DTONE_ESIMhost R, username 🔑R, password 🔑R, callback_url O, webhook_path_token 🔑R-for-inbound; service_id O (dead — adapter hardcodes 13)
OCTO_ESIMhost R, username 🔑R, password 🔑R, webhook_secret 🔑O, webhook_path_token 🔑R-for-inbound, self_base_url O

Payout line (separate payout_provider_attributes table)

ProviderKeys
MERITapi_url|base_url|host R, api_token|api_key|token 🔑R (bearer)
LEDIGapi_url|base_url|host R, api_key|api_token|token 🔑R

Merit's payout_provider_id and Ledig's account_alias are per-payout request fields, not attributes. See Payouts.

Shared keys

KeyConsumed byMeaning
hostall linesvendor API base URL
username/passwordTRS, IRW, NEO, DTONE(all), OCTO(all)login / HTTP Basic
api_keyRUNA, EPIN, GH, WPX, MERIT, LEDIGprimary credential (header varies)
webhook_secretRUNA (Svix), OCTO family (HMAC)inbound webhook signature verify
self_base_urlOCTO familyself-loop guard — federated Octopus can't point at us; falls back to env APP_BASE_URL (octopuscommon/guard.go)
callback_url/webhook_urltopup + eSIMoutbound callback URL we hand the vendor (both spellings accepted)
webhook_path_tokeneSIM inbound (DTONE_ESIM, OCTO_ESIM)unguessable URL path segment, constant-time compared (esim_webhook.go:194)
timeoutvoucher line onlyHTTP timeout secs — topup/eSIM seed it but never apply it (see dead keys)

The Octopus family (OCTO / OCTO_TOPUP / OCTO_ESIM) shares octopuscommon TokenManager + SelfLoopGuard and the public /api/v1 contract. See Octopus adapters.

Vendors-table config columns

ColumnPurpose
codeadapter selector in every factory switch — must equal the adapter's VendorCode constant
vendor_typeproduct-line association
is_activefactories refuse inactive vendors
is_bulkorder fan-out mode — one CreateOrder for the group vs one per item (Order Lifecycle)
p_limitparallelism for non-bulk vendors
balance + fetched_atwallet-balance auto-sync snapshot (vendor-balance-sync cron)
has_webhookgates inbound webhook acceptance
has_reference_id_supportenables the pre-persist-ref recovery path in checkpoint 2

One-row-per-line vs shared rows

OCTO is three distinct codes/rows (OCTO, OCTO_TOPUP, OCTO_ESIM) — like the DTONE/DTONE_ESIM split — each with its own attribute set. WPX uses a single code "WPX" for both its voucher and top-up adapters; a BOTH row's attributes must satisfy both (note merchant_code is read from Extra by the voucher adapter but Credentials by the top-up adapter — both resolve from the same flattened key, so it works in practice).

Setting attributes & onboarding

Admin CRUD (admin_ui.go, routed admin.go:218): list / create ({key,value}, upsert) / update / delete under /vendors/:id/attributes. The UI stores raw strings — no per-key validation, no secret masking, no encryption.

Onboarding a new vendor:

  1. Insert a vendors row — code must equal the adapter's VendorCode constant (types/common.go:60); set vendor_type, is_active, currency_code, and behavior flags (is_bulk, p_limit, has_webhook, has_api, pre_fetch_enabled, max_quantity).
  2. Add the attributes from the per-vendor tables above. Family minimums: voucher = host + auth; SEAGM = host+uid+secret_key; DTONE = host+username+password(+callback_url); eSIM = +webhook_path_token; OCTO family = also self_base_url (or APP_BASE_URL) so the self-loop guard passes, and webhook_secret to enforce signature verification; payout = api_url+token.

See the vendor chaos + tests rule — a new adapter also needs a mocky route + tests in the same PR (Testing).

Findings

CategoryDetail
Plaintext secretsEvery credential in vendor_attributes.value TEXT — no encryption/masking/is_secret; payout table's is_secret ignored
Dead keysenvironment (RUNA/NEO/OCTO family — never read), delay (voucher field parsed but no adapter reads it), timeout for topup/eSIM (seeded but never mapped into config → constructors see 0), service_id on DTONE_ESIM (hardcoded 13), api_secret/secret for payouts (mapped but unread)
Naming inconsistencybase URL host vs APIBaseURL vs credentials.host; credential api_key vs api_token vs token; callback callback_url vs webhook_url; secret zoo (secret_key/webhook_secret/api_secret/client_secret); merchant_code/self_base_url read from Extra vs Credentials depending on adapter

Key files

  • Models: database/models/{vendor,vendor_attributes}.go; repo database/repo/vendor{,_attributes}.go
  • Factories: services/external_vendors/{vouchers,direct_topup,esim,payouts}/factory.go
  • Config structs: vouchers/contract.go, vendorhttp/config.go, payouts/types.go
  • Shared: services/external_vendors/octopuscommon/guard.go; admin CRUD http/handler/admin_ui.go:4063

On this page