Skip to Content

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

HTTPcodeWhen
401unauthorizedNo access token.
401invalid_tokenToken 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

HTTPcodeWhen
401unauthorized, invalid_token
403forbiddenTrying to update someone else’s user.
409conflictusername already taken.
422validation_failedField 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 }>

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-keysUserPublicKey[].

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

HTTPcodeWhen
400bad_signatureSignature does not verify.
400stale_challengeChallenge older than 5 minutes.
400replayed_nonceChallenge nonce already redeemed.
409wallet_in_useWallet 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 100

Response:

{ results: Array<{ address: string user: User | null }> }

Rate limits

Endpoint patternLimit
GET /users/me/*60/min/user
POST /users/me/api-keys10/hour/user
POST /users/me/public-keys10/min/user (signature crypto cost)
POST /users/find-by-* (admin)30/min/key

Headers RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset on every response.

Last updated on