# REST API

> Lies Formulare und Einsendungen, verschiebe Einsendungen in den Spam und wieder heraus und lösche sie mit einem Secret Key.

Mit der REST-API kann dein Server Formulare und Einsendungen lesen, Einsendungen in den Spam und wieder heraus verschieben und sie löschen. Sie spricht JSON über HTTPS unter `https://ikiform.com/api/v1`. Das OpenAPI-3.1-Dokument liegt unter [/api/v1/openapi.json](https://ikiform.com/api/v1/openapi.json).

> Einsendungen erstellen gehört nicht zu `/api/v1`. Sende stattdessen mit einem Secret Key an den Formular-Endpunkt; siehe [Von einem Server](https://ikiform.com/de/docs/guides/server.md).

## API-Schlüssel

Erstelle Schlüssel im Dashboard unter **API keys**. Ein Schlüssel gehört zu einem Workspace und sieht nur diesen Workspace. Der vollständige Schlüssel wird einmal angezeigt; Ikiform speichert nur seinen Hash.

| Art | Präfix | Verwende |
| --- | --- | --- |
| Secret | `ik_sk_` | Für Server. Ruft die REST-API mit den Scopes auf, die du wählst, und kann Einsendungen erstellen. |
| Publishable | `ik_pub_` | Für Browser. Kann nur Formulare absenden, und nur von den Domains, die du beim Schlüssel einträgst. |

### Scopes

| Scope | Erlaubt |
| --- | --- |
| `forms:read` | `GET /forms`, `GET /forms/:formId` |
| `submissions:read` | `GET /submissions`, `GET /submissions/:id` |
| `submissions:write` | `DELETE /submissions/:id`, `POST /submissions/:id/spam` und `/not-spam` sowie das Senden an `/f/:formId` |

## Authentifizierung

Sende den Secret Key als Bearer-Token. Die API funktioniert nur von Server zu Server: Sie sendet keine CORS-Header und beantwortet Browser-Preflights mit `405`, daher kann ein Secret Key nie von einer Webseite aus funktionieren.

`Terminal`:

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

- Ein fehlender, fehlerhafter, unbekannter, widerrufener oder abgelaufener Schlüssel erhält `401 unauthorized` mit `WWW-Authenticate: Bearer`.
- Ein Publishable Key erhält `403 forbidden` (`Publishable keys can only submit forms.`). Ein Schlüssel ohne den nötigen Scope erhält `403 forbidden`, und die Meldung nennt den Scope.
- Antworten werden mit `Cache-Control: no-store` gesendet.

Die SDK-Beispiele unten nutzen alle denselben Client:

`ikiform.ts`:

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

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

## Request-Builder

Der interaktive Request-Builder auf dieser Seite baut aus einem Endpunkt, Filtern und IDs einen REST-API-Request und zeigt ihn als curl-, fetch- und SDK-Code. Er sendet nie etwas: Die REST-API arbeitet von Server zu Server und lehnt Browser-Requests absichtlich ab.

## Endpunkte

| Methode | Pfad | Scope | Gibt zurück |
| --- | --- | --- | --- |
| `GET` | [`/forms`](#list-forms) | `forms:read` | Formulare auflisten |
| `GET` | [`/forms/:formId`](#get-form) | `forms:read` | Ein Formular abrufen |
| `GET` | [`/submissions`](#list-submissions) | `submissions:read` | Einsendungen auflisten |
| `GET` | [`/submissions/:id`](#get-submission) | `submissions:read` | Eine Einsendung abrufen |
| `POST` | [`/submissions/:id/spam`](#mark-spam) | `submissions:write` | Als Spam markieren |
| `POST` | [`/submissions/:id/not-spam`](#mark-not-spam) | `submissions:write` | Als kein Spam markieren |
| `DELETE` | [`/submissions/:id`](#delete-submission) | `submissions:write` | Eine Einsendung löschen |
| `GET` | `/openapi.json` | Keiner | Das OpenAPI-Dokument |
| `GET` | `/health` | Keiner | `{ "ok": true }` |

### Formulare auflisten

`GET /forms` mit dem Scope `forms:read`.

- Neueste zuerst. Akzeptiert `limit` (1 bis 100, Standard 25) und `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();
```

**Antwort** (`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"
}
```

### Ein Formular abrufen

`GET /forms/:formId` mit dem Scope `forms:read`.

- Eine Formular-ID aus einem anderen Workspace gibt `404` zurück, genau wie eine, die nicht existiert.

**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();
```

**Antwort** (`200 OK`)

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

### Einsendungen auflisten

`GET /submissions` mit dem Scope `submissions:read`.

| Parameter | Werte | Default |
| --- | --- | --- |
| `form_id` | Eine Formular-ID | Alle Formulare im Workspace |
| `status` | `received` oder `spam` | Beide |
| `limit` | 1 bis 100 | 25 |
| `cursor` | `next_cursor` von der vorherigen Seite | Die erste Seite |

- Neueste zuerst. Eine `form_id` aus einem anderen Workspace gibt statt eines Fehlers eine leere Liste zurück, sodass sich IDs nicht ausprobieren lassen.

**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();
```

**Antwort** (`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"
}
```

### Eine Einsendung abrufen

`GET /submissions/:id` mit dem Scope `submissions:read`.

- IDs aus einem anderen Workspace geben `404` zurück, genau wie IDs, die nicht existieren.

**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();
```

**Antwort** (`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"
}
```

### Als Spam markieren

`POST /submissions/:id/spam` mit dem Scope `submissions:write`.

- Verschiebt eine empfangene Einsendung in den Spam, senkt den `submission_count` des Formulars und bricht ihre ausstehenden Webhook-Zustellungen ab.
- Bei einer Einsendung, die schon Spam ist, wird sie unverändert zurückgegeben.

**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();
```

**Antwort** (`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"
}
```

### Als kein Spam markieren

`POST /submissions/:id/not-spam` mit dem Scope `submissions:write`.

- Verschiebt sie zurück zu „empfangen“, löscht ihren Spam-Score und die Gründe und sendet `submission.created` an jeden passenden Webhook-Endpunkt.
- Bei einer empfangenen Einsendung wird sie unverändert zurückgegeben.

**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();
```

**Antwort** (`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"
}
```

### Eine Einsendung löschen

`DELETE /submissions/:id` mit dem Scope `submissions:write`.

- Entfernt die Einsendung und ihre Webhook-Zustellungen dauerhaft. Das lässt sich nicht rückgängig machen. Erneutes Löschen gibt `404` zurück.

**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();
```

**Antwort** (`200 OK`)

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

## Objekte

Objekte sind JSON in snake_case mit einem Feld `object`. Zeitstempel sind ISO 8601 in UTC mit Millisekunden. Einsendungs-IDs sind zeitlich geordnet (`sub_` plus 26 Zeichen); Formular-IDs sind zufällig (`frm_` plus 16 Zeichen).

| Formularfeld | Beschreibung |
| --- | --- |
| `status` | `active`, `paused` oder `disabled` |
| `submission_count` | Empfangene Einsendungen, kein Spam |

| Einsendungsfeld | Beschreibung |
| --- | --- |
| `status` | `received` oder `spam` |
| `fields` | Die gespeicherten Werte |
| `meta` | `user_agent`, `origin`, `referrer`, `country`, `subject`, `reply_to`. Jeder Schlüssel ist vorhanden und jeder Wert kann `null` sein. Der IP-Hash wird nie zurückgegeben |
| `spam.score`, `spam.reasons` | Der Spam-Score und die Gründe, etwa `honeypot` oder `origin_mismatch` |

## Paginierung

Listen sind nach Neuesten zuerst sortiert und nutzen opake Cursor.

1. Sende den Request ohne Cursor.
2. Wenn `has_more` true ist, sende `next_cursor` als `cursor` mit denselben Filtern.
3. Hör auf, wenn `next_cursor` `null` ist.

Seiten bleiben stabil, während neue Einsendungen eintreffen: Neue erscheinen auf der ersten Seite, nie als Duplikate weiter unten. Baue oder parse Cursor nicht selbst; ein ungültiger gibt `400 invalid_request` zurück.

**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);
```

## Fehler

Fehler nutzen dieselbe Hülle wie Formular-Einsendungen. Jede Antwort hat einen Header `X-Request-Id`, den Fehler als `request_id` wiederholen.

`403 Forbidden`:

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

| Status | Code | Wann |
| --- | --- | --- |
| 400 | `invalid_request` | `limit`, `status`, `form_id` oder `cursor` ist ungültig; die Meldung nennt den Parameter |
| 401 | `unauthorized` | Fehlender oder ungültiger Schlüssel |
| 403 | `forbidden` | Publishable Key oder fehlender Scope |
| 404 | `not_found` | Unbekannte Route oder ID, oder eine ID in einem anderen Workspace |
| 405 | `method_not_allowed` | Ein Browser-Preflight |
| 429 | `rate_limited` | Rate-Limit pro Schlüssel überschritten |
| 500 | `server_error` | Bei uns ist etwas fehlgeschlagen; gib die `request_id` an |

## Rate-Limit

Jeder Schlüssel kann 100 Requests pro 60 Sekunden senden. Über dem Limit gibt die API `429 rate_limited` mit einem Header `Retry-After` in Sekunden zurück. Das SDK stellt ihn als `error.retryAfter` bereit.
