User Verification — Technical Reference
Where this lives
Backend — badge system (non-admin)
apps/backend/graphql/resolvers/verification.resolver.js— QueryisVerified,getVerificationStatus,verificationPriceCoins; MutationrequestVerification,grantVerification,removeVerification. Note:getPendingVerificationRequestsandrejectVerificationRequestused 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).— this file no longer exists (this doc previously cited it as a source of overlapping/duplicate resolvers — stale; the monolithic file was split into manyapps/backend/graphql/resolvers/user.resolver.jsuser-*.resolver.jsfiles). The verification-related survivor isapps/backend/graphql/resolvers/user-verification.resolver.js, which now only hasverificationRequest(singular, by-ID query) andverificationBadgeInfo— its header comment explains the old duplicateisVerified/getPendingVerificationRequests/requestVerification/grantVerification/removeVerificationresolvers "had real argument-order bugs" and were deleted outright rather than kept as dead code.apps/backend/graphql/types/verification.type.js— itsextend type Mutationblock now only declaresgrantVerification;getPendingVerificationRequests/rejectVerificationRequestare declared solely in the admin type file below.apps/backend/managers/user-managers/verification-badges.manager.js— request/grant/reject business logic. Also contains areviewVerificationRequestmethod (lines ~443-523) that internally callsgrantVerificationbut 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.js—adminGetPendingVerificationRequests,adminVerifyUser(userId, verificationType),adminRemoveVerification(userId, reason),adminRejectVerificationRequest(userId, reason). Reuses the sharedVerificationRequestsResponse/VerificationRequesttypes fromverification.type.jsrather than redeclaring them.apps/backend/graphql/resolvers/admin/user-moderation.resolver.js— all four gated on theVERIFY_USERSadmin permission. List and reject are thin wrappers around the exact same manager methods as the non-admin resolver above (adminGetPendingVerificationRequests→userManager.getPendingVerificationRequests→verificationBadgesManager.getPendingVerificationRequests;adminRejectVerificationRequest→userManager.rejectVerificationRequest→verificationBadgesManager.rejectVerificationRequest— same logic, just gated by a realAdminUserJWT + permission check instead of the old ungated queries). Approve is a genuinely separate implementation:adminVerifyUsercallsuserModerationManager.verifyUserinapps/backend/managers/admin-managers/user-moderation.manager.js(lines ~777-820) — notverificationBadgesManager.grantVerification. It setsisVerified: true,verificationStatus: 'approved',verifiedAt, and deliberately leavesverifiedBy: null(a comment explains: in this admin pathadmin.adminIdis anadmin_user.id, a different table fromusers.id, so writing it to theverifiedByFK column would violate theuser_verified_by_fkeyconstraint — the who-approved-it audit trail goes throughlogModerationAction()instead). UnlikegrantVerification, this path does not persistverificationCategory/verificationBadgeType(the frontend does sendverificationType, 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— readuser?.isVerifiedand render a<VerifiedBadge>next to the usernameapps/frontend-nextjs/src/page-components/settings/VerificationRequestPage.tsx— queriesgetVerificationStatusand callsrequestVerification. No admin review UI or appeal form exists inapps/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,verificationCategorybadge, truncatedverificationNotes, and formatted request date, with per-row Approve/Reject buttons; Reject opens a modal collecting a free-text reason. Gated client-side onadmin.role === 'super_admin' || admin.permissions.VERIFY_USERS. Note: the query fetchesverificationDocumentsbut 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, notVERIFY_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)
apps/backend/database/migrations/20260718000000-create-appeal.js+apps/backend/database/models/Appeal.js— new dedicatedappealtable (idempotent migration,showAllTables()guard), indexed on(user_id, status)apps/backend/data-access-services/user/appeal.access-service.js+apps/backend/managers/user-managers/appeal.manager.js— real business logic, built againstuser.accountStatus/suspensionReason/suspendedUntil(the real restriction system used byuser-moderation.manager.js), deliberately not the separate deadisRestricted/restrictedUntilsystem inrestrictions-limits.manager.jsapps/backend/graphql/types/appeal.type.js+apps/backend/graphql/resolvers/appeal.resolver.js— user-facingappealRestriction/getAppealStatus/myLatestAppeal. Replaces the old broken stubs of the same names that used to live incontacts-validation.type.js/user-restrictions.resolver.js(those have been removed — argument mismatches, wrong return shapes, no real persistence)apps/backend/graphql/types/admin/appeal-admin.type.js+apps/backend/graphql/resolvers/admin/appeal-admin.resolver.js— admin review queue,adminGetAppeals/adminReviewAppeal, gated on theMODERATE_CONTENTpermission (reused rather than adding a new permission key, same precedent as the identity-verification queue). Approving an appeal calls the realunsuspendUser/unbanUserinuser-moderation.manager.jsto actually lift the restriction; denying just records the decision.apps/backend/graphql/types/account-standing.type.js—accountStandingStatusgained two fields this pass,reasonandsuspendedUntil, populated only while the caller is actually suspended/banned, soAccountStatusPage.tsxcan show why and until when without a second query. See Settings → Account status for that query's other fields.
Frontend — restriction appeals
apps/frontend-nextjs/src/page-components/settings/AccountStatusPage.tsx(/settings/account-status) — queriesaccountStandingStatus+myLatestAppealtogether; while restricted, renders anAppealSectionthat shows a submit form (callsappealRestriction), a "pending" state, or a "denied" state with the admin's notes, depending onmyLatestAppeal.statusapps/frontend-admin/src/app/moderation/appeals/page.tsx(/moderation/appeals) — admin review queue, paginated, gated client-side onMODERATE_CONTENT; a review modal shows the user's current standing and appeal reason with Approve/Deny actions (Deny requires notes). See Appeals Review.
Technical implementation checklist
-
isVerified—verification.resolver.js; frontendProfilePage.tsx/PublicProfilePage.tsxread it and render the checkmark badge -
getVerificationStatus— resolver wired;VerificationRequestPage.tsxqueries it and readsverificationStatus/verificationCategory/verifiedAt/verificationRequestedAtto drive its UI -
verificationBadgeInfo— resolver wired inuser-verification.resolver.js; no frontend consumer found -
requestVerification—VerificationRequestPage.tsxcalls it directly -
adminGetPendingVerificationRequests—frontend-admin's/verificationpage calls it (this doc previously said no admin UI existed anywhere — corrected) -
adminVerifyUser—/verificationpage's Approve button; separate implementation fromgrantVerification, see above -
adminRejectVerificationRequest—/verificationpage's Reject button (modal collects the reason) -
adminRemoveVerification— wired tofrontend-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 throughadminVerifyUserinstead -
appealRestriction/getAppealStatus/myLatestAppeal— built this pass, real implementation againstuser.accountStatus.appealRestriction/myLatestAppealare consumed byAccountStatusPage.tsx;getAppealStatus(single appeal by id) has no direct frontend caller — the frontend usesmyLatestAppealinstead -
adminGetAppeals/adminReviewAppeal— built this pass, wired tofrontend-admin's/moderation/appealsreview 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:
| Field | Description |
|---|---|
verificationCategory | The category being requested (e.g. creator, business, public_figure) |
verificationDocuments | Array of S3 URLs pointing to uploaded supporting documents |
verificationNotes | Optional 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.accountStatus — suspended/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.