# Send form leads to HubSpot or Pipedrive

> Map each signed Ikiform webhook to HubSpot's contact upsert or Pipedrive's person and lead calls, without duplicate contacts when a delivery is retried.

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

Point an Ikiform webhook at a small relay that verifies the signature and maps the lead to your CRM's API: HubSpot's batch upsert keyed by email, or Pipedrive's person search, person create and lead create. Ikiform's webhook body is fixed, so the relay does the mapping. Make every step safe to repeat, because a failed delivery is retried with the same `webhook-id`.

## What's different about sending leads to a CRM

There are no one-click integrations. Every tool is reached through a signed [webhook](https://ikiform.com/docs/webhooks.md), directly or through an automation tool such as [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md). The webhook body is the same for every destination: `data.fields` holds the form's values, and `data.meta` holds the origin, referrer, country and reply-to address. Ikiform doesn't let you reshape it, so something in between turns it into the call your CRM expects.

Two things make a CRM harder than a chat channel:

- **A CRM keeps records, not messages.** Posting the same lead to Slack twice is noise. Creating the same contact twice is a mess someone merges by hand. The relay must look up before it creates.
- **Retries are part of the contract.** If your relay or the CRM fails, Ikiform retries the delivery for about 3 days with the same `webhook-id`. A relay that half-succeeded the first time must not duplicate on the second.

## Relay, or an automation tool

| Option | Good at | Trade-offs |
| --- | --- | --- |
| [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) | Ready-made HubSpot and Pipedrive steps, with find-or-create actions and no code | Per-task pricing, and one more account that holds your CRM credentials |
| Your own relay | Exact field mapping, your own duplicate rules, nothing billed per lead | You deploy and watch it. About 60 lines per CRM, shown below |

Either way, the webhook setup is the same: add the endpoint under **Webhooks**, copy its `whsec_` secret, and send a test. The [webhooks docs](https://ikiform.com/docs/webhooks.md) cover the payload and retries, and [your own server](https://ikiform.com/blog/form-submissions-to-your-own-server.md) covers verifying deliveries in more depth.

## HubSpot: upsert the contact by email

HubSpot treats email as a contact's unique identifier, and its batch upsert endpoint takes `idProperty: "email"`. One call creates the contact if the address is new and updates it if not, so a retry can't create a duplicate.

Diagram: How the relay maps an Ikiform submission to HubSpot contact

1. fields.email, lowercased becomes email, and the upsert key.
2. fields.name, first word becomes firstname.
3. fields.name, the rest becomes lastname.
4. fields.company becomes company.
5. fields.interest becomes ikiform_interest, a property you create.
6. owner and lifecycle stage, from HubSpot's own rules is added by the relay.

Everything on the left comes from Ikiform's fixed webhook body. The grey row is left to HubSpot workflows rather than set by the relay.

### Setup

1. In HubSpot, create a private app (HubSpot now files these as legacy apps in some accounts) with the `crm.objects.contacts.write` scope, and copy its access token into `HUBSPOT_TOKEN`.
2. Create a contact property with the internal name `ikiform_interest`, or drop that line. HubSpot refuses properties that don't exist.
3. Deploy the route below and add its URL as a webhook endpoint in 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 });
}
```

- A `429` from HubSpot becomes a `429` with `Retry-After`, which Ikiform honours before trying again.
- Any other failure, including a `207` with per-record errors, becomes a `502` with HubSpot's answer as the body. The first 4 KB lands in Ikiform's delivery log, so you can read HubSpot's message without your own logs.
- The upsert sends every property each time, because HubSpot doesn't support partial upserts keyed by email. A blank `company` on a second form overwrites a known one; drop empty values from `properties` if that matters to you.

> HubSpot's reference now also publishes date-versioned API paths and lists this `v3` path under its legacy section. Check the current contacts reference when you build, and keep the request body the same.

## Pipedrive: find the person, then add a lead

Pipedrive separates people from leads. A lead must link to a person or an organization, so the relay finds or creates the person, then adds a lead with a title. People and the person search use API v2; leads and notes are v1 endpoints. Both take your API token in the `x-api-token` header.

Diagram: The Pipedrive relay, with every step safe to repeat

1. A delivery arrives with its webhook-id.
2. The relay checks a key-value store for that id.
3. If it was handled before, the relay answers 200 and stops.
4. Otherwise it searches persons by exact email and creates one only when none matches.
5. It adds a lead linked to that person, then a note with the message, and records the webhook-id.

The person search makes a retry safe before the lead exists. The stored webhook-id makes it safe after.

`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` is your company subdomain, the `acme` in `acme.pipedrive.com`. The token is under your personal preferences, on the API tab.
- `SEEN` is a Workers KV namespace bound to the Worker. The id is stored only after the lead is created, so a failure halfway leaves nothing behind that blocks the retry.
- `origin_id` holds the Ikiform submission id, so you can trace a lead back to the stored submission.
- Leads created through the API get `API` as their source in Pipedrive.

## Check it works

1. Choose **Send test** on the endpoint. The relay answers `204` and ignores the `endpoint.test` event.
2. Submit the form with a new address. A HubSpot contact or a Pipedrive person and lead appear within seconds.
3. Submit again with the same address. HubSpot updates the one contact; Pipedrive adds a second lead to the existing person.
4. Open the delivery under **Webhooks** and choose **Retry**. Nothing new is created.
5. Break the token and submit. The delivery shows the CRM's error in the log; fix the token and retry it.

## Gotchas

- **One lead per submission, not per person.** That is usually right in Pipedrive, where each inquiry is its own lead. If you'd rather attach repeats to an open lead, search leads by person first.
- **3 endpoints per workspace.** One relay can call both CRMs and chat, which keeps endpoints free for other forms.
- **Spam never reaches the CRM.** Spam sends no webhook. When you mark a submission as not spam, its webhook goes out then, and the lead appears late.
- **Values are untrusted.** Field values come from the public. Send them as JSON values, never build query strings or formulas from them without encoding.

## Questions

### Does Ikiform have a HubSpot or Pipedrive integration?

Not a one-click one. Ikiform sends each submission as a signed webhook, and a relay or an automation tool such as Zapier, Make or n8n creates the contact or lead through the CRM's API.

### Can I change the webhook body to match my CRM's API?

No. The webhook body is fixed for every destination, with the form's values under data.fields. A relay or an automation step maps it to the call your CRM expects.

### How do I avoid duplicate contacts when a webhook is retried?

Make each step safe to repeat. In HubSpot, upsert by email. In Pipedrive, search the person by exact email before creating one, and store the webhook-id after creating the lead so a retry skips it.

### Where do I see why a lead didn't reach the CRM?

Open the endpoint under Webhooks in the dashboard. Each delivery lists its attempts with your relay's status code and the first 4 KB of its response, so return the CRM's error text in the body.

## More guides

- [Lead capture form](https://ikiform.com/guides/lead-capture.md): A lead capture form on your own site that emails you with reply-to, pings team chat and feeds your CRM, so you answer while the lead is still there.
- [Demo request form](https://ikiform.com/guides/lead-capture/demo-request.md): A book-a-demo form that stores the lead, then sends the visitor to Cal.com or Calendly with their name and email already filled in.
- [Project inquiry form](https://ikiform.com/guides/lead-capture/project-inquiry.md): A start-a-project form for studios and freelancers that sorts inquiries into hot, warm and no-fit by budget and timing before anyone reads the brief.
- [Quote request form](https://ikiform.com/guides/lead-capture/quote-request.md): A request-a-quote form that collects briefs as shared links instead of uploads, puts a due date on every request, and keeps link-heavy leads out of spam.
- [Consultation request form](https://ikiform.com/guides/lead-capture/consultation-request.md): A consultation request form for accountants, lawyers, coaches and clinics that asks how and when to call, and leaves sensitive details for the call.
- [Next.js lead form](https://ikiform.com/guides/lead-capture/nextjs.md): A Next.js App Router lead form that validates with Zod in a server action, keeps first-touch UTMs in a cookie and sends qualified leads to your calendar.
