# REST API

> Baca formulir dan kiriman, pindahkan kiriman ke dan dari spam, dan hapus dengan secret key.

REST API memungkinkan server Anda membaca formulir dan kiriman, memindahkan kiriman ke dan dari spam, serta menghapusnya. API ini berupa JSON lewat HTTPS di `https://ikiform.com/api/v1`. Dokumen OpenAPI 3.1 ada di [/api/v1/openapi.json](https://ikiform.com/api/v1/openapi.json).

> Membuat kiriman bukan bagian dari `/api/v1`. Kirim ke endpoint formulir dengan secret key; lihat [Dari server](https://ikiform.com/id/docs/guides/server.md).

## Kunci API

Buat key di dashboard pada **API keys**. Satu key milik satu workspace dan hanya bisa melihat workspace itu. Key lengkap hanya ditampilkan sekali; Ikiform hanya menyimpan hash-nya.

| Jenis | Awalan | Gunakan |
| --- | --- | --- |
| Secret | `ik_sk_` | Untuk server. Memanggil REST API dengan scope yang Anda pilih, dan bisa membuat kiriman. |
| Publishable | `ik_pub_` | Untuk browser. Hanya bisa mengirim formulir, dan hanya dari domain yang Anda cantumkan di key. |

### Scope

| Scope | Memberi akses |
| --- | --- |
| `forms:read` | `GET /forms`, `GET /forms/:formId` |
| `submissions:read` | `GET /submissions`, `GET /submissions/:id` |
| `submissions:write` | `DELETE /submissions/:id`, `POST /submissions/:id/spam` dan `/not-spam`, serta mengirim ke `/f/:formId` |

## Autentikasi

Kirim secret key sebagai Bearer token. API ini hanya untuk server ke server: tidak mengirim header CORS dan menjawab preflight browser dengan `405`, jadi secret key tidak pernah bisa dipakai dari halaman web.

`Terminal`:

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

- Key yang tidak ada, salah format, tidak dikenal, dicabut, atau kedaluwarsa mendapat `401 unauthorized` dengan `WWW-Authenticate: Bearer`.
- Publishable key mendapat `403 forbidden` (`Publishable keys can only submit forms.`). Key tanpa scope yang dibutuhkan mendapat `403 forbidden` dengan nama scope disebut di pesannya.
- Respons dikirim dengan `Cache-Control: no-store`.

Contoh SDK di bawah memakai satu client yang sama:

`ikiform.ts`:

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

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

## Penyusun request

Penyusun request interaktif di halaman ini membuat request REST API dari endpoint, filter, dan ID, lalu menampilkannya sebagai kode curl, fetch, dan SDK. Penyusun ini tidak pernah mengirim apa pun: REST API bersifat server ke server dan sengaja menolak request dari browser.

## Endpoint

| Metode | Path | Scope | Mengembalikan |
| --- | --- | --- | --- |
| `GET` | [`/forms`](#list-forms) | `forms:read` | Daftar formulir |
| `GET` | [`/forms/:formId`](#get-form) | `forms:read` | Ambil satu formulir |
| `GET` | [`/submissions`](#list-submissions) | `submissions:read` | Daftar kiriman |
| `GET` | [`/submissions/:id`](#get-submission) | `submissions:read` | Ambil satu kiriman |
| `POST` | [`/submissions/:id/spam`](#mark-spam) | `submissions:write` | Tandai sebagai spam |
| `POST` | [`/submissions/:id/not-spam`](#mark-not-spam) | `submissions:write` | Tandai sebagai bukan spam |
| `DELETE` | [`/submissions/:id`](#delete-submission) | `submissions:write` | Hapus kiriman |
| `GET` | `/openapi.json` | Tidak ada | Dokumen OpenAPI |
| `GET` | `/health` | Tidak ada | `{ "ok": true }` |

### Daftar formulir

`GET /forms` dengan scope `forms:read`.

- Terbaru lebih dulu. Menerima `limit` (1 sampai 100, default 25) dan `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"
}
```

### Ambil satu formulir

`GET /forms/:formId` dengan scope `forms:read`.

- ID formulir dari workspace lain mengembalikan `404`, persis seperti ID yang tidak ada.

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

### Daftar kiriman

`GET /submissions` dengan scope `submissions:read`.

| Parameter | Nilai | Default |
| --- | --- | --- |
| `form_id` | ID formulir | Semua formulir di workspace |
| `status` | `received` atau `spam` | Keduanya |
| `limit` | 1 sampai 100 | 25 |
| `cursor` | `next_cursor` dari halaman sebelumnya | Halaman pertama |

- Terbaru lebih dulu. `form_id` dari workspace lain mengembalikan daftar kosong alih-alih error, jadi ID tidak bisa ditebak-tebak.

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

### Ambil satu kiriman

`GET /submissions/:id` dengan scope `submissions:read`.

- ID dari workspace lain mengembalikan `404`, persis seperti ID yang tidak ada.

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

### Tandai sebagai spam

`POST /submissions/:id/spam` dengan scope `submissions:write`.

- Memindahkan kiriman yang diterima ke spam, mengurangi `submission_count` formulir, dan membatalkan pengiriman webhook-nya yang tertunda.
- Memanggilnya pada kiriman yang sudah spam mengembalikannya tanpa perubahan.

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

### Tandai sebagai bukan spam

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

- Memindahkannya kembali ke diterima, menghapus skor dan alasan spam-nya, dan mengirim `submission.created` ke setiap endpoint webhook yang cocok.
- Memanggilnya pada kiriman yang diterima mengembalikannya tanpa perubahan.

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

### Hapus kiriman

`DELETE /submissions/:id` dengan scope `submissions:write`.

- Menghapus kiriman dan pengiriman webhook-nya secara permanen. Tidak bisa dibatalkan. Menghapus lagi mengembalikan `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 }
```

## Objek

Objek berupa JSON snake_case dengan field `object`. Timestamp dalam ISO 8601 UTC dengan milidetik. ID kiriman berurutan menurut waktu (`sub_` ditambah 26 karakter); ID formulir acak (`frm_` ditambah 16 karakter).

| Field formulir | Deskripsi |
| --- | --- |
| `status` | `active`, `paused`, atau `disabled` |
| `submission_count` | Kiriman yang diterima, bukan spam |

| Field kiriman | Deskripsi |
| --- | --- |
| `status` | `received` atau `spam` |
| `fields` | Nilai yang disimpan |
| `meta` | `user_agent`, `origin`, `referrer`, `country`, `subject`, `reply_to`. Setiap key selalu ada dan nilai apa pun bisa `null`. Hash IP tidak pernah dikembalikan |
| `spam.score`, `spam.reasons` | Skor dan alasan spam, seperti `honeypot` atau `origin_mismatch` |

## Paginasi

Daftar diurutkan dari yang terbaru dan memakai cursor opaque.

1. Kirim request tanpa cursor.
2. Jika `has_more` bernilai true, kirim `next_cursor` sebagai `cursor` dengan filter yang sama.
3. Berhenti saat `next_cursor` bernilai `null`.

Halaman tetap stabil saat kiriman baru masuk: kiriman baru muncul di halaman pertama, tidak pernah sebagai duplikat di halaman berikutnya. Jangan membuat atau mengurai cursor sendiri; cursor yang tidak valid mengembalikan `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);
```

## Error

Error memakai envelope yang sama dengan kiriman formulir. Setiap respons punya header `X-Request-Id`, yang diulang oleh error sebagai `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 | Kode | Kapan |
| --- | --- | --- |
| 400 | `invalid_request` | `limit`, `status`, `form_id`, atau `cursor` tidak valid; pesannya menyebut yang mana |
| 401 | `unauthorized` | Key tidak ada atau tidak valid |
| 403 | `forbidden` | Publishable key, atau scope tidak ada |
| 404 | `not_found` | Route atau ID tidak dikenal, atau ID di workspace lain |
| 405 | `method_not_allowed` | Preflight browser |
| 429 | `rate_limited` | Melewati rate limit per key |
| 500 | `server_error` | Ada yang gagal di pihak kami; sertakan `request_id` |

## Rate limit

Setiap key bisa membuat 100 request per 60 detik. Jika melewati batas, API mengembalikan `429 rate_limited` dengan header `Retry-After` dalam detik. SDK mengeksposnya sebagai `error.retryAfter`.
