DE

Connect your CRM & software

Connect meiti to your system — in one afternoon.

meiti answers calls, WhatsApp, chats and email for your team. Three building blocks make it part of your CRM, ERP or shop: your system gives meiti context, meiti writes results back, and your team jumps between both with one click.

  • The assistant greets regulars by name and knows where they stand.
  • Every conversation note lands in your customer record automatically.
  • Your team opens the case from the CRM with one click — or calls right away.

What you do in the app — your IT does the rest

  1. API key: Settings → Setup & operations → Connected systems → API access → create key.
  2. CRM webhook: Settings → Setup & operations → Connected systems → CRM & business software → Webhook — your IT enters URL and token.
  3. Hand key and token over safely — not by plain email, e.g. via a password manager.

Prerequisite: your software can receive webhooks or show its own links/buttons — and someone can write a small program for it.

The API is in beta: it runs stably, details may still change before the final version — every change is listed in the changelog.

Questions, or unsure whether your software fits? Message us on WhatsApp or call +49 89 41434078 — we also talk to your IT directly.

How a good meiti integration fits together

Your system stays the system of record for customers, cases and invoices. meiti is where conversations happen. A good integration closes the loop in four steps:

  • 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.
  1. 1

    Context in

    A call or chat starts. meiti sends IncomingCallLookup or NewConversation; your system answers with the name and a short note in crmAiInfo.

  2. 2

    meiti handles the conversation

    The assistant knows the customer, answers questions, takes requests and hands over to your team when needed.

  3. 3

    Results back

    After the conversation, FinishedCall or ConversationPaused arrives with the summary. Your system files the case and returns its number as crmProjectId.

  4. 4

    Keep working — both ways

    Your team opens the case via deep link with your own number. Your system sends messages and appointments via the API — they land in the same conversation.

Link ids once

Give meiti your numbers (crmContactId, crmProjectId) and keep meiti's (meitiContactId, meitiProjectId). From then on each side finds the other.

Answer briefly and fast

The assistant reads crmAiInfo live — two or three sentences are enough. Answer within 2 seconds directly; anything longer via callback.

Survive retries

Webhooks can arrive twice, networks drop. Process idempotently and send with an Idempotency-Key.

Respect consent

Tell meiti what a message is with purpose. Promotions only go out with marketing consent.

Only share what anyone may hear

Caller ids can be spoofed. Put nothing into crmAiInfo the assistant must not tell a stranger — such as amounts or health data.

CRM webhook or API webhooks? Both are on the Webhooks page, with a comparison, fields and signature.

What happens in meiti

Who gets what — step by step

Pick a flow and step through it: on the left what your system sends or receives, in the middle what your team sees in the meiti app, on the right what reaches the customer.

The texts show what an inbox with its own WhatsApp number sends (formal form). Via the shared meiti number templates arrive in informal form under the sender “meiti”.

Your system
Team in the meiti app
Customer

All flows as text

A call comes in

CRM webhook: your system tells meiti who is calling — and gets the summary back afterwards.

  1. Immediately – Ms Weber calls. The team gets a push. If she calls again about an open project, “Incoming call” appears in the conversation.
    • Team: Call · +49 170 1234567
    • Customer: 📞 calling Heizung Weber …
  2. Immediately, max. 2 seconds – meiti asks your system: who is this?. meiti waits up to 2 seconds for your answer before the assistant greets. Whatever comes back with 200 in that time, it knows right away.
    • Event to you: POST /meiti/webhook · IncomingCallLookup
    • Response: 200 OK
    • Team: Webhook (IncomingCallLookup) sending...
    • Team: Webhook (IncomingCallLookup) succeeded. Data was updated.
  3. During the call – The assistant takes over. With the context your system delivered. Team members who turned on live-transcript pushes read every utterance of the caller.
    • Team: “I wanted to ask about the heating service …”
    • Customer gets: Good morning, this is Heizung Weber. How can I help you?
    • Customer writes: I wanted to ask about the heating service …
  4. After the call, about 1 minute later – Summary back into your system. With a recording, FinishedCall comes after the transcription. If you answer with crmProjectId, the project carries your case number from now on.
    • Event to you: POST /meiti/webhook · FinishedCall
    • Response: 200 OK
    • Team: Webhook (FinishedCall) succeeded. Data was updated.
  5. Any time after – From the CRM straight into the project. One click on the link in your CRM opens the project in the meiti app — in another inbox too (after asking).
    • Request: https://m.meiti.ai/open/project?crmId=AZ-2024%2F117
    • Team: Project “Heizungswartung” opened

Appointment for confirmation

API: POST /v1/appointments with confirmed: false — meiti invites, the customer confirms in the chat.

  1. Immediately – Your system creates the appointment. If the WhatsApp window is closed, the invitation goes out as an approved template. The appointment events go to your webhooks.
    • Request: POST /v1/appointments
    • Response: 201 Created
    • Event to you: appointment.created · AppointmentCreated
    • Team: New appointment (#4K7Q) 20.10.26 10:00
    • Customer gets: You have received an appointment invitation 📆 (#4K7Q) from Heizung Weber. Please review it now. [review]
  2. Customer taps “review” – The invitation with all details. Confirmation happens in the chat — via the buttons.
    • Customer: Customer taps “review”
    • Customer gets: Appointment invitation (#4K7Q) with Heizung Weber Heizungswartung 📅 Tue 20.10.26 10:00 - 11:00 [Accept ✅ | Decline ❌]
  3. Customer taps “Accept” – Confirmed — everyone hears about it. The team gets a push, your system appointment.confirmed or AppointmentConfirmed. If the customer declines, meiti asks once more — only then appointment.cancelled arrives.
    • Event to you: appointment.confirmed · AppointmentConfirmed
    • Team: The customer confirmed the appointment (#4K7Q)
    • Team: Ingrid Weber · confirmed the appointment.
    • Customer: Customer taps “Accept ✅”
    • Customer gets: Your appointment (#4K7Q) is confirmed ✅ Tue 20.10.26 10:00 - 11:00 [Manage appointment 📆 | Save appointment 📩]
  4. 24 hours before – Reminder to the customer. Reminders come with your own WhatsApp number once both sides have confirmed. Default: 24 hours and 30 minutes before, configurable in the calendar.
    • Event to you: appointment.reminder_sent · AppointmentReminderSent
    • Customer gets: Reminder: Your appointment (#4K7Q) is on Tue 20.10.26 10:00. We look forward to seeing you. [Manage appointment]
  5. 30 minutes before – Second reminder.
    • Event to you: appointment.reminder_sent · AppointmentReminderSent
    • Customer gets: Reminder: Your appointment (#4K7Q) is on Tue 20.10.26 10:00. We look forward to seeing you. [Manage appointment]

Appointment already agreed

API: POST /v1/appointments with confirmed: true — for example when the time was already agreed on the phone.

  1. Immediately – Confirmation instead of invitation. The appointment is confirmed by both sides right away. That is why only appointment.created arrives — no appointment.confirmed.
    • Request: POST /v1/appointments
    • Response: 201 Created
    • Event to you: appointment.created · AppointmentCreated
    • Team: New appointment (#4K7Q) 20.10.26 10:00
    • Customer gets: Your appointment (#4K7Q) is confirmed ✅ Tue 20.10.26 10:00 - 11:00 [Manage appointment 📆 | Save appointment 📩]
  2. Customer taps “Save appointment” – Into the calendar. The customer gets a link to the appointment page and an .ics file.
    • Customer: Customer taps “Save appointment 📩”
    • Customer gets: Save appointment (#4K7Q) to your calendar. 📎 Termin.ics
  3. 24 hours before – Reminder. As above: with your own WhatsApp number, default 24 hours and 30 minutes before.
    • Event to you: appointment.reminder_sent · AppointmentReminderSent
    • Customer gets: Reminder: Your appointment (#4K7Q) is on Tue 20.10.26 10:00. We look forward to seeing you. [Manage appointment]

Move & cancel an appointment

API: PATCH and DELETE /v1/appointments/{id} — what the customer gets.

  1. Immediately – New time. Without confirmed: true the customer has to confirm again — the confirmation is reset. With confirmed: true he gets “Your appointment was moved 📆 Before … New …” instead.
    • Request: PATCH /v1/appointments/5120
    • Event to you: appointment.rescheduled · AppointmentRescheduled
    • Team: Appointment (#4K7Q) moved. new time: 22.10.26 09:00
    • Customer gets: ‼️ATTENTION‼️, we had to reschedule the 📆 appointment (#4K7Q). Please book it again now! [review]
  2. Later – Cancellation by your system. If you pass message, it follows as a message of its own right after the cancellation.
    • Request: DELETE /v1/appointments/5120
    • Event to you: appointment.cancelled · AppointmentCancelled
    • Team: Appointment (#4K7Q) cancelled
    • Customer gets: ATTENTION, your appointment (#4K7Q) was cancelled.
    • Customer gets: Unser Techniker ist leider krank. Wir melden uns mit einem neuen Termin.

Send a message

API: POST /v1/messages — here with a closed WhatsApp window, the most common case for a first message.

  1. Immediately – Your system sends. The message shows in the inbox like a reply from your team (from the key's creator). The assistant does not take over automatically afterwards. The customer first gets a short notification.
    • Request: POST /v1/messages
    • Response: 202 Accepted
    • Team: Hallo Frau Weber, Ihre Rechnung 2024-117 ist fertig.
    • Customer gets: Hi Ingrid, You've received updates from Heizung Weber regarding your request. Check them out now! [let's see]
  2. Customer taps – The actual message arrives. With an approved WhatsApp template in the request the content arrives right away — without this step.
    • Customer: Customer taps “let's see”
    • Customer gets: Hallo Frau Weber, Ihre Rechnung 2024-117 ist fertig.
  3. When the customer replies – The reply lands in the inbox. Your team answers in the app — or your system reads the transcript with GET /v1/projects/{id}/messages.
    • Request: GET /v1/projects/67890/messages
    • Team: New reply from Ingrid Weber
    • Customer writes: Thanks! Can I pay by bank transfer?

Your first API call in 5 minutes

  1. Create an API key

    In the meiti app: Settings → Setup & operations → Connected systems → API access, create a key — the API is available in every inbox. It belongs to exactly one inbox and is shown only once — store it as MEITI_API_KEY in your environment.

  2. Check the key

    GET /v1/me is the simplest test: it shows which inbox the key belongs to.

    curl https://api.meiti.ai/v1/me \
      -H "X-Api-Key: $MEITI_API_KEY"
    const res = await fetch("https://api.meiti.ai/v1/me", {
      headers: { "X-Api-Key": process.env.MEITI_API_KEY },
    });
    console.log(await res.json()); // { workspaceId, workspaceName, keyName }
    import os, requests
    
    res = requests.get(
        "https://api.meiti.ai/v1/me",
        headers={"X-Api-Key": os.environ["MEITI_API_KEY"]},
    )
    print(res.json())  # {'workspaceId': ..., 'workspaceName': ..., 'keyName': ...}
    // main.go — go run main.go
    package main
    
    import (
    	"bytes"
    	"encoding/json"
    	"fmt"
    	"io"
    	"net/http"
    	"os"
    )
    
    // call sends JSON to the meiti API and decodes the JSON answer. Extra args are header name/value pairs.
    func call(method, path string, in any, headers ...string) (map[string]any, int, error) {
    	var body io.Reader
    	if in != nil {
    		b, err := json.Marshal(in)
    		if err != nil {
    			return nil, 0, err
    		}
    		body = bytes.NewReader(b)
    	}
    	req, err := http.NewRequest(method, "https://api.meiti.ai"+path, body)
    	if err != nil {
    		return nil, 0, err
    	}
    	req.Header.Set("X-Api-Key", os.Getenv("MEITI_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	for i := 0; i+1 < len(headers); i += 2 {
    		req.Header.Set(headers[i], headers[i+1])
    	}
    	res, err := http.DefaultClient.Do(req)
    	if err != nil {
    		return nil, 0, err
    	}
    	defer res.Body.Close()
    	var out map[string]any
    	_ = json.NewDecoder(res.Body).Decode(&out)
    	return out, res.StatusCode, nil
    }
    
    func main() {
    	me, status, err := call("GET", "/v1/me", nil)
    	if err != nil {
    		panic(err)
    	}
    	fmt.Println(status, me) // 200 map[keyName:… workspaceId:… workspaceName:…]
    }
    using System.Net.Http.Json;
    using System.Text.Json;
    
    using var http = new HttpClient { BaseAddress = new Uri("https://api.meiti.ai/") };
    http.DefaultRequestHeaders.Add("X-Api-Key", Environment.GetEnvironmentVariable("MEITI_API_KEY"));
    
    var me = await http.GetFromJsonAsync<JsonElement>("v1/me");
    Console.WriteLine(me); // { "workspaceId": …, "workspaceName": …, "keyName": … }
    <?php
    $ch = curl_init("https://api.meiti.ai/v1/me");
    curl_setopt_array($ch, [
        CURLOPT_HTTPHEADER => ["X-Api-Key: " . getenv("MEITI_API_KEY")],
        CURLOPT_RETURNTRANSFER => true,
    ]);
    $me = json_decode(curl_exec($ch), true);
    print_r($me); // workspaceId, workspaceName, keyName
  3. Add yourself as a contact

    Messages go to contacts of the inbox. Add yourself with your mobile number — 201 means new, 200 means: already there.

    curl https://api.meiti.ai/v1/contacts \
      -H "X-Api-Key: $MEITI_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "phone": "+491701234567", "firstName": "Test", "lastName": "Ich" }'
    const res = await fetch("https://api.meiti.ai/v1/contacts", {
      method: "POST",
      headers: { "X-Api-Key": process.env.MEITI_API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({ phone: "+491701234567", firstName: "Test", lastName: "Ich" }),
    });
    console.log(res.status, await res.json()); // 201 (new) or 200 (already there) { id, … }
    import os, requests
    
    res = requests.post(
        "https://api.meiti.ai/v1/contacts",
        headers={"X-Api-Key": os.environ["MEITI_API_KEY"]},
        json={"phone": "+491701234567", "firstName": "Test", "lastName": "Ich"},
    )
    print(res.status_code, res.json())  # 201 (new) or 200 (already there)
    // call() from step 2
    contact, status, err := call("POST", "/v1/contacts", map[string]any{
    	"phone": "+491701234567", "firstName": "Test", "lastName": "Ich",
    })
    fmt.Println(status, contact, err) // 201 (new) or 200 (already there)
    // http from step 2 (BaseAddress + X-Api-Key)
    var res = await http.PostAsJsonAsync("v1/contacts", new { phone = "+491701234567", firstName = "Test", lastName = "Ich" });
    Console.WriteLine($"{(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}"); // 201 (new) or 200 (already there)
    <?php
    $ch = curl_init("https://api.meiti.ai/v1/contacts");
    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(["phone" => "+491701234567", "firstName" => "Test", "lastName" => "Ich"]),
    ]);
    $body = curl_exec($ch);
    echo curl_getinfo($ch, CURLINFO_HTTP_CODE) . " " . $body; // 201 (new) or 200 (already there)
  4. Send your first message

    curl https://api.meiti.ai/v1/messages \
      -H "X-Api-Key: $MEITI_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "phone": "+491701234567", "text": "Hallo aus meiner ersten Integration!" }'
    const res = await fetch("https://api.meiti.ai/v1/messages", {
      method: "POST",
      headers: { "X-Api-Key": process.env.MEITI_API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({ phone: "+491701234567", text: "Hallo aus meiner ersten Integration!" }),
    });
    console.log(res.status, await res.json()); // 202 { status, channel, waitingForReply, … }
    import os, requests
    
    res = requests.post(
        "https://api.meiti.ai/v1/messages",
        headers={"X-Api-Key": os.environ["MEITI_API_KEY"]},
        json={"phone": "+491701234567", "text": "Hallo aus meiner ersten Integration!"},
    )
    print(res.status_code, res.json())  # 202 {status, channel, waitingForReply, ...}
    // call() from step 2
    sent, status, err := call("POST", "/v1/messages", map[string]any{
    	"phone": "+491701234567",
    	"text":  "Hallo aus meiner ersten Integration!",
    })
    fmt.Println(status, sent, err) // 202 map[channel:… status:queued waitingForReply:…]
    // http from step 2 (BaseAddress + X-Api-Key)
    var res = await http.PostAsJsonAsync("v1/messages", new { phone = "+491701234567", text = "Hallo aus meiner ersten Integration!" });
    Console.WriteLine($"{(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}"); // 202 { status, channel, waitingForReply, … }
    <?php
    $ch = curl_init("https://api.meiti.ai/v1/messages");
    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(["phone" => "+491701234567", "text" => "Hallo aus meiner ersten Integration!"]),
    ]);
    $body = curl_exec($ch);
    echo curl_getinfo($ch, CURLINFO_HTTP_CODE) . " " . $body; // 202 { status, channel, waitingForReply, … }

    The answer comes right away with 202. The interesting part is waitingForReply:

    {
      "projectId": 67890,
      "contactId": 18342,
      "messageId": 991245,
      "status": "queued",
      "channel": "whatsapp",
      "senderId": "whatsapp_3",
      "sentAsTemplate": false,
      "waitingForReply": true,
      "consentOverridden": false
    }

    Why does it say “waitingForReply: true”?

    WhatsApp only allows free text when the customer wrote within the last 24 hours. If that window is closed, meiti automatically sends a short notification with a button. When the customer taps it, the window opens and your message follows right away — nothing is lost.

    If the content must arrive directly, pass an approved WhatsApp template (whatsApp.template, listed under GET /v1/whatsapp/templates) — the answer then says sentAsTemplate: true. With your own WhatsApp number your first test shows exactly this notification, otherwise a similar one from meiti.

Tip: to test, set up an inbox or an organisation of your own used only for testing and create an API key there. Every key belongs to exactly one inbox.

Recipes

Typical integrations to copy

In every example crm, customer, invoice, caseRecord and calendar stand for your own code.

Recognise callers and file call notes in your CRM — the CRM webhook recipes, plus local testing through a tunnel, are on the Webhooks page.
API

Message from your system: “Your invoice is ready”

Your system knows the moment, meiti knows the channel. Send a phone number, email or contactId plus the text — meiti picks WhatsApp, SMS, RCS or email, opens a new project when needed and shows the message in the inbox. It counts as a reply from your team: the assistant does not take over automatically afterwards; your team answers in the inbox.

curl https://api.meiti.ai/v1/messages \
  -H "X-Api-Key: $MEITI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-2024-117-ready" \
  -d '{
    "phone": "+491701234567",
    "text": "Hallo Frau Weber, Ihre Rechnung 2024-117 ist fertig. Fragen? Antworten Sie einfach hier.",
    "purpose": "service"
  }'
const res = await fetch("https://api.meiti.ai/v1/messages", {
  method: "POST",
  headers: {
    "X-Api-Key": process.env.MEITI_API_KEY,
    "Content-Type": "application/json",
    // Same key on a retry = the message is sent only once.
    "Idempotency-Key": `invoice-${invoice.number}-ready`,
  },
  body: JSON.stringify({
    phone: customer.phone,
    text: `Hallo ${customer.name}, Ihre Rechnung ${invoice.number} ist fertig. Fragen? Antworten Sie einfach hier.`,
    purpose: "service",
  }),
});
const sent = await res.json(); // 202: { projectId, contactId, messageId, status, channel }
import os, requests

res = requests.post(
    "https://api.meiti.ai/v1/messages",
    headers={
        "X-Api-Key": os.environ["MEITI_API_KEY"],
        # Same key on a retry = the message is sent only once.
        "Idempotency-Key": f"invoice-{invoice.number}-ready",
    },
    json={
        "phone": customer.phone,
        "text": f"Hallo {customer.name}, Ihre Rechnung {invoice.number} ist fertig. Fragen? Antworten Sie einfach hier.",
        "purpose": "service",
    },
)
sent = res.json()  # 202: projectId, contactId, messageId, status, channel
// call() from the quickstart; the last two args set the Idempotency-Key header.
sent, status, err := call("POST", "/v1/messages", map[string]any{
	"phone":   customer.Phone,
	"text":    fmt.Sprintf("Hallo %s, Ihre Rechnung %s ist fertig. Fragen? Antworten Sie einfach hier.", customer.Name, invoice.Number),
	"purpose": "service",
}, "Idempotency-Key", "invoice-"+invoice.Number+"-ready") // same key on a retry = sent only once
if err != nil {
	log.Fatal(err)
}
fmt.Println(status, sent["status"], sent["channel"]) // 202 queued whatsapp
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/messages")
{
    Content = JsonContent.Create(new
    {
        phone = customer.Phone,
        text = $"Hallo {customer.Name}, Ihre Rechnung {invoice.Number} ist fertig. Fragen? Antworten Sie einfach hier.",
        purpose = "service",
    }),
};
// Same key on a retry = the message is sent only once.
request.Headers.Add("Idempotency-Key", $"invoice-{invoice.Number}-ready");

var res = await http.SendAsync(request); // http from the quickstart (BaseAddress + X-Api-Key)
var sent = await res.Content.ReadFromJsonAsync<JsonElement>(); // 202: projectId, contactId, messageId, status, channel
<?php
$ch = curl_init("https://api.meiti.ai/v1/messages");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "X-Api-Key: " . getenv("MEITI_API_KEY"),
        "Content-Type: application/json",
        // Same key on a retry = the message is sent only once.
        "Idempotency-Key: invoice-{$invoice['number']}-ready",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "phone" => $customer["phone"],
        "text" => "Hallo {$customer['name']}, Ihre Rechnung {$invoice['number']} ist fertig. Fragen? Antworten Sie einfach hier.",
        "purpose" => "service",
    ]),
]);
$sent = json_decode(curl_exec($ch), true); // 202: projectId, contactId, messageId, status, channel
  • Idempotency-Key: the same request twice = one message. Valid for 24 hours.
  • purpose: none (default), service or marketing — marketing only with consent, otherwise 403.
  • Pick channels: channels: ["whatsapp", "sms"] in priority order; senders are listed under GET /v1/senders.
  • WhatsApp window closed? Pass an approved template under whatsApp (GET /v1/whatsapp/templates); otherwise the text waits until the customer replies.

Create contacts that don't exist yet first — POST /v1/contacts creates or updates by phone number:

curl https://api.meiti.ai/v1/contacts \
  -H "X-Api-Key: $MEITI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+491701234567",
    "firstName": "Ingrid",
    "lastName": "Weber",
    "email": "ingrid.weber@example.com",
    "serviceConsent": true
  }'
# 201 = created, 200 = updated. Keep the returned "id" next to your own customer id.
const res = await fetch("https://api.meiti.ai/v1/contacts", {
  method: "POST",
  headers: { "X-Api-Key": process.env.MEITI_API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({
    phone: "+491701234567",
    firstName: "Ingrid",
    lastName: "Weber",
    email: "ingrid.weber@example.com",
    serviceConsent: true,
  }),
});
const contact = await res.json(); // 201 created / 200 updated
await crm.customers.update(customerId, { meitiContactId: contact.id });
res = requests.post(
    "https://api.meiti.ai/v1/contacts",
    headers={"X-Api-Key": os.environ["MEITI_API_KEY"]},
    json={
        "phone": "+491701234567",
        "firstName": "Ingrid",
        "lastName": "Weber",
        "email": "ingrid.weber@example.com",
        "serviceConsent": True,
    },
)
contact = res.json()  # 201 created / 200 updated
crm.customers.update(customer_id, meiti_contact_id=contact["id"])
contact, status, err := call("POST", "/v1/contacts", map[string]any{
	"phone":          "+491701234567",
	"firstName":      "Ingrid",
	"lastName":       "Weber",
	"email":          "ingrid.weber@example.com",
	"serviceConsent": true,
})
if err != nil || status >= 300 {
	log.Fatal(status, err, contact)
}
// 201 created / 200 updated — keep the id next to your own customer id
crm.SaveMeitiContactID(customerID, int(contact["id"].(float64)))
var res = await http.PostAsJsonAsync("v1/contacts", new
{
    phone = "+491701234567",
    firstName = "Ingrid",
    lastName = "Weber",
    email = "ingrid.weber@example.com",
    serviceConsent = true,
});
var contact = await res.Content.ReadFromJsonAsync<JsonElement>(); // 201 created / 200 updated
customer.MeitiContactId = contact.GetProperty("id").GetInt32();
<?php
$ch = curl_init("https://api.meiti.ai/v1/contacts");
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([
        "phone" => "+491701234567",
        "firstName" => "Ingrid",
        "lastName" => "Weber",
        "email" => "ingrid.weber@example.com",
        "serviceConsent" => true,
    ]),
]);
$contact = json_decode(curl_exec($ch), true); // 201 created / 200 updated
$crm->updateCustomer($customerId, ["meiti_contact_id" => $contact["id"]]);
API

Mails from your own mailbox and notes in meiti

Not every message runs through meiti: mails from your Gmail or Outlook mailbox, WhatsApps from another tool. POST /v1/messages/log files them in the contact's project — for example from an n8n workflow on every new mail. meiti sends nothing and does not answer them; the assistant does read them as part of the conversation.

curl https://api.meiti.ai/v1/messages/log \
  -H "X-Api-Key: $MEITI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "erika.weber@example.com",
    "direction": "in",
    "channel": "email",
    "subject": "Angebot Badsanierung",
    "text": "Hallo, wann können Sie diese Woche vorbeikommen?",
    "occurredAt": "2026-10-09T08:12:00Z",
    "externalId": "<CAF7x9Q@mail.gmail.com>"
  }'
// e.g. from an n8n Gmail trigger: one call per new mail in your own mailbox.
const res = await fetch("https://api.meiti.ai/v1/messages/log", {
  method: "POST",
  headers: { "X-Api-Key": process.env.MEITI_API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({
    email: mail.from,
    direction: "in",                // "out" for mails your team sent
    channel: "email",
    subject: mail.subject,
    text: mail.text,
    occurredAt: mail.date,
    externalId: mail.messageId,     // same id again = filed only once
  }),
});
const logged = await res.json(); // 201: { projectId, contactId, messageId, created: true, truncated }
import os, requests

res = requests.post(
    "https://api.meiti.ai/v1/messages/log",
    headers={"X-Api-Key": os.environ["MEITI_API_KEY"]},
    json={
        "email": mail.sender,
        "direction": "in",             # "out" for mails your team sent
        "channel": "email",
        "subject": mail.subject,
        "text": mail.text,
        "occurredAt": mail.date.isoformat(),
        "externalId": mail.message_id,  # same id again = filed only once
    },
)
logged = res.json()  # 201 (200 if logged before): projectId, contactId, messageId, created, truncated
// call() from the quickstart.
logged, status, err := call("POST", "/v1/messages/log", map[string]any{
	"email":      mail.From,
	"direction":  "in", // "out" for mails your team sent
	"channel":    "email",
	"subject":    mail.Subject,
	"text":       mail.Text,
	"occurredAt": mail.Date.Format(time.RFC3339),
	"externalId": mail.MessageID, // same id again = filed only once
})
if err != nil {
	log.Fatal(err)
}
fmt.Println(status, logged["projectId"], logged["created"]) // 201 67890 true
var res = await http.PostAsJsonAsync("v1/messages/log", new // http from the quickstart
{
    email = mail.From,
    direction = "in",            // "out" for mails your team sent
    channel = "email",
    subject = mail.Subject,
    text = mail.TextBody,
    occurredAt = mail.Date,
    externalId = mail.MessageId, // same id again = filed only once
});
var logged = await res.Content.ReadFromJsonAsync<JsonElement>(); // 201: projectId, contactId, messageId, created, truncated
<?php
$ch = curl_init("https://api.meiti.ai/v1/messages/log");
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([
        "email" => $mail["from"],
        "direction" => "in",              // "out" for mails your team sent
        "channel" => "email",
        "subject" => $mail["subject"],
        "text" => $mail["text"],
        "occurredAt" => $mail["date"],
        "externalId" => $mail["messageId"], // same id again = filed only once
    ]),
]);
$logged = json_decode(curl_exec($ch), true); // 201: projectId, contactId, messageId, created, truncated
  • direction: in = from the customer, out = from your team. channel: email, whatsapp, sms or rcs; subject for email only.
  • Target: projectId or a contact (contactId, phone, email) — the message then lands in the contact's current project. The channel meiti answers the contact on stays as it is.
  • externalId (e.g. the mail's Message-ID): the same id on the same channel again = nothing new, answer 200 with the first message.
  • Incoming messages are unread for the team; a push notification only with notify: true. The assistant stays as it is — unless you send ai: "pause" (your team takes over) or ai: "resume" (back to the assistant).

What only your team should know goes into a note. Notes never reach the customer, and the assistant does not read them:

curl https://api.meiti.ai/v1/notes \
  -H "X-Api-Key: $MEITI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crm-note-4711" \
  -d '{
    "phone": "+491701234567",
    "title": "Aus dem CRM",
    "text": "Kundin möchte nur vormittags angerufen werden. Offene Rechnung 2024-117."
  }'
const res = await fetch("https://api.meiti.ai/v1/notes", {
  method: "POST",
  headers: {
    "X-Api-Key": process.env.MEITI_API_KEY,
    "Content-Type": "application/json",
    "Idempotency-Key": `crm-note-${note.id}`, // same key on a retry = one note
  },
  body: JSON.stringify({ phone: customer.phone, title: "Aus dem CRM", text: note.text }),
});
const created = await res.json(); // 201: { projectId, contactId, noteId }
import os, requests

res = requests.post(
    "https://api.meiti.ai/v1/notes",
    headers={
        "X-Api-Key": os.environ["MEITI_API_KEY"],
        "Idempotency-Key": f"crm-note-{note.id}",  # same key on a retry = one note
    },
    json={"phone": customer.phone, "title": "Aus dem CRM", "text": note.text},
)
created = res.json()  # 201: projectId, contactId, noteId
// call() from the quickstart; the last two args set the Idempotency-Key header.
created, status, err := call("POST", "/v1/notes", map[string]any{
	"phone": customer.Phone,
	"title": "Aus dem CRM",
	"text":  note.Text,
}, "Idempotency-Key", "crm-note-"+note.ID) // same key on a retry = one note
if err != nil {
	log.Fatal(err)
}
fmt.Println(status, created["noteId"]) // 201 5531
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/notes")
{
    Content = JsonContent.Create(new { phone = customer.Phone, title = "Aus dem CRM", text = note.Text }),
};
request.Headers.Add("Idempotency-Key", $"crm-note-{note.Id}"); // same key on a retry = one note

var res = await http.SendAsync(request); // http from the quickstart
var created = await res.Content.ReadFromJsonAsync<JsonElement>(); // 201: projectId, contactId, noteId
<?php
$ch = curl_init("https://api.meiti.ai/v1/notes");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "X-Api-Key: " . getenv("MEITI_API_KEY"),
        "Content-Type: application/json",
        "Idempotency-Key: crm-note-{$note['id']}", // same key on a retry = one note
    ],
    CURLOPT_POSTFIELDS => json_encode(["phone" => $customer["phone"], "title" => "Aus dem CRM", "text" => $note["text"]]),
]);
$created = json_decode(curl_exec($ch), true); // 201: projectId, contactId, noteId
API + Webhooks

Get appointments from your calendar confirmed

Your calendar plans, meiti handles the customer: with confirmed: false meiti invites and collects the confirmation, with true the appointment is agreed right away. Reminders run automatically. Your own id in externalId comes back on every read and webhook.

curl https://api.meiti.ai/v1/appointments \
  -H "X-Api-Key: $MEITI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+491701234567",
    "start": "2026-10-20T08:00:00Z",
    "durationMinutes": 60,
    "title": "Heizungswartung",
    "kind": "in_person",
    "confirmed": false,
    "externalId": "CAL-8812",
    "metadata": { "technician": "Jonas" }
  }'
const res = await fetch("https://api.meiti.ai/v1/appointments", {
  method: "POST",
  headers: { "X-Api-Key": process.env.MEITI_API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({
    phone: "+491701234567",
    start: "2026-10-20T08:00:00Z",
    durationMinutes: 60,
    title: "Heizungswartung",
    confirmed: false,          // meiti asks the customer to confirm
    externalId: calendarEvent.id, // comes back on every read and webhook
    metadata: { technician: "Jonas" },
  }),
});
const appointment = await res.json(); // 201: { id, status: "proposed", startLocal, … }
res = requests.post(
    "https://api.meiti.ai/v1/appointments",
    headers={"X-Api-Key": os.environ["MEITI_API_KEY"]},
    json={
        "phone": "+491701234567",
        "start": "2026-10-20T08:00:00Z",
        "durationMinutes": 60,
        "title": "Heizungswartung",
        "confirmed": False,            # meiti asks the customer to confirm
        "externalId": calendar_event.id,  # comes back on every read and webhook
        "metadata": {"technician": "Jonas"},
    },
)
appointment = res.json()  # 201: id, status "proposed", startLocal, ...
appointment, status, err := call("POST", "/v1/appointments", map[string]any{
	"phone":           "+491701234567",
	"start":           "2026-10-20T08:00:00Z",
	"durationMinutes": 60,
	"title":           "Heizungswartung",
	"kind":            "in_person",
	"confirmed":       false,                      // meiti asks the customer to confirm
	"externalId":      calendarEvent.ID,           // comes back on every read and webhook
	"metadata":        map[string]string{"technician": "Jonas"},
})
if err != nil || status != 201 {
	log.Fatal(status, err, appointment)
}
fmt.Println(appointment["id"], appointment["status"]) // … proposed
var res = await http.PostAsJsonAsync("v1/appointments", new
{
    phone = "+491701234567",
    start = new DateTime(2026, 10, 20, 8, 0, 0, DateTimeKind.Utc),
    durationMinutes = 60,
    title = "Heizungswartung",
    confirmed = false,              // meiti asks the customer to confirm
    externalId = calendarEvent.Id,  // comes back on every read and webhook
    metadata = new Dictionary<string, string> { ["technician"] = "Jonas" },
});
var appointment = await res.Content.ReadFromJsonAsync<JsonElement>(); // 201: id, status "proposed", …
<?php
$ch = curl_init("https://api.meiti.ai/v1/appointments");
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([
        "phone" => "+491701234567",
        "start" => "2026-10-20T08:00:00Z",
        "durationMinutes" => 60,
        "title" => "Heizungswartung",
        "confirmed" => false,            // meiti asks the customer to confirm
        "externalId" => $calendarEvent["id"],
        "metadata" => ["technician" => "Jonas"],
    ]),
]);
$appointment = json_decode(curl_exec($ch), true); // 201: id, status "proposed", …

So your calendar learns what the customer does, subscribe to the appointment events:

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

Every delivery is signed. Verify the signature before you change anything — code and events are under API webhooks.

Deep Links

“Open in meiti” and “Call with meiti” in your CRM

One link, your own number, no API key: meiti looks for the project in every inbox the signed-in person can open, switches inbox if needed and opens it. If a customer has no open project, meiti starts a new one. The call link opens the call screen — nothing is dialled until the person taps.

// https works everywhere; "meiti://open" opens the installed app directly (e.g. from a desktop CRM).
const base = "https://m.meiti.ai/open";

const openCase     = `${base}/project?crmId=${encodeURIComponent(caseRecord.number)}`;
const openCustomer = `${base}/project?crmContactId=${encodeURIComponent(customer.number)}`;
const openById     = `${base}/project?id=${meitiProjectId}`; // projectData.meitiProjectId from the webhook
const call         = `${base}/dial?number=${encodeURIComponent(customer.phone)}`;
from urllib.parse import quote

# https works everywhere; "meiti://open" opens the installed app directly (e.g. from a desktop CRM).
BASE = "https://m.meiti.ai/open"

open_case = f"{BASE}/project?crmId={quote(case.number, safe='')}"
open_customer = f"{BASE}/project?crmContactId={quote(customer.number, safe='')}"
open_by_id = f"{BASE}/project?id={meiti_project_id}"  # projectData.meitiProjectId from the webhook
call = f"{BASE}/dial?number={quote(customer.phone, safe='')}"
// imports: fmt, net/url, strings
// https works everywhere; "meiti://open" opens the installed app directly (e.g. from a desktop CRM).
const base = "https://m.meiti.ai/open"

// QueryEscape encodes & = / +, but turns spaces into "+"; meiti expects %20.
func esc(v string) string { return strings.ReplaceAll(url.QueryEscape(v), "+", "%20") }

func meitiLinks(caseNumber, customerNumber, phone string, meitiProjectID int) []string {
	return []string{
		base + "/project?crmId=" + esc(caseNumber),
		base + "/project?crmContactId=" + esc(customerNumber),
		fmt.Sprintf("%s/project?id=%d", base, meitiProjectID), // projectData.meitiProjectId from the webhook
		base + "/dial?number=" + esc(phone),
	}
}
using System.Diagnostics;

// Web CRM: https works everywhere. Windows desktop app with meiti installed: use "meiti://open".
var scheme = "https://m.meiti.ai/open"; // or "meiti://open"

var openCase     = $"{scheme}/project?crmId={Uri.EscapeDataString(caseRecord.Number)}";
var openCustomer = $"{scheme}/project?crmContactId={Uri.EscapeDataString(customer.Number)}";
var openById     = $"{scheme}/project?id={meitiProjectId}"; // projectData.meitiProjectId from the webhook
var call         = $"{scheme}/dial?number={Uri.EscapeDataString(customer.Phone)}";

Process.Start(new ProcessStartInfo(openCase) { UseShellExecute = true }); // hands the link to the OS
<?php
// https works everywhere; "meiti://open" opens the installed app directly (e.g. from a desktop CRM).
$base = "https://m.meiti.ai/open";

$openCase     = "$base/project?crmId=" . rawurlencode($case["number"]);
$openCustomer = "$base/project?crmContactId=" . rawurlencode($customer["number"]);
$openById     = "$base/project?id=" . (int)$meitiProjectId; // projectData.meitiProjectId from the webhook
$call         = "$base/dial?number=" . rawurlencode($customer["phone"]);
<!-- In your CRM's case view -->
<a href="https://m.meiti.ai/open/project?crmId=AZ-2024%2F117">In meiti öffnen</a>

<!-- Next to a phone number -->
<a href="https://m.meiti.ai/open/dial?number=%2B491701234567">Mit meiti anrufen</a>

Build with AI

Let Claude Code, Cursor or Codex write the integration

This page is made for humans and for agents. Give your coding agent the prompt below — it reads the llms.txt and the OpenAPI spec and builds the three building blocks in your stack. Or copy the whole page as Markdown at the top.

Build a meiti integration for my system.

Read first:
- Full text:       https://meiti.app/docs/developers/llms-full.txt
- Developer guide: https://meiti.app/docs/developers
- OpenAPI spec:   https://api.meiti.ai/v1/openapi.json

Goal:
1. A CRM webhook endpoint that answers IncomingCallLookup/NewConversation with the caller's name,
   customer number (crmContactId) and a short crmAiInfo, and files FinishedCall/ConversationPaused
   summaries as cases, answering with crmProjectId.
2. Send messages from my system with POST /v1/messages (Idempotency-Key, purpose "service").
3. "Open in meiti" and "Call with meiti" links next to my cases (https://m.meiti.ai/open/...).

Rules: check the bearer token; ids are strings; answer within 2 seconds or use the callbackUrl;
dedupe CRM events on callbackUrl and API webhook events on X-Meiti-Delivery-Id; accept eventType as
string or legacy number; create a contact (POST /v1/contacts) before sending to it; read MEITI_API_KEY
and MEITI_WEBHOOK_TOKEN from env. Write tests that replay the sample payloads, and tell me how to test
live with the Manual event and POST /v1/webhooks/{id}/test.
My stack: <your language/framework here>
  • llms.txt: a short overview with the facts agents get wrong most easily. llms-full.txt: plus every sample in every language and every payload — no HTML.
  • openapi.json: every field, every error code — machine-readable, also for Postman, Insomnia or n8n (import).

API v1 Beta

Base URLhttps://api.meiti.ai/v1/
AuthenticationX-Api-Key: meiti_sk_… or Authorization: Bearer meiti_sk_…
ErrorsRFC 9457 application/problem+json: stable code in title, sentence in detail
Limits60 requests per minute per key; 429 with Retry-After
EndpointWhat for
GET /v1/meWhich inbox the key acts on.
POST /v1/messagesSend one message to one contact.
POST /v1/messages/logFile a message that was written outside meiti (your own mailbox, another tool) — nothing is sent.
POST /v1/notesInternal note on a project — for the team only.
GET /v1/sendersActive senders (WhatsApp number, mailboxes, SMS).
GET /v1/whatsapp/templatesApproved WhatsApp templates.
POST /v1/contactsCreate or update a contact (by phone number).
GET /v1/contacts?phone=|email=, GET /v1/contacts/{id}Look a contact up.
POST /v1/appointmentsCreate an appointment — agreed or for confirmation.
GET /v1/appointments?updatedSince=Poll changes (ascending by updatedAt).
PATCH / DELETE /v1/appointments/{id}Move, confirm, cancel.
GET /v1/projects, …/{id}/messagesRead projects (conversations with a contact) and transcripts; the id is the same as meitiProjectId in the CRM webhook.
GET|POST|DELETE /v1/webhooksManage webhook subscriptions, test with …/{id}/test — see API webhooks.
GET /v1/agents, PATCH /v1/agents/{id}, …/{id}/secret, …/{id}/testConnect own-AI assistants — see BYOAI.
POST /v1/projects/{id}/agent-repliesYour own AI's later answer in a conversation.

Types instead of guessing: npx openapi-typescript https://api.meiti.ai/v1/openapi.json -o meiti.d.ts (TypeScript) or oapi-codegen (Go) generate them from the spec.

Every field, example and error code is in the OpenAPI reference.

Open the interactive API reference

Go-live checklist

  • Key and webhook secret live in environment variables, not in code.
  • Your webhook checks the bearer token or signature and answers in under 2 seconds.
  • Tested with the Manual event (button in the project details) and POST /v1/webhooks/{id}/test.
  • Duplicate events and retries change nothing twice.
  • Messages carry an Idempotency-Key and the right purpose.
  • Ids are linked both ways — deep links open the right project.
  • BYOAI: chat endpoint and WebSocket verify the signature, answer within timeoutSeconds and drop late answers on 409; “Test connection” is green.
  • Played through with a test inbox and your own number, then switched to real customers.

Questions about your integration?

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