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
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 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.
| Rule | Where |
|---|---|
| Random (non-sequential) codes | randomCode() in cards.ts |
| Values only on the server | cards table; the client never computes money |
| One card, one redemption | atomic UPDATE ... WHERE status = 'active' |
| Activation gate | inactive cards can't be redeemed |
| Void / lost-card kill switch | status = 'void' |
| Full audit trail | every change writes to card_txns |
| Integer cents (no float drift) | toCents() / fmtCents() |
| Admin routes require a token | ADMIN_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.
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
Base URL: https://card-redeem.val.run
| Method | Path | Purpose |
|---|---|---|
GET | /api/cards/:code | Look up a card (shows its value) |
POST | /api/cards/:code/redeem | Redeem the full value β { account } |
GET | /api/accounts/:name | Check an account's balance |
| Method | Path | Purpose |
|---|---|---|
GET | /api/admin/stats | Totals + credited amounts |
POST | /api/admin/cards/generate | Create a batch (count, amount, prefix, note) |
GET | /api/admin/cards | List recent cards (?batch= &limit=) |
POST | /api/admin/cards/:code/activate | Make a card live |
POST | /api/admin/cards/:code/topup | Add value |
POST | /api/admin/cards/:code/refund | Refund |
POST | /api/admin/cards/:code/void | Void a card |
GET | /api/admin/txns | Recent transactions (?code=) |
GET | /api/admin/accounts | Accounts + 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"}'
- π· 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.
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
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 }),
});
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.