DynamoDB Tables
The User Service writes most entities to one single-table DynamoDB table, {env}-user-service-users, and keeps phone numbers, access codes and balance settings in three shared tables that live in us-east-1 in prod. Physical settings are in Infrastructure.
{env}-user-service-users
Key schema PK / SK; entities share the table and are told apart by their SK prefix and the db_type attribute. GSI1–GSI5 exist with KEYS_ONLY projection; GSI1 and GSI2 are used. Every write carries an event_type attribute that the kinesis-feeder forwards as the Kinesis event type for USER and MEMBERSHIP records — see Users stream.
With USE_LEGACY_ONLY=true (both environments) the USER entity is never written: user reads go to RDS, and the DynamoDB USER rows, GSI1/GSI2 lookups and USER_* Kinesis events described below exist only when the flag is off. See Feature Flags.
|
Entity Index
| Entity | PK | SK | db_type |
Written by |
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
— |
|
|
|
|
— |
|
|
|
|
— |
|
|
|
|
— |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
— |
|
User
One row per update: Update merges the new fields into the latest row and writes a new row with a fresh date_updated, so the newest SK is the current profile. GSI1PK = USER_EMAIL#<email>, GSI2PK = USER_PHONE#<phone>, both with SK USER_INFORMATION#<date_updated> for email and phone lookups.
| Attribute | Description |
|---|---|
|
Auth0 user identifier |
|
Email address |
|
Name |
|
Postal address |
|
10-digit phone number |
|
Account status — User Status |
|
Firebase messaging install ID |
|
Firebase instance ID |
|
AppsFlyer attribution ID |
|
Client platform ( |
|
Creation and last-update timestamps |
|
Map of tag name → Tag, populated on read from the Tag rows |
|
|
Source: pkg/repository/users.go
Membership
Append-only: every change writes a new TIER#<start_date> row and the latest row is the current membership (Get queries PK with begins_with(SK, "TIER#"), newest first, limit 1; List returns the full history).
| Attribute | Description |
|---|---|
|
User identifier |
|
Tier name, upper-cased ( |
|
GrowthBook tier version at the time of the record ( |
|
|
|
Membership Status; empty means good standing |
|
Actor that produced the record — Event source |
|
|
|
Payments Service confirmation of the upgrade charge |
|
Subscription Service identifier |
|
Scheduled downgrade — Membership Downgrades |
|
Record timestamp (also the |
|
Most recent reactivation — Reactivation |
|
Waiver program the most recent reactivation was waived under; absent when it was charged. The win-back repeat rule reads it, with |
|
Pause window; duration |
Source: pkg/repository/memberships.go
Membership Status
| Value | Meaning |
|---|---|
(empty) |
Good standing |
|
Cancellation scheduled for the end of the billing term |
|
Account closed |
|
Pause requested; takes effect at the next collection date |
|
Collection skipped; membership paused |
|
Resume requested after a full pause |
|
Resumed |
|
Subscription active |
|
Mirror of the user status written by ban and flag operations |
Source: pkg/repository/memberships.go, pkg/api/ban.go
Membership Event Types
The value forwarded as the Kinesis event type; downstream services key automation off it.
| Value | Written by |
|---|---|
|
Scheduled cancellation — Cancellation |
|
Instant account closure — Cancellation |
|
Pending change retracted ( |
|
MX reactivation — MX Reactivation |
|
Collect-on-reactivation — Collect-on-Reactivation |
|
Ban or investigation cleared — Clear |
|
Pause requested — Pause |
|
Collection skipped while paused — Subscription worker |
|
Manual unpause, with a catch-up charge when a collection was already skipped — Unpause |
|
Automatic resume — Subscription worker |
|
Ban and flag — Bans & Fraud Signals |
|
Defined; no code path in this service writes it |
|
Tier upgrade — Upgrade |
|
Downgrade lifecycle — Membership Downgrades |
Source: pkg/repository/memberships.go, pkg/subscriptions/handler.go
Tag
| Attribute | Description |
|---|---|
|
Key and string value |
|
Soft-deleted when |
|
RFC3339 write timestamp |
Tags used by this service
| Tag | Values | Set / read by |
|---|---|---|
|
RFC3339 |
Set at account creation ( |
|
|
Pause state; archived when resumed |
|
any, unarchived |
Read only: blocks pausing — Eligibility |
|
|
Set by |
|
|
Set by |
|
|
Set by |
Source: pkg/api/users.go, pkg/api/user_creation.go, pkg/api/memberships.go, pkg/api/membership_pausing.go, pkg/api/user_update.go, pkg/api/offers.go
Consent, SMS and Beta Agreements
All three share the Agreement shape and set GSI1PK = SK, GSI1SK = PK for reverse lookups. GetAcceptedTerms returns the newest CONSENT#<key># row.
| Attribute | Description |
|---|---|
|
User and, for billing consent, the related item (empty from |
|
Agreement identifier supplied by the client (billing, terms, …) |
|
Server write time |
|
Client-supplied acceptance details |
|
SMS agreement rows only |
|
MVP beta agreement rows only |
Data Deletion Request
Created pending by the API and updated by the janitor. Anonymous requests (no account for the requester) use the hashed-email PK so they stay in the USERS# keyspace without colliding with a user id.
| Attribute | Description |
|---|---|
|
Requester; email is canonicalised (trimmed, lower-cased) and only stored for anonymous rows |
|
|
|
|
|
|
|
Timestamps; |
|
Map vendor → |
|
Originating ticket; 0 for app and backoffice |
|
|
Flow in Data Deletion.
User Update
Audit row per cooldown-tracked profile change (address today): user_id, type, date_updated. Read by CheckUserProfileChange and the address-change cooldown — Profile updates.
Prorated Subscription
Written after a successful upgrade charge: user_id, next_subscription_id, proration_id, confirmation_id, amount_cents, created_at. GSI1 = SUBSCRIPTIONS#<next_subscription_id> / PRORATION#<proration_id>, GSI2PK = PRORATION#<proration_id>. A write failure does not fail the upgrade.
Survey Response
Raw Tally webhook payload stored under raw_response by the survey-webhook-handler for downstream ingestion.
Access Patterns
User by id / email / phone → Query PK | GSI1 USER_EMAIL#… | GSI2 USER_PHONE#…, newest USER_INFORMATION# row Current membership → Query PK, begins_with(SK, "TIER#"), ScanIndexForward=false, Limit 1 Membership history → Query PK, begins_with(SK, "TIER#") Tags (with/without archived) → Query PK, begins_with(SK, "TAG#") Latest consent for a key → Query PK, begins_with(SK, "CONSENT#<key>"), newest first, Limit 1 Latest data-deletion request → Query PK, begins_with(SK, "DATA_DELETION#"), newest first, Limit 1 Profile changes of one field → Query PK, begins_with(SK, "USER_UPDATE#<field>")
us-east-1 Tables
Shared with other FloatMe systems; no environment prefix. Region from USR_LEGACY_DYNAMO_REGION / LEGACY_DYNAMO_REGION.
user-phone-numbers
user_id (PK), phone_number, last_updated, is_opted_out, is_phone_verified. GSI phone_number-index on phone_number backs duplicate-phone checks at signup, access-code requests and admin updates. Written by CreateUser, UpdateUser, AdminUpdateUser.
access-codes
phone_number (PK), access_code, oob_code, created_on, request_count, failed_attempts, validation_token, validate_on. Writes are conditional on created_on for optimistic concurrency. Flow in Access Codes.
{env}-floatmetrics
PK = FLOATMETRIC#<user_id> (or ANONFLOATMETRIC#<install_id> when no user id), SK = EVENTTIME#<time_of_event>#<event_name>; attributes user_id, install_id, event_name, app_version, app_build_number, time_of_event, time_of_event_storage, metric_properties (JSON string), ttl (2 days). Written by the floatmetrics Lambda from {env}-floatmetrics-queue; events missing an id, name, version, build or time are dropped with a warning.
Source: pkg/repository/floatmetrics.go, pkg/floatmetrics/floatmetrics.go
{env}-user-service-settlement-participants
One item per settlement claimant: fmsdk’s user.SettlementParticipant, shared with the Subscription Service. Defined in the infrastructure repo with no stream, so the participant list never reaches the data lake. Same region and client as the users table; name from USR_SETTLEMENT_DYNAMO_TABLE.
| Key | Value |
|---|---|
|
|
|
|
|
|
|
|
|
|
Attributes: db_type (SETTLEMENT_PARTICIPANT), claimant_id, user_id, email, name, phone_number, street_address, city, state, postal_code, needs_manual_review, review_user_ids, started_at, reviewed_by, reviewed_at, cancelled_at, forfeited_at. Each GSI SK is the item’s PK.
ImportSettlementParticipant writes one item per claimant; a claimant already in the table is returned as stored, not written again. An account with the list email sets user_id, and starts the waiver (started_at now, to the hour) when the account is a current subscriber: ACTIVE with a membership that is not cancelled. For a started participant, import then calls the Subscription Service’s ApplySettlementWaiver with the period’s ends_at, which cancels the user’s past-due cycles and prices their upcoming SCHEDULED and PAUSED cycles inside the period at 0.00. It is called also when the claimant was already imported, so a rerun retries it. A transport error, 409 (billing lock held), 429 or 5xx is retried with fmsdk backoff (4 attempts over about 4.5s); any other answer is final. A failure after that is logged and the import still succeeds. Anyone else starts it by reactivating. Otherwise an account with the phone number sets needs_manual_review and lists every matching account in review_user_ids, for a person to confirm the match on name and phone; user_id stays unset until then.
Activation (ActivateUser, ActivatePrequalify) matches a new member before their subscription is activated and, for a participant whose waiver has started, is not forfeited and has not ended, sends the settlement waiver (program and ends_at) with the activation, so the Subscription Service writes their first cycle at 0.00. An account already linked to a participant (one waiver per person) is left alone once that waiver has started or been forfeited; one linked at import before it was a member enrolls now, the same as an email match. Otherwise the first participant with the account’s email and no user_id gets the account’s user_id, its waiver starts (started_at now), and its review flag is cleared. After the enrollment deadline (end of January 4, 2027, US Eastern) that participant is instead unlinked and marked needs_manual_review with the account in review_user_ids, for a person to decide whether it is a cure. Failing that, the first unlinked participant with the account’s phone number gets needs_manual_review and the account added to review_user_ids.
ListSettlementReviews returns the participants with needs_manual_review (GSI4). ReviewSettlementParticipant records a person’s decision, with reviewed_by and reviewed_at, and clears the flag and review_user_ids either way. Approving sets user_id to one of the review_user_ids (an account already linked to another participant is refused), and for a current subscriber starts the waiver, now or at the cure’s attempt_at, and calls ApplySettlementWaiver; anyone else starts it by reactivating. Dismissing leaves the participant unmatched.
The subscriptions Lambda records a participant’s membership ending: on cancel-successful or account-closed for a user matched to a participant, it sets cancelled_at, and on or after the enrollment deadline also forfeited_at, once. A forfeited participant no longer has a waiver: reactivation and activation skip them (falling back to a win-back offer), so a rejoin is billed at the standard rate. A cancellation before the deadline keeps the waiver for when they rejoin.
Source: pkg/repository/settlement.go, pkg/api/settlement.go