CrydenSync
HTTP API

API Endpoints

Every public and RequireAuth endpoint, with a copyable curl command for each. All paths are relative to /v1.

All paths are relative to /v1. All request/response bodies are JSON. Examples use devray@example.com / Pass@2026 as placeholder credentials and https://api.example.com as a placeholder deployment URL — swap in your own.

Every curl command below is the literal request — copy it into any language's HTTP client and it works identically, since there's nothing Go-specific about calling this API. A full interactive reference is also served by every running deployment at GET /v1/docs — see the overview.

For OAuth, see OAuth. For everything behind the admin operator role, see Admin Console.

The response envelope, precisely

Success is always {"data": ...}. data is an object for most endpoints, but an array with no wrapper for three of them: GET /v1/sessions, GET /v1/passkeys, and GET /v1/api-keys.

Error is always {"error": {"code": "...", "message": "..."}}, with one exception: a 400 password_policy_violation also carries error.details, an array of stable rule codes (min_length and similar) — the only error with a details array.

Timestamps are RFC 3339 with an offset. Optional ones (expires_at, last_used_at) are JSON null when absent, never a zero-valued string — "never" and "a real instant" must stay distinguishable.

Core auth

POST /signup

Public.

curl -X POST https://api.example.com/v1/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "devray@example.com", "password": "Pass@2026"}'

Response 201: { "data": { "user_id": "019f...", "email": "devray@example.com" } }

Errors: bad_request, user_exists (409), password_policy_violation (400, +details), password_breached (400), rate_limited (429)

POST /login

Public. Returns tokens, or pauses for a second factor.

curl -X POST https://api.example.com/v1/login \
  -H "Content-Type: application/json" \
  -d '{"email": "devray@example.com", "password": "Pass@2026"}'

Response 200, no second factor enrolled:

{ "data": { "access_token": "eyJ...", "refresh_token": "a1b2..." } }

Response 200, a second factor is enrolled — see Authentication:

{ "data": { "second_factor_required": true, "pending_token": "spt_...", "methods": ["totp", "passkey"] } }

methods is always an array, never null. pending_token proves only that this caller already supplied a correct first factor — it is its own token type, not an access or refresh token, and does nothing on any other endpoint.

Errors: invalid_credentials (401), account_locked (403), rate_limited (429)

POST /refresh

Public.

curl -X POST https://api.example.com/v1/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "a1b2..."}'

Response 200: same shape as /login's no-second-factor case.

Errors: invalid_token (401), token_reused (401) — reuse means the whole session family was already revoked. Discard every stored token and force a full login; don't retry.

GET /health

Public.

curl https://api.example.com/v1/health

Response 200: { "data": { "status": "ok" } }. Response 503 with {"error":{"code":"db_unreachable",...}} if the database is unreachable — this bypasses the normal error mapping entirely, it's the one other hardcoded response besides bad_request.

Second-factor login completion

All five are public — they are how the caller finishes authenticating and don't have a token yet. All but the magic-link pair take the pending_token from a paused /login.

POST /login/totp

curl -X POST https://api.example.com/v1/login/totp \
  -H "Content-Type: application/json" \
  -d '{"pending_token": "spt_...", "code": "123456"}'

Response 200: tokens.

POST /login/passkey/begin

curl -X POST https://api.example.com/v1/login/passkey/begin \
  -H "Content-Type: application/json" \
  -d '{"pending_token": "spt_..."}'

Response 200: { "data": { "public_key": { ... }, "ceremony_token": "..." } } — public_key is a raw JSON object, not a JSON-encoded string, ready to hand to navigator.credentials.get().

POST /login/passkey/finish

curl -X POST https://api.example.com/v1/login/passkey/finish \
  -H "Content-Type: application/json" \
  -d '{"pending_token": "spt_...", "ceremony_token": "...", "credential": { ... }}'

credential is the browser's raw ceremony output, passed through unmodified — the engine does its own shape checking.

Response 200: tokens.

POST /login/recovery-code

curl -X POST https://api.example.com/v1/login/recovery-code \
  -H "Content-Type: application/json" \
  -d '{"pending_token": "spt_...", "code": "xxxx-xxxx"}'

Response 200: tokens.

POST /magic-link/request

Answers 200 whether or not the email belongs to an account, and never creates one — anything else would let a caller enumerate registered addresses one request at a time.

curl -X POST https://api.example.com/v1/magic-link/request \
  -H "Content-Type: application/json" \
  -d '{"email": "devray@example.com"}'

Response 200: { "data": {} }, always.

POST /magic-link/complete

curl -X POST https://api.example.com/v1/magic-link/complete \
  -H "Content-Type: application/json" \
  -d '{"token": "<raw token from the email>"}'

Response 200: tokens, or the paused second-factor shape.

Errors across this group: invalid_totp_code (401), invalid_pending_login (401), invalid_ceremony_token (400), invalid_passkey_response (401), no_passkeys_enrolled (400), totp_not_configured / passkeys_not_configured / magic_link_not_configured / recovery_codes_not_configured (404), verification_token_invalid / verification_token_expired (400)

Sessions

GET /sessions

Requires auth.

curl https://api.example.com/v1/sessions \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response 200:

{
  "data": [
    {
      "id": "019f...",
      "ip": "1.2.3.4",
      "user_agent": "Mozilla/5.0...",
      "created_at": "2026-08-14T08:32:38-04:00",
      "label": "Chrome on macOS",
      "device": { "browser": "Chrome", "os": "macOS", "form": "desktop" },
      "location": {}
    }
  ]
}

label/device/location were added in a documented breaking response change (spec bumped to 1.1). The four original fields (id, ip, user_agent, created_at) kept their names and types unchanged. label is derived on read from the session's own IP and user agent — nothing new is stored, so existing sessions get one retroactively with no backfill. It's never empty: an unparseable or missing user agent yields "Unknown device". location is always present as an object but its fields are empty on a deployment with no geolocator configured — every real implementation of that interface calls a third-party service, which stays a deployment's own decision. Neither token_hash nor family_id is ever included here.

DELETE /sessions/{id}

Requires auth.

curl -X DELETE https://api.example.com/v1/sessions/019f... \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response 200: { "data": { "status": "session revoked" } }

Errors: session_not_owned (403) — someone else's session, not 404, so ownership failures stay distinguishable from a genuinely missing row.

Account management

POST /logout

Requires auth. Revokes one session.

curl -X POST https://api.example.com/v1/logout \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"session_id": "019f..."}'

POST /logout-all

Requires auth. No body.

curl -X POST https://api.example.com/v1/logout-all \
  -H "Authorization: Bearer $ACCESS_TOKEN"

GET /verify

Requires auth.

curl https://api.example.com/v1/verify \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response 200: { "data": { "user_id": "019f..." } }

POST /change-password

Requires auth. Revokes every session for the user, including the one making this request — this is the engine's own behavior, not an API-layer choice. The client must discard its tokens and force a fresh login afterward.

curl -X POST https://api.example.com/v1/change-password \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"current_password": "Pass@2026", "new_password": "NewPass@2027"}'

Errors: invalid_credentials (401), password_policy_violation (400, +details), password_breached (400)

POST /delete-account

Requires auth. Irreversible.

curl -X POST https://api.example.com/v1/delete-account \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"current_password": "Pass@2026"}'

POST /email/request-change

Requires auth. Does not change the email yet — sends a verification token to the new address.

curl -X POST https://api.example.com/v1/email/request-change \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"new_email": "proguy@example.com"}'

POST /email/confirm-change

Public — deliberately requires no Authorization header, since the user may be clicking a link from an email client with no active session. The token is the proof.

curl -X POST https://api.example.com/v1/email/confirm-change \
  -H "Content-Type: application/json" \
  -d '{"token": "<raw token from the verification email>"}'

Errors: verification_token_invalid / verification_token_expired (400)

Second-factor management

Requires auth for all of these. TOTP/passkeys need ENCRYPTION_KEY configured server-side; otherwise every route below answers 404.

POST /totp/enroll

curl -X POST https://api.example.com/v1/totp/enroll \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response 200: { "data": { "otpauth_url": "otpauth://..." } }

POST /totp/confirm

Must succeed before TOTP actually gates login — enrolling alone does not.

curl -X POST https://api.example.com/v1/totp/confirm \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'

POST /totp/disable

curl -X POST https://api.example.com/v1/totp/disable \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"password": "Pass@2026"}'

Errors: totp_not_enabled (400), totp_already_enabled (409, on a second enroll), invalid_totp_code (401)

POST /passkeys/register/begin

curl -X POST https://api.example.com/v1/passkeys/register/begin \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response 200: { "data": { "public_key": { ... }, "ceremony_token": "..." } }

POST /passkeys/register/finish

curl -X POST https://api.example.com/v1/passkeys/register/finish \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ceremony_token": "...", "credential": { ... }, "nickname": "MacBook Touch ID"}'

GET /passkeys

curl https://api.example.com/v1/passkeys \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response 200: { "data": [ { "credential_id": "...", "nickname": "...", "created_at": "...", "last_used_at": "..." } ] } — credential_id is base64url-encoded, matching how it travels in the WebAuthn spec; pass it back verbatim to delete. last_used_at is omitted entirely if the passkey has never been used.

DELETE /passkeys/{credentialID}

curl -X DELETE https://api.example.com/v1/passkeys/<credentialID> \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"password": "Pass@2026"}'

Errors across this group: no_passkeys_enrolled (400), invalid_passkey_response (401), invalid_ceremony_token (400)

POST /recovery-codes/generate

Generates a fresh batch, invalidating any previous one. The response is the only time the raw codes are ever shown — the engine stores only hashes.

curl -X POST https://api.example.com/v1/recovery-codes/generate \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response 200: { "data": { "codes": ["xxxx-xxxx", "..."], "notice": "..." } }

API keys

For machine-to-machine auth, deliberately outside the second-factor system. Requires auth for all three.

POST /api-keys

curl -X POST https://api.example.com/v1/api-keys \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "CI deploy bot", "scopes": ["deploy"], "expires_in_days": 0}'

Response 201: { "data": { "key": "ck_...", "notice": "...", "api_key": { "id": "019f...", "name": "CI deploy bot", "prefix": "ck_9f3a", "scopes": ["deploy"], "expires_at": null, "expired": false, "created_at": "..." } } }

key is shown exactly once, here — nothing on the api_key object can reproduce it. expires_in_days of 0 or omitted means the key never expires (the honest default for a credential living in a deploy pipeline's environment); valid range is 0 to 3650. scopes are host-defined strings, stored and returned verbatim — the engine never interprets one, and no endpoint enforces a scope yet.

GET /api-keys

curl https://api.example.com/v1/api-keys \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response 200: { "data": [ { "id": "...", "name": "...", "prefix": "...", "scopes": [...], "expires_at": null, "expired": false, "created_at": "...", "last_used_at": null } ] }

expired is the server's own computed answer, sent rather than left for a client to derive from expires_at, so the two can never disagree. last_used_at is written at most once every five minutes — it answers "is anything still using this" rather than the exact time of the last request.

DELETE /api-keys/{keyID}

curl -X DELETE https://api.example.com/v1/api-keys/019f... \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Errors across this group: api_key_not_found (404) — a key belonging to someone else, a nonexistent key, and an already-revoked key all answer this identically, so a caller can never learn whether somebody else's key exists. invalid_api_key_scope / invalid_api_key_ttl (400), api_keys_not_configured (404).

Ask-AI widget

POST /ask-ai

Requires auth — not the admin role. This is the one AI-assisted endpoint in the whole API that isn't behind RequireAdmin, and that's deliberate: every other AI tool answers an operator's question about the deployment, this one answers an ordinary signed-in user's question about their own account.

curl -X POST https://api.example.com/v1/ask-ai \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"question": "what sessions do I have open?"}'

Response 200: { "data": { "answer": "...", "row_count": 2 } }

The request body carries nothing but the question — there's no field that could name a different user, so there's nowhere for a caller to even attempt to ask about somebody else. answer is plain text, deterministically rendered from the matching rows rather than composed by a second model call, so the same question over the same data always produces the same string. No matching rows renders as the literal string "No matching results.", never an empty string — "nothing matched" and "the widget failed" must never look alike. row_count reflects the same rows answer was built from, after any deployment-side row cap; 0 is a real answer, not an error.

This endpoint is still unavailable on a SQLite deployment, since the provider it reads lives in the Postgres-only settings table — it answers 404 not_configured, the same shape as a Postgres deployment with nothing configured, so a client never has to know which backend it's talking to.

Errors: not_configured (404), ask_ai_widget_disabled (404), origin_not_allowed (403), invalid_question (400, empty or over 500 characters), question_not_answerable (400) — this last one covers two different internal reasons (the deployment's configured scope refusing, or the engine's own fail-closed default for an entity it can't bind to one user) and deliberately never says which, since naming either would describe this deployment's schema to whoever is asking questions at it.