Authentication#
This page covers the contracts for account sign-up, login, sessions, captcha, and developer API keys.
Base URL
| Environment | Base URL |
|---|---|
| Production | https://zuzunza.com/api/v1 |
| Local | http://localhost:3001/api/v1 |
Contents#
- Authentication model
- Response envelope
- Endpoints
- Captcha
- Register / Signup
- Login
- Logout / Refresh
- Me (profile)
- Sessions
- OAuth
- Developer API keys
- curl and JS/TS examples
- Error code summary
- Shared browser login
1. Authentication model#
The ZUKU API accepts two kinds of credentials.
| Method | Header | Use |
|---|---|---|
| Bearer session | Authorization: Bearer <access_token> |
A login session. Required by most write APIs and by profile, session, and developer key management. |
| X-API-Key | X-API-Key: sk_live_… |
A key issued from the developer portal. The sk_live_ prefix is required. When no Bearer token is present, some server-to-server routes (for example, content creation) use it to identify the caller. |
Summary of rules:
- Session tokens are
tokens.access_tokenandtokens.refresh_tokenfrom thePOST /auth/loginorPOST /auth/registerresponse. token_typeis always"Bearer".- The raw developer key (
key) is returned only once, right after it is issued. Afterwards,GET /developer/keysreturns metadata only. - Most routes in these docs, including social, notifications, DMs, and session management, accept Bearer sessions only.
2. Response envelope#
Success:
{
"success": true,
"data": { },
"meta": {
"request_id": "…",
"timestamp": "2026-08-22T04:00:00Z",
"version": "v1"
}
}
Failure:
{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "…",
"details": [{ "field": "password", "message": "…" }]
},
"meta": { "request_id": "…", "timestamp": "…", "version": "v1" }
}
details is included only for cases such as validation errors (VALIDATION_ERROR).
Some successful responses are 204 No Content with no body (logout, session revocation, API key deletion).
3. Endpoints#
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/auth/register |
None† | Sign up |
POST |
/auth/signup |
None† | Same handler as /auth/register (alias) |
POST |
/auth/login |
None† | Log in and issue a session |
POST |
/auth/logout |
Bearer | Revoke the current access session → 204 |
POST |
/auth/refresh |
None (token in body) | Rotate the access token |
GET |
/auth/me |
Bearer | Current user |
PATCH |
/auth/me |
Bearer | Partially update the profile |
GET |
/auth/sessions |
Bearer | List active sessions |
DELETE |
/auth/sessions/{id} |
Bearer | Revoke one session → 204 |
POST |
/auth/oauth/{provider} |
— | 501 NOT_IMPLEMENTED |
POST |
/captcha/challenge |
None | Issue a proof-of-work challenge |
POST |
/captcha/verify |
None | Verify a token (server use and debugging) |
POST |
/developer/keys |
Bearer | Issue an API key → 201 |
GET |
/developer/keys |
Bearer | List your keys |
DELETE |
/developer/keys/{id} |
Bearer | Revoke a key → 204 |
† In environments where CAPTCHA_HMAC_SECRET is set, the register and login bodies must include a valid zcaptcha_token.
4. Captcha#
The captcha protocol, environment variables, and solving flow are described in detail in Captcha.
POST /captcha/challenge#
- 503
CAPTCHA_NOT_CONFIGURED:CAPTCHA_HMAC_SECRETis not set - Example
dataon success:
{
"algorithm": "SHA-256",
"challenge": "…",
"salt": "…",
"signature": "…",
"maxnumber": 100000
}
POST /captcha/verify#
Request:
{ "token": "<solved-token>", "dev_host": "localhost" }
- 503
CAPTCHA_NOT_CONFIGURED - 403
CAPTCHA_FAILED: invalid token - Success:
{ "success": true }(inside the envelope'sdata)
Register / login gate (zcaptcha_token)#
| Server configuration | Behavior |
|---|---|
CAPTCHA_HMAC_SECRET not set |
Gate disabled (passes). Sign-up and login are not blocked. |
CAPTCHA_HMAC_SECRET set |
zcaptcha_token is required in the body. On failure, 403 CAPTCHA_FAILED. |
CAPTCHA_DEV_BYPASS=1 + allowed token/host |
Development bypass is possible |
5. Register / Signup#
POST /auth/register ≡ POST /auth/signup
Request body#
| Field | Required | Rule |
|---|---|---|
email |
Yes | Must contain @ |
password |
Yes | At least 8 characters |
password_confirm |
Yes | Must match password |
handle |
Yes | Must not be empty |
display_name |
Optional | Defaults to the handle |
zcaptcha_token |
Conditional | Required when the captcha secret is set |
Success 201#
data.user + data.tokens (access_token, refresh_token, token_type, expires_in)
Common errors#
| HTTP | code | Situation |
|---|---|---|
| 409 | ALREADY_AUTHENTICATED |
Sign-up attempted while already logged in with a valid Bearer token |
| 409 | EMAIL_EXISTS |
Email already in use |
| 409 | LEGACY_ACCOUNT_EXISTS |
An existing ZUKU account already has this email/handle → direct the user to log in |
| 409 | HANDLE_EXISTS |
Handle already in use (or a database duplicate) |
| 422 | VALIDATION_ERROR |
Password, email, or handle validation failed |
| 403 | CAPTCHA_FAILED |
Captcha gate failed |
6. Login#
POST /auth/login
Request body#
| Field | Required | Description |
|---|---|---|
identifier or email |
Yes (one of them) | identifier takes precedence. Accepts an email, username, or nickname. |
password |
Yes | |
device_id |
Optional | Session metadata |
zcaptcha_token |
Conditional | When the captcha secret is set |
The server first tries a current ZUKU account, then falls back to linking a legacy account. When a legacy login succeeds, the account is linked to a current ZUKU account and the password is re-hashed.
Success 200#
data.user + data.tokens
Common errors#
| HTTP | code |
|---|---|
| 401 | INVALID_CREDENTIALS |
| 403 | ACCOUNT_SUSPENDED |
| 403 | CAPTCHA_FAILED |
7. Logout / Refresh#
POST /auth/logout#
- Header:
Authorization: Bearer <access_token> - Success: 204 (no body)
- Missing or malformed header: 401
UNAUTHORIZED
POST /auth/refresh#
{ "refresh_token": "…" }
- Success:
data.tokens(a newaccess_token; the existingrefresh_tokenis kept;expires_in≈ 7 days) - Failure: 401
INVALID_REFRESH_TOKEN
8. Me (profile)#
GET /auth/me#
Bearer required. Returns data.user.
Example public user fields: id (usr_…), email, display_name, handle, role, avatar_url, is_verified, created_at, profile_completion_status, bio, website_url, location, cover_url, banner_tone
PATCH /auth/me#
Only the fields you send are updated. Allowed fields: display_name, bio, website_url, location, banner_tone, cover_url, avatar_url
banner_tone must be one of cyan | swipe | jump | slate.
9. Sessions#
Instead of forcing a full logout when the same account signs in elsewhere, users can end only the sessions they don't recognize.
GET /auth/sessions#
data.sessions[]: id, device_label, created_at, last_seen_at, is_current
Raw tokens are never returned.
DELETE /auth/sessions/{id}#
{id}is the numeric session id- You can only revoke your own sessions. Missing or belonging to someone else → 404
- Success: 204
10. OAuth#
POST /auth/oauth/{provider}
Always returns 501 Not Implemented with code NOT_IMPLEMENTED.
OAuth providers are currently out of scope.
11. Developer API keys#
All routes require Bearer.
POST /developer/keys#
{ "name": "CI bot" }
name: 1–50 characters- 201:
data.api_key(metadata) +data.key(plaintext, returned only once)
GET /developer/keys#
data.api_keys array
DELETE /developer/keys/{id}#
Success: 204. Missing or belonging to someone else → 404 API_KEY_NOT_FOUND
Issued keys can be used as X-API-Key: sk_live_… in place of Bearer on some APIs.
12. curl and JS/TS examples#
These examples use the local base URL. For production, replace it with https://zuzunza.com/api/v1.
Register#
curl -sS -X POST "http://localhost:3001/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "demo@example.com",
"password": "password1",
"password_confirm": "password1",
"handle": "demo_user",
"zcaptcha_token": "OPTIONAL_WHEN_CAPTCHA_SET"
}'
const base = "http://localhost:3001/api/v1";
const registerRes = await fetch(`${base}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "demo@example.com",
password: "password1",
password_confirm: "password1",
handle: "demo_user",
// When CAPTCHA_HMAC_SECRET is set:
// zcaptcha_token: solvedToken,
}),
});
const registerJson = await registerRes.json();
const { access_token, refresh_token } = registerJson.data.tokens;
Login#
curl -sS -X POST "http://localhost:3001/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{
"identifier": "demo@example.com",
"password": "password1"
}'
const loginRes = await fetch(`${base}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identifier: "demo@example.com",
password: "password1",
}),
});
const loginJson = await loginRes.json();
const accessToken = loginJson.data.tokens.access_token;
const refreshToken = loginJson.data.tokens.refresh_token;
Refresh#
curl -sS -X POST "http://localhost:3001/api/v1/auth/refresh" \
-H "Content-Type: application/json" \
-d "{\"refresh_token\":\"$REFRESH\"}"
const refreshRes = await fetch(`${base}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
const refreshed = await refreshRes.json();
const newAccess = refreshed.data.tokens.access_token;
Me#
curl -sS "http://localhost:3001/api/v1/auth/me" \
-H "Authorization: Bearer $ACCESS"
const meRes = await fetch(`${base}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const me = await meRes.json();
console.log(me.data.user.handle);
Create an API key#
curl -sS -X POST "http://localhost:3001/api/v1/developer/keys" \
-H "Authorization: Bearer $ACCESS" \
-H "Content-Type: application/json" \
-d '{"name":"my-integration"}'
const keyRes = await fetch(`${base}/developer/keys`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "my-integration" }),
});
const keyJson = await keyRes.json();
// Store the plaintext key from this response; it is never shown again
const rawKey = keyJson.data.key;
13. Error code summary#
| code | Typical HTTP | Meaning |
|---|---|---|
ALREADY_AUTHENTICATED |
409 | Sign-up while logged in |
EMAIL_EXISTS |
409 | Email already in use |
LEGACY_ACCOUNT_EXISTS |
409 | Legacy account exists → log in |
HANDLE_EXISTS |
409 | Handle already in use |
VALIDATION_ERROR |
422 | Field validation |
INVALID_CREDENTIALS |
401 | Login failed |
ACCOUNT_SUSPENDED |
403 | Suspended account |
UNAUTHORIZED |
401 | Missing or invalid Bearer token |
INVALID_REFRESH_TOKEN |
401 | Invalid refresh token |
CAPTCHA_NOT_CONFIGURED |
503 | No captcha secret (challenge/verify) |
CAPTCHA_FAILED |
403 | Captcha failed |
NOT_IMPLEMENTED |
501 | OAuth |
API_KEY_NOT_FOUND |
404 | Key to delete not found |
NOT_FOUND |
404 | Sessions and similar |
See Errors for the full list and Rate limits for request limits.
Shared browser login#
ZUKU's official domains share an HttpOnly session cookie. Set credentials: 'include' on regular browser API requests. When a valid shared cookie is present, the cookie's account takes precedence over a long-stored Bearer token. Server clients without cookies can keep using the existing Bearer contract.
const response = await fetch('https://zuzunza.com/api/v1/auth/me', {
credentials: 'include',
headers: { Accept: 'application/json' },
});
const { data } = await response.json();
// data.user: the user of the current shared session
A successful GET /auth/me extends the same browser session's expiry to 7 days. The cookie is HttpOnly and is never read by docs pages or game code. Existing cookies are migrated to a new browser session during a normal session check.
POST /api/v1/auth/session#
Use this when the user explicitly selects a different saved account. The selected account's Authorization: Bearer <access_token> is required, and no body is needed. Success is 200 with data.user, and the shared browser cookie switches to the selected account. Sending only the cookie returns 401.
await fetch('https://zuzunza.com/api/v1/auth/session', {
method: 'POST',
credentials: 'include',
headers: { Authorization: `Bearer ${selectedAccountAccessToken}` },
});
POST /auth/refresh keeps the existing { refresh_token } → data.tokens contract and keeps the same browser session. POST /auth/logout ends the shared session and clears the cookie. Repeated logouts also return 204. Browser session credentials themselves are never included in response JSON.