Age Verification
E-dates are for verified adults only. "Verified" means two things, both checked on the server:
- The self-declared birthdate on the account (
User.birthdate, locked once set) says 18 or older. - An admin has checked a photo of the user's driver's license against that birthdate, once.
PlayPals hosting e-dates are verified by their creator approval (an admin reviewed their government ID, and approval requires 18+); their birthdate is re-checked too. 1-on-1 sessions don't need any of this.
User endpoints
All require authentication. Mounted at /api/v1/age-verification.
| Method | Path | Purpose |
|---|---|---|
GET | /age-verification | Status, and whether the user can book e-dates now |
POST | /age-verification/document | Send the license photo (multipart/form-data, field document, JPEG or PNG, ≤ 10 MB) |
{
"status": "NONE" | "PENDING" | "APPROVED" | "REJECTED",
"submittedAt": "…" | null,
"reviewedAt": "…" | null,
"rejectionReason": "…" | null,
"edates": { "eligible": false, "blockers": ["AGE_VERIFICATION_PENDING"] }
}Sending while PENDING replaces the photo. Errors: 403 BIRTHDATE_REQUIRED, 403 UNDERAGE (no photo is collected from someone under 18 by their own birthdate), 409 AGE_ALREADY_VERIFIED, 503 AGE_VERIFICATION_UNAVAILABLE (KYC storage or encryption not configured).
The photo is handled like a creator's ID: memory-only upload, re-encoded (drops EXIF/GPS), AES-GCM encrypted, stored in the private KYC bucket as a VerificationAsset of type age_document. It is deleted as soon as an admin approves or rejects; AgeVerification keeps the decision, reviewer and time.
Enforcement
assertEdateEligible({ bookerId, playpalId }) (apps/api/src/services/ageVerification.ts) throws 403 with the first blocker as the code:
| Code | Who | Meaning |
|---|---|---|
BIRTHDATE_REQUIRED | booker | No birthdate on the account |
UNDERAGE | booker | Under 18 today (re-checked every time, even with an approved check on file) |
AGE_VERIFICATION_REQUIRED | booker | Never sent a license, or it was rejected |
AGE_VERIFICATION_PENDING | booker | Sent, not reviewed yet |
PLAYPAL_NOT_VERIFIED | host | Not an approved PlayPal, or not an adult (one code, so nothing about the host's age leaks) |
It is called when an e-date is held, when it's paid for, and when either person asks to join its room.
Admin review
Admin-only, mounted at /api/v1/admin/age-verifications; the admin panel's Age verifications page uses it.
| Method | Path | Purpose |
|---|---|---|
GET | / | Queue (status = PENDING default, APPROVED, REJECTED; cursor paging, oldest first) |
GET | /:userId | One verification, with accountBirthdate and accountAge to compare against |
GET | /:userId/documents/:assetId | The decrypted photo (no-store); every view is logged |
POST | /:userId/approve | { "birthdateMatches": true } required; the account's birthdate must be 18+ |
POST | /:userId/reject | { "reason": "…" }, shown to the user |
Every view and decision writes a VerificationAccessLog row; decisions also write AdminAuditLog (age.approved, age.rejected) and notify the user. Purpose limitation: this module only touches age_document assets, never creator IDs or selfies.
Deleting an account removes its age verification (and any pending photo).
