API Conventions¶
The contract every Tomoda endpoint follows. New endpoints must adhere to these conventions; reviewers will reject PRs that diverge. For the full route table see the Endpoint Reference.
Content type¶
All non-binary request and response bodies are JSON. Set:
Content-Type: application/json
Accept: application/json
File uploads never hit the API as multipart/form-data. The client asks an upload starter under /uploads/* for a presigned PUT URL, uploads the bytes straight to object storage, then finalises by sending the returned storage key as a normal JSON field (avatar, chat image, moment, event cover, plan cover, item image). See backend/internal/services/media/upload_handler.go. Webhooks (Stripe, Apple) receive the provider's raw body and verify a signature.
Status codes¶
| Code | When |
|---|---|
200 OK |
Successful GET / PATCH / PUT / DELETE / non-creating POST |
201 Created |
POST that creates a new resource (returns the resource in the body) |
204 No Content |
DELETE with nothing to return |
400 Bad Request |
Input binding or validation failure |
401 Unauthorized |
Missing or invalid JWT / API key |
403 Forbidden |
Authenticated but not authorised (admin route, partner-scope gate, blocked IP) |
404 Not Found |
Resource doesn't exist or isn't visible to this user |
409 Conflict |
Idempotency / uniqueness violation (e.g. duplicate friend request) |
422 Unprocessable Entity |
Semantic validation failure (input parses but business rules reject it) |
429 Too Many Requests |
Rate limit hit |
500 Internal Server Error |
Unhandled error, always logged with a trace ID |
Error response shape¶
The canonical error is a JSON object with a single error field holding a structured detail. Response helpers in backend/internal/api/respond.go (api.Error, api.ErrorWithDetails, api.InternalError, plus the status shortcuts api.BadRequest / api.Unauthorized / api.Forbidden / api.NotFoundError / api.Conflict / api.TooManyRequests) emit it:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "You must be signed in to perform this action.",
"details": {
"field": "email",
"reason": "format"
}
}
}
codeis a stable, machine-readable identifier. Clients branch on this.messageis a human-readable description, safe to surface to end users.detailsis optional (omitempty), free-form per-error data (e.g. field-level validation errors).api.InternalErrorputs the request'strace_idhere.
Standard error codes¶
Defined as constants in backend/internal/utils/response.go. Reuse these rather than minting new ones.
| Code | Typical HTTP | Meaning |
|---|---|---|
AUTH_REQUIRED |
401 | No credentials on a protected route |
TOKEN_EXPIRED |
401 | JWT past its exp, call /auth/refresh |
TOKEN_INVALID |
401 | Signature or claims failed verification |
PERMISSION_DENIED |
403 | Authenticated but not permitted |
NOT_ATTENDEE |
403 | Caller is not a participant of the event/chat |
RESOURCE_NOT_FOUND |
404 | Resource missing or not visible |
INVALID_REQUEST |
400 | Malformed body, bad query param, unparseable ID |
VALIDATION_FAILED |
400 / 422 | Input failed validation; details carries field errors |
RATE_LIMITED |
429 | Slow down, see Retry-After |
SERVICE_UNAVAILABLE |
503 | A dependency (upstream provider) is down |
INTERNAL_SERVER_ERROR |
500 | Unhandled exception; details.trace_id is set |
Two edge middlewares use a flat shape
The rate limiter and IP blocker run before the handler chain and emit a flat { "error": "message" } (the blocker adds reason + message). Treat any 401/403/429 defensively on the client: read error whether it is a string or an object. See Auth Endpoints for the rate-limit bodies.
Pagination¶
There is no single list envelope; the pattern depends on whether the endpoint needs a stable order across concurrent writes.
| Pattern | Query params | Response fields | Used by |
|---|---|---|---|
| Offset / limit | limit, offset |
list array + total (and echoed limit / offset), or has_more |
admin user directory, friends, chat member reads |
| Cursor | limit, cursor |
list array + next_cursor (omitted at the end) |
chat history, moments, notifications, plan list |
Cursor endpoints exist where ordering must stay stable as rows are inserted (message history, feeds). Consult the per-domain handler for the exact field names, for example backend/internal/services/chat/handler.go (GetMessages) and backend/internal/services/plan/plan_handler.go (ListPlans).
Dates and times¶
- All timestamps are ISO 8601 with a UTC offset:
2026-05-23T14:30:00Z. Naive, zoneless timestamps are never returned. - Durations are integer seconds with a named field (
expires_in_seconds) or an ISO 8601 duration string. - Event start/end times are stored in UTC; the API also returns the event's IANA timezone (
Asia/Tokyo) so the client can render local time.GET /events/timezoneresolves a venue's zone.
IDs¶
UUIDs (lowercase canonical form, as strings) are used for everything user-created. Path params are :id, :userId, :partner_id, and similar per domain.
Idempotency¶
Mutating endpoints aim to be idempotent on natural unique keys (friend requests by (from, to), OAuth login by external ID, check-ins throttled per (user, location)). Duplicate mutations return 409 Conflict rather than creating a second row.
Trace ID¶
Every response carries the X-Trace-ID header, a UUID set by middleware.TraceID() (backend/internal/middleware/trace.go) and stored on the request context (read via api.TraceID(r)). 500 responses echo it in details.trace_id. Include it in bug reports; server logs are indexed by it.
CORS¶
Configured in backend/internal/wiring/router.go. Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH. Allowed request headers: Origin, Content-Type, Authorization, X-API-Key. Exposed response header: Content-Length.
Versioning¶
The single live version is v1. Breaking changes ship under /api/v2 rather than mutating v1 in place. Additive changes (new fields, new endpoints) land in v1 directly.