# How to Add Smart Notifications Based on User Behavior — Copy-Paste Prompts Inside

- Tool: App Features
- Last updated: July 2026

## TL;DR

Smart behavior-based notifications need four layers: an event tracker that records what each user does in the app, a scoring engine that ranks notification candidates by recency and frequency, a suppressor that blocks sends when a user has been ignoring messages, and a send-time optimizer that delivers at the hour the user historically opens. With Lovable or FlutterFlow you can ship a working v1 in 6-10 hours for $0-25/month at 100 users. This is an Advanced feature — plan the scoring pipeline before generating code.

## What Smart Behavior-Based Notifications Actually Are

Standard push notifications are broadcast messages — the same text goes to every user at the same time. Smart behavior-based notifications flip this: the app watches what each user does (views, searches, favorites, cart additions, completions), builds a behavioral profile per user, and uses that profile to decide what to send, when to send it, and whether to send at all. A user who has viewed a specific product three times in two days gets a personalized nudge about that product; a user who has ignored the last five notifications gets nothing until they re-engage. The technical components are an event ingestion table, an hourly aggregation job, a scoring function, a suppressor that enforces fatigue limits, and a send-time optimizer that learns each user's most active hour. This is not a feature to generate without a plan — the multiple Edge Functions and SQL aggregations are where AI tools are most likely to produce plausible-looking but broken code.

## Anatomy of the Feature

Seven components organized across four layers. The UI components are straightforward. The data pipeline — aggregation, scoring, suppression, send-time optimization — is where AI tools generate incorrect logic most often. Read each component before prompting.

- **User Event Tracker** (data): Supabase table user_events (id uuid, user_id uuid, event_type text, entity_id uuid, entity_type text, metadata jsonb, created_at timestamptz). Populated by client-side calls on every tracked user action: view, search, favorite, add-to-cart, complete. Flutter uses a Custom Action or Dart function that POSTs to a Supabase Edge Function on each interaction. Web clients use supabase.from('user_events').insert() directly with the anon key.
- **Behavior Aggregation Job** (backend): Supabase pg_cron job running hourly via a Supabase Edge Function. Reads user_events for the past 7 days grouped by user_id, aggregates event counts by event_type, and upserts into user_behavior_summary (user_id uuid PRIMARY KEY, preferred_hour int, events_last_7d int, last_active_at timestamptz, updated_at timestamptz). The preferred_hour column is updated from notification_opens — the hour-of-day with the highest open count per user.
- **Notification Scoring Engine** (backend): SQL function or Supabase Edge Function that evaluates each candidate notification per user using a weighted score: (event_recency_score * 0.4) + (event_frequency_score * 0.3) + (notification_history_penalty * 0.2) + (user_activity_score * 0.1). Output is a score 0-100 per user per notification type, stored in notification_candidates (id, user_id, type, variant, score, scheduled_for, sent_at, opened_at, suppressed bool). Candidates scoring above a threshold (e.g., 50) are scheduled for delivery.
- **Send-Time Optimizer** (data): Tracks notification_opens (user_id, opened_at timestamptz) and aggregates EXTRACT(HOUR FROM opened_at) to find preferred_hour — the hour-of-day with the most opens for each user. This value is stored in user_behavior_summary.preferred_hour and used by the notification sender to schedule delivery via pg_cron or a delayed send. New users default to preferred_hour = 10 (10 AM local) until enough opens accumulate.
- **Notification Suppressor** (backend): Before any notification send, checks notification_fatigue table: (user_id uuid PRIMARY KEY, sent_24h int DEFAULT 0, ignored_streak int DEFAULT 0, last_reset timestamptz). If sent_24h >= 3 OR ignored_streak >= 5, the candidate is marked suppressed = true in notification_candidates and skipped. ignored_streak increments when a notification is sent but opened_at remains NULL after 24 hours. A separate reset job checks: if user has any app_open event in the last 7 days and ignored_streak > 0, reset to 0.
- **FCM Sender** (service): firebase_messaging (Flutter) or firebase-admin SDK (Supabase Edge Function) sends FCM push notifications to stored tokens. The notification payload is dynamically constructed from the user's event data — 'You've saved 3 items, complete your purchase' rather than 'Don't forget your cart'. The entity_id and entity_type from user_events are used to look up the entity name for personalization. FCM tokens stored in push_tokens table.
- **A/B Test Layer** (data): notification_variants table stores 2-3 message variants per notification type (e.g., 'You left something behind' vs 'Complete your order for {product_name}'). Variant assignment: user_id::text % 2 (or % 3 for three variants) gives a stable, consistent assignment without a separate assignment table. Open and convert rates per variant are tracked via opened_at and conversion_event in notification_candidates.

## Data model

Run this in the Supabase SQL editor. Creates the event tracking, behavior summary, notification pipeline, and fatigue management tables with RLS. This schema is the foundation — wire the Edge Functions to it.

```sql
-- User event tracking table
create table public.user_events (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users not null,
  event_type text not null, -- 'view', 'search', 'favorite', 'add_to_cart', 'complete'
  entity_id uuid,
  entity_type text, -- 'product', 'article', 'course', etc.
  metadata jsonb,
  created_at timestamptz default now()
);

alter table public.user_events enable row level security;

create policy "Users can insert own events"
  on public.user_events for insert
  with check (auth.uid() = user_id);

create policy "Service role can read all events"
  on public.user_events for select
  to service_role using (true);

-- Critical index for per-user aggregation performance
create index user_events_user_type_time_idx
  on public.user_events (user_id, event_type, created_at desc);

-- Behavior summary: upserted hourly by aggregation job
create table public.user_behavior_summary (
  user_id uuid primary key references auth.users,
  preferred_hour int default 10, -- 0-23, defaults to 10am
  preferred_variant text default 'A', -- stable A/B assignment
  events_last_7d int default 0,
  last_active_at timestamptz,
  updated_at timestamptz default now()
);

alter table public.user_behavior_summary enable row level security;

create policy "Users can read own behavior summary"
  on public.user_behavior_summary for select
  using (auth.uid() = user_id);

create policy "Service role can manage behavior summaries"
  on public.user_behavior_summary for all
  to service_role using (true);

-- Notification candidates: scored and scheduled sends
create table public.notification_candidates (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users not null,
  type text not null,
  variant text,
  score int,
  scheduled_for timestamptz,
  sent_at timestamptz,
  opened_at timestamptz,
  suppressed bool default false,
  suppression_reason text
);

alter table public.notification_candidates enable row level security;

create policy "Users can read own notification candidates"
  on public.notification_candidates for select
  using (auth.uid() = user_id);

create policy "Service role can manage candidates"
  on public.notification_candidates for all
  to service_role using (true);

create index notification_candidates_user_sent_idx
  on public.notification_candidates (user_id, sent_at desc);

-- Fatigue tracking
create table public.notification_fatigue (
  user_id uuid primary key references auth.users,
  sent_24h int default 0,
  ignored_streak int default 0,
  last_reset timestamptz default now()
);

alter table public.notification_fatigue enable row level security;

create policy "Service role manages fatigue records"
  on public.notification_fatigue for all
  to service_role using (true);

-- Notification variants definition table
create table public.notification_variants (
  id uuid default gen_random_uuid() primary key,
  type text not null,
  variant text not null, -- 'A', 'B', 'C'
  title_template text not null,
  body_template text not null, -- supports {{entity_name}} placeholder
  is_active bool default true,
  unique(type, variant)
);

alter table public.notification_variants enable row level security;

create policy "Authenticated users can read variants"
  on public.notification_variants for select
  to authenticated using (true);
```

The index on user_events (user_id, event_type, created_at desc) is non-optional — the hourly aggregation query filters by all three columns and will timeout on tables with more than 10K rows without it.

## Build paths

### Flutterflow — fit 3/10, 6-9 hours

FlutterFlow can track user events via Custom Actions that call Supabase, and Firebase Cloud Functions handle the scoring and FCM delivery. Expect to write all scoring and suppression logic in Cloud Functions JavaScript — the visual builder cannot express it.

1. In the Action editor on each tracked screen, add a Custom Action (Dart) that calls supabase.from('user_events').insert({'user_id': currentUserUid, 'event_type': 'view', 'entity_id': entityId, 'entity_type': 'product', 'created_at': DateTime.now().toIso8601String()})
2. In Firebase Console, enable Cloud Functions and write three functions: aggregateBehavior (runs hourly via Cloud Scheduler, reads Supabase user_events via REST API, upserts user_behavior_summary), scoreNotifications (reads behavior summary and produces notification_candidates), and sendSmartPush (reads scheduled candidates, applies suppression check, sends FCM via firebase-admin, records opens)
3. In FlutterFlow, add a notification open tracking Custom Action in the App State initialization or notification handler: on FCM notification tap, call supabase.from('notification_candidates').update({'opened_at': now}).eq('id', notificationId)
4. Add a notification preference screen with toggle widgets bound to a user_notification_prefs Supabase table; filter send candidates by pref in the scoring function
5. Build and test on a real device — FCM push cannot be tested in the FlutterFlow web preview

Limitations:

- All scoring engine logic — SQL aggregations, suppression rules, A/B assignment — must be written in Cloud Functions JavaScript; FlutterFlow's visual builder offers no way to configure the pipeline
- Cloud Scheduler configuration for the hourly aggregation job requires the Google Cloud Console; FlutterFlow cannot set it up visually
- Total complexity of this feature pushes into Advanced territory even in FlutterFlow; plan for the scoring pipeline to require debugging rounds

### Lovable — fit 2/10, 8-10 hours

Lovable can scaffold the Supabase tables and the basic Edge Functions, but behavior scoring, send-time optimization, and suppression logic across multiple interdependent functions is where AI looping failure is most likely. Use Plan Mode to break the feature into phases before generating any code.

1. Switch to Plan Mode in Lovable and describe the full feature architecture: event tracker, aggregation cron, scoring function, suppressor, send-time optimizer, and FCM sender; let Lovable generate a formal plan before writing any code
2. Implement Phase 1 first: the user_events table, the insert call on key user actions, and the basic notification_candidates table; verify data is flowing in Supabase Table Editor before moving on
3. Implement Phase 2: the behavior aggregation Edge Function and pg_cron schedule; verify user_behavior_summary is populating with correct per-user preferred_hour values
4. Implement Phase 3: the scoring function and suppressor check; verify notification_candidates rows are created with correct suppressed values for high-fatigue users
5. Implement Phase 4: the FCM sender and dynamic copy personalization; add FIREBASE_SERVICE_ACCOUNT_JSON to Lovable Secrets and test push delivery on the published URL

Starter prompt:

```
Build a smart behavior-based notification system. Phase 1 — Event tracking: when a user views a product, adds to cart, favorites an item, or completes a purchase, insert a row into a Supabase user_events table (id uuid, user_id uuid, event_type text check event_type in ('view','search','favorite','add_to_cart','complete'), entity_id uuid, entity_type text, metadata jsonb, created_at timestamptz). Create an index on (user_id, event_type, created_at desc). Phase 2 — Hourly aggregation Edge Function: SELECT user_id, COUNT(*) as events_last_7d, MAX(created_at) as last_active_at FROM user_events WHERE created_at > NOW() - INTERVAL '7 days' GROUP BY user_id; upsert into user_behavior_summary (user_id, events_last_7d, last_active_at, updated_at). Also compute preferred_hour from notification_candidates.opened_at grouped by user_id and EXTRACT(HOUR FROM opened_at) — use the hour with the max count; default to 10 if no opens exist. Schedule this Edge Function hourly via pg_cron. Phase 3 — Notification scoring: for each user where events_last_7d > 2 AND last_active_at > NOW() - INTERVAL '3 days', create a notification_candidates row with score = events_last_7d, scheduled_for = today at preferred_hour UTC. Before inserting, check notification_fatigue: if sent_24h >= 3 OR ignored_streak >= 5, set suppressed = true with suppression_reason. Phase 4 — FCM sender Edge Function: select notification_candidates WHERE suppressed = false AND scheduled_for <= NOW() AND sent_at IS NULL; for each, look up the most recent user_events entity_id and entity_type to build personalized copy (e.g., 'You viewed [entity_name] 3 times — still interested?'); send FCM push via firebase-admin SDK with FIREBASE_SERVICE_ACCOUNT_JSON from Secrets; set sent_at = NOW() and increment notification_fatigue.sent_24h. A/B test: assign variant A or B based on user_id::text % 2; store variant in user_behavior_summary.preferred_variant. Phase 5 — Re-engagement reset: a separate daily pg_cron job resets notification_fatigue.ignored_streak = 0 for any user who has a user_events row in the last 7 days and ignored_streak > 0. Cover edge case for new users: if no user_behavior_summary row exists, create one with defaults (preferred_hour = 10, events_last_7d = 0) on first user_events insert.
```

Limitations:

- Lovable's looping failure mode is most likely on this feature — multiple interconnected Edge Functions with SQL aggregations are the highest-risk scenario; use Plan Mode before generating
- The Lovable preview iframe cannot test FCM push delivery — test all notification sends on the published URL with a real device
- Complex multi-function features require explicit phase-by-phase prompting in Lovable; generating the entire system in one prompt produces incomplete or broken wiring between components

### Custom — fit 5/10, 2-4 weeks

Production-grade behavioral notification platform: event ingestion via Segment or Amplitude, cohort-based targeting via PostHog feature flags, campaign orchestration via Customer.io or Braze with built-in suppression and A/B testing. Right for funded products with 10K+ active users.

1. Event pipeline: Segment (free tier 1K MTU/mo; Team $120/mo for 10K MTU — approx) ingests all user events and forwards to Amplitude for behavioral cohort building
2. Cohort targeting: PostHog feature flags define notification audience segments (active-last-7d, cart-abandoners, power-users) without custom SQL
3. Campaign orchestration: Customer.io (Essentials $100/mo for 5K contacts — approx) handles suppression, send-time optimization, A/B testing, and channel selection natively — no custom suppressor code needed
4. Delivery: Customer.io sends FCM push via its native mobile connector; SMS fallback via Twilio for high-value segments
5. Analytics: open rate, conversion rate, revenue attributed per variant tracked in Customer.io campaign dashboard

Limitations:

- Braze starts at approximately $50,000/year — appropriate only for enterprise-scale products
- Customer.io at $100/mo (approx) and Segment at $120/mo (approx) add $220+/mo in service costs before infrastructure; validate that notification revenue uplift justifies the spend
- Segment and Customer.io introduce vendor lock-in for behavioral data and campaign logic — migration is expensive

## Gotchas

- **Scoring engine runs but every user gets the same score** — The behavior aggregation query is missing GROUP BY user_id. Instead of computing per-user event counts, it sums events across all users and assigns the global average to every row. Every user gets an identical score and identical notifications — the behavioral personalization is completely absent even though the pipeline appears to run correctly. Fix: Verify GROUP BY user_id in the aggregation SQL before anything else. Add an index on user_events(user_id, event_type, created_at) to make per-user grouping fast at scale. After fixing the query, check user_behavior_summary rows manually — each user_id should have a distinct events_last_7d count.
- **Send-time optimizer sends all notifications at the same hour because preferred_hour is NULL** — New users have no notification_opens history. preferred_hour is computed from opened_at timestamps but remains NULL until a user has opened at least one notification. The scoring function doesn't handle the NULL case and the scheduler falls through to an unhandled default, sending all new-user notifications at midnight UTC — the worst possible time. Fix: Set preferred_hour DEFAULT 10 in user_behavior_summary and handle the NULL case in the optimizer: COALESCE(preferred_hour, 10). Populate preferred_hour on first app open from the device timezone using Flutter's DateTime.now().timeZoneOffset, converting local 10 AM to UTC.
- **Suppression logic never resets — re-engaged users stay blocked permanently** — ignored_streak only resets when a notification is opened. But if a user is suppressed (ignored_streak >= 5), no notifications are sent — so they can never open one — so ignored_streak never resets. The user re-downloads the app, starts using it again, generates fresh events, but never receives a notification because they are permanently stuck in the suppressed state. Fix: Add a separate daily reset job that runs independently of sends: UPDATE notification_fatigue SET ignored_streak = 0 WHERE ignored_streak > 0 AND user_id IN (SELECT user_id FROM user_events WHERE created_at > NOW() - INTERVAL '7 days'). This job checks raw app activity, not notification history, to identify re-engaged users.
- **A/B test shows both variants being sent to the same user** — Variant assignment is computed as user_id % 2 on every scoring run, which is stable for a given user_id. However, if the notification_candidates table is queried without filtering by the stored variant — for example, if the sender function re-computes the variant rather than reading it from the candidates row — a schema change, UUID format difference, or modulo edge case can produce different variant assignments on different sends. Fix: Compute variant assignment once and store it in user_behavior_summary.preferred_variant on first notification creation. All subsequent scoring and sending functions read preferred_variant from this column instead of recalculating. This guarantees stable assignment regardless of query path.

## Best practices

- Break this feature into five explicit phases before generating any code: event tracking, aggregation, scoring, suppression, and sending — AI tools that attempt all five at once produce incomplete wiring between components
- Enforce a hard cap of 3 notifications per user per 24 hours at the database level via notification_fatigue.sent_24h, not just in application code — this prevents bugs in the scoring engine from spamming users
- Index user_events on (user_id, event_type, created_at desc) before writing the aggregation query — this is the highest-impact performance decision for this feature at any scale
- Default preferred_hour to 10 (10 AM local) for new users and update it only after a user has at least 3 notification opens — early data from 1-2 opens is too noisy to determine genuine preference
- Make dynamic notification copy specific to the actual entity the user interacted with — reference the product name, article title, or saved item; generic 'don't forget your cart' copy has 3-5x lower open rates
- Store the full notification_candidates record including suppressed = true rows and their suppression_reason — this is the only way to diagnose why a specific user never receives notifications
- Add a user-facing 'why did I get this?' explanation in the notification preference screen showing the event history that triggered the score — transparency reduces opt-out rates
- Plan a retention policy for user_events from the start: at 10 events/user/day, a 10K user app generates 100M rows per year; delete events older than 90 days via a weekly pg_cron job

## Frequently asked questions

### What user actions should I track to personalize notifications?

Track the actions that signal intent: view (user looked at something), favorite or save (user expressed interest), add-to-cart or start (user began a flow), and complete or purchase (user finished). Five event types is enough to build useful behavioral scores. Track entity_id and entity_type with each event so you know which specific product, article, or course to reference in the notification copy.

### How do I avoid annoying users with too many notifications?

Hard cap at 3 notifications per user per 24 hours in the notification_fatigue table, and pause all sends after 5 consecutive unread notifications (ignored_streak >= 5). These two rules eliminate the most common annoyance pattern — re-engagement campaigns that fire repeatedly at users who have disengaged. The 5-notification pause should last until the user opens the app again, not for a fixed time period.

### What is the minimum number of users needed to make behavioral targeting worthwhile?

Behavioral targeting starts producing measurably better open rates at around 200-500 active users. Below that, there is not enough signal per user to distinguish individual behavior patterns from noise, and the aggregation query overhead is not justified. For early-stage apps under 500 users, a well-timed push based on a single event type (cart abandonment 2 hours after add-to-cart) outperforms a full scoring pipeline.

### Can I use this for re-engagement campaigns to bring back inactive users?

Yes, but only if the suppressor reset job is working correctly. Re-engagement specifically targets users whose last_active_at is more than 7 days ago. The catch is: if these users were also previously suppressed (ignored_streak >= 5), your reset job must clear the ignored_streak when they return to the app — otherwise they will never receive a re-engagement notification regardless of your campaign logic.

### How do I measure whether smart notifications are actually improving engagement?

Track open rate (opened_at IS NOT NULL / sent_at IS NOT NULL) and conversion rate (did the user perform the target action within 24 hours of opening the notification) per notification type and variant in the notification_candidates table. Compare these metrics against a control group using your A/B variant assignment. A 10-15% improvement in open rate over a broadcast baseline is a realistic initial target.

### What is the difference between smart notifications and a standard push blast?

A standard push blast sends the same message to all users at the same time from a marketing tool. Smart behavioral notifications are generated per-user based on their individual in-app activity, sent at the time they are most likely to open the app, suppressed if they have been ignoring recent messages, and worded to reference the specific thing they were doing. The technical cost is significantly higher — event tracking, aggregation, scoring — but open rates are typically 3-5x higher than broadcast.

### Can I test two different notification messages to see which performs better?

Yes, using the A/B variant assignment in user_behavior_summary. Assign users to Variant A or B based on user_id::text % 2 — this is stable for each user across all sends. Define each variant's title and body template in the notification_variants table. Track opened_at per variant in notification_candidates and compare open rates after accumulating at least 100 opens per variant before drawing conclusions.

### Do I need a third-party tool or can I build this in Supabase?

You can build the full pipeline in Supabase: event tracking in a table, aggregation via pg_cron, scoring in an Edge Function, suppression checks in the same function, and FCM delivery via firebase-admin. This is the right choice for products under 10K active users. Above that, Customer.io or Braze handle suppression, send-time optimization, and A/B testing natively at a cost of $100-$50,000/month (approx) depending on the platform — the tradeoff is infrastructure cost versus engineering time.

---

Source: https://www.rapidevelopers.com/app-features/smart-notifications-based-on-user-behavior
© RapidDev — https://www.rapidevelopers.com/app-features/smart-notifications-based-on-user-behavior
