Skip to content

KYC

Document identity verification behavior for NSFW character creation.

The Explore 18+ category is offered once an account has ever held an activated subscription (hasSubscribed). A character is not required. Abandoned checkouts without an activated subscription do not qualify; former subscribers do.

The category can be listed before age confirmation, but its media is served only after the account either confirms it is 18 or older or has approved identity verification. The same subscription and age rules control the 18+ content toggle. Existing reviewer restrictions and payment-processor demo rules remain.

The profile 18+ of myself Verify entry also uses subscription history, so current and former subscribers can enter the existing KYC flow before creating a character. Never-subscribed accounts do not see that entry. Existing verification, character creation, and generation requirements are unchanged.

Selecting an 18+ Explore preset with subscription history and age proof opens the VI/RI chooser when the nsfw-onboarding flag is enabled, the character list has loaded, and the account has no finished character. The RI option requires approved account KYC; otherwise it offers Verify and opens the same existing onboarding flow as the profile menu, including for awaiting or rejected KYC. The VI option opens the shop. Approved users resume their unfinished 18+ RI draft, including the one gifted by KYC approval, rather than an unfinished SFW draft. SFW preset handling is unchanged. See apps/brisket/src/features/explore-page/hooks/use-handle-select-example.tsx and apps/brisket/src/features/explore-page/components/character-required-dialog/.

The character purchase dialog offers the same Verify path. With the nsfw-onboarding flag enabled and 18+ content on, its character type picker appears for approved account KYC and for accounts with subscription history and age confirmation. Before KYC, 18+ of myself shows Verify and opens the same existing onboarding flow instead of buying a character slot. 18+ AI influencer still requires approved KYC, because the legacy KYC start always records a real-person pick. See apps/brisket/src/features/character/components/character-purchase-dialog/index.tsx and apps/brisket/src/server/api/routers/kyc.ts.

See apps/sirloin/internal/app/services/media/taghelpers.go, apps/sirloin/internal/app/services/billing/haseversubscribed.go, and apps/brisket/src/features/character-page-ccv4/hooks/use-nsfw-onboarding.tsx.

  • Brisket initiates KYC-related user action and displays verification status.
  • Sirloin owns account-level KYC state relevant to NSFW access.
  • Ondato handles external identity verification.
  • S3 stores KYC image artifacts used for NSFW character verification.
  • Clerk supplies the user email that Sirloin passes to Ondato for the verification record.
  • Strip displays account-level KYC status with links to the Ondato IDV page and Operator Station.
sequenceDiagram
  participant Brisket
  participant Sirloin
  participant Clerk
  participant Ondato
  participant S3
  Brisket->>Sirloin: Start KYC verification
  Sirloin->>Clerk: Look up user email
  Sirloin->>Ondato: Create verification (with email)
  Sirloin-->>Brisket: verification URL / ID
  Brisket->>Ondato: User completes verification
  Ondato-->>Sirloin: Verification callback/status
  Sirloin->>S3: Store KYC image artifact
  Sirloin->>Sirloin: Gift draft NSFW real character once

The first onboarding screen (/characters?n=0) calls CheckKYCVerification once when it mounts. The protected Brisket query supplies the signed-in user’s ID; Sirloin reads only that account’s current session using the existing Ondato CheckIdentification client. The check does not create sessions, synchronize approval, or grant access.

An Ondato identification that is awaiting processing or approved but not yet synced to the account displays Verification pending on the intro. Its Continue button returns to Explore. No identification yet, or an awaiting identification with reason Unfinished, retains the existing verification flow, including after abandonment or expiry before submission. Rejected attempts keep the existing retry flow. Accounts already approved in Sirloin keep their existing onboarding behavior without a provider lookup.

Continue is disabled while the check loads; a failed or unknown provider response offers Try again instead of advancing to verification. There is no polling or second check inside StartKYCVerification; changes in another tab after this check are not detected. Starting a session still happens only when the user clicks Start verification on the next screen.

See apps/brisket/src/features/character-page-ccv4/views/nsfw-onboarding/steps/intro/index.tsx and apps/sirloin/internal/app/services/kyc/checkkycverification.go.

When Sirloin starts a KYC verification (startkycverification.go), it fetches the user’s primary email from Clerk and sends it in the email field of Ondato’s POST /v1/identity-verifications request. This makes the user email visible in the Ondato dashboard for identity management. If the Clerk lookup fails (e.g. missing API key in test environments), the email is omitted from the request and verification continues normally.

Ondato returns the user to Brisket’s /verification page. For a signed-in user that page polls subscription.getCurrentUsage every 3 seconds for up to 60 seconds (MAX_RETRIES * RETRY_INTERVAL in apps/brisket/src/app/verification/page.tsx) and reacts to kyc_identification_status:

  • APPROVED — redirect into NSFW onboarding.
  • REJECTED — the “Verification Failed” screen, which invites another attempt.
  • neither yet — a spinner for the first 15 seconds (SHOW_PENDING_AFTER), then the “Verification in progress” screen.

The wait is therefore two-stage. At 15 seconds the spinner gives way to the pending screen, but polling does not stop there: it continues behind that screen for the rest of the 60-second budget, so an APPROVED redirect or a REJECTED failure screen can still arrive while the user is reading it. If the budget runs out first, the pending screen simply stays as the terminal state.

The pending screen exists because Ondato often takes longer than the poll budget to produce a verdict. A timeout is not a rejection, so it must never be shown as one: users who are actually approved would otherwise reset and redo verification, and Ondato refuses the second attempt as a duplicate. The pending copy states that the account updates itself and deliberately does not offer a retry. The account menu shows a “Verified” label alongside the 18+ toggle once account KYC is approved, so “Reset verification” cannot be read as the retry affordance.

Strip surfaces account-level KYC details on the user details modal (userdetails.templ). The StripUser message includes four fields populated from users.kyc_verifications (the current row, is_current=TRUE):

  • ondato_verification_id — links to the Ondato IDV verification page
  • ondato_kyc_identification_id — links to the Ondato Operator Station KYC view
  • kyc_status — awaiting, approved, rejected, or reset
  • kyc_status_reason — free-text reason (e.g. DuplicatedInfo)

These are populated via scalar subqueries in StripListUsers so they add no additional round-trips. The operator UI shows a status badge and conditional links to Ondato IDV and OS.

Strip KYC bypass is account-level. Eligible operators can approve the current user KYC row from the user details modal; Sirloin records a synthetic strip-... Ondato verification ID with status=approved. The operator controls and badges carry distinct names, and they are not interchangeable:

  • Bypass KYC — the button on the user details modal that creates the bypassed row (apps/strip/internal/app/templates/details/userdetails.templ:139). Eligibility is permissive outside production and restricted to listed domains and beta addresses in production (apps/sirloin/internal/app/services/strip/kycpolicy.go:8).
  • Strip bypass — the badge shown when the current verification id carries the strip- prefix (apps/strip/internal/app/templates/details/userdetails.templ:152).
  • KYC bypass active — the status line beside the KYC badge.
  • Likeness bypass / Bypass likeness for 24h — a character-level, time-boxed control on the character details modal (apps/strip/internal/app/templates/details/characterdetails.templ:204-233). It affects legacy Kitsune onboarding scoring, not account KYC and not recipe-driven identity validation. Do not read it as the same switch.

A bypassed row is approved but carries no selfie, so kyc_image_path stays empty. In the recipe-driven adult flow that does not skip the face comparison: it substitutes the reference with the character’s own bound generation/face_frontal asset and records the anchor as strip-bypass:<account id>, which cannot collide with an Ondato identification id (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:120-156). See Character Adult Identity Validation.

Character-local Ondato columns remain for compatibility with in-flight sessions and historical fallbacks only. Strip account-level sync lives on the user details modal; the old character-level Ondato sync route and control have been removed.

kyc_image_path on the current approved row is a mutable pointer, not a handle on the Ondato artifact. Approving a contested likeness review copies the approved face_frontal image to users/<user id>/kyc/likeness-reference.jpg and repoints the row at it (apps/sirloin/internal/app/services/strip/striplikenessreviews.go:473, apps/sirloin/internal/pkg/storage/userkycverifications.go:310), so the account stops failing likeness checks against a stale selfie. The Ondato selfie is keyed by verification id and is not deleted, so the original artifact survives the promotion. Note that this account-level column now has a second consumer: the recipe-driven adult flow reads it as its identity reference. Recipe-driven characters cannot request a review, so they never cause a promotion, but an account promoted while it held a legacy character keeps that pointer after the legacy character is gone. See Character Adult Identity Validation and Contested Likeness Review.

Account KYC is authoritative for current KYC actions and NSFW gates. Approved account KYC does not cascade verification state to character records; it does idempotently create one draft NSFW real character for users without an existing NSFW character. The KYC handler can reuse a verified character’s Ondato verification ID only when the user lacks their own current account KYC record.

Ondato can reject a KYC identification with status_reason=DuplicatedInfo. That reason alone is not enough to approve local account KYC. Sirloin only promotes Rejected + DuplicatedInfo to approved account KYC when the same user_id already has an approved, real Ondato-backed KYC row in users.kyc_verifications. Without that same-user prior approval, Sirloin stores the result as rejected and keeps kyc_status_reason=DuplicatedInfo visible in Strip.

CS checklist for duplicate reports:

  • Check Strip user details first; account KYC is the source of truth.
  • If Strip shows approved with DuplicatedInfo, the duplicate matched a prior approved KYC for that user and the user should not be sent through KYC again.
  • If Strip shows rejected with DuplicatedInfo, the duplicate was not auto-approved; escalate for manual review instead of treating the Ondato duplicate as a pass.
  • Use Sync from Ondato when Strip looks stale and a real Ondato verification or KYC identification is available.
  • True duplicate current-account merge/linking is separate future work.

Rules here come from the vendor, not from Beef code. Treat them as the vendor contract that our handling must follow.

One Ondato identification can hold several attempts, and each attempt keeps its own photos. Only the latest attempt, the one with the highest number, is valid. An approved status applies only to that attempt’s photos. If an identification’s highest attempt is 3, the approval covers attempt 3 photos. Photos from earlier attempts are not approved and are not identity evidence.

Ondato reviews each attempt independently, and its reviewers cannot see earlier attempts. Different people can therefore appear across attempts of one identification. The extracted document data belongs to the approved attempt.

Sirloin follows this rule when it stores the KYC selfie:

  • Every Ondato face and document file lists its attempt numbers in usedForVerificationAttempts. The latest attempt is the highest number across all of them.
  • Sirloin keeps only the face files of that attempt (apps/sirloin/internal/pkg/ondato/client.go:511).
  • From those, it takes the FaceAnimation file. If the attempt has none, it takes a still JPEG or PNG face image (apps/sirloin/internal/pkg/ondato/client.go:531).
  • If the latest attempt has neither, Sirloin never falls back to a face from an earlier attempt. It clears kyc_image_path on the current approved account KYC row and logs an error. Without a selfie the account has no usable character identity, so 18+ is blocked until the user verifies again (apps/sirloin/internal/pkg/models/userkycverification.go:67).
  • Strip Sync from Ondato reports that case to the operator as a failed precondition, after it records the sync and a kyc_selfie_missing audit field. A failure to reach Ondato, download, convert, or upload the selfie keeps the stored path.

Both the Ondato webhook and Strip Sync from Ondato use this selection. Selfies stored before this rule was applied came from the earliest attempt and are not re-synced automatically.

Source: Ondato support, confirmed on TRUCOM-62 and accepted as a third-party rule on 2026-10-15.

  • apps/brisket/src/server/api/routers/kyc.ts
  • apps/brisket/src/app/verification/page.tsx
  • apps/sirloin/internal/app/services/characters/createcharacter.go
  • apps/sirloin/internal/app/services/kyc/startkycverification.go
  • apps/sirloin/internal/pkg/storage/characters.go
  • apps/sirloin/internal/pkg/ondato/client.go

Users start verification from Brisket’s api.kyc router. Sirloin records the Ondato verification ID and KYC state in users.kyc_verifications, with one current row per user. Character-local Ondato columns remain for compatibility with in-flight sessions and legacy fallback reads, but account KYC is authoritative for new NSFW gates.

Users may reset approved account KYC only before any successful 18+ generation on an active 18+ character. Eligibility and the reset RPC both count non-deleted NSFW media with MEDIA_STATUS_AVAILABLE on non-deleted 18+ characters; deleted media and media on deleted 18+ characters do not block reset. There is no manual-photo marker requirement.

  • NSFW character creation requires approved account KYC.
  • Sirloin owns the account KYC state that gates NSFW character creation and generation.
  • Strip test bypass approves account KYC, not a single character.
  • A bypassed account KYC row has no selfie; downstream identity checks substitute a reference rather than skipping the check, and the substitution is legible in the recorded anchor.
  • Approved account KYC creates at most one draft NSFW real character; users.credits.kyc_fac_bonus_granted makes this idempotent across webhook and poller retries.
  • Ondato is the external verifier.
  • User email is propagated to Ondato on verification creation for dashboard visibility.

Missing approved account KYC blocks NSFW character creation and generation with FailedPrecondition. Reset is denied with FailedPrecondition after any successful 18+ generation.

  • cd apps/sirloin && make run-tests
  • cd apps/brisket && pnpm test