EN

Webhooks

Erfahre sofort, was in meiti passiert.

Ein Anruf kommt rein, ein Chat pausiert, ein Kunde bestätigt seinen Termin: meiti meldet es deinem System, bevor dein Team nachfragen muss. Der CRM-Webhook holt dabei auch Kontext aus deinem CRM – die API-Webhooks melden signiert jede Termin-Änderung.

  • Gespräch
  • Webhook von meiti
  • Antwort deines Systems
  • API-Call
Kunde Telefon · WhatsApp · Chat
meiti Assistent & Postfach
Dein System CRM · ERP · Kalender
  1. Anruf von +49 170 1234567 Ingrid Weber ruft an. meiti nimmt ab.
  2. POST · IncomingCallLookup CRM-Webhook: meiti fragt dein System, wer anruft – noch während es klingelt.
  3. 200 · firstName, crmAiInfo Dein System antwortet in unter 2 Sekunden mit Namen und einem kurzen Hinweis.
  4. „Hallo Frau Weber! Geht es um Ihre Heizung?“ Der Assistent kennt die Kundin und führt das Gespräch.
  5. POST · FinishedCall Nach dem Gespräch kommt die Zusammenfassung zu deinem System.
  6. 200 · crmProjectId: "AZ-2024/117" Dein System legt den Vorgang an und gibt seine Nummer zurück.
  7. POST /v1/appointments API: Dein Kalender plant den Termin, meiti lädt die Kundin ein.
  8. Termin Di., 9:00 – passt das? Die Einladung kommt per WhatsApp, mit Button zum Bestätigen.
  9. ✓ Bestätigt Frau Weber tippt auf „Bestätigen“.
  10. appointment.confirmed Signierter API-Webhook: Dein Kalender erfährt es sofort.
Ein Anruf, von der ersten Sekunde bis zum bestätigten Termin – und überall, wo dein System dabei ist.

Zwei Webhooks, zwei Aufgaben

CRM-WebhookAPI-Webhooks
MeldetAnrufe, Chats, Formulare, TermineTermine
Einrichtenin der App, eine URL pro Postfachper API (POST /v1/webhooks), pro Schlüssel
AbsicherungAuthorization: Bearer <Token>Signatur X-Meiti-Signature (HMAC)
Antwortkann Daten zurückgeben (Namen, Nummern, Hinweise)nur 2xx, keine Daten
Wiederholung erkennencallbackUrlX-Meiti-Delivery-Id

Deine eigene KI soll selbst antworten? Dafür gibt es keinen der beiden Webhooks, sondern Eigene KI (BYOAI).

Referenz

CRM-Webhook

Der CRM-Webhook meldet deinem System, was in meiti passiert, und nimmt deine Antwort entgegen: Bei jedem Anruf, Chat, Formular und Termin bekommt dein Server ein Event – und kann mit Namen, Kundennummer und Hinweisen für den Assistenten antworten.

Einrichten in der App unter Einstellungen → Einrichtung & Betrieb → Verbundene Systeme → CRM & Fachsoftware → Webhook: URL und Bearer-Token. Jedes Postfach hat seinen eigenen Webhook – das Event selbst nennt das Postfach nicht, gib jedem Postfach also eine eigene URL oder einen eigenen Token. meiti sendet jedes Event als POST mit Authorization: Bearer <dein Token>.

Zusätzlich zu deinem CRM: Ist HERO, Pipedrive, HubSpot oder Zendesk direkt verbunden, trägst du den Webhook unter CRM & Fachsoftware → Zusätzlich Webhook ein und bekommst dieselben Events. Kunden- und Vorgangsnummer gehören dann dem CRM: crmContactId und crmProjectId in deinen Antworten werden ignoriert, alle anderen Felder übernommen.

eventTypeWann
IncomingCallLookupBei jedem eingehenden Anruf – auch wenn der Kunde zu einem offenen Vorgang erneut anruft.
FinishedCallNach jedem Anruf, mit Zusammenfassung.
NewConversationEin Chat beginnt neu (neuer Vorgang oder erste Nachricht nach mindestens 1 Stunde).
ConversationPaused10 Minuten ohne Aktivität im Chat.
FormSubmittedDer Kunde hat ein Formular ausgefüllt.
AppointmentCreated, …Confirmed, …Rescheduled, …Cancelled, …Finished, …ReminderSentEin Termin wurde angelegt, vom Kunden bestätigt, verschoben, abgesagt, erledigt oder erinnert – egal ob in der App, vom Assistenten, auf der Terminseite des Kunden oder per API. Dazu kommt appointmentData in derselben Form wie in der API.
ManualManuell aus den Vorgangsdetails in der App ausgelöst – praktisch zum Testen.
  • eventType ist der Name als String. Unbekannte Werte ignorieren – es kommen neue dazu. Umstellung: Bis Oktober 2026 kam hier eine Zahl (0 = IncomingCallLookup, 1 = FinishedCall, 2 = NewConversation, 3 = ConversationPaused, 4 = Manual, 5 = FormSubmitted). Bestehende Integrationen sollten übergangsweise beides akzeptieren, z. B. const name = typeof e === "number" ? ["IncomingCallLookup", "FinishedCall", "NewConversation", "ConversationPaused", "Manual", "FormSubmitted"][e] : e;
  • Sofort antworten (200 + Daten, innerhalb von 2 Sekunden) oder später (202, dann POST mit denselben Daten an die einmalige callbackUrl, 5 Minuten gültig). 204 = nichts zu ändern. Kommt die Antwort später, nutzt meiti sie ab da – ein Gespräch, das schon läuft, beginnt nicht neu.
  • Übernommen wird nur, was du mit requestContactUpdate: true bzw. requestProjectUpdate: true markierst. IDs wie crmContactId sind immer Strings.
  • Antwortet dein Server zu langsam (über 2 Sekunden) oder mit einem Fehler, stellt meiti dasselbe Event – mit derselben callbackUrl – ein weiteres Mal zu, dann mit 30 Sekunden Zeit. Eine 2xx-Antwort (auch 202/204) wird nicht wiederholt. Erkenne Wiederholungen an der callbackUrl.
  • Nur gesetzte Felder werden übernommen; leere bleiben unverändert. crmAiInfo liest der Assistent in jedem Gespräch – kurz halten (max. 5000 Zeichen).
Langsames CRM? Später per callbackUrl antworten
# Your webhook answered 202 Accepted. Later, within 5 minutes, exactly once:
curl -X POST "$CALLBACK_URL" \
  -H "Content-Type: application/json" \
  -d '{
    "requestContactUpdate": true,
    "contactData": { "crmContactId": "K-1042", "crmAiInfo": "Kundin seit 2019." }
  }'
app.post("/meiti/webhook", express.json(), (req, res) => {
  if (req.get("Authorization") !== `Bearer ${process.env.MEITI_WEBHOOK_TOKEN}`) return res.sendStatus(401);
  res.sendStatus(202);                       // answer right away …
  lookUpSlowly(req.body).then(update =>      // … and send the data when you have it
    fetch(req.body.callbackUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(update),          // same shape as a direct answer; no auth header needed
    }));
});
import threading, requests

@app.post("/meiti/webhook")
def meiti_webhook():
    if request.headers.get("Authorization") != f"Bearer {os.environ['MEITI_WEBHOOK_TOKEN']}":
        abort(401)
    event = request.get_json()
    def later():
        update = look_up_slowly(event)  # same shape as a direct answer
        requests.post(event["callbackUrl"], json=update, timeout=10)  # no auth header needed
    threading.Thread(target=later).start()
    return "", 202  # answer right away
// add "bytes" to the imports of the handler above
func meitiWebhook(w http.ResponseWriter, r *http.Request) {
	// … bearer check and decoding as in the handler above …
	w.WriteHeader(http.StatusAccepted) // answer right away …
	go func(e meitiEvent) {            // … and send the data when you have it
		update := lookUpSlowly(e)      // same shape as a direct answer
		b, _ := json.Marshal(update)
		res, err := http.Post(e.CallbackURL, "application/json", bytes.NewReader(b)) // no auth header needed
		if err == nil {
			res.Body.Close()
		}
	}(e)
}
// ASP.NET Core: after the 202 the request is gone — hand the work to a background queue.
using System.Text.Json;
using System.Threading.Channels;

builder.Services.AddSingleton(Channel.CreateUnbounded<JsonElement>());
builder.Services.AddHttpClient();
builder.Services.AddHostedService<CallbackWorker>();

app.MapPost("/meiti/webhook", async (HttpRequest request, Channel<JsonElement> queue) =>
{
    // … bearer check as in the handler above …
    var evt = await JsonSerializer.DeserializeAsync<JsonElement>(request.Body);
    await queue.Writer.WriteAsync(evt);
    return Results.Accepted(); // answer right away
});

class CallbackWorker(Channel<JsonElement> queue, IHttpClientFactory http) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var evt in queue.Reader.ReadAllAsync(ct))
        {
            try
            {
                var update = await LookUpSlowlyAsync(evt);   // same shape as a direct answer
                var callbackUrl = evt.GetProperty("callbackUrl").GetString();
                await http.CreateClient().PostAsJsonAsync(callbackUrl, update, ct); // no auth header needed
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                Console.Error.WriteLine(ex); // one failed lookup must not stop the worker
            }
        }
    }
}
<?php
// … bearer check as in the handler above …
$event = json_decode(file_get_contents("php://input"), true);

http_response_code(202);          // answer right away …
if (function_exists("fastcgi_finish_request")) {
    fastcgi_finish_request();     // … PHP-FPM closes the request, the script keeps running
}

$update = lookUpSlowly($event);   // same shape as a direct answer
$ch = curl_init($event["callbackUrl"]);
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Content-Type: application/json"], // no auth header needed
    CURLOPT_POSTFIELDS => json_encode($update),
]);
curl_exec($ch);
// Without PHP-FPM: put $event on a queue and let a worker do this part.
  • Der CRM-Webhook ist in jedem Tarif enthalten – ohne Kontingent.
  • Lieber ohne Code? Fertige n8n-Vorlagen (z. B. HubSpot) findest du weiter unten.

Alle Felder und Längen

Rezepte

Der CRM-Webhook in der Praxis

In den Beispielen steht crm für deinen eigenen Code.

CRM-Webhook

Anrufer erkennen und dem Assistenten Kontext geben

„Frau Weber, schön, dass Sie anrufen – wie kann ich helfen?“ Bei jedem Anruf schickt meiti IncomingCallLookup mit der Telefonnummer. Findet dein System den Kunden, antwortest du direkt mit Name, Kundennummer und einem Hinweis für den Assistenten. Derselbe Handler beantwortet auch NewConversation (Chat) und nimmt nach dem Gespräch die Zusammenfassung entgegen – siehe nächstes Rezept.

// Node + Express. crm.* stands for your own CRM code.
import express from "express";

const app = express();

app.post("/meiti/webhook", express.json(), async (req, res) => {
  if (req.get("Authorization") !== `Bearer ${process.env.MEITI_WEBHOOK_TOKEN}`) return res.sendStatus(401);
  const { eventType, contactData, projectData } = req.body;

  // 1) Before the conversation: tell meiti who this is.
  if (eventType === "IncomingCallLookup" || eventType === "NewConversation") {
    const customer = await crm.findByPhone(contactData.phoneNumber);
    if (!customer) return res.sendStatus(204); // unknown: nothing to add
    return res.json({
      requestContactUpdate: true,
      contactData: {
        crmContactId: String(customer.id), // ids are strings
        firstName: customer.firstName,
        lastName: customer.lastName,
        crmAiInfo: `Kundin seit ${customer.since}. Bevorzugt Termine vormittags.`,
      },
    });
  }

  // 2) After the conversation: file the summary, hand back your case number.
  if (eventType === "FinishedCall" || eventType === "ConversationPaused") {
    const caseId = await crm.upsertCase({
      customerId: contactData.crmContactId,          // null when the caller was unknown
      meitiProjectId: projectData.meitiProjectId,
      summary: projectData.currentSummary,          // running summary of the whole project
    });
    return res.json({ requestProjectUpdate: true, projectData: { crmProjectId: String(caseId) } });
  }

  res.sendStatus(204); // other or newer events: nothing to do
});

app.listen(3000);
# Python + Flask. crm.* stands for your own CRM code.
import os
from flask import Flask, abort, request

app = Flask(__name__)

@app.post("/meiti/webhook")
def meiti_webhook():
    if request.headers.get("Authorization") != f"Bearer {os.environ['MEITI_WEBHOOK_TOKEN']}":
        abort(401)
    event = request.get_json()
    contact, project = event["contactData"], event["projectData"]

    # 1) Before the conversation: tell meiti who this is.
    if event["eventType"] in ("IncomingCallLookup", "NewConversation"):
        customer = crm.find_by_phone(contact["phoneNumber"])
        if customer is None:
            return "", 204  # unknown: nothing to add
        return {
            "requestContactUpdate": True,
            "contactData": {
                "crmContactId": str(customer.id),  # ids are strings
                "firstName": customer.first_name,
                "lastName": customer.last_name,
                "crmAiInfo": f"Kundin seit {customer.since}. Bevorzugt Termine vormittags.",
            },
        }

    # 2) After the conversation: file the summary, hand back your case number.
    if event["eventType"] in ("FinishedCall", "ConversationPaused"):
        case_id = crm.upsert_case(
            customer_id=contact.get("crmContactId"),  # None when the caller was unknown
            meiti_project_id=project["meitiProjectId"],
            summary=project["currentSummary"],  # running summary of the whole project
        )
        return {"requestProjectUpdate": True, "projectData": {"crmProjectId": str(case_id)}}

    return "", 204  # other or newer events: nothing to do
// net/http. crm stands for your own CRM code.
package main

import (
	"encoding/json"
	"net/http"
	"os"
)

type meitiEvent struct {
	EventType   string `json:"eventType"`
	CallbackURL string `json:"callbackUrl"`
	ContactData struct {
		PhoneNumber  string  `json:"phoneNumber"`
		CrmContactID *string `json:"crmContactId"` // nil when the caller was unknown
	} `json:"contactData"`
	ProjectData struct {
		MeitiProjectID int     `json:"meitiProjectId"`
		CurrentSummary *string `json:"currentSummary"` // running summary of the whole project, can be empty
	} `json:"projectData"`
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(v)
}

func meitiWebhook(w http.ResponseWriter, r *http.Request) {
	if r.Header.Get("Authorization") != "Bearer "+os.Getenv("MEITI_WEBHOOK_TOKEN") {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	var e meitiEvent
	if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	switch e.EventType {
	// 1) Before the conversation: tell meiti who this is.
	case "IncomingCallLookup", "NewConversation":
		customer, ok := crm.FindByPhone(e.ContactData.PhoneNumber)
		if !ok {
			w.WriteHeader(http.StatusNoContent) // unknown: nothing to add
			return
		}
		writeJSON(w, map[string]any{
			"requestContactUpdate": true,
			"contactData": map[string]any{
				"crmContactId": customer.ID, // ids are strings
				"firstName":    customer.FirstName,
				"lastName":     customer.LastName,
				"crmAiInfo":    "Kundin seit " + customer.Since + ". Bevorzugt Termine vormittags.",
			},
		})

	// 2) After the conversation: file the summary, hand back your case number.
	case "FinishedCall", "ConversationPaused":
		caseID := crm.UpsertCase(e.ContactData.CrmContactID, e.ProjectData.MeitiProjectID, e.ProjectData.CurrentSummary)
		writeJSON(w, map[string]any{"requestProjectUpdate": true, "projectData": map[string]any{"crmProjectId": caseID}})

	default:
		w.WriteHeader(http.StatusNoContent) // other or newer events: nothing to do
	}
}

func main() {
	http.HandleFunc("POST /meiti/webhook", meitiWebhook)
	http.ListenAndServe(":3000", nil)
}
// ASP.NET Core minimal API (Program.cs). crm stands for your own CRM service.
using System.Text.Json;

var app = WebApplication.CreateBuilder(args).Build();

app.MapPost("/meiti/webhook", async (HttpRequest request) =>
{
    if (request.Headers.Authorization != $"Bearer {Environment.GetEnvironmentVariable("MEITI_WEBHOOK_TOKEN")}")
        return Results.Unauthorized();

    using var doc = await JsonDocument.ParseAsync(request.Body);
    var evt = doc.RootElement;
    var type = evt.GetProperty("eventType").GetString();
    var contact = evt.GetProperty("contactData");
    var project = evt.GetProperty("projectData");

    // 1) Before the conversation: tell meiti who this is.
    if (type is "IncomingCallLookup" or "NewConversation")
    {
        var customer = await crm.FindByPhoneAsync(contact.GetProperty("phoneNumber").GetString());
        if (customer is null) return Results.NoContent(); // unknown: nothing to add
        return Results.Ok(new
        {
            requestContactUpdate = true,
            contactData = new
            {
                crmContactId = customer.Id.ToString(), // ids are strings
                firstName = customer.FirstName,
                lastName = customer.LastName,
                crmAiInfo = $"Kundin seit {customer.Since}. Bevorzugt Termine vormittags.",
            },
        });
    }

    // 2) After the conversation: file the summary, hand back your case number.
    if (type is "FinishedCall" or "ConversationPaused")
    {
        var caseId = await crm.UpsertCaseAsync(
            customerId: contact.GetProperty("crmContactId").GetString(),   // null when the caller was unknown
            meitiProjectId: project.GetProperty("meitiProjectId").GetInt32(),
            summary: project.GetProperty("currentSummary").GetString());  // running summary of the whole project
        return Results.Ok(new { requestProjectUpdate = true, projectData = new { crmProjectId = caseId.ToString() } });
    }

    return Results.NoContent(); // other or newer events: nothing to do
});

app.Run();
<?php
// meiti-webhook.php. $crm stands for your own CRM code.
// Apache/FPM often strip Authorization; check the fallbacks too.
$auth = $_SERVER["HTTP_AUTHORIZATION"]
    ?? $_SERVER["REDIRECT_HTTP_AUTHORIZATION"]
    ?? (function_exists("getallheaders") ? (getallheaders()["Authorization"] ?? "") : "");
if ($auth !== "Bearer " . getenv("MEITI_WEBHOOK_TOKEN")) {
    http_response_code(401);
    exit;
}
$event = json_decode(file_get_contents("php://input"), true);
$contact = $event["contactData"];
$project = $event["projectData"];
header("Content-Type: application/json");

// 1) Before the conversation: tell meiti who this is.
if (in_array($event["eventType"], ["IncomingCallLookup", "NewConversation"], true)) {
    $customer = $crm->findByPhone($contact["phoneNumber"]);
    if ($customer === null) { http_response_code(204); exit; } // unknown: nothing to add
    echo json_encode([
        "requestContactUpdate" => true,
        "contactData" => [
            "crmContactId" => (string)$customer["id"], // ids are strings
            "firstName" => $customer["first_name"],
            "lastName" => $customer["last_name"],
            "crmAiInfo" => "Kundin seit {$customer['since']}. Bevorzugt Termine vormittags.",
        ],
    ]);
    exit;
}

// 2) After the conversation: file the summary, hand back your case number.
if (in_array($event["eventType"], ["FinishedCall", "ConversationPaused"], true)) {
    // crmContactId is null when the caller was unknown; currentSummary covers the whole project.
    $caseId = $crm->upsertCase($contact["crmContactId"] ?? null, $project["meitiProjectId"], $project["currentSummary"]);
    echo json_encode(["requestProjectUpdate" => true, "projectData" => ["crmProjectId" => (string)$caseId]]);
    exit;
}

http_response_code(204); // other or newer events: nothing to do
So sieht ein eingehendes Event aus
{
  "eventType": "IncomingCallLookup",
  "timestampUtc": "2026-10-08T09:14:03.512Z",
  "callbackUrl": "https://api.meiti.ai/api/webhook-callback/5f0c2e…",
  "contactData": {
    "meitiContactId": 18342,
    "phoneNumber": "+491701234567",
    "crmContactId": null,
    "language": "de",
    "firstName": null,
    "lastName": null,
    "crmAiInfo": null,
    "postalAddresses": []
  },
  "projectData": {
    "meitiProjectId": 67890,
    "crmProjectId": null,
    "projectName": "Anruf von +49 170 1234567",
    "currentSummary": null,
    "inquirySummary": null,
    "crmAiInfo": null
  }
}

Gekürzt: Kontakt- und Vorgangsdaten enthalten außerdem Adresse, eigene Felder und Zusammenfassungen. Unbekannte Anrufer haben noch keine crmContactId.

Akte existiert schon? Gib beim IncomingCallLookup zusätzlich requestProjectUpdate: true mit deiner crmProjectId zurück – dann trägt meitis Vorgang sofort deine Aktennummer. Mehrere meiti-Vorgänge können dieselbe Nummer tragen; ein Deep Link zeigt dann eine Auswahl. Mit projectData.crmAiInfo bekommt der Assistent auch einen Hinweis zur Akte selbst.

CRM-Webhook

Gesprächsnotiz und Vorgang ins CRM

Nach jedem Anruf kommt FinishedCall, nach 10 Minuten Chat-Pause ConversationPaused – beide mit projectData.currentSummary. Leg damit deinen Vorgang an und antworte mit crmProjectId: Ab dann trägt der meiti-Vorgang deine Nummer, und Deep Links wie ?crmId=AZ-2024%2F117 finden ihn. currentSummary fasst den ganzen Vorgang zusammen – ruft jemand zweimal an, wird sie fortgeschrieben. Willst du jeden Anruf einzeln protokollieren, speichere timestampUtc mit. Ohne KI-Zusammenfassung im Tarif bleibt das Feld leer.

Der Code steht im Rezept oben – Teil 2 desselben Handlers.

Ein Event komplett

So sieht ein Event mit bekanntem Kunden aus – mit jedem Feld, das meiti sendet. Felder ohne Wert kommen als null; appointmentData ist nur bei den Termin-Events gesetzt.

{
  "eventType": "FinishedCall",
  "timestampUtc": "2026-10-08T09:21:47.118Z",
  "callbackUrl": "https://api.meiti.ai/api/webhook-callback/9b41d7…",
  "contactData": {
    "meitiContactId": 18342,
    "phoneNumber": "+491701234567",
    "crmContactId": "K-1042",
    "language": "de",
    "city": "München",
    "addressLine1": "Lindenstraße 12",
    "postCode": "80331",
    "salutation": "Frau",
    "firstName": "Petra",
    "lastName": "Weber",
    "company": "Weber Immobilien GmbH",
    "email": "petra.weber@example.com",
    "customDate1": "2026-06-15",
    "customDate2": null,
    "customNumeric1": "42",
    "customNumeric2": null,
    "customString1": "Wartungsvertrag Gold",
    "customString2": null,
    "customString3": null,
    "customString4": null,
    "customString5": null,
    "customCheckBox1": true,
    "customCheckBox2": null,
    "customOptIn1": "2026-03-02T10:15:00",
    "customOptIn2": null,
    "customOptIn3": null,
    "crmInternalInfo": "Zahlt per Lastschrift.",
    "crmAiInfo": "Kundin seit 2019. Bevorzugt Termine vormittags.",
    "postalAddresses": [
      {
        "type": "general",
        "label": null,
        "isDefault": true,
        "addressLine1": "Lindenstraße 12",
        "addressLine2": null,
        "postalCode": "80331",
        "city": "München",
        "country": "DE"
      }
    ]
  },
  "projectData": {
    "meitiProjectId": 67890,
    "crmProjectId": "AZ-2024/117",
    "projectName": "Heizung Wartung",
    "address": "Lindenstraße 12",
    "city": "München",
    "postcode": "80331",
    "currentSummary": "Frau Weber meldet, dass die Heizung seit gestern nicht mehr warm wird, und bittet um einen Termin diese Woche.",
    "inquirySummary": "Heizung wird nicht warm, Termin gewünscht.",
    "crmInternalInfo": null,
    "crmAiInfo": "Wartungsvertrag läuft, Anfahrt ist inklusive."
  },
  "appointmentData": null
}

Deine Antwort – direkt mit 200 oder später an die callbackUrl – hat immer dieselbe Form: { requestContactUpdate, contactData, requestProjectUpdate, projectData }. Schick nur die Felder mit, die du ändern willst.

Felder und Längen

„Nur lesen“ heißt: meiti sendet das Feld, übernimmt es aber nicht aus deiner Antwort. Alle anderen Felder kannst du setzen – leere oder fehlende bleiben unverändert.

Kontakt (contactData)

FeldTypMax. ZeichenBedeutung
meitiContactIdint–meitis Kontakt-ID. Nur lesen.
phoneNumberstring–Telefonnummer des Kontakts. Nur lesen.
crmContactIdstring100Deine Kundennummer – immer als String.
salutationstring100Anrede, z. B. „Frau“.
firstNamestring100Vorname.
lastNamestring100Nachname.
companystring200Firma.
emailstring500E-Mail-Adresse.
addressLine1string200Straße und Hausnummer.
citystring80Ort.
postCodestring20Postleitzahl.
languagestring10Sprachcode, z. B. „de“ oder „en“. Nur lesen.
customString1–5string500Eigene Textfelder.
customDate1–2string100Eigene Datumsfelder (als Text).
customNumeric1–2string100Eigene Zahlenfelder (als Text).
customCheckBox1–2bool?–Eigene Ankreuzfelder.
customOptIn1–3DateTime?–Wann der Kunde einer Einwilligung zugestimmt hat.
crmInternalInfostring5000Interne Notiz – nicht für Kunden und nicht für den Assistenten.
crmAiInfostring5000Hinweis, den der Assistent in jedem Gespräch liest – kurz halten.
postalAddressesarray–Alle Anschriften, Standard zuerst (type: general, billing, jobSite, other). addressLine1, city und postCode spiegeln die Standard-Anschrift. Nur lesen.

Vorgang (projectData)

FeldTypMax. ZeichenBedeutung
meitiProjectIdint–meitis Vorgangs-ID – für Deep Links mit ?id=. Nur lesen.
crmProjectIdstring100Deine Vorgangs- oder Aktennummer – immer als String.
projectNamestring80Titel des Vorgangs.
addressstring100Straße und Hausnummer des Einsatzorts.
citystring100Ort.
postcodestring20Postleitzahl.
currentSummarystring5000KI-Zusammenfassung des ganzen Vorgangs, wird fortgeschrieben. Nur lesen.
inquirySummarystring5000KI-Zusammenfassung der ursprünglichen Anfrage. Nur lesen.
crmInternalInfostring5000Interne Notiz zum Vorgang – nicht für den Assistenten.
crmAiInfostring5000Hinweis zum Vorgang, den der Assistent liest – kurz halten.
  • Halte dich an die Längen: Eine Antwort an die callbackUrl mit zu langen Feldern lehnt meiti mit 400 Bad Request ab – die Fehlermeldung nennt die betroffenen Felder.
  • crmAiInfo liest der Assistent live in jedem Gespräch. Kurze, klare Hinweise halten die Antwortzeit niedrig – zwei, drei Sätze reichen.

Ohne Code: fertige n8n-Vorlagen

Du musst nichts selbst programmieren: Mit unseren n8n-Vorlagen verbindest du meiti mit deinem CRM.

  1. Vorlage herunterladen und in n8n importieren – n8n führt dich durch die Einrichtung.
  2. Im Setup-Dialog dein CRM einmal verbinden (Token einfügen). Alle Knoten nutzen diese Verbindung.
  3. Workflow aktivieren und die Production-URL des Webhook-Knotens kopieren.
  4. In meiti unter Einstellungen → Einrichtung & Betrieb → Verbundene Systeme → CRM & Fachsoftware → Webhook als Webhook-URL eintragen.

HubSpot

Sucht Kontakte per Telefonnummer (legt sie an, falls nötig) und verknüpft jede Anfrage mit einem offenen Vorgang – ein vorhandener wird wiederverwendet, sonst neu erstellt. Deals gibt es in jedem HubSpot-Tarif, Tickets nur mit Service Hub.

Ohne n8n geht es auch: HubSpot direkt verbinden – die Vorlage läuft dann bei Bedarf zusätzlich.

Bald verfügbar

Salesforce

Kontakte und Aktivitäten automatisch mit Salesforce abgleichen.

Pipedrive

Direkt verbunden, ohne n8n: Personen, Deals und eine Notiz pro Gespräch.

Zendesk

Direkt verbunden, ohne n8n: Endbenutzer, Tickets und eine interne Notiz pro Gespräch.

Dein CRM ist nicht dabei? Der Webhook nutzt ein offenes Format, damit lässt sich jedes CRM anbinden. Schreib uns per WhatsApp oder ruf an: +49 89 41434078 – wir erstellen gern eine Vorlage für dich.

Referenz

API-Webhooks

Nicht zu verwechseln mit dem CRM-Webhook: API-Webhooks abonnierst du per API, sie melden Termin-Ereignisse und sind signiert. Ein Abo gehört zu einem API-Schlüssel – also zu genau einem Postfach.

curl https://api.meiti.ai/v1/webhooks \
  -H "X-Api-Key: $MEITI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://crm.example.com/meiti/events",
    "events": ["appointment.confirmed", "appointment.cancelled", "appointment.rescheduled"],
    "description": "Kalender-Sync"
  }'
# The response contains "secret" exactly once — store it, it signs every delivery.
const res = await fetch("https://api.meiti.ai/v1/webhooks", {
  method: "POST",
  headers: { "X-Api-Key": process.env.MEITI_API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({
    url: "https://crm.example.com/meiti/events",
    events: ["appointment.confirmed", "appointment.cancelled", "appointment.rescheduled"],
    description: "Kalender-Sync",
  }),
});
const { subscription, secret } = await res.json(); // secret is shown exactly once
res = requests.post(
    "https://api.meiti.ai/v1/webhooks",
    headers={"X-Api-Key": os.environ["MEITI_API_KEY"]},
    json={
        "url": "https://crm.example.com/meiti/events",
        "events": ["appointment.confirmed", "appointment.cancelled", "appointment.rescheduled"],
        "description": "Kalender-Sync",
    },
)
secret = res.json()["secret"]  # shown exactly once
created, status, err := call("POST", "/v1/webhooks", map[string]any{
	"url":         "https://crm.example.com/meiti/events",
	"events":      []string{"appointment.confirmed", "appointment.cancelled", "appointment.rescheduled"},
	"description": "Kalender-Sync",
})
if err != nil || status != 201 {
	log.Fatal(status, err, created)
}
secret := created["secret"].(string) // shown exactly once — store it
saveSecret(secret)
var res = await http.PostAsJsonAsync("v1/webhooks", new
{
    url = "https://crm.example.com/meiti/events",
    events = new[] { "appointment.confirmed", "appointment.cancelled", "appointment.rescheduled" },
    description = "Kalender-Sync",
});
var secret = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("secret").GetString(); // shown exactly once
<?php
$ch = curl_init("https://api.meiti.ai/v1/webhooks");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["X-Api-Key: " . getenv("MEITI_API_KEY"), "Content-Type: application/json"],
    CURLOPT_POSTFIELDS => json_encode([
        "url" => "https://crm.example.com/meiti/events",
        "events" => ["appointment.confirmed", "appointment.cancelled", "appointment.rescheduled"],
        "description" => "Kalender-Sync",
    ]),
]);
$secret = json_decode(curl_exec($ch), true)["secret"]; // shown exactly once
EreignisWann
appointment.createdEin Termin wurde angelegt – in der App, vom Assistenten oder per API.
appointment.confirmedDer Kunde hat bestätigt. (Mit confirmed: true angelegte Termine melden nur appointment.created.)
appointment.rescheduledDer Termin wurde verschoben.
appointment.cancelledAbgesagt – vom Kunden oder vom Team.
appointment.finishedAls erledigt markiert.
appointment.reminder_sentDie Erinnerung ging raus.
  • Umschlag: { id, type, createdAt, workspaceId, data } – data ist der aktuelle Termin.
  • Header: X-Meiti-Signature: t=<Unix-Sekunden>,v1=<hex HMAC-SHA256(secret, t + "." + body)>, dazu X-Meiti-Event und X-Meiti-Delivery-Id. Lehne Zeitstempel ab, die älter als 5 Minuten sind.
  • Antworte mit 2xx. Sonst versucht meiti es erneut – bis zu 8 Versuche insgesamt, Abstände von 1 Minute bis 12 Stunden. Bleiben 50 Ereignisse in Folge unzustellbar, wird das Abo deaktiviert; leg dann ein neues an.
  • Erkenne Wiederholungen an X-Meiti-Delivery-Id. Zeiten ohne Zeitzone (2026-10-14T16:02:11.48) sind UTC; startLocal ist die Ortszeit des Postfachs.

Signatur prüfen

Jede Zustellung ist signiert. Prüf die Signatur über den unveränderten Body, bevor du etwas änderst – mit dem Secret, das du beim Abonnieren bekommen hast:

// Node + Express
import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.MEITI_WEBHOOK_SECRET;

// Raw body: the signature covers the exact bytes meiti sent.
app.post("/meiti/events", express.raw({ type: "application/json" }), (req, res) => {
  const sig = Object.fromEntries((req.get("X-Meiti-Signature") ?? "").split(",").map(p => p.split("=")));
  const expected = crypto.createHmac("sha256", secret).update(`${sig.t}.${req.body}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
  const valid = sig.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig.v1), Buffer.from(expected));
  if (!fresh || !valid) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  if (event.type === "appointment.confirmed") {
    calendar.markConfirmed(event.data.externalId); // your own id from POST /v1/appointments
  }
  res.sendStatus(200);
});

app.listen(3000);
# Python + Flask
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["MEITI_WEBHOOK_SECRET"].encode()

@app.post("/meiti/events")
def meiti_events():
    sig = dict(p.split("=", 1) for p in request.headers.get("X-Meiti-Signature", "").split(",") if "=" in p)
    t, v1 = sig.get("t", ""), sig.get("v1", "")
    body = request.get_data()  # the exact bytes meiti signed
    expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
    if not t.isdigit() or abs(time.time() - int(t)) > 300 or not hmac.compare_digest(v1, expected):
        abort(401)

    event = json.loads(body)
    if event["type"] == "appointment.confirmed":
        calendar.mark_confirmed(event["data"]["externalId"])
    return "", 200
// net/http. calendar stands for your own code.
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func meitiEvents(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body) // the exact bytes meiti signed
	var t, v1 string
	for _, part := range strings.Split(r.Header.Get("X-Meiti-Signature"), ",") {
		k, v, _ := strings.Cut(part, "=")
		switch k {
		case "t":
			t = v
		case "v1":
			v1 = v
		}
	}
	unix, err := strconv.ParseInt(t, 10, 64)
	mac := hmac.New(sha256.New, []byte(os.Getenv("MEITI_WEBHOOK_SECRET")))
	mac.Write([]byte(t + "."))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	age := time.Since(time.Unix(unix, 0))
	if err != nil || age > 5*time.Minute || age < -5*time.Minute || !hmac.Equal([]byte(expected), []byte(v1)) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	var event struct {
		Type string `json:"type"`
		Data struct {
			ExternalID string `json:"externalId"`
		} `json:"data"`
	}
	_ = json.Unmarshal(body, &event)
	if event.Type == "appointment.confirmed" {
		calendar.MarkConfirmed(event.Data.ExternalID)
	}
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("POST /meiti/events", meitiEvents)
	http.ListenAndServe(":3000", nil)
}
// ASP.NET Core minimal API (works from .NET 8 on). calendar stands for your own code.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

app.MapPost("/meiti/events", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var body = buffer.ToArray(); // the exact bytes meiti signed

    var sig = request.Headers["X-Meiti-Signature"].ToString().Split(',')
        .Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToDictionary(p => p[0], p => p[1]);
    if (!sig.TryGetValue("t", out var t) || !long.TryParse(t, out var unix) || !sig.TryGetValue("v1", out var v1)
        || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - unix) > 300)
        return Results.Unauthorized();

    var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("MEITI_WEBHOOK_SECRET")!);
    var signed = Encoding.UTF8.GetBytes(t + ".").Concat(body).ToArray();
    var expected = Convert.ToHexString(HMACSHA256.HashData(secret, signed)).ToLowerInvariant();
    if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(v1)))
        return Results.Unauthorized();

    using var evt = JsonDocument.Parse(body);
    if (evt.RootElement.GetProperty("type").GetString() == "appointment.confirmed")
        calendar.MarkConfirmed(evt.RootElement.GetProperty("data").GetProperty("externalId").GetString());
    return Results.Ok();
});
<?php
$body = file_get_contents("php://input"); // the exact bytes meiti signed
parse_str(str_replace(",", "&", $_SERVER["HTTP_X_MEITI_SIGNATURE"] ?? ""), $sig);
$t = $sig["t"] ?? "";
$expected = hash_hmac("sha256", $t . "." . $body, getenv("MEITI_WEBHOOK_SECRET"));
if (!ctype_digit($t) || abs(time() - (int)$t) > 300 || !hash_equals($expected, $sig["v1"] ?? "")) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
if ($event["type"] === "appointment.confirmed") {
    $calendar->markConfirmed($event["data"]["externalId"]);
}
http_response_code(200);
So sieht ein Termin-Event aus
{
  "id": "48211",
  "type": "appointment.confirmed",
  "createdAt": "2026-10-14T16:02:11.4823311",
  "workspaceId": 245,
  "data": {
    "id": 5120,
    "externalId": "CAL-8812",
    "metadata": { "technician": "Jonas" },
    "contactId": 18342,
    "projectId": 67890,
    "title": "Heizungswartung",
    "start": "2026-10-20T08:00:00",
    "startLocal": "2026-10-20T10:00:00",
    "durationMinutes": 60,
    "kind": "in_person",
    "status": "confirmed",
    "customerConfirmedAt": "2026-10-14T16:02:10.9910824",
    "businessConfirmedAt": "2026-10-14T09:30:00.1270017",
    "cancelledAt": null,
    "finishedAt": null,
    "rescheduledCount": 0,
    "createdAt": "2026-10-14T09:30:00.1270017",
    "updatedAt": "2026-10-14T16:02:11.4510342"
  }
}

Dasselbe Verfahren nutzt auch Eigene KI (BYOAI) – dort mit dem Schlüssel des Assistenten.

Lokal testen

Lokal entwickeln: Mach deinen Server mit einem Tunnel erreichbar, z. B. cloudflared tunnel --url http://localhost:3000 oder ngrok http 3000, trag die Adresse als Webhook-URL ein und löse in der App über die Vorgangsdetails ein Manual-Event aus.
  • CRM-Webhook: In den Vorgangsdetails der App löst der Webhook-Button ein Manual-Event für diesen Vorgang aus.
  • API-Webhooks: POST /v1/webhooks/{id}/test schickt eine Test-Zustellung an dein Abo.

Fragen zur Anbindung?

Wir helfen dir und deiner IT bei der Planung, schauen auf den ersten Entwurf und nehmen Wünsche für die API auf.