code + shopId + cartTotalCents + currency) so a single unified redeem field on the storefront can swap the URL path based on which kind the buyer typed.
Fail-open posture. Backend faults return HTTP 200 with { valid: false, error: "service_unavailable" } — never a 5xx. The redeem field is payment-adjacent and 5xx-ing it would leave the buyer with a broken checkout. The FE renders an inline error + retry CTA instead of crashing.
These endpoints are read-only. They never bump a discount’s
timesUsed counter or flip a gift card’s isRedeemed state. Actual redemption happens atomically with payment authorization at the checkout-intent resolver chokepoint when the FE submits the cart with the validated code field set.When to use this
- A storefront or partner checkout renders a unified redeem field that accepts either a discount code or a gift-card code, and dispatches to the matching validator based on which path the buyer picks (or based on inline heuristics in the FE)
- A custom checkout wants to preview the redeem amount in the cart summary before the buyer hits “Pay” — so the buyer sees the same total at validation, at the cart summary, and at the PSP authorization step
- The FE needs a stable typed error envelope (rather than HTTP status branching) so it can surface inline messages without try/catching every fetch
Authentication
None — both endpoints are public. Decorated with@Public() on the controller and mounted under /v2/redeem/*.
Shared request body
Both endpoints accept the sameRedeemValidationDto payload.
Shared response envelope
Both endpoints return the same union response shape at HTTP 200 for every business outcome. Success —valid: true:
Error —
valid: false:
Error codes
Theerror enum is stable — new codes will only be added, never renamed. FE clients should default to a generic “This code can’t be applied” string when an unknown reason arrives.
POST /v2/redeem/discount/validate
Validates a merchant-issued coupon code against the cart’s total.
Curl example
Success response
Min-purchase failure response
Invalid-code failure response
POST /v2/redeem/giftcard/validate
Validates a gift-card code against the cart’s total. Same request envelope; kind: "giftcard" on success.
Curl example
Success response
Zero-balance failure response
Expired failure response
Schema-validation errors (400)
The only non-200 status either endpoint emits is 400 for structurally malformed requests (missing required field, negative cartTotalCents, code longer than 64 chars, etc.). A malformed request is a structural FE bug that a retry can’t fix — so the fail-open envelope does not apply.
Architecture notes
Reason-code collapsing (discount internals)
The underlying discount engine surfaces a rich internal reason union —code_not_found / code_disabled / code_expired / code_not_yet_active / code_exhausted / product_not_in_scope. The public /v2/redeem/discount/validate surface deliberately collapses all six into invalid_code.
This follows the Apple / Shopify convention of not leaking promotion-engine internals to the buyer:
- Telling an enumeration attacker which exact code is
disabledvsexpiredwidens the leak surface - The FE can’t render anything different for “disabled” vs “expired” anyway — the UX outcome is identical
- The internal reason is preserved in server logs for the merchant dashboard
min_purchase_not_met is the one discount internal reason that does surface (as min_cart_not_met + minCartCents) — because the FE can render a useful “spend $X more to unlock” prompt the buyer can act on.
Gift-card disambiguation
The gift-card repo’sgetGiftCardByCode returns null across multiple failure modes (missing / fully-redeemed / expired). To preserve a useful FE surface, the service runs a best-effort secondary lookup on the null path to disambiguate zero_balance vs expired vs invalid_code. The disambiguation walk is wrapped in its own try/catch so transient gift-card-repo faults cannot escape the fail-open envelope — the worst case is the public reason collapses to invalid_code.
Fail-open: backend errors return HTTP 200
Both validators wrap the engine call in try/catch and return{ valid: false, error: "service_unavailable" } at HTTP 200 on any backend fault. The redeem field is payment-adjacent — 5xx-ing it would leave buyers with a broken checkout. The FE renders an inline retry CTA and the buyer keeps going. This matches the PSP-stability backbone discipline (the same posture the resolver/gate path uses).
The only exception is request-schema validation — a malformed body returns 400, because a structural FE bug is not something a retry can fix.
Redemption happens at the checkout-intent chokepoint
These endpoints are a precondition for redemption, not the write. The actual mutation (decrementing remaining uses on a discount, marking a gift card asisRedeemed, applying the line-item discount to the order) happens server-side at the checkout-intent resolver chokepoint when the FE submits the cart with the validated code field set.
That single chokepoint design means:
- No double-redemption is possible — the redemption write is atomic with payment authorization
- These validators can be called repeatedly without side effects
- The FE never has to “release” a code on cart-abandonment — nothing was ever locked
Known v1 limitation: line items not in the unified envelope
The unifiedRedeemValidationDto does not carry line items. Discounts scoped to specific products (PRODUCT_IDS scope) will therefore validate as invalid_code on this surface — the public collapse layer doesn’t expose product_not_in_scope separately. This matches the v1 shape of the shop-builder unified redeem field, which doesn’t submit line items.
If you need product-scoped discount validation today, call the original POST /v2/discounts/validate endpoint — it carries lineItems and returns the granular reason union directly.
Related
Checkout payment-intent resolver
The chokepoint where validated discount + gift-card codes are atomically redeemed against payment authorization. These validators are a precondition — the resolver is the write.
Validate a discount code (granular)
The original discount-only validator. Carries
lineItems and returns the full reason union (code_expired / code_not_yet_active / product_not_in_scope / etc.) without the public collapse layer. Use when you need product-scope validation.Resume an abandoned cart
The storefront-side endpoint customers hit from the recovery email link. Often paired with redeem-field pre-validation when a recovery email carries a bounce-back coupon.
Order lifecycle
Where the resolver’s atomic redemption write fits into the full order timeline (cart → intent → PSP authorization → order created).