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 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.
{
"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 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:
{
"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"?: { ... } } } |