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
POST/v1/users

Request

JSON
{
   "email": "anders@example.com",
   "username": "anders",
   "password": "hunter22-longer",
   "display_name": "Anders"
}
FieldTypeConstraints
emailstringrequired, valid email, unique.
usernamestringrequired. 3–20 chars, [A-Za-z0-9_], unique.
passwordstringrequired, min 8 chars.
display_namestring|nulloptional. Defaults to username.

Response201 Created

Same shape as POST /v1/gateway/login. Sets katforge_refresh.

Errors

CodeMeaning
409Email or username already taken.
422Validation 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
POST/v1/users/check

Request

JSON
{
   "email": "anders@example.com",
   "username": "anders"
}

Both fields are optional individually but at least one is required overall.

Response200 OK

JSON
{
   "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
GET/v1/users/@me

Response200 OK

JSON
{
   "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

CodeMeaning
401Access token missing, expired, or not a registered user.
404User 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
PATCH/v1/users/@me

Request

JSON
{
   "email": "anders@example.com",
   "username": "anders",
   "display_name": "Anders",
   "timezone": "America/New_York"
}
FieldTypeConstraints
emailstring|nullvalid email, unique.
usernamestring|null3–20 chars, [A-Za-z0-9_], unique.
display_namestring|null
timezonestring|nullvalid IANA identifier.

Response200 OK

JSON
{
   "user": { "…": "as on GET /users/@me" }
}

Errors

CodeMeaning
400No fields supplied.
409Email or username already taken.
422Validation 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
DELETE/v1/users/@me

Response200 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

JSON
{
   "current_password": "hunter22-longer",
   "new_password": "new-hunter22-even-longer"
}

Response200 OK, empty body.

Errors

CodeMeaning
400New password doesn't meet strength requirements.
401Current 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

JSON
{
   "password": "hunter22-longer"
}
FieldTypeConstraints
passwordstringrequired, min 8 chars, at least one letter and one number.

Response200 OK, empty body.

Errors

CodeMeaning
409Account already has a password. Use POST /v1/users/@me/password instead.
422Password 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

Response204 No Content.

Errors

CodeMeaning
400No email on account.
409Email is already verified.
502Failed 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

JSON
{
   "channels": [ "email:product", "email:marketing" ]
}

Common channels: email:account, email:product, email:marketing.

Response200 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

JSON
{
   "phone": "+15551234567"
}

Response200 OK, empty body.

Errors

CodeMeaning
400Not a valid E.164 number.
429Too 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

JSON
{
   "code": "482317"
}

Response200 OK

JSON
{
   "phone": "+15551234567",
   "phone_verified_at": "2026-04-18T12:00:00+00:00"
}

Errors

CodeMeaning
400code missing.
401Code invalid or expired.

DELETE /v1/users/@me/phone

Remove the verified phone number.

  • Auth: ROLE_REGISTERED
DELETE/v1/users/@me/phone

Response200 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

FieldConstraints
sizeinteger, clamped to [16, 512], default 128.
fallbackboolean. Force the auto-generated icon regardless of source.

Response200 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. Returns 409 if 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 via POST /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

Request

JSON
{
   "source": "custom",
   "icon": "lorc/dragon-head",
   "color": "cyan",
   "bg": "crimson",
   "border": null
}
FieldTypeConstraints
sourcestring|nullOne of custom, upload, discord, google, apple, steam, or null.
iconstring|null{author}/{slug}, max 64 chars. Required when source="custom".
colorstring|nullpalette key, max 16 chars. Defaults to amber when source="custom".
bgstring|nullpalette key, max 16 chars. Defaults to slate when source="custom".
borderstring|nullpalette key, max 16 chars. null clears the border.

Response200 OK

JSON
{
   "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

CodeMeaning
400Invalid icon, color, bg, or border. No approved upload when source="upload". Provider not linked when source="<provider>".
403Guest trying to claim, activate an upload, or use a provider avatar.
409Icon/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).

Response200 OK

JSON
{
   "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

CodeMeaning
400Missing image field, or upload failed (invalid file).
500Could 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

Response204 No Content.