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.
API versioning
Every route is served under a version prefix:
GET /api/v1/usersPOST /api/v1/auth/loginGET /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:
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: trueLink: </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.
{"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:
{"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:
// Single item with messagec.JSON(http.StatusCreated, gin.H{"data": post,"message": "Post created successfully",})// Single item without messagec.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.
{"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
| Field | Type | Description |
|---|---|---|
| total | integer | Total number of records matching the query (before pagination) |
| page | integer | Current page number (1-based) |
| page_size | integer | Number of records per page (max 100) |
| pages | integer | Total number of pages (ceil(total / page_size)) |
In 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.
{"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):
{"error": {"code": "NOT_FOUND","message": "User not found"}}
In Go:
// Simple errorc.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "NOT_FOUND","message": "User not found",},})// Error with field detailsc.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:
{"message": "User deleted successfully"}
HTTP Status Codes
Grit uses standard HTTP status codes consistently across all endpoints:
| Code | Name | When Used |
|---|---|---|
| 200 | OK | Successful GET, PUT, DELETE requests |
| 201 | Created | Successful POST that creates a resource |
| 400 | Bad Request | Malformed request body or invalid parameters |
| 401 | Unauthorized | Missing, invalid, or expired JWT token |
| 403 | Forbidden | Authenticated but lacks permission (wrong role) |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate entry (e.g., email already registered) |
| 422 | Unprocessable Entity | Validation errors on request fields |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected 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 Code | HTTP Status | Description |
|---|---|---|
| VALIDATION_ERROR | 422 | One or more request fields failed validation |
| NOT_FOUND | 404 | The requested resource does not exist |
| UNAUTHORIZED | 401 | Authentication is required or the token is invalid |
| FORBIDDEN | 403 | Authenticated but insufficient permissions (role check failed) |
| INTERNAL_ERROR | 500 | An unexpected server-side error occurred |
| CONFLICT | 409 | A resource with the same unique key already exists |
| INVALID_CREDENTIALS | 401 | Email/password combination is incorrect |
| INVALID_TOKEN | 401 | The refresh token is invalid or expired |
| EMAIL_EXISTS | 409 | An account with this email already exists |
| ACCOUNT_DISABLED | 403 | The user account has been deactivated |
| TOKEN_ERROR | 500 | Failed to generate JWT tokens |
| RATE_LIMITED | 429 | Too many requests from the same IP address |
Full JSON Examples
Register (Success)
{"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)
{"error": {"code": "EMAIL_EXISTS","message": "A user with this email already exists"}}
Validation Error
{"error": {"code": "VALIDATION_ERROR","message": "Key: 'Title' Error:Field validation for 'Title' failed on the 'required' tag"}}
Unauthorized (Missing Token)
{"error": {"code": "UNAUTHORIZED","message": "Authorization header is required"}}
Forbidden (Insufficient Role)
{"error": {"code": "FORBIDDEN","message": "You do not have permission to access this resource"}}
Paginated List
{"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
{"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:
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
| Scenario | Shape |
|---|---|
| Single resource | { "data": { ... }, "message"?: "..." } |
| List (paginated) | { "data": [...], "meta": { total, page, page_size, pages } } |
| Action (delete, logout) | { "message": "..." } |
| Error | { "error": { "code": "...", "message": "...", "details"?: { ... } } } |
