A push notification architecture decays into spam without a priority-and-cap system. We designed a lifecycle messaging system with 27+ triggers, P0–P3 priority tiers, a 3-slot daily cap, and 5 geo-regions — for GST-compliance software used by 50K+ Indian SMBs. The lesson: constrain volume so every send is earned.
A GST rejection notice is worth a ₹10,000+ penalty if the merchant misses it. A "new feature!" nudge is worth nothing if they don't. Send both the same afternoon and the merchant mutes you — and now your ₹10,000 alert dies in a muted channel next quarter. That's the real stakes of notification architecture: spam doesn't just annoy users, it destroys the channel you need for the messages that matter. This system's goal was to make every ping buy back its own attention, for a B2B SaaS with 50K+ SMBs and real money on the line.
Each trigger carried execution context: legal requirement (yes/no), average revenue at risk (₹), and a P0–P3 priority.
27+ triggers → P0–P3 priority → 3-slot cap → 5 geo-regions. Volume constrained so relevance wins.
The full trigger matrix (what we actually shipped)
Bucket
Trigger
Priority
Channel
Geo-gated
Revenue at risk
Renewal
D-30 renewal reminder
P2
Push + Email
All
₹500–5,000/user
Renewal
D-7 renewal reminder
P1
Push + Email
All
₹500–5,000/user
Renewal
D-1 renewal reminder
P0
Push + SMS + Email
All
₹500–5,000/user
Renewal
Trial expiry D-3
P1
Push + Email
All
₹0 (conversion)
Billing
Invoice ready
P1
Push
All
₹100–10,000
Billing
Payment failed
P0
Push + SMS
All
₹500–50,000
Billing
Auto-renewal confirmed
P3
Push
All
—
Transactional
GST return filed
P2
Push
All
—
Transactional
Payment received
P2
Push
All
—
Transactional
Reversal confirmed
P3
Push
All
—
Transactional
Credit note issued
P3
Push
All
—
Conflict
GST rejection (wrong GSTIN)
P0
Push + SMS
All
₹10,000+ penalty
Conflict
GST rejection (mismatch)
P0
Push + SMS
All
₹10,000+ penalty
Conflict
GST rejection (duplicate)
P1
Push
All
₹5,000+ penalty
Conflict
Compliance deadline missed
P0
Push + SMS
All
₹25,000+ penalty
Conflict
Tally sync error
P1
Push
All
Data integrity
Conflict
Bank reconciliation mismatch
P1
Push
All
—
Feature
Release notes (major)
P3
Push
All
—
Feature
New feature nudge
P3
Push
All
—
Feature
Seasonal greeting
P3
Push
Geo (5 regions)
—
Feature
Compliance calendar alert
P2
Push
Geo (5 regions)
₹10,000+ penalty
Feature
Year-end checklist
P2
Push
All
—
Feature
Quarterly audit reminder
P2
Push
All
—
System
App update available
P3
Push
All
—
System
Session expiry warning
P1
Push
All
—
The three rules that made it work
1. Priority tiers (P0–P3). The tiers rank what can interrupt vs what may wait. Billing and compliance errors are P0; a "new report is ready" ping is P3.
Tier
Definition
Max latency
Channels
Example
P0
Legal requirement, revenue at risk, user harm
under 5 min
Push + SMS + Email
Payment failed, GST rejection
P1
User action required, revenue impact
under 30 min
Push + Email
Invoice ready, sync error
P2
Helpful context, time-sensitive
under 4 hours
Push
Renewal D-7, GST filed
P3
Nice to know, no urgency
Next slot
Push
Release notes, greetings
2. Three-slot daily cap. Only three notifications per user per day, globally. When the message isn't same-day critical, it queues for tomorrow. Stacking is the single fastest way to get blocked and muted.
The cap logic:
daily_cap = 3
p0_slots = 1 (reserved)
p1_slots = 1
p2_p3_slots = 1 (shared)
if p0_queued > p0_slots: escalate to SMS
if daily_sent >= daily_cap: defer p2/p3 to tomorrow 09:00
3. Geo-segmented festivals. India sends greetings-relevant pushes across five regional festival calendars rather than one national blast — relevance over reach.
The conflict resolution logic (the hard part)
When a P0 GST rejection and a P3 feature nudge collide on the same day for the same user, who wins? We built a conflict resolver that runs at send-time:
def resolve_conflicts(queued_notifications, user_state):
# 1. Deduplicate by template_id
unique = deduplicate(queued_notifications)
# 2. Apply daily cap
sent_today = count_sent_today(user_state.user_id)
if sent_today >= DAILY_CAP:
# Keep only P0/P1, defer rest
unique = [n for n in unique if n.priority in [P0, P1]]
# 3. P0 always gets a slot
p0_count = sum(1 for n in unique if n.priority == P0)
if p0_count > 1:
# Multiple P0s → send highest revenue_at_risk, defer rest
unique = [max(unique, key=lambda n: n.priority == P0 and n.revenue_at_risk)]
# 4. Geo-gate
if user_state.region not in notification.geo_regions:
unique = [n for n in unique if n.geo_regions is None or user_state.region in n.geo_regions]
return unique
This resolver runs for every user, every send window. It's the reason engagement held.
The technical architecture (how it actually runs)
The notification system isn't a monolith — it's three services talking via a message queue:
Trigger Service — Stateless Go service that evaluates 27+ triggers on schedule (cron) or event (webhook). Each trigger is a small function: checkRenewalD7(), checkGSTRejection(), etc. Outputs a normalized NotificationIntent to Kafka.
Conflict Resolver — Consumer group that reads NotificationIntent, loads user state from Redis (region, tier, opt-outs, daily_sent_count), runs the resolver logic, outputs NotificationJob to delivery queue.
Delivery Workers — Pool of workers that handle the actual send:
The center itself is a simple React page backed by a Firestore document. The Conflict Resolver reads this before sending. Opt-out rate dropped from 12% to under 2% within two weeks of launch.
What the numbers said
Metric
Value
Triggers mapped & released
27+
Priority tiers
P0–P3 consistently honored
Daily cap
3 slots per user
Geo-regions
5 (festival calendars)
Engagement per send
held (no decay)
Opt-out rate
under 2% (vs 12% pre-cap)
Revenue recovered (P0 billing)
₹2.3M/quarter
The engagement lift from the AI seasonal feature — +168% in the Send Greetings work — only works because users never felt spammed in the weeks before.
What I'd do differently
Instrument conflict logic first. We shipped conflict-resolution rules last; measuring how many bad sends the resolver prevented would have justified the whole system in week one.
Dry-run the calendar. A festival calendar looks clean on paper; a dry-run week would have caught the collisions with quiet hours and regional weekends before real users did.
Build the preference center earlier. Users wanted to tune "I don't want payment received pings." We added it in month 3; should've been month 1.
The constraint is the feature. Volume without a cap isn't a strategy — it's spam with a roadmap.