리드 수집 폼

리드 수집 폼4분 분량

폼 리드를 HubSpot이나 Pipedrive로 보내기

서명된 Ikiform 웹훅을 HubSpot의 연락처 upsert나 Pipedrive의 person, lead 호출로 매핑하고, 전달이 재시도되어도 연락처가 중복되지 않게 합니다.

Preet SutharIkiform 창업자

서명을 검증하고 리드를 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일 동안 전달을 재시도합니다. 첫 시도에서 절반만 성공한 릴레이가 두 번째에 중복을 만들면 안 됩니다.

릴레이 또는 자동화 도구

선택지잘하는 것단점
Zapier, Make, n8n 같은 도구찾거나 만드는 액션이 있는 기성 HubSpot, Pipedrive 단계. 코드가 필요 없습니다작업당 과금, 그리고 CRM 자격 증명을 보관하는 계정이 하나 더 생깁니다
직접 만든 릴레이정확한 필드 매핑, 나만의 중복 규칙, 리드당 과금 없음직접 배포하고 지켜봐야 합니다. 아래에 있듯 CRM당 약 60줄입니다

어느 쪽이든 웹훅 설정은 같습니다. Webhooks에서 엔드포인트를 추가하고, whsec_ 시크릿을 복사하고, 테스트를 보내세요. 웹훅 문서에서 페이로드와 재시도를, 자체 서버에서 전달 검증을 더 자세히 다룹니다.

HubSpot: 이메일로 연락처 upsert하기

HubSpot은 이메일을 연락처의 고유 식별자로 다루며, 배치 upsert 엔드포인트는 idProperty: "email"을 받습니다. 한 번의 호출로 새 주소면 연락처를 만들고 아니면 업데이트하므로 재시도해도 중복이 생기지 않습니다.

릴레이가 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의 고정된 웹훅 본문에서 옵니다. 회색 행은 릴레이가 설정하지 않고 HubSpot 워크플로에 맡깁니다.

설정

  1. HubSpot에서 crm.objects.contacts.write 스코프로 비공개 앱을 만들고(일부 계정에서는 이제 레거시 앱으로 분류됩니다), 액세스 토큰을 HUBSPOT_TOKEN에 복사하세요.
  2. 내부 이름이 ikiform_interest인 연락처 속성을 만들거나, 그 줄을 빼세요. HubSpot은 존재하지 않는 속성을 거부합니다.
  3. 아래 라우트를 배포하고 그 URL을 Ikiform의 웹훅 엔드포인트로 추가하세요.
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는 매번 모든 속성을 보냅니다. 두 번째 폼의 빈 company가 알려진 값을 덮어씁니다. 중요하다면 properties에서 빈 값을 빼세요.
HubSpot 레퍼런스는 이제 날짜 버전이 붙은 API 경로도 공개하며, 이 v3 경로를 레거시 섹션에 둡니다. 구현할 때 최신 연락처 레퍼런스를 확인하고, 요청 본문은 그대로 유지하세요.

Pipedrive: person을 찾은 뒤 lead 추가하기

Pipedrive는 person과 lead를 구분합니다. lead는 person이나 organization에 연결되어야 하므로, 릴레이는 person을 찾거나 만든 뒤 제목이 있는 lead를 추가합니다. person과 person 검색은 API v2를, lead와 note는 v1 엔드포인트를 씁니다. 둘 다 x-api-token 헤더로 API 토큰을 받습니다.

모든 단계를 반복해도 안전한 Pipedrive 릴레이웹훅webhook-id이미 처리했나?KV 조회person이메일로 검색 또는 생성leadtitle, person_id200 응답할 일 없음아니요예모든 단계를 반복해도 안전한 Pipedrive 릴레이웹훅webhook-id이미 처리했나?KV 조회person이메일로 검색 또는 생성leadtitle, person_id200 응답할 일 없음아니요예
  1. 전달이 webhook-id와 함께 도착합니다.
  2. 릴레이가 키-값 저장소에서 그 ID를 확인합니다.
  3. 이미 처리한 것이면 200으로 응답하고 멈춥니다.
  4. 아니면 정확한 이메일로 person을 검색하고, 일치하는 것이 없을 때만 새로 만듭니다.
  5. 그 person에 연결된 lead를 추가하고, 메시지를 담은 note를 추가한 뒤 webhook-id를 기록합니다.
person 검색 덕분에 lead가 생기기 전의 재시도가 안전하고, 저장된 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는 lead가 만들어진 뒤에만 저장되므로, 중간에 실패해도 재시도를 막는 흔적이 남지 않습니다.
  • origin_id에 Ikiform 제출 ID가 담기므로 lead에서 저장된 제출을 역추적할 수 있습니다.
  • API로 만든 lead는 Pipedrive에서 출처가 API로 표시됩니다.

작동 확인하기

  1. 엔드포인트에서 Send test를 누르세요. 릴레이가 204로 응답하고 endpoint.test 이벤트는 무시합니다.
  2. 새 주소로 폼을 제출하세요. 몇 초 안에 HubSpot 연락처나 Pipedrive person과 lead가 나타납니다.
  3. 같은 주소로 다시 제출하세요. HubSpot은 그 연락처 하나를 업데이트하고, Pipedrive는 기존 person에 두 번째 lead를 추가합니다.
  4. Webhooks에서 전달을 열고 Retry를 누르세요. 새로 만들어지는 것이 없습니다.
  5. 토큰을 망가뜨리고 제출하세요. 전달 로그에 CRM의 오류가 표시됩니다. 토큰을 고치고 재시도하세요.

주의할 점

  • 사람당이 아니라 제출당 lead 하나. 문의마다 별도 lead가 되는 Pipedrive에서는 대개 이게 맞습니다. 반복 문의를 열린 lead에 붙이고 싶다면 먼저 person으로 lead를 검색하세요.
  • 워크스페이스당 엔드포인트 3개. 릴레이 하나가 두 CRM과 채팅을 모두 호출할 수 있으므로 다른 폼을 위해 엔드포인트를 남겨 둘 수 있습니다.
  • 스팸은 CRM에 닿지 않습니다. 스팸은 웹훅을 보내지 않습니다. 제출을 스팸 아님으로 표시하면 그때 웹훅이 나가므로 lead가 늦게 나타납니다.
  • 값은 신뢰할 수 없습니다. 필드 값은 일반 사용자가 입력한 것입니다. JSON 값으로 보내고, 인코딩 없이 쿼리 문자열이나 수식을 만들지 마세요.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.