Birthday Engine
The flagship native retention surface — merchants offer real birthday perks, the app shows a birthday calendar and delivers via push, redeeming through existing voucher/points rails. The one feature that owns befday's name.
Status: Accepted (direction); implementation deferred
Date: June 2026
Decision: Build a birthday engine as the flagship retention surface in apps/native. On a user’s birthday (and a lead-up window), merchants the user has a relationship with surface real birthday perks. The app shows a birthday calendar of “what’s waiting for me,” delivers via push, and redeems through existing voucher/points rails. It’s the highest-emotion, most on-brand, guaranteed once-a-year return, and uniquely defensible.
Relationship to the existing Twin Birthday feature (already built)
apps/nativealready ships a Twin Birthday surface (docs): a social birthday calendar that surfaces people who share your birthday day, backed by thetwinBirthdaysrouter +user.me. It is consumer-only and owns the “who shares my day” social moment — it has no merchant perks. This Birthday Engine is complementary, not a replacement: Twin Birthday owns the social layer; the Birthday Engine adds the merchant-perk / value layer on the samedateOfBirth. See Reconciliation with Twin Birthday below before implementing — the two should share one birthday surface, not compete.
TL;DR
The birthday is befday’s identity hook. Merchants define birthday perks (modeled as voucher campaigns); a daily job mints perk instances for eligible users in their birthday window; the app shows a “what’s waiting for you” calendar and delivers via push; perks redeem through existing voucher/points rails. Anti-abuse rests on a locked birth date + idempotency per (user, perk, year). Builds on the shipped Twin Birthday social surface — adds the perk layer, doesn’t fork it.
Context
befday Points Currency and Consumer Receipt Management give users money and memory reasons to return. The missing pillar is identity / emotion — personal and uniquely befday’s. The brand is literally befday, so the birthday should be the centerpiece. A birthday is:
- High-emotion — a perk feels like a gift, not a discount.
- Guaranteed cadence — one per year; a predictable, calendar-driven return.
- Defensible — “the app that gets me free stuff on my birthday” is a one-line identity no competitor owns.
- Two-sided value — merchants get a high-intent, emotionally primed visit (birthdays bring groups and spend); befday gets a retention spike + a reason for push permission.
The pieces already exist: verified-phone identity (Stamp Identification Flow), voucher campaigns/codes/redemptions, and the points wallet (befday Points Currency). The engine composes these rather than inventing primitives.
Decision
A birthday engine that (1) captures and verifies birth date, (2) lets merchants define birthday perks, (3) matches eligible users to perks during a birthday window, (4) surfaces a birthday calendar in native, (5) delivers via push, and (6) redeems through existing voucher/points rails.
1. Birth date capture
- Collect
birth_date(month + day required; year optional, for age-gating only) during onboarding or profile. - Reuse the existing field: the app already collects
dateOfBirthat onboarding (it’s the anchor for the shipped Twin Birthday feature). The engine should derive month/day from that same source of truth rather than introducing a parallel birthday field — see Reconciliation. - Lock editing — once set, changing it requires support or is capped (e.g. once per 12 months). This is the single most important anti-abuse rule: an editable birthday = unlimited birthday perks. (Note: Twin Birthday today treats
dateOfBirthas freely editable profile data — adding perks requires introducing this lock.) - Birth year is optional and never required to earn a perk (lowers friction, respects privacy); store separately if used for age-gated offers (e.g. alcohol).
2. Merchant birthday perks
A merchant opts in and defines a birthday perk — modeled as a specialized voucher campaign so it reuses all existing redemption + settlement plumbing:
| Field | Example |
|---|---|
| Perk type | free item / % off / fixed off / bonus points |
| Eligibility | any user / only past customers of this shop |
| Window | on-the-day / birthday week / birthday month |
| Cap | once per user per year; max redemptions |
| Funding | merchant-funded (default), like any voucher |
Eligibility default: offer to past customers of the shop (users with a store_customer link or prior order). This keeps perks high-intent and prevents the app becoming a freebie-farm. A merchant may opt into “any befday user” as an acquisition play.
3. Matching engine (the core)
A scheduled job (daily) finds users entering their birthday window and mints birthday perk instances (voucher codes) for eligible merchant campaigns:
Daily birthday job:
1. Find users whose birthday falls within any active perk's window
(on-day / week / month), respecting age-gates.
2. For each (user, eligible merchant perk):
- Idempotent per (user_id, perk_campaign_id, birthday_year)
- Mint a personal voucher_code (or points grant) for that user
- Create a birthday_perk_grant row
3. Enqueue notifications (see §5).
Idempotency is keyed on birthday_year — one grant per user per perk per year. This, combined with locked birth dates, is the anti-abuse backbone.
4. Native birthday calendar
The headline surface in apps/native:
- “Your birthday is in N days” countdown when the window approaches.
- “What’s waiting for you” — the list/grid of birthday perks the user has been granted this year, each with merchant, perk, and expiry.
- On the day — a celebratory state (“Happy birthday! Here’s your gifts”) — high emotion, screenshot-worthy, shareable (feeds referrals from Platform Stamps & Referral).
- Redeemed perks move to a “used” state; unused ones show expiry.
5. Delivery — push notifications
Push is what turns a granted perk into a visit. The birthday sequence:
| Timing | Message |
|---|---|
| ~7 days before | “Your birthday’s coming up 🎂 — N perks are waiting” |
| On the day (AM) | “Happy birthday! Your gifts from [merchants] are ready” |
| Mid-window reminder | “Don’t miss it — your birthday perk at [merchant] expires [date]” |
The birthday is also the single best justification for requesting push permission at onboarding (“get notified when your birthday gifts are ready”) — far more compelling than generic “enable notifications.”
6. Redemption
- Voucher-style perks redeem through the existing POS voucher flow (the data model’s
voucher_redemptions) — no new POS work beyond surfacing “birthday” provenance. - Bonus-points perks grant into the befday Points Currency wallet via the existing idempotent earn path (
event_typeextended withbirthday).
Why this over the other retention ideas
From the retention shortlist (nearby/map, spend insights, tiers, streaks), the birthday engine wins on defensibility × emotion × brand fit:
| Idea | Emotion | Frequency | Defensible (ownable) | Build cost |
|---|---|---|---|---|
| Birthday engine | Highest | Once/yr (but guaranteed + push) | Uniquely befday | Medium (reuses vouchers) |
| Nearby + perks | Low | High | No (everyone has maps) | Medium |
| Spend insights | Low | Medium | No | Low |
| Tiers/status | Medium | Passive | No | Low |
The others are good supporting features; the birthday engine defines the app. Build it as the spine, layer the rest around it.
Data Model Impact (sketch)
Reuses voucher + points infra. New: birth date on the user, perk campaign flag, and a grant ledger.
user / profile extension
| Column | Type | Notes |
|---|---|---|
birth_month |
integer | 1–12, required for the engine |
birth_day |
integer | 1–31 |
birth_year |
integer | nullable — age-gating only, never required for perks |
birth_date_locked_at |
timestamp | set on first save; gates future edits (anti-abuse) |
Store month/day separately from year so matching is a cheap indexed comparison and year stays optional/private.
voucher_campaigns extension (birthday flag)
Rather than a new table, mark a campaign as a birthday perk:
| Column | Type | Notes |
|---|---|---|
is_birthday |
boolean | default false — flags this campaign as a birthday perk |
birthday_window |
enum | on_day | week | month |
birthday_eligibility |
enum | past_customers (default) | any_user |
birthday_perk_grants (grant ledger)
One row per (user, perk, year) — the idempotency + audit record.
| Column | Type | Notes |
|---|---|---|
id |
uuid | PK |
user_id |
text | FK → user.id |
campaign_id |
uuid | FK → voucher_campaigns.id (the birthday perk) |
birthday_year |
integer | the year this grant is for |
voucher_code_id |
uuid | nullable FK → voucher_codes.id (for voucher-style perks) |
points_granted |
integer | nullable — for bonus-points perks |
status |
enum | granted | notified | redeemed | expired |
granted_at |
timestamp |
Idempotency: unique (user_id, campaign_id, birthday_year) — the core anti-abuse constraint, backed by locked birth dates.
API Impact (sketch)
| Procedure | Status | Notes |
|---|---|---|
consumer.profile.setBirthDate |
New | Sets month/day (+optional year); enforces lock |
consumer.birthday.upcoming |
New | Countdown + granted perks for the birthday calendar |
merchant.birthdayPerk.upsert |
New | Create/edit a birthday perk (a flagged voucher_campaign) |
(job) runBirthdayGrantJob |
New | Daily matcher — mints grants, idempotent per (user, perk, year) |
(job) sendBirthdayNotifications |
New | Enqueues the push sequence |
consumer.birthday.redeem |
Reuse | Routes to existing voucher redeem or points grant |
All grants are server-side, idempotent, scheduled — mirroring stamp/voucher issuance patterns already in the stack.
Anti-abuse
- Locked birth date — the #1 control. Editable birthdays = infinite perks. Lock on first save; changes are rare/supported-only.
- Idempotent per year —
(user_id, campaign_id, birthday_year)unique; reinstall/re-trigger grants nothing extra. - Verified identity — perks require the existing verified-phone account (Stamp Identification Flow); ties a birthday to a real person.
- Eligibility default = past customers — keeps perks high-intent and avoids freebie-farming across the whole network.
- Age-gating — perks needing age (alcohol) require
birth_yearand a verified-adult check.
Consequences
| Type | Consequence |
|---|---|
| Pro | The most on-brand, defensible retention hook befday can build — owns “the birthday app” identity. |
| Pro | Reuses voucher + points rails — medium build, not a new subsystem. |
| Pro | Strongest possible justification for push-notification opt-in. |
| Pro | Drives merchant value (high-intent, group, emotionally-primed visits) and feeds referrals (shareable birthday moment). |
| Con | Once-a-year per user — must be paired with the everyday hooks (points, receipts, nearby) so the app isn’t dormant 364 days. |
| Con | Requires reliable birth-date capture + a hard lock; mishandling enables abuse. |
| Con | Needs a dependable scheduled job + notification infrastructure (timezones, delivery windows). |
| Con | Merchant adoption needed — empty birthday calendars feel worse than no feature; seed with platform-funded perks early. |
Resolved Decisions
| Question | Decision |
|---|---|
| Flagship hook? | Yes — birthday engine is the identity/emotion pillar |
| Perk model | Specialized voucher_campaign (is_birthday) — reuse redemption rails |
| Default eligibility | Past customers of the shop; merchant may opt into any-user |
| Birth date | Month/day required, year optional; locked after first save |
| Idempotency | Per (user, perk, birthday_year) |
| Delivery | Push sequence (pre-window, on-day, reminder); drives opt-in |
| Redemption | Existing voucher flow or points grant |
Reconciliation with Twin Birthday
The app already ships Twin Birthday — a social birthday calendar. The Birthday Engine must build on it, not duplicate or fork it.
What exists today (Twin Birthday)
| Aspect | Twin Birthday (built) |
|---|---|
| Job | Social — “who shares my birthday day,” current user merged in |
| Data | twinBirthdays.getByMonth + user.me; anchored on dateOfBirth |
| Scope | Consumer-only — explicitly no merchant counterpart, no perks |
| Surface | Month calendar grid, per-day people sheet, reach-out via threads |
| Birth date | dateOfBirth, collected at onboarding, freely editable profile data |
| Calendar math | lib/twin-birthday-calendar.ts (unit-tested — treat as contract) |
What the Birthday Engine adds
| Aspect | Birthday Engine (this decision) |
|---|---|
| Job | Value — merchant birthday perks matched to the user |
| Data | voucher_campaigns (is_birthday), birthday_perk_grants, points |
| Scope | Two-sided — merchants define perks; users receive/redeem them |
| Surface | “What’s waiting for you” perks list + push delivery |
| Birth date | Same dateOfBirth, but locked after first save (anti-abuse) |
The reconciliation rules
- One birthday surface, two layers. Don’t ship a second birthday screen. The native birthday surface keeps Twin Birthday’s social layer (who shares my day) and adds a perks layer (“what’s waiting for me”) — likely as a section or tab on the same screen. Twin Birthday owns social; the engine owns value.
- Single source of truth for the date. Both read the same
dateOfBirth. The engine derivesbirth_month/birth_dayfrom it; do not introduce a parallel birthday field. The data-model “extension” in this doc should be re-framed as deriving from / locking the existingdateOfBirth, not adding a new one. - Locking is a real change to existing behaviour. Twin Birthday treats
dateOfBirthas editable. Adding perks requires introducingbirth_date_locked_atand gating edits — this is a modification to the existing profile/onboarding flow, not greenfield. Flag it as a migration, and confirm it doesn’t break Twin Birthday’s expectations. - Don’t break the tested contract.
lib/twin-birthday-calendar.tsis unit-tested. Perk annotations should be layered on top of the existing calendar derivations without rewriting that pure logic — keep its tests green.
Net effect on this decision
- The “birth date capture” and
user/profile data-model sections are partially already built (dateOfBirthexists) — the genuinely new work is the lock, the merchant perk campaign flag, the grant ledger, the matching job, and push. - Build cost is lower than greenfield for capture, but adds the integration constraint of not regressing Twin Birthday.
- Open question added: should the perks layer live as a tab on the Twin Birthday screen, or a sibling surface that links to it?
Open Questions
- Window default: is the standard perk on-the-day, birthday week, or birthday month? (Longer = more redemption, more merchant cost.)
- Empty-calendar cold start: how many platform-funded/seed perks does befday guarantee so early users always have something?
- Year-over-year: do perks reset cleanly each year; any “birthday streak” mechanic for returning users?
- Group/birthday-party angle: can a birthday user bring friends (party perk) — higher merchant value, more complex?
- Timezone & delivery time: what local time do on-the-day notifications fire?
- Birthday + points multiplier: does the birthday also boost points earning that day (ties the points currency to the brand moment)?
- Verification of birth date: do we ever need to verify it (e.g. for age-gated perks), and how without adding friction?