Form ID

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
npm install @ikiform/sdk
ImportFromRuns in
submit@ikiform/sdkBrowser or server
challenge, ChallengeError@ikiform/sdkBrowser
getSchema, validate, validateField@ikiform/sdkBrowser or server
createClient, IkiformError@ikiform/sdkServer only
verifyWebhook, WebhookVerificationError@ikiform/sdk/webhooksServer

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
type SubmitResult =
  | { ok: true; id: string }
  | {
      ok: false;
      error: { code: string; message: string; fields?: Record<string, string[]> };
      status: number | null;
      requestId: string | null;
    };
dataSent as
FormData with only textapplication/x-www-form-urlencoded
FormData with a Filemultipart/form-data, which Ikiform refuses until uploads are built
URLSearchParamsapplication/x-www-form-urlencoded
A plain objectapplication/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.
OptionDescription
baseUrlDefaults to https://ikiform.com.
signalAn AbortSignal to cancel the request.
idempotencyKeySent as the Idempotency-Key header.
turnstileTokenSent as cf-turnstile-response in whichever body format is used.
fetchA custom fetch implementation.
submit.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.

OptionDescription
containerWhere 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.
timeoutMsReject after this many milliseconds. No timeout by default.
signalAn AbortSignal.
baseUrlDefaults to https://ikiform.com.

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

challenge.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.

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
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.

MethodRequest
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
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
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

reasonMeaning
invalid_secretThe secret isn't whsec_ plus base64
missing_headerswebhook-id, webhook-timestamp or webhook-signature is missing
invalid_timestampwebhook-timestamp isn't whole Unix seconds
timestamp_too_oldSigned more than toleranceSeconds ago, for example a replayed request
timestamp_too_newSigned in the future; check your server clock
no_matching_signatureNo v1 signature matches: wrong secret, or the body was changed
invalid_payloadThe signature matched but the body isn't JSON
The event types are exported as WebhookEvent, SubmissionCreatedEvent and EndpointTestEvent. See Webhooks for the full payload.