# フォームのリードを HubSpot や Pipedrive に送る

> 署名付きの Ikiform の Webhook を、HubSpot の連絡先 upsert や Pipedrive の人物とリードの呼び出しに変換し、配信がリトライされても連絡先が重複しないようにします。

公開日 2026-10-25 · https://ikiform.com/ja/guides/lead-capture/leads-to-crm

Ikiform の Webhook を、署名を検証してリードを CRM の API に変換する小さなリレーに向けます。HubSpot ならメールアドレスをキーにしたバッチ upsert、Pipedrive なら人物の検索、人物の作成、リードの作成です。Ikiform の Webhook ボディは決まった形なので、変換はリレーが行います。失敗した配信は同じ `webhook-id` でリトライされるので、すべての手順を繰り返しても安全にしてください。

## リードを CRM に送るときの違い

ワンクリックの連携はありません。どのツールにも、署名付きの [Webhook](https://ikiform.com/ja/docs/webhooks.md) で直接、または [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) や [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) などの自動化ツールを経由して届けます。Webhook ボディはどの送信先でも同じです。`data.fields` にフォームの値が、`data.meta` にオリジン、リファラー、国、reply-to のアドレスが入ります。Ikiform では形を変えられないので、間に何かを置いて CRM が求める呼び出しに変換します。

CRM がチャットのチャンネルより難しい理由は 2 つあります。

- **CRM が保持するのはメッセージではなく記録。** 同じリードを Slack に 2 回投稿してもノイズで済みますが、同じ連絡先を 2 回作ると誰かが手作業で統合する羽目になります。リレーは作成する前に検索しなければなりません。
- **リトライは約束事の一部。** リレーか CRM が失敗すると、Ikiform は同じ `webhook-id` で約 3 日間配信をリトライします。1 回目に途中まで成功したリレーが、2 回目に重複を作ってはいけません。

## リレーか、自動化ツールか

| 選択肢 | 得意なこと | トレードオフ |
| --- | --- | --- |
| [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)、[Make](https://ikiform.com/blog/form-submissions-to-make.md)、または [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) | HubSpot と Pipedrive の既製のステップ。検索または作成のアクションがあり、コードは不要 | タスク単位の料金で、CRM の認証情報を持つアカウントが 1 つ増える |
| 自前のリレー | フィールドを正確に対応付けられ、重複のルールも自分で決められ、リードごとの課金もない | デプロイと監視は自分で行う。CRM ごとに約 60 行で、下に示します |

どちらの場合も Webhook の設定は同じです。**Webhooks** にエンドポイントを追加し、`whsec_` のシークレットをコピーして、テストを送ります。ペイロードとリトライについては [Webhook のドキュメント](https://ikiform.com/ja/docs/webhooks.md)に、配信の検証については[自前のサーバー](https://ikiform.com/blog/form-submissions-to-your-own-server.md)に詳しくあります。

## HubSpot：メールアドレスで連絡先を upsert する

HubSpot はメールアドレスを連絡先の一意の識別子として扱い、バッチ upsert のエンドポイントは `idProperty: "email"` を受け付けます。1 回の呼び出しで、アドレスが新しければ連絡先を作成し、既存なら更新するので、リトライで重複が作られることはありません。

図: リレーが Ikiform の送信データを HubSpot の連絡先に対応付ける方法

1. 小文字にした fields.email は email になり、upsert のキーになります。
2. fields.name の最初の語は firstname になります。
3. fields.name の残りは lastname になります。
4. fields.company は company になります。
5. fields.interest は、自分で作るプロパティ ikiform_interest になります。
6. 担当者とライフサイクルステージは HubSpot が自身のルールで設定します。リレーは触れません。

左側はすべて Ikiform の決まった形の Webhook ボディから来ます。グレーの行はリレーでは設定せず、HubSpot のワークフローに任せます。

### 設定

1. HubSpot で `crm.objects.contacts.write` スコープ付きのプライベートアプリを作り（アカウントによっては現在レガシーアプリとして分類されています）、そのアクセストークンを `HUBSPOT_TOKEN` にコピーします。
2. 内部名 `ikiform_interest` の連絡先プロパティを作るか、その行を削除します。HubSpot は存在しないプロパティを拒否します。
3. 下のルートをデプロイし、その URL を Ikiform の Webhook のエンドポイントとして追加します。

`app/api/hubspot/route.ts`:

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

const UPSERT = "https://api.hubapi.com/crm/v3/objects/contacts/batch/upsert";

export async function POST(request: Request) {
  const payload = await request.text();
  let event;
  try {
    event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const fields = event.data.fields;
  const email = String(fields.email ?? "").trim().toLowerCase();
  if (!email) return new Response("No email, nothing to upsert", { status: 200 });
  const [firstname = "", ...rest] = String(fields.name ?? "").trim().split(/\s+/);

  const response = await fetch(UPSERT, {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      inputs: [
        {
          idProperty: "email",
          id: email,
          properties: {
            email,
            firstname,
            lastname: rest.join(" "),
            company: String(fields.company ?? ""),
            phone: String(fields.phone ?? ""),
            ikiform_interest: String(fields.interest ?? ""),
          },
        },
      ],
    }),
  });

  if (response.status === 429) {
    return new Response("HubSpot rate limit", { status: 429, headers: { "retry-after": "10" } });
  }
  if (response.status === 207 || !response.ok) {
    return new Response(await response.text(), { status: 502 });
  }
  return new Response(null, { status: 204 });
}
```

- HubSpot からの `429` は `Retry-After` 付きの `429` になり、Ikiform はそれに従ってからリトライします。
- レコードごとのエラーを含む `207` も含め、それ以外の失敗はすべて、HubSpot の応答をボディにした `502` になります。最初の 4 KB が Ikiform の配信ログに残るので、自分のログがなくても HubSpot のメッセージを読めます。
- HubSpot はメールアドレスをキーにした部分的な upsert に対応していないので、upsert は毎回すべてのプロパティを送ります。2 つ目のフォームで `company` が空だと既知の値が上書きされます。それが問題なら、`properties` から空の値を取り除いてください。

> HubSpot のリファレンスは現在、日付でバージョン管理された API のパスも公開しており、この `v3` のパスをレガシーのセクションに載せています。作るときは最新の連絡先のリファレンスを確認し、リクエストボディは同じままにしてください。

## Pipedrive：人物を探してから、リードを追加する

Pipedrive は人物とリードを分けています。リードは人物か組織に紐づける必要があるので、リレーは人物を探すか作成し、そのあとタイトル付きのリードを追加します。人物と人物の検索は API v2 を使い、リードとメモは v1 のエンドポイントです。どちらも `x-api-token` ヘッダーで API トークンを受け取ります。

図: すべての手順を繰り返しても安全な Pipedrive のリレー

1. 配信が webhook-id 付きで届きます。
2. リレーが、キーバリューストアでその ID を確認します。
3. 以前に処理済みなら、リレーは 200 を返して止まります。
4. そうでなければ、メールアドレスの完全一致で人物を検索し、一致するものがないときだけ作成します。
5. その人物に紐づくリードを追加し、次にメッセージ入りのメモを追加して、webhook-id を記録します。

人物の検索で、リードが存在する前のリトライが安全になります。保存した webhook-id で、そのあとのリトライも安全になります。

`src/index.ts`:

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

interface Env {
  IKIFORM_WEBHOOK_SECRET: string;
  PIPEDRIVE_TOKEN: string;
  PIPEDRIVE_DOMAIN: string;
  SEEN: KVNamespace;
}

export default {
  async fetch(request: Request, env: Env) {
    const payload = await request.text();
    let event;
    try {
      event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
    } catch (error) {
      if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
      throw error;
    }
    if (event.type !== "submission.created") return new Response(null, { status: 204 });
    if (await env.SEEN.get(event.id)) return new Response("Already handled", { status: 200 });

    const base = `https://${env.PIPEDRIVE_DOMAIN}.pipedrive.com/api`;
    const headers = { "x-api-token": env.PIPEDRIVE_TOKEN, "content-type": "application/json" };
    const call = async (path: string, init?: RequestInit) => {
      const response = await fetch(`${base}${path}`, { ...init, headers });
      if (!response.ok) throw new Error(`Pipedrive ${path} answered ${response.status}: ${await response.text()}`);
      return (await response.json()) as { data: any };
    };

    const fields = event.data.fields;
    const email = String(fields.email ?? "").trim().toLowerCase();
    const name = String(fields.name ?? email);

    try {
      const found = await call(`/v2/persons/search?term=${encodeURIComponent(email)}&fields=email&exact_match=true`);
      let personId: number | undefined = found.data?.items?.[0]?.item?.id;
      if (!personId) {
        const created = await call("/v2/persons", {
          method: "POST",
          body: JSON.stringify({ name, emails: [{ value: email, primary: true, label: "work" }] }),
        });
        personId = created.data.id;
      }

      const lead = await call("/v1/leads", {
        method: "POST",
        body: JSON.stringify({
          title: `${fields.company || name}: ${fields.interest ?? "website lead"}`,
          person_id: personId,
          origin_id: event.data.submission_id,
        }),
      });

      if (fields.message) {
        await call("/v1/notes", {
          method: "POST",
          body: JSON.stringify({ lead_id: lead.data.id, content: String(fields.message) }),
        });
      }
    } catch (error) {
      return new Response(String(error), { status: 502 });
    }

    await env.SEEN.put(event.id, "1", { expirationTtl: 60 * 60 * 24 * 5 });
    return new Response(null, { status: 204 });
  },
};
```

- `PIPEDRIVE_DOMAIN` は会社のサブドメインで、`acme.pipedrive.com` の `acme` の部分です。トークンは個人設定の API タブにあります。
- `SEEN` は Worker にバインドした Workers KV の名前空間です。ID はリードを作成したあとでだけ保存されるので、途中で失敗してもリトライを妨げるものは残りません。
- `origin_id` には Ikiform の送信 ID が入るので、リードから保存された送信データまでたどれます。
- API で作成したリードは、Pipedrive でソースが `API` になります。

## 動作を確認する

1. エンドポイントで **Send test** を選びます。リレーは `204` を返し、`endpoint.test` イベントを無視します。
2. 新しいアドレスでフォームを送信します。数秒で HubSpot の連絡先、または Pipedrive の人物とリードが現れます。
3. 同じアドレスでもう一度送信します。HubSpot は 1 つの連絡先を更新し、Pipedrive は既存の人物に 2 つ目のリードを追加します。
4. **Webhooks** で配信を開き、**Retry** を選びます。新しく作られるものはありません。
5. トークンを壊して送信します。配信のログに CRM のエラーが表示されます。トークンを直してリトライしてください。

## ハマりどころ

- **リードは人物ごとではなく送信ごとに 1 つ。** 問い合わせごとに独立したリードになる Pipedrive では、たいていそれで正解です。繰り返しの問い合わせを未処理のリードにまとめたいなら、先に人物でリードを検索してください。
- **エンドポイントはワークスペースごとに 3 つ。** 1 つのリレーから両方の CRM とチャットを呼べば、エンドポイントをほかのフォームのために空けておけます。
- **スパムが CRM に届くことはない。** スパムには Webhook が送られません。送信データをスパムではないとマークすると、その時点で Webhook が送られ、リードは遅れて現れます。
- **値は信頼しない。** フィールドの値は一般の人から来ます。JSON の値として送り、エンコードせずにクエリ文字列や数式を組み立てることは絶対にしないでください。

## よくある質問

### Ikiform には HubSpot や Pipedrive との連携機能がありますか？

ワンクリックの連携はありません。Ikiform は各送信を署名付きの Webhook として送り、リレーか、Zapier、Make、n8n などの自動化ツールが CRM の API で連絡先やリードを作成します。

### Webhook ボディを CRM の API に合わせて変えられますか？

いいえ。Webhook ボディはどの送信先でも決まった形で、フォームの値は data.fields に入ります。リレーか自動化のステップが、それを CRM が求める呼び出しに変換します。

### Webhook がリトライされたときに連絡先の重複を防ぐには？

すべての手順を繰り返しても安全にします。HubSpot ではメールアドレスで upsert します。Pipedrive では作成する前にメールアドレスの完全一致で人物を検索し、リードを作成したあとに webhook-id を保存して、リトライではそれを飛ばすようにします。

### リードが CRM に届かなかった理由はどこで確認できますか？

ダッシュボードの Webhooks でエンドポイントを開きます。各配信には試行ごとにリレーのステータスコードとレスポンスの先頭 4 KB が表示されるので、CRM のエラーの文言をボディで返すようにしてください。

## その他のガイド

- [リード獲得フォーム](https://ikiform.com/ja/guides/lead-capture.md): 自分のサイトに置くリード獲得フォームです。返信先付きでメールを送り、チームチャットに通知し、CRM に流し込むので、見込み客がまだいるうちに返信できます。
- [デモ依頼フォーム](https://ikiform.com/ja/guides/lead-capture/demo-request.md): リードを保存してから、名前とメールアドレスを入力済みの状態で訪問者を Cal.com や Calendly に送るデモ予約フォームです。
- [プロジェクト問い合わせフォーム](https://ikiform.com/ja/guides/lead-capture/project-inquiry.md): スタジオやフリーランス向けのプロジェクト依頼フォームです。誰かが概要を読む前に、予算と時期で問い合わせを有望、検討、対象外に振り分けます。
- [見積もり依頼フォーム](https://ikiform.com/ja/guides/lead-capture/quote-request.md): 資料をアップロードではなく共有リンクで受け取り、すべての依頼に期限を付け、リンクの多いリードがスパムにならないようにする見積もり依頼フォームです。
- [相談申し込みフォーム](https://ikiform.com/ja/guides/lead-capture/consultation-request.md): 会計士、弁護士、コーチ、クリニック向けの相談申し込みフォームです。連絡方法と時間を尋ね、デリケートな詳細は電話のときに回します。
- [Next.js のリードフォーム](https://ikiform.com/ja/guides/lead-capture/nextjs.md): Next.js App Router のリードフォームです。サーバーアクションで Zod による検証を行い、ファーストタッチの UTM を Cookie に保持し、条件に合うリードをカレンダーに送ります。
