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.
| Scope | Grants |
|---|---|
| validate | POST /v1/validate — check codes without consuming them |
| redeem | POST /v1/redeem — consume coupons for a customer |
| coupons:read | GET /v1/coupons — list promotions |
| coupons:write | POST /v1/coupons, PATCH /v1/coupons/:id — create and update promotions |
Key shown once
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.
| Field | Type | Required | Description |
|---|---|---|---|
| codes | string[] | Yes | Coupon codes to check (case-insensitive) |
| customerRef | string | No | Customer identifier — used for per-customer limits |
| orderTotal | number | No | Order total in the promotion's currency; used to compute the discount amount |
Response shape
{
"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
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
| Field | Type | Required | Description |
|---|---|---|---|
| codes | string[] | Yes | Coupon codes to redeem |
| customerRef | string | Yes | The customer the redemption is attributed to |
| orderTotal | number | No | Order total used to compute the discount amount |
| Idempotency-Key | header | Yes | Unique key per logical redemption; same key + same body → original result |
Response shape
{
"idempotent_replay": false,
"redemptions": [
{
"id": "red_01...",
"promotionId": "promo_01...",
"code": "WELCOME10",
"amount": 10
}
],
"totalDiscount": 10
}Runs against the real API with your key.
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.
// 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.
| Method | Endpoint | Notes |
|---|---|---|
| POST | /v1/coupons | Create: {code, type: PERCENTAGE|FIXED, value, ...} |
| GET | /v1/coupons | List your promotions |
| PATCH | /v1/coupons/:id | code and type are immutable; flip the archived boolean to retire a coupon — data is never deleted |
duplicate_code (409)
# 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.
| Status | Code | What to do |
|---|---|---|
| 401 | key_revoked | Key was revoked mid-integration. Rotate it under Settings → API keys and redeploy with the new value. |
| 401 | key_expired | Key is past its expiry date — create a new one. |
| 403 | insufficient_scope | The key lacks the required scope. Create a key with the right scope (see Authentication). |
| 404 | invalid_code | No promotion exists with that code. |
| 409 | duplicate_code | Code already exists — choose another or patch the existing one. |
| 422 | cap_reached · expired | The code is real but can't be used: usage cap hit, per-customer limit reached, or the promotion expired. |
| 429 | rate_limited | Too many requests. Honor retry_after seconds (and the Retry-After header) — then retry. For redeem, reuse your idempotency key. |
{
"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
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
}