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.
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": 1,"name": "John 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": 42,"title": "Getting Started with Grit","slug": "getting-started-with-grit","body": "Grit is a full-stack meta-framework...","published": false,"author_id": 1,"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": 11,"name": "Alice Smith","email": "alice@example.com","role": "user","active": true,"created_at": "2026-02-10T09:00:00Z","updated_at": "2026-02-10T09:00:00Z"},{"id": 12,"name": "Bob 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": 1,"name": "John 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": 1,"name": "John 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": 15,"name": "Johnny 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"?: { ... } } } |