API Integration Guide
How to connect to a Radish-generated app from external clients, scripts, and services.
Authentication
Radish apps support two authentication methods:
Cookie Sessions (Browser Apps)
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
tokenis 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
Text Search
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:
| Header | Description |
|---|---|
x-ratelimit-limit | Maximum requests allowed in the window |
x-ratelimit-remaining | Requests remaining |
x-ratelimit-reset | Seconds until the window resets |
Default limits:
| Tier | Limit | Applies To |
|---|---|---|
auth | 20 req/min | /api/v1/auth/* (login, register) |
api | 100 req/min | Authenticated requests (per user/key) |
public | 60 req/min | Unauthenticated 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
| Code | HTTP Status | Description |
|---|---|---|
VALIDATION_ERROR | 400 | Input validation failed (see details for field errors) |
UNAUTHORIZED | 401 | No valid session or API key provided |
FORBIDDEN | 403 | Authenticated but missing required permission |
NOT_FOUND | 404 | Resource does not exist |
CONFLICT | 409 | Duplicate resource (e.g., unique constraint) |
RATE_LIMITED | 429 | Too many requests |
UNPROCESSABLE_ENTITY | 422 | Business rule violation |
INTERNAL_ERROR | 500 | Server 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
- Authentication & Authorization — Sessions, roles, permissions, LDAP
- Routes Reference — Full endpoint documentation
- Services — Server-side service layer
- Telemetry — Observability and monitoring