Skip to main content

API Integration Guide

How to connect to a Radish-generated app from external clients, scripts, and services.

Authentication​

Radish apps support two authentication methods:

For browser-based clients, authenticate via the login endpoint. The server sets a signed httpOnly cookie automatically.

# Login — sets session cookie
curl -X POST https://myapp.example.com/api/v1/auth/login \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{
"authType": "password",
"email": "user@example.com",
"password": "yourpassword"
}'

# Subsequent requests use the cookie
curl https://myapp.example.com/api/v1/products \
-b cookies.txt

API Keys (Scripts, CI/CD, External Services)​

For programmatic access, create an API key and pass it as a Bearer token.

Step 1: Create an API Key​

Log in as an admin and create a key with specific permissions:

curl -X POST https://myapp.example.com/api/v1/apikeys \
-b cookies.txt \
-H "Content-Type: application/json" \
-d '{
"name": "CI/CD Integration",
"permissions": ["product:view:all", "order:view:all", "order:create"],
"expiresAt": "2027-12-31T23:59:59Z"
}'

Response:

{
"id": "668f...",
"name": "CI/CD Integration",
"token": "rk_a1b2c3d4e5f6...",
"lastEight": "...e5f6a1b2",
"expiresAt": "2027-12-31T23:59:59Z"
}

⚠️ The token is only returned once. Store it securely (e.g., in environment variables or a secrets manager).

Step 2: Use the API Key​

Pass the token in the Authorization header:

curl https://myapp.example.com/api/v1/products \
-H "Authorization: Bearer rk_a1b2c3d4e5f6..."

The API key inherits the permissions you specified at creation. Requests to endpoints beyond those permissions return 403 Forbidden.

Making Requests​

Base URL​

All API endpoints are under /api/v1/:

https://myapp.example.com/api/v1/{entity-plural}

Entity names use the plural form from your blueprint (e.g., products, orders, categories).

Content Types​

  • Request body: Content-Type: application/json
  • Response body: Always JSON

Response Envelope​

Every response follows a standard envelope format.

Success (single item):

{
"data": {
"id": "668f...",
"title": "Widget",
"price": 29.99,
"status": "active",
"createdAt": "2026-08-01T10:00:00.000Z"
}
}

Success (list):

{
"data": {
"items": [...],
"totalCount": 142,
"totalPages": 6,
"nextCursor": "eyJpZCI6..."
}
}

Error:

{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"title": "Required",
"price": "Must be a positive number"
}
}
}

Correlation IDs​

Every response includes an x-correlation-id header — a UUID that traces the request through the system. If you send an x-correlation-id header on your request (e.g., from your own tracing system), the server preserves it.

# Send your own correlation ID
curl https://myapp.example.com/api/v1/products \
-H "Authorization: Bearer rk_..." \
-H "x-correlation-id: my-trace-id-123"

Include correlation IDs when reporting issues — they link directly to server logs and telemetry.

CRUD Operations​

List Records​

GET /api/v1/products

Get a Single Record​

GET /api/v1/products/668f1a2b3c4d5e6f7a8b9c0d

Create a Record​

curl -X POST https://myapp.example.com/api/v1/products \
-H "Authorization: Bearer rk_..." \
-H "Content-Type: application/json" \
-d '{
"title": "New Widget",
"price": 29.99,
"status": "draft",
"categoryId": "668f..."
}'

Update a Record​

curl -X PATCH https://myapp.example.com/api/v1/products/668f... \
-H "Authorization: Bearer rk_..." \
-H "Content-Type: application/json" \
-d '{
"price": 34.99,
"status": "active"
}'

Delete a Record​

curl -X DELETE https://myapp.example.com/api/v1/products/668f... \
-H "Authorization: Bearer rk_..."

Atomic Increment​

Increment or decrement numeric fields without a read-modify-write cycle:

curl -X PATCH https://myapp.example.com/api/v1/products/668f.../increment \
-H "Authorization: Bearer rk_..." \
-H "Content-Type: application/json" \
-d '{
"viewCount": 1,
"stock": -5
}'

Bulk Create​

curl -X POST https://myapp.example.com/api/v1/products/bulk \
-H "Authorization: Bearer rk_..." \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "title": "Widget A", "price": 10 },
{ "title": "Widget B", "price": 20 }
]
}'

Querying & Filtering​

Field Filters​

Filter by any field defined in your blueprint's filters array:

# Enum filter
GET /api/v1/products?status=active

# ObjectId reference filter
GET /api/v1/products?categoryId=668f...

# Array field filter
GET /api/v1/products?tags=electronics&tags=sale

# Boolean filter
GET /api/v1/products?featured=true

Full-text search across searchable fields:

GET /api/v1/products?q=wireless+headphones

Date Range Filtering​

Filter records by date ranges. All dates use ISO 8601 format.

# Records created after a date
GET /api/v1/orders?dateFrom=2026-01-01

# Records created before a date
GET /api/v1/orders?dateTo=2026-06-30

# Records in a date range
GET /api/v1/orders?dateFrom=2026-01-01&dateTo=2026-03-31

# Records in a specific month (shorthand)
GET /api/v1/orders?dateMonth=2026-03

# Target a different date field (default: createdAt)
GET /api/v1/orders?dateFrom=2026-01-01&dateField=shippedAt

Reference Population​

Expand referenced entities inline to avoid extra API calls:

# Populate a single reference
GET /api/v1/orders?populate=customerId

# Populate multiple references
GET /api/v1/orders?populate=customerId,productId

# Also works on single-item endpoints
GET /api/v1/orders/668f...?populate=customerId

Without population:

{ "customerId": "668f1a2b..." }

With population:

{
"customerId": {
"id": "668f1a2b...",
"name": "Acme Corp",
"email": "orders@acme.com"
}
}

Sorting​

String Shorthand​

The simplest way to sort — prefix with - for descending:

# Newest first
GET /api/v1/products?sort=-createdAt

# Alphabetical by title
GET /api/v1/products?sort=title

# By price, highest first
GET /api/v1/products?sort=-price

Object Format​

For advanced sorting with collation options:

GET /api/v1/products?sort[field]=title&sort[direction]=asc&sort[collation][locale]=en&sort[collation][strength]=2

Collation enables case-insensitive and natural number ordering ("Item 1", "Item 2", "Item 10").

Pagination​

Offset-Based (Simple)​

Best for admin panels, reports, and moderate datasets:

# First page
GET /api/v1/products?limit=25

# Second page
GET /api/v1/products?limit=25&skip=25

# Third page (offset is an alias for skip)
GET /api/v1/products?limit=25&offset=50

The response includes totalCount and totalPages for building paginators:

{
"data": {
"items": [...],
"totalCount": 142,
"totalPages": 6
}
}

Cursor-Based (Efficient)​

Best for infinite scroll, large datasets, and real-time feeds:

# First page
GET /api/v1/products?limit=25

# Next page — use the cursor from the previous response
GET /api/v1/products?limit=25&cursor=eyJpZCI6...

The response includes nextCursor when more pages exist:

{
"data": {
"items": [...],
"nextCursor": "eyJpZCI6..."
}
}

Note: Cursor pagination is incompatible with custom sorting — the cursor encodes the sort position. Use offset-based pagination when you need custom sort order.

Limit​

The limit parameter controls page size:

  • Default: 25
  • Maximum: 200
  • Minimum: 1

Rate Limiting​

All API endpoints are rate-limited. Response headers tell you your current status:

HeaderDescription
x-ratelimit-limitMaximum requests allowed in the window
x-ratelimit-remainingRequests remaining
x-ratelimit-resetSeconds until the window resets

Default limits:

TierLimitApplies To
auth20 req/min/api/v1/auth/* (login, register)
api100 req/minAuthenticated requests (per user/key)
public60 req/minUnauthenticated requests (per IP)

When rate-limited, the server returns 429 Too Many Requests with a retry-after header:

{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests"
}
}

Handling rate limits in code:

async function fetchWithRetry(url: string, options: RequestInit, retries = 3) {
const response = await fetch(url, options);

if (response.status === 429 && retries > 0) {
const retryAfter = parseInt(response.headers.get('retry-after') || '5');
await new Promise(r => setTimeout(r, retryAfter * 1000));
return fetchWithRetry(url, options, retries - 1);
}

return response;
}

Error Codes​

CodeHTTP StatusDescription
VALIDATION_ERROR400Input validation failed (see details for field errors)
UNAUTHORIZED401No valid session or API key provided
FORBIDDEN403Authenticated but missing required permission
NOT_FOUND404Resource does not exist
CONFLICT409Duplicate resource (e.g., unique constraint)
RATE_LIMITED429Too many requests
UNPROCESSABLE_ENTITY422Business rule violation
INTERNAL_ERROR500Server error

Idempotency​

All POST requests support idempotency keys to prevent duplicate operations:

curl -X POST https://myapp.example.com/api/v1/orders \
-H "Authorization: Bearer rk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-abc-123" \
-d '{ "productId": "668f...", "quantity": 2 }'

If the same Idempotency-Key is sent again within 24 hours, the server returns the original response without re-executing the operation. This is critical for payment processing, order creation, and any operation where retries must be safe.

TypeScript Client Example​

A complete typed client for external integration:

const API_BASE = 'https://myapp.example.com/api/v1';
const API_KEY = process.env.MYAPP_API_KEY!;

interface ListResponse<T> {
data: {
items: T[];
totalCount: number;
totalPages: number;
nextCursor?: string;
};
}

interface ItemResponse<T> {
data: T;
}

interface ApiError {
error: { code: string; message: string; details?: Record<string, string> };
}

async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
...init?.headers,
},
});

if (!res.ok) {
const { error } = (await res.json()) as ApiError;
throw new Error(`[${error.code}] ${error.message}`);
}

return res.json() as Promise<T>;
}

// List with filtering, sorting, and pagination
const orders = await api<ListResponse<Order>>(
'/orders?status=pending&sort=-createdAt&limit=50&populate=customerId'
);

// Create with idempotency
const newOrder = await api<ItemResponse<Order>>('/orders', {
method: 'POST',
headers: { 'Idempotency-Key': `order-${externalId}` },
body: JSON.stringify({ productId: '668f...', quantity: 2 }),
});

// Date range query
const monthlyReport = await api<ListResponse<Order>>(
'/orders?dateMonth=2026-07&dateField=completedAt&sort=-total&limit=200'
);

// Atomic increment
await api<ItemResponse<Product>>(`/products/${productId}/increment`, {
method: 'PATCH',
body: JSON.stringify({ viewCount: 1 }),
});

Health Check​

Verify the app is running and check basic metrics:

# Public — minimal status
curl https://myapp.example.com/api/health

# Authenticated as admin — full telemetry stats
curl https://myapp.example.com/api/health \
-H "Authorization: Bearer rk_..."

Admin response includes: uptime, request count, error rate, memory usage, slowest endpoints, service metrics, external call metrics, and database status.

Next Steps​