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

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

2026-10-25 게시 · https://ikiform.com/ko/guides/lead-capture/leads-to-crm

서명을 검증하고 리드를 CRM의 API로 매핑하는 작은 릴레이에 Ikiform 웹훅을 연결하세요. HubSpot은 이메일을 키로 하는 배치 upsert, Pipedrive는 person 검색, person 생성, lead 생성입니다. Ikiform의 웹훅 본문은 고정되어 있으므로 매핑은 릴레이가 합니다. 실패한 전달은 같은 `webhook-id`로 재시도되므로 모든 단계를 반복해도 안전하게 만드세요.

## CRM으로 리드를 보낼 때 다른 점

원클릭 연동은 없습니다. 모든 도구는 서명된 [웹훅](https://ikiform.com/ko/docs/webhooks.md)으로 직접, 또는 [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)나 [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) 같은 자동화 도구를 거쳐 연결합니다. 웹훅 본문은 모든 목적지에 대해 같습니다. `data.fields`에는 폼의 값이, `data.meta`에는 origin, referrer, 국가, reply-to 주소가 담깁니다. Ikiform에서는 형태를 바꿀 수 없으므로 중간의 무언가가 CRM이 기대하는 호출로 바꿉니다.

CRM이 채팅 채널보다 어려운 이유는 두 가지입니다.

- **CRM은 메시지가 아니라 레코드를 보관합니다.** 같은 리드를 Slack에 두 번 올리는 것은 소음입니다. 같은 연락처를 두 번 만드는 것은 누군가 손으로 병합해야 하는 엉망입니다. 릴레이는 만들기 전에 먼저 조회해야 합니다.
- **재시도는 계약의 일부입니다.** 릴레이나 CRM이 실패하면 Ikiform은 같은 `webhook-id`로 약 3일 동안 전달을 재시도합니다. 첫 시도에서 절반만 성공한 릴레이가 두 번째에 중복을 만들면 안 됩니다.

## 릴레이 또는 자동화 도구

| 선택지 | 잘하는 것 | 단점 |
| --- | --- | --- |
| [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 자격 증명을 보관하는 계정이 하나 더 생깁니다 |
| 직접 만든 릴레이 | 정확한 필드 매핑, 나만의 중복 규칙, 리드당 과금 없음 | 직접 배포하고 지켜봐야 합니다. 아래에 있듯 CRM당 약 60줄입니다 |

어느 쪽이든 웹훅 설정은 같습니다. **Webhooks**에서 엔드포인트를 추가하고, `whsec_` 시크릿을 복사하고, 테스트를 보내세요. [웹훅 문서](https://ikiform.com/ko/docs/webhooks.md)에서 페이로드와 재시도를, [자체 서버](https://ikiform.com/blog/form-submissions-to-your-own-server.md)에서 전달 검증을 더 자세히 다룹니다.

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

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

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

### 설정

1. HubSpot에서 `crm.objects.contacts.write` 스코프로 비공개 앱을 만들고(일부 계정에서는 이제 레거시 앱으로 분류됩니다), 액세스 토큰을 `HUBSPOT_TOKEN`에 복사하세요.
2. 내부 이름이 `ikiform_interest`인 연락처 속성을 만들거나, 그 줄을 빼세요. HubSpot은 존재하지 않는 속성을 거부합니다.
3. 아래 라우트를 배포하고 그 URL을 Ikiform의 웹훅 엔드포인트로 추가하세요.

`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는 매번 모든 속성을 보냅니다. 두 번째 폼의 빈 `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 릴레이

1. 전달이 webhook-id와 함께 도착합니다.
2. 릴레이가 키-값 저장소에서 그 ID를 확인합니다.
3. 이미 처리한 것이면 200으로 응답하고 멈춥니다.
4. 아니면 정확한 이메일로 person을 검색하고, 일치하는 것이 없을 때만 새로 만듭니다.
5. 그 person에 연결된 lead를 추가하고, 메시지를 담은 note를 추가한 뒤 webhook-id를 기록합니다.

person 검색 덕분에 lead가 생기기 전의 재시도가 안전하고, 저장된 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는 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 값으로 보내고, 인코딩 없이 쿼리 문자열이나 수식을 만들지 마세요.

## 질문

### Ikiform에 HubSpot이나 Pipedrive 연동이 있나요?

원클릭 연동은 없습니다. Ikiform은 각 제출을 서명된 웹훅으로 보내고, 릴레이나 Zapier, Make, n8n 같은 자동화 도구가 CRM의 API로 연락처나 lead를 만듭니다.

### CRM의 API에 맞게 웹훅 본문을 바꿀 수 있나요?

아니요. 웹훅 본문은 모든 목적지에 대해 고정되어 있고 폼의 값은 data.fields 아래에 있습니다. 릴레이나 자동화 단계가 CRM이 기대하는 호출로 매핑합니다.

### 웹훅이 재시도될 때 연락처 중복을 어떻게 피하나요?

모든 단계를 반복해도 안전하게 만드세요. HubSpot에서는 이메일로 upsert합니다. Pipedrive에서는 person을 만들기 전에 정확한 이메일로 검색하고, lead를 만든 뒤 webhook-id를 저장해 재시도가 건너뛰게 하세요.

### 리드가 CRM에 닿지 않은 이유는 어디서 보나요?

대시보드의 Webhooks에서 엔드포인트를 여세요. 각 전달에 릴레이의 상태 코드와 응답의 처음 4 KB가 시도별로 나오므로, CRM의 오류 텍스트를 본문으로 반환하세요.

## 가이드 더 보기

- [리드 수집 폼](https://ikiform.com/ko/guides/lead-capture.md): reply-to가 설정된 이메일을 보내고, 팀 채팅에 알리고, CRM에 넣는 내 사이트의 리드 수집 폼입니다. 리드가 아직 있을 때 답할 수 있습니다.
- [데모 요청 폼](https://ikiform.com/ko/guides/lead-capture/demo-request.md): 리드를 저장한 뒤 이름과 이메일이 미리 채워진 Cal.com이나 Calendly로 방문자를 보내는 데모 예약 폼입니다.
- [프로젝트 문의 폼](https://ikiform.com/ko/guides/lead-capture/project-inquiry.md): 누군가 브리프를 읽기 전에 예산과 시기로 문의를 유력, 관심, 부적합으로 분류하는 스튜디오와 프리랜서용 프로젝트 의뢰 폼입니다.
- [견적 요청 폼](https://ikiform.com/ko/guides/lead-capture/quote-request.md): 자료를 업로드 대신 공유 링크로 받고, 모든 요청에 기한을 붙이고, 링크가 많은 리드가 스팸으로 분류되지 않게 하는 견적 요청 폼입니다.
- [상담 신청 폼](https://ikiform.com/ko/guides/lead-capture/consultation-request.md): 회계사, 변호사, 코치, 클리닉을 위한 상담 신청 폼입니다. 연락 방법과 시간을 묻고, 민감한 내용은 통화에서 다룹니다.
- [Next.js 리드 폼](https://ikiform.com/ko/guides/lead-capture/nextjs.md): 서버 액션에서 Zod로 검증하고, 첫 유입 UTM을 쿠키에 보관하고, 자격을 갖춘 리드를 캘린더로 보내는 Next.js App Router 리드 폼입니다.
