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

notifier-scheduler

cmd/notifier-scheduler

CloudWatch + SQS trigger; scans DynamoDB, batches subscriptions to worker queue, re-enqueues itself when paginating

notifier-worker

cmd/notifier-worker

SQS consumer; sends Iterable track event, optionally submits ACH prenote

Dependencies

Dependency Interface Role

subscriptions.Repository

pkg/dynamo/subscriptions.DDBRepository

GetNotificationSubscriptionPage — queries the billing_status-index GSI on billing-activity

notification.IterableClient

fmsdk/notification/iterable

Sends three_day_notification track events to Iterable

user.API

FMSDK user client

Fetches user record (email, status, app_platform, appsflyer_id, date_joined)

payments.API

FMSDK payments client

Fetches bank account for account-number display; submits JPM or Usio ACH prenote

transactions.API

FMSDK txn client

Lists active transaction items to find the user’s main_account_id; checks Plaid accounts

growthbook.API

pkg/growthbook

Feature flag subscriptions.prenotes gates prenote submission; subscriptions.payments.provider selects the prenote provider (JPM vs Usio)

Schedule

The notifier runs once per day, seven days a week. The CloudWatch rule pre_subscription_notifier is configured with:

Parameter Value

Schedule expression

cron(0 12 ? * * *) (12:00 UTC daily, every day of the week)

Description

"Runs daily at 12:00 UTC (6:00 AM CST / 7:00 AM CDT)"

Enabled

prod only — disabled in all other environments

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 (billing_status)

= event.Status (defaults to "SCHEDULED" from the CloudWatch detail)

Filter: billing_date >=

today + 4 days at 00:00:00 UTC (the notification window start)

Filter: billing_date <

today + 5 days at 00:00:00 UTC (the notification window end — a one-day window)

startKey

Pagination resume token (empty on first invocation, populated from DynamoDB LastEvaluatedKey on subsequent pages)

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 (Track)

three_day_notification

  • event_type"three_day_notification"

  • user_id — subscription owner

  • billing_date — formatted "MM/DD/YYYY"

  • amount — subscription amount string

  • membership_tier — tier name (e.g. "LITE", "PRO")

  • account — bank account number (sourced from Payments service via txn-service)

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 (Track)

user_subscription_payment_failed

  • event_type"user_subscription_payment_failed"

  • user_id

  • frequency — subscription term

  • amount

  • membership_tier

  • due_date — formatted "2006-01-02 15:04:05 -07:00"

  • payment_type"ACH"

  • mask — masked account number

  • reason"ACH_FAILED"

  • billing_group"legacy" or "Friday"

  • billing_week"1st", "2nd", "3rd", "4th", or "last" (Friday billing only)

AppsFlyer

ad_subscription_payment_failed

  • membership_tier

  • payment_type

  • subscription_date

  • reason

  • return_code

  • process

Infrastructure

SQS Queues

Queue name Visibility timeout Max receive count DLQ Consumer

site-subscription-service-pre-subscription-notifier-scheduler

900 s

5

…​-scheduler-dlq

notifier-scheduler (batch size 1)

site-subscription-service-pre-subscription-notifier-worker

900 s

5

…​-worker-dlq

notifier-worker (batch size 10, ReportBatchItemFailures)

Lambda Configuration

Parameter Value

notifier-scheduler timeout

floor(scheduler_queue_visibility_timeout * 0.9) = 810 s

notifier-scheduler triggers

CloudWatch rule pre_subscription_notifier + SQS pre-subscription-notifier-scheduler (batch size 1)

notifier-scheduler env vars

DYNAMO_REGION, DYNAMO_TABLE, SQS_REGION, SCHEDULER_QUEUE_URL, WORKER_QUEUE_URL, PAGES_PER_INVOKE

notifier-scheduler IAM

dynamodb:Query on billing-activity; sqs:DeleteMessage, GetQueueAttributes, ReceiveMessage, SendMessage on both notifier queues

notifier-worker timeout

floor(worker_queue_visibility_timeout * 0.9) = 810 s

notifier-worker max concurrency

5

notifier-worker triggers

SQS pre-subscription-notifier-worker (batch size 10)

notifier-worker env vars

SM_ITERABLE_NAME, SM_GROWTHBOOK_NAME, USER_SERVICE_URL, USER_SERVICE_REGION, TXN_SERVICE_URL, TXN_SERVICE_REGION, DD_ENV

notifier-worker Secrets Manager

Iterable API key (SM_ITERABLE_NAME), GrowthBook SDK key + host (SM_GROWTHBOOK_NAME)

notifier-worker IAM

sqs:DeleteMessage, GetQueueAttributes, ReceiveMessage on worker queue; secretsmanager:GetSecretValue on Iterable and GrowthBook secrets; execute-api:Invoke on user-service, payments-service, and txn-service APIs

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

pkg/notification/models.go

ThreeDaySubNotification struct; PaymentFailedEvent struct and GetPaymentFailedEvent helper; event name constants (ThreeDayNotificationEvent, SubPaymentFailedEvent); payment failure reason constants (BLOCKED, INSUFFICIENT_BALANCE, ERROR, ACH_FAILED); IterableTimeFormat

pkg/notification/notification.go

Interface definitions: SenderClient (wraps fmNotification.Sender for Segment), IterableClient (wraps iterable.API), AppsflyerClient (wraps appsflyer.API)

pkg/notification/appsflyer.go

PlatformAppsflyerClients struct (holds separate iOS and Android AppsFlyer clients); SendAppsflyerEvent helper used by ach-handler and collections workers

pkg/notification/notifier/scheduler.go

Scheduler struct; HandleCloudWatch, HandleSQS entry points; handleEvent pagination loop; sendToWorkerQueue, sendToSchedulerQueue; getStartAndEndDates (today + 4 days logic)

pkg/notification/notifier/worker.go

Worker struct; SQS entry point (batch processing with ReportBatchItemFailures); handle (per-subscription orchestration); buildNotification; prenoteUser (GrowthBook flag → Plaid check → age check → payment history check → submitPrenote); submitPrenote (JPM or Usio branch); isUserActive; hasGoodPlaidItem; hasGoodPayment

  • ACH Processinguser_subscription_payment_failed Iterable + 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 LifecycleSCHEDULED subscription status and billing_date field that drive the notification query

  • DynamoDB Tablesbilling-activity table structure, billing_status-index GSI

  • Event Flows — CloudWatch schedule topology and SQS queue wiring

  • Infrastructure — Full Lambda, SQS, and Secrets Manager configuration