# SDK reference

> The @ikiform/sdk package: submit, challenge, getSchema, validate, createClient and verifyWebhook.

`@ikiform/sdk` is a small TypeScript package for submitting forms, reading data from the REST API and verifying webhooks. It has no dependencies and uses only `fetch`, `FormData` and Web Crypto, so it runs in browsers, Node 20+, Bun, Deno and Cloudflare Workers. It ships as ESM with type declarations.

`Terminal`:

```bash
npm install @ikiform/sdk
```

| Import | From | Runs in |
| --- | --- | --- |
| `submit` | `@ikiform/sdk` | Browser or server |
| `challenge`, `ChallengeError` | `@ikiform/sdk` | Browser |
| `getSchema`, `validate`, `validateField` | `@ikiform/sdk` | Browser or server |
| `createClient`, `IkiformError` | `@ikiform/sdk` | Server only |
| `verifyWebhook`, `WebhookVerificationError` | `@ikiform/sdk/webhooks` | Server |

## submit

`submit(formId, data, options?)` posts to `/f/:formId` and resolves to a result. It never throws: HTTP errors return the error envelope, network failures return the code `network_error` and aborts return `aborted`.

`SubmitResult`:

```ts
type SubmitResult =
  | { ok: true; id: string }
  | {
      ok: false;
      error: { code: string; message: string; fields?: Record<string, string[]> };
      status: number | null;
      requestId: string | null;
    };
```

| data | Sent as |
| --- | --- |
| `FormData` with only text | `application/x-www-form-urlencoded` |
| `FormData` with a `File` | `multipart/form-data`, which Ikiform refuses until uploads are built |
| `URLSearchParams` | `application/x-www-form-urlencoded` |
| A plain object | `application/json` |

- React's `$ACTION_` fields are dropped from `FormData`.
- The return value also satisfies React's form action type, so `action={(data) => submit(id, data)}` works.
- `submit` sends `Accept: application/json`. It doesn't send an `Authorization` header; use `fetch` to post with an API key.

| Option | Description |
| --- | --- |
| `baseUrl` | Defaults to `https://ikiform.com`. |
| `signal` | An `AbortSignal` to cancel the request. |
| `idempotencyKey` | Sent as the `Idempotency-Key` header. |
| `turnstileToken` | Sent as `cf-turnstile-response` in whichever body format is used. |
| `fetch` | A custom `fetch` implementation. |

`submit.ts`:

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

const result = await submit("frm_xxxxxxxxxxxxxxxx", { email: "ada@example.com" }, {
  idempotencyKey: crypto.randomUUID(),
  signal: AbortSignal.timeout(10_000),
});

if (!result.ok) console.error(result.error.code, result.error.fields);
```

## challenge

For forms whose spam check is **Ikiform**. `challenge(formId, options?)` mounts a 300 pixel wide ikiform.com frame that stays at zero height unless Turnstile needs a click, resolves with a token and removes the frame. Tokens are single use and live 300 seconds, so call it once per submit.

| Option | Description |
| --- | --- |
| `container` | Where to mount the frame. Defaults to `document.body`. |
| `mode` | `"frame"` (default) or `"popup"`, for browsers that block the frame. The popup must be opened from a click. |
| `timeoutMs` | Reject after this many milliseconds. No timeout by default. |
| `signal` | An `AbortSignal`. |
| `baseUrl` | Defaults to `https://ikiform.com`. |

It rejects with `ChallengeError`, whose `code` is `challenge_failed`, `challenge_timeout` or `aborted`.

`challenge.ts`:

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

async function send(form) {
  try {
    const turnstileToken = await challenge("frm_xxxxxxxxxxxxxxxx", { timeoutMs: 60_000 });
    return submit("frm_xxxxxxxxxxxxxxxx", new FormData(form), { turnstileToken });
  } catch (error) {
    if (!(error instanceof ChallengeError) || error.code !== "challenge_failed") throw error;
    const turnstileToken = await challenge("frm_xxxxxxxxxxxxxxxx", { mode: "popup" });
    return submit("frm_xxxxxxxxxxxxxxxx", new FormData(form), { turnstileToken });
  }
}
```

## getSchema

`getSchema(formId, options?)` fetches the form's public schema: its published fields, display settings, honeypot name and challenge mode. Like `submit` it never throws, resolving to `{ ok: true, schema }` or an error result. Options are `baseUrl`, `signal` and `fetch`. The response is described on the [shadcn/ui page](https://ikiform.com/docs/shadcn.md#schema-endpoint).

## validate

`validate(fields, data)` checks `FormData` or a plain object against field definitions and returns `Record<string, string>`, one message per invalid field, or an empty object. It uses the same rules and messages as the server for all 12 field types. `validateField(field, value)` checks one value and returns a message or `undefined`.

`validate.ts`:

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

const result = await getSchema("frm_xxxxxxxxxxxxxxxx");

if (result.ok) {
  const errors = validate(result.schema.fields, new FormData(form));
  if (Object.keys(errors).length === 0) {
    await submit("frm_xxxxxxxxxxxxxxxx", new FormData(form));
  }
}
```

## createClient

`createClient({ apiKey, baseUrl?, fetch? })` returns a client for the [REST API](https://ikiform.com/docs/api.md). It throws a `TypeError` if the key isn't a secret key, so keep it on the server.

| Method | Request |
| --- | --- |
| `forms.list({ limit, cursor })` | `GET /forms` |
| `forms.get(formId)` | `GET /forms/:formId` |
| `forms.iterate({ limit })` | Every form, following cursors |
| `submissions.list({ formId, status, limit, cursor })` | `GET /submissions` |
| `submissions.get(id)` | `GET /submissions/:id` |
| `submissions.iterate({ formId, status, limit })` | Every submission, following cursors |
| `submissions.markSpam(id)` | `POST /submissions/:id/spam` |
| `submissions.markNotSpam(id)` | `POST /submissions/:id/not-spam` |
| `submissions.delete(id)` | `DELETE /submissions/:id` |

Failures throw `IkiformError` with `code`, `status`, `requestId`, `message`, plus `fields` when present and `retryAfter` in seconds on `429`. Network failures throw with `code: "network_error"` and `status: 0`.

`client.ts`:

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

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

const forms = await ikiform.forms.list({ limit: 10 });
const form = await ikiform.forms.get("frm_xxxxxxxxxxxxxxxx");

for await (const submission of ikiform.submissions.iterate({ formId: form.id, status: "received" })) {
  console.log(submission.id, submission.fields);
}

try {
  await ikiform.submissions.delete("sub_01k7v2c9t3e8m5q4x6z0a1b2c3");
} catch (error) {
  if (error instanceof IkiformError && error.status === 429) {
    console.log(`Retry in ${error.retryAfter} seconds`);
  }
}
```

## verifyWebhook

`verifyWebhook({ payload, headers, secret, toleranceSeconds? })` verifies a webhook signature and returns the parsed event. Pass the raw body string. The default tolerance is 300 seconds in both directions.

`webhook.ts`:

```ts
import { verifyWebhook, type WebhookEvent } from "@ikiform/sdk/webhooks";

const event = await verifyWebhook<WebhookEvent>({
  payload: rawBody,
  headers: request.headers,
  secret: process.env.IKIFORM_WEBHOOK_SECRET!,
  toleranceSeconds: 300,
});
```

### Verification errors

| reason | Meaning |
| --- | --- |
| `invalid_secret` | The secret isn't `whsec_` plus base64 |
| `missing_headers` | `webhook-id`, `webhook-timestamp` or `webhook-signature` is missing |
| `invalid_timestamp` | `webhook-timestamp` isn't whole Unix seconds |
| `timestamp_too_old` | Signed more than `toleranceSeconds` ago, for example a replayed request |
| `timestamp_too_new` | Signed in the future; check your server clock |
| `no_matching_signature` | No `v1` signature matches: wrong secret, or the body was changed |
| `invalid_payload` | The signature matched but the body isn't JSON |

> The event types are exported as `WebhookEvent`, `SubmissionCreatedEvent` and `EndpointTestEvent`. See [Webhooks](https://ikiform.com/docs/webhooks.md#payloads) for the full payload.
