Lead capture form

Lead capture form6 min read

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.

Preet SutharFounder, Ikiform

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, directly or through an automation tool such as Zapier or n8n. 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

OptionGood atTrade-offs
Zapier, Make or n8nReady-made HubSpot and Pipedrive steps, with find-or-create actions and no codePer-task pricing, and one more account that holds your CRM credentials
Your own relayExact field mapping, your own duplicate rules, nothing billed per leadYou 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 cover the payload and retries, and your own server 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.

How the relay maps an Ikiform submission to HubSpot contactIkiform eventHubSpot contactfields.email, lowercasedemail, and the upsert keyfields.name, first wordfirstnamefields.name, the restlastnamefields.companycompanyfields.interestikiform_interest, a property youcreateowner and lifecycle stage, fromHubSpot's own rulesHow the relay maps an Ikiform submission to HubSpot contactIkiform eventHubSpot contactfields.email,lowercasedemail, and theupsert keyfields.name,first wordfirstnamefields.name,the restlastnamefields.companycompanyfields.interestikiform_interest,a property youcreateowner andlifecyclestage, fromHubSpot's ownrules
  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
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.

The Pipedrive relay, with every step safe to repeatwebhookwebhook-idseenbefore?KV lookuppersonsearch by email,or createleadtitle, person_idanswer 200nothing to donoyesThe Pipedrive relay, with every step safe to repeatwebhookwebhook-idseen before?KV lookuppersonsearch by email, orcreateleadtitle, person_idanswer200nothing todonoyes
  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
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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.