# HTTP API

Base URL: `https://api.trymirio.com` (or your `apiUrl`). JSON in and out. Errors always have this shape:

```json
{ "error": { "code": "validation_failed", "message": "2 fields are invalid", "fields": { "email": "Enter a valid email address" }, "hint": "…", "docs": "https://api.trymirio.com/docs/schema.md" } }
```

Codes: `project_not_found`, `form_not_found`, `project_unclaimed` (403), `origin_not_allowed` (403), `agents_blocked` (403), `validation_failed` (400), `schema_rejected` (400), `rate_limited` (429, with `Retry-After`), `unauthorized` (401), `forbidden` (403), `not_found` (404), `conflict` (409), `invalid_request` (400), `internal` (500).

## Submissions (public, used by the SDK)

`POST /v1/projects/{projectId}/forms/{slug}/submissions`

CORS is limited to the site's domains (`mirio domains`; sandbox projects: localhost only). Requests without an `Origin` header are rejected unless they carry the project secret key. Headers: `Idempotency-Key` (optional, recommended), `X-Mirio-Test: 1` (with the secret key; stores a `test` submission). A localhost `Origin` on a live project is also stored as `test` (enriched, never delivered or counted).

```json
{
  "schemaHash": "sha256 hex of the canonical schema (the SDK computes it)",
  "values": { "email": "a@b.co", "name": "Ada" },
  "status": "complete",
  "sessionId": "uuid, required for partial",
  "pageIndex": 0,
  "meta": { "pageUrl": "…", "referrer": "/pricing", "landingPage": "/blog/x", "entryReferrer": "https://…", "utm": { "source": "x" }, "startedAt": "ISO", "honeypot": "", "turnstileToken": "…",
            "actor": { "type": "agent", "name": "Claude", "onBehalfOf": "Ada" } }
}
```

Response `200`: `{ "id": "sub_…", "status": "complete" | "partial" | "test", "success": { …settings.success }, "flags": ["unregistered_schema", "agent"] }`. Spam is stored but never revealed to the client; the response says `complete`. A declared agent (`meta.actor.type: "agent"`) always gets the `agent` flag, or `403 agents_blocked` when the form's policy is `block`.

## Project API (secret key)

`Authorization: Bearer sk_…`. This is what the CLI uses.

| Method and path | Purpose |
| --- | --- |
| `POST /v1/projects` | Create a sandbox project (no auth; rate limited). Returns the secret key once and the claim link. |
| `POST /v1/projects/link` | Connect a repo to a site created in the dashboard (no auth; same rate limit as provisioning): `{ "code": "mil_XXXXX-XXXXX-XXXXX-XXXXX" }` → `{ project, secretKey, drafts: [{ slug, name, template, brief }] }`. Codes are single use and last 24 hours (`400` when used or expired: copy a fresh prompt). A site that already has a key gets `409 conflict` unless the code is a reconnect code, which replaces the key (the old repo stops working). `mirio init --link <code>` calls this. |
| `GET /v1/project` | Status: mode, claim link, origins, workspace, forms with 7-day counts. Drafts are listed with `status: "draft"` and null version fields. |
| `PUT /v1/project/forms/{slug}` | Register a schema: `{ "schema": …, "source"?: { "env": "production" \| "preview" \| "development" \| "unknown", "branch"?, "commit"?, "url"? } }`. The framework plugin and `mirio sync` fill `source` from the host; without it the push is `unknown` and never changes production. A production push makes this version the form's questions (`productionVersionNumber`) and marks the form live; preview and development pushes register versions without changing production or, once the form is live, its settings. Returns the version, whether it was created, the diff and warnings (type changes on kept fields, removed fields that have answers). |
| `GET /v1/project/forms` | List forms, each with `status`: `draft` (created in the dashboard, waiting for its schema file), `active` or `archived`. Pushing a draft's slug registers its first version and makes it `active`, keeping its notifications and triage settings. Drafts take no responses and have no `schema.json` (`404 form_not_found`). |
| `GET /v1/project/forms/{slug}` | Form (`versionNumber` = latest registration, `productionVersionNumber`, `liveAt`), settings, and versions with fields and provenance (`env`, `branch`, `commit`, `deployUrl`, `productionAt`). |
| `GET /v1/project/forms/{slug}/submissions?status=&q=&limit=&cursor=` | Paged submissions, newest first. `status` is what a response is: `all` (default), `complete`, `partial`, `spam`, `test`. `q` searches the answers. |
| `GET /v1/project/forms/{slug}/submissions/{id}` | One submission. |
| `PATCH /v1/project/forms/{slug}/submissions/{id}` | `{ "category": "<review category>", "fit": "high" \| "medium" \| "low", "groups": { "<group key>": "<option>" } }` (any of them). They correct triage (see [AI review](/docs/ai-review.md#corrections)). Every change is recorded in the response's history. Other fields are read only. Returns the updated record. |
| `GET /v1/project/forms/{slug}/export.csv?status=&q=` | CSV: `id`, `status`, `ai_summary`, `ai_category`, `ai_fit`, `ai_group_<key>` per triage group, `created_at`, one column per field across versions, `page_url`, `referrer`, `landing_page`, `entry_referrer`, `utm_*`, `flags`. `status` defaults to `complete`. |
| `GET` / `PUT /v1/project/forms/{slug}/review` | Triage settings: `{ "review": { "enabled"?, "idealResponse"?, "summary"?, "categories"?, "groups"? }, "effective": { … } }`. `groups` is up to 5 `{ key?, name, options, hint? }`: triage picks one option in each. `summary` is what each summary should focus on, in plain words. `PUT` replaces them with the body you send. |
| `GET` / `POST` / `DELETE /v1/project/domains` | The site's domains: `{ "domains": ["https://acme.com", "https://*.acme.com"] }`. `POST { "domain": "acme.com" }` adds one (normalized); `DELETE ?domain=acme.com` removes one. |
| `GET` / `PUT` / `DELETE /v1/project/turnstile` | The site's Cloudflare Turnstile widget: `{ "siteKey": "0x4AAAAAAA…" \| null, "forms": [{ "slug": "contact", "siteKey": "0x4AAAAAAA…", "verified": true }], "note"?: "…" }`. `PUT { "siteKey", "secret" }` stores the secret key encrypted after Cloudflare accepts it (it is never returned); `DELETE` removes it. See [Turnstile](/docs/spam.md#turnstile). |
| `GET /v1/project/destinations` | List destinations (never includes secrets; a channel connected with "Add to Slack" shows as `#channel (Workspace)`). |
| `POST /v1/project/destinations` | `{ type: 'email' \| 'webhook' \| 'slack' \| 'postgres', formSlug \| allForms: true, email?, url?, connectionString?, table?, schema?, label?, filter?: { categories?, fit?, groups?: { <group key>: [options] } } }`. Destinations are per form; `allForms` creates one per form and returns `{ destinations }`, `formSlug` returns `{ destination }`. Email goes to workspace members only. `filter` is for email and Slack only. Webhooks return the signing secret once. |
| `POST /v1/project/destinations/slack-link` | `{ formSlug } \| { allForms: true }` → `201 { url, expiresAt, formSlug }`: a 30-minute link to the dashboard's "Add to Slack" for a person in the site's workspace (claimed sites only). Webhook and database URLs must be public https; see [destinations](/docs/destinations.md). |
| `DELETE /v1/project/destinations/{id}` | Remove a destination. |
| `POST /v1/project/destinations/{id}/test` | Send a sample submission to it. |

Public, no auth: `GET /v1/projects/{projectId}/forms/{slug}/schema.json` describes a form for agents (fields, conditions, agent policy, an example request). See [Agent-native forms](/docs/agents.md).

Submission record:

```json
{
  "id": "sub_…", "formId": "frm_…", "schemaVersionId": "sv_…", "versionNumber": 3,
  "status": "complete", "values": { "email": "a@b.co" }, "extra": {},
  "sessionId": null, "pagesCompleted": null, "flags": [], "spamScore": 0, "spamReasons": [],
  "meta": { "pageUrl": "…", "referrer": "/pricing", "landingPage": "/blog/x", "entryReferrer": "https://news.ycombinator.com", "utm": {}, "userAgent": "…", "country": "DE" },
  "enrichments": { "email": { "domain": "b.co", "isFreeMail": false, "isRole": false, "isDisposable": false, "mxValid": true }, "duplicate": null, "velocity": { "ipSubmissions1h": 0 } },
  "review": { "summary": "…", "category": "Sales lead", "fit": "high", "fitReason": "…", "facts": { "company": "…" }, "spam": { "likely": false, "reason": null }, "model": "gpt-6-luna", "reviewedAt": "…" },
  "createdAt": "…", "completedAt": "…"
}
```

`status` is what a response is (Mirio sets it). `review` is [triage](/docs/ai-review.md), `null` when review is off, pending or failed.

## Read API (workspace API key)

`Authorization: Bearer mi_…`, created in the dashboard. Read only.

| Method and path | Purpose |
| --- | --- |
| `GET /v1/forms` | All forms in the workspace. |
| `GET /v1/forms/{formId}/submissions` | Same query parameters and shape as above. |
| `GET /v1/forms/{formId}/submissions/{id}` | One submission. |
| `GET /v1/forms/{formId}/export.csv` | CSV, same columns and filters as above. |

## Rate limits

Per IP 60 submissions a minute, per form and per origin 600 a minute, provisioning 5 an hour per IP. Over the limit returns `429` with `Retry-After`. The SDK retries 5xx and network errors, never 4xx.
