DE

Webhooks

Learn right away what happens in meiti.

A call comes in, a chat pauses, a customer confirms an appointment: meiti tells your system before your team has to ask. The CRM webhook also fetches context from your CRM — the API webhooks report every appointment change, signed.

  • Conversation
  • Webhook from meiti
  • Your system's answer
  • API call
Customer Phone · WhatsApp · chat
meiti Assistant & inbox
Your system CRM · ERP · calendar
  1. Call from +49 170 1234567 Ingrid Weber calls. meiti answers.
  2. POST · IncomingCallLookup CRM webhook: meiti asks your system who is calling — while it still rings.
  3. 200 · firstName, crmAiInfo Your system answers within 2 seconds with the name and a short note.
  4. “Hello Ms Weber! Is this about your heating?” The assistant knows the customer and handles the conversation.
  5. POST · FinishedCall After the call the summary goes to your system.
  6. 200 · crmProjectId: "AZ-2024/117" Your system files the case and returns its number.
  7. POST /v1/appointments API: your calendar plans the appointment, meiti invites the customer.
  8. Appointment Tue, 9:00 — does that work? The invitation arrives on WhatsApp, with a button to confirm.
  9. ✓ Confirmed Ms Weber taps “Confirm”.
  10. appointment.confirmed Signed API webhook: your calendar learns it right away.
One call, from the first second to the confirmed appointment — and everywhere your system takes part.

Two webhooks, two jobs

CRM-WebhookAPI webhooks
ReportsCalls, chats, forms, appointmentsAppointments
Set upin the app, one URL per inboxvia the API (POST /v1/webhooks), per key
SecurityAuthorization: Bearer <token>Signature X-Meiti-Signature (HMAC)
Answercan return data (names, numbers, notes)2xx only, no data
Spot repeatscallbackUrlX-Meiti-Delivery-Id

Should your own AI answer itself? That is neither webhook but BYOAI.

Reference

CRM-Webhook

The CRM webhook tells your system what happens in meiti and takes your answer: on every call, chat, form and appointment your server gets an event — and can answer with the name, customer number and notes for the assistant.

Set up in the app under Settings → Setup & operations → Connected systems → CRM & business software → Webhook: URL and bearer token. Every inbox has its own webhook — the event itself does not name the inbox, so give each inbox its own URL or token. meiti sends every event as a POST with Authorization: Bearer <your token>.

On top of your CRM: with HERO, Pipedrive, HubSpot or Zendesk connected directly, add the webhook under CRM & business software → Webhook in addition and get the same events. The customer and case numbers then belong to the CRM: crmContactId and crmProjectId in your answers are ignored, every other field is applied.

eventTypeWhen
IncomingCallLookupOn every incoming call — also when the customer calls again about an open project.
FinishedCallAfter every call, with the summary.
NewConversationA chat starts (new project, or first message after at least one hour).
ConversationPaused10 minutes without chat activity.
FormSubmittedThe customer filled in a form.
AppointmentCreated, …Confirmed, …Rescheduled, …Cancelled, …Finished, …ReminderSentAn appointment was created, confirmed by the customer, moved, cancelled, finished or reminded — whether in the app, by the assistant, on the customer's appointment page or via the API. Comes with appointmentData in the same shape as the API.
ManualTriggered manually from the project details in the app — handy for testing.
  • eventType is the name as a string. Ignore unknown values — new ones will be added. Transition: until October 2026 this was a number (0 = IncomingCallLookup, 1 = FinishedCall, 2 = NewConversation, 3 = ConversationPaused, 4 = Manual, 5 = FormSubmitted). Existing integrations should accept both for a while, e.g. const name = typeof e === "number" ? ["IncomingCallLookup", "FinishedCall", "NewConversation", "ConversationPaused", "Manual", "FormSubmitted"][e] : e;
  • Answer now (200 + data, within 2 seconds) or later (202, then POST the same data to the one-time callbackUrl, valid for 5 minutes). 204 = nothing to change. A later answer is used from then on — a conversation already running does not restart.
  • Only what you mark with requestContactUpdate: true or requestProjectUpdate: true is applied. Ids like crmContactId are always strings.
  • If your server answers too slowly (over 2 seconds) or with an error, meiti delivers the same event — with the same callbackUrl — once more, then with 30 seconds to answer. A 2xx answer (including 202/204) is not repeated. Recognise repeats by their callbackUrl.
  • Only fields with values are applied; empty ones stay unchanged. The assistant reads crmAiInfo in every conversation — keep it short (max. 5000 characters).
Slow CRM? Answer later via callbackUrl
# 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.
  • The CRM webhook is included in every plan — without a quota.
  • Prefer no code? Ready-made n8n templates (e.g. HubSpot) are further down.

All fields and limits

Recipes

The CRM webhook in practice

In the examples crm stands for your own code.

CRM-Webhook

Recognise callers and give the assistant context

“Ms Weber, thanks for calling — how can I help?” On every call meiti sends IncomingCallLookup with the phone number. If your system knows the customer, answer directly with name, customer number and a note for the assistant. The same handler also answers NewConversation (chat) and receives the summary afterwards — see the next recipe.

// 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
What an incoming event looks like
{
  "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
  }
}

Shortened: contact and project data also carry address, custom fields and summaries. Unknown callers have no crmContactId yet.

Case already exists? Also return requestProjectUpdate: true with your crmProjectId on IncomingCallLookup — meiti's project carries your case number right away. Several meiti projects can carry the same number; a deep link then shows a list. With projectData.crmAiInfo the assistant also gets a note about the case itself.

CRM-Webhook

Call notes and the case into your CRM

After every call FinishedCall arrives, after a 10-minute chat pause ConversationPaused — both with projectData.currentSummary. File your case with it and answer with crmProjectId: from then on the meiti project carries your number, and deep links like ?crmId=AZ-2024%2F117 find it. currentSummary summarises the whole project — if someone calls twice, it is extended. To log every call separately, store timestampUtc with it. Without AI summaries in the plan the field stays empty.

The code is in the recipe above — part 2 of the same handler.

A complete event

This is an event for a known customer — with every field meiti sends. Fields without a value arrive as null; appointmentData is only set on the appointment events.

{
  "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
}

Your answer — directly with 200 or later to the callbackUrl — always has the same shape: { requestContactUpdate, contactData, requestProjectUpdate, projectData }. Only send the fields you want to change.

Fields and limits

“Read only” means: meiti sends the field but does not take it from your answer. You can set every other field — empty or missing ones stay unchanged.

Contact (contactData)

FieldTypeMax. charactersMeaning
meitiContactIdint–meiti's contact id. Read only.
phoneNumberstring–The contact's phone number. Read only.
crmContactIdstring100Your customer number — always a string.
salutationstring100Salutation, e.g. “Frau”.
firstNamestring100First name.
lastNamestring100Last name.
companystring200Company.
emailstring500Email address.
addressLine1string200Street and house number.
citystring80City.
postCodestring20Postcode.
languagestring10Language code, e.g. “de” or “en”. Read only.
customString1–5string500Custom text fields.
customDate1–2string100Custom date fields (as text).
customNumeric1–2string100Custom number fields (as text).
customCheckBox1–2bool?–Custom checkboxes.
customOptIn1–3DateTime?–When the customer gave a consent.
crmInternalInfostring5000Internal note — not for customers and not for the assistant.
crmAiInfostring5000Note the assistant reads in every conversation — keep it short.
postalAddressesarray–Every postal address, default first (type: general, billing, jobSite, other). addressLine1, city and postCode mirror the default one. Read only.

Project (projectData)

FieldTypeMax. charactersMeaning
meitiProjectIdint–meiti's project id — for deep links with ?id=. Read only.
crmProjectIdstring100Your case number — always a string.
projectNamestring80Title of the project.
addressstring100Street and house number of the job site.
citystring100City.
postcodestring20Postcode.
currentSummarystring5000AI summary of the whole project, kept up to date. Read only.
inquirySummarystring5000AI summary of the original enquiry. Read only.
crmInternalInfostring5000Internal note on the project — not for the assistant.
crmAiInfostring5000Note on the project the assistant reads — keep it short.
  • Stay within the limits: an answer to the callbackUrl with fields that are too long is refused with 400 Bad Request — the error names the fields.
  • The assistant reads crmAiInfo live in every conversation. Short, clear notes keep the response time low — two or three sentences are enough.

No code: ready-made n8n templates

You don't have to write code: our n8n templates connect meiti to your CRM.

  1. Download the template and import it into n8n — n8n guides you through the setup.
  2. Connect your CRM once in the setup dialog (paste the token). Every node uses that connection.
  3. Activate the workflow and copy the webhook node's production URL.
  4. Enter it as the webhook URL in meiti under Settings → Setup & operations → Connected systems → CRM & business software → Webhook.

HubSpot

Finds contacts by phone number (creates them if needed) and links every enquiry to an open record — an existing one is reused, otherwise a new one is created. Deals exist on every HubSpot tier, tickets only with Service Hub.

No n8n needed either: connect HubSpot directly — the template can still run on top.

Coming soon

Salesforce

Keep contacts and activities in sync with Salesforce automatically.

Pipedrive

Connected directly, no n8n: persons, deals and one note per conversation.

Zendesk

Connected directly, no n8n: end users, tickets and one internal note per conversation.

Your CRM isn't listed? The webhook uses an open format, so any CRM can be connected. Message us on WhatsApp or call +49 89 41434078 — we're happy to build a template for you.

Reference

API webhooks

Not to be confused with the CRM webhook: API webhooks are subscribed via the API, report appointment events and are signed. A subscription belongs to an API key — so to exactly one inbox.

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
EventWhen
appointment.createdAn appointment was created — in the app, by the assistant or via the API.
appointment.confirmedThe customer confirmed. (Appointments created with confirmed: true only report appointment.created.)
appointment.rescheduledThe appointment was moved.
appointment.cancelledCancelled — by the customer or the team.
appointment.finishedMarked as finished.
appointment.reminder_sentThe reminder went out.
  • Envelope: { id, type, createdAt, workspaceId, data } — data is the current appointment.
  • Headers: X-Meiti-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)>, plus X-Meiti-Event and X-Meiti-Delivery-Id. Reject timestamps older than 5 minutes.
  • Answer with 2xx. Otherwise meiti tries again — up to 8 attempts in total, gaps from 1 minute to 12 hours. If 50 events in a row stay undeliverable, the subscription is disabled; create a new one then.
  • Recognise repeats by X-Meiti-Delivery-Id. Times without a zone (2026-10-14T16:02:11.48) are UTC; startLocal is the inbox's local time.

Verify the signature

Every delivery is signed. Verify the signature over the unchanged body before you change anything — with the secret you got when subscribing:

// 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);
What an appointment event looks like
{
  "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"
  }
}

BYOAI uses the same scheme — there with the assistant's secret.

Test locally

Develop locally: expose your server through a tunnel, e.g. cloudflared tunnel --url http://localhost:3000 or ngrok http 3000, enter that address as webhook URL and trigger a Manual event from the project details in the app.
  • CRM webhook: in the app's project details, the webhook button triggers a Manual event for that project.
  • API webhooks: POST /v1/webhooks/{id}/test sends a test delivery to your subscription.

Questions about your integration?

We help you and your IT with planning, review the first draft and take requests for the API.