# JavaScript and fetch

> The submission endpoint in detail: body formats, headers, CORS, responses and idempotency.

Any code that can send an HTTP request can submit to Ikiform. Post JSON, `FormData` or urlencoded data to `https://ikiform.com/f/<formId>` and read a JSON answer. The [SDK](https://ikiform.com/docs/sdk.md) wraps this in one function, but it is optional. For framework walkthroughs, see the guides for [Next.js](https://ikiform.com/docs/guides/nextjs.md), [React](https://ikiform.com/docs/guides/react.md), [Vue](https://ikiform.com/docs/guides/vue.md), [Svelte](https://ikiform.com/docs/guides/svelte.md) and [Astro](https://ikiform.com/docs/guides/astro.md).

## With fetch

**JSON** (`submit.js`)

```js
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
  method: "POST",
  headers: { "Content-Type": "application/json", Accept: "application/json" },
  body: JSON.stringify({
    name: "Ada Lovelace",
    email: "ada@example.com",
    message: "Hello",
  }),
});

const result = await response.json();
if (!result.ok) console.error(result.error.code, result.error.fields);
```

**FormData** (`form.js`)

```js
const form = document.querySelector("form");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const response = await fetch(form.action, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  });
  const result = await response.json();
  if (result.ok) form.replaceWith("Thanks, we got it.");
});
```

**SDK** (`submit.ts`)

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

const result = await submit("frm_xxxxxxxxxxxxxxxx", {
  name: "Ada Lovelace",
  email: "ada@example.com",
});

if (result.ok) console.log("Stored", result.id);
else console.error(result.error.code, result.error.fields);
```

## Try it

The interactive request builder composes a submission from field rows, shows the curl, fetch and SDK code for it, and can send one real POST when you enter your own form id. Nothing is sent automatically.

## Endpoints

| Method | Path | What it does |
| --- | --- | --- |
| `POST` | `/f/<formId>` | Create a submission |
| `OPTIONS` | `/f/<formId>` | CORS preflight, always `204` |
| `GET` | `/f/<formId>/schema` | The form's public field definition, see [shadcn/ui](https://ikiform.com/docs/shadcn.md#schema-endpoint) |
| `GET` | `/f/<formId>/thanks` | The hosted thank-you page |
| `GET` | `/f/<formId>/challenge` | The Ikiform human check frame, used by the SDK |

Opening `/f/<formId>` itself in a browser tab is a `GET`, which answers `404 not_found`. That is expected: the endpoint only takes posts.

## Body formats

| Content-Type | Shape | Notes |
| --- | --- | --- |
| `application/json` and `+json` types | One JSON object | Values can be strings, numbers, booleans, arrays and objects, nested at most 3 levels counting the top object |
| `application/x-www-form-urlencoded` | Name and value pairs | Repeated names and `name[]` become arrays |
| `multipart/form-data` | Name and value pairs | Same as urlencoded; real files are refused |
| Anything else |  | `415 unsupported_media_type` |

Bodies must be UTF-8. A `charset` other than `utf-8` gets `415 unsupported_charset`, and bytes that aren't valid UTF-8 get the same code. A JSON array or a malformed body gets `400 invalid_body`.

## Headers

### Request headers

| Header | Required | Effect |
| --- | --- | --- |
| `Content-Type` | Yes | Picks the body parser, see above |
| `Accept` | No | `application/json` forces a JSON answer, see [JSON or a redirect](https://ikiform.com/docs/how-it-works.md#response-modes) |
| `Idempotency-Key` | No | Makes retries safe, up to 255 characters, see [Idempotency](#idempotency) |
| `Authorization` | No | `Bearer ik_sk_...` or `Bearer ik_pub_...`, see [From a server](https://ikiform.com/docs/guides/server.md) |
| `Origin`, `Referer` | Sent by browsers | Checked against the form's allowed domains |

### Response headers

| Header | When | Value |
| --- | --- | --- |
| `X-Request-Id` | Every response | `req_...`, repeated as `request_id` in error bodies |
| `Retry-After` | `429` | Seconds until the next post is allowed |
| `Location` | `303` browser success | Your redirect or the hosted thank-you page |
| `Access-Control-Allow-Origin` | When the request has an `Origin` | The request's own origin |
| `Access-Control-Expose-Headers` | With CORS | `X-Request-Id, Retry-After`, so browser code can read both |
| `Cache-Control` | Success | `no-store` |

## CORS

The submission endpoint accepts cross-origin posts from any site. Preflights always succeed and echo the request's origin. No cookies or credentials are used, so CORS is not what protects your form: **allowed domains** are. With allowed domains set, a post from another site is stored as spam or refused, depending on the form's setting. See [Allowed domains](https://ikiform.com/docs/spam.md#allowed-domains).

`Preflight`:

```http
OPTIONS /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Origin: https://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, Accept, Idempotency-Key, Authorization
Access-Control-Max-Age: 86400
Vary: Origin
```

> The [REST API](https://ikiform.com/docs/api.md) under `/api/v1` is different: it sends no CORS headers and answers browser preflights with `405`, so a secret key can never be used from a web page.

## Responses

Success is `201` with the new submission id. Errors use one envelope everywhere, with field errors keyed by field id when validation fails. Every code is listed on [Errors](https://ikiform.com/docs/errors.md).

**201** (`Created`)

```json
{ "ok": true, "id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3" }
```

**422** (`Validation failed`)

```json
{
  "ok": false,
  "error": {
    "code": "validation_failed",
    "message": "Some fields are invalid.",
    "fields": { "email": ["Enter a valid email address"] }
  },
  "request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}
```

> A post that scores as spam still gets `201`. Spam is stored separately and never reaches your webhooks or inbox.

## Idempotency

Send an `Idempotency-Key` header, or an `_idempotency` field, to make retries safe. Keys are up to 255 characters and are scoped to the form for 24 hours. The header wins when both are sent.

| Retry with | Result |
| --- | --- |
| The same key and the same fields | The first response is replayed. No second submission is created. |
| The same key and different fields | `422 idempotency_mismatch` |
| The same key while the first request is still running | `409 idempotency_in_progress` |

Posts that fail before the submission is stored, such as validation errors, don't keep the key, so you can fix the data and retry with the same key. A safe retry loop:

`retry.js`:

```js
const key = crypto.randomUUID();

async function send(attempt = 1) {
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: { "Content-Type": "application/json", "Idempotency-Key": key },
    body: JSON.stringify({ email: "ada@example.com" }),
  }).catch(() => null);
  if (response && response.status < 500 && response.status !== 429) return response.json();
  if (attempt === 3) throw new Error("Ikiform is unreachable");
  const wait = Number(response?.headers.get("Retry-After")) || attempt * 2;
  await new Promise((resolve) => setTimeout(resolve, wait * 1000));
  return send(attempt + 1);
}
```
