# Kirim lead dari formulir ke HubSpot atau Pipedrive

> Petakan setiap webhook Ikiform bertanda tangan ke contact upsert HubSpot atau panggilan person dan lead Pipedrive, tanpa kontak duplikat saat pengiriman dicoba ulang.

Terbit 2026-10-25 · https://ikiform.com/id/guides/lead-capture/leads-to-crm

Arahkan webhook Ikiform ke relay kecil yang memverifikasi tanda tangan dan memetakan lead ke API CRM Anda: batch upsert HubSpot dengan kunci email, atau person search, person create, dan lead create di Pipedrive. Body webhook Ikiform tetap, jadi relay yang melakukan pemetaan. Buat setiap langkah aman untuk diulang, karena pengiriman yang gagal dicoba ulang dengan `webhook-id` yang sama.

## Apa yang berbeda saat mengirim lead ke CRM

Tidak ada integrasi sekali klik. Setiap alat dijangkau lewat [webhook](https://ikiform.com/id/docs/webhooks.md) bertanda tangan, langsung atau melalui alat otomasi seperti [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) atau [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md). Body webhook sama untuk setiap tujuan: `data.fields` berisi nilai formulir, dan `data.meta` berisi origin, referrer, negara, dan alamat reply-to. Ikiform tidak memungkinkan Anda mengubah bentuknya, jadi sesuatu di antaranya mengubahnya menjadi panggilan yang diharapkan CRM Anda.

Dua hal membuat CRM lebih sulit daripada channel chat:

- **CRM menyimpan record, bukan pesan.** Mengirim lead yang sama ke Slack dua kali hanyalah noise. Membuat kontak yang sama dua kali adalah kekacauan yang harus digabungkan seseorang secara manual. Relay harus mencari dulu sebelum membuat.
- **Percobaan ulang adalah bagian dari kontrak.** Jika relay atau CRM Anda gagal, Ikiform mencoba ulang pengiriman selama sekitar 3 hari dengan `webhook-id` yang sama. Relay yang setengah berhasil pada percobaan pertama tidak boleh membuat duplikat pada percobaan kedua.

## Relay, atau alat otomasi

| Opsi | Unggul dalam | Kekurangan |
| --- | --- | --- |
| [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md), atau [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) | Langkah HubSpot dan Pipedrive siap pakai, dengan action find-or-create dan tanpa kode | Harga per tugas, dan satu akun lagi yang memegang kredensial CRM Anda |
| Relay Anda sendiri | Pemetaan field yang persis, aturan duplikat Anda sendiri, tidak ada tagihan per lead | Anda yang men-deploy dan memantaunya. Sekitar 60 baris per CRM, ditampilkan di bawah |

Apa pun pilihannya, penyiapan webhook sama: tambahkan endpoint di **Webhooks**, salin secret `whsec_`-nya, dan kirim uji. [Dokumentasi webhook](https://ikiform.com/id/docs/webhooks.md) membahas payload dan percobaan ulang, dan [server Anda sendiri](https://ikiform.com/blog/form-submissions-to-your-own-server.md) membahas verifikasi pengiriman lebih dalam.

## HubSpot: upsert kontak berdasarkan email

HubSpot memperlakukan email sebagai identifier unik kontak, dan endpoint batch upsert-nya menerima `idProperty: "email"`. Satu panggilan membuat kontak jika alamatnya baru dan memperbaruinya jika tidak, jadi percobaan ulang tidak bisa membuat duplikat.

Diagram: Bagaimana relay memetakan kiriman Ikiform ke kontak HubSpot

1. fields.email, huruf kecil menjadi email, dan kunci upsert.
2. fields.name, kata pertama menjadi firstname.
3. fields.name, sisanya menjadi lastname.
4. fields.company menjadi company.
5. fields.interest menjadi ikiform_interest, properti yang Anda buat.
6. HubSpot menetapkan owner dan lifecycle stage dengan aturannya sendiri; relay tidak menyentuhnya.

Semua di sebelah kiri berasal dari body webhook Ikiform yang tetap. Baris abu-abu diserahkan ke workflow HubSpot alih-alih diatur oleh relay.

### Setup

1. Di HubSpot, buat private app (HubSpot kini menyebutnya legacy app di beberapa akun) dengan scope `crm.objects.contacts.write`, dan salin access token-nya ke `HUBSPOT_TOKEN`.
2. Buat properti kontak dengan nama internal `ikiform_interest`, atau hapus baris itu. HubSpot menolak properti yang tidak ada.
3. Deploy route di bawah dan tambahkan URL-nya sebagai endpoint webhook di 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 });
}
```

- `429` dari HubSpot menjadi `429` dengan `Retry-After`, yang dipatuhi Ikiform sebelum mencoba lagi.
- Kegagalan lainnya, termasuk `207` dengan error per record, menjadi `502` dengan jawaban HubSpot sebagai body. 4 KB pertamanya masuk ke log pengiriman Ikiform, jadi Anda bisa membaca pesan HubSpot tanpa log sendiri.
- Upsert mengirim setiap properti setiap kali, karena HubSpot tidak mendukung upsert parsial dengan kunci email. `company` kosong di formulir kedua menimpa nilai yang sudah diketahui; buang nilai kosong dari `properties` jika itu penting bagi Anda.

> Referensi HubSpot kini juga menerbitkan path API berversi tanggal dan mencantumkan path `v3` ini di bagian legacy. Periksa referensi kontak terbaru saat Anda membangunnya, dan biarkan body request tetap sama.

## Pipedrive: cari person, lalu tambahkan lead

Pipedrive memisahkan person dari lead. Lead harus terhubung ke person atau organization, jadi relay mencari atau membuat person, lalu menambahkan lead dengan judul. Person dan person search memakai API v2; lead dan note adalah endpoint v1. Keduanya menerima API token Anda di header `x-api-token`.

Diagram: Relay Pipedrive, dengan setiap langkah aman untuk diulang

1. Sebuah pengiriman tiba dengan webhook-id-nya.
2. Relay memeriksa ID itu di key-value store.
3. Jika sudah pernah ditangani, relay menjawab 200 dan berhenti.
4. Jika belum, relay mencari person berdasarkan email persis dan hanya membuat yang baru jika tidak ada yang cocok.
5. Relay menambahkan lead yang terhubung ke person itu, lalu note berisi pesannya, dan mencatat webhook-id.

Pencarian person membuat percobaan ulang aman sebelum lead ada. webhook-id yang disimpan membuatnya aman sesudahnya.

`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` adalah subdomain perusahaan Anda, `acme` di `acme.pipedrive.com`. Token ada di preferensi pribadi Anda, di tab API.
- `SEEN` adalah namespace Workers KV yang di-bind ke Worker. ID hanya disimpan setelah lead dibuat, jadi kegagalan di tengah jalan tidak meninggalkan apa pun yang menghalangi percobaan ulang.
- `origin_id` berisi ID kiriman Ikiform, jadi Anda bisa melacak lead kembali ke kiriman yang tersimpan.
- Lead yang dibuat lewat API mendapat `API` sebagai sumbernya di Pipedrive.

## Pastikan berfungsi

1. Pilih **Send test** di endpoint. Relay menjawab `204` dan mengabaikan event `endpoint.test`.
2. Kirim formulir dengan alamat baru. Kontak HubSpot atau person dan lead Pipedrive muncul dalam hitungan detik.
3. Kirim lagi dengan alamat yang sama. HubSpot memperbarui satu kontak itu; Pipedrive menambahkan lead kedua ke person yang sudah ada.
4. Buka pengiriman di **Webhooks** dan pilih **Retry**. Tidak ada yang baru dibuat.
5. Rusak token-nya lalu kirim. Pengiriman menampilkan error CRM di log; perbaiki token dan coba ulang.

## Hal yang sering menjebak

- **Satu lead per kiriman, bukan per orang.** Itu biasanya tepat di Pipedrive, tempat setiap inquiry adalah lead tersendiri. Jika Anda lebih suka melampirkan pengulangan ke lead yang masih terbuka, cari lead berdasarkan person lebih dulu.
- **3 endpoint per workspace.** Satu relay bisa memanggil kedua CRM dan chat, sehingga endpoint tetap tersedia untuk formulir lain.
- **Spam tidak pernah sampai ke CRM.** Spam tidak mengirim webhook. Saat Anda menandai kiriman sebagai bukan spam, webhook-nya dikirim saat itu, dan lead muncul terlambat.
- **Nilai tidak tepercaya.** Nilai field berasal dari publik. Kirim sebagai nilai JSON, jangan pernah menyusun query string atau formula darinya tanpa encoding.

## Pertanyaan

### Apakah Ikiform punya integrasi HubSpot atau Pipedrive?

Bukan yang sekali klik. Ikiform mengirim setiap kiriman sebagai webhook bertanda tangan, dan relay atau alat otomasi seperti Zapier, Make, atau n8n membuat kontak atau lead lewat API CRM.

### Bisakah saya mengubah body webhook agar sesuai dengan API CRM saya?

Tidak. Body webhook tetap untuk setiap tujuan, dengan nilai formulir di bawah data.fields. Relay atau langkah otomasi memetakannya ke panggilan yang diharapkan CRM Anda.

### Bagaimana cara menghindari kontak duplikat saat webhook dicoba ulang?

Buat setiap langkah aman untuk diulang. Di HubSpot, upsert berdasarkan email. Di Pipedrive, cari person berdasarkan email persis sebelum membuat yang baru, dan simpan webhook-id setelah membuat lead agar percobaan ulang melewatinya.

### Di mana saya bisa melihat alasan lead tidak sampai ke CRM?

Buka endpoint di Webhooks di dashboard. Setiap pengiriman mendaftar percobaannya dengan kode status relay Anda dan 4 KB pertama responsnya, jadi kembalikan teks error CRM di body.

## Panduan lainnya

- [Formulir lead capture](https://ikiform.com/id/guides/lead-capture.md): Formulir lead capture di situs Anda sendiri yang mengirimi Anda email dengan reply-to, memberi tahu chat tim, dan mengisi CRM, agar Anda menjawab selagi lead masih ada.
- [Formulir permintaan demo](https://ikiform.com/id/guides/lead-capture/demo-request.md): Formulir pesan demo yang menyimpan lead, lalu mengarahkan pengunjung ke Cal.com atau Calendly dengan nama dan email yang sudah terisi.
- [Formulir inquiry proyek](https://ikiform.com/id/guides/lead-capture/project-inquiry.md): Formulir mulai proyek untuk studio dan freelancer yang memilah inquiry menjadi hot, warm, dan tidak cocok berdasarkan anggaran dan waktu sebelum ada yang membaca brief-nya.
- [Formulir permintaan penawaran](https://ikiform.com/id/guides/lead-capture/quote-request.md): Formulir minta penawaran yang mengumpulkan brief sebagai tautan bersama alih-alih unggahan, memberi tenggat pada setiap permintaan, dan menjauhkan lead yang penuh tautan dari spam.
- [Formulir permintaan konsultasi](https://ikiform.com/id/guides/lead-capture/consultation-request.md): Formulir permintaan konsultasi untuk akuntan, pengacara, coach, dan klinik yang menanyakan cara dan waktu menghubungi, dan menyimpan detail sensitif untuk panggilannya.
- [Formulir lead Next.js](https://ikiform.com/id/guides/lead-capture/nextjs.md): Formulir lead App Router Next.js yang memvalidasi dengan Zod di server action, menyimpan UTM first-touch di cookie, dan mengirim lead yang lolos kualifikasi ke kalender Anda.
