Skip to content

Promo Code

Try the interactive example ↓

Campaign batches of unique redemption codes with atomic, exactly-once redemption: single-use, multi-use, and stored-value claim types, redemption caps (global, per-user, per-referrer, per-code), claim windows, geo fences, idempotency keys, two-phase entitlement locks, and a quarantine kill switch for leaked batches.

When to reach for it: launch giveaways, partner- or creator-distributed reward codes with referral attribution, gift-card-style stored-value codes, region-limited campaigns, and trade flows that must reserve a code before committing it.

When not to: earning and spending loyalty points belongs to Loyalty; recurring entitlement plans belong to Subscription; weighted random prize draws belong to Lottery.

Concepts

  • A batch is a campaign - one create call generates up to 100,000 unique codes (unambiguous alphabet, no 0/O/1/I) with an optional prefix/suffix policy, plus the reward payload, caps, window, and fences they all share.
  • Raw codes are never stored or returned - the server keeps only salted SHA-256 hashes. Redemption hashes the submitted code (case- and whitespace-insensitive) and looks the hash up; export returns hashes, not codes.
  • Claim types: single_use (one redemption, ever), multi_use (shared code, bounded by per_code and the other caps), stored_value (each code carries a balance debited across redemptions).
  • Eligibility gates run in order on every redemption: batch status must be active, the claim window must be open, the geo fence must pass, and no cap may be exhausted. Failures are 409 (or 403 for quarantine and geo) with a machine-readable code.
  • Reward payloads are hidden until redemption - the payload set at creation is revealed in successful redemption responses; batch reads reveal it only to merchant/admin callers.
  • Ownership and roles: creating and operating batches requires a merchant capability or a platform admin token; redemption is open to any authenticated user. Batches are visible only to their creator (and admins) - anyone else gets a 404, not a 403.

Create a batch

Batch management calls below use a merchant or platform admin token.

snug promo-code generate --count 1000 --name spring_promo \
  --prefix SPRING --suffix VIP --length 10 \
  --reward '{"type":"account_credit","amount":25,"currency":"USD"}'

snug promo-code batch get --batch-id <batch_id>
snug promo-code batch list --limit 10

Creation responds 202 Accepted with a progress_url, but generation is synchronous - the batch is already active with all codes generated when the response returns. Caps, claim windows, geo fences, and stored-value policies are additional JSON fields on POST /api/v1/promo-codes/batches (see the API reference); over HTTP a capped, windowed batch looks like:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "docs4-spring-promo", "count": 1000,
       "claim_type": "multi_use",
       "caps": {"per_user": 1, "per_code": 5},
       "claim_window": {"ends_at": "2026-09-30T00:00:00Z"},
       "reward_payload": {"type": "item_grant", "item": "spring-hat"}}' \
  http://localhost:4000/api/v1/promo-codes/batches
{
  "status": 202,
  "msg": "Accepted",
  "data": {
    "batch_id": "0199e100-0000-7000-8000-000000000001",
    "status": "active",
    "requested_count": 1000,
    "progress_url": "/api/v1/promo-codes/batches/0199e100-0000-7000-8000-000000000001"
  }
}

batch list speaks the standard list contract: q over name and description, filters on status, claim_type, created_by, created_at, and updated_at (filter[claim_type][eq]=stored_value), and sorts on created_at (newest first by default), updated_at, name, and requested_count. Non-admin callers only ever see their own batches.

Redeem

snug promo-code redeem --code SPRING-7K9Q2M4P8X-VIP
snug promo-code redeem --code SPRING-7K9Q2M4P8X-VIP --idempotency-key checkout:ord_123
snug promo-code redeem --code SPRING-7K9Q2M4P8X-VIP --referrer-id partner_7 --country US

A successful redemption returns committed and reveals the reward payload. Verified behaviors:

  • Redeeming a spent single-use code fails 409 already_redeemed.
  • Replaying with the same idempotency key returns the original redemption_id and payload instead of failing - always send one from checkout flows.
  • An unknown code is a deliberately generic 409 ineligible - the API does not confirm whether a guessed code exists.
  • A capped redemption (per-user, per-referrer, or global) fails 409 cap_reached; an exhausted per-code limit returns 409 already_redeemed; outside the claim window it is 409 claim_window_closed; a geo-fenced batch rejects missing or unlisted countries with 403 geo_blocked (pass --country/geo).

Caps can be tightened or raised later without touching committed redemptions - snug promo-code caps update --batch-id <id> --per-user 2 (PATCH .../caps).

Stored value

A stored-value batch gives every code a balance that is debited across redemptions until exhausted:

snug promo-code generate --count 100 --name gift_cards \
  --reward '{"type":"gift_card"}' \
  --stored-value-initial 50 --partial-redemption

snug promo-code stored-value redeem --code GIFT-XXXX --amount 20

Verified: a 20 debit from a 50 balance returns partially_redeemed with remaining_value: 30; the debit that reaches zero returns exhausted; an over-balance debit fails 409 insufficient_stored_value. Stored-value codes must go through the stored-value endpoint - the plain redeem endpoint rejects them with 400 invalid_request.

Entitlement locks

For trades and multi-step flows, a single-use code can be reserved with a TTL lock and committed or released later - see Entitlement locks for the full two-phase protocol.

Operations

snug promo-code quarantine --batch-id <id> --reason suspected_leak
snug promo-code resume --batch-id <id> --note reviewed
snug promo-code archive --batch-id <id>
snug promo-code batch export --batch-id <id>
snug promo-code redemptions --batch-id <id> --filter 'status[eq]=committed' --filter 'country[eq]=US'

Quarantine freezes redemption immediately (403 quarantined, verified) and records an audit event; resume reopens the batch. archive is terminal - an archived batch cannot be resumed (400) and its codes are permanently 409 ineligible. export returns each code's salted hash and current status for audit and reconciliation - never raw codes. The redemptions list, newest first, filters on status, claimant_id, referrer_id, country, claim_type, and redeemed_at.

Persistence and retries

Batches, code state, usage counters, lock state and redemption receipts are stored in PostgreSQL. A redemption commits its balance change, counters, receipt and idempotency key together. If any write fails, the transaction rolls back; retry with the same idempotency key. Batch generation is also atomic.

Idempotency keys are scoped to the tenant, batch and claimant, and expire after the configured retention period. Reuse a key only for retries of the same operation. Committed lock replays have the same bounded retention period.

Limits and configuration

Up to 100,000 codes per batch, 64 KB reward payloads, 14-character default code cores, 24-hour idempotency keys, and the lock TTL cap and sweeper cadence are all tunable via the PROMO_CODE_* variables.

Reference

Interactive example

Issue codes with redemption limits, expiry, and rewards. Track each use so a retried request cannot issue the same reward twice.

An illustrative example that runs in your browser. No account or API calls required.