API reference

Validate and redeem coupons over a small REST API. Keys are scoped, every error is machine-readable, and retries are safe by design — read the idempotency section before your first redeem.

Authentication

Authenticate with a Bearer token in the Authorization header. Every key starts with pk_live_ and is scoped to exactly the endpoints it may call — create keys with the minimum scopes you need from Settings.

ScopeGrants
validatePOST /v1/validate — check codes without consuming them
redeemPOST /v1/redeem — consume coupons for a customer
coupons:readGET /v1/coupons — list promotions
coupons:writePOST /v1/coupons, PATCH /v1/coupons/:id — create and update promotions

Key shown once

Copy your key the moment you create it — PromoStack never stores the raw value. If you lose it, rotate the key and update your env vars.
Auth example
curl https://your-app.com/v1/validate \
  -H "Authorization: Bearer {{KEY}}" \
  -H "Content-Type: application/json" \
  -d '{"codes":["WELCOME10"],"orderTotal":100}'

{{KEY}} — create a key in Settings to auto-fill these examples.

POST/v1/validate

Check whether coupon codes apply to an order — without consuming anything. Use this to show customers their discount before they pay. Requires the validate scope.

FieldTypeRequiredDescription
codesstring[]YesCoupon codes to check (case-insensitive)
customerRefstringNoCustomer identifier — used for per-customer limits
orderTotalnumberNoOrder total in the promotion's currency; used to compute the discount amount

Response shape

validate — response
{
  "results": [
    {
      "code": "WELCOME10",
      "valid": true,
      "promotionId": "promo_01...",
      "discount": 10,
      "rateOnly": false,
      "reason": { "code": "ok", "message": "Valid" }
    }
  ],
  "applied": ["WELCOME10"],
  "totalDiscount": 10
}

Each entry in results carries its own verdict: valid, the computed discount, and rateOnly — true when the code is real but produced no discount for this order (e.g. a minimum spend not met). When invalid, reason explains why in the same machine-readable {code, message} shape as API errors.

Runs against the real API with your key.

Add your API key to send a request. Create one in Settings

fetch — validate
const res = await fetch("https://your-app.com/v1/validate", {
  method: "POST",
  headers: {
    "Authorization": "Bearer {{KEY}}",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    codes: ["WELCOME10"],
    orderTotal: 100,
  }),
});

const data = await res.json();
// { results: [...], applied: [...], totalDiscount: 10 }

{{KEY}} — create a key in Settings to auto-fill these examples.

POST/v1/redeem

Consume coupons for a customer after checkout. Requires the redeem scope and — critically — an Idempotency-Key header on every request.

Idempotency-Key is required

redeem rejects requests missing the header. Generate one unique key per logical redemption (e.g. your order id) and reuse it on retries — the API returns the original result instead of charging the coupon twice, with idempotent_replay: true in the response. The Try-It runner below generates one for you.
FieldTypeRequiredDescription
codesstring[]YesCoupon codes to redeem
customerRefstringYesThe customer the redemption is attributed to
orderTotalnumberNoOrder total used to compute the discount amount
Idempotency-KeyheaderYesUnique key per logical redemption; same key + same body → original result

Response shape

redeem — response
{
  "idempotent_replay": false,
  "redemptions": [
    {
      "id": "red_01...",
      "promotionId": "promo_01...",
      "code": "WELCOME10",
      "amount": 10
    }
  ],
  "totalDiscount": 10
}

Runs against the real API with your key.

Add your API key to send a request. Create one in Settings

curl — redeem
curl https://your-app.com/v1/redeem \
  -H "Authorization: Bearer {{KEY}}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"codes":["WELCOME10"],"customerRef":"cust_123","orderTotal":100}'

{{KEY}} — create a key in Settings to auto-fill these examples.

Safe retries — idempotency
// Same Idempotency-Key + same body → the ORIGINAL result is
// returned, no double redemption. Retrying is always safe.
const res = await fetch("https://your-app.com/v1/redeem", {
  method: "POST",
  headers: {
    "Authorization": "Bearer {{KEY}}",
    "Content-Type": "application/json",
    "Idempotency-Key": "order-42-attempt-1",
  },
  body: JSON.stringify({
    codes: ["WELCOME10"],
    customerRef: "cust_123",
    orderTotal: 100,
  }),
});

{{KEY}} — create a key in Settings to auto-fill these examples.

Coupons CRUD

Manage promotions server-to-server. Reads need coupons:read; writes need coupons:write.

MethodEndpointNotes
POST/v1/couponsCreate: {code, type: PERCENTAGE|FIXED, value, ...}
GET/v1/couponsList your promotions
PATCH/v1/coupons/:idcode and type are immutable; flip the archived boolean to retire a coupon — data is never deleted

duplicate_code (409)

Coupon codes are unique per merchant (case-insensitive). Reusing an existing code returns 409 with code: "duplicate_code" — pick another code or patch the existing promotion.
Coupons — curl
# Create a coupon (requires coupons:write scope)
curl https://your-app.com/v1/coupons \
  -H "Authorization: Bearer {{KEY}}" \
  -H "Content-Type: application/json" \
  -d '{"code":"WELCOME10","type":"PERCENTAGE","value":10}'

# Archive it later — code and type are immutable,
# but archived is a plain boolean
curl -X PATCH https://your-app.com/v1/coupons/<id> \
  -H "Authorization: Bearer {{KEY}}" \
  -H "Content-Type: application/json" \
  -d '{"archived":true}'

{{KEY}} — create a key in Settings to auto-fill these examples.

Errors

Every error shares one machine-readable envelope — {code, message, retry_after?, hint?} — so you can branch on code instead of parsing prose. retry_after appears only when waiting helps; hint points at the fix.

StatusCodeWhat to do
401key_revokedKey was revoked mid-integration. Rotate it under Settings → API keys and redeploy with the new value.
401key_expiredKey is past its expiry date — create a new one.
403insufficient_scopeThe key lacks the required scope. Create a key with the right scope (see Authentication).
404invalid_codeNo promotion exists with that code.
409duplicate_codeCode already exists — choose another or patch the existing one.
422cap_reached · expiredThe code is real but can't be used: usage cap hit, per-customer limit reached, or the promotion expired.
429rate_limitedToo many requests. Honor retry_after seconds (and the Retry-After header) — then retry. For redeem, reuse your idempotency key.
Error envelope
{
  "code": "key_revoked",
  "message": "This API key has been revoked.",
  "hint": "Rotate the key in Settings → API keys.",
  "retry_after": 30
}

Rate limits

Limits are enforced with a sliding window (backed by Upstash) on per-key buckets — one noisy integration can't burn another key's quota.

When you exceed the limit you get a 429 with code: "rate_limited", retry_after seconds in the body, and a Retry-After header. A 429 is guidance, not a dead end: wait the indicated time (exponential backoff if you hit it repeatedly) and retry the same request — for /v1/redeem, always with the same Idempotency-Key, so the retry is provably safe.

Designing for the limit

Validate is read-only and cheap — call it on every cart update if you like. Redeem is the expensive one: fire it exactly once per checkout and let idempotency make retries harmless.
Handle 429 — retry
const res = await fetch("https://your-app.com/v1/validate", { ... });

if (res.status === 429) {
  const retryAfter = Number(res.headers.get("Retry-After")) || 5;
  await new Promise((r) => setTimeout(r, retryAfter * 1000));
  // retry with the same idempotency key for /v1/redeem
}