Skip to main content

User Verification — Technical Reference

← Back to User Verification

Where this lives

Backend — badge system (non-admin)

  • apps/backend/graphql/resolvers/verification.resolver.js — Query isVerified, getVerificationStatus, verificationPriceCoins; Mutation requestVerification, grantVerification, removeVerification. Note: getPendingVerificationRequests and rejectVerificationRequest used to live here too but were removed — the resolver file's own comments explain they "used to be reachable by any regular user with zero role check" and were moved to the admin-namespaced operations below (this doc previously cited them as still present here — corrected).
  • apps/backend/graphql/resolvers/user.resolver.jsthis file no longer exists (this doc previously cited it as a source of overlapping/duplicate resolvers — stale; the monolithic file was split into many user-*.resolver.js files). The verification-related survivor is apps/backend/graphql/resolvers/user-verification.resolver.js, which now only has verificationRequest (singular, by-ID query) and verificationBadgeInfo — its header comment explains the old duplicate isVerified/getPendingVerificationRequests/requestVerification/grantVerification/removeVerification resolvers "had real argument-order bugs" and were deleted outright rather than kept as dead code.
  • apps/backend/graphql/types/verification.type.js — its extend type Mutation block now only declares grantVerification; getPendingVerificationRequests/rejectVerificationRequest are declared solely in the admin type file below.
  • apps/backend/managers/user-managers/verification-badges.manager.js — request/grant/reject business logic. Also contains a reviewVerificationRequest method (lines ~443-523) that internally calls grantVerification but operates on a hardcoded mock request object — not called by any resolver found in the repo; dead code, not part of the live flow.

Backend — admin review (frontend-admin, separate app from frontend-nextjs)

There's no dedicated verification-admin.resolver.js — the admin operations are bundled into the general admin user-moderation files:

  • apps/backend/graphql/types/admin/user-moderation.type.jsadminGetPendingVerificationRequests, adminVerifyUser(userId, verificationType), adminRemoveVerification(userId, reason), adminRejectVerificationRequest(userId, reason). Reuses the shared VerificationRequestsResponse/VerificationRequest types from verification.type.js rather than redeclaring them.
  • apps/backend/graphql/resolvers/admin/user-moderation.resolver.js — all four gated on the VERIFY_USERS admin permission. List and reject are thin wrappers around the exact same manager methods as the non-admin resolver above (adminGetPendingVerificationRequestsuserManager.getPendingVerificationRequestsverificationBadgesManager.getPendingVerificationRequests; adminRejectVerificationRequestuserManager.rejectVerificationRequestverificationBadgesManager.rejectVerificationRequest — same logic, just gated by a real AdminUser JWT + permission check instead of the old ungated queries). Approve is a genuinely separate implementation: adminVerifyUser calls userModerationManager.verifyUser in apps/backend/managers/admin-managers/user-moderation.manager.js (lines ~777-820) — not verificationBadgesManager.grantVerification. It sets isVerified: true, verificationStatus: 'approved', verifiedAt, and deliberately leaves verifiedBy: null (a comment explains: in this admin path admin.adminId is an admin_user.id, a different table from users.id, so writing it to the verifiedBy FK column would violate the user_verified_by_fkey constraint — the who-approved-it audit trail goes through logModerationAction() instead). Unlike grantVerification, this path does not persist verificationCategory/verificationBadgeType (the frontend does send verificationType, but it's only used in a notification message string, never written to the user row) and has no guard against re-approving an already-verified user.

Frontend

  • apps/frontend-nextjs/src/page-components/ProfilePage.tsx / PublicProfilePage.tsx — read user?.isVerified and render a <VerifiedBadge> next to the username
  • apps/frontend-nextjs/src/page-components/settings/VerificationRequestPage.tsx — queries getVerificationStatus and calls requestVerification. No admin review UI or appeal form exists in apps/frontend-nextjs — that lives entirely in the separate admin app below.
  • apps/frontend-admin/src/app/verification/page.tsx — the real admin review queue (this doc previously said no admin UI existed anywhere — corrected). A paginated table (20/page) of pending requests showing avatar, username, verificationCategory badge, truncated verificationNotes, and formatted request date, with per-row Approve/Reject buttons; Reject opens a modal collecting a free-text reason. Gated client-side on admin.role === 'super_admin' || admin.permissions.VERIFY_USERS. Note: the query fetches verificationDocuments but the component never renders them — there's no document-image review modal on this page (contrast with the separate identity-verification admin page, which does have one — see below).
  • This page handles only the notability/badge system. The unrelated age/ID (KYC-style) system has its own separate admin page, apps/frontend-admin/src/app/moderation/identity-verifications/page.tsx — different GraphQL operations (adminPendingIdentityVerifications/adminApproveIdentityVerification/adminRejectIdentityVerification), different permission (MODERATE_CONTENT, not VERIFY_USERS), and it does have a document + selfie review modal. See Age & Identity Verification for that system.

Backend — restriction appeals (built this pass, replaces the previous "unverified" note below)

Frontend — restriction appeals

Technical implementation checklist

  • isVerifiedverification.resolver.js; frontend ProfilePage.tsx/PublicProfilePage.tsx read it and render the checkmark badge
  • getVerificationStatus — resolver wired; VerificationRequestPage.tsx queries it and reads verificationStatus/verificationCategory/verifiedAt/verificationRequestedAt to drive its UI
  • verificationBadgeInfo — resolver wired in user-verification.resolver.js; no frontend consumer found
  • requestVerificationVerificationRequestPage.tsx calls it directly
  • adminGetPendingVerificationRequestsfrontend-admin's /verification page calls it (this doc previously said no admin UI existed anywhere — corrected)
  • adminVerifyUser/verification page's Approve button; separate implementation from grantVerification, see above
  • adminRejectVerificationRequest/verification page's Reject button (modal collects the reason)
  • adminRemoveVerification — wired to frontend-admin's /users/[id] page's "Quitar verificación" action, shown once a user is verified — this doc previously said no admin page called it; corrected
  • grantVerification (the non-admin mutation) — still declared and wired, but nothing in either frontend calls it directly; the live approve flow goes through adminVerifyUser instead
  • appealRestriction / getAppealStatus / myLatestAppeal — built this pass, real implementation against user.accountStatus. appealRestriction/myLatestAppeal are consumed by AccountStatusPage.tsx; getAppealStatus (single appeal by id) has no direct frontend caller — the frontend uses myLatestAppeal instead
  • adminGetAppeals / adminReviewAppeal — built this pass, wired to frontend-admin's /moderation/appeals review queue

Checking verification status

isVerified is a lightweight boolean — use it to decide whether to render a badge icon without loading the full verification object.

getVerificationStatus returns the full verification record including verificationStatus (whether the application is pending, approved, or rejected) and verificationCategory. Use this on a user's settings screen to show their application state.

verificationBadgeInfo returns all the rendering data needed to display the badge: badgeColor, badgeIcon, and displayName are returned so clients can render the correct badge style per category without hardcoding.

query IsVerified($userId: ID!) {
isVerified(userId: $userId)
}

query VerificationStatus($userId: ID!) {
getVerificationStatus(userId: $userId) {
isVerified
verificationStatus # pending | approved | rejected
verificationCategory # creator | business | public_figure | etc.
verifiedAt
verificationRequestedAt
}
}

query BadgeInfo($userId: ID!) {
verificationBadgeInfo(userId: $userId) {
isVerified
badgeType
verificationCategory
verifiedAt
badgeColor
badgeIcon
displayName
}
}

Requesting verification

requestVerification submits a new application. The user must upload supporting documents to S3 first and pass the resulting URLs in verificationDocuments. The request enters the admin review queue with status pending. A user can only have one active request at a time.

mutation RequestVerification($input: RequestVerificationInput!) {
requestVerification(input: $input) {
success message
user { id username isVerified }
}
}

RequestVerificationInput fields:

FieldDescription
verificationCategoryThe category being requested (e.g. creator, business, public_figure)
verificationDocumentsArray of S3 URLs pointing to uploaded supporting documents
verificationNotesOptional free-text explanation

Admin review

Reached from frontend-admin's /verification page, gated on the VERIFY_USERS admin permission — these are the real, live operations (this doc previously showed the non-admin getPendingVerificationRequests/grantVerification/rejectVerificationRequest/removeVerification names here, which either moved to the admin namespace or aren't actually called by any frontend — corrected):

adminGetPendingVerificationRequests lists all unresolved verification applications — a thin wrapper around the same verificationBadgesManager.getPendingVerificationRequests logic as before, just gated by a real admin permission check instead of the old ungated query.

adminVerifyUser approves the request. Unlike the older grantVerification mutation, this is a separate implementation (userModerationManager.verifyUser) that sets isVerified/verificationStatus/verifiedAt but does not persist verificationCategory, verificationBadgeType, or verifiedBy (left null — see "Where this lives" above for why), and has no guard against approving an already-verified user.

adminRejectVerificationRequest denies the application with a reason — another thin wrapper around the same manager logic as before.

adminRemoveVerification exists and is wired end-to-end to frontend-admin's /users/[id] page ("Quitar verificación" action, shown once a user is verified) — this doc previously said no admin page called it; corrected.

# List pending requests
query AdminPendingVerificationRequests($limit: Int, $offset: Int) {
adminGetPendingVerificationRequests(limit: $limit, offset: $offset) {
total
requests {
id userId verificationCategory verificationStatus
verificationRequestedAt verificationDocuments verificationNotes
user { id username profilePicture }
}
}
}

# Approve a verification request
mutation AdminVerifyUser($userId: ID!, $verificationType: String) {
adminVerifyUser(userId: $userId, verificationType: $verificationType) {
success message
}
}

# Deny a request with a reason communicated to the user
mutation AdminRejectVerificationRequest($userId: ID!, $reason: String) {
adminRejectVerificationRequest(userId: $userId, reason: $reason) { success message }
}

# Revoke an already-granted badge — wired to /users/[id]'s "Quitar verificación" action
mutation AdminRemoveVerification($userId: ID!, $reason: String!) {
adminRemoveVerification(userId: $userId, reason: $reason) { success message }
}

The non-admin grantVerification mutation (shown in earlier versions of this doc) still exists in the schema and still works, but nothing in either frontend calls it — the live approve flow goes through adminVerifyUser instead.

Restriction appeals

Built this pass — replaces the previous broken stubs (appealRestriction/getAppealStatus used to live in contacts-validation.type.js/user-restrictions.resolver.js with argument mismatches, wrong return shapes, and no real persistence; both were removed outright). The real implementation is a new Appeal model/table, built against the real restriction system (user.accountStatussuspended/banned, set by suspendUser/banUser), not the separate dead isRestricted/restrictedUntil system.

A user whose accountStatus is currently suspended or banned can submit one appeal explaining why the restriction should be lifted (appealRestriction) — the manager rejects a second submission while one is already pending. myLatestAppeal returns the caller's most recent appeal (any status) so the frontend can render the right state (form / pending / previously denied) without needing an appeal id up front; getAppealStatus fetches a specific appeal by id, with an ownership check, but has no frontend caller today since myLatestAppeal covers the actual UI need.

On the admin side, adminGetAppeals lists appeals (defaults to pending, oldest first), gated on the MODERATE_CONTENT permission. adminReviewAppeal approves or denies one: on approval it calls the real unsuspendUser/unbanUser in user-moderation.manager.js (picking whichever matches the user's current accountStatus) so the restriction is actually lifted, not just marked as reviewed; on denial it just records the decision and the restriction stays in place. Both operations share the AppealStatus GraphQL type with the user-facing schema (same precedent as CoinCashout being shared between the payout type files).

mutation AppealRestriction($reason: String!) {
appealRestriction(reason: $reason) {
id status reason submittedAt
}
}

query MyLatestAppeal {
myLatestAppeal {
id status reason submittedAt reviewedAt decisionNotes
}
}

query AppealStatus($appealId: ID!) {
getAppealStatus(appealId: $appealId) {
id status reason submittedAt reviewedAt decisionNotes
reviewer { username }
}
}

Admin review

query AdminGetAppeals($status: String, $limit: Int, $offset: Int) {
adminGetAppeals(status: $status, limit: $limit, offset: $offset) {
total limit offset
appeals {
id status appealType reason submittedAt reviewedAt decisionNotes
user { id username email accountStatus suspensionReason suspendedUntil }
}
}
}

mutation AdminReviewAppeal($appealId: ID!, $decision: String!, $notes: String) {
adminReviewAppeal(appealId: $appealId, decision: $decision, notes: $notes) {
success message
appeal { id status }
}
}

See Appeals Review for the admin-panel side.