Skip to main content
Validate a discount code or gift-card code before applying it at checkout. Both endpoints share a single request envelope (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
Call this once when the buyer enters a code, before enabling the “Apply” CTA. The endpoint never mutates state — calling it repeatedly is safe.

Authentication

None — both endpoints are public. Decorated with @Public() on the controller and mounted under /v2/redeem/*.

Shared request body

Both endpoints accept the same RedeemValidationDto payload.
FieldTypeRequiredDescription
codestring (1–64 chars)YesThe redemption code as the buyer typed it. Case-insensitive on the server side.
shopIdstringYesThe shop the code belongs to (Shop._id).
cartTotalCentsint (>= 0)YesPre-redeem cart subtotal in the shop’s currency, in cents (minor units).
currencystring (1–8 chars)YesISO-4217 currency code (e.g. USD). Informational for the discount path (the discount row carries its own currency for FIXED_AMOUNT codes) and ignored for the gift-card path (gift-card balance is currency-less per the v1 schema). Required on the wire so the FE contract matches the unified field exactly.
appliedCodesstring[]NoCodes already applied to the cart. When supplied, a same-code reapply short-circuits to already_applied without a service round-trip. v1 of the shop-builder unified redeem field does not send this — safe to omit.

Shared response envelope

Both endpoints return the same union response shape at HTTP 200 for every business outcome. Success — valid: true:
{
  "valid": true,
  "kind": "discount",
  "code": "FREESHIP10",
  "valueCents": 1000,
  "label": "$10 off shipping"
}
FieldTypeNotes
validtrueCode is eligible against this cart shape.
kind"discount" | "giftcard"Mirrors which endpoint was hit — useful for typed reducers on the FE.
codestringNormalized echo of the redemption code (canonical case as stored).
valueCentsintDiscount amount applied to this cart, in cents. Capped server-side so it never exceeds cartTotalCents (you’ll never refund more than the cart is worth).
labelstringHuman-readable name surfaced as a confirmation pill on the order summary (e.g. "$10 off shipping", "Gift card balance applied").
Error — valid: false:
{
  "valid": false,
  "error": "min_cart_not_met",
  "minCartCents": 5000
}
FieldTypeNotes
validfalseCode is structurally well-formed but not eligible against this cart.
errorenumOne of the values in the error-code table below.
minCartCentsintOnly set on error: "min_cart_not_met" — the threshold the FE should render as “spend $X more to unlock”.

Error codes

The error 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.
errorReturned byWhenSuggested FE message
invalid_codediscount + giftcardCode unrecognized, disabled, expired, not-yet-active, exhausted, or out-of-scope for the cart’s products. The endpoint deliberately collapses every promo-engine lifecycle failure into a single public reason (see Architecture notes).”That code isn’t recognized. Check the spelling and try again.”
already_applieddiscount + giftcardThe same code already appears in the buyer’s appliedCodes array.”You’ve already applied this code.”
min_cart_not_metdiscount onlyDiscount has a minimum-purchase rule and cartTotalCents < minPurchaseCents. The response carries minCartCents so the FE can render “spend $X more”.”Add more to your cart to use this code.”
zero_balancegiftcard onlyGift card exists but its remaining balance is 0 (fully spent).”This gift card has no balance left.”
expiredgiftcard onlyGift card exists but its expiry timestamp has passed.”This gift card has expired.”
service_unavailablediscount + giftcardBackend fault while calling into the discount engine or gift-card repo. Fail-open envelope — the FE should render an inline “Try again” CTA.”Something went wrong validating that code. Try again.”

POST /v2/redeem/discount/validate

Validates a merchant-issued coupon code against the cart’s total.

Curl example

curl -X POST https://apiv3.droplinked.com/v2/redeem/discount/validate \
  -H 'content-type: application/json' \
  -d '{
    "code": "FREESHIP10",
    "shopId": "65f8000000000000000000aa",
    "cartTotalCents": 9999,
    "currency": "USD"
  }'

Success response

{
  "valid": true,
  "kind": "discount",
  "code": "FREESHIP10",
  "valueCents": 1000,
  "label": "$10 off shipping"
}

Min-purchase failure response

{
  "valid": false,
  "error": "min_cart_not_met",
  "minCartCents": 5000
}

Invalid-code failure response

{
  "valid": false,
  "error": "invalid_code"
}

POST /v2/redeem/giftcard/validate

Validates a gift-card code against the cart’s total. Same request envelope; kind: "giftcard" on success.

Curl example

curl -X POST https://apiv3.droplinked.com/v2/redeem/giftcard/validate \
  -H 'content-type: application/json' \
  -d '{
    "code": "GIFTABC1234",
    "shopId": "65f8000000000000000000aa",
    "cartTotalCents": 9999,
    "currency": "USD"
  }'

Success response

{
  "valid": true,
  "kind": "giftcard",
  "code": "GIFTABC1234",
  "valueCents": 2500,
  "label": "Gift card balance applied"
}

Zero-balance failure response

{
  "valid": false,
  "error": "zero_balance"
}

Expired failure response

{
  "valid": false,
  "error": "expired"
}

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.
StatusWhen
200Every business outcome — success or typed error (valid: false). Branch on the response body, not the status code.
400Schema validation failed on the request body.

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 disabled vs expired widens 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’s getGiftCardByCode 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 as isRedeemed, 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 unified RedeemValidationDto 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.

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).