Skip to content

API Overview

luno provides three distinct APIs for different use cases. Choose the right one for your integration.

API Types

Public API

Base URL: https://{your-domain}/public/v1

PropertyValue
AuthenticationNone required
CORSAccess-Control-Allow-Origin: *
PurposeRead published content, submit contact forms, serve media
ClientsBrowsers, CDNs, AI agents, external systems
bash
# No auth needed — call directly
curl https://your-domain.com/public/v1/form-sets/blog/entries

Admin API

Base URL: https://{your-domain}/admin/v1

PropertyValue
AuthenticationJWT Bearer token
PurposeCreate, edit, approve, and manage content; manage members and settings
Clientsluno Admin SPA
bash
curl https://your-domain.com/admin/v1/form-sets \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."

Agent API

Base URL: https://{your-domain}/admin/v1 (same as Admin API)

PropertyValue
AuthenticationAgent API key (sk-agent- prefix)
PurposeContent and schema operations from AI agents and automation
Issue keysSettings → Agent API Keys (/settings/api-keys)
Scopesfull (recommended: entries + schema) / content (entries only) / schema (compat alias of full)
bash
curl https://api.luno.rest/admin/v1/form-sets \
  -H "Authorization: Bearer sk-agent-xxxxxxxx"

Authentication Details

Obtaining a JWT (Admin API)

bash
# Log in to receive a JWT
curl -X POST https://your-domain.com/admin/v1/auth/login/password \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]", "password": "your-password" }'
json
{
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "user": { "id": "uuid", "email": "[email protected]" }
}

Include the token as Authorization: Bearer <token> in subsequent requests.

Issuing an Agent API Key

  1. Open the admin panel → Settings → Agent API Keys → New key
  2. Set a name and choose a scope (usually full; use content to restrict to entries)
  3. Copy the generated key (sk-agent-…) — it's shown only once

Prefer full for day-to-day work. Use content when you want entries-only access. schema is a compat alias with the same permissions as full. Agent keys cannot delete Form Sets or Contact Forms.

Keep API keys secret

Never commit API keys to version control or embed them in client-side code. Store them as server-side environment variables.

Response Format

All responses use Content-Type: application/json.

Single resource

json
{
  "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "slug": "my-first-post",
  "data": {
    "title": "My First Post",
    "body": "<p>...</p>"
  },
  "mediaUrls": {
    "cover": "https://your-domain.com/public/v1/media/asset-uuid"
  }
}

List resource

json
{
  "items": [
    {
      "entry": { "id": "uuid", "slug": "my-post" },
      "published": { "revisionId": "uuid", "revision": 2, "updatedAt": "2025-01-15T10:00:00Z" }
    }
  ],
  "total": 42,
  "limit": 20,
  "offset": 0
}

Error response

json
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Published entry not found"
  }
}

Error Codes

CodeHTTP StatusDescriptionAction
NOT_FOUND404Resource not foundCheck the slug or ID
VALIDATION_ERROR400Invalid request parametersSee the error message for details
UNAUTHORIZED401Missing or expired tokenRe-authenticate to get a fresh token
FORBIDDEN403Insufficient permissionsUse a higher-privilege role
PLAN_REQUIRED403Feature requires a higher planUpgrade to access this feature
CONFLICT409Conflict (e.g., duplicate slug)Use a different slug
RATE_LIMITED429Agent API request rate limit exceededWait for Retry-After seconds, then retry
INTERNAL_ERROR500Unexpected server errorRetry; contact support if it persists

Pagination

All list endpoints support pagination:

ParameterTypeDefaultDescription
pageinteger1Page number (1-based)
limitinteger20Items per page (max 100)
offsetintegerByte offset (alternative to page)

Fetching all pages

typescript
async function fetchAll(formSetSlug: string) {
  const BASE = 'https://your-domain.com/public/v1'
  const all: unknown[] = []
  let page = 1
  const limit = 100

  while (true) {
    const res = await fetch(
      `${BASE}/form-sets/${formSetSlug}/entries?page=${page}&limit=${limit}&include_snapshot=true`
    )
    const { items, total, offset } = await res.json()
    all.push(...items)
    if (offset + limit >= total) break
    page++
  }

  return all
}

Caching and ETags

The public API attaches ETag headers to all responses. Send If-None-Match with the previously received ETag to get a 304 Not Modified response when content hasn't changed — saving bandwidth and counting as a lightweight request.

http
# First request
GET /public/v1/form-sets/blog/entries/my-post HTTP/1.1

← HTTP 200
← ETag: "550e8400e29b41d4"
← Cache-Control: public, max-age=60

# Second request (cache validation)
GET /public/v1/form-sets/blog/entries/my-post HTTP/1.1
If-None-Match: "550e8400e29b41d4"

← HTTP 304 Not Modified (no body)

Cache-Control: public, max-age=60 also enables automatic caching at CDNs (Cloudflare, Fastly, CloudFront, etc.).

CORS

The public API allows all origins:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type

You can call the public API directly from browser JavaScript with fetch() without any proxy or CORS workaround.

Rate Limits

APILimit
Public APIGoverned by Cloudflare Workers standard limits
Admin API (JWT)Not limited by agent key rate limits
Agent APIPer key: Free / Solo 60 / 60 s; Standard+ 300 / 60 s (see AI Agents)

Reduce request volume with ETags

Use ETag + If-None-Match to skip re-fetching unchanged content. 304 responses are significantly cheaper than full responses at scale.

Next Steps