# REST API

> 시크릿 키로 폼과 제출을 읽고, 제출을 스팸으로 옮기거나 되돌리고, 삭제하세요.

REST API로 서버에서 폼과 제출을 읽고, 제출을 스팸으로 옮기거나 되돌리고, 삭제할 수 있습니다. `https://ikiform.com/api/v1`에서 HTTPS로 JSON을 주고받습니다. OpenAPI 3.1 문서는 [/api/v1/openapi.json](https://ikiform.com/api/v1/openapi.json)에 있습니다.

> 제출 생성은 `/api/v1`에 포함되지 않습니다. 대신 시크릿 키와 함께 폼 엔드포인트로 POST하세요. [서버에서 보내기](https://ikiform.com/ko/docs/guides/server.md)를 참고하세요.

## API 키

대시보드의 **API keys**에서 키를 만드세요. 키는 하나의 워크스페이스에 속하며 그 워크스페이스만 볼 수 있습니다. 전체 키는 한 번만 표시되며, Ikiform은 해시만 저장합니다.

| 종류 | 접두사 | 용도 |
| --- | --- | --- |
| 시크릿 | `ik_sk_` | 서버용. 선택한 스코프로 REST API를 호출하고, 제출을 생성할 수 있습니다. |
| 퍼블리셔블 | `ik_pub_` | 브라우저용. 폼 제출만 할 수 있고, 키에 등록한 도메인에서만 작동합니다. |

### 스코프

| 스코프 | 허용 범위 |
| --- | --- |
| `forms:read` | `GET /forms`, `GET /forms/:formId` |
| `submissions:read` | `GET /submissions`, `GET /submissions/:id` |
| `submissions:write` | `DELETE /submissions/:id`, `POST /submissions/:id/spam`과 `/not-spam`, 그리고 `/f/:formId`로 POST |

## 인증

시크릿 키를 Bearer 토큰으로 보내세요. 이 API는 서버 간 통신 전용입니다. CORS 헤더를 보내지 않고 브라우저 프리플라이트에 `405`로 응답하므로, 시크릿 키는 웹 페이지에서 절대 작동하지 않습니다.

`Terminal`:

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

- 키가 없거나, 형식이 잘못되었거나, 알 수 없거나, 폐기되었거나, 만료되었으면 `WWW-Authenticate: Bearer`와 함께 `401 unauthorized`를 받습니다.
- 퍼블리셔블 키는 `403 forbidden`(`Publishable keys can only submit forms.`)을 받습니다. 필요한 스코프가 없는 키는 메시지에 해당 스코프가 적힌 `403 forbidden`을 받습니다.
- 응답은 `Cache-Control: no-store`와 함께 전송됩니다.

아래 SDK 예제는 모두 하나의 클라이언트를 공유합니다.

`ikiform.ts`:

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

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

## 요청 작성기

이 페이지의 인터랙티브 요청 작성기는 엔드포인트, 필터, ID로 REST API 요청을 만들고 curl, fetch, SDK 코드로 보여 줍니다. 아무것도 보내지 않습니다. REST API는 서버 간 통신 전용이며 설계상 브라우저 요청을 거부합니다.

## 엔드포인트

| 메서드 | 경로 | 스코프 | 반환 |
| --- | --- | --- | --- |
| `GET` | [`/forms`](#list-forms) | `forms:read` | 폼 목록 |
| `GET` | [`/forms/:formId`](#get-form) | `forms:read` | 폼 조회 |
| `GET` | [`/submissions`](#list-submissions) | `submissions:read` | 제출 목록 |
| `GET` | [`/submissions/:id`](#get-submission) | `submissions:read` | 제출 조회 |
| `POST` | [`/submissions/:id/spam`](#mark-spam) | `submissions:write` | 스팸으로 표시 |
| `POST` | [`/submissions/:id/not-spam`](#mark-not-spam) | `submissions:write` | 스팸 아님으로 표시 |
| `DELETE` | [`/submissions/:id`](#delete-submission) | `submissions:write` | 제출 삭제 |
| `GET` | `/openapi.json` | 없음 | OpenAPI 문서 |
| `GET` | `/health` | 없음 | `{ "ok": true }` |

### 폼 목록

`forms:read` 스코프로 `GET /forms`.

- 최신순입니다. `limit`(1~100, 기본값 25)과 `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"
}
```

### 폼 조회

`forms:read` 스코프로 `GET /forms/:formId`.

- 다른 워크스페이스의 폼 ID는 존재하지 않는 ID와 똑같이 `404`를 반환합니다.

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

### 제출 목록

`submissions:read` 스코프로 `GET /submissions`.

| 파라미터 | 값 | 기본값 |
| --- | --- | --- |
| `form_id` | 폼 ID | 워크스페이스의 모든 폼 |
| `status` | `received` 또는 `spam` | 둘 다 |
| `limit` | 1~100 | 25 |
| `cursor` | 이전 페이지의 `next_cursor` | 첫 페이지 |

- 최신순입니다. 다른 워크스페이스의 `form_id`는 오류 대신 빈 목록을 반환하므로 ID를 탐색할 수 없습니다.

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

### 제출 조회

`submissions:read` 스코프로 `GET /submissions/:id`.

- 다른 워크스페이스의 ID는 존재하지 않는 ID와 똑같이 `404`를 반환합니다.

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

### 스팸으로 표시

`submissions:write` 스코프로 `POST /submissions/:id/spam`.

- 수신된 제출을 스팸으로 옮기고, 폼의 `submission_count`를 줄이고, 대기 중인 웹훅 전송을 취소합니다.
- 이미 스팸인 제출에 호출하면 변경 없이 그대로 반환합니다.

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

### 스팸 아님으로 표시

`submissions:write` 스코프로 `POST /submissions/:id/not-spam`.

- 제출을 다시 수신 상태로 옮기고, 스팸 점수와 사유를 지우고, 일치하는 모든 웹훅 엔드포인트로 `submission.created`를 보냅니다.
- 수신 상태인 제출에 호출하면 변경 없이 그대로 반환합니다.

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

### 제출 삭제

`submissions:write` 스코프로 `DELETE /submissions/:id`.

- 제출과 그 웹훅 전송을 영구적으로 삭제합니다. 되돌릴 수 없습니다. 다시 삭제하면 `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 }
```

## 객체

객체는 `object` 필드가 있는 snake_case JSON입니다. 타임스탬프는 밀리초를 포함한 UTC 기준 ISO 8601입니다. 제출 ID는 시간순으로 정렬되며(`sub_` 뒤에 26자), 폼 ID는 무작위입니다(`frm_` 뒤에 16자).

| 폼 필드 | 설명 |
| --- | --- |
| `status` | `active`, `paused` 또는 `disabled` |
| `submission_count` | 스팸을 제외한 수신 제출 수 |

| 제출 필드 | 설명 |
| --- | --- |
| `status` | `received` 또는 `spam` |
| `fields` | 저장된 값 |
| `meta` | `user_agent`, `origin`, `referrer`, `country`, `subject`, `reply_to`. 모든 키가 항상 있으며 어떤 값이든 `null`일 수 있습니다. IP 해시는 절대 반환하지 않습니다 |
| `spam.score`, `spam.reasons` | 스팸 점수와 `honeypot`, `origin_mismatch` 같은 사유 |

## 페이지네이션

목록은 최신순이며 불투명 커서를 사용합니다.

1. 커서 없이 요청합니다.
2. `has_more`가 true이면 같은 필터와 함께 `next_cursor`를 `cursor`로 보냅니다.
3. `next_cursor`가 `null`이면 멈춥니다.

새 제출이 들어와도 페이지는 안정적으로 유지됩니다. 새 제출은 첫 페이지에 나타나며, 뒤쪽 페이지에 중복으로 나타나지 않습니다. 커서를 직접 만들거나 파싱하지 마세요. 잘못된 커서는 `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);
```

## 오류

오류는 폼 제출과 같은 형식을 사용합니다. 모든 응답에는 `X-Request-Id` 헤더가 있으며, 오류에는 이 값이 `request_id`로도 담깁니다.

`403 Forbidden`:

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

| 상태 | 코드 | 발생 조건 |
| --- | --- | --- |
| 400 | `invalid_request` | `limit`, `status`, `form_id`, `cursor` 중 하나가 잘못됨. 메시지에 해당 항목이 표시됩니다 |
| 401 | `unauthorized` | 키가 없거나 잘못됨 |
| 403 | `forbidden` | 퍼블리셔블 키 또는 스코프 누락 |
| 404 | `not_found` | 알 수 없는 경로나 ID, 또는 다른 워크스페이스의 ID |
| 405 | `method_not_allowed` | 브라우저 프리플라이트 |
| 429 | `rate_limited` | 키별 레이트 리밋 초과 |
| 500 | `server_error` | Ikiform 쪽에서 문제가 발생함. `request_id`를 알려 주세요 |

## 레이트 리밋

키마다 60초에 100건의 요청을 보낼 수 있습니다. 한도를 넘으면 API는 초 단위의 `Retry-After` 헤더와 함께 `429 rate_limited`를 반환합니다. SDK에서는 `error.retryAfter`로 제공됩니다.
