API Documentation
Olyron CRM exposes REST endpoints under /api on your app host. Requests are authenticated with your signed-in Supabase session and scoped to a single tenant, so an integration acts as the logged-in advisor with the same permissions and row-level isolation as the web app.
#Overview
Every endpoint lives under the /api path on the same host that serves the app (for example https://yourworkspace.example.com/api/...). Routes are Next.js App Router handlers found under src/app/api/. There is no separate API subdomain today — you call the same origin you sign in to.
An interactive endpoint browser ships at /api-docs. It groups planned resources (Contacts, Leads, Deals, Activities, Automations) into an expandable reference and includes copy-ready curl snippets. Use it to explore method + path shapes; treat the exact set of resources there as the product roadmap surface, and this page as the description of what is wired up and callable today.
/api-docs marketing page shows a Authorization: Bearer <api_key> header and a dedicated API host. In the shipping build, endpoints authenticate off your Supabase auth session (cookies) rather than a personal API key. Long-lived Bearer API keys are a roadmap item — build against the session model described below.#Authentication & tenant scope
Authenticated routes call requireTenantUser() (see src/lib/supabase/helpers.ts). It resolves the current user with supabase.auth.getUser(), then resolves an active tenant membership from the tenant_users table. Both must succeed or the request is rejected.
- No user session ->
401with body{ "error": "Unauthorized" }. - Signed in but no active membership ->
403with body{ "error": "No active tenant membership" }. - Success -> the handler runs with
{ user, tenantId, role }and every query is filtered to thattenant_id.
Choosing the tenant
If you belong to more than one workspace, pick which one a request targets with the x-tenant-id request header. When the header is absent, the server falls back to the active-tenant cookie, and finally to your most recently joined active membership.
curl -X GET "https://yourworkspace.example.com/api/crm/deals?limit=50" \
-H "Content-Type: application/json" \
-H "x-tenant-id: <your_tenant_uuid>" \
--cookie "sb-access-token=<supabase_session_cookie>"#Response format & error codes
Newer routes use the shared envelope in src/lib/api-response.ts. Success responses look like { "success": true, "data": ..., "message": ... }; failures look like { "success": false, "error": ..., "code": ..., "message": ..., "details": ... } with a machine-readable code.
GET /api/crm/deals returns { "deals": [...] } and the lead importer returns { "success", "imported", "failed", ... }. Always branch on the HTTP status code first, then read the body shape documented for that specific endpoint.| HTTP status | code (when enveloped) | Meaning |
|---|---|---|
| 400 | INVALID_REQUEST / VALIDATION_ERROR / INVALID_JSON | Malformed body or failed Zod validation |
| 401 | UNAUTHORIZED | No valid session |
| 403 | FORBIDDEN / TENANT_ACCESS_DENIED | Authenticated but not permitted for this tenant/resource |
| 404 | NOT_FOUND | Resource does not exist in this tenant |
| 409 | CONFLICT / ALREADY_EXISTS | Duplicate or conflicting write |
| 429 | RATE_LIMIT_EXCEEDED | Too many requests |
| 500 | INTERNAL_ERROR / DATABASE_ERROR | Unexpected server or persistence failure |
| 503 | SERVICE_UNAVAILABLE / AI_NOT_CONFIGURED | A dependency (DB, AI provider) is down or unconfigured |
#Endpoints that exist today
The table below lists live handlers under src/app/api/. Paths are relative to your app host. Session means requireTenantUser is enforced; Public means no auth (tenant is resolved from the URL slug); Signed means HMAC request signing (see the server-to-server section).
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/v1/import/leads | Session | Bulk-create leads into crm_leads and log an import job |
| GET | /api/crm/deals | Session | List deals for the tenant (query: stage, assigned_to, limit) |
| POST | /api/crm/deals | Session | Create a deal |
| POST | /api/exports | Session | Generate a CSV/XLSX/PDF export of a record type |
| POST | /api/public/forms/:tenantSlug/:formSlug/submit | Public | Accept a public web-form submission and create a lead |
| POST | /api/migrate/ingest | Signed | Ingest migrated records from Olyron Migrate |
| GET | /api/health | Public | Service + database health probe |
GET /api/health needs no auth and returns { status, version, uptime, checks: { database, memory } }. status is healthy, degraded, or unhealthy — handy for uptime monitors and deploy smoke tests.#Worked example: importing leads
POST /api/v1/import/leads is the most complete write endpoint. Send a leads array; each item is validated (email is required), the source is normalized, and unknown keys like company are tucked into custom_fields. Rows are inserted into crm_leads and the batch is recorded in import_jobs.
emailstring (required)- Lead email. Rows without it are skipped and reported in
errors. first_namestring- Optional given name; stored null if omitted.
last_namestring- Optional family name; stored null if omitted.
phonestring- Optional phone number.
statusstring- Lifecycle status; defaults to "new".
lead_source / sourcestring- Origin of the lead; normalized server-side. Defaults to "api_import".
companystring- Not a column — merged into
custom_fields.company. custom_fieldsobject- Arbitrary extra key/values persisted on the lead.
curl -X POST "https://yourworkspace.example.com/api/v1/import/leads" \
-H "Content-Type: application/json" \
-H "x-tenant-id: <your_tenant_uuid>" \
--cookie "sb-access-token=<supabase_session_cookie>" \
-d '{
"leads": [
{
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe",
"phone": "+15551234567",
"source": "webinar",
"company": "Acme LLC"
},
{ "first_name": "NoEmail" }
]
}'{
"success": true,
"imported": 1,
"failed": 1,
"errors": [
{ "index": 1, "error": "Email is required" }
],
"data": [
{ "id": "…", "email": "jane@example.com", "status": "new", "source": "webinar" }
]
}200 — check failed and the errors array rather than relying on status alone. An empty or non-array leads value returns 400 { "error": "Invalid request: leads array required" }.#Signed server-to-server calls
Machine-to-machine receivers under /api/migrate/* do not use a session. They require an HMAC-SHA256 signature, verified by verifyOlyronMigrateRequest (src/lib/olyron-migrate/verify.ts). This is the pattern to follow for any future keyless backend integration.
- 1Configure the shared secretSet
OLYRON_MIGRATE_SIGNING_SECRETon the server. When it is absent, verification fails and the receiver rejects the request with401 missing_signing_secret. - 2Build the signing stringJoin
method,path,timestamp(ms),nonce, and the raw requestbodywith newline (\n) separators. - 3Sign itCompute HMAC-SHA256 over that string with the secret and hex-encode the digest.
- 4Send the headersAttach
x-olyron-signature,x-olyron-timestamp, andx-olyron-nonceto the request.
| reason (in body) | Status | Cause |
|---|---|---|
| missing_signing_secret | 401 | Server has no OLYRON_MIGRATE_SIGNING_SECRET configured |
| timestamp_out_of_range | 401 | Timestamp is outside the 5-minute skew window |
| bad_signature | 401 | Signature mismatch — wrong secret or tampered body |
| replay | 401 | This nonce was already used (replay protection) |
| nonce_store_error | 401 | Could not durably record the nonce; fails closed |
/api/migrate/* receivers return 401 { "error": "<reason>" } for all rejected requests — the specific cause is carried by the reason string in the body, not the status code. Branch on reason (not status) to tell a config error from a stale timestamp or a replay.x-olyron-nonce. The server records each nonce and rejects repeats, so retries need a new nonce and a fresh timestamp.#Pagination, limits & troubleshooting
List endpoints cap result size with a limit query parameter — for example GET /api/crm/deals?limit=50 (default 100). Helpers in src/lib/supabase/helpers.ts (applyPagination, buildPaginatedResult) and the apiPaginated response builder support page-based pagination with page, pageSize, sortBy, and sortOrder, returning { items, total, page, pageSize, totalPages }.
Rate limits
The /api-docs page advertises per-plan rate limiting (up to 1,000 requests/min) and a 429 RATE_LIMIT_EXCEEDED code exists in the response library. Treat a 429 as retryable with backoff, and honor a details.retryAfter value if present.
| Symptom | Likely cause | Fix |
|---|---|---|
| 401 Unauthorized | No/expired session cookie | Sign in again and reissue the request with a fresh session |
| 403 No active tenant membership | User not active in the resolved tenant | Send the correct x-tenant-id, or confirm your membership status |
| 400 Invalid request: leads array required | Missing or empty leads array | Send a non-empty leads array of objects |
| Rows silently missing after import | Per-row validation skipped them | Inspect the errors[] and failed count in the response body |
| 401 on /api/migrate/* | Bad signature, stale timestamp, or reused nonce | Re-sign with a fresh timestamp and unique nonce |
/api-docs in your workspace to browse resources and confirm method + path shapes, then verify the exact live behavior against the endpoints listed on this page before wiring up production traffic.