Techadamia Backend — API Reference
The complete HTTP API: every endpoint, what it expects, and what it returns. Skim the endpoint index to find what you need, then jump to its card for the details.
Note
Two things to know before your first call: request bodies are form-encoded, not JSON, and anything beyond the public endpoints needs a session (cookie or bearer token). See Authentication.
Jump to: Basics · Endpoint index · Authentication · Data model · Pagination · Rate limiting · Errors
Basics
| Aspect | Value |
|---|---|
| Base URL | Your deployment, e.g. http://localhost:8080. No version prefix in routes. |
| Request bodies | application/x-www-form-urlencoded for everything except POST /media, which uses multipart/form-data. JSON request bodies are not accepted. |
| Responses | JSON. |
| IDs | UUID strings. |
| Timestamps | RFC 3339, e.g. 2026-05-27T18:31:27.679128Z. Null timestamps are omitted from the response. |
| Roles | reader, author, admin. Self-registration creates an author by default, or a reader with role=reader. |
| User statuses | pending, active, suspended, deleted. Only active users can authenticate. |
| Post statuses | draft, published, archived. |
| Correlation | Every response carries an X-Request-Id header for log correlation. |
Endpoints are grouped by the access level they require:
| Access level | Requirement |
|---|---|
| Public | No authentication |
| Authenticated | Any active user with a valid session |
| Author | Active user with role author or admin |
| Admin | Active user with role admin |
Endpoint index
Public
| Method | Endpoint | Description |
|---|---|---|
POST |
/register |
Register an author (starts pending) or reader account |
POST |
/login |
Authenticate and start a session |
POST |
/password-reset/request |
Email a password-reset link |
POST |
/password-reset/confirm |
Set a new password with a reset token |
POST |
/verify-email |
Redeem an email-verification token |
POST |
/verify-email/resend |
Re-send a verification link |
POST |
/admin/bootstrap |
Create the first admin — first deploy only |
GET |
/health |
Liveness probe |
GET |
/ready |
Readiness probe (checks DB, and Redis if configured) |
GET |
/recent |
List recently published posts (filters: category, author, sort) |
GET |
/search |
Full-text search over published posts |
GET |
/posts/:slug |
Fetch one published post by slug |
GET |
/posts/:slug/comments |
List a post's comments |
GET |
/categories |
List categories |
GET |
/users/:username |
Public profile |
GET |
/users/:username/posts |
A user's published posts |
GET |
/users/:username/followers |
Who follows this user |
GET |
/users/:username/following |
Who this user follows |
GET |
/media/:key |
Serve an uploaded file |
Authenticated (any active account: reader, author, or admin)
| Method | Endpoint | Description |
|---|---|---|
POST |
/logout |
End the current session |
GET |
/me |
Own profile + unread counters |
GET |
/me/posts |
Own posts, drafts and archived included |
PUT |
/me |
Edit profile (partial form update) |
PUT |
/me/password |
Change password (rotates sessions) |
GET |
/events |
Server-Sent Events stream (notifications, messages) |
GET |
/notifications |
Notification inbox + unread count |
PUT |
/notifications/read |
Mark all notifications read |
PUT |
/notifications/:id/read |
Mark one notification read |
POST |
/posts/:slug/comments |
Comment on a post (optional parent_comment_id for replies) |
PUT |
/comments/:id |
Edit own comment |
DELETE |
/comments/:id |
Delete own comment (admins: any) |
PUT |
/posts/:slug/like |
Like a post (idempotent) |
DELETE |
/posts/:slug/like |
Remove a like |
PUT |
/users/:username/follow |
Follow a user (idempotent) |
DELETE |
/users/:username/follow |
Unfollow |
GET |
/messages |
Conversation list (latest message + unread per peer) |
GET |
/messages/:username |
Message thread with a user |
POST |
/messages/:username |
Send a direct message |
PUT |
/messages/:username/read |
Mark a thread read |
POST |
/media |
Upload a media file (also used for avatars) |
Author
| Method | Endpoint | Description |
|---|---|---|
POST |
/posts |
Create a post |
PUT |
/posts/:slug |
Update a post |
DELETE |
/posts/:slug |
Soft-delete a post |
Admin
| Method | Endpoint | Description |
|---|---|---|
GET |
/users |
List all users (with post counts) |
GET |
/users/pending |
List users awaiting activation |
DELETE |
/users/:id |
Soft-delete a user |
PUT |
/users/:id/status |
Change a user's status |
PUT |
/posts/archive/:id |
Archive a post |
POST |
/categories |
Create a category |
PUT |
/comments/:id/hide |
Hide a comment (moderation) |
Authentication
Every non-public endpoint needs a session. Sessions are server-side (a UUID stored in the database, 7-day TTL). There are two interchangeable ways to present one.
Important
Logging in wipes the user's other sessions — one active session per user. Only active users authenticate, regardless of mechanism.
Cookie + CSRF (browsers)
POST /login sets two cookies:
| Cookie | Flags | Purpose |
|---|---|---|
session_id |
HttpOnly | Identifies the session. |
csrf_token |
readable by JS | Used for CSRF protection. |
For any state-changing method (POST, PUT, PATCH, DELETE) sent with the session_id cookie, you must also send an X-CSRF-Token header equal to the csrf_token cookie (double-submit). Missing header → 403 csrf token missing; mismatch → 403 csrf token invalid.
Bearer token (programmatic clients)
Send the session ID as a bearer token. Bearer-authenticated requests skip CSRF entirely — no cookies involved.
curl -H "Authorization: Bearer <session_id>" -X POST https://api.example.com/posts \
--data-urlencode "title=Hello" \
--data-urlencode "md_content=Body" \
--data-urlencode "status=draft"
Tip
For the full session, CSRF, and password-hashing model, see SECURITY.md.
Data model
Shared object shapes referenced by the endpoint cards below. Fields marked omitted when null/empty simply do not appear in the JSON when they have no value.
Post
{
"post_id": "025d33b1-…",
"title": "Hello World",
"slug": "hello-world",
"md_content": "body",
"status": "published",
"published_at": "2026-05-27T18:31:27.679128Z",
"updated_at": "2026-05-27T18:31:27.679128Z",
"archived_at": "2026-05-27T18:31:27.679128Z",
"author_user_id": "ab6fe699-…",
"author_username": "yazar",
"author_display_name": "Yazar Adı",
"categories": [ { "category_id": "…", "name": "Yazılım", "slug": "yazilim" } ],
"like_count": 3,
"comment_count": 5,
"liked_by_me": true,
"media": [ { "media_id": "…", "storage_key": "uuid.jpg" } ]
}
md_content, published_at, updated_at, archived_at, author_*, and media are omitted when empty/null. categories is always an array (possibly empty). liked_by_me appears on GET /posts/:slug only when the request carries a valid session. The /recent feed omits md_content and media for brevity — fetch the full body with GET /posts/:slug.
Category
{ "category_id": "48d4ce1c-…", "name": "Technology", "slug": "technology" }
User (admin views)
{
"user_id": "ab6fe699-…",
"username": "admin",
"email": "admin@example.com",
"user_role": "admin",
"user_status": "active",
"created_at": "2026-05-27T18:31:27Z",
"last_login_at": "2026-05-27T18:31:27Z",
"deleted_at": "2026-05-27T18:31:27Z",
"post_count": 3
}
created_at, last_login_at, deleted_at, and post_count are omitted when null. post_count is included only by GET /users.
Media
{ "media_id": "550e8400-…", "storage_key": "550e8400-….jpg" }
Pagination
List endpoints accept limit and offset query parameters.
| Param | Type | Notes |
|---|---|---|
limit |
int | Per-endpoint default, capped at a per-endpoint maximum. Non-positive/invalid values fall back to the default. |
offset |
int | Default 0. Negative/invalid values clamp to 0. |
Responses wrap the rows in data and add a pagination object:
{
"data": [ /* … */ ],
"pagination": { "limit": 20, "offset": 0, "count": 1 }
}
| Field | Meaning |
|---|---|
limit |
The effective limit applied. |
offset |
The effective offset applied. |
count |
Number of items in this page. |
Rate limiting
Limited endpoints use a fixed-window limiter, keyed by client IP. Each window is configured by an environment variable (defaults live in .env.example):
| Variable | Applies to |
|---|---|
RATE_LIMIT_LOGIN |
/login and /admin/bootstrap |
RATE_LIMIT_REGISTER |
/register |
RATE_LIMIT_MEDIA |
/media (upload) and /media/:key (serve) |
RATE_LIMIT_WRITE |
Author write endpoints (post create/update/delete) |
RATE_LIMIT_ADMIN |
Admin endpoints |
Every response from a limited endpoint includes:
X-RateLimit-Limit: <max requests per window>
X-RateLimit-Remaining: <requests left in window>
X-RateLimit-Reset: <seconds until the window resets>
When the limit is exceeded the response is 429 with a Retry-After header:
{ "error": "rate limit exceeded" }
Note
Limits are in-memory per instance by default. Set RATE_LIMIT_REDIS_URL to share them across instances — see DEPLOYMENT.md.
Errors
Errors return a JSON object with an error field:
{ "error": "message" }
Validation failures return 400 with a structured errors array:
{
"error": "validation failed",
"errors": [
{ "field": "username", "message": "Username must be at least 3 characters" },
{ "field": "email", "message": "Invalid email address" },
{ "field": "password", "message": "Password must be at least 8 characters" }
]
}
The endpoint cards below list only their endpoint-specific errors. These can occur on many endpoints and are not repeated each time:
| Status | When |
|---|---|
401 |
Authentication required, missing, or invalid (protected endpoints). |
403 |
CSRF check failed, or the account is not active. |
429 |
Rate limit exceeded. |
500 |
Unexpected server error. |
All status codes used by the API
| Status | Meaning |
|---|---|
200 |
Success |
201 |
Resource created |
400 |
Bad request / validation failed |
401 |
Authentication required or failed |
403 |
Forbidden (insufficient role, CSRF failure, inactive account) |
404 |
Not found |
409 |
Conflict (unique constraint, last-admin protection) |
413 |
Payload too large (media upload) |
415 |
Unsupported media type (media upload) |
429 |
Rate limit exceeded |
500 |
Internal server error |
503 |
Service unavailable (not ready) |
Public endpoints
POST /register
Registers a new user. There are two self-service account kinds:
author(default) — startspending; an admin must activate the account (viaPUT /users/:id/status) before the user can log in. Active admins receive anew_pending_usernotification.reader— can comment, like, follow, and message but not write posts. With SMTP configured, the account startspendingand a verification email is sent (POST /verify-emailactivates it). Without SMTP the account is activated immediately.
Access Public · Rate limit RATE_LIMIT_REGISTER · Body form-encoded
Fields
| Field | Required | Rules |
|---|---|---|
username |
yes | 3–50 characters; letters, digits, ., _, -; must start/end alphanumeric (usernames appear in profile URLs) |
email |
yes | Valid email; lowercased and trimmed server-side |
password |
yes | 8–72 characters |
phonenumber |
no | Exactly 10 digits if provided |
role |
no | author (default) or reader |
201 Created
{
"message": "registration received; check your email to verify your account",
"data": { "user_id": "94360182-…", "verification_required": true }
}
Errors — 400 validation failed / invalid role · 409 username or email already exists
POST /login
Authenticates a user and starts a session, setting the session_id and csrf_token cookies. See Authentication.
Access Public · Rate limit RATE_LIMIT_LOGIN · Body form-encoded
Fields
| Field | Required |
|---|---|
email |
yes |
password |
yes |
200 OK (also sets the auth cookies)
{ "user_id": "ab6fe699-…", "username": "admin", "user_role": "admin" }
Errors — 400 email and password are required · 401 invalid credentials · 403 account not active
POST /admin/bootstrap
Creates the first admin account, activated immediately. Intended for first deploy: it refuses with 409 once any admin exists, so it is safe to leave enabled (or clear BOOTSTRAP_TOKEN to disable it).
Access Public + bootstrap token · Rate limit RATE_LIMIT_LOGIN · Body form-encoded
Headers
| Header | Required | Notes |
|---|---|---|
X-Bootstrap-Token |
yes | Must equal the BOOTSTRAP_TOKEN env var (compared in constant time). |
Fields
| Field | Required | Rules |
|---|---|---|
username |
yes | 3–50 characters |
email |
yes | Valid email; lowercased and trimmed |
password |
yes | 8–72 characters |
201 Created
{ "message": "admin account created", "data": { "user_id": "…" } }
Errors — 400 validation failed · 401 invalid bootstrap token · 403 bootstrap is disabled (BOOTSTRAP_TOKEN unset) · 409 an admin account already exists
GET /health
Liveness probe. Always 200 while the process is running.
Access Public
200 OK
{ "status": "ok" }
GET /ready
Readiness probe. Ready only when the database ping succeeds — and, if Redis is configured, the Redis ping too. Use this for load-balancer and orchestrator health checks.
Access Public
| Status | Body | When |
|---|---|---|
200 |
{ "status": "ready" } |
All dependencies healthy. |
503 |
{ "status": "starting" } |
Process not yet marked ready. |
503 |
{ "status": "unavailable" } |
A dependency ping failed. |
GET /recent
Lists published posts from a recent time window, newest first. Each entry carries the author, categories, and like/comment counts; the list omits md_content (see Post).
Access Public
Query parameters
| Param | Default | Notes |
|---|---|---|
time_interval |
7 |
Look-back window in days. |
category |
— | Only posts in this category slug. |
author |
— | Only posts owned by this username. |
sort |
newest |
newest or oldest (by published_at). |
limit |
20 |
Max 100. |
offset |
0 |
200 OK
{
"data": [
{
"post_id": "…", "title": "…", "slug": "…", "status": "published", "published_at": "…",
"author_user_id": "…", "author_username": "yazar",
"categories": [ { "category_id": "…", "name": "Yazılım", "slug": "yazilim" } ],
"like_count": 3, "comment_count": 5
}
],
"pagination": { "limit": 20, "offset": 0, "count": 1 }
}
Errors — 400 Invalid time_interval / invalid sort
GET /posts/:slug
Returns a single published post by slug, including its associated media.
Access Public
Path parameters
| Param | Notes |
|---|---|
slug |
URL-safe post slug. |
200 OK — a Post object wrapped in data:
{
"data": {
"post_id": "025d33b1-…",
"title": "Hello World",
"slug": "hello-world",
"md_content": "body",
"status": "published",
"published_at": "2026-05-27T18:31:27.679128Z",
"updated_at": "2026-05-27T18:31:27.679128Z",
"media": [ { "media_id": "…", "storage_key": "uuid.jpg" } ]
}
}
Errors — 400 Invalid slug format · 404 post not found
GET /categories
Lists categories.
Access Public
Query parameters
| Param | Default | Notes |
|---|---|---|
limit |
50 |
Max 200. |
offset |
0 |
200 OK
{
"data": [ { "category_id": "48d4ce1c-…", "name": "Technology", "slug": "technology" } ],
"pagination": { "limit": 50, "offset": 0, "count": 1 }
}
GET /media/:key
Serves a stored media file by its storage_key (the value returned by POST /media and embedded in each post's media[]). This is how a frontend renders an uploaded image.
Access Public · Rate limit RATE_LIMIT_MEDIA
Path parameters
| Param | Notes |
|---|---|
key |
The storage_key, e.g. 550e8400-….jpg. Must be a bare filename — keys with path separators or .. are rejected. |
200 OK — the raw file bytes, with:
Content-Typederived from the extension (image/jpeg,image/png,image/webp).Cache-Control: public, max-age=31536000, immutable— keys are content-addressed and never change.X-Content-Type-Options: nosniff.
Range and conditional (If-Modified-Since / If-None-Match) requests are supported, so 206 Partial Content and 304 Not Modified may be returned.
<!-- Render an image returned by POST /media or a post's media[] entry -->
<img src="https://api.example.com/media/550e8400-e29b-41d4-a716-446655440000.jpg" />
Errors — 400 invalid media key · 404 media not found
Authenticated endpoints
Require a valid session (cookie or bearer). State-changing requests sent via cookie also require the CSRF header — see Authentication.
POST /logout
Ends the current session and clears the session_id and csrf_token cookies.
Access Authenticated
200 OK
{ "message": "logged out successfully" }
Author endpoints
Require role author or admin, and are subject to RATE_LIMIT_WRITE.
POST /posts
Creates a post owned by the authenticated user. The slug is auto-generated from the title (uniqueness is guaranteed by appending a numeric suffix on collision). published_at is stamped when status=published.
Access Author · Rate limit RATE_LIMIT_WRITE · Body form-encoded
Fields
| Field | Required | Notes |
|---|---|---|
title |
yes | 1–255 characters |
md_content |
yes | Non-empty |
status |
yes | draft or published. Any other value is treated as draft. |
category_ids |
no | UUIDs — comma-separated or repeated fields. All must exist. |
media_ids |
no | UUIDs — comma-separated or repeated fields. Must exist and be owned by you. |
201 Created
{ "message": "post created successfully", "data": { "post_id": "…" } }
Errors — 400 validation failed · 400 invalid category_ids / invalid media_ids (unparseable UUIDs) · 400 with a service message if a referenced category/media ID does not exist or is not yours
PUT /posts/:slug
Updates a post identified by slug. Non-admins may update only posts they own; admins may update any post. The slug itself is immutable.
Access Author (owner) or Admin · Rate limit RATE_LIMIT_WRITE · Body form-encoded
Path parameters
| Param | Notes |
|---|---|
slug |
URL-safe post slug. |
Fields
| Field | Required | Notes |
|---|---|---|
title |
yes | 1–255 characters |
md_content |
yes | Non-empty |
status |
no | draft, published, or archived. Omitted → current status is kept. |
category_ids |
no | UUIDs. See replace semantics below. |
media_ids |
no | UUIDs. See replace semantics below. |
Note
Association replace semantics: if you send category_ids (or media_ids), the post's associations are replaced with exactly that set — sending an empty value clears them. Omit the field to leave the existing associations unchanged.
200 OK
{ "message": "post updated successfully" }
Errors — 400 validation failed · 400 status must be one of: draft, published, archived · 400 invalid category_ids / invalid media_ids · 403 not authorized to update this post · 404 post not found
DELETE /posts/:slug
Soft-deletes a post identified by slug. Non-admins may delete only posts they own; admins may delete any post.
Access Author (owner) or Admin · Rate limit RATE_LIMIT_WRITE
Path parameters
| Param | Notes |
|---|---|
slug |
URL-safe post slug. |
200 OK
{ "message": "post deleted successfully" }
Errors — 403 not authorized to delete this post · 404 post not found
POST /media
Uploads a media file using multipart/form-data. Returns the storage_key you then reference from a post's media_ids and serve via GET /media/:key.
Access Author · Rate limit RATE_LIMIT_MEDIA (also counts against RATE_LIMIT_WRITE) · Body multipart/form-data
Fields
| Field | Required | Notes |
|---|---|---|
file |
yes | Validated against MEDIA_MAX_BYTES (default 10 MB) and MEDIA_ALLOWED_MIME (default image/jpeg,image/png,image/webp). The MIME type is verified by content sniffing and the declared Content-Type. |
curl -H "Authorization: Bearer <session_id>" -F "file=@photo.jpg" https://api.example.com/media
201 Created — storage_key is a relative filename (<uuid>.<ext>), not a path:
{ "message": "media uploaded successfully", "data": { "media_id": "…", "storage_key": "550e8400-….jpg" } }
Errors — 400 file is required · 413 file exceeds maximum allowed size · 415 unsupported media type
Admin endpoints
Require role admin, and are subject to RATE_LIMIT_ADMIN.
GET /users
Lists all users, each with a post_count. See the User shape.
Access Admin
Query parameters
| Param | Default | Notes |
|---|---|---|
limit |
50 |
Max 200. |
offset |
0 |
200 OK
{
"data": [
{
"user_id": "…", "username": "author1", "email": "author1@example.com",
"user_role": "author", "user_status": "active",
"created_at": "…", "last_login_at": "…", "post_count": 2
}
],
"pagination": { "limit": 50, "offset": 0, "count": 1 }
}
GET /users/pending
Lists users with status pending — the activation queue. Same pagination as GET /users; rows do not include post_count.
Access Admin
200 OK
{
"data": [
{ "user_id": "…", "username": "newauthor", "email": "new@example.com",
"user_role": "author", "user_status": "pending", "created_at": "…" }
],
"pagination": { "limit": 50, "offset": 0, "count": 1 }
}
DELETE /users/:id
Soft-deletes a user and wipes their sessions.
Access Admin
Path parameters
| Param | Notes |
|---|---|
id |
Target user UUID. |
200 OK
{ "message": "user deleted successfully" }
Errors — 400 invalid user id · 400 cannot delete your own user · 409 cannot delete the last active admin
PUT /users/:id/status
Updates a user's status — this is how an admin activates a pending author (status=active). Setting deleted routes to a soft-delete; any non-active status wipes the user's sessions.
Access Admin · Body form-encoded
Path parameters
| Param | Notes |
|---|---|
id |
Target user UUID. |
Fields
| Field | Required | Notes |
|---|---|---|
status |
yes | One of pending, active, suspended, deleted. |
200 OK
{ "message": "user status updated successfully" }
Errors — 400 invalid user id · 400 cannot change your own status · 400 status is required · 400 invalid status · 409 cannot deactivate the last active admin
PUT /posts/archive/:id
Archives a post by its UUID. (Authors archive their own posts by sending status=archived to PUT /posts/:slug; this admin route archives any post by ID.)
Access Admin
Path parameters
| Param | Notes |
|---|---|
id |
Post UUID. |
200 OK
{ "message": "post archived successfully" }
Errors — 400 invalid post id · 404 post not found
POST /categories
Creates a category. The slug is auto-generated from the name.
Access Admin · Body form-encoded
Fields
| Field | Required | Rules |
|---|---|---|
name |
yes | 1–100 characters |
201 Created
{ "message": "category added successfully" }
Errors — 400 validation failed · 409 category already exists
PUT /comments/:id/hide
Moderation: hides a comment from public lists without deleting it (recoverable, unlike a delete).
Access Admin
200 OK — { "message": "comment hidden successfully" }
Errors — 400 invalid comment id · 404 comment not found
Account endpoints
GET /me
The authenticated user's own profile, avatar, follower/following counts, and unread notification/message counters. Use this on app load instead of caching the login response.
Access Authenticated
200 OK
{
"data": {
"user_id": "…", "username": "yazar", "email": "yazar@example.com",
"display_name": "Yazar", "bio": "…", "phone_number": "…",
"user_role": "author", "user_status": "active",
"avatar_key": "550e8400-….jpg", "email_verified": true,
"created_at": "…", "last_login_at": "…",
"follower_count": 4, "following_count": 2,
"unread_notifications": 3, "unread_messages": 1
}
}
GET /me/posts
Every non-deleted post the caller owns — drafts and archived included — newest activity first. This is the studio's "manage posts" view; the public equivalent (GET /users/:username/posts) shows published posts only.
Access Authenticated
Query parameters
| Param | Default | Notes |
|---|---|---|
status |
— | Narrow to draft, published, or archived. |
limit |
20 |
Max 100. |
offset |
0 |
200 OK — data: array of { post_id, title, slug, status, published_at, updated_at, archived_at, like_count, comment_count } + pagination.
Errors — 400 invalid status filter
PUT /me
Partial profile update: only submitted form fields change; submitting an empty value clears an optional field. Returns the updated /me payload.
Access Authenticated · Rate limit RATE_LIMIT_WRITE · Body form-encoded
Fields (all optional)
| Field | Rules |
|---|---|
username |
Same rules as registration; must be unique |
display_name |
≤ 100 characters |
bio |
≤ 1000 characters |
phone_number |
10 digits, or empty to clear |
avatar_media_id |
UUID of media you uploaded via POST /media, or empty to clear |
Errors — 400 validation failed / avatar must be media you uploaded · 409 username already exists
PUT /me/password
Changes the password after verifying the current one. Every session is revoked (all devices) and the calling client receives fresh session_id/csrf_token cookies, exactly like a new login. Outstanding password-reset links are invalidated.
Access Authenticated · Rate limit RATE_LIMIT_LOGIN · Body form-encoded
Fields
| Field | Required | Rules |
|---|---|---|
current_password |
yes | |
new_password |
yes | 8–72 characters |
200 OK — { "message": "password changed successfully; other sessions were logged out" }
Errors — 400 validation failed · 401 current password is incorrect
POST /password-reset/request
Emails a single-use, 1-hour password-reset link to the account behind email. The response is identical whether or not the account exists (no email enumeration). Requesting again invalidates the previous link. Without SMTP configured, the link is written to the server log instead.
Access Public · Rate limit RATE_LIMIT_LOGIN · Body form-encoded
Fields — email (required)
200 OK — { "message": "If an account exists for that email, a password reset link has been sent." }
The emailed link points at APP_PUBLIC_URL/reset-password?token=…; the frontend page collects the new password and calls the confirm endpoint below.
POST /password-reset/confirm
Redeems a reset token: sets the new password, burns the token, and revokes all sessions.
Access Public · Rate limit RATE_LIMIT_LOGIN · Body form-encoded
Fields
| Field | Required | Rules |
|---|---|---|
token |
yes | From the emailed link |
password |
yes | 8–72 characters |
200 OK — { "message": "password reset successfully; you can log in with the new password" }
Errors — 400 invalid or expired reset token / validation failed
POST /verify-email
Redeems an email-verification token (from the APP_PUBLIC_URL/verify-email?token=… link). Readers become active; authors are marked verified but stay pending for admin approval.
Access Public · Rate limit RATE_LIMIT_LOGIN · Body form-encoded
Fields — token (required)
200 OK
{ "message": "email verified successfully", "data": { "username": "okur", "account_active": true } }
Errors — 400 invalid or expired verification token
POST /verify-email/resend
Re-issues the verification link (replacing any outstanding token) for an account that still needs verification. Like the reset request, the response is identical for every input — account existence and verification status cannot be probed. Without SMTP the link lands in the server log.
Access Public · Rate limit RATE_LIMIT_LOGIN · Fields email (required)
200 OK — { "message": "If an account exists that still needs verification, a new link has been sent." }
Profile endpoints
GET /users/:username
Public profile of an active user. Email, phone, and status are never exposed. When the request carries a valid session and the viewer isn't looking at themselves, following says whether the viewer follows this user.
Access Public (optionally personalized)
200 OK
{
"data": {
"user_id": "…", "username": "yazar", "display_name": "Yazar",
"bio": "…", "user_role": "author", "avatar_key": "….jpg",
"created_at": "…", "post_count": 12,
"follower_count": 4, "following_count": 2, "following": true
}
}
Errors — 404 user not found (unknown, pending, suspended, or deleted)
GET /users/:username/posts
The user's published posts, newest first.
Access Public · Query limit (default 20, max 100), offset
200 OK — data: array of { post_id, title, slug, status, published_at, updated_at, like_count, comment_count } + pagination.
GET /users/:username/followers
GET /users/:username/following
Follower / following lists (active users only), newest follow first.
Access Public · Query limit (default 50, max 200), offset
200 OK — data: array of { user_id, username, display_name, avatar_key, followed_at } + pagination.
PUT /users/:username/follow
Follow a user. Idempotent — following twice is a no-op. The followee gets a follow notification.
Access Authenticated · Rate limit RATE_LIMIT_WRITE
200 OK — { "data": { "following": true } }
Errors — 400 you cannot follow yourself · 404 user not found
DELETE /users/:username/follow
Unfollow (idempotent).
Access Authenticated · Rate limit RATE_LIMIT_WRITE
200 OK — { "data": { "following": false } }
Comment endpoints
GET /posts/:slug/comments
Visible comments on a published post as a flat, oldest-first page; thread replies client-side via parent_comment_id.
Access Public · Query limit (default 50, max 200), offset
200 OK
{
"data": [
{
"comment_id": "…", "post_id": "…", "user_id": "…",
"parent_comment_id": "…", "body": "İlk yorum!", "is_ai": false,
"username": "okur", "display_name": "Okur", "avatar_key": "….jpg",
"created_at": "…", "edited_at": "…"
}
],
"pagination": { "limit": 50, "offset": 0, "count": 1 }
}
is_aiis reserved: if AI-generated comments are ever added, they must carryis_ai: trueso the frontend can label them.
POST /posts/:slug/comments
Comments on a published post. Replies set parent_comment_id to a visible comment on the same post. The post owner gets a comment notification; on replies the parent comment's author gets a reply notification (self-notifications are suppressed).
Access Authenticated · Rate limit RATE_LIMIT_WRITE · Body form-encoded
Fields
| Field | Required | Rules |
|---|---|---|
body |
yes | 1–5000 characters |
parent_comment_id |
no | UUID of the parent comment |
201 Created — the created comment in data.
Errors — 400 validation failed / parent on another post · 404 post or parent comment not found
PUT /comments/:id
Edits the caller's own comment (stamps edited_at).
Access Authenticated (comment author) · Rate limit RATE_LIMIT_WRITE · Fields body
Errors — 403 not your comment · 404 comment not found
DELETE /comments/:id
Soft-deletes a comment. Authors delete their own; admins can delete any.
Access Authenticated · Rate limit RATE_LIMIT_WRITE
Errors — 403 not your comment · 404 comment not found
Like endpoints
PUT /posts/:slug/like
Likes a published post. Idempotent — liking twice neither duplicates the like nor re-notifies. The post owner gets a like notification.
Access Authenticated · Rate limit RATE_LIMIT_WRITE
200 OK — { "data": { "liked": true, "like_count": 4 } }
DELETE /posts/:slug/like
Removes the caller's like (idempotent).
Access Authenticated · Rate limit RATE_LIMIT_WRITE
200 OK — { "data": { "liked": false, "like_count": 3 } }
Notification endpoints
Notification type values: comment, reply, like, follow, new_post (someone you follow published), new_pending_user (admins only), account_activated.
GET /notifications
The caller's inbox, newest first, plus the total unread count.
Access Authenticated · Query limit (default 20, max 100), offset
200 OK
{
"data": [
{
"notification_id": "…", "type": "like",
"actor_username": "okur", "actor_display_name": "Okur",
"post_slug": "hello-world", "post_title": "Hello World",
"comment_id": "…", "read_at": null, "created_at": "…"
}
],
"unread_count": 3,
"pagination": { "limit": 20, "offset": 0, "count": 1 }
}
PUT /notifications/read
Marks all of the caller's notifications read.
Access Authenticated · 200 OK — { "message": "all notifications marked as read" }
PUT /notifications/:id/read
Marks one notification read. 404 if it doesn't exist, belongs to someone else, or is already read.
Access Authenticated
Message endpoints
Direct messages between two active users. Threads are addressed by the peer's username.
GET /messages
Conversation list: one entry per peer with the latest message and the caller's unread count, most recent conversation first.
Access Authenticated · Query limit (default 20, max 100), offset
200 OK
{
"data": [
{
"peer_user_id": "…", "peer_username": "alice",
"peer_display_name": "Alice", "peer_avatar_key": "….jpg",
"last_message": "Orada mısın?", "last_message_mine": false,
"last_message_at": "…", "unread_count": 2
}
],
"pagination": { "limit": 20, "offset": 0, "count": 1 }
}
GET /messages/:username
The two-way thread with that user, newest first (reverse for display).
Access Authenticated · Query limit (default 50, max 200), offset
200 OK — data: array of { message_id, sender_id, mine, body, created_at, read_at } + pagination.
Errors — 404 user not found
POST /messages/:username
Sends a message. The recipient's live /events streams receive a message event.
Access Authenticated · Rate limit RATE_LIMIT_WRITE · Fields body (1–2000 chars)
201 Created — the created message in data.
Errors — 400 validation failed / you cannot message yourself · 404 user not found
PUT /messages/:username/read
Marks every unread message from that user as read.
Access Authenticated
200 OK — { "message": "messages marked as read", "data": { "read_count": 2 } }
Search
GET /search
DB-backed full-text search over published posts (PostgreSQL FTS, Turkish stemming; titles outrank body matches). The excerpt highlights matches with **…** markers — render them as emphasis, never as raw HTML.
Access Public
Query parameters
| Param | Required | Notes |
|---|---|---|
q |
yes | 1–200 characters; supports quoted phrases and -exclusions (websearch syntax) |
limit |
no | Default 20, max 100 |
offset |
no |
200 OK
{
"data": [
{
"post_id": "…", "title": "Golang Rehberi", "slug": "golang-rehberi",
"published_at": "…", "author_user_id": "…", "author_username": "yazar",
"excerpt": "Bu yazıda **concurrency** anlatılıyor",
"like_count": 3, "comment_count": 5
}
],
"pagination": { "limit": 20, "offset": 0, "count": 1 }
}
Errors — 400 q is required / too long
Realtime (Server-Sent Events)
GET /events
A long-lived text/event-stream of realtime events for the authenticated user. Consume it with a plain EventSource; the server sends a heartbeat comment every 25 s and the browser auto-reconnects on drop. Delivery is best-effort — everything pushed here also exists as a notification/message row, so re-sync from the REST endpoints on reconnect.
Access Authenticated (cookie works out of the box with EventSource)
Event types
event: |
data: payload |
When |
|---|---|---|
connected |
{} |
Handshake, once per connection |
notification |
{ notification_id, type, actor_username, post_slug, post_title, comment_id, created_at } |
Any new notification (see types) |
message |
{ message_id, sender_username, body, created_at } |
New direct message |
const events = new EventSource(API_BASE + "/events", { withCredentials: true });
events.addEventListener("notification", (e) => {
const n = JSON.parse(e.data);
// bump the bell badge, toast, etc.
});
events.addEventListener("message", (e) => {
const m = JSON.parse(e.data);
// bump the unread messages badge
});
Source of truth: internal/api/routes.go and the internal/api/*.go handler files. Last updated: 2026-07-01.