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.

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/1 -- 200 OK
{
    "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:

POST /api/posts -- 201 Created
{
    "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:

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": 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

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": 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)

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": 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

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