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
| Status | Meaning |
|---|---|
|
Account created; no floats, no billing |
|
Bank account, primary debit card and subscription in place; eligible for floats and billing |
|
Membership cancelled or ban/investigation cleared; the app shows the reactivation screen; not billed |
|
Flagged by MX; no floats, not billed, cannot reactivate |
|
Banned by MX, a chargeback (only while |
Source: pkg/repository/users.go
| From | To | Trigger |
|---|---|---|
|
|
|
|
|
Cancellation (instant or at the end of the billing term) |
|
|
|
any |
|
Ban (MX, chargeback while |
any |
|
|
|
|
|
any |
any |
|
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.
-
The phone number is reduced to digits;
app_platformfalls back to theX-Floatme-Phone-Osheader;statusdefaults toPROCESSING. -
With a
layer_tokenthe Transactions Service creates the Plaid items from the Layer stub (422 LAYER_CONNECTION_FAILEDon failure) and the phone checks are skipped. -
Without one, two checks run concurrently: the phone number must not already exist (
409 PHONE_NUMBER_EXISTS) andvalidation_tokenmust match the one issued by ValidateAccessCode (403 VALIDATION_TOKEN_REQUIRED/INVALID_VALIDATION_TOKEN). When both fail the reported status is whichever check finished last. -
The user row is written (
500 UNEXPECTED_ERRORon failure). -
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. -
Best effort, concurrently, for every caller: Auth0
auth_require_mfaapp metadata, theSTART_DATEtag, the SMS agreement whensms_authorized, and a seconduser-phone-numberswrite of the same number. -
Layer signups continue into activation; a failure there is
500 UNEXPECTED_ERROR. -
201with an empty body. Metricuser.signuptaggedplatform:<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.
-
Acquire the user’s
first_debit_card_connectedmarketing lock in the Payments Service; a conflict means the event is already in flight or was already sent, and the call returns. -
Send the Segment event (
appsflyer_id,app_platform, and the user’s Firebase app instance IDfirebase_instance_idasfirebase_install_id, sent even when blank); a failure releases the lock so the next attempt retries. -
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. -
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:
-
Load the user (
404), then score the request with Castle — Castle. WithENABLE_CASTLE_BLOCKINGa Castle failure is500and adenyverdict or missing verdict is401. -
A changed phone number removes the user’s Auth0 SMS enrollments, updates
user-phone-numbersand sends the Iterableuser_personal_info_updatedevent (changed_field: phone number). -
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. -
Any address field is subject to the address cooldown (
409when inside it). -
is_militarywrites the tag of that name;low_balance_alertwritesuser-balance-settings. -
The user row is updated (
USER_UPDATEDevent); a name change sends the Iterable event (name); an address change writes a User update row and sends the event (address). -
202with 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 |
|---|---|
|
Scheduled cancellation: the current membership is re-written with status |
|
Instant closure (below). |
|
Writes the current membership again with event |
Instant closure:
-
An unknown user or a
BANNEDuser is a200no-op. -
HasActiveFloat(PostgreSQL) decides whether cleanup runs; a user with an active float is cancelled but keeps their debit card and Plaid items. -
The membership is re-written with status
CANCELLED, eventCLOSEACCOUNTand the event source; the user becomesPAUSED. -
The
first_debit_card_connectedandfirst_float_acceptedmarketing 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. -
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).
-
Segment
cancellation_finishedis 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 |
|---|---|
|
|
|
|
|
|
|
(empty — the previous record already carries the source) |
no caller identity |
|
anything else |
|
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.
Consent
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 |
|---|---|
|
Per created user, |
|
Per |
|
Per |
|
Handler latency in seconds for the same requests, |
|
Per prequalify activation, |
|
Per prequalify activation, |
|
User row write failure |
|
|
|
Fin JWT outcomes |