Event Flows
The Subscription Service participates in the FloatMe event-driven architecture in four ways: it publishes subscription lifecycle events to a Kinesis stream via a DynamoDB Stream feeder, it consumes scheduled EventBridge rules to trigger collection and notification runs, it reacts to real-time income and balance signals via SQS webhook workers, and it consumes Kinesis streams from the User Service and Payments Service for membership and ACH callbacks.
Overview
Subscription Service
│
┌───────────────────────┼─────────────────────────┐
│ │ │
▼ ▼ ▼
Publishes Consumes Consumes
Kinesis events scheduled EventBridge SQS queues and
(outbound) rules (inbound) Kinesis streams
│ │ (inbound)
▼ ▼ │
{env}-subscriptions collections-job ┌─────┴──────┐
Kinesis stream notifier-scheduler │ │
subscription- invoked on schedule Webhook Kinesis
updated events to queue work Workers Consumers
consumed by to SQS (income, (memberships,
downstream balance) ach-handler)
services
Events Published
subscription-updated
Published to the site-subscriptions Kinesis stream by the kinesis-feeder Lambda. The feeder is triggered by a DynamoDB Stream on the billing-activity table and forwards every INSERT or MODIFY change to Kinesis as a wrapped FMSDK event. REMOVE events are silently skipped.
| Field | Value |
|---|---|
Kinesis stream |
|
Event source |
|
Event version |
|
Event type |
|
Payload |
Full |
Trigger |
Every |
The event type is overridden when a membership lifecycle event (such as CANCEL) writes a specific updated_event value into the DynamoDB record before the feeder sees the stream record. For example, a CANCEL membership event sets updated_event = "PENDING_CANCELLATION" and the feeder emits that as the Kinesis event type instead of the default.
Downstream consumers (e.g. analytics pipelines, other services) subscribe to this Kinesis stream independently. The Subscription Service does not manage those subscriptions.
Consumed Kinesis Streams
site-user-service-users (Memberships)
Consumed by the memberships Lambda. The stream carries FMSDK-wrapped membership lifecycle events published by the User Service. The processor unmarshals each record into a Membership struct and routes on event_type.
| Event Type | Action |
|---|---|
|
Locks the subscription; updates |
|
Applies pause logic — advances the subscription date by the pause duration months. |
|
Retracts a previously applied membership change. |
|
Unpauses the subscription and schedules the next billing date. |
|
Unpauses and immediately triggers a charge attempt. |
|
Handles account closure — cancels any open subscriptions. |
Any event type not in the list above is logged as unsupported and the record is skipped.
site-payments (ACH Handler)
Consumed by the ach-handler Lambda. The stream carries payment outcome events from the Payments Service. The handler filters for subscription-related ACH debit results and updates billing-activity accordingly, then emits notifications via Segment, Iterable, and AppsFlyer.
See ACH Processing for the full event-type-to-status mapping.
Scheduled Collections
Three CloudWatch EventBridge rules trigger the collections-job Lambda on weekdays. Each rule injects a process field into the event detail that tells the job which collection run to perform. The job queries DynamoDB for qualifying subscriptions and enqueues them to the appropriate SQS queue for the collections-worker Lambda to process.
EventBridge Rules
| Rule | Schedule (UTC) | Injected Detail | Effect |
|---|---|---|---|
|
8:00 AM Mon–Fri |
|
Queries for subscriptions in |
|
7:00 AM Mon–Fri |
|
Queries for subscriptions in |
|
10:00 PM Mon–Fri UTC (16:00 CST / 17:00 CDT) |
|
Queries for subscriptions in |
Scheduler → Worker Flow
The collections-job Lambda supports two trigger types — CloudWatch (initial schedule fire) and SQS (pagination self-invocation when results exceed one page). The pattern is identical for all three process types.
EventBridge rule fires (scheduled / retry / pause)
│
▼
collections-job Lambda invoked via CloudWatch event
detail.process = "scheduled" | "retry" | "pause"
│
▼
Query DynamoDB (billing-activity) for qualifying subscriptions
scheduled: status=SCHEDULED, subscription_date <= today
retry: status=ERROR, subscription_date passed
pause: status=PAUSED, ready for collection
│
▼
For each page of results (up to PAGES_PER_INVOKE):
Enqueue subscriptions to the process-specific SQS queue
│ │
│ └─ If more pages remain:
│ Enqueue pagination message to
│ the process-specific page queue
│ (collections-job re-invoked via SQS)
▼
collections-worker Lambda dequeues from:
{env}-subscription-service-collections-scheduled
{env}-subscription-service-collections-retry
{env}-subscription-service-collections-pause
│
▼
Attempt collection for each subscription
(see xref:collections.adoc[Collections] for decision logic)
Webhook Worker (Income Detection)
The webhook-worker Lambda reacts to income detection events from the Insight Service. When income is deposited for a user, the worker checks whether that user has a subscription in ERROR status and, if so, attempts immediate collection via pinless debit or ACH.
Event Path
Insight Service detects income transaction for a user
(filter: detail-type=income_txn, source=insight-service.income,
amount < -$75.00 i.e. < -7500 cents)
│
▼
EventBridge rule: {env}-subscription-service-income-detected
│
▼
SQS: {env}-subscription-service-income-event-tap
(visibility timeout: 900s, max receive count: 1, DLQ present)
│
▼
webhook-worker Lambda invoked (batch size 10, max concurrency 5)
Event payload: CloudWatch envelope wrapping {user_id, amount}
│
▼
GrowthBook flag subscriptions.webhook.balance.authoritative?
true ──► skip (balance worker is authoritative for this user)
false ──► proceed with income-triggered collection
│
▼
Acquire DynamoDB distributed lock for user
│
▼
Query DynamoDB for subscriptions in ERROR status
(within the last 2 months)
│
├── no collectable subs ──► release lock, return
│
▼
Get user from User Service
inactive user ──► set subs to INACTIVE, release lock, return
│
▼
For each collectable subscription:
Debit card valid? ──► attempt pinless debit
Debit card invalid? ──► run ACH checks:
income event amount < ACH_INCOME_EVENT_THRESHOLD (-$100.00)?
──► skip (income not high enough)
ACH attempts in last month >= ACH_ATTEMPTS_PER_MONTH (3)?
──► skip (too many ACH attempts)
balance < ACH_BALANCE_THRESHOLD ($200.00)?
──► skip (balance too low)
user blocklisted by returned payments?
──► skip
──► attempt ACH debit
│
▼
Update subscription status in DynamoDB
COMPLETED (pinless success) | ACHSENT (ACH sent) | ERROR (failure)
Emit Datadog metric: sub_collect
GrowthBook Flags
| Flag | Effect |
|---|---|
|
Routes a user to the balance webhook worker path. When |
|
When set to |
The income threshold filter on the EventBridge rule (amount < -$75.00) is separate from the per-user ACH income check at collection time (ACH_INCOME_EVENT_THRESHOLD = -$100.00). Both must pass for an ACH collection to proceed.
|
Balance Webhook Worker
The webhook-balance-worker Lambda reacts to account balance update events from the Transaction Service. It applies a richer set of balance and collection rules before attempting collection, making it suitable for users whose income detection path has been migrated to balance-based triggering.
Event Path
Transaction Service publishes account balance update
(filter: detail-type=new_account, source=txn-service.feeder,
is_main=true,
available >= 0 OR current >= 0 OR calc_available >= 0)
│
▼
EventBridge rule: {env}-subscription-service-balance-detected
│
▼
SQS: {env}-subscription-service-balance-event-tap
(visibility timeout: 900s, max receive count: 1, DLQ present)
│
▼
webhook-balance-worker Lambda invoked (batch size 10, max concurrency 5)
Event payload: CloudWatch envelope wrapping txn-service Account object
│
▼
GrowthBook flag subscriptions.webhook.balance.authoritative?
false ──► skip (user is on the income webhook path)
true ──► proceed with balance-triggered collection
│
▼
Acquire DynamoDB distributed lock for user
│
▼
Populate collection state from account update:
- available balance, current balance, calc_available balance
- institution ID
- user status (short-circuit if inactive)
- subscriptions in ERROR status (within last 2 months)
- subscription ACH history per subscription
- debit card validity
- blocklist status
│
├── no collectable subs ──► release lock, return
│
▼
For each collectable subscription, evaluate collection rules:
ruleUserIsActive
ruleDefaultPinlessIfNotSet
ruleAchIfInvalidDebitCard
rulePinlessIfBlocklisted
ruleDontRetryAchIfBlocklisted
ruleHasValidDebitCard
rulePinlessBalanceCheck (threshold from GB flag)
ruleAchBalanceCheck (threshold from GB flag)
ruleACHLimitNotExceeded
│
├── rules fail ──► emit sub_check metric with reason, skip
│
▼
Attempt collection (pinless debit or ACH)
error code 51 (NSF) + achIf51 flag enabled?
──► fallback to ACH attempt
│
▼
Update subscription status in DynamoDB
Emit notifications: Segment, AppsFlyer (iOS + Android)
Emit Datadog metric: sub_collect
GrowthBook Flags
| Flag | Effect |
|---|---|
|
Routes a user to the balance worker path. When |
|
Pinless debit balance threshold (in cents) for standard-tier subscriptions. Defaults to |
|
Pinless debit balance threshold (in cents) for premium-tier subscriptions. Defaults to |
|
ACH balance threshold (in cents). Defaults to |
|
When |
|
When |
|
When set to |
Notifier
The three-day pre-billing notifier runs as two cooperating Lambdas: notifier-scheduler and notifier-worker.
Flow
EventBridge rule fires (daily, 12:00 UTC — prod only)
rule: pre_subscription_notifier
detail: {"status": "SCHEDULED"}
│
▼
notifier-scheduler Lambda invoked via CloudWatch
Reads target date = today + 4 calendar days
│
▼
Query DynamoDB (billing-activity) via GSI
status = SCHEDULED, subscription_date in [target_date, target_date+1)
Up to PAGES_PER_INVOKE pages per invocation
│
▼
For each page of results:
Wrap each subscription in an FMSDK event
event_type = "three_day_notification"
Batch-send to SQS:
{env}-subscription-service-pre-subscription-notifier-worker
│
│ If more pages remain:
└─► Enqueue pagination cursor to:
{env}-subscription-service-pre-subscription-notifier-scheduler
(notifier-scheduler re-invoked via SQS to continue paging)
│
▼
notifier-worker Lambda invoked (max concurrency 5)
Dequeues from pre-subscription-notifier-worker
│
▼
For each subscription:
Skip if updated_event = PENDING_CANCELLATION
Get user — skip if inactive
Skip non-test-domain emails in test environments
│
▼
Send Iterable track event
event_name: "three_day_notification"
properties: membership_tier, billing_date, amount, account_number
│
▼
Prenote user (GrowthBook-gated: subscriptions.prenotes defaults true)
User < 30 days old:
submit prenote to payments service
User >= 30 days old:
require a JPM COMPLETED payment in last 40 days
require active Plaid item
if eligible: submit prenote to payments service
provider = "usio" (GB flag subscriptions.payments.provider)?
──► SubmitUsioPrenote
default:
──► SubmitJpmPrenote
GrowthBook Flags
| Flag | Effect |
|---|---|
|
Master switch for prenote submissions in the notifier worker. Defaults to |
|
When set to |
Related Pages
-
Collections — Full decision logic for scheduled, retry, pause, and webhook collection paths
-
ACH Processing — Kinesis-based ACH payment outcome callbacks and status transitions
-
Memberships — Membership Kinesis consumer and state transitions
-
Notifications — Three-day notifier scheduler and worker in detail
-
Architecture — System context diagram showing all inbound and outbound event paths
-
Infrastructure — SQS queues, EventBridge rules, and Lambda configuration