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.
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.
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 fromFormData. - The return value also satisfies React's form action type, so
action={(data) => submit(id, data)}works. submitsendsAccept: application/json. It doesn't send anAuthorizationheader; usefetchto 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. |
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.
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.
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.
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. 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.
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.
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 |
WebhookEvent, SubmissionCreatedEvent and EndpointTestEvent. See Webhooks for the full payload.