OctoWiki

Grasshopper — API & Provisioning

The exact wire contract of every Grasshopper endpoint (request fields, response shapes, every error status), plus how Octopus provisions vouchers and catalog into Grasshopper's D1 — the vendor-adapter push, the admin catalog push vs the worker's self-pull /sync, the SHA-256-vs-SHA-3-256 dual-hash split, and the dynamic input-field pipeline.

The per-endpoint contract appendix for Grasshopper, plus the provisioning story (how vouchers and catalog rows get into D1). For the system overview see Grasshopper; for the UI that consumes these see Grasshopper UI.

Cross-cutting behaviour (every endpoint)

  • Global response headers — set by app.use('*') after next() (index.tsx:57-75), so they attach to every response including errors: the full CSP, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin.
  • Uncaught throws → 500. Handlers don't wrap DB/Octopus calls in try/catch except where noted, so malformed JSON to c.req.json(), a DB failure, or an Octopus-client throw becomes a bare framework 500.
  • No Retry-After / X-RateLimit-* on any 429. CF native RateLimit bindings return only {success}; clients must back off blindly.
  • Pagination divergence — Grasshopper embeds pagination in the JSON body ({data,page,per_page,total,total_pages,has_more}), only on the voucher-types list. Octopus signals pagination via X-* headers (API Reference). A client written against Octopus's convention finds no pagination metadata here. Contact list is a bare array.
  • Admin gate (authenticateAdmin, admin-auth.ts:10): header X-Auth-Token constant-time vs ADMIN_TOKEN. Missing → 401 {"error":"Authentication token required"}; mismatch/unset → 401 {"error":"Invalid authentication token"}. No rate limiting on admin routes.
  • Content negotiation — the claim status endpoint branches on HX-Request: true (isHtmx): HTMX gets HTML fragments, non-HTMX gets JSON.

Root & status

EndpointAuthSuccessErrors
GET / (index.tsx:86)none200 HTML <LandingPage> (injects Turnstile key + Trustpilot score)throw in Trustpilot fetch → 500
GET /status (index.tsx:79)IP rate-limit (STATUS_RATE_LIMITER)200 {healthy:true, time:<ISO>}429 {error:"Rate limit exceeded"}

Claim flow (public)

All claim bodies are HTML fragments (Content-Type: text/html) except /notify and the non-HTMX branches of /status. Claim "errors" are HTTP 200 with an alert fragment — not error statuses.

POST /api/claim/verify-code (claim.ts:178)

  • Request (parseBody): code (string, required, trimmed → SHA-256 for lookup); cf-turnstile-response (required only if TURNSTILE_SECRET_KEY set).
  • Gates: CLAIM_IP_RATE_LIMITER(ip) + CLAIM_RATE_LIMITER(codeHash) + Turnstile.
  • Success (200 HTML), branched on voucher state: fresh → PIN-entry form (mints HMAC nonce, builds dynamic fields, OOB card header/title/FAQs); redeemed in-flight → polling fragment; eSIM already delivered → OOB QR/ICCID reveal.
  • Errors (all 200 HTML errorHtml): rate-limited; captcha missing/failed; missing code; not found; expired; already redeemed.

POST /api/claim/submit-direct (claim.ts:673)

  • Request (parseBody): code + pin (required, trimmed, SHA-256; pin compared constant-time); nonce (required, HMAC-verified against codeHash, fail-closed if CLAIM_NONCE_SECRET unset); input_* dynamic fields (key ^[a-z0-9_]{1,50}$, value ≤500 chars, schema-validated).
  • Success (200 HTML): plain gift card → atomic claim + redeemedHtml; topup/eSIM accepted → redeemed + poll token + processing fragment; ambiguous timeout → PENDING + poll fragment.
  • Errors (200 HTML): rate-limited; missing code/pin; misconfigured (nonce secret unset); invalid/expired nonce (+ HX-Refresh: true); bad code/PIN; expired; already redeemed; race-lost; per-field validation messages; definite 4xx → FAILED card + "Notify Me" box.
  • Idempotency: topup_client_ref = gh-<voucherId>-<Date.now()>, re-sent on retry; plaintext code encrypted into topup_voucher_code_enc.

GET /api/claim/status/:token (claim.ts:1093)

  • Request: path token (poll_token UUID, constant-time verified — the capability itself); query n (poll counter, clamped ≤20). Branches on HX-Request.
  • Success: HTMX terminal → 200 HTML flip (eSIM QR / recharge success / failure card); HTMX pending → 200 HTML self-replacing #status-poll (backoff min(5·2ⁿ,120)s, email prompt after 3 polls); non-HTMX → 200 JSON {order_id, status, status_text, failure_code, failure_reason, is_user_fixable}.
  • Errors (content-negotiated — HTMX 200 HTML span vs non-HTMX real status): rate-limit (429), empty token (400), not found (404), token mismatch (403), token expired (HX-Refresh: true + 200 empty body).

POST /api/claim/notify (claim.ts:1724)

  • Request (json): poll_token (required), email (required, regex-validated, lower-cased, stored encrypted).
  • Success: 200 {success:true} (best-effort syncs email to Octopus if order known).
  • Errors (JSON): 429 rate-limit; 400 missing fields; 400 invalid email; 404 voucher/session not found.

Vouchers admin (/api/vouchers, all authenticateAdmin)

Hash-expectation asymmetry — the handover gotcha

POST /api/vouchers expects code_hash/pin_hash already SHA-256-hashed by Octopus and stores them verbatim (vouchers.ts:50-51). But PUT /:id with a code field hashes it itself (vouchers.ts:100), and GET /code/:code + PATCH /code/:code/redeem take plaintext and hash for lookup (vouchers.ts:77,133). The claim router always hashes plaintext itself. So the same "code" concept arrives pre-hashed on create but plaintext on read/redeem.

EndpointRequestSuccessErrors
POST / (:23)JSON code_hash* pin_hash* voucher_type_id? denomination? expires_at? (default now+1yr)200 {id, expires_at}400 code_hash/pin_hash required; 400 voucher type not found / not active
GET /code/:code (:68)path code (plaintext → hashed)200 full vouchers row (incl. pin_hash, poll_token, notification_email)400 code required; 404 not found
PUT /:id (:89)JSON any of code(re-hashed) voucher_type_id denomination full_name mobile_* is_redeemed expires_at display_until200 updated row400 invalid ID; 400 no fields; 404 not found
PATCH /code/:code/redeem (:124)path code (plaintext)200 updated row is_redeemed:true400 code required; 404 not found; 400 already redeemed; 400 expired
DELETE /:id (:158)path id200 {message, deletedVoucher}400 invalid ID; 404 not found

GET /code/:code returns the entire row including pin_hash, poll_token, and the encrypted notification_email — a broad read surface for an admin token. And PATCH …/redeem has no Go-side caller (see provisioning) — Grasshopper self-redeems; the endpoint is effectively dead.

Voucher-types admin (/api/voucher-types, all authenticateAdmin)

Writable columns (ALL_FIELDS, voucher-types.ts:13) — name, octopus_voucher_product_id, denomination, currency, is_active, topup_type, octopus_topup_product_id, octopus_esim_product_id, input_fields_json, product_name, country, country_code, category, validity_days, par_value, unit, delivery_time, benefits, dialing_prefix, faqs_json. input_fields_json/faqs_json are JSON-stringified if passed as objects.

EndpointNotesSuccessErrors
POST / (:73)name required201 inserted row400 name required
GET / list (:117)query page(≥1), per_page(def 50, max 200, 0→1000), q(LIKE name/product_name), country, type, octopus_topup_product_id, octopus_esim_product_id, denomination, name; auto-excludes ESIM unless a type/product-id filter is present200 {data, page, per_page, total, total_pages, has_more} (id ASC)
GET /filters (:214)200 {countries:[...]} (distinct, non-ESIM)
GET /:id (:231)path id200 row400 invalid ID; 404 not found
PUT /:id (:249)picked fields200 updated row400 invalid ID; 400 no fields; 404 not found
DELETE /:id (:274)path id200 {message, deletedVoucherType}400 invalid ID; 404 not found
POST /sync (:301)optional query product_id; worker calls back into Octopus200 {created, updated, skipped, errors}502 Octopus auth failed; 404 product not found; 502 fetch failed

Contact (/api/contact)

CORS allow-list (contact.ts:11): the octopuscards.io origins + localhost dev ports; corsOrigin() echoes an allow-listed Origin else falls back to https://octopuscards.io.

EndpointAuthRequestSuccessErrors
POST / (:26)public, Turnstile mandatory, CONTACT_RATE_LIMITER 3/windowJSON turnstileToken* name(≤200) company?(≤200) email(≤320, regex) subject(≤200) message(≤1000); stores ip_address200 {success:true} (+ CORS)429; 400 invalid body / captcha required / captcha failed / missing fields / bad email
OPTIONS / (:98)public204 CORS preflight (POST/OPTIONS, 24h)
GET / (:114)adminquery status?200 bare array (created_at DESC)401
PATCH /:id (:127)adminJSON status*200 updated row400 invalid ID / status required; 404 not found

Provisioning — how data gets into D1

Two distinct flows push data from Octopus into Grasshopper. Don't conflate them.

FlowGH endpointGo callerTrigger
Voucher codesPOST /api/vouchersGH vendor adapter grasshopper_vendor.goorder fulfilment (runtime, per voucher)
Catalog (push, primary)POST/PUT /api/voucher-typesGH admin handler admin_grasshopper.goadmin button (manual)
Catalog (self-pull, alt)POST /api/voucher-types/sync(none — the worker calls Octopus)admin/curl on the GH side

Config lives in the DB, not env

The GH base URL + admin token are not env vars on the Go side — they're the GH pseudo-vendor's host/api_key attributes in vendor_attributes, loaded by getGrasshopperConfig (admin_grasshopper.go:115) and by the adapter (grasshopper_vendor.go:38). Both send X-Auth-Token: <api_key>. There is no provisioning cron — vouchers are order-driven, catalog is admin-button-driven.

Voucher provisioning (Octopus hashes; plaintext never leaves)

The adapter (grasshopper_vendor.go) generates the code/PIN locally, hashes both with SHA-256 (:216), and POSTs code_hash/pin_hash + expires_at = now+1yr (:220). Grasshopper stores the hashes as received (vouchers.ts:50) — it never sees the plaintext. The plaintext rides back up inside Octopus for buyer delivery.

Dual-hash split — SHA-256 vs SHA-3-256

Grasshopper stores SHA-256(code) for its own D1 lookups (claim verify-code/submit-direct, GET /code/:code). But Octopus's internal redemption matching uses SHA-3-256 (services/voucher_redemption.go:32, matched against order_items.code_hashed). Two different algorithms for two different purposes — a genuine footgun if anyone assumes they're the same hash.

Catalog provisioning (push vs self-pull)

Both write voucher_types, same column mapping, but with two differences that matter:

Go push (admin_grasshopper.go, primary)Worker /sync (voucher-types.ts:45)
DirectionOctopus → GH (PUT/POST /voucher-types)GH pulls Octopus via OctopusClient
dialing_prefixset (resolved from country)null
octopus_esim_product_idset only by the separate eSIM push (admin_esim.go:1019, TopupType:"ESIM")never set
Triggeradmin push/push-all(200 ms throttle)/syncadmin/curl on GH

topup_type is taken verbatim from product.topup_type (GAMING/MOBILE/PAYOUT/ESIM) — that's what drives the claim UI's field defaults. Denomination logic: fixed-amount → one row per amount; flexible-amount → skipped. /sync returns {created, updated, skipped, errors} keyed on (octopus_topup_product_id, denomination, name).

The dynamic input-field system

input_fields_json is a JSON array of { field_name, field_label, field_type, is_required, validation_regex?, placeholder?, help_text?, options?[] }, defined on both sides (octopus-client.ts:212 TopupInputField; admin_grasshopper.go:96 grasshopperInputField). It's populated from Octopus's TopupProductInputField table (ListTopupProductInputFieldsByProductID, mapped by buildInputFieldsJSON admin_grasshopper.go:394) — or serialized verbatim from product.input_fields on the /sync path. The claim flow renders it in verify-code and validates it in submit-direct (UI → dynamic fields).

Redemption is driven by Grasshopper, not Octopus

Redemption happens locally in the worker: submit-direct sets is_redeemed itself and calls Octopus's topup API with the plaintext code as redeem_voucher_code (claim.ts:904), which Octopus consumes via voucher_redemption.go (SHA-3-256 match). Octopus never calls PATCH /api/vouchers/code/:code/redeem — that endpoint has no Go-side caller. Cancellation flows the other way: the adapter's CancelVoucherDELETE /api/vouchers/:id. No expiry-cleanup cron exists on either side; expires_at is stamped at creation and enforced at redeem time.

Key files

  • Worker: src/api/{claim,vouchers,voucher-types,contact,index}.ts, src/middleware/admin-auth.ts, src/utils/octopus-client.ts
  • Go push/adapter: services/external_vendors/vouchers/grasshopper_vendor.go, http/handler/admin_grasshopper.go, http/handler/admin_esim.go, http/routes/admin.go:355, services/voucher_redemption.go:32

On this page