Authentication
The Quazzar Space API authenticates every request with a JWT access token. Send it either as a Bearer token in the Authorization header or as the httpOnly access_token cookie that POST /auth/login sets — the API accepts both, and checks the header first.
Base URL
https://app.quazzar.spaceMost authentication endpoints are prefixed with /auth.
Login
POST /auth/login
curl -X POST https://app.quazzar.space/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "your-password"
}'Response (200 OK):
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "8Kc1r...",
"token_type": "bearer"
}The response carries only tokens — there is no user object. Read the account with GET /auth/me after signing in. The same two tokens are also set as httpOnly cookies (access_token, refresh_token), which is how the browser app authenticates.
If the account has an authenticator enrolled, or policy requires one, send the six-digit code as totp_code in the same body. A single-use recovery code is accepted in that field as well.
Refusals:
| Status | Detail | Cause |
|---|---|---|
401 | Invalid credentials | Unknown email, wrong password, or a blocked account |
401 | TOTP code required | Correct password, second factor not yet supplied |
401 | TOTP code required or invalid | A code was supplied and did not verify |
401 | TOTP required. Please configure an authenticator app... | Policy requires MFA and the account has none enrolled |
401 | Please accept your invitation via the email link before logging in | A pending, unexpired invitation exists for this address |
423 | Account temporarily locked due to failed login attempts... | Lockout is in force; the body also carries locked_until |
422 | — | Missing or malformed fields |
A blocked account is refused like an unknown one
An account with is_active cleared — blocked by an administrator — is refused 401 Invalid credentials, the same status and the same body an unknown address gets, and no tokens are issued. Login used to answer 200 and hand back a real token pair that every subsequent request then rejected, which reads as “login works, the app is broken” rather than “this account is suspended”.
Saying “your account is disabled” is deliberately avoided: it would turn the login form into an account-state oracle. The password is verified before the check either way, so the timing matches too.
An access token issued before a block stops working immediately. The account row is re-read on every request, and the paths that decode a token themselves — GET /mcp/status, which takes the token as a query parameter, and the scorecards and gitops progress WebSockets, which cannot use a dependency — go through the same resolver as everything else, so they refuse it as well. A refresh token cannot mint a replacement: POST /auth/token/refresh answers 401 for an inactive account.
Using the token
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
https://app.quazzar.space/auth/meToken refusals all arrive as 401 with one of a small set of details: Invalid or expired token, Invalid token claims, Invalid subject in token, Invalid token version (the session was revoked, e.g. by a password reset), Token revoked, or User not found or inactive. That last one covers “no such account” and “that account is blocked” with one message on purpose, so a caller supplying account ids cannot learn which of them exist.
Refresh a session
POST /auth/token/refresh
curl -X POST https://app.quazzar.space/auth/token/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "8Kc1r..."}'The refresh token may come from the JSON body or from the refresh_token cookie; an empty body is fine when the cookie is present. Returns a new access_token / refresh_token pair in the same shape as login.
Refreshing does not update the account’s last-login timestamp — a refresh token is only ever issued by a login, so refreshing cannot change whether an account has ever signed in.
Registration
POST /auth/register
curl -X POST https://app.quazzar.space/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "secure-password-123",
"name": "Jane Developer",
"phone": "+1-555-0100"
}'The body is email, password (minimum 8 characters), and optional name and phone. There is no full_name or company_name field.
Response (201 Created):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"name": "Jane Developer",
"phone": "+1-555-0100",
"is_superuser": false,
"is_staff": false,
"admin_role": null,
"preferred_language": null,
"navigation_mode": null,
"job_title": null,
"timezone": null,
"table_density": null
}A new account is created as a customer (see Account classes) and is put on the Free plan.
| Status | Description |
|---|---|
400 | Email already registered, or the password fails the configured policy |
403 | Self-service registration is disabled, registration is invite-only, or the email domain is not on the allowlist |
422 | Validation error (invalid email, password shorter than 8 characters) |
Logout
POST /auth/logout
curl -X POST https://app.quazzar.space/auth/logout \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Response: 204 No Content. Revokes the refresh token (from the body or the cookie) and clears both auth cookies. Requires a valid access token.
Sessions
| Endpoint | Description |
|---|---|
GET /auth/sessions | List active sessions — device, IP, created_at, last_seen_at, and current for the calling session |
DELETE /auth/sessions/{session_id} | Revoke one session (204) |
DELETE /auth/sessions | Revoke every other session; returns {"revoked": n} |
Two-Factor Authentication (TOTP)
Enrolment is two calls: provision mints a secret and returns it to you without storing anything, then verify proves you can generate codes from it and stores it as a named device. Up to three authenticators per account.
POST /auth/totp/provision
curl -X POST https://app.quazzar.space/auth/totp/provision \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Response (200 OK):
{
"secret": "JBSWY3DPEHPK3PXP",
"otpauth_url": "otpauth://totp/[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Quazzar%20Space",
"issuer": "Quazzar Space",
"account_name": "[email protected]",
"qrcode_data_url": "data:image/png;base64,iVBOR..."
}Nothing is persisted by this call. Keep the secret — the next call needs it back.
POST /auth/totp/verify
curl -X POST https://app.quazzar.space/auth/totp/verify \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"secret": "JBSWY3DPEHPK3PXP", "code": "123456", "name": "Phone"}'Response: 204 No Content. All three fields are required. 400 for an invalid code, a missing name, or a fourth authenticator.
Recovery codes are not returned here — they are a separate endpoint.
Recovery codes
| Endpoint | Description |
|---|---|
POST /auth/totp/recovery/regenerate | Issue a fresh set of 10 single-use codes, replacing any prior set. Returns {"codes": [...]} — shown once |
GET /auth/totp/recovery | {"total": n, "unused": n} |
A recovery code is accepted in the totp_code field of POST /auth/login and is consumed on use.
Managing authenticators
| Endpoint | Description |
|---|---|
GET /auth/totp/devices | List enrolled authenticators |
DELETE /auth/totp/devices/{device_id} | Remove one authenticator (204) |
POST /auth/totp/delete | Clear the account’s legacy single-secret TOTP. Body {"password": "..."}; 204 on success, 401 for a wrong password |
POST /auth/totp/delete does not remove totp/devices rows — use the per-device endpoint for those. There is no DELETE /auth/totp.
Password Reset
POST /auth/password/reset
curl -X POST https://app.quazzar.space/auth/password/reset \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}'Always answers 200 {"status": "ok"}, whether or not the address exists, and pads every response to a constant minimum duration so timing does not reveal it either.
If the account has an authenticator enrolled, a valid totp_code must be included in this body. Without it the request still answers 200 and simply sends nothing — second-factor accounts cannot be reset from mailbox control alone. The reset link is valid for 60 minutes.
POST /auth/password/reset/confirm
curl -X POST https://app.quazzar.space/auth/password/reset/confirm \
-H "Content-Type: application/json" \
-d '{
"token": "reset-token-from-email",
"new_password": "new-secure-password-456"
}'Answers 200 {"status": "ok"}. Confirming a reset revokes every session: all refresh tokens are marked revoked and the account’s token version is bumped, so access tokens issued before the reset are refused.
| Status | Description |
|---|---|
400 | Empty token, token expired or already used, or the new password fails the policy |
404 | No such reset token |
The paths are /auth/password/reset and /auth/password/reset/confirm. There is no /auth/password-reset/request.
Current User
GET /auth/me
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
https://app.quazzar.space/auth/meReturns the same object shape as registration.
PATCH /auth/me
curl -X PATCH https://app.quazzar.space/auth/me \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Jane M. Developer", "job_title": "SRE", "timezone": "Europe/Kyiv"}'Writable fields: email, name, phone, preferred_language, navigation_mode, job_title, timezone, table_density. Returns the updated object.
There is no /users/me; the profile lives under /auth/me. (POST /users exists and creates an account, but self-service signup should go through POST /auth/register, which applies the registration policies above.)
GET /auth/me/capabilities
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
"https://app.quazzar.space/auth/me/capabilities?project_id=PROJECT_UUID"Returns {project_id, is_owner, is_member, capabilities} — the caller’s effective permission keys in one project.
API Keys
GET/POST/DELETE /me/api-keys manage named, scoped, expiring API keys, and require both the crud_pro and crud_api features. The key value is returned once, on creation.
Unverified: what a key authenticates. Nothing in the backend reads
user_api_keys.token_hashback — it is written on create and never compared, and no request path accepts a key in place of an access token. On the evidence in the code these keys are managed but not yet honoured as request credentials, so treat “authenticate with an API key” as unconfirmed until someone runs a request with one. To check: create a key and call any authenticated endpoint with it asAuthorization: Bearer <key>; a401 Invalid or expired tokenconfirms it is not accepted.
The /metrics endpoint has its own separate keys (/me/metrics-api-keys), and Pulsar has its own (/pulsar/api-keys).
Rate Limits
| Endpoint | Limit |
|---|---|
POST /auth/login | 10 requests per minute per IP |
POST /auth/password/reset | 5 requests per minute per IP |
POST /billing/stripe/webhook | 120 requests per minute per IP |
These three are the only rate-limited routes. POST /auth/register is not rate-limited, and there is no global per-token limit on authenticated endpoints. Limits are keyed on the remote address. Exceeding one returns 429 Too Many Requests.
Next Steps
- Errors & Permissions — why an id you do not own answers
404 - Invitations — inviting people and accepting invitations
- Projects API — create and manage projects
- Services API — register and query services
- Billing API — plans, subscriptions, and invoices