# API REST

> Lisez les formulaires et les soumissions, déplacez les soumissions vers ou hors du spam et supprimez-les avec une clé secrète.

L'API REST permet à votre serveur de lire les formulaires et les soumissions, de déplacer les soumissions vers ou hors du spam, et de les supprimer. C'est du JSON en HTTPS à l'adresse `https://ikiform.com/api/v1`. Le document OpenAPI 3.1 se trouve à [/api/v1/openapi.json](https://ikiform.com/api/v1/openapi.json).

> La création de soumissions ne fait pas partie de `/api/v1`. Envoyez plutôt vers l'endpoint du formulaire avec une clé secrète ; voir [Depuis un serveur](https://ikiform.com/fr/docs/guides/server.md).

## Clés API

Créez des clés dans le tableau de bord sous **API keys**. Une clé appartient à un seul espace de travail et ne voit que celui-ci. La clé complète n'est affichée qu'une fois ; Ikiform n'en stocke que le hachage.

| Type | Préfixe | Utilisation |
| --- | --- | --- |
| Secrète | `ik_sk_` | Pour les serveurs. Appelle l'API REST avec les portées choisies, et peut créer des soumissions. |
| Publiable | `ik_pub_` | Pour les navigateurs. Peut seulement soumettre des formulaires, et uniquement depuis les domaines listés sur la clé. |

### Portées

| Portée | Autorise |
| --- | --- |
| `forms:read` | `GET /forms`, `GET /forms/:formId` |
| `submissions:read` | `GET /submissions`, `GET /submissions/:id` |
| `submissions:write` | `DELETE /submissions/:id`, `POST /submissions/:id/spam` et `/not-spam`, et l'envoi vers `/f/:formId` |

## Authentification

Envoyez la clé secrète comme token Bearer. L'API fonctionne uniquement de serveur à serveur : elle n'envoie aucun en-tête CORS et répond aux preflights des navigateurs avec `405`, donc une clé secrète ne peut jamais fonctionner depuis une page web.

`Terminal`:

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

- Une clé absente, mal formée, inconnue, révoquée ou expirée reçoit `401 unauthorized` avec `WWW-Authenticate: Bearer`.
- Une clé publiable reçoit `403 forbidden` (`Publishable keys can only submit forms.`). Une clé sans la portée nécessaire reçoit `403 forbidden` avec le nom de la portée dans le message.
- Les réponses sont envoyées avec `Cache-Control: no-store`.

Les exemples SDK ci-dessous partagent un même client :

`ikiform.ts`:

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

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

## Compositeur de requête

Le compositeur de requête interactif de cette page construit une requête à l'API REST à partir d'un endpoint, de filtres et d'ids, et l'affiche en code curl, fetch et SDK. Il n'envoie jamais rien : l'API REST fonctionne de serveur à serveur et refuse par conception les requêtes venant du navigateur.

## Endpoints

| Méthode | Chemin | Portée | Renvoie |
| --- | --- | --- | --- |
| `GET` | [`/forms`](#list-forms) | `forms:read` | Lister les formulaires |
| `GET` | [`/forms/:formId`](#get-form) | `forms:read` | Récupérer un formulaire |
| `GET` | [`/submissions`](#list-submissions) | `submissions:read` | Lister les soumissions |
| `GET` | [`/submissions/:id`](#get-submission) | `submissions:read` | Récupérer une soumission |
| `POST` | [`/submissions/:id/spam`](#mark-spam) | `submissions:write` | Marquer comme spam |
| `POST` | [`/submissions/:id/not-spam`](#mark-not-spam) | `submissions:write` | Marquer comme légitime |
| `DELETE` | [`/submissions/:id`](#delete-submission) | `submissions:write` | Supprimer une soumission |
| `GET` | `/openapi.json` | Aucun | Le document OpenAPI |
| `GET` | `/health` | Aucun | `{ "ok": true }` |

### Lister les formulaires

`GET /forms` avec la portée `forms:read`.

- Les plus récents en premier. Accepte `limit` (1 à 100, 25 par défaut) et `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();
```

**Réponse** (`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"
}
```

### Récupérer un formulaire

`GET /forms/:formId` avec la portée `forms:read`.

- Un id de formulaire d'un autre espace de travail renvoie `404`, exactement comme un id qui n'existe pas.

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

**Réponse** (`200 OK`)

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

### Lister les soumissions

`GET /submissions` avec la portée `submissions:read`.

| Paramètre | Valeurs | Par défaut |
| --- | --- | --- |
| `form_id` | Un id de formulaire | Tous les formulaires de l'espace de travail |
| `status` | `received` ou `spam` | Les deux |
| `limit` | 1 à 100 | 25 |
| `cursor` | `next_cursor` de la page précédente | La première page |

- Les plus récentes en premier. Un `form_id` d'un autre espace de travail renvoie une liste vide plutôt qu'une erreur, pour que les ids ne puissent pas être sondés.

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

**Réponse** (`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"
}
```

### Récupérer une soumission

`GET /submissions/:id` avec la portée `submissions:read`.

- Les ids d'un autre espace de travail renvoient `404`, exactement comme des ids qui n'existent pas.

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

**Réponse** (`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"
}
```

### Marquer comme spam

`POST /submissions/:id/spam` avec la portée `submissions:write`.

- Déplace une soumission reçue vers le spam, diminue le `submission_count` du formulaire et annule ses livraisons de webhook en attente.
- L'appeler sur une soumission déjà marquée comme spam la renvoie inchangée.

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

**Réponse** (`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"
}
```

### Marquer comme légitime

`POST /submissions/:id/not-spam` avec la portée `submissions:write`.

- La replace parmi les reçues, efface son score et ses raisons de spam, et envoie `submission.created` à chaque endpoint de webhook concerné.
- L'appeler sur une soumission reçue la renvoie inchangée.

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

**Réponse** (`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"
}
```

### Supprimer une soumission

`DELETE /submissions/:id` avec la portée `submissions:write`.

- Supprime définitivement la soumission et ses livraisons de webhook. Impossible d'annuler. Une nouvelle suppression renvoie `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();
```

**Réponse** (`200 OK`)

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

## Objets

Les objets sont du JSON en snake_case avec un champ `object`. Les horodatages sont en ISO 8601 UTC avec les millisecondes. Les ids de soumission sont triés par date (`sub_` suivi de 26 caractères) ; les ids de formulaire sont aléatoires (`frm_` suivi de 16 caractères).

| Champ du formulaire | Description |
| --- | --- |
| `status` | `active`, `paused` ou `disabled` |
| `submission_count` | Soumissions reçues, hors spam |

| Champ de la soumission | Description |
| --- | --- |
| `status` | `received` ou `spam` |
| `fields` | Les valeurs stockées |
| `meta` | `user_agent`, `origin`, `referrer`, `country`, `subject`, `reply_to`. Chaque clé est présente et n'importe quelle valeur peut être `null`. Le hachage de l'IP n'est jamais renvoyé |
| `spam.score`, `spam.reasons` | Le score et les raisons de spam, comme `honeypot` ou `origin_mismatch` |

## Pagination

Les listes commencent par les plus récents et utilisent des curseurs opaques.

1. Faites une requête sans curseur.
2. Si `has_more` vaut true, envoyez `next_cursor` dans `cursor` avec les mêmes filtres.
3. Arrêtez quand `next_cursor` vaut `null`.

Les pages restent stables pendant l'arrivée de nouvelles soumissions : les nouvelles apparaissent sur la première page, jamais en double plus loin. Ne construisez pas et n'analysez pas les curseurs vous-même ; un curseur invalide renvoie `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);
```

## Erreurs

Les erreurs utilisent la même enveloppe que les soumissions de formulaire. Chaque réponse a un en-tête `X-Request-Id`, que les erreurs reprennent en `request_id`.

`403 Forbidden`:

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

| Statut | Code | Quand |
| --- | --- | --- |
| 400 | `invalid_request` | `limit`, `status`, `form_id` ou `cursor` est invalide ; le message indique lequel |
| 401 | `unauthorized` | Clé absente ou invalide |
| 403 | `forbidden` | Clé publiable, ou portée manquante |
| 404 | `not_found` | Route ou id inconnu, ou id d'un autre espace de travail |
| 405 | `method_not_allowed` | Un preflight de navigateur |
| 429 | `rate_limited` | Limite de débit par clé dépassée |
| 500 | `server_error` | Une erreur de notre côté ; indiquez le `request_id` |

## Limite de débit

Chaque clé peut faire 100 requêtes par tranche de 60 secondes. Au-delà, l'API renvoie `429 rate_limited` avec un en-tête `Retry-After` en secondes. Le SDK l'expose dans `error.retryAfter`.
