OctoWiki
Vendor AdaptersVoucher Vendors

Grasshopper — GH

Voucher vendor adapter — Octopus's own inverted-flow claim system. Octopus mints code+PIN and ships only SHA-256 hashes to a Cloudflare Worker.

Quick facts

Code GH · Line voucher · Auth X-Auth-Token header · Model inverted — Octopus mints code+PIN, sends only hashes · Webhook none (claim-callback) · Delivery plaintext code+PIN + claim URL · Balance n/a (no prepaid float) · Adapter services/external_vendors/vouchers/grasshopper_vendor.go; admin push http/handler/admin_grasshopper.go; downstream frontend/grasshopper/

Grasshopper is Octopus's own downstream claim system (a Cloudflare Worker + D1). Unlike every other vendor — where Octopus buys a code the vendor mints — for GH, Octopus mints the code+PIN itself and ships only SHA-256 hashes to GH. Redemption happens on claim.octopuscards.io, which calls back into the Octopus topup/eSIM API. See Frontends and Products & Catalog.

The inverted mint-and-hash model (headline)

CreateOrder does not ask GH for a code. It:

  1. Mints locallyGenerateVoucherCode() (xxxx-xxx-xxx) + GenerateSecurePINString() (6-digit crypto PIN).
  2. HashescodeHash = HashSHA256(code), pinHash = HashSHA256(pin).
  3. Sets expiry Octopus-side — now + 1 year.
  4. POSTs only the hashesPOST /api/vouchers {code_hash, pin_hash, expires_at, voucher_type_id, denomination}. The plaintext never leaves Octopus.
  5. Returns the plaintext in the Voucher for delivery to the buyer — the only place the raw code exists.

GH's D1 stores only code_hash/pin_hash. Octopus is the sole holder of plaintext, transiently at create time. So GetOrderStatus/GetVouchers look up by voucher id, never by code. Expiry is owned by Octopus, not GH.

Auth

Per-request X-Auth-Token: <apiKey> (no login). Auth/RedoAuth just Ping (GET /api/status, unauthenticated). Config from the GH vendor row's host + api_key attributes.

Claim callback — GH → Octopus (inverted flow)

The redemption UI + callback live in the Cloudflare Worker (frontend/grasshopper/src/api/claim.ts), not Go:

  1. User enters the code on claim.octopuscards.io → worker hashes it, looks up by code_hash in D1, checks expiry.
  2. User enters PIN (+ inputs); worker validates pin_hash, then calls back: POST {OCTOPUS_API_URL}/api/v1/topups/orders (or /esim/orders) with a JWT and redeem_voucher_code = the plaintext code recovered client-side.
  3. The worker persists topup_order_id/topup_status/topup_client_ref on the D1 row and polls; reconciliation by client_reference avoids double-charging.

So money moves user claim → GH Worker → Octopus topups API → real vendor. GH itself holds no value (GetBalance returns empty; ProductAvailability always true).

Delivery

The buyer's Voucher carries plaintext Code (xxxx-xxx-xxx), plaintext Pin (6 digits), ClaimURL = the claim site, and 1-year expiry.

Catalog / push

Gift cards are Octopus fixed-amount topup variants projected into GH voucher_types by pushVariantToGrasshopper (admin_grasshopper.go:785): lookup existing type by (octopus_topup_product_id, denomination, name)PUT/POST /api/voucher-types → upsert products+vendor_products (vendor=GH) → write the GH voucher_type id back onto vendor_products.vendor_product_id (that's the voucher_type_id sent at order time). Admin entry points: push / push-all (200ms throttle) / sync / upsert-from-topups (DB-only). HTTP helpers set a browser-like User-Agent to dodge Cloudflare Bot Fight Mode in front of the claim site.

Only fixed-amount variants are pushable (!IsFixedAmount || MinAmount==nil is skipped everywhere). Flexible-amount gift cards are admin-created manually. The GH↔Octopus link uses two separate id spaces (octopus_topup_product_id vs octopus_esim_product_id), only one populated per row.

Quirks

  • Hashes not plaintext; inverted callback flow; GH holds no balance.
  • Downstream is a Hono Worker on Cloudflare with a D1 (SQLite) DB (Drizzle migrations incl. 0005_inverted_voucher_flow.sql).
  • CancelVoucher = DELETE /api/vouchers/:id; GenerateReferenceCode = GH_<uuid>.
  • Rate-limit aware (200ms throttles + bot-evading UA) because GH runs on Workers.

Tests

Go orchestration test test/clientapi/g2a_orchestration_test.go binds GH's host to an in-process httptest stub answering POST /api/vouchers with {"id":123456} (the GH "mock" analogue — deliberately not mocky-balboa). Worker test suite in frontend/grasshopper/test/ (claim verify/submit/notify/status, chaos/concurrent_claims). Deploy via deploy-grasshopper.yml.

On this page