# Leads aus Formularen an HubSpot oder Pipedrive senden

> Bilde jeden signierten Ikiform-Webhook auf HubSpots Kontakt-Upsert oder die Personen- und Lead-Aufrufe von Pipedrive ab, ohne doppelte Kontakte bei einer wiederholten Zustellung.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/lead-capture/leads-to-crm

Richte einen Ikiform-Webhook auf ein kleines Relay aus, das die Signatur verifiziert und den Lead auf die API deines CRM abbildet: HubSpots Batch-Upsert mit der E-Mail als Schlüssel oder Pipedrives Personensuche, Personenanlage und Lead-Anlage. Der Webhook-Body von Ikiform ist fest, also übernimmt das Relay die Zuordnung. Mach jeden Schritt wiederholbar, denn eine fehlgeschlagene Zustellung wird mit derselben `webhook-id` erneut versucht.

## Was beim Senden von Leads an ein CRM anders ist

Es gibt keine Ein-Klick-Integrationen. Jedes Tool wird über einen signierten [Webhook](https://ikiform.com/de/docs/webhooks.md) erreicht, direkt oder über ein Automatisierungstool wie [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) oder [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md). Der Webhook-Body ist für jedes Ziel derselbe: `data.fields` enthält die Werte des Formulars, und `data.meta` enthält Origin, Referrer, Land und Reply-To-Adresse. Ikiform lässt dich ihn nicht umformen, also macht etwas dazwischen daraus den Aufruf, den dein CRM erwartet.

Zwei Dinge machen ein CRM schwieriger als einen Chat-Channel:

- **Ein CRM führt Datensätze, keine Nachrichten.** Denselben Lead zweimal in Slack zu posten, ist Rauschen. Denselben Kontakt zweimal anzulegen, ist ein Durcheinander, das jemand von Hand zusammenführt. Das Relay muss nachschlagen, bevor es anlegt.
- **Wiederholungen gehören zum Vertrag.** Scheitert dein Relay oder das CRM, versucht Ikiform die Zustellung etwa 3 Tage lang mit derselben `webhook-id` erneut. Ein Relay, das beim ersten Mal halb erfolgreich war, darf beim zweiten Mal nichts doppelt anlegen.

## Relay oder ein Automatisierungstool

| Option | Stark bei | Nachteile |
| --- | --- | --- |
| [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md) oder [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) | Fertige Schritte für HubSpot und Pipedrive, mit Find-or-Create-Aktionen und ohne Code | Preise pro Task und ein weiteres Konto, das deine CRM-Zugangsdaten hält |
| Dein eigenes Relay | Exakte Feldzuordnung, deine eigenen Regeln für Duplikate, keine Abrechnung pro Lead | Du deployst und überwachst es. Etwa 60 Zeilen pro CRM, unten gezeigt |

So oder so ist das Webhook-Setup dasselbe: Füge den Endpunkt unter **Webhooks** hinzu, kopiere sein `whsec_`-Secret und sende einen Test. Die [Webhook-Doku](https://ikiform.com/de/docs/webhooks.md) behandelt Payload und Wiederholungen, und [dein eigener Server](https://ikiform.com/blog/form-submissions-to-your-own-server.md) behandelt das Verifizieren von Zustellungen ausführlicher.

## HubSpot: den Kontakt per E-Mail upserten

HubSpot behandelt die E-Mail als eindeutige Kennung eines Kontakts, und sein Batch-Upsert-Endpunkt nimmt `idProperty: "email"`. Ein Aufruf legt den Kontakt an, wenn die Adresse neu ist, und aktualisiert ihn sonst, sodass eine Wiederholung kein Duplikat anlegen kann.

Diagramm: Wie das Relay eine Ikiform-Einsendung auf einen HubSpot-Kontakt abbildet

1. fields.email, kleingeschrieben, wird zu email und zum Upsert-Schlüssel.
2. fields.name, erstes Wort, wird zu firstname.
3. fields.name, der Rest, wird zu lastname.
4. fields.company wird zu company.
5. fields.interest wird zu ikiform_interest, einer Eigenschaft, die du anlegst.
6. HubSpot setzt Owner und Lifecycle-Phase nach seinen eigenen Regeln; das Relay lässt beide unangetastet.

Alles links stammt aus dem festen Webhook-Body von Ikiform. Die graue Zeile bleibt HubSpot-Workflows überlassen, statt vom Relay gesetzt zu werden.

### Einrichtung

1. Erstelle in HubSpot eine Private App (HubSpot führt sie in manchen Konten inzwischen als Legacy Apps) mit dem Scope `crm.objects.contacts.write` und kopiere ihr Access Token in `HUBSPOT_TOKEN`.
2. Lege eine Kontakteigenschaft mit dem internen Namen `ikiform_interest` an oder streiche diese Zeile. HubSpot lehnt Eigenschaften ab, die nicht existieren.
3. Deploye die Route unten und füge ihre URL in Ikiform als Webhook-Endpunkt hinzu.

`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 });
}
```

- Ein `429` von HubSpot wird zu einem `429` mit `Retry-After`, den Ikiform vor dem nächsten Versuch respektiert.
- Jeder andere Fehler, auch ein `207` mit Fehlern pro Datensatz, wird zu einem `502` mit HubSpots Antwort als Body. Die ersten 4 KB landen im Zustellungsprotokoll von Ikiform, sodass du HubSpots Meldung ohne eigene Logs lesen kannst.
- Der Upsert sendet jedes Mal jede Eigenschaft, weil HubSpot keine partiellen Upserts mit der E-Mail als Schlüssel unterstützt. Ein leeres `company` aus einem zweiten Formular überschreibt ein bekanntes; entferne leere Werte aus `properties`, wenn dir das wichtig ist.

> HubSpots Referenz veröffentlicht inzwischen auch API-Pfade mit Datumsversion und führt diesen `v3`-Pfad im Legacy-Abschnitt. Prüf beim Bauen die aktuelle Kontakt-Referenz und lass den Request-Body gleich.

## Pipedrive: die Person finden, dann einen Lead hinzufügen

Pipedrive trennt Personen von Leads. Ein Lead muss mit einer Person oder Organisation verknüpft sein, also findet oder erstellt das Relay die Person und fügt dann einen Lead mit Titel hinzu. Personen und die Personensuche nutzen API v2; Leads und Notizen sind v1-Endpunkte. Beide nehmen dein API-Token im Header `x-api-token`.

Diagramm: Das Pipedrive-Relay, mit jedem Schritt wiederholbar

1. Eine Zustellung kommt mit ihrer webhook-id an.
2. Das Relay prüft einen Key-Value-Store auf diese ID.
3. Wurde sie schon verarbeitet, antwortet das Relay mit 200 und hört auf.
4. Sonst sucht es Personen per exakter E-Mail und erstellt nur dann eine, wenn keine passt.
5. Es fügt einen Lead hinzu, der mit dieser Person verknüpft ist, dann eine Notiz mit der Nachricht, und speichert die webhook-id.

Die Personensuche macht eine Wiederholung sicher, bevor der Lead existiert. Die gespeicherte webhook-id macht sie danach sicher.

`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` ist die Subdomain deines Unternehmens, das `acme` in `acme.pipedrive.com`. Das Token findest du in deinen persönlichen Einstellungen im Tab API.
- `SEEN` ist ein Workers-KV-Namespace, der an den Worker gebunden ist. Die ID wird erst gespeichert, nachdem der Lead angelegt ist, sodass ein Fehler auf halbem Weg nichts hinterlässt, was die Wiederholung blockiert.
- `origin_id` enthält die ID der Ikiform-Einsendung, sodass du einen Lead zur gespeicherten Einsendung zurückverfolgen kannst.
- Über die API angelegte Leads bekommen in Pipedrive `API` als Quelle.

## Prüfen, ob es funktioniert

1. Wähle **Send test** am Endpunkt. Das Relay antwortet mit `204` und ignoriert das Event `endpoint.test`.
2. Sende das Formular mit einer neuen Adresse ab. Ein HubSpot-Kontakt oder eine Pipedrive-Person mit Lead erscheint innerhalb von Sekunden.
3. Sende erneut mit derselben Adresse. HubSpot aktualisiert den einen Kontakt; Pipedrive fügt der bestehenden Person einen zweiten Lead hinzu.
4. Öffne die Zustellung unter **Webhooks** und wähle **Retry**. Nichts Neues wird angelegt.
5. Mach das Token kaputt und sende ab. Die Zustellung zeigt den Fehler des CRM im Protokoll; korrigiere das Token und versuch es erneut.

## Stolperfallen

- **Ein Lead pro Einsendung, nicht pro Person.** In Pipedrive ist das meist richtig, wo jede Anfrage ihr eigener Lead ist. Willst du Wiederholungen lieber an einen offenen Lead hängen, suche zuerst Leads nach Person.
- **3 Endpunkte pro Workspace.** Ein Relay kann beide CRMs und den Chat aufrufen, was Endpunkte für andere Formulare freihält.
- **Spam erreicht nie das CRM.** Spam löst keinen Webhook aus. Markierst du eine Einsendung als kein Spam, geht ihr Webhook dann raus, und der Lead erscheint verspätet.
- **Werte sind nicht vertrauenswürdig.** Feldwerte kommen von der Öffentlichkeit. Sende sie als JSON-Werte und bau nie Query-Strings oder Formeln daraus, ohne sie zu kodieren.

## Fragen

### Hat Ikiform eine Integration für HubSpot oder Pipedrive?

Keine mit einem Klick. Ikiform sendet jede Einsendung als signierten Webhook, und ein Relay oder ein Automatisierungstool wie Zapier, Make oder n8n legt den Kontakt oder Lead über die API des CRM an.

### Kann ich den Webhook-Body an die API meines CRM anpassen?

Nein. Der Webhook-Body ist für jedes Ziel fest, mit den Werten des Formulars unter data.fields. Ein Relay oder ein Automatisierungsschritt bildet ihn auf den Aufruf ab, den dein CRM erwartet.

### Wie vermeide ich doppelte Kontakte, wenn ein Webhook wiederholt wird?

Mach jeden Schritt wiederholbar. In HubSpot upsertest du per E-Mail. In Pipedrive suchst du die Person per exakter E-Mail, bevor du eine anlegst, und speicherst die webhook-id nach dem Anlegen des Leads, damit eine Wiederholung ihn überspringt.

### Wo sehe ich, warum ein Lead das CRM nicht erreicht hat?

Öffne den Endpunkt unter Webhooks im Dashboard. Jede Zustellung listet ihre Versuche mit dem Statuscode deines Relays und den ersten 4 KB seiner Antwort auf, also gib den Fehlertext des CRM im Body zurück.

## Weitere Anleitungen

- [Formular zur Lead-Erfassung](https://ikiform.com/de/guides/lead-capture.md): Ein Lead-Formular auf deiner eigenen Site, das dich per E-Mail mit Reply-To benachrichtigt, den Team-Chat anpingt und dein CRM füttert, damit du antwortest, solange der Lead noch da ist.
- [Formular für Demo-Anfragen](https://ikiform.com/de/guides/lead-capture/demo-request.md): Ein Formular zum Demo-Buchen, das den Lead speichert und den Besucher dann mit vorausgefülltem Namen und E-Mail zu Cal.com oder Calendly schickt.
- [Formular für Projektanfragen](https://ikiform.com/de/guides/lead-capture/project-inquiry.md): Ein Formular für neue Projekte für Studios und Freelancer, das Anfragen nach Budget und Zeitpunkt in heiß, warm und unpassend sortiert, bevor jemand das Briefing liest.
- [Formular für Angebotsanfragen](https://ikiform.com/de/guides/lead-capture/quote-request.md): Ein Formular für Angebotsanfragen, das Briefings als geteilte Links statt als Uploads sammelt, jeder Anfrage ein Fälligkeitsdatum gibt und linklastige Leads aus dem Spam hält.
- [Formular für Beratungsanfragen](https://ikiform.com/de/guides/lead-capture/consultation-request.md): Ein Formular für Beratungsanfragen für Steuerberater, Anwälte, Coaches und Praxen, das fragt, wie und wann angerufen werden soll, und heikle Details für das Gespräch lässt.
- [Lead-Formular mit Next.js](https://ikiform.com/de/guides/lead-capture/nextjs.md): Ein Lead-Formular für den Next.js App Router, das in einer Server Action mit Zod validiert, UTMs des ersten Kontakts in einem Cookie hält und qualifizierte Leads in deinen Kalender schickt.
