Recipe: gift cards
Goal: issue a gift card as a merchant, and let a shopper redeem it at checkout.
Audience: merchant (issuing, via the fdk_ API) and storefront
(redeeming, via the SDK).
A gift card is bearer-instrument money — whoever holds the code holds the value — distinct from per-shopper store credit. The code is stored tenant-salted-SHA-256 hashed and is shown exactly once, at issuance; a later read yields the hash only. Full module behavior is in the gift cards reference.
Prerequisites
- The store’s manifest gate
behavior.gating.giftCards.enabledmust be on for gift cards to work as a checkout tender. It ships disabled — a new money surface goes out dark. - Currency is a closed allowlist (USD today); anything else is a
400 unsupported_currency, never persisted.
Step 1 — issue a card (merchant API)
Use the tenant-facing merchant route with an fdk_ key that carries the
manage:own_giftcards scope. Money is integer cents.
POST /v1/merchant/gift-cards HTTP/1.1
Host: api.ruo.pro
Authorization: Bearer fdk_live_…
Content-Type: application/json
{ "amount_cents": 5000, "currency": "USD" }{
"gift_card_id": "gc_…",
"code": "A1B2-C3D4-E5F6-G7H8",
"initial_cents": 5000,
"balance_cents": 5000,
"currency": "USD",
"status": "active"
}
codeis returned here and only here. Capture it now — hand it to the buyer, email it, print it. A lost code cannot be recovered, only re-issued. A laterGET /v1/merchant/gift-cardslists your cards without plaintext codes (gift_card_id,balance_cents,status, …).
Issuance is idempotent: a fresh idempotency key mints a new card; a replay of the same key returns the card’s metadata without re-showing the code.
Step 2 — redeem it at checkout (storefront)
Redemption is a checkout tender — there is no separate “redeem” call from the
storefront. Pass the code to store.checkout.submit:
// Server-side.
const result = await store.checkout.submit(
{
customer_ref: "guest:shopper@example.com",
items: [{ variant_id: "var_123", quantity: 1 }],
gift_card_code: "A1B2-C3D4-E5F6-G7H8",
card: { ccnumber: "4111111111111111", ccexp: "12/28", cvv: "123" },
},
{ idempotencyKey: `cart-${cartId}:attempt-1` },
);The platform applies the card as a partial tender: it decrements
balance_cents under a row lock (a concurrent double-redeem can never spend
more than the balance) and reduces the amount charged to the payment card
accordingly. If the gift card covers the whole total, no payment card is
charged. The placed-order response reports the split:
{
"outcome": "placed",
"order": { "id": "ord_…", "total_cents": 4900 },
"gift_card_applied_cents": 4900,
"card_charged_cents": 0,
// …
}Partial redemption is first-class — a card with a remaining balance stays
active for the next order. Void, empty, and unknown codes are rejected with
typed errors, so a bad code surfaces as a decline you can show, not a silent
zero.
Step 3 — void a card, if you must (merchant API)
Voiding zeroes a card’s remaining balance under a row lock and reverses its outstanding liability in accounting:
POST /v1/merchant/gift-cards/{id}/void HTTP/1.1
Host: api.ruo.pro
Authorization: Bearer fdk_live_…{
"gift_card_id": "gc_…",
"status": "void",
"balance_cents": 0,
"voided_cents": 5000,
"currency": "USD"
}A double-void is refused with an honest 409 — it is not silently replayed as
success.
The money model (why this is its own module)
A gift card is real liability on the books, not a discount. Under the hood the
giftcards module keeps a two-account chart per tenant —
giftcard:liability (what you owe outstanding bearers) and
giftcard:program_expense. An issue increases both; a redeem decreases
both symmetrically; a void extinguishes the remaining liability the same way
a redeem draws it down. Every posting is a balanced transaction to
accounting via a liability outbox and drain sweep, with
deterministic idempotency keys. See Money invariants.
Related
- Gift cards reference — operator/merchant routes, events, and seams.
- Store credit — the per-shopper ledger alternative.
- Checkout — the one-shot flow and multi-tender split.