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 selectsis_link_enabledwhile the migration named the columnis_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):
| Line | Struct | Key routing |
|---|---|---|
| Voucher | VendorConfig{Host, Username, Password, APIKey, Timeout, Delay, Extra} | well-known host/username/password/api_key/timeout/delay; everything else → Extra |
| Top-up / eSIM | vendorhttp.VendorConfig{APIBaseURL, Credentials map, WebhookURL, …} | host→APIBaseURL; callback_url|webhook_url→WebhookURL; rest → Credentials[…] |
| Payout | ProviderConfig{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
| Vendor | Keys |
|---|---|
| TRS | host O*, username 🔑O*, password 🔑O* — constructor validates none; Basic auth pulled at call time |
| RUNA | host R, api_key 🔑R (X-Api-Key), webhook_secret 🔑O (Svix), timeout O(30); environment O (dead) |
| WPX | host R, api_key 🔑R (x-api-key), merchant_code R (from Extra, falls back to username) |
| IRW | host O, username 🔑R (email), password 🔑R |
| EPIN | host O, api_key 🔑R (bearer) |
| GH | host R, api_key 🔑R (X-Auth-Token), timeout O(30) |
| NEO | host R, client_id 🔑R, client_secret 🔑R, email 🔑R, password 🔑R, prepaid_client_id O |
| OCTO | host R, username 🔑R, password 🔑R, webhook_secret 🔑O (HMAC), self_base_url O (else APP_BASE_URL) |
Top-up line
| Vendor | Keys |
|---|---|
| SEAGM | host R, uid 🔑R, secret_key 🔑R (request signing) |
| DTONE | host R, username 🔑R, password 🔑R, service_id O(1), callback_url|webhook_url O |
| WPX | host R, api_key 🔑R, merchant_code R (from Credentials) |
| OCTO_TOPUP | host R, username 🔑R, password 🔑R, webhook_secret 🔑O, self_base_url O |
eSIM line
| Vendor | Keys |
|---|---|
| DTONE_ESIM | host R, username 🔑R, password 🔑R, callback_url O, webhook_path_token 🔑R-for-inbound; service_id O (dead — adapter hardcodes 13) |
| OCTO_ESIM | host 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)
| Provider | Keys |
|---|---|
| MERIT | api_url|base_url|host R, api_token|api_key|token 🔑R (bearer) |
| LEDIG | api_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
| Key | Consumed by | Meaning |
|---|---|---|
host | all lines | vendor API base URL |
username/password | TRS, IRW, NEO, DTONE(all), OCTO(all) | login / HTTP Basic |
api_key | RUNA, EPIN, GH, WPX, MERIT, LEDIG | primary credential (header varies) |
webhook_secret | RUNA (Svix), OCTO family (HMAC) | inbound webhook signature verify |
self_base_url | OCTO family | self-loop guard — federated Octopus can't point at us; falls back to env APP_BASE_URL (octopuscommon/guard.go) |
callback_url/webhook_url | topup + eSIM | outbound callback URL we hand the vendor (both spellings accepted) |
webhook_path_token | eSIM inbound (DTONE_ESIM, OCTO_ESIM) | unguessable URL path segment, constant-time compared (esim_webhook.go:194) |
timeout | voucher line only | HTTP 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
| Column | Purpose |
|---|---|
code | adapter selector in every factory switch — must equal the adapter's VendorCode constant |
vendor_type | product-line association |
is_active | factories refuse inactive vendors |
is_bulk | order fan-out mode — one CreateOrder for the group vs one per item (Order Lifecycle) |
p_limit | parallelism for non-bulk vendors |
balance + fetched_at | wallet-balance auto-sync snapshot (vendor-balance-sync cron) |
has_webhook | gates inbound webhook acceptance |
has_reference_id_support | enables 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:
- Insert a
vendorsrow —codemust equal the adapter'sVendorCodeconstant (types/common.go:60); setvendor_type,is_active,currency_code, and behavior flags (is_bulk,p_limit,has_webhook,has_api,pre_fetch_enabled,max_quantity). - 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 = alsoself_base_url(orAPP_BASE_URL) so the self-loop guard passes, andwebhook_secretto 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
| Category | Detail |
|---|---|
| Plaintext secrets | Every credential in vendor_attributes.value TEXT — no encryption/masking/is_secret; payout table's is_secret ignored |
| Dead keys | environment (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 inconsistency | base 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; repodatabase/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 CRUDhttp/handler/admin_ui.go:4063
Vendors & Integrations
The vendor adapter architecture, the full adapter inventory, order orchestration, inbound sales channels (Shopify, G2A), and OCTO federation.
Vendor Adapters
Per-vendor implementation notes — one page per adapter, grouped by product line, documenting each vendor's auth, catalog, order, webhook, delivery, and unique quirks.