The first real promotion on Now I Get It! is a launch gift: five free credits to the earliest 200 people who sign up. I could have built something narrowly scoped to that but I may want to run campaigns in the future for things like, contests, partnerships, anniversary promos, referral rewards. So I built a general system using DynamoDB and Lambda.
How a code moves through the system
An operator generates a batch of GIFT-XXXX-XXXX codes via a script, tags them with a campaign name, and distributes them. Each code moves through three states: available, assigned, redeemed. Users enter codes on the credits page in a new "Redeem Gift Code" card. Redemption is a three-operation TransactWriteItems: mark the code redeemed, increment the user's credit balance, and append an audit entry to the credit transactions table. All atomic.
Campaign scoping
Every code belongs to a named campaign with a start date and optional end date. The one-per-user constraint is per campaign, enforced by a CampaignRedemptionIndex GSI keyed on (redeemed_by, campaign). Someone who redeemed in launch-waitlist can still redeem in summer-contest. Codes outside their campaign's date window are rejected before the redemption attempt runs.
Code generation
Codes are generated over a 26-character alphabet that excludes ambiguous characters -- no O, no 0, no I, no 1, no L. That gives roughly 208 billion possible codes. The redemption handler uses attribute_not_exists(code) on writes, so collision at the table level is physically impossible: the code itself is the table's primary key. The same code can't exist in two campaigns by construction.
Operator workflow
The script has two modes. --generate creates codes in DynamoDB. --assign-and-send scans the waitlist, filters out blocked country-code top-level domains, sorts by signup date, assigns codes, and emails them. Both modes support --dry-run. For non-waitlist campaigns -- contests, partnerships, anything that doesn't live in our database -- codes can be assigned manually via the DynamoDB CLI and distributed through any channel.
Deployment friction
Two snags worth writing down. First, the new Lambda needed the same :live alias pattern as the rest of the stack to support Blue/Green deployment, but new functions can't reference :live in their CloudFormation permission resource -- the alias doesn't exist yet during the first stack creation. The fix was to reference the unqualified function in CloudFormation and add a manual add-permission for the :live alias in the deploy script, matching the pattern already used for a few other late-added Lambdas. Second, the GSI schema change (adding campaign as a sort key) required a two-deploy dance: first deploy removes the old single-key GSI, second deploy adds the new composite one. DynamoDB only allows one GSI operation per UpdateTable call. And I caught a missing environment variable that caused the Lambda to ignore its SSM-configured rate limits and fall back to a hardcoded default of twenty requests per hour -- no functional bug yet, but it would have mattered the first time someone abused the redemption endpoint.
The waitlist campaign is queued up for launch. The system is generic enough that the next campaign doesn't need new code.