# API REST

> Lee formularios y envíos, mueve envíos dentro y fuera del spam y elimínalos con una clave secreta.

La API REST permite a tu servidor leer formularios y envíos, mover envíos dentro y fuera del spam y eliminarlos. Es JSON por HTTPS en `https://ikiform.com/api/v1`. El documento OpenAPI 3.1 está en [/api/v1/openapi.json](https://ikiform.com/api/v1/openapi.json).

> Crear envíos no forma parte de `/api/v1`. En su lugar, envía al endpoint del formulario con una clave secreta; consulta [Desde un servidor](https://ikiform.com/es/docs/guides/server.md).

## Claves de API

Crea las claves en el panel, en **API keys**. Una clave pertenece a un espacio de trabajo y solo ve ese espacio de trabajo. La clave completa se muestra una sola vez; Ikiform solo guarda su hash.

| Tipo | Prefijo | Uso |
| --- | --- | --- |
| Secreta | `ik_sk_` | Para servidores. Llama a la API REST con los permisos que elijas y puede crear envíos. |
| Publicable | `ik_pub_` | Para navegadores. Solo puede enviar formularios, y solo desde los dominios que indiques en la clave. |

### Permisos

| Permiso | Concede |
| --- | --- |
| `forms:read` | `GET /forms`, `GET /forms/:formId` |
| `submissions:read` | `GET /submissions`, `GET /submissions/:id` |
| `submissions:write` | `DELETE /submissions/:id`, `POST /submissions/:id/spam` y `/not-spam`, y enviar a `/f/:formId` |

## Autenticación

Envía la clave secreta como token Bearer. La API es solo de servidor a servidor: no envía cabeceras CORS y responde a los preflights del navegador con `405`, así que una clave secreta nunca puede funcionar desde una página web.

`Terminal`:

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

- Una clave ausente, mal formada, desconocida, revocada o caducada recibe `401 unauthorized` con `WWW-Authenticate: Bearer`.
- Una clave publicable recibe `403 forbidden` (`Publishable keys can only submit forms.`). Una clave sin el permiso necesario recibe `403 forbidden` con el permiso indicado en el mensaje.
- Las respuestas se envían con `Cache-Control: no-store`.

Los ejemplos del SDK de abajo comparten un mismo cliente:

`ikiform.ts`:

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

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

## Compositor de peticiones

El compositor de peticiones interactivo de esta página construye una petición a la API REST a partir de un endpoint, filtros e ids, y la muestra como código curl, fetch y SDK. Nunca envía nada: la API REST es de servidor a servidor y rechaza las peticiones del navegador por diseño.

## Endpoints

| Método | Ruta | Permiso | Devuelve |
| --- | --- | --- | --- |
| `GET` | [`/forms`](#list-forms) | `forms:read` | Lista los formularios |
| `GET` | [`/forms/:formId`](#get-form) | `forms:read` | Obtiene un formulario |
| `GET` | [`/submissions`](#list-submissions) | `submissions:read` | Lista los envíos |
| `GET` | [`/submissions/:id`](#get-submission) | `submissions:read` | Obtiene un envío |
| `POST` | [`/submissions/:id/spam`](#mark-spam) | `submissions:write` | Marca como spam |
| `POST` | [`/submissions/:id/not-spam`](#mark-not-spam) | `submissions:write` | Marca como no spam |
| `DELETE` | [`/submissions/:id`](#delete-submission) | `submissions:write` | Elimina un envío |
| `GET` | `/openapi.json` | Ninguno | El documento OpenAPI |
| `GET` | `/health` | Ninguno | `{ "ok": true }` |

### Lista los formularios

`GET /forms` con el permiso `forms:read`.

- De más reciente a más antiguo. Acepta `limit` (de 1 a 100, por defecto 25) y `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();
```

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

### Obtiene un formulario

`GET /forms/:formId` con el permiso `forms:read`.

- Un id de formulario de otro espacio de trabajo devuelve `404`, exactamente igual que uno que no existe.

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

**Respuesta** (`200 OK`)

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

### Lista los envíos

`GET /submissions` con el permiso `submissions:read`.

| Parámetro | Valores | Por defecto |
| --- | --- | --- |
| `form_id` | Un id de formulario | Todos los formularios del espacio de trabajo |
| `status` | `received` o `spam` | Ambos |
| `limit` | De 1 a 100 | 25 |
| `cursor` | `next_cursor` de la página anterior | La primera página |

- De más reciente a más antiguo. Un `form_id` de otro espacio de trabajo devuelve una lista vacía en lugar de un error, para que no se puedan sondear ids.

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

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

### Obtiene un envío

`GET /submissions/:id` con el permiso `submissions:read`.

- Los ids de otro espacio de trabajo devuelven `404`, exactamente igual que los ids que no existen.

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

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

### Marca como spam

`POST /submissions/:id/spam` con el permiso `submissions:write`.

- Mueve un envío recibido a spam, reduce el `submission_count` del formulario y cancela sus entregas de webhook pendientes.
- Llamarlo sobre un envío que ya es spam lo devuelve sin cambios.

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

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

### Marca como no spam

`POST /submissions/:id/not-spam` con el permiso `submissions:write`.

- Lo devuelve a recibidos, borra su puntuación y sus motivos de spam, y envía `submission.created` a todos los endpoints de webhook correspondientes.
- Llamarlo sobre un envío recibido lo devuelve sin cambios.

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

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

### Elimina un envío

`DELETE /submissions/:id` con el permiso `submissions:write`.

- Elimina de forma permanente el envío y sus entregas de webhook. No se puede deshacer. Eliminarlo otra vez devuelve `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();
```

**Respuesta** (`200 OK`)

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

## Objetos

Los objetos son JSON en snake_case con un campo `object`. Las marcas de tiempo están en ISO 8601 en UTC con milisegundos. Los ids de envío están ordenados por tiempo (`sub_` más 26 caracteres); los ids de formulario son aleatorios (`frm_` más 16 caracteres).

| Campo del formulario | Descripción |
| --- | --- |
| `status` | `active`, `paused` o `disabled` |
| `submission_count` | Envíos recibidos, sin contar el spam |

| Campo del envío | Descripción |
| --- | --- |
| `status` | `received` o `spam` |
| `fields` | Los valores guardados |
| `meta` | `user_agent`, `origin`, `referrer`, `country`, `subject`, `reply_to`. Todas las claves están presentes y cualquier valor puede ser `null`. El hash de la IP nunca se devuelve |
| `spam.score`, `spam.reasons` | La puntuación y los motivos de spam, como `honeypot` u `origin_mismatch` |

## Paginación

Las listas van de más reciente a más antiguo y usan cursores opacos.

1. Haz la petición sin cursor.
2. Si `has_more` es true, envía `next_cursor` como `cursor` con los mismos filtros.
3. Para cuando `next_cursor` sea `null`.

Las páginas se mantienen estables mientras llegan envíos nuevos: los nuevos aparecen en la primera página, nunca como duplicados más abajo. No construyas ni analices los cursores tú mismo; uno no válido devuelve `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);
```

## Errores

Los errores usan el mismo formato que los envíos de formularios. Cada respuesta tiene una cabecera `X-Request-Id`, que los errores repiten como `request_id`.

`403 Forbidden`:

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

| Estado | Código | Cuándo |
| --- | --- | --- |
| 400 | `invalid_request` | `limit`, `status`, `form_id` o `cursor` no es válido; el mensaje indica cuál |
| 401 | `unauthorized` | Clave ausente o no válida |
| 403 | `forbidden` | Clave publicable o falta un permiso |
| 404 | `not_found` | Ruta o id desconocidos, o un id de otro espacio de trabajo |
| 405 | `method_not_allowed` | Un preflight del navegador |
| 429 | `rate_limited` | Se superó el límite de frecuencia por clave |
| 500 | `server_error` | Algo falló de nuestro lado; indica el `request_id` |

## Límite de frecuencia

Cada clave puede hacer 100 peticiones cada 60 segundos. Por encima del límite, la API devuelve `429 rate_limited` con una cabecera `Retry-After` en segundos. El SDK la expone como `error.retryAfter`.
