# SDK リファレンス

> @ikiform/sdk パッケージ: submit、challenge、getSchema、validate、createClient、verifyWebhook。

`@ikiform/sdk` は、フォームの送信、REST API からのデータの読み取り、Webhook の検証を行う小さな TypeScript パッケージです。依存関係はなく、`fetch`、`FormData`、Web Crypto だけを使うので、ブラウザー、Node 20 以降、Bun、Deno、Cloudflare Workers で動きます。型宣言付きの ESM として提供しています。

`ターミナル`:

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

| インポート | インポート元 | 実行環境 |
| --- | --- | --- |
| `submit` | `@ikiform/sdk` | ブラウザーまたはサーバー |
| `challenge`, `ChallengeError` | `@ikiform/sdk` | ブラウザー |
| `getSchema`, `validate`, `validateField` | `@ikiform/sdk` | ブラウザーまたはサーバー |
| `createClient`, `IkiformError` | `@ikiform/sdk` | サーバーのみ |
| `verifyWebhook`, `WebhookVerificationError` | `@ikiform/sdk/webhooks` | サーバー |

## submit

`submit(formId, data, options?)` は `/f/:formId` に送信し、結果に解決されます。例外は投げません。HTTP エラーではエラーの情報を、ネットワークエラーではコード `network_error` を、中断では `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 | 送信形式 |
| --- | --- |
| テキストだけの `FormData` | `application/x-www-form-urlencoded` |
| `File` を含む `FormData` | `multipart/form-data`。アップロードが実装されるまで Ikiform は拒否します |
| `URLSearchParams` | `application/x-www-form-urlencoded` |
| 単純なオブジェクト | `application/json` |

- React の `$ACTION_` フィールドは `FormData` から除外されます。
- 戻り値は React のフォームアクションの型も満たすので、`action={(data) => submit(id, data)}` と書けます。
- `submit` は `Accept: application/json` を送ります。`Authorization` ヘッダーは送らないので、API キーを付けて送信するには `fetch` を使ってください。

| オプション | 説明 |
| --- | --- |
| `baseUrl` | デフォルトは `https://ikiform.com`。 |
| `signal` | リクエストをキャンセルするための `AbortSignal`。 |
| `idempotencyKey` | `Idempotency-Key` ヘッダーとして送られます。 |
| `turnstileToken` | どのボディの形式でも、`cf-turnstile-response` として送られます。 |
| `fetch` | 独自の `fetch` の実装。 |

`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

スパムチェックが **Ikiform** のフォーム向けです。`challenge(formId, options?)` は幅 300 ピクセルの ikiform.com のフレームをマウントします。フレームは Turnstile がクリックを必要としない限り高さ 0 のままで、トークンで resolve されるとフレームは削除されます。トークンは 1 回限りで有効期間は 300 秒なので、送信ごとに 1 回呼び出してください。

| オプション | 説明 |
| --- | --- |
| `container` | フレームをマウントする場所。デフォルトは `document.body`。 |
| `mode` | `"frame"`（デフォルト）または `"popup"`。フレームをブロックするブラウザー向けです。ポップアップはクリックから開く必要があります。 |
| `timeoutMs` | このミリ秒数が経過すると reject します。デフォルトではタイムアウトなし。 |
| `signal` | `AbortSignal`。 |
| `baseUrl` | デフォルトは `https://ikiform.com`。 |

`code` が `challenge_failed`、`challenge_timeout`、`aborted` のいずれかの `ChallengeError` で reject されます。

`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?)` はフォームの公開スキーマを取得します。公開済みのフィールド、表示設定、ハニーポットの名前、チャレンジのモードです。`submit` と同じく例外は投げず、`{ ok: true, schema }` かエラーの結果に解決されます。オプションは `baseUrl`、`signal`、`fetch` です。レスポンスは [shadcn/ui のページ](https://ikiform.com/ja/docs/shadcn.md#schema-endpoint)で説明しています。

## validate

`validate(fields, data)` は `FormData` または単純なオブジェクトをフィールド定義と照合し、無効なフィールドごとに 1 つのメッセージを持つ `Record<string, string>`、または空のオブジェクトを返します。12 種類すべてのフィールドタイプで、サーバーと同じルールとメッセージを使います。`validateField(field, value)` は 1 つの値を確認し、メッセージか `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? })` は [REST API](https://ikiform.com/ja/docs/api.md) のクライアントを返します。キーがシークレットキーでない場合は `TypeError` を投げるので、サーバー上だけで使ってください。

| メソッド | リクエスト |
| --- | --- |
| `forms.list({ limit, cursor })` | `GET /forms` |
| `forms.get(formId)` | `GET /forms/:formId` |
| `forms.iterate({ limit })` | カーソルをたどって、すべてのフォーム |
| `submissions.list({ formId, status, limit, cursor })` | `GET /submissions` |
| `submissions.get(id)` | `GET /submissions/:id` |
| `submissions.iterate({ formId, status, limit })` | カーソルをたどって、すべての送信データ |
| `submissions.markSpam(id)` | `POST /submissions/:id/spam` |
| `submissions.markNotSpam(id)` | `POST /submissions/:id/not-spam` |
| `submissions.delete(id)` | `DELETE /submissions/:id` |

失敗すると、`code`、`status`、`requestId`、`message` に加え、ある場合は `fields`、`429` の場合は秒数を示す `retryAfter` を持つ `IkiformError` を投げます。ネットワークエラーでは `code: "network_error"`、`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? })` は Webhook の署名を検証し、解析済みのイベントを返します。生のボディの文字列を渡してください。デフォルトの許容範囲は前後 300 秒です。

`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,
});
```

### 検証エラー

| reason | 意味 |
| --- | --- |
| `invalid_secret` | シークレットが `whsec_` と base64 の形式ではない |
| `missing_headers` | `webhook-id`、`webhook-timestamp`、`webhook-signature` のいずれかがない |
| `invalid_timestamp` | `webhook-timestamp` が整数の Unix 秒ではない |
| `timestamp_too_old` | `toleranceSeconds` より前に署名された。リプレイされたリクエストなど |
| `timestamp_too_new` | 未来の時刻で署名された。サーバーの時計を確認してください |
| `no_matching_signature` | 一致する `v1` 署名がない。シークレットが間違っているか、ボディが変更された |
| `invalid_payload` | 署名は一致したが、ボディが JSON ではない |

> イベントの型は `WebhookEvent`、`SubmissionCreatedEvent`、`EndpointTestEvent` としてエクスポートされています。ペイロードの全体は [Webhook](https://ikiform.com/ja/docs/webhooks.md#payloads) を参照してください。
