Notifications
The subscription-service sends proactive notifications to users before their billing date arrives. Two Lambda functions handle this: notifier-scheduler scans DynamoDB for subscriptions due in three days and enqueues them, and notifier-worker processes each subscription and sends the notification via Iterable. The worker also conditionally submits an ACH prenote to validate the user’s bank account before the collection runs.
Payment-failure notifications (event name user_subscription_payment_failed) are emitted by the ach-handler Lambda — not by the notifier. See ACH Processing for that flow.
Overview
The three-day notifier is a paging scheduler pattern. A CloudWatch rule fires the notifier-scheduler Lambda daily. The scheduler queries the billing-activity DynamoDB table for SCHEDULED subscriptions whose billing_date falls on the date that is four days from today (i.e., today + 4 days, which gives the user approximately a three-day heads-up). It pages through results PAGES_PER_INVOKE pages at a time, batching each page of subscriptions into the worker queue. If more pages remain when the per-invocation budget is exhausted, the scheduler re-enqueues itself to the scheduler queue so it continues from where it left off on the next invocation.
The notifier-worker Lambda consumes from the worker queue. For each subscription it receives, it fetches the user record, checks the user is active, builds a notification payload, and fires a three_day_notification track event to Iterable. It then attempts an optional ACH prenote via the Payments service.
Lambda Summary
| Lambda | Binary | Responsibility |
|---|---|---|
|
|
CloudWatch + SQS trigger; scans DynamoDB, batches subscriptions to worker queue, re-enqueues itself when paginating |
|
|
SQS consumer; sends Iterable track event, optionally submits ACH prenote |
Dependencies
| Dependency | Interface | Role |
|---|---|---|
|
|
|
|
|
Sends |
|
FMSDK user client |
Fetches user record (email, status, |
|
FMSDK payments client |
Fetches bank account for account-number display; submits JPM or Usio ACH prenote |
|
FMSDK txn client |
Lists active transaction items to find the user’s |
|
|
Feature flag |
Schedule
The notifier runs once per day, seven days a week. The CloudWatch rule pre_subscription_notifier is configured with:
| Parameter | Value |
|---|---|
Schedule expression |
|
Description |
"Runs daily at 12:00 UTC (6:00 AM CST / 7:00 AM CDT)" |
Enabled |
|
The CloudWatch event target injects the following static detail object into the event payload delivered to the Lambda:
{
"detail": {
"status": "SCHEDULED"
}
}
This means each daily run queries for SCHEDULED subscriptions by default. The status field can be overridden by injecting a different event detail (e.g. when invoking the Lambda manually).
DynamoDB Query Logic
The scheduler calls GetNotificationSubscriptionPage on the billing-activity table using the billing_status-index GSI. The query parameters are:
| Parameter | Value |
|---|---|
Key condition ( |
|
Filter: |
|
Filter: |
|
|
Pagination resume token (empty on first invocation, populated from DynamoDB |
The target date is computed as:
notificationDate = time.Now().UTC().truncate(day) + 4 days
If the Event.Date field is set (format YYYYMMDD), that date is used as the start of the window instead of today + 4, allowing manual backfill runs.
Scheduler Flow
CloudWatch rule fires at 12:00 UTC
│
▼
notifier-scheduler Lambda (HandleCloudWatch)
│
▼
Parse event.detail → Scheduler.Event
{ date: "", status: "SCHEDULED", start_key: nil }
│
▼
Compute notification window:
startDate = today + 4 days (00:00 UTC)
endDate = today + 5 days (00:00 UTC)
│
▼
Loop (up to PAGES_PER_INVOKE pages):
│
├──► GetNotificationSubscriptionPage(status, startDate, endDate, startKey)
│ │
│ ▼
│ Batch subscriptions → ThreeDayNotificationEvent fmsdk events
│ │
│ ▼
│ SendMessageBatch → pre-subscription-notifier-worker SQS queue
│ │
│ endKey == nil? ──► done (no more pages)
│ │
│ currentPage++; startKey = endKey
│
└── PAGES_PER_INVOKE reached AND endKey != nil
│
▼
SendMessage → pre-subscription-notifier-scheduler SQS queue
{ date: event.Date, status: event.Status, start_key: endKey }
│
▼
notifier-scheduler Lambda (HandleSQS) picks up the continuation
and repeats the loop from the saved startKey
The scheduler SQS queue (pre-subscription-notifier-scheduler) is consumed by the same notifier-scheduler Lambda with an SQS event source mapping (batch size 1). This creates a self-paging loop: each invocation processes PAGES_PER_INVOKE DynamoDB pages and re-enqueues itself if work remains. The loop terminates when GetNotificationSubscriptionPage returns an empty LastEvaluatedKey.
|
Worker Flow
The notifier-worker Lambda consumes from pre-subscription-notifier-worker (batch size 10, ReportBatchItemFailures enabled, max concurrency 5).
SQS message received (batch of up to 10 records)
│
▼
For each record:
│
├──► Parse body → fmsdkEvents.Event
│ │
│ ├── Parse error ──► add to failures, continue
│ │
│ ▼
│ Decode payload → subscriptions.Subscription
│ │
│ ├── Decode error ──► add to failures, continue
│ │
│ ▼
│ handle(ctx, sub)
│ │
│ ├── sub.UpdatedEvent == "PENDING_CANCELLATION"?
│ │ └──► log "User is pending cancellation", skip (return nil)
│ │
│ ▼
│ GetUser (user service)
│ │
│ ├── Error ──► add to failures, continue
│ │
│ ▼
│ user.Status == "ACTIVE"?
│ │
│ ├── No ──► log "User is not active, skipping", skip (return nil)
│ │
│ ▼
│ TestEnvironment check:
│ email must end with "integrationtest.com"
│ or "integrationtest.floatme.io"
│ │
│ ├── Non-test email in test env ──► skip (return nil)
│ │
│ ▼
│ buildNotification(sub):
│ - fetch active transaction items → find main_account_id
│ - fetch bank account → extract account number (last 4)
│ - if sub.TierName == "" set TierName = "LITE"
│ → ThreeDaySubNotification {
│ event_type: "three_day_notification"
│ user_id: sub.UserID
│ billing_date: sub.SubscriptionDate formatted "01/02/2006"
│ amount: sub.SubscriptionAmount
│ membership_tier: sub.GetTier()
│ account: bank account number
│ }
│ │
│ ▼
│ Iterable.Track(TrackEvent {
│ email: user.Email
│ event_name: "three_day_notification"
│ properties: ThreeDaySubNotification
│ })
│ │
│ ├── Error ──► log error, add to failures
│ │
│ ▼
│ prenoteUser(user) ← see Prenote section below
│
▼
Return SQSEventResponse with batch item failures list
Prenote Submission
After sending the Iterable notification, the worker attempts to submit an ACH prenote for the user’s bank account. A prenote is a zero-dollar test ACH transaction used to validate a bank account before the real collection runs. The prenote logic is gated by several checks:
prenoteUser(user):
│
├── GrowthBook: subscriptions.prenotes enabled for user?
│ └── No ──► metric prenotes.submitted:false (reason: feature_flag_disabled), return
│
▼
hasGoodPlaidItem(user.UserId):
GET /plaid-accounts via txn-service
→ has primary_account_id AND accounts list not empty?
│
├── No ──► metric prenotes.submitted:false (reason: no_good_plaid_item), return
│
▼
userAgeInDays = days since user.DateJoined
│
├── userAgeInDays < 30 ──► submitPrenote(user) (new users skip payment check)
│
▼
GetPaymentsByUserID (payments service)
│
▼
hasGoodPayment?
A "good" payment requires all of:
- provider == "jpm"
- status == "COMPLETED"
- completion_date within the last 40 days
│
├── No ──► metric prenotes.submitted:false (reason: no_good_payment), return
│
▼
submitPrenote(user):
│
├── GrowthBook: subscriptions.payments.provider == "usio"?
│ │
│ ├── Yes ──► Payments.SubmitUsioPrenoteWithResponse {
│ │ email: user.Email
│ │ first_name: user.FirstName
│ │ last_name: user.LastName
│ │ user_id: user.UserId
│ │ usio_account: "subscription"
│ │ }
│ │ response.StatusCode != 201 ──► metric submitted:false (bad_response_code)
│ │ success ──► metric submitted:true, provider:usio
│ │
│ └── No (default JPM) ──► Payments.SubmitJpmPrenoteWithResponse {
│ first_name: user.FirstName
│ last_name: user.LastName
│ user_id: user.UserId
│ }
│ response.StatusCode != 201 ──► metric submitted:false
│ success ──► metric submitted:true, provider:jpm
The prenote metric is subscriptions.notifier.prenotes (Datadog custom metric via ddlambda.Metric). Tags on the metric include submitted:true/false, submitted_reason:<reason>, and provider:jpm/usio.
Notification Events
three_day_notification (Iterable)
Sent by notifier-worker for every active, non-cancelling SCHEDULED subscription whose billing date falls in the three-day window.
| Channel | Event name | Payload fields |
|---|---|---|
Iterable ( |
|
|
The Iterable event is sent via iterable.TrackEvent. The user’s email address is used as the Iterable identifier.
No Segment events are emitted by the notifier Lambdas. No AppsFlyer events are emitted by the notifier Lambdas. (Both Segment and AppsFlyer are used by the ach-handler Lambda for payment-outcome notifications — see ACH Processing.)
user_subscription_payment_failed (Iterable + AppsFlyer — ach-handler)
The PaymentFailedEvent type and SubPaymentFailedEvent constant ("user_subscription_payment_failed") are defined in pkg/notification/models.go and are sent by the ach-handler Lambda when an ACH return is received. See ACH Processing for the full flow.
| Channel | Event name | Payload fields |
|---|---|---|
Iterable ( |
|
|
AppsFlyer |
|
|
Infrastructure
SQS Queues
| Queue name | Visibility timeout | Max receive count | DLQ | Consumer |
|---|---|---|---|---|
|
900 s |
5 |
|
|
|
900 s |
5 |
|
|
Lambda Configuration
| Parameter | Value |
|---|---|
|
|
|
CloudWatch rule |
|
|
|
|
|
|
|
5 |
|
SQS |
|
|
|
Iterable API key ( |
|
|
Error Handling
Scheduler
HandleSQS enforces a strict batch size of 1 (ErrTooManyRecords). If the SQS batch contains more than one record the entire invocation fails without processing any messages. This is consistent with the SQS event source mapping configured for batch size 1.
DynamoDB query errors from GetNotificationSubscriptionPage are returned immediately and cause the SQS message to be retried (up to the maxReceiveCount of 5 before the message moves to the DLQ).
Individual fmsdk event creation errors within sendToWorkerQueue are logged and skipped — the subscription is silently dropped from the batch rather than failing the entire page.
Worker
The worker uses ReportBatchItemFailures. Failed records (parse errors, decode errors, user-service errors, Iterable send errors) are added to the SQSBatchItemFailures list and returned to SQS for retry. Successfully processed records are not re-delivered.
Subscriptions whose updated_event is "PENDING_CANCELLATION" are silently skipped (return nil, not a failure) — no notification is sent to users who have already initiated a cancellation.
Inactive users (user-service status != "ACTIVE") are also skipped without failure.
Prenote errors are logged but do not cause the SQS record to fail — a prenote failure does not prevent the Iterable notification from being counted as successfully delivered.
Package Structure
pkg/notification/ and pkg/notification/notifier/ contain the following files:
| File | Contents |
|---|---|
|
|
|
Interface definitions: |
|
|
|
|
|
|
Related Pages
-
ACH Processing —
user_subscription_payment_failedIterable + AppsFlyer events; ACH-triggered notifications for returned or charged-back payments -
Collections Engine — Scheduled, webhook, and pause collection flows that trigger the billing dates being notified about
-
Subscription Lifecycle —
SCHEDULEDsubscription status andbilling_datefield that drive the notification query -
DynamoDB Tables —
billing-activitytable structure,billing_status-indexGSI -
Event Flows — CloudWatch schedule topology and SQS queue wiring
-
Infrastructure — Full Lambda, SQS, and Secrets Manager configuration