Skip to content

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:

TablePurpose
redemption_campaignsGroups 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_codesThe 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_attemptsAppend-only audit log of every lookup/redeem hit, used for fraud analysis & rate-limiting heuristics.

Code status machine

StatusMeaningTransitions to
pending_stockToken exists but no real voucher attached yet.unused (admin sets actual_voucher_code) or voided
unusedReady to be scanned.redeemed, voided
redeemedConsumed; reveal returned to the user.— (terminal)
voidedAdmin invalidated; reveal blocked.— (terminal)
expiredPast 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:

HTTPdata.codeWhen
404unknown_codeToken doesn’t exist (or was deleted).
410expiredCampaign past ends_at.
410not_startedCampaign before starts_at.
410voidedCode voided by admin.
410already_redeemedCode already consumed (and not same-IP recoverable).
410campaign_inactiveCampaign status is paused/expired.
503temporarily_unavailablepending_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/redeem
Content-Type: application/json
{
"code": "AJHSXJAHS",
"phone": "+8801712345678",
"email": "user@example.com"
}
  • code — required. The 9-char redemption token from the QR / URL (case-insensitive).
  • phonerequired. Recipient phone number (msisdn). Captured as a lead and used by support if there is any issue with the voucher. The alias msisdn is 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:

HTTPdata.codeWhen
400phone_requiredphone (or msisdn) was omitted or blank.
400ip_limit_reachedIP already redeemed max_per_ip codes from this campaign.

Admin API (JWT-guarded, /admin/redemption)

Campaigns

MethodPathPurpose
GET/admin/redemption/campaignsPaginated list with status & search filters.
POST/admin/redemption/campaignsCreate.
GET/admin/redemption/campaigns/:idDetail + code count.
PUT/admin/redemption/campaigns/:idUpdate.
DELETE/admin/redemption/campaigns/:idDelete — 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

MethodPathPurpose
POST/admin/redemption/campaigns/:id/codes/generateBulk-create tokens.
GET/admin/redemption/campaigns/:id/codesPaginated, filter by status + search.
GET/admin/redemption/campaigns/:id/codes/export?base_url=https://bdvoucher.comCSV download for the print shop.
POST/admin/redemption/campaigns/:id/codes/voidBulk void unused codes: { ids: [1,2,3] }.
PUT/admin/redemption/codes/:idSet actual_voucher_code, instructions, or status. Auto-flips pending_stockunused. Setting status: redeemed directly is blocked — redemption must occur through the client flow.
DELETE/admin/redemption/codes/:idAllowed 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-alikes 0/1/I/L/O removed).
  • 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 × 10 times 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

  1. Fetches the Zendit offer to read requiredFields and priceType.
  2. Sends a per-iteration vouchers/purchases call with the recipient.firstName / lastName / email defaults that Zendit silently requires on most VOUCHER offers, then polls transactionId until status=DONE or FAILED.
  3. Reads the real voucher code from receipt.epin, falling back to receipt.voucherId (Netflix) or receipt.redemptionUrl.
  4. Splits the resulting codes per redeemCount / preloadedCount:
    • Redemption-bound → creates the campaign if it doesn’t exist and inserts N rows in redemption_codes with auto-generated 9-char tokens, actual_voucher_code already set, status='unused'.
    • Preloaded-bound → inserts rows into vouchers with source_type='PRELOADED', status='available', cost filled from Zendit’s reported cost so margin reports stay accurate.

The two ready-to-run entry points are:

Terminal window
npm run zendit:import:spotify-redemption # SPOTIFY_CO_1M_WHS → backfills spotify-10-promo
npm run zendit:import:netflix-redemption # NETFLIX_US_20_WHS → 10 redeem + 5 preloaded

Operational considerations

  • DB integrity is enforced at the schema level. Unique constraint on redemption_codes.code ensures no token collision; cascading delete on campaigns wipes orphan codes; redemption_attempts are kept even after the code is deleted (FK is ON DELETE SET NULL).
  • Rate-limiting beyond max_per_ip is governed by the global ThrottlerModule (already configured in src/app.module.ts). The redemption_attempts table is also a goldmine for ad-hoc analysis if abuse is ever suspected.
  • No PII leakage: when a code is already_redeemed and 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_attempts grows fastest. Consider a nightly sweeper (or pg_partman) that prunes anything older than 90 days — it’s never read by user-facing paths.