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.

data model

{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

USERS#<user_id>

USER_INFORMATION#<date_updated unix>

USER

pkg/repository/users.go

Membership

USERS#<user_id>

TIER#<start_date RFC3339>

MEMBERSHIP

pkg/repository/memberships.go

Tag

USERS#<user_id>

TAG#<tag_name>

—

pkg/repository/tags.go

Consent agreement

USERS#<user_id>

CONSENT#<consent_key>#<timestamp>

—

pkg/repository/consent_agreement.go

SMS agreement

USERS#<user_id>

PREFERENCES#SMS_AGREEMENT#<timestamp>

—

pkg/repository/sms_agreement.go

MVP beta agreement

USERS#<user_id>

MVP_BETA_AGREEMENT#<timestamp>

—

pkg/beta/beta.go

Data deletion request

USERS#<user_id> or USERS#anon-<sha256(email)[:16]>

DATA_DELETION#<requested_at RFC3339>

DATA_DELETION

pkg/repository/data_deletion.go

User update

USERS#<user_id>

USER_UPDATE#<field>#<date_updated RFC3339>

USER_UPDATE

pkg/repository/user_updates.go

Prorated subscription

USERS#<user_id>

PRORATION#<proration_id>

PRORATED_SUBSCRIPTION

pkg/repository/prorated.go

Survey response

SURVEY

INGESTED#<timestamp>#ID#<uuid v7>

—

pkg/repository/survey.go

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

user_id

Auth0 user identifier

user_email

Email address

user_first_name, user_last_name

Name

user_address, user_city, user_state, user_zip_code

Postal address

user_phone

10-digit phone number

user_status

Account status — User Status

user_install_id

Firebase messaging install ID

user_firebase_instance_id

Firebase instance ID

user_appsflyer_id

AppsFlyer attribution ID

user_app_platform, user_app_version, user_app_build_number

Client platform (ios, android) and build

user_date_joined, user_date_updated

Creation and last-update timestamps

tags

Map of tag name → Tag, populated on read from the Tag rows

event_type

USER_CREATED, USER_ACTIVE (PROCESSING → ACTIVE) or USER_UPDATED

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

membership_user_id

User identifier

membership_tier

Tier name, upper-cased (BASE, PLUS, …)

membership_tier_version

GrowthBook tier version at the time of the record (v1, …)

membership_term

MONTHLY or ANNUAL

membership_status

Membership Status; empty means good standing

membership_event_source

Actor that produced the record — Event source

event_type

Membership Event Type

membership_confirmation_id

Payments Service confirmation of the upgrade charge

membership_subscription_id

Subscription Service identifier

membership_is_pending_downgrade, membership_downgrade_date

Scheduled downgrade — Membership Downgrades

membership_start_date

Record timestamp (also the SK)

membership_reactivation_date

Most recent reactivation — Reactivation

membership_waiver_program

Waiver program the most recent reactivation was waived under; absent when it was charged. The win-back repeat rule reads it, with membership_reactivation_date, across the user’s memberships — Collect-on-Reactivation

membership_pause_date, membership_unpause_date, membership_pause_duration_months

Pause window; duration -1 = indefinite — Membership Pause

Source: pkg/repository/memberships.go

Membership Status

Value Meaning

(empty)

Good standing

PENDING_CANCELLATION

Cancellation scheduled for the end of the billing term

CANCELLED

Account closed

SUB_PENDING_PAUSE

Pause requested; takes effect at the next collection date

SUB_PAUSED

Collection skipped; membership paused

SUB_PENDING_RESUME

Resume requested after a full pause

SUB_RESUMED

Resumed

SUB_ACTIVE

Subscription active

BANNED, INVESTIGATE

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

CANCEL

Scheduled cancellation — Cancellation

CLOSEACCOUNT

Instant account closure — Cancellation

RETRACT

Pending change retracted (RetractMembershipEvent)

REACTIVATE

MX reactivation — MX Reactivation

MANUAL_REACTIVATION

Collect-on-reactivation — Collect-on-Reactivation

MX_UNBLOCK

Ban or investigation cleared — Clear

SUB_PAUSED

Pause requested — Pause

PAUSE_COLLECTION_SKIPPED

Collection skipped while paused — Subscription worker

UNPAUSE, UNPAUSE_CHARGE

Manual unpause, with a catch-up charge when a collection was already skipped — Unpause

SUB_RESUMED

Automatic resume — Subscription worker

BANNED, INVESTIGATE

Ban and flag — Bans & Fraud Signals

PAYNOW

Defined; no code path in this service writes it

MEMBERSHIP_UPGRADE

Tier upgrade — Upgrade

MEMBERSHIP_DOWNGRADE, MEMBERSHIP_DOWNGRADE_FINALIZED, MEMBERSHIP_RETRACT

Downgrade lifecycle — Membership Downgrades

Source: pkg/repository/memberships.go, pkg/subscriptions/handler.go

Tag

Attribute Description

tag_name, tag_value

Key and string value

tag_archived

Soft-deleted when true; GetUserTags can include or exclude archived tags

added_on

RFC3339 write timestamp

Tags used by this service

Tag Values Set / read by

START_DATE

RFC3339

Set at account creation (AddUser, CreateUser)

MEMBERSHIP_PAUSE_STATUS

SUB_PENDING_PAUSE, SUB_PAUSED, SUB_PENDING_RESUME, SUB_RESUMED

Pause state; archived when resumed

PAUSE_SUB_DISABLED

any, unarchived

Read only: blocks pausing — Eligibility

is_military

true / false

Set by UpdateUser when is_military is in the body

MEMBERSHIP_WAIVER_OFFER

WaiverOffer JSON (program_name, cohort, offer_length, expires_at, granted_at, granted_by, waiver_used_at, withdrawn_at, withdrawn_by)

Set by GrantWaiverOffer for a test cell with an offer; read on reactivation, which skips float payback and asks the Subscription Service to waive the fee while it is unarchived and unexpired, then sets waiver_used_at and archives it. WithdrawWaiverOffer (backoffice) sets withdrawn_at and withdrawn_by on an unarchived offer and archives it

OFFER_COHORT

OfferCohort JSON (cohort, cell, offered, notification_event, assigned_at, assigned_by)

Set by GrantWaiverOffer for every user placed in a reactivation offer test cell, with or without an offer or an Iterable event, so reactivation can be reported per cell; replaced when the user is assigned again

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

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_id, item_id

User and, for billing consent, the related item (empty from ActivatePrequalify)

consent_key

Agreement identifier supplied by the client (billing, terms, …)

agreement_timestamp

Server write time

agreement_approval_date, agreement_ip_address, agreement_app_version

Client-supplied acceptance details

sms_active, sms_agreement_timestamp

SMS agreement rows only

beta_agreement_timestamp

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

data_deletion_user_id, data_deletion_email

Requester; email is canonicalised (trimmed, lower-cased) and only stored for anonymous rows

data_deletion_scope

full or marketing

data_deletion_source

app, website or backoffice

data_deletion_status

pending, completed, partial_failure

data_deletion_requested_at, data_deletion_completed_at

Timestamps; requested_at is also the SK

data_deletion_vendor_results

Map vendor → ok, skipped: … or error: …

data_deletion_zendesk_ticket_id

Originating ticket; 0 for app and backoffice

event_type

DATA_DELETION_REQUESTED

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.

user-balance-settings

user_id (PK), created_at, low_balance_alert (cents). Written by UpdateUser when low_balance_alert is in the body; returned on GetUser.

{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

PK / SK

CLAIMANT#<claimant_id> / PARTICIPANT

GSI1PK

EMAIL#<email, trimmed and lower-cased>

GSI2PK

USERS#<user_id>, once matched to an account

GSI3PK

PHONE#<10 digits>, when the phone number has digits

GSI4PK

NEEDS_MANUAL_REVIEW, only while needs_manual_review is true (sparse: the review queue)

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