GuidesRecipesGift cards

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.enabled must 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"
}

code is 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 later GET /v1/merchant/gift-cards lists 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.