Promo Campaigns
The redemption module is a self-contained promo channel: print a QR card,
hand it to a customer, and when they scan it, they land on
https://bdvoucher.com/redeem/<TOKEN> and reveal a real voucher code
(Spotify Premium, Netflix gift, Free Fire diamonds, etc.) on screen.
It is decoupled from the storefront shopping flow — no payment, no cart, no account required. The goal is to drive bdvoucher.com brand awareness and collect email/phone leads while distributing pre-funded voucher codes.
How it works
┌──────────────────┐ print ┌──────────────────────────────┐│ Admin generates │ ───────────▶ │ QR card with ││ codes for a │ │ https://bdvoucher.com/ ││ campaign │ │ redeem/AJHSXJAHS │└──────────────────┘ └──────────────┬───────────────┘ │ user scans ▼ ┌──────────────────────────────┐ │ Storefront landing page │ │ /redeem/:code │ │ - GET /redemption/lookup │ │ - POST /redemption/redeem │ └──────────────┬───────────────┘ │ on success ▼ ┌──────────────────────────────┐ │ Real voucher code + │ │ redemption instructions │ └──────────────────────────────┘Data model
Three tables under production:
| Table | Purpose |
|---|---|
redemption_campaigns | Groups N codes. Holds headline, CTA, default instructions, max_per_ip, start/end window, optional variant_id link to a real product variant for analytics. (require_email/require_phone columns exist for backward compat but are ignored by the client since 2026-05-18.) |
redemption_codes | The unique 9-char token printed on the QR (code), the actual_voucher_code revealed at redemption time, optional per-code instructions override, and the lifecycle status. |
redemption_attempts | Append-only audit log of every lookup/redeem hit, used for fraud analysis & rate-limiting heuristics. |
Code status machine
| Status | Meaning | Transitions to |
|---|---|---|
pending_stock | Token exists but no real voucher attached yet. | unused (admin sets actual_voucher_code) or voided |
unused | Ready to be scanned. | redeemed, voided |
redeemed | Consumed; reveal returned to the user. | — (terminal) |
voided | Admin invalidated; reveal blocked. | — (terminal) |
expired | Past campaign ends_at. | — (terminal) |
Client API (public, anonymous)
Both endpoints are excluded from auth middleware — anyone with a token can hit them.
GET /api/v1/redemption/lookup/:code
Called by the storefront landing page on mount. Returns campaign info + which fields the form must collect, without consuming the code.
GET /api/v1/redemption/lookup/AJHSXJAHS{ "success": true, "data": { "status": "available", "campaign": { "id": 1, "name": "Spotify Premium $10 — Promo", "slug": "spotify-10-promo", "headline": "You've unlocked Spotify Premium $10!", "cta_text": "Reveal my Spotify code", "description": "Scan QR → reveal a Spotify Premium $10 redemption code.", "product": null }, "requires": { "phone": true, "email": false } }}requires is a fixed contract — phone (msisdn) is always required,
email is always optional. The lookup endpoint surfaces it so the
client form knows what to render without consulting the docs.
Error responses:
| HTTP | data.code | When |
|---|---|---|
| 404 | unknown_code | Token doesn’t exist (or was deleted). |
| 410 | expired | Campaign past ends_at. |
| 410 | not_started | Campaign before starts_at. |
| 410 | voided | Code voided by admin. |
| 410 | already_redeemed | Code already consumed (and not same-IP recoverable). |
| 410 | campaign_inactive | Campaign status is paused/expired. |
| 503 | temporarily_unavailable | pending_stock — admin hasn’t filled in the real voucher yet. |
POST /api/v1/redemption/redeem
Atomic. The redemption_codes row is locked with SELECT … FOR UPDATE
inside a $transaction, so two concurrent scans of the same code
serialise — the second one gets already_redeemed.
POST /api/v1/redemption/redeemContent-Type: application/json
{ "code": "AJHSXJAHS", "phone": "+8801712345678", "email": "user@example.com"}code— required. The 9-char redemption token from the QR / URL (case-insensitive).phone— required. Recipient phone number (msisdn). Captured as a lead and used by support if there is any issue with the voucher. The aliasmsisdnis also accepted on input.email— optional. When supplied, we email the revealed voucher code + redemption instructions to the address after a successful redemption. The email is fire-and-forget — its delivery never blocks the response and never rolls back the redemption.
{ "success": true, "data": { "status": "redeemed", "campaign": { /* same shape as lookup */ }, "voucher_code": "SPOTIFY-XXXX-YYYY-ZZZZ", "instructions": "1. Open Spotify and sign in...\n2. Go to spotify.com/redeem\n3. Enter the code shown above." }}Error responses use the same { code, message } shape as lookup, plus:
| HTTP | data.code | When |
|---|---|---|
| 400 | phone_required | phone (or msisdn) was omitted or blank. |
| 400 | ip_limit_reached | IP already redeemed max_per_ip codes from this campaign. |
Admin API (JWT-guarded, /admin/redemption)
Campaigns
| Method | Path | Purpose |
|---|---|---|
GET | /admin/redemption/campaigns | Paginated list with status & search filters. |
POST | /admin/redemption/campaigns | Create. |
GET | /admin/redemption/campaigns/:id | Detail + code count. |
PUT | /admin/redemption/campaigns/:id | Update. |
DELETE | /admin/redemption/campaigns/:id | Delete — blocked if any codes have been redeemed (use status: paused instead to preserve audit trail). Cascades to non-redeemed codes. |
GET | /admin/redemption/campaigns/:id/stats | { totals: {unused, pending_stock, redeemed, voided}, total, conversion_rate } |
Campaign create body:
{ "name": "Netflix $25 — Diwali", "slug": "netflix-25-diwali", "headline": "Happy Diwali — enjoy Netflix on us 🎉", "cta_text": "Reveal my Netflix code", "instruction_template": "1. Go to netflix.com/redeem\n2. Enter the code\n3. Activate", "max_per_ip": 1, "starts_at": "2026-11-01T00:00:00Z", "ends_at": "2026-11-30T23:59:59Z", "status": "active"}Codes
| Method | Path | Purpose |
|---|---|---|
POST | /admin/redemption/campaigns/:id/codes/generate | Bulk-create tokens. |
GET | /admin/redemption/campaigns/:id/codes | Paginated, filter by status + search. |
GET | /admin/redemption/campaigns/:id/codes/export?base_url=https://bdvoucher.com | CSV download for the print shop. |
POST | /admin/redemption/campaigns/:id/codes/void | Bulk void unused codes: { ids: [1,2,3] }. |
PUT | /admin/redemption/codes/:id | Set actual_voucher_code, instructions, or status. Auto-flips pending_stock → unused. Setting status: redeemed directly is blocked — redemption must occur through the client flow. |
DELETE | /admin/redemption/codes/:id | Allowed only for non-redeemed codes (keeps audit trail intact). |
Generating codes — two flavours
A. With real voucher codes already in hand:
POST /admin/redemption/campaigns/1/codes/generate{ "entries": [ { "actual_voucher_code": "SPOT-AAAA-BBBB-CCCC" }, { "actual_voucher_code": "SPOT-DDDD-EEEE-FFFF", "instructions": "Custom instructions for this code only." } ], "code_length": 9}→ Two new redemption tokens, status unused, paired with the real codes.
B. Generate placeholders now, fill in later (print-first workflow):
POST /admin/redemption/campaigns/1/codes/generate{ "count": 500, "code_length": 9 }→ 500 random tokens, status pending_stock. Send the CSV to the print
shop today; ops fills in actual_voucher_code per row tomorrow when the
Spotify codes are procured.
Generator details
- Alphabet:
ABCDEFGHJKMNPQRSTUVWXYZ23456789(Crockford-ish base32, look-alikes0/1/I/L/Oremoved). - Length: configurable, defaults to 9 (≈ 31⁹ ≈ 26 quadrillion combinations).
- Source: Node’s
crypto.randomBytes— cryptographically random. - Collision-checked against the DB on every candidate; the helper retries up to
count × 10times before erroring.
CSV export
GET /admin/redemption/campaigns/1/codes/export?base_url=https://bdvoucher.com"code","url","status","actual_voucher_code","created_at""AJHSXJAHS","https://bdvoucher.com/redeem/AJHSXJAHS","unused","SPOT-AAAA-BBBB-CCCC","2026-05-17T10:23:00Z""KQPWZMRTY","https://bdvoucher.com/redeem/KQPWZMRTY","unused","SPOT-DDDD-EEEE-FFFF","2026-05-17T10:23:00Z"…Hand the url column to any QR generator (e.g. qrencode) or to the
print shop directly — they already know how to render QR from URLs.
Bootstrapping a campaign from Zendit
For campaigns whose actual_voucher_code comes from a partner API (Zendit
today, others later) you usually want to buy a batch of N codes and split
them between a redemption campaign and the regular preloaded pool. A
reusable Node helper lives at scripts/lib/zendit-redemption-import.ts:
import { runZenditRedemptionImport } from './lib/zendit-redemption-import';
await runZenditRedemptionImport({ offerId: 'NETFLIX_US_20_WHS', totalToBuy: 15, redeemCount: 10, // → new "netflix-20-promo" campaign preloadedCount: 5, // → vouchers.source_type='PRELOADED' preloadedVariantId: 22406, preloadedPartnerId: 5, // Zendit transactionIdPrefix: 'bdv-netflix-mixed', redemptionTarget: { mode: 'new-campaign', slug: 'netflix-20-promo', name: 'Netflix US $20 — Promo', ctaText: 'Reveal my Netflix code', instructionTemplate: '1. Sign in to https://www.netflix.com …', variantId: 22406, // requireEmail / requirePhone are deprecated (no-op since 2026-05-18) },});What it does
- Fetches the Zendit offer to read
requiredFieldsandpriceType. - Sends a per-iteration
vouchers/purchasescall with therecipient.firstName / lastName / emaildefaults that Zendit silently requires on most VOUCHER offers, then pollstransactionIduntilstatus=DONEorFAILED. - Reads the real voucher code from
receipt.epin, falling back toreceipt.voucherId(Netflix) orreceipt.redemptionUrl. - Splits the resulting codes per
redeemCount/preloadedCount:- Redemption-bound → creates the campaign if it doesn’t exist
and inserts N rows in
redemption_codeswith auto-generated 9-char tokens,actual_voucher_codealready set,status='unused'. - Preloaded-bound → inserts rows into
voucherswithsource_type='PRELOADED',status='available',costfilled from Zendit’s reported cost so margin reports stay accurate.
- Redemption-bound → creates the campaign if it doesn’t exist
and inserts N rows in
The two ready-to-run entry points are:
npm run zendit:import:spotify-redemption # SPOTIFY_CO_1M_WHS → backfills spotify-10-promonpm run zendit:import:netflix-redemption # NETFLIX_US_20_WHS → 10 redeem + 5 preloadedOperational considerations
- DB integrity is enforced at the schema level. Unique constraint on
redemption_codes.codeensures no token collision; cascading delete on campaigns wipes orphan codes; redemption_attempts are kept even after the code is deleted (FK isON DELETE SET NULL). - Rate-limiting beyond
max_per_ipis governed by the global ThrottlerModule (already configured insrc/app.module.ts). Theredemption_attemptstable is also a goldmine for ad-hoc analysis if abuse is ever suspected. - No PII leakage: when a code is
already_redeemedand the requester isn’t the original IP (or the 24-hour grace expired), the API does not re-reveal the voucher; it just returns 410. - Audit retention:
redemption_attemptsgrows fastest. Consider a nightly sweeper (orpg_partman) that prunes anything older than 90 days — it’s never read by user-facing paths.