Public
Gift-card API with QR redeem scanner UI
apifrontendreact
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

πŸ’³ Gift Card System

An Xbox/Roblox-style gift card backend with a QR redeem page and a token-protected admin screen for generating and printing cards. Built on Val Town (Hono + SQLite).

Live: https://card-redeem.val.run

The flow

A customer buys a card, then redeems it into their account. There's no cashier and no amount to choose β€” a card's full value goes to the account, once.

Rendering mermaid diagram...

The core idea: a card is a key, not a wallet

The QR code contains only a random code (e.g. NBL-4GJ9-4FG4-KRUE) β€” never a value. The value lives in SQLite, behind the server. A screenshot or photocopy of a card is worthless without the matching database row.

Why it's safe

RuleWhere
Random (non-sequential) codesrandomCode() in cards.ts
Values only on the servercards table; the client never computes money
One card, one redemptionatomic UPDATE ... WHERE status = 'active'
Activation gateinactive cards can't be redeemed
Void / lost-card kill switchstatus = 'void'
Full audit trailevery change writes to card_txns
Integer cents (no float drift)toCents() / fmtCents()
Admin routes require a tokenADMIN_TOKEN + Authorization: Bearer

The atomic claim is the important one: redeeming flips the card from active to redeemed in a single conditional UPDATE. If two people submit the same code at the same instant, only one can match the row β€” so only one gets the value.

Authentication

Everything under /api/admin/* requires the ADMIN_TOKEN secret:

curl -H "Authorization: Bearer $ADMIN_TOKEN" https://card-redeem.val.run/api/admin/stats

The token is compared in constant time. Requests without it (or with the wrong one) get a 401.

The admin UI prompts for the token once and keeps it in sessionStorage for that browser tab only β€” it's never baked into the JavaScript bundle.

πŸ‘‰ Set/change it here: https://www.val.town/x/IceyDomain/card-redeem/environment-variables?key=ADMIN_TOKEN

API

Base URL: https://card-redeem.val.run

Public β€” the customer's side

MethodPathPurpose
GET/api/cards/:codeLook up a card (shows its value)
POST/api/cards/:code/redeemRedeem the full value β†’ { account }
GET/api/accounts/:nameCheck an account's balance

Admin β€” token required

MethodPathPurpose
GET/api/admin/statsTotals + credited amounts
POST/api/admin/cards/generateCreate a batch (count, amount, prefix, note)
GET/api/admin/cardsList recent cards (?batch= &limit=)
POST/api/admin/cards/:code/activateMake a card live
POST/api/admin/cards/:code/topupAdd value
POST/api/admin/cards/:code/refundRefund
POST/api/admin/cards/:code/voidVoid a card
GET/api/admin/txnsRecent transactions (?code=)
GET/api/admin/accountsAccounts + balances

Amounts accept dollars (amount: 25) or exact cents (amountCents: 2500).

TOKEN=your_admin_token # Generate 50 Γ— $25 cards (they start INACTIVE) curl -X POST https://card-redeem.val.run/api/admin/cards/generate \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"count":50,"amount":25,"prefix":"NBL"}' # Activate one when it ships curl -X POST https://card-redeem.val.run/api/admin/cards/NBL-4GJ9-4FG4-KRUE/activate \ -H "Authorization: Bearer $TOKEN" # A customer redeems it (no token needed β€” the code is the credential) curl -X POST https://card-redeem.val.run/api/cards/NBL-4GJ9-4FG4-KRUE/redeem \ -H 'Content-Type: application/json' -d '{"account":"icey_player"}'

The UI

  • πŸ“· Redeem β€” scan the QR (camera via jsQR) or type the code, then claim the full value into an account. Shows the resulting balance.
  • βš™οΈ Admin β€” token-gated. Generate batches, render printable QR codes, activate cards, void cards, and see stats + accounts.

Files

index.ts                        ← Hono server: public + admin API, frontend
cards.ts                        ← The card engine (schema + lifecycle)
frontend/
  root.tsx                      ← HTML shell
  index.tsx                     ← React entrypoint
  components/
    App.tsx                     ← Tab shell
    Redeem.tsx                  ← QR scanner + claim flow
    Admin.tsx                   ← Token-gated generate / print / activate
    api.ts                      ← API client + types

Pointing your own app at it

Your Nebula frontend can call this API directly β€” the card logic stays here, where it can't be read out of your JavaScript. CORS is open (access-control-allow-origin: *):

// Look up a card's value const res = await fetch("https://card-redeem.val.run/api/cards/" + code); const { card } = await res.json(); // Redeem it into the signed-in user's account await fetch(`https://card-redeem.val.run/api/cards/${code}/redeem`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ account: currentUser }), });

⚠️ Before real money

This is a working foundation. A production system would also add:

  • Real user accounts β€” right now an account is just a name, so anyone could redeem into anyone's name. Wire this to your own auth and pass a verified account id.
  • Rate limiting on lookups, so codes can't be brute-forced.
  • Idempotency keys on redeem, so a network retry can't double-claim.
  • Signed codes (HMAC) so a fake code can be rejected without a DB hit.