Skip to main content
Coupons are the core reward delivery mechanism in Zupy. When a customer redeems a reward, a coupon is created with a unique code (CZ-XXXXXXXX). The partner can then list, display, and validate (use) that coupon at the point of sale.
Prerequisites: Your API key (zupy_pk_*) and familiarity with the Authentication page. If your integration requires OTP for redemption or coupon usage, see the OTP Flow guide first.

Coupon Lifecycle Overview

Every coupon follows this flow:
(optional) Request + Verify OTP  →  X-OTP-Session token

Browse Rewards → Redeem Reward → Coupon Created (CZ-XXXXXXXX)

                  List Customer Coupons (active/used/expired)

                  Validate Coupon (atomically mark as used)
1

(If required) Get an OTP session

Most companies enable require_otp_for_redemption and require_otp_for_coupon_usage by default — without a valid X-OTP-Session header, the redeem and validate calls below return 403 otp-required. See OTP Flow for the full request → verify dance.
2

Browse Available Rewards

Query the reward catalog to see what the customer can redeem.
3

Redeem Reward

Create a coupon by redeeming a reward for a specific customer. Returns the coupon’s KSUID id and the customer-facing CZ- code.
4

List Coupons

View the customer’s coupons, filtered by status. Use this to find the KSUID id you’ll need for validation.
5

Validate (Use) Coupon

Atomically mark the coupon as used at the point of sale. Pass the KSUID id in the path — not the CZ- code.

Two identifiers, one coupon

Every coupon carries two identifiers:
IdentifierFormatWhere it comes from
idKSUID (e.g. 16a33f27fbbc1801d63d56d2027)Returned by GET /coupons/issued/ and POST /rewards/{id}/redeem/. Stable, opaque, never shown to customers.
coupon_codeCZ-XXXXXXXX (e.g. CZ-914F15F3)Customer-facing — printed on receipts, shown in wallet passes, spoken by the cashier.
Both work as the {coupon_id} path parameter on the validate endpoint (case-insensitive on the CZ-/CP- form). When a customer hands you a receipt at the POS, you can validate directly with the printed code — no extra lookup needed. To go from CZ- code → full coupon record (for example, to check status before validating), use the dedicated lookup:
GET /api/v2/coupons/issued/?code=CZ-914F15F3
Returns the matching coupon (or an empty data array if not found) with its id, customer_id, status, valid_until, etc.

Step 1: Browse Available Rewards

Query the reward catalog to see what rewards are available for a customer. The customer_id parameter enriches each reward with the customer’s redemption status.
GET /api/v2/rewards/?customer_id={id}
Key query parameters:
ParameterTypeDescription
customer_idstringCustomer KSUID — enriches response with already_redeemed flag
redeemablebooleanIf true, only returns rewards the customer can currently afford
Response fields:
FieldTypeDescription
idstringReward KSUID (use in redeem URL)
namestringReward display name
points_requiredintegerPoints needed to redeem
is_availablebooleanWhether the reward is currently active
already_redeemedbooleanWhether this customer already redeemed this reward
validity_daysintegerDays the coupon remains valid after redemption
curl -X GET "https://api.zupy.com/api/v2/rewards/?customer_id=2awTHloSJX7kGGprFerOOsvABcd" \
  -H "X-API-Key: zupy_pk_your_api_key_here"
Example response:
{
  "data": [
    {
      "id": "2bxRKmpWJY8lHHqsGfsQQtwCDef",
      "name": "Free Dessert",
      "points_required": 200,
      "is_available": true,
      "already_redeemed": false,
      "validity_days": 30
    },
    {
      "id": "2bxRKnqXKZ9mIIrtHgtRRuxEFgh",
      "name": "10% Off Next Order",
      "points_required": 500,
      "is_available": true,
      "already_redeemed": true,
      "validity_days": 15
    }
  ],
  "meta": {}
}

Step 2: Redeem Reward (Create Coupon)

Redeem a reward for a customer. This creates a coupon with a unique code and deducts points from the customer’s balance.
POST /api/v2/customers/{id}/rewards/{reward_id}/redeem/
Request body:
FieldTypeDefaultDescription
use_z_tokensbooleanfalseIf true, Z$ tokens cover any points gap. See Z$ Tokens guide
Response fields:
FieldTypeDescription
customer_idstringCustomer KSUID
coupon_codestringUnique coupon code (format: CZ-XXXXXXXX). Display this to the customer for in-store redemption.
points_usedintegerPoints deducted from the customer’s balance (0 for marketing rewards)
z_tokens_usedstringZ$ tokens consumed by this redemption — "0.000000" when use_z_tokens=false or the reward is purely points-based
new_balanceintegerCustomer’s remaining points balance after the redemption
new_z_balancestringCustomer’s current Z$ balance after any token consumption. Always returned (not conditional on use_z_tokens) — partners use it to surface the live balance back to the customer.
valid_untilstringISO 8601 expiration date (server-computed from reward.validity_days)
created_atstringISO 8601 coupon creation timestamp
curl -X POST "https://api.zupy.com/api/v2/customers/2awTHloSJX7kGGprFerOOsvABcd/rewards/2bxRKmpWJY8lHHqsGfsQQtwCDef/redeem/" \
  -H "X-API-Key: zupy_pk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"use_z_tokens": false}'
Example response:
{
  "data": {
    "customer_id": "2awTHloSJX7kGGprFerOOsvABcd",
    "coupon_code": "CZ-A1B2C3D4",
    "points_used": 200,
    "z_tokens_used": "0.000000",
    "new_balance": 350,
    "new_z_balance": "2418.400000",
    "valid_until": "2026-04-25T00:00:00Z",
    "created_at": "2026-03-25T14:30:00Z"
  },
  "meta": {}
}
If your integration’s OTP policy requires verification for redemption, include the X-OTP-Session header. See the OTP Flow guide.

Redemption Errors

StatusTypeWhen
403otp-requiredOTP policy is on but X-OTP-Session was not provided. See OTP Flow.
404not-foundCustomer or reward not found in your company
422validation-errorCustomer doesn’t have enough points — detail field explains which constraint failed
422validation-errorMarketing reward already redeemed by this customer (free rewards have a 1-per-customer limit). errors[].detail = "Prêmio gratuito já resgatado por este cliente"

Step 3: List Customer’s Coupons

Retrieve all coupons for a customer, optionally filtered by status.
GET /api/v2/coupons/issued/?customer_id={id}
Key query parameters:
ParameterTypeDescription
customer_idstringFilter to one customer (KSUID). Omit to list all issued coupons for the company
statusstringFilter by status: active, used, expired, cancelled
origin_typestringFilter by provenance: loyalty_reward (redeemed with points) or marketing (claimed without points)
reward_idstringFilter to coupons issued from one reward
codestringExact coupon_code lookup (case-insensitive)
Response fields:
FieldTypeDescription
idstringCoupon KSUID (use in validate URL)
coupon_codestringUnique coupon code (CZ-XXXXXXXX)
statusstringactive, used, expired, or cancelled
origin_typestringloyalty_reward (redeemed by spending points) or marketing (claimed without points)
usage_datestringISO 8601 timestamp when the coupon was used/validated (null while still active)
remaining_usagesintegerHow many times the coupon can still be used
total_usages_allowedintegerTotal uses allowed (1 for single-use coupons)
valid_untilstringISO 8601 expiration date
reward_namestringName of the redeemed reward
# All coupons for a customer
curl -X GET "https://api.zupy.com/api/v2/coupons/issued/?customer_id=2awTHloSJX7kGGprFerOOsvABcd" \
  -H "X-API-Key: zupy_pk_your_api_key_here"

# Only active coupons
curl -X GET "https://api.zupy.com/api/v2/coupons/issued/?customer_id=2awTHloSJX7kGGprFerOOsvABcd&status=active" \
  -H "X-API-Key: zupy_pk_your_api_key_here"
Example response:
{
  "data": [
    {
      "id": "2cxSLnqYLZ0nJJsuIhuSSvyFGij",
      "coupon_code": "CZ-A1B2C3D4",
      "status": "active",
      "origin_type": "loyalty_reward",
      "usage_date": null,
      "remaining_usages": 1,
      "total_usages_allowed": 1,
      "valid_until": "2026-04-25T00:00:00Z",
      "reward_name": "Free Dessert"
    },
    {
      "id": "2cxSLoRZMA1oKKtvJivTTwzGHjk",
      "coupon_code": "CZ-E5F6G7H8",
      "status": "used",
      "origin_type": "marketing",
      "usage_date": "2026-04-18T14:32:00Z",
      "remaining_usages": 0,
      "total_usages_allowed": 1,
      "valid_until": "2026-04-20T00:00:00Z",
      "reward_name": "10% Off Next Order"
    }
  ],
  "meta": {}
}

Step 4: Validate (Use) Coupon

Validate a coupon to mark it as used. This is an atomic operation — concurrent validate calls are race-safe (uses database-level locking).
POST /api/v2/customers/{id}/coupons/{coupon_id}/validate/
POS ergonomics: {coupon_id} accepts both identifier formats — pass whichever you have:
  • KSUID id (e.g. 16a33f27fbbc1801d63d56d2027) — returned by GET /coupons/issued/
  • coupon_code (e.g. CZ-914F15F3 or CP-LEGACY01) — printed on the customer’s receipt or wallet pass
Lookup is case-insensitive on the code path, so cz-914f15f3 resolves the same as CZ-914F15F3. The response always echoes the canonical upper-case form.Use the CZ- code when a cashier types it from a receipt; use the KSUID when you already obtained it from a previous redeem or coupons/issued/ call.
Send an empty POST body (no JSON needed). Response fields:
FieldTypeDescription
customer_idstringCustomer KSUID (matches the {id} in the URL path).
coupon_codestringThe validated coupon code.
statusstring"validated" — confirms the coupon was successfully used.
remaining_usagesintegerRemaining uses after validation.
total_usages_allowedintegerTotal uses allowed for this coupon.
validated_atstringISO 8601 timestamp of validation.
new_balanceintegerCustomer’s points balance after validation. Validate itself debits 0 pts (points were spent at redemption time); this is the current balance, returned so partners don’t need a follow-up GET /customers/{id}/points/.
new_z_balancestringCustomer’s current Z$ balance after validation (stringified Decimal with 6 places — same format as RewardRedeemResponse).
This is an atomic operation. The backend uses select_for_update() to prevent race conditions. If two concurrent validate calls hit the same coupon, only one will succeed — the other returns 409 Conflict.
curl -X POST "https://api.zupy.com/api/v2/customers/2awTHloSJX7kGGprFerOOsvABcd/coupons/2cxSLnqYLZ0nJJsuIhuSSvyFGij/validate/" \
  -H "X-API-Key: zupy_pk_your_api_key_here"
Example response:
{
  "data": {
    "customer_id": "2awTHloSJX7kGGprFerOOsvABcd",
    "coupon_code": "CZ-A1B2C3D4",
    "status": "validated",
    "remaining_usages": 0,
    "total_usages_allowed": 1,
    "validated_at": "2026-03-25T14:30:00Z",
    "new_balance": 350,
    "new_z_balance": "2418.400000"
  },
  "meta": {}
}
If your integration’s OTP policy requires verification for coupon usage, include the X-OTP-Session header. See the OTP Flow guide.

Validation Errors

StatusTypeWhen
403otp-requiredOTP verification required but X-OTP-Session not provided. See OTP Flow.
404not-foundCoupon not found under this customer. Confirm: (a) the coupon belongs to the customer_id in the path, (b) the CZ-/CP- code (if you used one) is spelled correctly.
409conflictCoupon already fully consumed. detail: "Cupom já Used" (or "Expired" / "Cancelled"). Re-validating an already-used coupon is safe and idempotent — it returns 409, never double-applies the side effect.

POS Quick Lookup — validate by receipt code

When a customer hands you a printed coupon code at the point of sale, you have two equivalent ways to validate it. Use whichever fits your flow.
# Pass the CZ-code directly in the path — no GET needed.
curl -X POST "https://api.zupy.com/api/v2/customers/{customer_id}/coupons/CZ-914F15F3/validate/" \
  -H "X-API-Key: zupy_pk_..." \
  -H "X-OTP-Session: ..."
If you don’t know the customer_id ahead of time (cashier scanning a printed code from a customer they don’t recognize), the lookup-first path tells you both customer_id and status so you can decide whether to proceed and which OTP session to attach.

Multi-Use Coupons

Some rewards create coupons that can be used more than once. The remaining_usages and total_usages_allowed fields track usage:
Coupon Typetotal_usages_allowedBehavior
Single-use1After one validate call, remaining_usages becomes 0 and status becomes used
Multi-use2+Each validate call decrements remaining_usages. Status stays active until the last use, then becomes used
A “3x Free Coffee” reward creates a coupon with total_usages_allowed: 3:
// After redemption
{ "remaining_usages": 3, "total_usages_allowed": 3, "status": "active" }

// After 1st validate
{ "remaining_usages": 2, "total_usages_allowed": 3, "status": "active" }

// After 2nd validate
{ "remaining_usages": 1, "total_usages_allowed": 3, "status": "active" }

// After 3rd validate (final use)
{ "remaining_usages": 0, "total_usages_allowed": 3, "status": "used" }

Coupon Expiration

Coupons have a valid_until date set at redemption time, calculated as:
valid_until = redemption_time + reward.validity_days
  • Auto-expires: a validate call on an expired coupon returns 409 Conflict
  • Check before displaying: partners should compare valid_until with the current time before showing a coupon to the customer
  • Expired status: coupons past their valid_until date will show status: "expired" in list responses
Expired coupons cannot be reactivated. The customer must redeem the reward again to get a new coupon (if the reward allows re-redemption).

Reward & Coupon Stats

Aggregate how many coupons were issued (rewards redeemed) and how many were used — company-wide or per reward.
GET /api/v2/coupons/issued/stats/
Query parameters (same filters as the list):
ParameterDescription
reward_idPer-reward stats — issued/used for one reward
origin_typeloyalty_reward or marketing
customer_idRestrict to one customer
codeExact coupon code
A status filter is ignored here so the breakdown always spans every status.
# Company-wide
curl -X GET "https://api.zupy.com/api/v2/coupons/issued/stats/" \
  -H "X-API-Key: zupy_pk_your_api_key_here"

# Per reward
curl -X GET "https://api.zupy.com/api/v2/coupons/issued/stats/?reward_id=2cx...reward" \
  -H "X-API-Key: zupy_pk_your_api_key_here"
Response
{
  "data": {
    "total_issued": 120,
    "used": 80,
    "usage_rate": 66.67,
    "by_status": { "active": 30, "used": 80, "expired": 8, "cancelled": 2 },
    "by_origin": { "loyalty_reward": 90, "marketing": 30 }
  },
  "meta": {}
}
FieldDescription
total_issuedRewards redeemed = coupons issued
usedCoupons validated/consumed
usage_rateused / total_issued * 100
by_statusCount per status
by_originCount per provenance (loyalty vs marketing)

Full Example: Complete Coupon Flow

End-to-end flow validated in production 2026-05-26: request OTP → verify → browse rewards → redeem → list coupons → validate. Skip the OTP block if your integration’s policy disables require_otp_for_redemption and require_otp_for_coupon_usage.
import requests

BASE_URL = "https://api.zupy.com/api/v2"
HEADERS = {"X-API-Key": "zupy_pk_your_api_key_here"}
customer_id = "2awTHloSJX7kGGprFerOOsvABcd"
customer_phone = "+5511987654321"

# ── Step 0 (when OTP is required): get a trusted session ──
requests.post(
    f"{BASE_URL}/auth/request-otp/",
    json={"identifier": customer_phone},
    headers=HEADERS,
)
# Customer receives a 6-digit code via WhatsApp. Collect it from them, then:
otp_code = input("OTP from customer: ")
verify = requests.post(
    f"{BASE_URL}/auth/verify-otp/",
    json={"identifier": customer_phone, "otp_code": otp_code},
    headers=HEADERS,
).json()["data"]
otp_session = verify["otp_session"]
AUTHED = {**HEADERS, "X-OTP-Session": otp_session}

# ── Step 1: Browse rewards the customer can afford ──
response = requests.get(
    f"{BASE_URL}/rewards/",
    params={"customer_id": customer_id, "redeemable": True},
    headers=HEADERS,
)
rewards = response.json()["data"]
print(f"Found {len(rewards)} redeemable rewards")

# ── Step 2: Redeem the first available reward ──
reward_id = rewards[0]["id"]
response = requests.post(
    f"{BASE_URL}/customers/{customer_id}/rewards/{reward_id}/redeem/",
    json={"use_z_tokens": False},
    headers=AUTHED,  # uses the OTP session
)
coupon = response.json()["data"]
print(f"Coupon created: {coupon['coupon_code']}, valid until {coupon['valid_until']}")
print(f"Customer balance: {coupon['new_balance']} pts, {coupon['new_z_balance']} Z$")

# ── Step 3: List active coupons (returns the KSUID 'id' field) ──
response = requests.get(
    f"{BASE_URL}/coupons/issued/",
    params={"customer_id": customer_id, "status": "active"},
    headers=HEADERS,
)
active_coupons = response.json()["data"]
print(f"{len(active_coupons)} active coupons")

# ── Step 4: Validate the coupon at the point of sale ──
# IMPORTANT: pass the KSUID 'id' field, NOT the CZ-XXX coupon_code.
coupon_ksuid = active_coupons[0]["id"]
response = requests.post(
    f"{BASE_URL}/customers/{customer_id}/coupons/{coupon_ksuid}/validate/",
    headers=AUTHED,
)
result = response.json()["data"]
print(f"Coupon {result['coupon_code']} used at {result['validated_at']}")
print(f"Remaining uses: {result['remaining_usages']} / {result['total_usages_allowed']}")

Next Steps

Z$ Tokens

Learn how Z$ tokens work and how they can cover points gaps in reward redemption

OTP Flow

Set up customer identity verification for sensitive operations

API Reference

Browse all endpoints with request/response schemas