# REST API

> Read forms and submissions, move submissions in and out of spam, and delete them with a secret key.

The REST API lets your server read forms and submissions, move submissions in and out of spam, and delete them. It is JSON over HTTPS at `https://ikiform.com/api/v1`. The OpenAPI 3.1 document is at [/api/v1/openapi.json](https://ikiform.com/api/v1/openapi.json).

> Creating submissions is not part of `/api/v1`. Post to the form endpoint with a secret key instead; see [From a server](https://ikiform.com/docs/guides/server.md).

## API keys

Create keys in the dashboard under **API keys**. A key belongs to one workspace and only sees that workspace. The full key is shown once; Ikiform stores only its hash.

| Kind | Prefix | Use |
| --- | --- | --- |
| Secret | `ik_sk_` | For servers. Calls the REST API with the scopes you choose, and can create submissions. |
| Publishable | `ik_pub_` | For browsers. Can only submit forms, and only from the domains you list on the key. |

### Scopes

| Scope | Grants |
| --- | --- |
| `forms:read` | `GET /forms`, `GET /forms/:formId` |
| `submissions:read` | `GET /submissions`, `GET /submissions/:id` |
| `submissions:write` | `DELETE /submissions/:id`, `POST /submissions/:id/spam` and `/not-spam`, and posting to `/f/:formId` |

## Authentication

Send the secret key as a Bearer token. The API is server to server only: it sends no CORS headers and answers browser preflights with `405`, so a secret key can never work from a web page.

`Terminal`:

```bash
curl https://ikiform.com/api/v1/forms \
  -H "Authorization: Bearer $IKIFORM_SECRET_KEY"
```

- A missing, malformed, unknown, revoked or expired key gets `401 unauthorized` with `WWW-Authenticate: Bearer`.
- A publishable key gets `403 forbidden` (`Publishable keys can only submit forms.`). A key without the needed scope gets `403 forbidden` with the scope named in the message.
- Responses are sent with `Cache-Control: no-store`.

The SDK examples below share one client:

`ikiform.ts`:

```ts
import { createClient } from "@ikiform/sdk";

const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY! });
```

## Request composer

The interactive request composer on this page builds a REST API request from an endpoint, filters and ids, and shows it as curl, fetch and SDK code. It never sends anything: the REST API is server to server and refuses browser requests by design.

## Endpoints

| Method | Path | Scope | Returns |
| --- | --- | --- | --- |
| `GET` | [`/forms`](#list-forms) | `forms:read` | List forms |
| `GET` | [`/forms/:formId`](#get-form) | `forms:read` | Get a form |
| `GET` | [`/submissions`](#list-submissions) | `submissions:read` | List submissions |
| `GET` | [`/submissions/:id`](#get-submission) | `submissions:read` | Get a submission |
| `POST` | [`/submissions/:id/spam`](#mark-spam) | `submissions:write` | Mark as spam |
| `POST` | [`/submissions/:id/not-spam`](#mark-not-spam) | `submissions:write` | Mark as not spam |
| `DELETE` | [`/submissions/:id`](#delete-submission) | `submissions:write` | Delete a submission |
| `GET` | `/openapi.json` | None | The OpenAPI document |
| `GET` | `/health` | None | `{ "ok": true }` |

### List forms

`GET /forms` with the `forms:read` scope.

- Newest first. Takes `limit` (1 to 100, default 25) and `cursor`.

**curl** (`Terminal`)

```bash
curl "https://ikiform.com/api/v1/forms?limit=10" \
  -H "Authorization: Bearer $IKIFORM_SECRET_KEY"
```

**SDK** (`ikiform.ts`)

```ts
const page = await ikiform.forms.list({ limit: 10 });
```

**fetch** (`fetch.js`)

```js
const response = await fetch("https://ikiform.com/api/v1/forms?limit=10", {
  headers: { Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}` },
});
const body = await response.json();
```

**Response** (`200 OK`)

```json
{
  "object": "list",
  "data": [
    {
      "object": "form",
      "id": "frm_xxxxxxxxxxxxxxxx",
      "name": "Contact",
      "status": "active",
      "submission_count": 42,
      "created_at": "2026-10-01T09:30:00.000Z"
    }
  ],
  "has_more": true,
  "next_cursor": "c3ViXzAxazd2MmM5dDNlOG01cTR4NnowYTFiMmMz"
}
```

### Get a form

`GET /forms/:formId` with the `forms:read` scope.

- A form id from another workspace returns `404`, exactly like one that doesn't exist.

**curl** (`Terminal`)

```bash
curl https://ikiform.com/api/v1/forms/frm_xxxxxxxxxxxxxxxx \
  -H "Authorization: Bearer $IKIFORM_SECRET_KEY"
```

**SDK** (`ikiform.ts`)

```ts
const form = await ikiform.forms.get("frm_xxxxxxxxxxxxxxxx");
```

**fetch** (`fetch.js`)

```js
const response = await fetch("https://ikiform.com/api/v1/forms/frm_xxxxxxxxxxxxxxxx", {
  headers: { Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}` },
});
const body = await response.json();
```

**Response** (`200 OK`)

```json
{
  "object": "form",
  "id": "frm_xxxxxxxxxxxxxxxx",
  "name": "Contact",
  "status": "active",
  "submission_count": 42,
  "created_at": "2026-10-01T09:30:00.000Z"
}
```

### List submissions

`GET /submissions` with the `submissions:read` scope.

| Parameter | Values | Default |
| --- | --- | --- |
| `form_id` | A form id | All forms in the workspace |
| `status` | `received` or `spam` | Both |
| `limit` | 1 to 100 | 25 |
| `cursor` | `next_cursor` from the previous page | The first page |

- Newest first. A `form_id` from another workspace returns an empty list rather than an error, so ids can't be probed.

**curl** (`Terminal`)

```bash
curl "https://ikiform.com/api/v1/submissions?form_id=frm_xxxxxxxxxxxxxxxx&status=received&limit=50" \
  -H "Authorization: Bearer $IKIFORM_SECRET_KEY"
```

**SDK** (`ikiform.ts`)

```ts
const page = await ikiform.submissions.list({
  formId: "frm_xxxxxxxxxxxxxxxx",
  status: "received",
  limit: 50,
});
```

**fetch** (`fetch.js`)

```js
const response = await fetch("https://ikiform.com/api/v1/submissions?form_id=frm_xxxxxxxxxxxxxxxx&status=received&limit=50", {
  headers: { Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}` },
});
const body = await response.json();
```

**Response** (`200 OK`)

```json
{
  "object": "list",
  "data": [
    {
      "object": "submission",
      "id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
      "form_id": "frm_xxxxxxxxxxxxxxxx",
      "status": "received",
      "fields": { "name": "Ada", "email": "ada@example.com" },
      "meta": {
        "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
        "origin": "https://example.com",
        "referrer": "https://example.com/contact",
        "country": "GB",
        "subject": null,
        "reply_to": "ada@example.com"
      },
      "spam": { "score": 0, "reasons": [] },
      "created_at": "2026-10-17T12:00:00.000Z"
    }
  ],
  "has_more": true,
  "next_cursor": "c3ViXzAxazd2MmM5dDNlOG01cTR4NnowYTFiMmMz"
}
```

### Get a submission

`GET /submissions/:id` with the `submissions:read` scope.

- Ids from another workspace return `404`, exactly like ids that don't exist.

**curl** (`Terminal`)

```bash
curl https://ikiform.com/api/v1/submissions/sub_01k7v2c9t3e8m5q4x6z0a1b2c3 \
  -H "Authorization: Bearer $IKIFORM_SECRET_KEY"
```

**SDK** (`ikiform.ts`)

```ts
const submission = await ikiform.submissions.get("sub_01k7v2c9t3e8m5q4x6z0a1b2c3");
```

**fetch** (`fetch.js`)

```js
const response = await fetch("https://ikiform.com/api/v1/submissions/sub_01k7v2c9t3e8m5q4x6z0a1b2c3", {
  headers: { Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}` },
});
const body = await response.json();
```

**Response** (`200 OK`)

```json
{
  "object": "submission",
  "id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
  "form_id": "frm_xxxxxxxxxxxxxxxx",
  "status": "received",
  "fields": { "name": "Ada", "email": "ada@example.com" },
  "meta": {
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
    "origin": "https://example.com",
    "referrer": "https://example.com/contact",
    "country": "GB",
    "subject": null,
    "reply_to": "ada@example.com"
  },
  "spam": { "score": 0, "reasons": [] },
  "created_at": "2026-10-17T12:00:00.000Z"
}
```

### Mark as spam

`POST /submissions/:id/spam` with the `submissions:write` scope.

- Moves a received submission to spam, lowers the form's `submission_count` and cancels its pending webhook deliveries.
- Calling it on a submission that is already spam returns it unchanged.

**curl** (`Terminal`)

```bash
curl -X POST https://ikiform.com/api/v1/submissions/sub_01k7v2c9t3e8m5q4x6z0a1b2c3/spam \
  -H "Authorization: Bearer $IKIFORM_SECRET_KEY"
```

**SDK** (`ikiform.ts`)

```ts
const submission = await ikiform.submissions.markSpam("sub_01k7v2c9t3e8m5q4x6z0a1b2c3");
```

**fetch** (`fetch.js`)

```js
const response = await fetch("https://ikiform.com/api/v1/submissions/sub_01k7v2c9t3e8m5q4x6z0a1b2c3/spam", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}` },
});
const body = await response.json();
```

**Response** (`200 OK`)

```json
{
  "object": "submission",
  "id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
  "form_id": "frm_xxxxxxxxxxxxxxxx",
  "status": "spam",
  "fields": { "name": "Ada", "email": "ada@example.com" },
  "meta": {
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
    "origin": "https://example.com",
    "referrer": "https://example.com/contact",
    "country": "GB",
    "subject": null,
    "reply_to": "ada@example.com"
  },
  "spam": { "score": 0, "reasons": [] },
  "created_at": "2026-10-17T12:00:00.000Z"
}
```

### Mark as not spam

`POST /submissions/:id/not-spam` with the `submissions:write` scope.

- Moves it back to received, clears its spam score and reasons, and sends `submission.created` to every matching webhook endpoint.
- Calling it on a received submission returns it unchanged.

**curl** (`Terminal`)

```bash
curl -X POST https://ikiform.com/api/v1/submissions/sub_01k7v2c9t3e8m5q4x6z0a1b2c3/not-spam \
  -H "Authorization: Bearer $IKIFORM_SECRET_KEY"
```

**SDK** (`ikiform.ts`)

```ts
const submission = await ikiform.submissions.markNotSpam("sub_01k7v2c9t3e8m5q4x6z0a1b2c3");
```

**fetch** (`fetch.js`)

```js
const response = await fetch("https://ikiform.com/api/v1/submissions/sub_01k7v2c9t3e8m5q4x6z0a1b2c3/not-spam", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}` },
});
const body = await response.json();
```

**Response** (`200 OK`)

```json
{
  "object": "submission",
  "id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
  "form_id": "frm_xxxxxxxxxxxxxxxx",
  "status": "received",
  "fields": { "name": "Ada", "email": "ada@example.com" },
  "meta": {
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
    "origin": "https://example.com",
    "referrer": "https://example.com/contact",
    "country": "GB",
    "subject": null,
    "reply_to": "ada@example.com"
  },
  "spam": { "score": 0, "reasons": [] },
  "created_at": "2026-10-17T12:00:00.000Z"
}
```

### Delete a submission

`DELETE /submissions/:id` with the `submissions:write` scope.

- Permanently removes the submission and its webhook deliveries. There is no undo. Deleting again returns `404`.

**curl** (`Terminal`)

```bash
curl -X DELETE https://ikiform.com/api/v1/submissions/sub_01k7v2c9t3e8m5q4x6z0a1b2c3 \
  -H "Authorization: Bearer $IKIFORM_SECRET_KEY"
```

**SDK** (`ikiform.ts`)

```ts
const result = await ikiform.submissions.delete("sub_01k7v2c9t3e8m5q4x6z0a1b2c3");
```

**fetch** (`fetch.js`)

```js
const response = await fetch("https://ikiform.com/api/v1/submissions/sub_01k7v2c9t3e8m5q4x6z0a1b2c3", {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}` },
});
const body = await response.json();
```

**Response** (`200 OK`)

```json
{ "object": "submission", "id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3", "deleted": true }
```

## Objects

Objects are snake_case JSON with an `object` field. Timestamps are ISO 8601 in UTC with milliseconds. Submission ids are ordered by time (`sub_` plus 26 characters); form ids are random (`frm_` plus 16 characters).

| Form field | Description |
| --- | --- |
| `status` | `active`, `paused` or `disabled` |
| `submission_count` | Received submissions, not spam |

| Submission field | Description |
| --- | --- |
| `status` | `received` or `spam` |
| `fields` | The stored values |
| `meta` | `user_agent`, `origin`, `referrer`, `country`, `subject`, `reply_to`. Every key is present and any value can be `null`. The IP hash is never returned |
| `spam.score`, `spam.reasons` | The spam score and reasons, such as `honeypot` or `origin_mismatch` |

## Pagination

Lists are newest first and use opaque cursors.

1. Request without a cursor.
2. If `has_more` is true, send `next_cursor` as `cursor` with the same filters.
3. Stop when `next_cursor` is `null`.

Pages are stable while new submissions arrive: new ones appear on the first page, never as duplicates further down. Don't build or parse cursors yourself; an invalid one returns `400 invalid_request`.

**SDK** (`export.ts`)

```ts
for await (const submission of ikiform.submissions.iterate({ formId: "frm_xxxxxxxxxxxxxxxx" })) {
  console.log(submission.id, submission.fields);
}
```

**fetch** (`export.js`)

```js
let cursor = null;

do {
  const url = new URL("https://ikiform.com/api/v1/submissions");
  url.searchParams.set("limit", "100");
  if (cursor) url.searchParams.set("cursor", cursor);
  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}` },
  });
  const page = await response.json();
  for (const submission of page.data) console.log(submission.id);
  cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
```

## Errors

Errors use the same envelope as form submissions. Every response has an `X-Request-Id` header, which errors repeat as `request_id`.

`403 Forbidden`:

```json
{
  "ok": false,
  "error": { "code": "forbidden", "message": "This API key is missing the forms:read scope." },
  "request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}
```

| Status | Code | When |
| --- | --- | --- |
| 400 | `invalid_request` | `limit`, `status`, `form_id` or `cursor` is invalid; the message names it |
| 401 | `unauthorized` | Missing or invalid key |
| 403 | `forbidden` | Publishable key, or a missing scope |
| 404 | `not_found` | Unknown route or id, or an id in another workspace |
| 405 | `method_not_allowed` | A browser preflight |
| 429 | `rate_limited` | Over the per-key rate limit |
| 500 | `server_error` | Something failed on our side; quote `request_id` |

## Rate limit

Each key can make 100 requests per 60 seconds. Over the limit, the API returns `429 rate_limited` with a `Retry-After` header in seconds. The SDK exposes it as `error.retryAfter`.
