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('*')afternext()(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 nativeRateLimitbindings 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 viaX-*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): headerX-Auth-Tokenconstant-time vsADMIN_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
| Endpoint | Auth | Success | Errors |
|---|---|---|---|
GET / (index.tsx:86) | none | 200 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 ifTURNSTILE_SECRET_KEYset). - 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 ifCLAIM_NONCE_SECRETunset);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 intotopup_voucher_code_enc.
GET /api/claim/status/:token (claim.ts:1093)
- Request: path
token(poll_token UUID, constant-time verified — the capability itself); queryn(poll counter, clamped ≤20). Branches onHX-Request. - Success: HTMX terminal → 200 HTML flip (eSIM QR / recharge success / failure card); HTMX pending → 200 HTML self-replacing
#status-poll(backoffmin(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.
| Endpoint | Request | Success | Errors |
|---|---|---|---|
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_until | 200 updated row | 400 invalid ID; 400 no fields; 404 not found |
PATCH /code/:code/redeem (:124) | path code (plaintext) | 200 updated row is_redeemed:true | 400 code required; 404 not found; 400 already redeemed; 400 expired |
DELETE /:id (:158) | path id | 200 {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.
| Endpoint | Notes | Success | Errors |
|---|---|---|---|
POST / (:73) | name required | 201 inserted row | 400 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 present | 200 {data, page, per_page, total, total_pages, has_more} (id ASC) | — |
GET /filters (:214) | — | 200 {countries:[...]} (distinct, non-ESIM) | — |
GET /:id (:231) | path id | 200 row | 400 invalid ID; 404 not found |
PUT /:id (:249) | picked fields | 200 updated row | 400 invalid ID; 400 no fields; 404 not found |
DELETE /:id (:274) | path id | 200 {message, deletedVoucherType} | 400 invalid ID; 404 not found |
POST /sync (:301) | optional query product_id; worker calls back into Octopus | 200 {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.
| Endpoint | Auth | Request | Success | Errors |
|---|---|---|---|---|
POST / (:26) | public, Turnstile mandatory, CONTACT_RATE_LIMITER 3/window | JSON turnstileToken* name(≤200) company?(≤200) email(≤320, regex) subject(≤200) message(≤1000); stores ip_address | 200 {success:true} (+ CORS) | 429; 400 invalid body / captcha required / captcha failed / missing fields / bad email |
OPTIONS / (:98) | public | — | 204 CORS preflight (POST/OPTIONS, 24h) | — |
GET / (:114) | admin | query status? | 200 bare array (created_at DESC) | 401 |
PATCH /:id (:127) | admin | JSON status* | 200 updated row | 400 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.
| Flow | GH endpoint | Go caller | Trigger |
|---|---|---|---|
| Voucher codes | POST /api/vouchers | GH vendor adapter grasshopper_vendor.go | order fulfilment (runtime, per voucher) |
| Catalog (push, primary) | POST/PUT /api/voucher-types | GH admin handler admin_grasshopper.go | admin 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) | |
|---|---|---|
| Direction | Octopus → GH (PUT/POST /voucher-types) | GH pulls Octopus via OctopusClient |
dialing_prefix | set (resolved from country) | null |
octopus_esim_product_id | set only by the separate eSIM push (admin_esim.go:1019, TopupType:"ESIM") | never set |
| Trigger | admin push/push-all(200 ms throttle)/sync | admin/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 CancelVoucher → DELETE /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
Grasshopper — UI & HTMX
How the claim UI is actually rendered and driven — the single JSX landing page, the template-literal HTML fragments, the HTMX 2.0 swap/OOB machinery, the full fragment-state catalog, the CSS card-flip, data-URI QR rendering, inline JS, the dynamic input-field system, and the hand-rolled XSS escaping with its gaps.
Admin Panel
The internal /admin operations console — server-rendered Jet templates, the three-tier RBAC (viewer/admin/super_admin) plus view-only mode, and a feature-by-feature inventory of every admin control from clients and catalog to orders, payouts, and vendor credentials.