How Redemption Works
Redemption is where an instrument becomes money. This page covers the mechanics you need before writing that code: how a customer's code is turned into a debit, what makes a retry safe, and how the platform behaves when two redemptions race.
The shape of a redemption
Gift cards and coupons redeem differently, because they represent different things.
| Gift card | Coupon | |
|---|---|---|
| Represents | Stored value | An entitlement |
| Redemption is | A DEBIT transaction | A consumption of the coupon |
| You send | An amount | The order |
| You receive | Balance before and after | A computed discount |
| Repeatable | Until the balance is exhausted | Until the usage limit is reached |
| Endpoint | POST /v1/giftCards/transactions | POST /v1/coupons/redeem |
The common thread: the customer presents a code, and the code is the identifier. Both redemption calls take a code in the request body, not an internal ID. Your integration does not need to have seen the instrument before.
Codes and identifiers
Each instrument has two identifiers, used in different places.
- The code — what the customer holds. Sent in a POST body, never in a URL, so it does not reach an access log. Only a hash is stored, so the platform can match a presented code but can never print one back to you.
- The internal ID (
giftCardId/couponId) — what your systems store. Returned at issuance and on every inquiry. Used for reads, and for money movements the customer did not initiate.
The split matters most for gift cards:
| Transaction | Identified by | Because |
|---|---|---|
DEBIT | code | The holder is present and initiating a spend |
REFUND | giftCardId | A back-office reversal; no customer is presenting a card |
VOID | giftCardId | An administrative action |
EXPIRE | giftCardId | System-initiated |
So: store the giftCardId from issuance or inquiry. Without it you cannot refund.
Idempotency
Every state-changing call requires an idempotencyKey. This is the single most important thing to get right, because the failure it prevents is charging a customer twice.
What it guarantees
The platform stores the key alongside the result under a unique constraint. A second call with the same key does not perform the operation again — it returns the original outcome.
First call → performs the debit → 201, balanceAfter: 350
Retry, same key → performs nothing → 201, balanceAfter: 350 (the original transaction)
This holds even when the retry arrives while the original is still in flight. The loser of that race is served the winner's result, not an error.
Choosing a key
Derive it from the operation you are performing, so that a retry of the same business action reproduces the same key.
order-20260825-0042-debit-01
Good keys are deterministic — recomputed identically after a process restart. A UUID generated fresh at call time is the one thing you must not use: a retry would generate a new one and debit twice.
| Practice | Verdict |
|---|---|
{orderId}-{operation}-{sequence} | ✅ Deterministic and unique per action |
| A UUID stored with the order before the call | ✅ Survives a restart |
uuid() generated inline at call time | ❌ A retry debits twice |
| The bare order ID for every operation | ❌ A refund would be swallowed as a replay |
The last one deserves emphasis: the key must be unique per operation, not per order. Reusing an order's key for a later refund makes the refund look like a replay of the debit, and it silently does nothing.
When keys collide
The key alone identifies the operation — the platform does not compare the parameters behind it. Reusing a key with a different amount, card, coupon or order returns the original outcome, and the new parameters are silently never applied.
This is the failure mode to design against. It does not announce itself: the call returns 2xx, your code reads a plausible result, and no money moved. IDEMPOTENCY_CONFLICT exists but is only raised in a narrow internal race, not on parameter mismatch — so do not rely on it to catch a key-derivation bug.
Retrying safely
Timeout or 5xx → retry the identical call, same key
409 VERSION_MISMATCH → retry once, same key
409 IDEMPOTENCY_CONFLICT → retry, same key (a rare internal race)
4xx business error → do not retry; the operation was rejected
A timeout is the case idempotency exists for. You cannot tell whether the platform committed the transaction before the connection dropped — so retry, and let the key resolve it.
Concurrency
Balance mutations use optimistic concurrency. Each card carries a version, and a debit only commits if the version has not changed since it was read. Two simultaneous redemptions against one card cannot both succeed against the same balance: one commits, and the other is retried internally against the new balance or fails cleanly.
The practical consequence: you cannot overdraw a card by racing it. If two of your workers redeem the same card at once, the second sees the balance the first left behind. A 409 VERSION_MISMATCH reaching you means contention was too high to resolve — retry it with the same idempotency key.
Balance reads during redemption go to the primary database, never a replica, so a debit is never computed against a stale balance.
Partial redemption
Whether a gift card can be spent across several orders is fixed on the template.
allowPartialRedemption: true — the card may be debited repeatedly until exhausted. Each debit must be at least minRedemptionAmount. Read both from the inquiry response before you decide what to charge.
allowPartialRedemption: false — a redemption must spend the entire remaining balance. Debiting less returns GC_PARTIAL_REDEMPTION_NOT_ALLOWED, and you should charge the customer the full card value or decline the card.
Handling a card that does not cover the order:
remainingBalance >= orderTotal → debit orderTotal (if partial allowed)
remainingBalance < orderTotal → debit remainingBalance, collect the rest by other means
partial not allowed → debit the full balance, or decline the card
Coupon redemption specifics
A coupon's discount is computed at redemption, from the order you send. This is why the redeem call carries the full order rather than a pre-agreed amount.
Send the real order
Applicability is evaluated per line item. A coupon scoped to CATEGORIES or PRODUCTS discounts only matching lines, and an order containing no matching line is rejected with CV_NOT_APPLICABLE. Sending a summary total instead of line items will produce the wrong discount, or a spurious rejection.
{
"amount": 4500,
"items": [
{ "productId": "sku-9981", "categoryId": "electronics", "quantity": 1, "unitPrice": 4500 }
]
}
Validate first, but re-send at redemption
POST /v1/coupons/validate computes the same discount without consuming anything — use it to show a saving at checkout. But the figure it returns is not a quote you can bank. The order may change between validation and payment, so redemption recomputes from what you send it. Always apply the discount from the redeem response, not the validate response.
Shared codes
A SHARED coupon is one code held by many customers, so it is issued once and distributed. Redemption is still one call per customer, each with its own idempotency key. A UNIQUE coupon is per-customer and typically has a usage limit of one; a second redemption returns CV_ALREADY_USED.
What can reject a redemption
Beyond the instrument's own state, redemption is gated on the campaign behind it.
| Condition | Error code |
|---|---|
| Code does not match any instrument | GC_INVALID / CV_INVALID |
| Gift card not active | GC_INACTIVE |
| Gift card expired | GC_EXPIRED |
| Debit exceeds the balance | GC_INSUFFICIENT_BALANCE |
| Partial debit on a whole-balance-only card | GC_PARTIAL_REDEMPTION_NOT_ALLOWED |
| Debit below the template's minimum | BAD_REQUEST |
| Coupon not active — used or voided | CV_ALREADY_USED |
| Coupon expired | CV_EXPIRED |
| Coupon usage limit reached | CV_USAGE_LIMIT_REACHED |
| Discount cannot be computed, including an order below the minimum | CV_NOT_APPLICABLE |
| Campaign paused, expired or archived (coupons only) | PROGRAM_INACTIVE |
| Coupon redemption breaches a value budget | TEMPLATE_LIMIT_EXCEEDED / CAMPAIGN_LIMIT_EXCEEDED |
Two of these are worth calling out because their names mislead. CV_ALREADY_USED covers any non-active coupon, including a voided one — there is no separate inactive code. And CV_NOT_APPLICABLE absorbs every discount-calculation failure, minimum-order-value included.
A gift card transaction is not gated on the campaign being active: a prepaid card holds settled value, so it stays spendable after its campaign ends. A coupon carries no settled value, so its redemption is refused once the campaign is inactive.
GC_INVALID and CV_INVALID are deliberately indistinguishable from a cross-merchant lookup: the platform does not disclose whether a code exists under another merchant.
Full descriptions and HTTP statuses are in Error Codes.
Reversals
A gift card debit is reversed with a REFUND transaction, referencing the card by giftCardId.
The refund must carry the referenceId of the original debit. The platform looks for prior debits on that card under exactly that reference and refuses the refund if it finds none, and the refunded total can never exceed what was debited under it. This is what stops REFUND being used to create spendable value.
{
"giftCardId": "9b2c4d6e-8f0a-4b1c-9d3e-5f7a1b2c3d4e",
"type": "REFUND",
"amount": 150,
"referenceId": "order-20260825-0042",
"idempotencyKey": "order-20260825-0042-refund-01"
}
referenceId matches the debit; idempotencyKey must not. Inventing a new reference such as order-…-refund fails with BAD_REQUEST, and reusing the debit's idempotency key replays the debit and refunds nothing.
VOID cancels a card instead of adjusting its balance, moving it to INACTIVE.
Coupon redemptions have no reversal operation. To undo one, issue a replacement coupon from the same template.
A checklist before you go live
- Every state-changing call sends an idempotency key derived deterministically from the business action
- Keys are unique per operation, so refunds are not mistaken for replays of a debit
- Timeouts retry the identical call rather than composing a new one
giftCardIdis stored at issuance, so refunds are possible- Partial redemption rules are read from the inquiry, not assumed
- Coupon redemption sends real order line items
- The discount applied is the one from the redeem response
- Errors are branched on
errCode, not onmessage - Client timeout exceeds the platform's own 30-second limit
Next steps
- Create Transaction — the gift card redemption endpoint
- Redeem Coupon — the coupon redemption endpoint
- Error Codes