리드 수집 폼4분 분량
폼 리드를 HubSpot이나 Pipedrive로 보내기
서명된 Ikiform 웹훅을 HubSpot의 연락처 upsert나 Pipedrive의 person, lead 호출로 매핑하고, 전달이 재시도되어도 연락처가 중복되지 않게 합니다.
서명을 검증하고 리드를 CRM의 API로 매핑하는 작은 릴레이에 Ikiform 웹훅을 연결하세요. HubSpot은 이메일을 키로 하는 배치 upsert, Pipedrive는 person 검색, person 생성, lead 생성입니다. Ikiform의 웹훅 본문은 고정되어 있으므로 매핑은 릴레이가 합니다. 실패한 전달은 같은 webhook-id로 재시도되므로 모든 단계를 반복해도 안전하게 만드세요.
CRM으로 리드를 보낼 때 다른 점
원클릭 연동은 없습니다. 모든 도구는 서명된 웹훅으로 직접, 또는 Zapier나 n8n 같은 자동화 도구를 거쳐 연결합니다. 웹훅 본문은 모든 목적지에 대해 같습니다. data.fields에는 폼의 값이, data.meta에는 origin, referrer, 국가, reply-to 주소가 담깁니다. Ikiform에서는 형태를 바꿀 수 없으므로 중간의 무언가가 CRM이 기대하는 호출로 바꿉니다.
CRM이 채팅 채널보다 어려운 이유는 두 가지입니다.
- CRM은 메시지가 아니라 레코드를 보관합니다. 같은 리드를 Slack에 두 번 올리는 것은 소음입니다. 같은 연락처를 두 번 만드는 것은 누군가 손으로 병합해야 하는 엉망입니다. 릴레이는 만들기 전에 먼저 조회해야 합니다.
- 재시도는 계약의 일부입니다. 릴레이나 CRM이 실패하면 Ikiform은 같은
webhook-id로 약 3일 동안 전달을 재시도합니다. 첫 시도에서 절반만 성공한 릴레이가 두 번째에 중복을 만들면 안 됩니다.
릴레이 또는 자동화 도구
어느 쪽이든 웹훅 설정은 같습니다. Webhooks에서 엔드포인트를 추가하고, whsec_ 시크릿을 복사하고, 테스트를 보내세요. 웹훅 문서에서 페이로드와 재시도를, 자체 서버에서 전달 검증을 더 자세히 다룹니다.
HubSpot: 이메일로 연락처 upsert하기
HubSpot은 이메일을 연락처의 고유 식별자로 다루며, 배치 upsert 엔드포인트는 idProperty: "email"을 받습니다. 한 번의 호출로 새 주소면 연락처를 만들고 아니면 업데이트하므로 재시도해도 중복이 생기지 않습니다.
- 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의 웹훅 엔드포인트로 추가하세요.
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는 매번 모든 속성을 보냅니다. 두 번째 폼의 빈
company가 알려진 값을 덮어씁니다. 중요하다면properties에서 빈 값을 빼세요.
v3 경로를 레거시 섹션에 둡니다. 구현할 때 최신 연락처 레퍼런스를 확인하고, 요청 본문은 그대로 유지하세요.Pipedrive: person을 찾은 뒤 lead 추가하기
Pipedrive는 person과 lead를 구분합니다. lead는 person이나 organization에 연결되어야 하므로, 릴레이는 person을 찾거나 만든 뒤 제목이 있는 lead를 추가합니다. person과 person 검색은 API v2를, lead와 note는 v1 엔드포인트를 씁니다. 둘 다 x-api-token 헤더로 API 토큰을 받습니다.
- 전달이 webhook-id와 함께 도착합니다.
- 릴레이가 키-값 저장소에서 그 ID를 확인합니다.
- 이미 처리한 것이면 200으로 응답하고 멈춥니다.
- 아니면 정확한 이메일로 person을 검색하고, 일치하는 것이 없을 때만 새로 만듭니다.
- 그 person에 연결된 lead를 추가하고, 메시지를 담은 note를 추가한 뒤 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는 lead가 만들어진 뒤에만 저장되므로, 중간에 실패해도 재시도를 막는 흔적이 남지 않습니다.origin_id에 Ikiform 제출 ID가 담기므로 lead에서 저장된 제출을 역추적할 수 있습니다.- API로 만든 lead는 Pipedrive에서 출처가
API로 표시됩니다.
작동 확인하기
- 엔드포인트에서 Send test를 누르세요. 릴레이가
204로 응답하고endpoint.test이벤트는 무시합니다. - 새 주소로 폼을 제출하세요. 몇 초 안에 HubSpot 연락처나 Pipedrive person과 lead가 나타납니다.
- 같은 주소로 다시 제출하세요. HubSpot은 그 연락처 하나를 업데이트하고, Pipedrive는 기존 person에 두 번째 lead를 추가합니다.
- Webhooks에서 전달을 열고 Retry를 누르세요. 새로 만들어지는 것이 없습니다.
- 토큰을 망가뜨리고 제출하세요. 전달 로그에 CRM의 오류가 표시됩니다. 토큰을 고치고 재시도하세요.
주의할 점
- 사람당이 아니라 제출당 lead 하나. 문의마다 별도 lead가 되는 Pipedrive에서는 대개 이게 맞습니다. 반복 문의를 열린 lead에 붙이고 싶다면 먼저 person으로 lead를 검색하세요.
- 워크스페이스당 엔드포인트 3개. 릴레이 하나가 두 CRM과 채팅을 모두 호출할 수 있으므로 다른 폼을 위해 엔드포인트를 남겨 둘 수 있습니다.
- 스팸은 CRM에 닿지 않습니다. 스팸은 웹훅을 보내지 않습니다. 제출을 스팸 아님으로 표시하면 그때 웹훅이 나가므로 lead가 늦게 나타납니다.
- 값은 신뢰할 수 없습니다. 필드 값은 일반 사용자가 입력한 것입니다. JSON 값으로 보내고, 인코딩 없이 쿼리 문자열이나 수식을 만들지 마세요.
질문
원클릭 연동은 없습니다. Ikiform은 각 제출을 서명된 웹훅으로 보내고, 릴레이나 Zapier, Make, n8n 같은 자동화 도구가 CRM의 API로 연락처나 lead를 만듭니다.
아니요. 웹훅 본문은 모든 목적지에 대해 고정되어 있고 폼의 값은 data.fields 아래에 있습니다. 릴레이나 자동화 단계가 CRM이 기대하는 호출로 매핑합니다.
모든 단계를 반복해도 안전하게 만드세요. HubSpot에서는 이메일로 upsert합니다. Pipedrive에서는 person을 만들기 전에 정확한 이메일로 검색하고, lead를 만든 뒤 webhook-id를 저장해 재시도가 건너뛰게 하세요.
대시보드의 Webhooks에서 엔드포인트를 여세요. 각 전달에 릴레이의 상태 코드와 응답의 처음 4 KB가 시도별로 나오므로, CRM의 오류 텍스트를 본문으로 반환하세요.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.