Runa — RUNA
Voucher vendor adapter — async, link-based (Runa-hosted payout URL), Svix-signed webhook + polling. The reference orchestration test.
Quick facts
Code RUNA · Line voucher · Auth X-Api-Key header (no login) · Model async + link-based (Runa-hosted payout URL) · Webhook Svix-signed order.completion (nudge) + polling · Delivery ClaimURL only (no code/PIN) · Adapter runa_vendor.go, runa_client.go, runa_errors.go · Tests contract + the reference orchestration suite
Runa never surfaces raw codes/PINs — each item yields a Runa-hosted payout link. Fulfilment is signalled by a Svix webhook or by polling GET /v2/order/{id}. Registered factory.go:54. See Order Lifecycle.
Auth
Header-based API key on every call: X-Api-Key. No login/token exchange. Auth/Ping = GET /v2/balance. vendor_attributes: host (must include /v2), api_key, timeout, plus webhook_secret (read by the webhook verifier, not the adapter). Playground key prefix xx_, prod wg_ (prod host https://api.runa.io/v2).
Catalog
GetCatalogBatched — GET /v2/product?limit=500&is_orderable=true with cursor pagination (&after=<cursor>, next from pagination.cursors.after). Markdown content is prefetched from CloudFront URL pointers (25-way concurrency, separate un-authed client, errors swallowed). Mapping: one row per country × per denomination; discount_multiplier IS the discount fraction (×100 → %). Fixed with available_list → one variant per value; fixed without → min/max; else open range.
Order create — async + link
POST /v2/order with X-Idempotency-Key: <uuid> + X-Execution-Mode: async. Body: payment_method{ACCOUNT_BALANCE, currency}, one item {face_value, distribution_method: PAYOUT_LINK, products{SINGLE, value}}, metadata carrying octopus_reference_code/client_id. Quantity 1 only. Success = HTTP 202; OrderID = Runa's O-<ULID> id (persisted to drive polling).
Decimal-string money. Input face_value is numeric, but completed orders return monetary values as decimal strings, parsed at the boundary by parseDecimalString. Real order ids carry the O- prefix; per-item payout status lives nested under payout.status, not on the item.
Order status — poll + webhook
Both GetOrderStatus and GetVouchers call GET /v2/order/{id}. A nil vendorOrderID returns ErrOrderNotFound (the orchestrator's "did my create land?" checkpoint) — Runa keys by its own id, not our reference. 404 → ErrOrderNotFound (transient, don't fail). A FAILED order sets ShouldReCreate. GetVouchers only returns when status is COMPLETED.
Webhook — Svix
Verified by the Svix SDK (svix-id/svix-timestamp/svix-signature headers); the verifier factory special-cases RUNA to build a Svix verifier from webhook_secret (falls back to Noop if absent). svix-id is the authoritative idempotency key. The order.completion webhook carries only {id, status} — it does not deliver the link; it schedules an immediate retry (retry_after=now) and the next poll fetches the payout link. NormalizeWebhookPayload also handles product.update.
Delivery — link only
Each completed item → Voucher{ClaimURL: payout.url, Code:"", Pin:""}, amount from the decimal-string face value, expiry from payout.expiry_date (default +1yr). CancelVoucher unsupported after acceptance.
Balance
GET /v2/balance → top-level JSON array of {currency, balance} → one BalanceResponse per currency (prepaid multi-currency wallet).
Quirks
- Singular endpoints (
/v2/product,/v2/order, …) — a resolved spec-drift bug: the Go client and the mock had both used plural, so they matched each other but not live Runa. See Memory Appendix. Host must include/v2or everything 404s. - The same fix corrected many schema bugs (balance array,
catalogkey, cursor pagination, decimal-string denominations, nestedpayout). - Error taxonomy (
runa_errors.go): 102 idempotency-in-progress → retry 30s; 400/401/403/422 → terminal; 429 → retry (honorsRetry-After);insufficient_funds→ terminal; ≥500 → retry 10s. The orchestrator intentionally ignores the terminal flag for insufficient-funds/out-of-stock, keeping the order retryable so it recovers on refill/restock.
Tests, mock, docs
Runa is the reference orchestration suite — test/clientapi/runa_orchestration_test.go (2403 lines, build tag orchestration): happy path, resiliency/retries, concurrency dedup, async status-polling delivery, 404-stays-retryable, and the Svix-signed webhook chain. Note runaSetup forces IsLinkEnabled=false so orders route through the vendor CreateOrder path. Contract test + mocky route frontend/mocky-balboa/src/routes/runa.ts (fires real Svix webhooks). Docs under docs/runa/.