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.
API key: Settings → Setup & operations → Connected systems → API access → create key.
CRM webhook: Settings → Setup & operations → Connected systems → CRM & business software → Webhook — your IT enters URL and token.
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
Deep Link
CustomerPhone · WhatsApp · chat
meitiAssistant & inbox
Your systemCRM · ERP · calendar
Call from +49 170 1234567Ingrid Weber calls. meiti answers.
POST · IncomingCallLookupCRM webhook: meiti asks your system who is calling — while it still rings.
200 · firstName, crmAiInfoYour system answers within 2 seconds with the name and a short note.
“Hello Ms Weber! Is this about your heating?”The assistant knows the customer and handles the conversation.
POST · FinishedCallAfter the call the summary goes to your system.
200 · crmProjectId: "AZ-2024/117"Your system files the case and returns its number.
POST /v1/appointmentsAPI: your calendar plans the appointment, meiti invites the customer.
Appointment Tue, 9:00 — does that work?The invitation arrives on WhatsApp, with a button to confirm.
✓ ConfirmedMs Weber taps “Confirm”.
appointment.confirmedSigned API webhook: your calendar learns it right away.
open/project?crmId=AZ-2024%2F117Deep link: your team clicks “Open in meiti” in the CRM and lands in the conversation.
One call, from the first second to the confirmed appointment — and everywhere your system takes part.
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
meiti handles the conversation
The assistant knows the customer, answers questions, takes requests and hands over to your team when needed.
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
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.
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 …
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.
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 …
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.
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).
API: POST /v1/appointments with confirmed: false — meiti invites, the customer confirms in the chat.
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]
Customer taps “review” – The invitation with all details.Confirmation happens in the chat — via the buttons.
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 📩]
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]
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.
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 📩]
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
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.
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]
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.
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]
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.
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
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.
Check the key
GET /v1/me is the simplest test: it shows which inbox the key belongs to.
// 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": … }
Hi Test, you've received updates from Heizung Weber regarding your request. Check them out now!
let's see
Customer taps …
Hallo aus meiner ersten Integration!
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.
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();
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:
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
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.
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", …
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
Endpoint
What for
GET /v1/me
Which inbox the key acts on.
POST /v1/messages
Send one message to one contact.
POST /v1/messages/log
File a message that was written outside meiti (your own mailbox, another tool) — nothing is sent.
POST /v1/notes
Internal note on a project — for the team only.
GET /v1/senders
Active senders (WhatsApp number, mailboxes, SMS).
GET /v1/whatsapp/templates
Approved WhatsApp templates.
POST /v1/contacts
Create or update a contact (by phone number).
GET /v1/contacts?phone=|email=, GET /v1/contacts/{id}
Look a contact up.
POST /v1/appointments
Create 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}/messages
Read projects (conversations with a contact) and transcripts; the id is the same as meitiProjectId in the CRM webhook.
GET|POST|DELETE /v1/webhooks
Manage webhook subscriptions, test with …/{id}/test — see API webhooks.
GET /v1/agents, PATCH /v1/agents/{id}, …/{id}/secret, …/{id}/test
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 link works as https://m.meiti.ai/open/… (mail, browser, phone — without the app it shows a download page) and as meiti://open/… (opens the installed app directly, e.g. from a Windows application).
Link
Opens
/open/project?id=67890
The project with meiti's id (projectData.meitiProjectId).
/open/project?crmId=AZ-2024%2F117
The projects with your case number (crmProjectId).
/open/project?crmContactId=K-1042
The customer's open projects for your customer number (crmContactId). If there is none, meiti starts a new one.
/open/dial?number=%2B491701234567
The call screen with this number — nothing is dialled until the person taps.
meiti looks in every inbox the signed-in person can open. If the project lives in another one, the app asks before switching. Several matches (e.g. the same case number on several projects): the person picks from a list with the inbox, open ones first, at most 20. No match: the app says so.
On desktop, https://m.meiti.ai/… opens a page with “Open in the app” in the browser. With the meiti app installed on Windows, meiti://open/… opens it directly.
Numbers are compared ignoring case, spaces, -, ., /, _ — but always as a whole number.
URL-encode values, e.g. / as %2F and + as %2B.
A link only opens the app: the person must be signed in and sees only what they may access. A link never creates contacts.
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.