Creator Onboarding & KYC
How a fan becomes a paid creator (a "PlayPal"). The creator verifies their identity with a government ID, sets up a Stripe Connect payout account, and adds a bank account. An admin then reviews the ID by hand. This page is the security reference for that flow: what personal data exists, where it lives, and who can read it.
Mobile screens live in
apps/mobile/components/creator-onboarding/, routed fromapp/(onboarding)/during onboarding and fromapp/(app)/creator/afterwards. The API is under/api/v1/creator-onboardingand/api/v1/admin/creator-verifications(see API → Creator Onboarding).
The steps
| # | Screen | What happens | What PlayPalz keeps |
|---|---|---|---|
| 1 | id-capture.tsx | Photo of a driver's license, state ID or passport (front, plus back for cards) | An encrypted image in the private KYC bucket |
| 2 | pii.tsx | Legal name, DOB, home address, phone, email and SSN are tokenized on the device with Stripe, then the Connect account is created | The Stripe account id and the time Stripe's terms were accepted |
| 3 | bank.tsx | Routing and account numbers are tokenized on the device with the Stripe SDK and attached as the payout account | Bank name and last 4 digits |
| — | review-pending.tsx | Submits for admin review and sets User.onboarded | CreatorProfile.verificationStatus = SUBMITTED |
The steps are defined once in apps/mobile/constants/creatorOnboarding.ts. Each screen gets its progress value and "Step N of M" label from there. creator-intro calls GET /creator-onboarding and resumes at the first unfinished step.
Background check (hidden)
The Checkr background check was descoped from the initial release. It is behind BACKGROUND_CHECK_ENABLED = false in the same constants file. While the flag is off, the step is missing from the intro list, from the step numbering and from the review screen. Re-enabling it needs a screen, a route on the step, and a backend integration. None of these exist today.
Data flow
PII data map
| Data | Where it lives | Who can read it |
|---|---|---|
| Legal name, DOB, home address, SSN | Stripe only (sent device → Stripe) | Stripe; the platform via the Stripe API/Dashboard |
| Phone and email given for payouts | Stripe (the account email is also User.email) | Same as above |
| Routing and account number | Stripe only | Stripe |
| Bank name, last 4 | CreatorProfile.bankLast4, bankName | The creator (GET /creator-onboarding), admins |
| Government ID image | KYC bucket kyc/<uuid>, encrypted | Admins only, through the audited document endpoint |
| ID wrapping metadata | VerificationAsset (iv, authTag, wrappedKey, keyVersion) | API only. Useless without KYC_ENCRYPTION_KEY |
| Stripe requirement descriptions | CreatorProfile.stripeRequirementsDue | Creator, admins. Stripe's own wording, never field values |
| Review decisions and every ID view | VerificationAccessLog | Database operators |
The API never receives SSNs, dates of birth, addresses or bank numbers. The request schemas only accept accttok_… / btok_… ids (apps/api/src/schemas/creatorOnboarding.ts). Validation errors return field names, never values. The pino logger redacts authorization, cookies, accountToken and bankToken.
Why tokens instead of our own encrypted columns
Account tokens and bank tokens are Stripe's mechanism for keeping PII off the platform's servers. They also let Stripe record the Connected Account Agreement acceptance (date, IP, user agent) directly from the device. Data we never receive cannot leak from our database, our logs or our backups.
Government ID storage
Separate private bucket
ID images never go to playpalz-media. They are stored in a dedicated bucket, playpalz-kyc (infra/terraform/main.tf), set up as follows:
acl = "private", with no CDN and no CORS. The API is the only client; devices upload through it.- No versioning, so a delete really removes the image.
- A bucket-scoped access key (
digitalocean_spaces_key.playpalz_kyc_api). The media credentials cannot read this bucket, and these credentials cannot read anything else. prevent_destroy, so a carelessterraform destroystops before it reaches this bucket.
apps/api/src/lib/kycStorage.ts has no fallback to the media bucket. If KYC_SPACES_* is unset, uploads return 503.
Obfuscation and encryption
apps/api/src/lib/kycCrypto.ts uses envelope encryption:
- The upload is re-encoded with
sharp. That applies EXIF rotation, then drops all metadata, GPS included. It also rejects anything that isn't really an image and caps the size at 2400px. - A random 256-bit data key encrypts the image with AES-256-GCM. The owner's user id is bound in as additional authenticated data, so a blob moved onto another user's row fails to decrypt.
- The data key is wrapped by the master key
KYC_ENCRYPTION_KEY(AES-256-GCM) and stored on theVerificationAssetrow. - The ciphertext is written to
kyc/<random uuid>. The key contains no user id and no file extension, and the content type isapplication/octet-stream.
Seeing an ID requires three things: the bucket object, the database row, and the master key. The master key lives only in the sealed api-secrets. It is deliberately not in Terraform, so it never lands in Terraform state.
On the device, the photo is taken with exif: false and re-encoded with expo-image-manipulator. It is never saved to the camera roll, and the local file is deleted right after upload or retake. usePreventScreenCapture blocks screenshots on all three steps.
Key rotation
- Generate a new key:
openssl rand -base64 32. - Move the current key to
KYC_ENCRYPTION_KEY_V<current version>. Set the new key asKYC_ENCRYPTION_KEYand bumpKYC_ENCRYPTION_KEY_VERSION. - Reseal
api-secretsand restart the API. New uploads use the new version, and old rows still decrypt throughKYC_ENCRYPTION_KEY_V<n>. - Remove the old key only once no
VerificationAssetrows have thatkeyVersion. Old rows age out through rejection, resubmission or account deletion. There is no bulk re-wrap job yet.
Deletion
ID images are deleted when:
- the creator retakes a side or switches document type: the old object is deleted;
- an admin rejects the verification: every image is deleted, and the creator captures again;
- the user deletes their account:
deleteUserAccountremoves the rows, then the objects.
No retention limit yet
Approved creators' ID images are kept indefinitely. Decide on a retention period, for example 90 days after approval, and add a purge job before collecting at scale.
Stripe Connect configuration
Accounts v2 (stripe@23, API version 2026-09-30.endive), created in apps/api/src/services/creatorOnboarding.ts:
| Setting | Value | Why |
|---|---|---|
dashboard | none | Creators never see a Stripe dashboard; PlayPalz owns the UX |
configuration.recipient | stripe_balance.stripe_transfers requested | Creators receive transfers, which is what anansi sends |
defaults.responsibilities | fees_collector and losses_collector = application | Required with dashboard: none. Makes PlayPalz the requirements collector, so the in-app form is allowed |
identity | country: "us" on the create call; entity_type: "individual" inside the device's account token | US individuals only for the initial release. Stripe rejects identity.entity_type when it is passed alongside account_token |
| Idempotency key | creator-acct-<userId>-<token> | A network retry can't create a second account |
User.payoutsEnabled (which anansi gates on) is true when stripe_transfers is active. Only the webhook and onboarding calls set it, both by re-reading the account from Stripe.
Platform liability
losses_collector: application means PlayPalz is liable for negative balances on creator accounts. This is the expected trade-off for a fully in-app flow. With only inbound transfers there's little to go negative, but keep it in mind if creators ever take direct charges.
Restricted key. Run the API on a restricted key (rk_…) with only Connect write access (Accounts v1 and v2, external accounts), not the account's full secret key. Anansi needs its own key with transfer and top-up permissions.
Admin review
Reviewers use the admin panel (apps/admin) or the API directly. The endpoints are listed in API → Creator Onboarding. They require User.role = "admin", the same rule apps/web/middleware.ts uses.
- Review only ever touches
type = "id_document"assets. A creator's reference selfie is biometric data and can't be viewed or deleted from here. - Viewing an ID writes an audit row first. If the audit write fails, the image isn't served.
- Decrypted images are sent with
Cache-Control: no-store. They are never written to disk or put behind a signed URL. - Approve sets
verificationStatus = APPROVEDanduserType = "playpal", and assigns theplaypalbadge. Approval is the only thing that turns someone into a creator. - Reject records the reason and deletes the ID images.
- Both decisions send the applicant a
SYSTEMnotification (push and in-app) whose target opens the application status screen. These are account notices (preferenceKey: null), so only the master push switch silences them.
Applying later, and the status indicator
Users who chose "Just a Player" can apply at any time from Settings → Creator → Become a PlayPal. It runs the same step screens as onboarding (components/creator-onboarding/) under (app)/creator/, with flow="app":
- The intro adds the creator perks and a "Not now" that just goes back. Users under 18 (by
User.birthdate) see an age notice instead of the start button, since approval refuses minors. reviewsubmits only when it is reached from the bank step (?submit=1). Opened anywhere else, it is the application's status page. It shows the rejection reason with Resubmit ID (which resumes at ID capture; the Stripe account and bank stay in place), or Open Creator Studio once approved.
Until approval the app treats an applicant as a fan: creator features check userType === "playpal", which only approval sets. While an application is underway, CreatorStatusCard shows where it stands, whichever way the user started:
| Status | Me tab card | Settings → Creator row |
|---|---|---|
NOT_STARTED | — | "Become a PlayPal" → intro |
IN_PROGRESS | "Finish your PlayPal application", steps done → next step | same |
SUBMITTED | "In review" with a pulsing dot → status page | same |
REJECTED | "Action needed" with the reason → status page | same |
APPROVED | — (the user is a PlayPal) | — |
useCreatorApplication skips the request for PlayPals, refetches on focus when older than 30 s, and is invalidated when a creator_verification notification arrives over the socket or is tapped. When the application reads APPROVED but the signed-in user is still a fan, it re-reads the user so Creator Studio and Earnings appear without a re-login.
