Skip to content

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"
    }
  }
}
  • code is a stable, machine-readable identifier. Clients branch on this.
  • message is a human-readable description, safe to surface to end users.
  • details is optional (omitempty), free-form per-error data (e.g. field-level validation errors). api.InternalError puts the request's trace_id here.

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/timezone resolves 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.