# 開発者向けドキュメント

> https://okaeri.tech/developers/ の Markdown 版。

OKAERI の公開 API の使い方です。リード・配信・レポートの取得、除外リストの登録、キャンペーン・シナリオ・文面の下書きの作成、Webhook の送信先の管理を、自社のシステムや AI エージェントから行えます。各操作の詳細は [API リファレンス](https://okaeri.tech/developers/reference/index.md) を参照してください。

- [はじめに](#start)
- [認証とスコープ](#auth)
- [レート制限](#rate-limit)
- [ドライラン（テストキー）](#dry-run)
- [再送の重複を防ぐ（Idempotency-Key）](#idempotency)
- [エラーの形](#errors)
- [Webhook](#webhooks)
- [契約の範囲](#contract)
- [クイックスタート](#quickstart)

## はじめに

- ベース URL：`https://api.okaeri.tech/api/v1`
- 機械可読の仕様（OpenAPI 3.1）：[`https://api.okaeri.tech/api/v1/openapi.json`](https://api.okaeri.tech/api/v1/openapi.json)（認証不要）。[API リファレンス](https://okaeri.tech/developers/reference/index.md)はこの文書から作っています
- リクエストの本文と応答は JSON（UTF-8）です
- 日時は ISO 8601・UTC・`Z` 付きで返します（例 `2026-01-01T00:00:00Z`）
- 一覧は cursor 方式です。`limit`（最大200）と `cursor` で取得し、応答の `next_cursor` を次の `cursor` に渡します。`next_cursor` が `null` なら最後のページです
- バージョンはパスの `v1` です。応答に項目を足すことがあるので、知らない項目は無視する実装にしてください

## 認証とスコープ

API キーを `Authorization` ヘッダに Bearer で付けて送ります。クエリ文字列でのキーの受け渡しは受け付けません。

```http
Authorization: Bearer okr_test_XXXXXXXXXXXXXXXXXXXXXX
```

| キーの種類 | 形 | 動き |
| --- | --- | --- |
| 本番キー | `okr_live_…` | 読み取り・書き込みとも本番のデータに反映します |
| テストキー | `okr_test_…` | 書き込みはドライランです（[下記](#dry-run)）。読み取りは本番のデータを返します |

- キーは管理画面の API キーの設定で発行します。発行できるのはオーナーと管理者です。キーの文字列は発行した直後に1回だけ表示されます
- キーを発行したメンバーが停止・削除されると、そのキーは使えなくなります（401 `api_key_creator_inactive`）
- 連絡先（電話番号・メールアドレス）は常にマスクして返します（例 `090-****-1234`・`us***@example.com`）

### スコープ

キーには発行時にスコープ（`<資源>:<操作>` の形の権限）を付けます。各操作に必要なスコープは、リファレンスと openapi.json の `x-okaeri-scopes` に載っています。足りない時は 403 `permission_error`（`code` は `scope_insufficient`）を返します。

| スコープ | 使える操作 |
| --- | --- |
| `campaigns:read` | [testSiteUrlRules](https://okaeri.tech/developers/reference/sites.md#testSiteUrlRules)・[listCampaigns](https://okaeri.tech/developers/reference/campaigns.md#listCampaigns)・[getCampaign](https://okaeri.tech/developers/reference/campaigns.md#getCampaign) |
| `campaigns:write` | [createCampaign](https://okaeri.tech/developers/reference/campaigns.md#createCampaign)・[updateCampaign](https://okaeri.tech/developers/reference/campaigns.md#updateCampaign)・[replaceCampaignUrlRules](https://okaeri.tech/developers/reference/campaigns.md#replaceCampaignUrlRules)・[replaceCampaignFormProfile](https://okaeri.tech/developers/reference/campaigns.md#replaceCampaignFormProfile)・[duplicateCampaign](https://okaeri.tech/developers/reference/campaigns.md#duplicateCampaign) |
| `deliveries:read` | [listDeliveries](https://okaeri.tech/developers/reference/deliveries.md#listDeliveries)・[getDelivery](https://okaeri.tech/developers/reference/deliveries.md#getDelivery) |
| `leads:read` | [listLeads](https://okaeri.tech/developers/reference/leads.md#listLeads)・[getLead](https://okaeri.tech/developers/reference/leads.md#getLead) |
| `messages:read` | [listMessages](https://okaeri.tech/developers/reference/messages.md#listMessages)・[getMessage](https://okaeri.tech/developers/reference/messages.md#getMessage)・[listMessageVariables](https://okaeri.tech/developers/reference/messages.md#listMessageVariables)・[listMessageExamples](https://okaeri.tech/developers/reference/messages.md#listMessageExamples)・[previewMessage](https://okaeri.tech/developers/reference/messages.md#previewMessage)・[estimateSmsSegments](https://okaeri.tech/developers/reference/messages.md#estimateSmsSegments) |
| `messages:write` | [duplicateCampaign](https://okaeri.tech/developers/reference/campaigns.md#duplicateCampaign)・[createCampaignScenarioFromPreset](https://okaeri.tech/developers/reference/scenarios.md#createCampaignScenarioFromPreset)・[duplicateScenario](https://okaeri.tech/developers/reference/scenarios.md#duplicateScenario)・[createMessage](https://okaeri.tech/developers/reference/messages.md#createMessage)・[updateMessage](https://okaeri.tech/developers/reference/messages.md#updateMessage)・[deleteMessage](https://okaeri.tech/developers/reference/messages.md#deleteMessage)・[duplicateMessage](https://okaeri.tech/developers/reference/messages.md#duplicateMessage) |
| `reports:read` | [getFunnelReport](https://okaeri.tech/developers/reference/reports.md#getFunnelReport)・[getDailyReport](https://okaeri.tech/developers/reference/reports.md#getDailyReport) |
| `scenarios:read` | [listCampaignScenarios](https://okaeri.tech/developers/reference/scenarios.md#listCampaignScenarios)・[getScenario](https://okaeri.tech/developers/reference/scenarios.md#getScenario)・[listScenarioPresets](https://okaeri.tech/developers/reference/scenarios.md#listScenarioPresets)・[getDeliveryStatus](https://okaeri.tech/developers/reference/delivery-status.md#getDeliveryStatus) |
| `scenarios:write` | [duplicateCampaign](https://okaeri.tech/developers/reference/campaigns.md#duplicateCampaign)・[createCampaignScenario](https://okaeri.tech/developers/reference/scenarios.md#createCampaignScenario)・[updateScenario](https://okaeri.tech/developers/reference/scenarios.md#updateScenario)・[deleteScenario](https://okaeri.tech/developers/reference/scenarios.md#deleteScenario)・[replaceScenarioSteps](https://okaeri.tech/developers/reference/scenarios.md#replaceScenarioSteps)・[createCampaignScenarioFromPreset](https://okaeri.tech/developers/reference/scenarios.md#createCampaignScenarioFromPreset)・[duplicateScenario](https://okaeri.tech/developers/reference/scenarios.md#duplicateScenario) |
| `sites:read` | [listSites](https://okaeri.tech/developers/reference/sites.md#listSites)・[getSite](https://okaeri.tech/developers/reference/sites.md#getSite)・[getSiteTagConfig](https://okaeri.tech/developers/reference/sites.md#getSiteTagConfig)・[listSitePpChecks](https://okaeri.tech/developers/reference/sites.md#listSitePpChecks)・[getSitePpCheck](https://okaeri.tech/developers/reference/sites.md#getSitePpCheck)・[getConsentSettings](https://okaeri.tech/developers/reference/consent-settings.md#getConsentSettings) |
| `suppressions:read` | [listSuppressions](https://okaeri.tech/developers/reference/suppressions.md#listSuppressions) |
| `suppressions:write` | [createSuppression](https://okaeri.tech/developers/reference/suppressions.md#createSuppression)・[deleteSuppression](https://okaeri.tech/developers/reference/suppressions.md#deleteSuppression) |
| `webhooks:write` | [listWebhookEndpoints](https://okaeri.tech/developers/reference/webhooks.md#listWebhookEndpoints)・[createWebhookEndpoint](https://okaeri.tech/developers/reference/webhooks.md#createWebhookEndpoint)・[getWebhookEndpoint](https://okaeri.tech/developers/reference/webhooks.md#getWebhookEndpoint)・[deleteWebhookEndpoint](https://okaeri.tech/developers/reference/webhooks.md#deleteWebhookEndpoint)・[listWebhookEndpointDeliveries](https://okaeri.tech/developers/reference/webhooks.md#listWebhookEndpointDeliveries)・[redeliverWebhookDelivery](https://okaeri.tech/developers/reference/webhooks.md#redeliverWebhookDelivery) |

### API キーではできない操作

- キャンペーン・シナリオの状態の変更（有効化・停止・アーカイブ）は 403 `session_required` です。管理画面で行ってください
- API キーで作ったキャンペーンは必ず停止中（`paused`）、シナリオは必ず下書き（`draft`）になります。それ以外の `status` を指定すると 422 `api_key_draft_only` です
- テスト送信と、メール本文の HTML の直接入力（`body_html`）は API キーでは使えません

## レート制限

API キーのリクエストは、テナント単位（テナントの全キーの合計）で直近60秒の回数を数えます。上限はプランごとに次のとおりです。

| プラン | 上限（直近60秒） |
| --- | --- |
| Free | 60 回 |
| Starter | 60 回 |
| Growth | 300 回 |
| Pro | 1,000 回 |

- 応答ヘッダで `X-RateLimit-Limit`（上限）・`X-RateLimit-Remaining`（残り）・`X-RateLimit-Reset`（次に1回分空く時刻・UNIX 秒）を返します
- 上限を超えると 429 `rate_limit_error` と `Retry-After`（秒）を返します。その秒数の後に再試行してください
- 個別の上限が設定されている場合は、`X-RateLimit-Limit` の値が正です
- テストキーのリクエストも同じ枠で数えます

## ドライラン（テストキー）

テストキー（`okr_test_…`）の書き込み（POST・PUT・PATCH・DELETE）はドライランです。検証・上限の判定・エラーの応答は本番キーと同じに行い、応答も同じ形で返しますが、何も保存しません。AI エージェントに試行錯誤させる時は、まずテストキーを渡してください。

- 応答ヘッダ `OKAERI-Dry-Run: true` が付きます
- 作成の応答に含まれる `id` は仮の値で、その資源は存在しません（続けて取得・更新すると 404）
- タグの設定の配信・メールの通知などの外部への反映は行いません
- 読み取りはテストキーでも本番のデータを返します
- `Idempotency-Key` の記録も残らないため、同じキーで送り直しても毎回ドライランになります

## 再送の重複を防ぐ（Idempotency-Key）

POST は `Idempotency-Key` ヘッダを受け付けます。ネットワークの再試行などで同じ要求を2回送っても、作成は1回にまとまります。要求ごとに一意な値（UUID など）を付けてください。

- 同じキーで送り直すと、保存済みの応答をそのまま返します（24時間・応答ヘッダ `Idempotency-Replayed: true`）
- キーはテナントとエンドポイントごとに区別します
- 先の要求の処理中に送り直すと 409 `idempotency_key_in_progress` を返します
- 5xx の応答は保存しません（同じキーで再試行できます）
- 秘密の値（Webhook の署名鍵 `secret` など）は、送り直した時の応答では伏せて返します。平文は最初の応答の1回だけです

## エラーの形

エラーは常に次の形で返します。分岐は HTTP ステータスでなく `type` と `code` で行ってください。

```json
{
  "error": {
    "type": "permission_error",
    "code": "scope_insufficient",
    "message": "（何が足りないかの説明）",
    "param": null,
    "request_id": "req_01JXXXXXXXXXXXXXXXXXXXXXXX"
  }
}
```

| type | HTTP | 主な場面 |
| --- | --- | --- |
| `invalid_request` | 400・409・422 | パラメータの不正（`param` に項目名）・状態の衝突（409） |
| `authentication_error` | 401 | API キーが無い・無効・失効している |
| `permission_error` | 403 | スコープの不足・プランの上限・API キーではできない操作 |
| `not_found` | 404 | 対象が無い（他のテナントの ID を指した時も同じ） |
| `rate_limit_error` | 429 | レート制限の超過（`Retry-After` の秒数の後に再試行する） |
| `api_error` | 5xx | サーバー側の失敗（`request_id` を添えて問い合わせる） |

- `message` は人と AI の両方が読む前提で、何が足りないかを日本語で書いています
- `param` は原因の項目名です（無ければ `null`）
- 問い合わせの際は `request_id` を添えてください
- 各操作で起こりうるエラーは、リファレンスの「応答」の表に載っています

## Webhook

登録した送信先（https の URL）へ、イベントの発生を POST で知らせます。送信先は API（[Webhook](https://okaeri.tech/developers/reference/webhooks.md)）か管理画面で登録します。送信先の本数にはプランごとの上限があります。

### イベント

| イベント | 送る時 | `data` の型 |
| --- | --- | --- |
| `lead.captured` | 入力途中の連絡先を取得した時 | [LeadCapturedData](https://okaeri.tech/developers/reference/schemas.md#LeadCapturedData) |
| `lead.abandoned` | 入力の途中で離脱したと判定した時 | [LeadAbandonedData](https://okaeri.tech/developers/reference/schemas.md#LeadAbandonedData) |
| `delivery.sent` | SMS・メールの送信を送信事業者が受け付けた時 | [DeliveryEventData](https://okaeri.tech/developers/reference/schemas.md#DeliveryEventData) |
| `delivery.failed` | 送信の失敗が確定した時 | [DeliveryEventData](https://okaeri.tech/developers/reference/schemas.md#DeliveryEventData) |
| `conversion.detected` | 成果（コンバージョン）を検知した時 | [ConversionDetectedData](https://okaeri.tech/developers/reference/schemas.md#ConversionDetectedData) |
| `usage.threshold_reached` | プランの送信枠の 80%・100% に達した時 | [UsageThresholdReachedData](https://okaeri.tech/developers/reference/schemas.md#UsageThresholdReachedData) |

本文に連絡先（電話番号・メールアドレス）は含みません。必要な時は `lead_id` で[リード](https://okaeri.tech/developers/reference/leads.md)を取得してください。

### 本文の例

```json
{
  "id": "00000000-0000-7000-8000-000000000001",
  "type": "lead.captured",
  "created_at": "2026-01-01T00:00:00Z",
  "tenant_id": "00000000-0000-7000-8000-000000000002",
  "data": {
    "lead_id": "00000000-0000-7000-8000-000000000003",
    "campaign_id": "00000000-0000-7000-8000-000000000004",
    "site_id": "00000000-0000-7000-8000-000000000005",
    "channels": [
      "email",
      "sms"
    ]
  }
}
```

### 受け方と署名の検証

Outbound Webhook で送信先へ送る本文（`POST`・`Content-Type: application/json`・`User-Agent: OKAERI-Webhook/1`）。`type` ごとに `data` の形が決まる。連絡先（電話・メール）は含まない（必要なら `lead_id` で取得する）。

**受け方**

- 2xx を返すと成功。それ以外の応答・接続できない時は再試行する（初回＋再試行5回＝最大6回・間隔は 1分 → 5分 → 30分 → 2時間 → 6時間）。リダイレクトは追わない。連続して失敗し続けた送信先は自動で停止する（`disabled_reason`）
- 少なくとも1回届ける＝同じイベントが2回以上届くことがある。`id` で重複を除く（再試行・再送でも`id` は同じ）
- ヘッダ `X-OKAERI-Event`＝`type` と同じ値。`X-OKAERI-Delivery`＝送信ごとの ID（再送で変わる）

**署名の検証**（ヘッダ `X-OKAERI-Signature: t=<UNIX 秒>,v1=<16進>`）

1. ヘッダを `,` で分け、`t` と `v1` を取り出す
2. `<t>.<受け取った本文のバイト列>` を、送信先の作成時に1回だけ受け取った `secret`（`whsec_…`）を鍵にして HMAC-SHA256 で計算し、16進（小文字）にする。本文は JSON として読み直したものでなく、受け取ったバイト列そのままを使う
3. 計算した値と `v1` を定数時間で比較する。一致しなければ破棄する
4. `t` と受信時刻の差が許容範囲（推奨 5分）を超えたら破棄する（盗んだ本文の再送の対策）。`t` は送るたびにその時刻で付け直す（再試行・再送でも古くならない）

検証の例（Python）：

```python
import hashlib
import hmac
import time

TOLERANCE = 300  # 秒（推奨は5分）


def verify(secret: str, header: str, body: bytes) -> bool:
    """X-OKAERI-Signature を検証する。body は受け取ったバイト列そのまま"""
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

## 契約の範囲

- 契約は [API リファレンス](https://okaeri.tech/developers/reference/index.md)（openapi.json）に載っている操作と項目です
- 載っていないエンドポイント（`/api/v1/admin/**`・`/api/v1/views/**` を含む）は管理画面のためのもので、契約の外です。予告なく変わるので使わないでください
- 「画面表示用・形は契約外」と書いた応答（サイトのタグの設定など）は、中の項目に依存した処理を組まないでください
- 値の一覧（`enum`）は今後増えることがあります。知らない値を受けても処理が止まらないようにしてください
- 後方互換を壊す変更は、パスのバージョン（`v1`）を上げて行います

## クイックスタート

動作の確認はテストキーで行ってください（書き込みは保存されません）。キーは環境変数で渡します。

```bash
export OKAERI_API_KEY="okr_test_XXXXXXXXXXXXXXXXXXXXXX"
```

### 1. サイトの一覧を取得する

必要なスコープは `sites:read`。応答の `data` にサイトが並びます。

```bash
curl https://api.okaeri.tech/api/v1/sites \
  -H "Authorization: Bearer $OKAERI_API_KEY"
```

### 2. 期間のファネル（段ごとの件数）を取得する

必要なスコープは `reports:read`。期間は `from` 以上 `to` 未満で、日時はオフセット付き（`+09:00` など）で指定します。

```bash
curl -G https://api.okaeri.tech/api/v1/reports/funnel \
  -H "Authorization: Bearer $OKAERI_API_KEY" \
  --data-urlencode "from=2026-01-01T00:00:00+09:00" \
  --data-urlencode "to=2026-02-01T00:00:00+09:00"
```

### 3. 除外リストに登録する（書き込み）

必要なスコープは `suppressions:write`。登録した宛先には配信しません。テストキーなら応答ヘッダに `OKAERI-Dry-Run: true` が付き、保存されません。

```bash
curl https://api.okaeri.tech/api/v1/suppressions \
  -H "Authorization: Bearer $OKAERI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"value": "user@example.com"}'
```
