Base URL: http://127.0.0.1:5000/api/v1 (internal — see
docs/ARCHITECTURE.md §2 for why the browser never calls this directly).
All responses are JSON with a consistent envelope:
{ "success": true, "data": { ... } }{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "..." } }All endpoints except register and login require an
X-Session-Token: <token> header. A missing/invalid/expired token returns
401 UNAUTHORIZED.
Request:
{ "username": "alice", "email": "alice@example.com", "password": "correcthorse1" }username: 3–32 chars, letters/numbers/underscore/hyphen only.password: at least 10 characters, containing at least one letter and one number.
Responses: 201 created · 422 validation error · 409 username or email already taken.
Request:
{ "identifier": "alice", "password": "correcthorse1" }identifier may be a username or email.
Responses: 200 with { token, user } · 401 invalid credentials (same generic message whether the account doesn't exist or the password is wrong) · 429 rate-limited after repeated failures (see NOTES_MAX_LOGIN_ATTEMPTS / NOTES_LOGIN_ATTEMPTS_WINDOW_MINUTES).
Requires X-Session-Token. Invalidates the session server-side. Responds 200.
Requires X-Session-Token. Returns the current user. Responds 200 with { user }, or 401 if the token is missing/expired.
All note endpoints are implicitly scoped to the authenticated user — there is no way to pass another user's ID; ownership is derived entirely from the session token.
Query parameters (all optional):
| Param | Values | Default |
|---|---|---|
category_id |
integer | none (all categories) |
archived |
1/true (archived only), all (both), anything else/absent → active only |
active only |
sort |
newest, oldest |
newest |
q |
search text (matches title or body) | none |
Response: 200 with { notes: [...], count: N }.
Request: { "title": "...", "body": "...", "category_id": 3 } (category_id optional/nullable).
Responses: 201 created · 422 validation error (empty/too-long title, body over 50,000 chars, category_id not owned by the user).
200 with { note }, or 404 if the note doesn't exist or belongs to another user (no distinction — prevents existence leaks).
Request: any of title, body, category_id. Omitted fields keep their
current value; "category_id": null explicitly clears the category.
Responses: 200 updated · 404 not found/not owned · 422 validation error.
200 on success, 404 if not found/not owned.
Sets is_archived = 1. 200 with the updated note, or 404.
Sets is_archived = 0. 200 with the updated note, or 404.
200 with { categories: [{ id, name, color, created_at, note_count }, ...] }, ordered by name.
Request: { "name": "Work", "color": "#4C6EF5" } (color optional, must be a 6-digit hex code if provided).
Responses: 201 created · 422 validation error · 409 a category with this name already exists for this user.
Same body shape as create. 200 updated · 404 not found/not owned · 409 name conflict · 422 validation error.
200 on success, 404 if not found/not owned. Notes referencing the
deleted category fall back to uncategorized (category_id = NULL) rather
than being deleted.
Query parameters:
| Param | Required | Notes |
|---|---|---|
q |
yes | 422 if missing/empty |
category_id |
no | filters within results |
archived |
no | 1/true archived-only, all/absent → both |
200 with { notes: [...], count: N, query: "..." }. LIKE metacharacters
(%, _) in q are escaped and matched literally, not as wildcards.
200 with:
{
"total_notes": 12,
"archived_notes": 3,
"recent_notes": [ /* last 5 active notes by created_at desc */ ],
"recently_edited": [ /* last 5 active notes by updated_at desc */ ],
"categories": [ { "id": 1, "name": "Work", "color": "#4C6EF5", "note_count": 5 }, ... ]
}| Code | Status | Meaning |
|---|---|---|
VALIDATION_ERROR |
422 | Input failed validation — message lists the specific issue(s) |
UNAUTHORIZED |
401 | Missing, malformed, or expired session token |
INVALID_CREDENTIALS |
401 | Login failed |
RATE_LIMITED |
429 | Too many failed login attempts |
NOT_FOUND |
404 | Resource doesn't exist, or doesn't belong to the caller |
CONFLICT |
409 | Unique constraint violation (username/email/category name) |
INVALID_JSON |
400 | Request body was not valid JSON |
METHOD_NOT_ALLOWED |
405 | Wrong HTTP method for this path |
SERVICE_UNAVAILABLE |
503 | Database temporarily unreachable |
INTERNAL_ERROR |
500 | Unhandled exception — logged server-side, never detailed to the client |