Users
Everything about the currently-authenticated user account. For avatar rendering (image endpoints), see Avatars.
All endpoints are under /v1/users.
Registration
POST /v1/users
Create a new registered account. Returns an access token and a refresh cookie; the freshly-created user is signed in.
- Auth: Public
- Rate limit: 10 / 60s
/v1/usersRequest
{
"email": "anders@example.com",
"username": "anders",
"password": "hunter22-longer",
"display_name": "Anders"
}
| Field | Type | Constraints |
|---|---|---|
email | string | required, valid email, unique. |
username | string | required. 3–20 chars, [A-Za-z0-9_], unique. |
password | string | required, min 8 chars. |
display_name | string|null | optional. Defaults to username. |
Response — 201 Created
Same shape as POST /v1/gateway/login. Sets katforge_refresh.
Errors
| Code | Meaning |
|---|---|
409 | Email or username already taken. |
422 | Validation failed. |
POST /v1/users/check
Live availability check for registration forms. Provide whichever fields you want to validate; at least one must be present.
- Auth: Public
- Rate limit: 20 / 60s
/v1/users/checkRequest
{
"email": "anders@example.com",
"username": "anders"
}
Both fields are optional individually but at least one is required overall.
Response — 200 OK
{
"email": { "available": false },
"username": { "available": true }
}
Only requested fields appear in the response.
Profile
GET /v1/users/@me
Return the current user's full profile plus their associated player_id.
- Auth:
ROLE_REGISTERED
Response — 200 OK
{
"user": {
"id": 42,
"username": "anders",
"display_name": "Anders",
"email": "anders@example.com",
"locale": "en",
"timezone": "America/New_York",
"last_login_at": "2026-04-17T22:04:11+00:00",
"created_at": "2024-08-03T17:02:00+00:00",
"email_verified_at": "2024-08-03T17:05:00+00:00",
"channels": [ "email:product" ],
"roles": [ "ROLE_REGISTERED" ],
"player_id": 42
}
}
Errors
| Code | Meaning |
|---|---|
401 | Access token missing, expired, or not a registered user. |
404 | User record missing (should never happen in practice). |
PATCH /v1/users/@me
Update the current user's profile. Any subset of fields may be provided; at least one is required.
- Auth:
ROLE_REGISTERED
/v1/users/@meRequest
{
"email": "anders@example.com",
"username": "anders",
"display_name": "Anders",
"timezone": "America/New_York"
}
| Field | Type | Constraints |
|---|---|---|
email | string|null | valid email, unique. |
username | string|null | 3–20 chars, [A-Za-z0-9_], unique. |
display_name | string|null | — |
timezone | string|null | valid IANA identifier. |
Response — 200 OK
{
"user": { "…": "as on GET /users/@me" }
}
Errors
| Code | Meaning |
|---|---|
400 | No fields supplied. |
409 | Email or username already taken. |
422 | Validation failed (e.g. bad timezone). |
DELETE /v1/users/@me
Soft-delete the current user. The account is marked is_deleted=true and is_active=false. The player record and game data are preserved so leaderboard history stays intact. Email subscriptions and verifications are removed.
- Auth:
ROLE_REGISTERED
/v1/users/@meResponse — 200 OK, empty body.
Password
POST /v1/users/@me/password
Change the current user's password. Requires the existing password for verification.
- Auth:
ROLE_REGISTERED
Request
{
"current_password": "hunter22-longer",
"new_password": "new-hunter22-even-longer"
}
Response — 200 OK, empty body.
Errors
| Code | Meaning |
|---|---|
400 | New password doesn't meet strength requirements. |
401 | Current password incorrect. |
POST /v1/users/@me/password/initial
Set the initial password for an account that was created without one (e.g. via OAuth/Steam). Refuses if the account already has a password. Once set, the account also has password login as a recovery method alongside the OAuth provider.
- Auth:
ROLE_REGISTERED
Request
{
"password": "hunter22-longer"
}
| Field | Type | Constraints |
|---|---|---|
password | string | required, min 8 chars, at least one letter and one number. |
Response — 200 OK, empty body.
Errors
| Code | Meaning |
|---|---|
409 | Account already has a password. Use POST /v1/users/@me/password instead. |
422 | Password doesn't meet strength requirements. |
Email verification
POST /v1/users/@me/email/resend-verification
Resend the verification email for the current user's address. Each call mints a fresh short-lived token (24h TTL). Throttled per-account to make brute-force or spam useless.
- Auth:
ROLE_REGISTERED - Rate limit: 3 / 600s
Response — 204 No Content.
Errors
| Code | Meaning |
|---|---|
400 | No email on account. |
409 | Email is already verified. |
502 | Failed to send verification email. |
Communication preferences
PATCH /v1/users/@me/communication
Replace the user's email channel opt-in map. Channels not present in the request are unset. Channel keys use medium:topic format.
- Auth:
ROLE_REGISTERED
Request
{
"channels": [ "email:product", "email:marketing" ]
}
Common channels: email:account, email:product, email:marketing.
Response — 200 OK, empty body.
Phone verification
POST /v1/users/@me/phone
Start phone verification. Sends a 6-digit SMS code to the supplied E.164 number. The code expires in 5 minutes.
- Auth:
ROLE_REGISTERED - Rate limit: 5 / 300s
Request
{
"phone": "+15551234567"
}
Response — 200 OK, empty body.
Errors
| Code | Meaning |
|---|---|
400 | Not a valid E.164 number. |
429 | Too many active codes; wait for them to expire. |
POST /v1/users/@me/phone/verify
Complete phone verification with the SMS code.
- Auth:
ROLE_REGISTERED - Rate limit: 10 / 60s
Request
{
"code": "482317"
}
Response — 200 OK
{
"phone": "+15551234567",
"phone_verified_at": "2026-04-18T12:00:00+00:00"
}
Errors
| Code | Meaning |
|---|---|
400 | code missing. |
401 | Code invalid or expired. |
DELETE /v1/users/@me/phone
Remove the verified phone number.
- Auth:
ROLE_REGISTERED
/v1/users/@me/phoneResponse — 200 OK, empty body.
Avatar
GET /v1/users/@me/avatar
Render the current caller's avatar. Convenience wrapper around GET /v1/players/{id}/avatar for surfaces that don't know the player ID.
- Auth: Public (works for both guests and registered users via access token)
Query params
| Field | Constraints |
|---|---|
size | integer, clamped to [16, 512], default 128. |
fallback | boolean. Force the auto-generated icon regardless of source. |
Response — 200 OK (image/svg+xml) or 302 redirect to a provider CDN.
PATCH /v1/users/@me/avatar
Update the caller's avatar selection. Four modes:
source: "custom"— claim a game-icon atomically. Returns409if another player has already claimed the same icon/color/bg combination.source: "upload"— activate the caller's most recent approved upload. Upload an image first viaPOST /v1/users/@me/avatar/upload.source: "<provider>"— switch to a linked OAuth provider's avatar (e.g.discord).source: null— release any claim and fall back to the auto-generated icon.
Guests may only reset (source: null). Claiming an icon, activating an upload, or using a provider avatar requires registration.
- Auth: Authenticated (any)
- Rate limit: 20 / 60s
/v1/users/@me/avatarRequest
{
"source": "custom",
"icon": "lorc/dragon-head",
"color": "cyan",
"bg": "crimson",
"border": null
}
| Field | Type | Constraints |
|---|---|---|
source | string|null | One of custom, upload, discord, google, apple, steam, or null. |
icon | string|null | {author}/{slug}, max 64 chars. Required when source="custom". |
color | string|null | palette key, max 16 chars. Defaults to amber when source="custom". |
bg | string|null | palette key, max 16 chars. Defaults to slate when source="custom". |
border | string|null | palette key, max 16 chars. null clears the border. |
Response — 200 OK
{
"avatar": {
"source": "custom",
"icon": "lorc/dragon-head",
"color": "cyan",
"bg": "crimson",
"border": null,
"upload_id": null,
"updated_at": "2026-04-18T12:00:00+00:00"
}
}
Errors
| Code | Meaning |
|---|---|
400 | Invalid icon, color, bg, or border. No approved upload when source="upload". Provider not linked when source="<provider>". |
403 | Guest trying to claim, activate an upload, or use a provider avatar. |
409 | Icon/color/bg combination already claimed. Body includes { "error": "combination_taken", "icon": "lorc/dragon-head", "color": "cyan", "bg": "slate" }. |
POST /v1/users/@me/avatar/upload
Accept a multipart upload, process it through moderation, and return the resulting upload row. An auto-approved upload also activates itself on the player in the same response; a queued upload leaves the active avatar unchanged and surfaces later when an admin reviews it.
- Auth:
ROLE_REGISTERED - Rate limit: 5 / 300s
- Content-Type:
multipart/form-data
Request — form field image (file).
Response — 200 OK
{
"upload": {
"id": 17,
"status": "approved",
"url": "/v1/players/42/avatar?v=1713456000",
"rejection_reason": null,
"created_at": "2026-04-18T12:00:00+00:00"
},
"avatar": {
"source": "upload",
"icon": null,
"color": null,
"bg": null,
"border": null,
"upload_id": 17,
"updated_at": "2026-04-18T12:00:00+00:00"
}
}
avatar is only present when the upload was auto-approved. url on upload is null until the upload is approved.
Errors
| Code | Meaning |
|---|---|
400 | Missing image field, or upload failed (invalid file). |
500 | Could not read uploaded file. |
DELETE /v1/users/@me/avatar/upload
Revert the active avatar away from upload back to the auto-generated default. The upload row itself is preserved for moderation history. No-op if the player's current avatar source is not upload.
- Auth:
ROLE_REGISTERED - Rate limit: 20 / 60s
Response — 204 No Content.