リード獲得フォーム2 分で読めます
フォームのリードを HubSpot や Pipedrive に送る
署名付きの Ikiform の Webhook を、HubSpot の連絡先 upsert や Pipedrive の人物とリードの呼び出しに変換し、配信がリトライされても連絡先が重複しないようにします。
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 回目に重複を作ってはいけません。
リレーか、自動化ツールか
どちらの場合も Webhook の設定は同じです。Webhooks にエンドポイントを追加し、whsec_ のシークレットをコピーして、テストを送ります。ペイロードとリトライについては Webhook のドキュメントに、配信の検証については自前のサーバーに詳しくあります。
HubSpot:メールアドレスで連絡先を upsert する
HubSpot はメールアドレスを連絡先の一意の識別子として扱い、バッチ upsert のエンドポイントは idProperty: "email" を受け付けます。1 回の呼び出しで、アドレスが新しければ連絡先を作成し、既存なら更新するので、リトライで重複が作られることはありません。
- 小文字にした fields.email は email になり、upsert のキーになります。
- fields.name の最初の語は firstname になります。
- fields.name の残りは lastname になります。
- fields.company は company になります。
- fields.interest は、自分で作るプロパティ ikiform_interest になります。
- 担当者とライフサイクルステージは HubSpot が自身のルールで設定します。リレーは触れません。
設定
- HubSpot で
crm.objects.contacts.writeスコープ付きのプライベートアプリを作り(アカウントによっては現在レガシーアプリとして分類されています)、そのアクセストークンをHUBSPOT_TOKENにコピーします。 - 内部名
ikiform_interestの連絡先プロパティを作るか、その行を削除します。HubSpot は存在しないプロパティを拒否します。 - 下のルートをデプロイし、その URL を Ikiform の Webhook のエンドポイントとして追加します。
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から空の値を取り除いてください。
v3 のパスをレガシーのセクションに載せています。作るときは最新の連絡先のリファレンスを確認し、リクエストボディは同じままにしてください。Pipedrive:人物を探してから、リードを追加する
Pipedrive は人物とリードを分けています。リードは人物か組織に紐づける必要があるので、リレーは人物を探すか作成し、そのあとタイトル付きのリードを追加します。人物と人物の検索は API v2 を使い、リードとメモは v1 のエンドポイントです。どちらも x-api-token ヘッダーで API トークンを受け取ります。
- 配信が webhook-id 付きで届きます。
- リレーが、キーバリューストアでその ID を確認します。
- 以前に処理済みなら、リレーは 200 を返して止まります。
- そうでなければ、メールアドレスの完全一致で人物を検索し、一致するものがないときだけ作成します。
- その人物に紐づくリードを追加し、次にメッセージ入りのメモを追加して、webhook-id を記録します。
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になります。
動作を確認する
- エンドポイントで Send test を選びます。リレーは
204を返し、endpoint.testイベントを無視します。 - 新しいアドレスでフォームを送信します。数秒で HubSpot の連絡先、または Pipedrive の人物とリードが現れます。
- 同じアドレスでもう一度送信します。HubSpot は 1 つの連絡先を更新し、Pipedrive は既存の人物に 2 つ目のリードを追加します。
- Webhooks で配信を開き、Retry を選びます。新しく作られるものはありません。
- トークンを壊して送信します。配信のログに CRM のエラーが表示されます。トークンを直してリトライしてください。
ハマりどころ
- リードは人物ごとではなく送信ごとに 1 つ。 問い合わせごとに独立したリードになる Pipedrive では、たいていそれで正解です。繰り返しの問い合わせを未処理のリードにまとめたいなら、先に人物でリードを検索してください。
- エンドポイントはワークスペースごとに 3 つ。 1 つのリレーから両方の CRM とチャットを呼べば、エンドポイントをほかのフォームのために空けておけます。
- スパムが CRM に届くことはない。 スパムには Webhook が送られません。送信データをスパムではないとマークすると、その時点で Webhook が送られ、リードは遅れて現れます。
- 値は信頼しない。 フィールドの値は一般の人から来ます。JSON の値として送り、エンコードせずにクエリ文字列や数式を組み立てることは絶対にしないでください。
よくある質問
ワンクリックの連携はありません。Ikiform は各送信を署名付きの Webhook として送り、リレーか、Zapier、Make、n8n などの自動化ツールが CRM の API で連絡先やリードを作成します。
いいえ。Webhook ボディはどの送信先でも決まった形で、フォームの値は data.fields に入ります。リレーか自動化のステップが、それを CRM が求める呼び出しに変換します。
すべての手順を繰り返しても安全にします。HubSpot ではメールアドレスで upsert します。Pipedrive では作成する前にメールアドレスの完全一致で人物を検索し、リードを作成したあとに webhook-id を保存して、リトライではそれを飛ばすようにします。
ダッシュボードの Webhooks でエンドポイントを開きます。各配信には試行ごとにリレーのステータスコードとレスポンスの先頭 4 KB が表示されるので、CRM のエラーの文言をボディで返すようにしてください。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com