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.
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
| Option | Good at | Trade-offs |
|---|---|---|
| Zapier, Make or n8n | 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 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.
- fields.email, lowercased becomes email, and the upsert key.
- fields.name, first word becomes firstname.
- fields.name, the rest becomes lastname.
- fields.company becomes company.
- fields.interest becomes ikiform_interest, a property you create.
- owner and lifecycle stage, from HubSpot's own rules is added by the relay.
Setup
- In HubSpot, create a private app (HubSpot now files these as legacy apps in some accounts) with the
crm.objects.contacts.writescope, and copy its access token intoHUBSPOT_TOKEN. - Create a contact property with the internal name
ikiform_interest, or drop that line. HubSpot refuses properties that don't exist. - Deploy the route below and add its URL as a webhook endpoint in Ikiform.
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
429from HubSpot becomes a429withRetry-After, which Ikiform honours before trying again. - Any other failure, including a
207with per-record errors, becomes a502with 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
companyon a second form overwrites a known one; drop empty values frompropertiesif that matters to you.
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.
- A delivery arrives with its webhook-id.
- The relay checks a key-value store for that id.
- If it was handled before, the relay answers 200 and stops.
- Otherwise it searches persons by exact email and creates one only when none matches.
- It adds a lead linked to that person, then a note with the message, and records the webhook-id.
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_DOMAINis your company subdomain, theacmeinacme.pipedrive.com. The token is under your personal preferences, on the API tab.SEENis 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_idholds the Ikiform submission id, so you can trace a lead back to the stored submission.- Leads created through the API get
APIas their source in Pipedrive.
Check it works
- Choose Send test on the endpoint. The relay answers
204and ignores theendpoint.testevent. - Submit the form with a new address. A HubSpot contact or a Pipedrive person and lead appear within seconds.
- Submit again with the same address. HubSpot updates the one contact; Pipedrive adds a second lead to the existing person.
- Open the delivery under Webhooks and choose Retry. Nothing new is created.
- 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.