Techadamia Backend Documentation

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.

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) — starts pending; an admin must activate the account (via PUT /users/:id/status) before the user can log in. Active admins receive a new_pending_user notification.
  • reader — can comment, like, follow, and message but not write posts. With SMTP configured, the account starts pending and a verification email is sent (POST /verify-email activates 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 }
}

Errors400 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" }

Errors400 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": "…" } }

Errors400 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 }
}

Errors400 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" } ]
  }
}

Errors400 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-Type derived 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" />

Errors400 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": "…" } }

Errors400 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" }

Errors400 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" }

Errors403 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 Createdstorage_key is a relative filename (<uuid>.<ext>), not a path:

{ "message": "media uploaded successfully", "data": { "media_id": "…", "storage_key": "550e8400-….jpg" } }

Errors400 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" }

Errors400 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" }

Errors400 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" }

Errors400 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" }

Errors400 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" }

Errors400 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 OKdata: array of { post_id, title, slug, status, published_at, updated_at, archived_at, like_count, comment_count } + pagination.

Errors400 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

Errors400 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" }

Errors400 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

Fieldsemail (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" }

Errors400 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

Fieldstoken (required)

200 OK

{ "message": "email verified successfully", "data": { "username": "okur", "account_active": true } }

Errors400 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
  }
}

Errors404 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 OKdata: 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 OKdata: 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 } }

Errors400 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_ai is reserved: if AI-generated comments are ever added, they must carry is_ai: true so 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.

Errors400 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

Errors403 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

Errors403 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 OKdata: array of { message_id, sender_id, mine, body, created_at, read_at } + pagination.

Errors404 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.

Errors400 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 } }


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 }
}

Errors400 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.

Generated from API_REFERENCE.md on 2026-08-17 23:37 CEST. Edit the Markdown source and re-run task docs to update.