User Lifecycle

A user is created in PROCESSING, becomes ACTIVE once a bank account, debit card and subscription exist, and leaves ACTIVE through cancellation, a ban or an investigation. The status is stored as employment_validation in PostgreSQL and as user_status on the DynamoDB User row. This page also covers signup, activation, profile updates, cancellation and the small profile-adjacent flows (KYC, consent, SMS opt-in, Fin JWT, tags).

User Status

user status
Status Meaning

PROCESSING

Account created; no floats, no billing

ACTIVE

Bank account, primary debit card and subscription in place; eligible for floats and billing

PAUSED

Membership cancelled or ban/investigation cleared; the app shows the reactivation screen; not billed

INVESTIGATE

Flagged by MX; no floats, not billed, cannot reactivate

BANNED

Banned by MX, a chargeback (only while ENABLE_CHARGEBACK_BAN is on) or the related-accounts threshold; Auth0 account blocked

Source: pkg/repository/users.go

From To Trigger

PROCESSING

ACTIVE

Activation

ACTIVE

PAUSED

Cancellation (instant or at the end of the billing term)

PAUSED

ACTIVE

Reactivation

any

BANNED

Ban (MX, chargeback while ENABLE_CHARGEBACK_BAN is on, related accounts)

any

INVESTIGATE

Flag

BANNED, INVESTIGATE

PAUSED

Clear

any

any

AdminUpdateUser may set status directly

Signup

POST /users/create (CreateUser) is the signup entry point, called by the signup web app through the Cloudflare Workers (edge repo) as the {env}-cloudflare-workers IAM user in test and prod and the staging-cloudflare-workers-role role in staging, and by the mobile app.

  1. The phone number is reduced to digits; app_platform falls back to the X-Floatme-Phone-Os header; status defaults to PROCESSING.

  2. With a layer_token the Transactions Service creates the Plaid items from the Layer stub (422 LAYER_CONNECTION_FAILED on failure) and the phone checks are skipped.

  3. Without one, two checks run concurrently: the phone number must not already exist (409 PHONE_NUMBER_EXISTS) and validation_token must match the one issued by ValidateAccessCode (403 VALIDATION_TOKEN_REQUIRED / INVALID_VALIDATION_TOKEN). When both fail the reported status is whichever check finished last.

  4. The user row is written (500 UNEXPECTED_ERROR on failure).

  5. For signup-app callers the phone number is first saved synchronously to user-phone-numbers; a failure is a 500 even though the user row exists.

  6. Best effort, concurrently, for every caller: Auth0 auth_require_mfa app metadata, the START_DATE tag, the SMS agreement when sms_authorized, and a second user-phone-numbers write of the same number.

  7. Layer signups continue into activation; a failure there is 500 UNEXPECTED_ERROR.

  8. 201 with an empty body. Metric user.signup tagged platform:<app_platform>.

POST /users (AddUser) is the internal variant: it writes the user with the status in the body, the START_DATE tag and the SMS agreement, and returns 200.

In the test environment three fixed phone numbers (pkg/api/utils.go, isWaldo) skip the duplicate and token checks.

Activation

Both endpoints require PROCESSING (409 otherwise; an ACTIVE user is a 202 no-op), match the account to a settlement participant (best effort: a failure is logged and activation goes on), activate the subscription in the Subscription Service (502 on a non-200), write the membership record (the current one, or BASE / MONTHLY at the current GrowthBook tier version when none exists), set the user ACTIVE, and emit user.activated tagged flow: and platform:.

POST /{user_id}/user/activate

Used by the mobile app and by Layer signups. Before activating it checks the Transactions and Payments services: at least one active Plaid item (400 NO_LINKED_ITEMS), an item with a main account (NO_MAIN_ACCOUNT), an active debit card (NO_DEBIT_CARD) and a primary card (NO_PRIMARY_DEBIT_CARD). Metric tag flow:legacy.

POST /{user_id}/user/activate/prequalify

Used after the prequalification onboarding, where the bank account and card were connected before this call and are not re-checked. It records the billing consent from the body (consent_key, IP, app version, approval date) as a Consent agreement, fires the first-debit-card event, then activates. After activation it persists every active Plaid item in the Transactions Service, which removes the expiry the prequalify item was saved with; a failure is logged and counted in user.activation.item_persist.outcome. It then asks the Underwriting Service to recalculate; a failure there is logged and counted in user.activation.underwriting_recalc.outcome. Metric tag flow:prequalify; every request also records user.activation.outcome and user.activation.duration (Metrics).

First Debit Card Event

first_debit_card_connected (first_debit_card_connected_test in test) is the marketing attribution signal sent once per active lifecycle (account closure re-arms it, see below), from prequalify activation and collect-on-reactivation.

  1. Acquire the user’s first_debit_card_connected marketing lock in the Payments Service; a conflict means the event is already in flight or was already sent, and the call returns.

  2. Send the Segment event (appsflyer_id, app_platform, and the user’s Firebase app instance ID firebase_instance_id as firebase_install_id, sent even when blank); a failure releases the lock so the next attempt retries.

  3. Send the AppsFlyer event with the iOS or Android client chosen from app_platform; unknown platforms and AppsFlyer errors are logged and do not release the lock.

  4. The whole send is bounded to 5 seconds; on timeout the lock is kept.

The appsflyer_id comes from the user record, falling back to the X-Floatme-Appsflyer-Id header. Account closure releases the lock so the event fires again after a reactivation.

Profile Reads

GET /users/{user_id} returns the profile with the low-balance alert (user-balance-settings), all tags (archived ones included) and the Insight Service income-verification record (is_manual_verification is true while that record is pending). ?include_email_verified=true adds Auth0’s email-verified flag (assumed true when Auth0 cannot be reached). GET /{user_id}/user/info adds the current membership (or the base default), the primary bank account from the Transactions Service’s active items (a Plaid error code is passed through) and the full income-verification record.

Lookups by email (GET/POST /users-by-email), phone (/users-by-phone/{phone}), join date (/users-by-date-joined) and the search endpoints (/users/search, /users/search/address) read PostgreSQL.

Profile Updates

PATCH /users/{user_id} (UpdateUser) is the self-service update; POST /users/admin/{user_id} (AdminUpdateUser) is the MX variant. Both validate the body (400; UpdateUser answers VALIDATION_FAILED with the validator text) and apply the X-Floatme-Phone-Os header as the app_platform fallback.

UpdateUser:

  1. Load the user (404), then score the request with Castle — Castle. With ENABLE_CASTLE_BLOCKING a Castle failure is 500 and a deny verdict or missing verdict is 401.

  2. A changed phone number removes the user’s Auth0 SMS enrollments, updates user-phone-numbers and sends the Iterable user_personal_info_updated event (changed_field: phone number).

  3. A changed email must not belong to another user (400 EMAIL_IN_USE); Auth0 is updated and the Iterable event goes to both the old and the new address.

  4. Any address field is subject to the address cooldown (409 when inside it).

  5. is_military writes the tag of that name; low_balance_alert writes user-balance-settings.

  6. The user row is updated (USER_UPDATED event); a name change sends the Iterable event (name); an address change writes a User update row and sends the event (address).

  7. 202 with an empty body.

AdminUpdateUser skips Castle and the cooldown, may change status, rejects a phone number that belongs to another user (409) and an email in use (400; 500 when that lookup fails), and otherwise performs the same Auth0 and row updates.

Address Cooldown

GET /{user_id}/user/check?field=address reports whether the field can change now: the newest USER_UPDATE#address# row plus USER_UPDATE_WINDOW_DAYS must be in the past. The response carries date_of_last_change, date_of_next_change and days_until_next_change. The window is 0 days in both environments, so the check always allows the change.

Cancellation

Operation Effect

POST /{user_id}/user/membership/cancel (CancelMembership)

Scheduled cancellation: the current membership is re-written with status PENDING_CANCELLATION and event CANCEL; idempotent when already pending or cancelled. The Subscription Service calls CancelAccount when the billing term ends.

POST /{user_id}/user/close-account (CloseAccount), POST /{user_id}/user/cancel (CancelAccount)

Instant closure (below).

POST /{user_id}/user/membership/retract (RetractMembershipEvent)

Writes the current membership again with event RETRACT and an empty status, undoing a pending change.

Instant closure:

  1. An unknown user or a BANNED user is a 200 no-op.

  2. HasActiveFloat (PostgreSQL) decides whether cleanup runs; a user with an active float is cancelled but keeps their debit card and Plaid items.

  3. The membership is re-written with status CANCELLED, event CLOSEACCOUNT and the event source; the user becomes PAUSED.

  4. The first_debit_card_connected and first_float_accepted marketing locks are released in the Payments Service (best effort; a failure is logged with the event name). A released lock lets its event be sent again when that event’s trigger next occurs.

  5. When cleanup runs: a full-scope message is queued for the janitor (Plaid items, Auth0 block, Array cleanup) and the debit card is deleted in the Payments Service (failure logged).

  6. Segment cancellation_finished is sent with the user’s email; 200.

Typeform Cancellation

The webhook-handler verifies the Typeform HMAC signature (Typeform-Signature, sha256= prefix) and queues the payload on {env}-typeform-events. The typeform Lambda handles forms titled "FloatMe Cancellation Process" whose confirmation field is true: it looks the user up by the email answer and calls CloseAccount. A 409 with error_code 6 (active float) sends the Segment event cancellation_outstanding_float (email, amount, fees, repayment date, total due) from the Float Service’s active float; code 5 (card not closed) is logged.

Event Source

membership_event_source records who caused a membership write. When the request body has no source, it is derived from the API Gateway caller identity:

Caller contains Value

admin-api

MX

ollie

Ollie

user-service (the typeform Lambda)

typeform

subscription-service

(empty — the previous record already carries the source)

no caller identity

in app

anything else

unknown

Source: pkg/api/cancellation.go

KYC

The four /{user_id}/user/kyc* operations proxy the Transactions Service: CreateKycSession sends the KYC_TEMPLATE_ID template with the user’s name, email, +1 phone and address; GetKycLinkToken, GetKycSession and KycSessionCompleted pass through, answering 404 KYC_SESSION_NOT_FOUND when the Transactions Service has no session.

POST /{user_id}/consent/{consent_key} records one agreement, POST /{user_id}/consents records several in one batch write, and GET /{user_id}/consent/{consent_key} returns the newest agreement for the key (404 when none). Each saved agreement is sent to Segment as consent_agreement_signed. A request without a body is 422. Storage in DynamoDB Tables. GET/POST /{user_id}/beta/mvp record and report the MVP beta agreement the same way (Segment mvp_beta_agreement_signed).

SMS Opt-In

The endpoint is described in the API Specification. Metric user.sms_opt_in.failure (tag reason:).

Fin JWT

GET /{user_id}/fin/jwt issues an HS256 JWT signed with the {env}/floatme/fin secret for Intercom Fin identity verification: claims user_id, email, exp (1 hour), plus name (first name) and phone when non-empty. Metrics user.fin.jwt.issued and user.fin.jwt.failure (tag reason:).

Tags

GET /{user_id}/tags lists every tag, archived ones included (each carries archived); POST /{user_id}/tags upserts every tag in the body map and answers 200 even when individual writes fail (logged). Known tags are listed in DynamoDB Tables.

Metrics

Metric Emitted

user.signup

Per created user, platform: tag

user.activated

Per PROCESSING → ACTIVE transition, flow:legacy or flow:prequalify, platform: tag

user.activation.outcome

Per POST /{user_id}/user/activate/prequalify request, flow:prequalify and outcome: one of success, already_active, invalid_status, user_not_found, get_user_error, consent_error, subscription_error, subscription_bad_gateway, membership_fetch_error, membership_add_error, user_update_error; a malformed body is rejected before the handler runs and is not counted

user.activation.duration

Handler latency in seconds for the same requests, flow:prequalify, outcome: and platform: tags

user.activation.item_persist.outcome

Per prequalify activation, flow:prequalify, outcome:success or outcome:error for persisting the user’s Plaid items

user.activation.underwriting_recalc.outcome

Per prequalify activation, flow:prequalify, outcome:success or outcome:error for the post-activation underwriting recalculation

Error_creating_user_in_RDS

User row write failure

floatme.user.update.install_id

UpdateUser with a changed install_id

user.fin.jwt.issued, user.fin.jwt.failure

Fin JWT outcomes