Users API
Routes for managing the authenticated user and (for admins) looking
up other users. Authentication is Authorization: Bearer <access_token>
unless noted otherwise.
Get current user
GET /users/current — alias for GET /users/me.
Request
GET /users/current
Authorization: Bearer <access_token>Response (200)
interface User {
id: string // 'sub' on the access token
username?: string
firstName?: string
lastName?: string
preferredLanguage?: string // ISO-639-1
profilePicture?: FileTracker
externalProfilePictureUrl?: string
lastSeen: string
userPublicKeys?: UserPublicKey[]
}
interface FileTracker {
fileKey: string
url: string // CDN URL
contentType: string
bytes: number
}
interface UserPublicKey {
id: string
blockchain: 'solana'
address: string
isPrimary: boolean
label?: string
lastUsedAt?: string
}Errors
| HTTP | code | When |
|---|---|---|
| 401 | unauthorized | No access token. |
| 401 | invalid_token | Token expired or invalid. |
Update profile
PATCH /users/:id — must be the caller’s own id.
Request
interface UpdateUserBody {
firstName?: string // trimmed, 1–80 chars
lastName?: string // trimmed, 1–80 chars
username?: string // lowercase, [a-z0-9-]{3,30}, unique
profilePicture?: string | null // FileTracker id, or null to clear
phoneNumber?: { countryCode: string; number: string } | null
preferredLanguage?: string // ISO-639-1
}All fields optional. Pass null to clear a nullable field.
Response (200)
The updated User.
Errors
| HTTP | code | When |
|---|---|---|
| 401 | unauthorized, invalid_token | — |
| 403 | forbidden | Trying to update someone else’s user. |
| 409 | conflict | username already taken. |
| 422 | validation_failed | Field validation. details.fields lists each. |
Username availability
POST /users/username-availability — check if a username is free.
Request
{ username: string }Response (200)
{ available: boolean }Usernames are lowercase, alphanumeric + dashes, 3–30 chars. Validation runs server-side too on PATCH; this endpoint is for UX-only pre-checks.
Recent activity
GET /users/me/activity — last 5 sign-in events.
Response (200)
{
items: Array<{
timestamp: string // ISO 8601
type: 'sign_in' | 'sign_in_failed' | 'link_provider' | 'unlink_provider' | 'device_authorized'
provider?: string // when applicable
ip?: string
userAgent?: string
}>
}Authentication providers
GET /users/me/authentication-providers — all linked OAuth + wallets.
Response (200)
type AuthenticationProvider =
| {
type: 'oauth'
id: string
provider: 'google' | 'apple' | 'microsoft' | 'discord' | 'facebook' | 'x' | 'telegram' | 'matrica'
externalUserId?: string
emailAddress?: string
username?: string
}
| {
type: 'publicKey'
id: string
blockchain: 'solana'
address: string
label?: string
isPrimary: boolean
lastUsedAt?: string
}
// returns AuthenticationProvider[]This is the canonical source for the user’s connected accounts screen.
OAuth tokens (linked providers)
List
GET /users/me/oauth-tokens
Array<{
id: string
provider: string
emailAddress?: string
username?: string
lastUsedAt?: string
createdAt: string
}>Unlink
DELETE /users/me/oauth-tokens/:id — revokes the provider-side
tokens (where supported) and removes the link from Synq.
Response: 204 No Content.
Public keys (wallets)
List
GET /users/me/public-keys → UserPublicKey[].
Add
POST /users/me/public-keys — link a new wallet with a signed
ownership proof.
Request
{
blockchain: 'solana'
address: string // base58 public key
signature: string // base64 Ed25519 signature
signedMessage: string // base64 of the original message bytes
label?: string
force?: boolean // override "wallet already linked elsewhere"
}signedMessage must be a current Synq-issued challenge from
POST /users/me/wallet-challenge (see below). Reuse / replay is
rejected.
Response (201)
The created UserPublicKey.
Errors
| HTTP | code | When |
|---|---|---|
| 400 | bad_signature | Signature does not verify. |
| 400 | stale_challenge | Challenge older than 5 minutes. |
| 400 | replayed_nonce | Challenge nonce already redeemed. |
| 409 | wallet_in_use | Wallet already linked to a different user. Pass force: true to transfer (requires re-auth on both sides). |
Request a challenge
POST /users/me/wallet-challenge
Request
{ address: string }Response (200)
{
message: string // sign this exactly
expiresAt: string // ISO 8601 (5 min from now)
}Update
PATCH /users/me/public-keys/:id
{ label?: string; isPrimary?: boolean }Setting isPrimary: true on one wallet automatically unsets it on
all others.
Remove
DELETE /users/me/public-keys/:id
{
signature: string // fresh ownership proof
signedMessage: string
force?: boolean
}Requires a fresh signature so a stolen session cookie can’t unlink all the user’s wallets.
API keys
Create
POST /users/me/api-keys
{
name: string // 1–80 chars
expiresAt?: string // ISO 8601, optional
scopes?: string[]
type?: 'standard' | 'ephemeral' // default 'standard'
ipAllowlist?: string[] // CIDRs
}Response (201)
{
id: string
name: string
prefix: string // first 8 chars
key: string // FULL key, returned exactly once
type: 'standard' | 'ephemeral'
scopes: string[]
expiresAt?: string
ipAllowlist: string[]
createdAt: string
}Store key now. Subsequent reads return only metadata.
List
GET /users/me/api-keys
{
items: Array<{
id: string
name: string
prefix: string
type: 'standard' | 'ephemeral'
scopes: string[]
expiresAt?: string
lastUsedAt?: string
ipAllowlist: string[]
createdAt: string
}>
}No key field.
Revoke
DELETE /users/me/api-keys/:id — sets revokedAt; the key starts
rejecting immediately.
Account deletion
DELETE /users/me — puts the account into a deletion grace window.
Behavior
- Session cookie destroyed immediately.
- OAuth tokens, wallets, API keys deleted immediately.
- User record moved to a graveyard collection after the grace period (default 30 days).
- Re-signing-in via the same
(provider, externalUserId)pair during the window reverses the deletion.
Response (204)
No body.
Admin lookups
These require AdminPermission.UsersView on the system role, not
org membership.
By identifier
POST /users/find-by-identifier
{
identifier: string // user id, username, or wallet address
}Response: User | null.
By wallets
POST /users/find-by-wallets
{ addresses: string[] } // up to 100Response:
{
results: Array<{
address: string
user: User | null
}>
}Rate limits
| Endpoint pattern | Limit |
|---|---|
GET /users/me/* | 60/min/user |
POST /users/me/api-keys | 10/hour/user |
POST /users/me/public-keys | 10/min/user (signature crypto cost) |
POST /users/find-by-* (admin) | 30/min/key |
Headers RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
on every response.