リード獲得フォーム

リード獲得フォーム2 分で読めます

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

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

Preet SutharIkiform 創業者

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

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

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

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

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

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

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

どちらの場合も Webhook の設定は同じです。Webhooks にエンドポイントを追加し、whsec_ のシークレットをコピーして、テストを送ります。ペイロードとリトライについては Webhook のドキュメントに、配信の検証については自前のサーバーに詳しくあります。

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

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

リレーが Ikiform の送信データを HubSpot の連絡先に対応付ける方法Ikiform のイベントHubSpot の連絡先fields.email(小文字化)email、upsert のキーfields.name の最初の語firstnamefields.name の残りlastnamefields.companycompanyfields.interestikiform_interest(自分で作るプロパティ)担当者とライフサイクルステージ(HubSpotのルールで)リレーが Ikiform の送信データを HubSpot の連絡先に対応付ける方法Ikiform のイベントHubSpot の連絡先fields.email(小文字化)email、upsertのキーfields.nameの最初の語firstnamefields.nameの残りlastnamefields.companycompanyfields.interestikiform_interest(自分で作るプロパティ)担当者とライフサイクルステージ(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
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 のリレーWebhookwebhook-id処理済み?KV で検索人物メールで検索、または作成リードtitle とperson_id200 を返す何もしないいいえはいすべての手順を繰り返しても安全な Pipedrive のリレーWebhookwebhook-id処理済み?KV で検索人物メールで検索、または作成リードtitle と person_id200 を返す何もしないいいえはい
  1. 配信が webhook-id 付きで届きます。
  2. リレーが、キーバリューストアでその ID を確認します。
  3. 以前に処理済みなら、リレーは 200 を返して止まります。
  4. そうでなければ、メールアドレスの完全一致で人物を検索し、一致するものがないときだけ作成します。
  5. その人物に紐づくリードを追加し、次にメッセージ入りのメモを追加して、webhook-id を記録します。
人物の検索で、リードが存在する前のリトライが安全になります。保存した webhook-id で、そのあとのリトライも安全になります。
src/index.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 の値として送り、エンコードせずにクエリ文字列や数式を組み立てることは絶対にしないでください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com