Backend (Go API)

API Response Format

All Grit API endpoints follow a consistent response format. This makes it predictable for frontend consumers and ensures error handling is uniform across the entire application.

Handler outcomeJSON envelopereturnsSuccess2xxError4xx / 5xx{ data, message }single item{ data, meta }paginated list{ error }code · message · details
SuccessError
Every response is one of these three envelopes — predictable shapes for every client

API versioning

Every route is served under a version prefix:

GET /api/v1/users
POST /api/v1/auth/login
GET /api/v1/invoices/:id/pdf

Why it matters. The moment something outside your repo calls your API — a mobile build you can't force-update, a partner integration, a customer's script — you can no longer rename a field or change a response shape without breaking it. The prefix gives the new shape somewhere to live. When that day comes, add a v2 group next to v1 in routes.go, leave v1 answering the old way, and delete it once your logs say nobody's calling it.

The version comes from one constant, so the whole surface moves together:

apps/api/internal/routes/routes.go
const APIVersion = "v1"
// Every /api group hangs off this one.
v1 := r.Group("/api/" + APIVersion)

Unversioned paths still work

A request to /api/users (no version) is re-dispatched internally to /api/v1/users and answered normally, so upgrading Grit doesn't break existing callers. Those responses carry two headers so the stale path is visible in the caller's logs:

Deprecation: true
Link: </api/v1>; rel="successor-version"

Treat that alias as a transition aid, not a second API: it always points at whatever the current version is, so a client that never adopts the prefix will eventually be dragged onto a version it wasn't written against. It runs as the 404 fallback, so it costs nothing on requests that already match a route.

One exception: /api/ws (the realtime WebSocket) stays unversioned. A WebSocket upgrade doesn't survive the re-dispatch reliably, and a transport endpoint isn't part of the REST surface being versioned.

Client apps

Every generated frontend — admin, web, the single-app SPA, desktop and Expo — exports an API_VERSION and applies it in one place. Endpoints stay written as /api/users, so moving an app to v2 is a one-line change rather than a find-and-replace across every call site, and an app can never end up half-migrated.

Success Response (Single Item)

When an endpoint returns a single resource, the response wraps it in adata field. An optional message field provides a human-readable description of what happened.

GET /api/users/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d -- 200 OK
{
"data": {
"id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"role": "ADMIN",
"avatar": "",
"active": true,
"email_verified_at": null,
"created_at": "2026-02-11T10:00:00Z",
"updated_at": "2026-02-11T10:00:00Z"
}
}

For create and update operations, include a message field:

POST /api/posts -- 201 Created
{
"data": {
"id": "3f2a1c6e-8d94-4b7a-bc11-2e5f9a0d4c88",
"title": "Getting Started with Grit",
"slug": "getting-started-with-grit",
"body": "Grit is a full-stack meta-framework...",
"published": false,
"author_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"created_at": "2026-02-11T14:30:00Z",
"updated_at": "2026-02-11T14:30:00Z"
},
"message": "Post created successfully"
}

In Go, this looks like:

handler.go
// Single item with message
c.JSON(http.StatusCreated, gin.H{
"data": post,
"message": "Post created successfully",
})
// Single item without message
c.JSON(http.StatusOK, gin.H{
"data": user,
})

Success Response (List with Pagination)

List endpoints return an array of resources in the data field and pagination metadata in the meta field.

GET /api/users?page=2&page_size=10 -- 200 OK
{
"data": [
{
"id": "1c8f0a2b-5e3d-4a91-b7c6-0f2a9d8e1b34",
"first_name": "Alice",
"last_name": "Smith",
"email": "alice@example.com",
"role": "USER",
"active": true,
"created_at": "2026-02-10T09:00:00Z",
"updated_at": "2026-02-10T09:00:00Z"
},
{
"id": "7d6c5b4a-3e2f-4109-a8b7-c6d5e4f3a2b1",
"first_name": "Bob",
"last_name": "Johnson",
"email": "bob@example.com",
"role": "EDITOR",
"active": true,
"created_at": "2026-02-10T10:30:00Z",
"updated_at": "2026-02-10T10:30:00Z"
}
],
"meta": {
"total": 57,
"page": 2,
"page_size": 10,
"pages": 6
}
}

Pagination Meta Structure

FieldTypeDescription
totalintegerTotal number of records matching the query (before pagination)
pageintegerCurrent page number (1-based)
page_sizeintegerNumber of records per page (max 100)
pagesintegerTotal number of pages (ceil(total / page_size))

In Go:

handler.go
pages := int(math.Ceil(float64(total) / float64(pageSize)))
c.JSON(http.StatusOK, gin.H{
"data": users,
"meta": gin.H{
"total": total,
"page": page,
"page_size": pageSize,
"pages": pages,
},
})

Error Response

All errors follow the same envelope format with an error object containing a machine-readable code, a human-readable message, and optional details for field-level validation errors.

422 Unprocessable Entity
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Key: 'Email' Error:Field validation for 'Email' failed on the 'required' tag",
"details": {
"email": "This field is required",
"password": "Must be at least 8 characters"
}
}
}

Simple error (no field details):

404 Not Found
{
"error": {
"code": "NOT_FOUND",
"message": "User not found"
}
}

In Go:

handler.go
// Simple error
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"code": "NOT_FOUND",
"message": "User not found",
},
})
// Error with field details
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": err.Error(),
"details": gin.H{
"email": "This field is required",
"password": "Must be at least 8 characters",
},
},
})

Action Response (Delete, Logout, etc.)

For operations that do not return a resource (like delete or logout), return only a message field:

DELETE /api/users/5 -- 200 OK
{
"message": "User deleted successfully"
}

HTTP Status Codes

Grit uses standard HTTP status codes consistently across all endpoints:

CodeNameWhen Used
200OKSuccessful GET, PUT, DELETE requests
201CreatedSuccessful POST that creates a resource
400Bad RequestMalformed request body or invalid parameters
401UnauthorizedMissing, invalid, or expired JWT token
403ForbiddenAuthenticated but lacks permission (wrong role)
404Not FoundResource does not exist
409ConflictDuplicate entry (e.g., email already registered)
422Unprocessable EntityValidation errors on request fields
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected server-side error

Error Codes

Error codes are machine-readable strings that the frontend can use to display localized messages or take programmatic action. They are always SCREAMING_SNAKE_CASE.

Error CodeHTTP StatusDescription
VALIDATION_ERROR422One or more request fields failed validation
NOT_FOUND404The requested resource does not exist
UNAUTHORIZED401Authentication is required or the token is invalid
FORBIDDEN403Authenticated but insufficient permissions (role check failed)
INTERNAL_ERROR500An unexpected server-side error occurred
CONFLICT409A resource with the same unique key already exists
INVALID_CREDENTIALS401Email/password combination is incorrect
INVALID_TOKEN401The refresh token is invalid or expired
EMAIL_EXISTS409An account with this email already exists
ACCOUNT_DISABLED403The user account has been deactivated
TOKEN_ERROR500Failed to generate JWT tokens
RATE_LIMITED429Too many requests from the same IP address

Full JSON Examples

Register (Success)

POST /api/auth/register -- 201 Created
{
"data": {
"user": {
"id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"role": "USER",
"avatar": "",
"active": true,
"email_verified_at": null,
"created_at": "2026-02-11T10:00:00Z",
"updated_at": "2026-02-11T10:00:00Z"
},
"tokens": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_at": 1707649200
}
},
"message": "User registered successfully"
}

Register (Email Taken)

POST /api/auth/register -- 409 Conflict
{
"error": {
"code": "EMAIL_EXISTS",
"message": "A user with this email already exists"
}
}

Validation Error

POST /api/posts -- 422 Unprocessable Entity
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Key: 'Title' Error:Field validation for 'Title' failed on the 'required' tag"
}
}

Unauthorized (Missing Token)

GET /api/posts -- 401 Unauthorized
{
"error": {
"code": "UNAUTHORIZED",
"message": "Authorization header is required"
}
}

Forbidden (Insufficient Role)

DELETE /api/users/3 -- 403 Forbidden
{
"error": {
"code": "FORBIDDEN",
"message": "You do not have permission to access this resource"
}
}

Paginated List

GET /api/users?page=1&page_size=2&search=john -- 200 OK
{
"data": [
{
"id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"role": "ADMIN",
"avatar": "",
"active": true,
"email_verified_at": null,
"created_at": "2026-02-11T10:00:00Z",
"updated_at": "2026-02-11T10:00:00Z"
},
{
"id": "a4e17b90-2c6d-4f38-9a1b-5d8e0c7f2a63",
"first_name": "Johnny",
"last_name": "Appleseed",
"email": "johnny@example.com",
"role": "USER",
"avatar": "",
"active": true,
"email_verified_at": "2026-02-11T12:00:00Z",
"created_at": "2026-02-11T11:00:00Z",
"updated_at": "2026-02-11T11:00:00Z"
}
],
"meta": {
"total": 3,
"page": 1,
"page_size": 2,
"pages": 2
}
}

Internal Server Error

POST /api/posts -- 500 Internal Server Error
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Failed to create post"
}
}

Consuming on the Frontend

Because the format is consistent, your React Query hooks can use a single error handler and response parser:

apps/web/hooks/use-posts.ts
import { useQuery, useMutation } from '@tanstack/react-query';
import api from '@/lib/api-client';
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
page_size: number;
pages: number;
};
}
interface ApiError {
error: {
code: string;
message: string;
details?: Record<string, string>;
};
}
export function usePosts(page = 1, pageSize = 20) {
return useQuery({
queryKey: ['posts', page, pageSize],
queryFn: async () => {
const { data } = await api.get<PaginatedResponse<Post>>(
`/api/posts?page=${page}&page_size=${pageSize}`
);
return data;
},
});
}
export function useCreatePost() {
return useMutation({
mutationFn: async (post: CreatePostInput) => {
const { data } = await api.post('/api/posts', post);
return data.data; // unwrap the "data" envelope
},
onError: (error: any) => {
const apiError = error.response?.data as ApiError;
// apiError.error.code === "VALIDATION_ERROR"
// apiError.error.message === "..."
// apiError.error.details?.title === "..."
},
});
}

Response Format Summary

ScenarioShape
Single resource{ "data": { ... }, "message"?: "..." }
List (paginated){ "data": [...], "meta": { total, page, page_size, pages } }
Action (delete, logout){ "message": "..." }
Error{ "error": { "code": "...", "message": "...", "details"?: { ... } } }