# meiti

> meiti answers phone calls, WhatsApp, chats and email for businesses with an AI assistant. Connect it to a CRM, ERP or shop with three building blocks: the CRM webhook (call/chat events, your answer adds context), the public REST API v1 (messages, contacts, appointments, conversations, signed appointment webhooks) and deep links into the meiti app.

Hosts: `meiti.app` (docs), `api.meiti.ai` (API), `m.meiti.ai` (app links).

Key facts:
- API base: https://api.meiti.ai/v1/ (to test, use an inbox or organisation of your own set up only for testing). Auth: `X-Api-Key: meiti_sk_…` or `Authorization: Bearer meiti_sk_…`; each key belongs to exactly one inbox; 60 requests/minute per key (default); errors are RFC 9457 problem details (code in `title`).
- `POST /v1/messages` needs an existing contact (create it with `POST /v1/contacts`, upsert by phone). Use an `Idempotency-Key` header. A closed WhatsApp 24h window returns `waitingForReply: true`: meiti sends a notice with a button and delivers the text when the customer taps it or replies. An API message counts as a team reply (the assistant does not take over afterwards).
- `POST /v1/messages/log` files a message sent or received OUTSIDE meiti (your own mailbox, another tool): `{ projectId|contactId|phone|email, direction: "in"|"out", channel: "email"|"whatsapp"|"sms"|"rcs", text, subject? (email only), occurredAt?, externalId?, ai?: "pause"|"resume", notify? }`. Nothing is sent; meiti's assistant never answers it but reads it as part of the conversation; an incoming one is unread for the team; the contact's reply channel stays as it is. Same `externalId` + channel again = 200 with the first message, nothing filed. `POST /v1/notes` `{ projectId|contactId|phone|email, title?, text }` adds an internal note (team only, never read by the assistant; `Idempotency-Key` supported).
- CRM webhook: meiti POSTs `{ eventType, timestampUtc, callbackUrl, contactData, projectData }` with `Authorization: Bearer <token>`. `eventType` is a string: IncomingCallLookup, FinishedCall, NewConversation, ConversationPaused, Manual, FormSubmitted, and the appointment events AppointmentCreated, AppointmentConfirmed (customer confirmed), AppointmentRescheduled, AppointmentCancelled, AppointmentFinished, AppointmentReminderSent — those also carry `appointmentData` in the API's appointment shape. Free on every plan, no quota. Answer within 2 seconds with 200 + `{ requestContactUpdate, contactData, requestProjectUpdate, projectData }` (ids are strings, only flagged parts are applied), or 202 and POST the same body to the one-time `callbackUrl` within 5 minutes; 204 = nothing to add. One webhook per inbox; events carry no inbox id. A slow or failed answer gets the same event once more (same `callbackUrl` — dedupe on it). Until October 2026 `eventType` was a number (0 IncomingCallLookup, 1 FinishedCall, 2 NewConversation, 3 ConversationPaused, 4 Manual, 5 FormSubmitted) — accept both for a while. `crmAiInfo` (max 5000 chars) is read by the assistant in every conversation: keep it short and never put anything there a stranger must not hear.
- API webhooks: subscribe with `POST /v1/webhooks`; header `X-Meiti-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>`, reject timestamps older than 5 minutes; events appointment.created|confirmed|rescheduled|cancelled|finished|reminder_sent; dedupe on `X-Meiti-Delivery-Id`.
- Deep links: `https://m.meiti.ai/open/project?id=<meitiProjectId>|crmId=<crmProjectId>|crmContactId=<crmContactId>` and `https://m.meiti.ai/open/dial?number=<phone>`; the same paths work as `meiti://open/…`. URL-encode values (`/` → %2F, `+` → %2B, space → %20).
- BYOAI (your own AI answers instead of meiti's assistant): create an assistant of kind "Your own AI" in the app (Settings → AI assistants → NEW), then configure it in the app or with `GET /v1/agents`, `GET`/`PATCH /v1/agents/{id}` (`chatUrl` https, `voiceUrl` wss, `timeoutSeconds` default 10, 2–30, `voiceId`; omitted = unchanged, "" = disconnect), `POST /v1/agents/{id}/secret` (signing secret, shown once; without one meiti sends nothing) and `POST /v1/agents/{id}/test`. Chat: meiti POSTs `{ id, type: "message.received", createdAt, protocol, workspaceId, agentId, data: { projectId, channel, contact, message, history, replyUrl, skills, autoReply } }` to `chatUrl` with `X-Meiti-Signature` (same scheme as API webhooks, over the raw body), `X-Meiti-Event`, `X-Meiti-Delivery-Id`, `X-Meiti-Agent-Protocol: 2026-10-11`. `data.autoReply` = `{ state: on|paused|off, pausedUntil? }`, the project's auto-reply as the team set it in the app: events also arrive while the team has the project (member took over or replied, auto-reply switched off or muted) — your agent decides; answer 204 to stay quiet. A text sent anyway is delivered (the team sees a note the first time after they stepped in); unless `state` is `on`, `data.skills` is empty, skills answer `not-allowed` and `handover` is a no-op. Answer within `timeoutSeconds` with 200 `{ text, action: none|handover|end, note }` (text max 4000, note max 500, team-only), or 202/204 and later `POST /v1/projects/{id}/agent-replies` with the same body and an API key of the inbox (202 `{ projectId, messageId, status: sent|handed_over|noop, autoReply }`, 409 `project_not_with_agent` when the project is not answered by an own-AI assistant, 422 `not_deliverable`). One attempt, no retry: timeout, non-2xx, unreadable JSON or an unknown action hand the conversation to the team (nothing extra when the team has it already). Phone: per call meiti opens a WebSocket to `voiceUrl`; the upgrade carries `X-Meiti-Call-Id` and `X-Meiti-Signature` over that call id. meiti → agent: `session.started`, `prompt { text }`, `dtmf { digit }`, `interrupt { spokenUntilInterrupt }`, `session.ended`; agent → meiti: `text { text, last }` (streamed speech), `action { action: end|handover, text, note }`. Skills as tools: forwarding (`kind: forward`) and alert (`kind: alert`) skills attached to the assistant (app: Skills section) are listed in `data.skills` / `session.started.skills` as `{ id, key, name, description, kind, announcement }` (only those allowed right now) and in `GET /v1/agents` (`skills`, `skillsPerHourLimit`, `forwardsPerCallLimit`). Trigger them with `executeSkills: ["key" or "id", …]` (max 5, alerts first, one forward last) and optional `announcement` ("" = none) in the chat answer or agent-replies body (202 adds `skills: [{ key, ok, reason }]`; a synchronous answer's outcome appears as a `system` history turn `… [key] … (reason)`), with the phone frame `{ type: "skill", executeSkills, note?, announcement? }` (meiti answers `skill.result { key, ok, reason }`; an ok forward transfers the call, no answer = reconnect with `resumed: "handover_failed"` and `resumedAfter: { key, ok: false, reason: "no-answer" }`), or `POST /v1/projects/{id}/skills/{key}` (`{ note, announcement }`, optional Idempotency-Key, 200 `{ key, ok, reason }`, chat semantics). Alert text = `note`, else `text`. Reasons: `unknown-skill`, `outside-hours`, `limit-reached`, `not-allowed`, `no-answer`, `no-target`, `no-recipient`, `one-forward-per-turn`, `failed` (`pending-approval` with ok). Each executed skill costs like any skill run. Billing: chat replies are free (like meiti's own AI); phone per started call minute; post-processing skills meiti runs cost as usual. These events go only to the agent's own endpoints — not to the CRM webhook or API webhooks.

## Docs

- [Full text for agents](https://meiti.app/docs/developers/llms-full.txt): this file plus every sample (all languages) and every payload as plain Markdown.
- [Developer guide](https://meiti.app/docs/developers): integration patterns, flows, quickstart, recipes with code in cURL, Node.js, Python, Go, C#, PHP, and the reference for API v1, deep links and the go-live checklist.
- [Webhooks](https://meiti.app/docs/developers/webhooks): the CRM webhook (events, answers, recipes, complete payload, n8n templates) and the signed API webhooks with signature verification.
- [CRM webhook fields](https://meiti.app/docs/developers/webhooks#crm-fields): every contact and project field with its length limit and whether it is read-only.
- [BYOAI](https://meiti.app/docs/developers/byoai): your own AI as the assistant — chat events, replies, phone frames, signature, billing.
- [OpenAPI v1](https://api.meiti.ai/v1/openapi.json): machine-readable spec of every endpoint, field and error code.
- [API reference](https://api.meiti.ai/v1/docs): the same spec as interactive Swagger UI.
- [Changelog](https://api.meiti.ai/v1/changelog): every caller-facing change of v1 (Beta).
- [MCP](https://meiti.app/docs/mcp): set meiti up (assistants, skills, knowledge, tests) from Claude Code, Codex, Grok or any MCP client.

## Optional

- [Developer guide in English](https://meiti.app/docs/developers?lang=en), [Webhooks in English](https://meiti.app/docs/developers/webhooks?lang=en), [BYOAI in English](https://meiti.app/docs/developers/byoai?lang=en)
- Support for integrations: WhatsApp or call +49 89 41434078 (https://wa.me/498941434078?text=Hallo%2C%20ich%20baue%20eine%20meiti-Integration.) — no email.
## Who gets what — flows step by step

### 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?

## Samples

Placeholders like `crm`, `customer`, `invoice`, `caseRecord` and `calendar` stand for the integrator's own code. Samples address the production API.

### Quickstart: check the key (GET /v1/me)

```curl
curl https://api.meiti.ai/v1/me \
  -H "X-Api-Key: $MEITI_API_KEY"
```

```js
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 }
```

```python
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': ...}
```

```go
// 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:…]
}
```

```csharp
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
<?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
```

### Quickstart: create yourself as a contact (POST /v1/contacts)

```curl
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" }'
```

```js
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, … }
```

```python
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)
```

```go
// 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)
```

```csharp
// 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
<?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)
```

### Quickstart: first message (POST /v1/messages)

```curl
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!" }'
```

```js
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, … }
```

```python
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, ...}
```

```go
// 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:…]
```

```csharp
// 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
<?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, … }
```

### Response of POST /v1/messages (closed WhatsApp window)

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

### CRM webhook handler: recognise callers, file call notes

```js
// 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
# 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
```

```go
// 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)
}
```

```csharp
// 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
<?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
```

### CRM webhook: incoming event payload (shortened)

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

### CRM webhook: complete event payload with every contact and project field

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

### CRM webhook: answer later via callbackUrl

```curl
# 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." }
  }'
```

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

```python
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
```

```go
// 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)
}
```

```csharp
// 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
<?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.
```

### Message from your system (with Idempotency-Key and purpose)

```curl
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"
  }'
```

```js
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 }
```

```python
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
```

```go
// 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
```

```csharp
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
<?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
```

### Log a message from your own mailbox or another tool (POST /v1/messages/log)

```curl
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>"
  }'
```

```js
// 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 }
```

```python
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
```

```go
// 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
```

```csharp
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
<?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
```

### Internal note on a project (POST /v1/notes)

```curl
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."
  }'
```

```js
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 }
```

```python
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
```

```go
// 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
```

```csharp
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
<?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
```

### Create or update a contact

```curl
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.
```

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

```python
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"])
```

```go
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)))
```

```csharp
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
<?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"]]);
```

### Create an appointment

```curl
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" }
  }'
```

```js
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, … }
```

```python
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, ...
```

```go
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
```

```csharp
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
<?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", …
```

### Subscribe to API webhooks

```curl
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.
```

```js
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
```

```python
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
```

```go
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)
```

```csharp
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
<?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
```

### Verify the X-Meiti-Signature of an API webhook

```js
// 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
# 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
```

```go
// 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)
}
```

```csharp
// 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
<?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);
```

### API webhook: appointment event envelope

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

### Deep links: open a project, open the dial pad

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

```python
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='')}"
```

```go
// 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),
	}
}
```

```csharp
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
<?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"]);
```

```html
<!-- 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>
```

### BYOAI: point an own-AI assistant at your endpoints (v1/agents)

```curl
# Your own-AI assistants (create them in the app: Settings → AI assistants → NEW → "Your own AI")
curl https://api.meiti.ai/v1/agents -H "X-Api-Key: $MEITI_API_KEY"

# Point one at your endpoints. Omitted fields stay as they are; "" disconnects a channel.
curl -X PATCH https://api.meiti.ai/v1/agents/812 \
  -H "X-Api-Key: $MEITI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "chatUrl": "https://ai.example.com/meiti/chat",
    "voiceUrl": "wss://ai.example.com/meiti/voice",
    "timeoutSeconds": 10
  }'

# Signing secret: the response { "secret": "whsec_…" } is the only time you can read it.
curl -X POST https://api.meiti.ai/v1/agents/812/secret -H "X-Api-Key: $MEITI_API_KEY"

# Signed "ping" to chatUrl, then one WebSocket connect to voiceUrl.
curl -X POST https://api.meiti.ai/v1/agents/812/test -H "X-Api-Key: $MEITI_API_KEY"
# 200: { success, statusCode, durationMs, error }
```

```js
const meiti = (path, method = "GET", body) =>
  fetch(`https://api.meiti.ai${path}`, {
    method,
    headers: { "X-Api-Key": process.env.MEITI_API_KEY, "Content-Type": "application/json" },
    body: body && JSON.stringify(body),
  }).then(r => r.json());

const [agent] = await meiti("/v1/agents"); // created in the app as "Your own AI"
await meiti(`/v1/agents/${agent.agentId}`, "PATCH", {
  chatUrl: "https://ai.example.com/meiti/chat",
  voiceUrl: "wss://ai.example.com/meiti/voice", // "" disconnects a channel
  timeoutSeconds: 10,
});
const { secret } = await meiti(`/v1/agents/${agent.agentId}/secret`, "POST"); // shown exactly once
const test = await meiti(`/v1/agents/${agent.agentId}/test`, "POST"); // { success, statusCode, durationMs, error }
```

```python
import os, requests

api = requests.Session()
api.headers["X-Api-Key"] = os.environ["MEITI_API_KEY"]
base = "https://api.meiti.ai/v1"

agent = api.get(f"{base}/agents").json()[0]  # created in the app as "Your own AI"
api.patch(f"{base}/agents/{agent['agentId']}", json={
    "chatUrl": "https://ai.example.com/meiti/chat",
    "voiceUrl": "wss://ai.example.com/meiti/voice",  # "" disconnects a channel
    "timeoutSeconds": 10,
})
secret = api.post(f"{base}/agents/{agent['agentId']}/secret").json()["secret"]  # shown exactly once
test = api.post(f"{base}/agents/{agent['agentId']}/test").json()  # success, statusCode, durationMs, error
```

```go
// call() from the quickstart. GET /v1/agents answers with a JSON array; read the id from the app or decode it yourself.
id := "812" // agentId of your "Your own AI" assistant
_, status, err := call("PATCH", "/v1/agents/"+id, map[string]any{
	"chatUrl":        "https://ai.example.com/meiti/chat",
	"voiceUrl":       "wss://ai.example.com/meiti/voice", // "" disconnects a channel
	"timeoutSeconds": 10,
})
if err != nil || status != 200 {
	log.Fatal(status, err)
}
created, _, _ := call("POST", "/v1/agents/"+id+"/secret", nil)
saveSecret(created["secret"].(string)) // shown exactly once
test, _, _ := call("POST", "/v1/agents/"+id+"/test", nil)
fmt.Println(test["success"], test["durationMs"], test["error"])
```

```csharp
// http from the quickstart (BaseAddress + X-Api-Key)
var agents = await http.GetFromJsonAsync<JsonElement>("v1/agents"); // created in the app as "Your own AI"
var id = agents[0].GetProperty("agentId").GetInt32();

await http.PatchAsJsonAsync($"v1/agents/{id}", new
{
    chatUrl = "https://ai.example.com/meiti/chat",
    voiceUrl = "wss://ai.example.com/meiti/voice", // "" disconnects a channel
    timeoutSeconds = 10,
});
var created = await (await http.PostAsync($"v1/agents/{id}/secret", null)).Content.ReadFromJsonAsync<JsonElement>();
var secret = created.GetProperty("secret").GetString(); // shown exactly once
var test = await (await http.PostAsync($"v1/agents/{id}/test", null)).Content.ReadFromJsonAsync<JsonElement>();
```

```php
<?php
function meiti(string $method, string $path, ?array $body = null): array {
    $ch = curl_init("https://api.meiti.ai" . $path);
    $options = [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["X-Api-Key: " . getenv("MEITI_API_KEY"), "Content-Type: application/json"],
    ];
    if ($method !== "GET") {
        $options[CURLOPT_POSTFIELDS] = $body === null ? "" : json_encode($body);
    }
    curl_setopt_array($ch, $options);
    return json_decode(curl_exec($ch), true);
}

$id = meiti("GET", "/v1/agents")[0]["agentId"]; // created in the app as "Your own AI"
meiti("PATCH", "/v1/agents/$id", [
    "chatUrl" => "https://ai.example.com/meiti/chat",
    "voiceUrl" => "wss://ai.example.com/meiti/voice", // "" disconnects a channel
    "timeoutSeconds" => 10,
]);
$secret = meiti("POST", "/v1/agents/$id/secret")["secret"]; // shown exactly once
$test = meiti("POST", "/v1/agents/$id/test"); // success, statusCode, durationMs, error
```

### BYOAI chat: message.received payload

```json
{
  "id": "evt_3f9a0c1e8b7d4b2a9e6f5d4c3b2a1908",
  "type": "message.received",
  "createdAt": "2026-10-14T09:14:03.5123456Z",
  "protocol": "2026-10-11",
  "workspaceId": 245,
  "agentId": 812,
  "data": {
    "projectId": 67890,
    "channel": "whatsapp",
    "contact": { "id": 18342, "name": "Ingrid Weber", "phone": "+491701234567" },
    "message": { "id": 991250, "text": "Kann ich die Lieferadresse noch ändern?", "receivedAt": "2026-10-14T09:14:02.947" },
    "history": [
      { "role": "customer", "text": "Hallo, ist meine Bestellung 4711 schon unterwegs?", "at": "2026-10-14T09:12:40.118" },
      { "role": "agent", "text": "Ja, sie ist gestern rausgegangen und kommt morgen bei Ihnen an.", "at": "2026-10-14T09:12:43.502" }
    ],
    "replyUrl": "https://api.meiti.ai/v1/projects/67890/agent-replies",
    "skills": [
      { "id": 412, "key": "buero", "name": "Ins Büro durchstellen", "kind": "forward", "announcement": "Einen Moment, ich verbinde Sie mit dem Büro." },
      { "id": 418, "key": "notdienst", "name": "Notdienst-Alarm", "description": "Wasserschaden, Heizungsausfall", "kind": "alert", "announcement": null }
    ],
    "autoReply": { "state": "on" }
  }
}
```

### BYOAI chat: endpoint that verifies the signature and answers right away

```js
// Node + Express. myAI stands for your own model, prompt and data.
import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.MEITI_AGENT_SECRET; // from "Create secret" or POST /v1/agents/{id}/secret

// X-Meiti-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + signed)>
function verify(header, signed) {
  const sig = Object.fromEntries((header ?? "").split(",").map(p => p.split("=")));
  const expected = crypto.createHmac("sha256", secret).update(`${sig.t}.${signed}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
  return fresh && sig.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig.v1), Buffer.from(expected));
}

// Raw body: the signature covers the exact bytes meiti sent.
app.post("/meiti/chat", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verify(req.get("X-Meiti-Signature"), req.body)) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  if (event.type !== "message.received") return res.sendStatus(200); // "ping" from the connection test
  if (event.data.autoReply.state !== "on") return res.sendStatus(204); // your team has it — stay quiet (or answer anyway)

  const { message, history, contact } = event.data;
  const answer = await myAI({ text: message.text, history, contact }); // within timeoutSeconds

  res.json({
    text: answer.text,
    action: answer.needsPerson ? "handover" : "none", // handover: your team takes over in the inbox
    note: answer.needsPerson ? answer.reason : undefined, // only your team sees it
  });
});

app.listen(3000);
```

```python
# Python + Flask. my_ai stands for your own model, prompt and data.
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["MEITI_AGENT_SECRET"].encode()  # from "Create secret" or POST /v1/agents/{id}/secret

# X-Meiti-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + signed)>
def verify(header: str, signed: bytes) -> bool:
    sig = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = sig.get("t", ""), sig.get("v1", "")
    expected = hmac.new(SECRET, t.encode() + b"." + signed, hashlib.sha256).hexdigest()
    return t.isdigit() and abs(time.time() - int(t)) <= 300 and hmac.compare_digest(v1, expected)

@app.post("/meiti/chat")
def meiti_chat():
    body = request.get_data()  # the exact bytes meiti signed
    if not verify(request.headers.get("X-Meiti-Signature", ""), body):
        abort(401)
    event = json.loads(body)
    if event["type"] != "message.received":
        return "", 200  # "ping" from the connection test

    data = event["data"]
    if data["autoReply"]["state"] != "on":
        return "", 204  # your team has it — stay quiet (or answer anyway)
    answer = my_ai(text=data["message"]["text"], history=data["history"], contact=data.get("contact"))
    reply = {"text": answer.text, "action": "handover" if answer.needs_person else "none"}
    if answer.needs_person:
        reply["note"] = answer.reason  # only your team sees it
    return reply
```

```go
// net/http. myAI stands for your own model, prompt and data.
package main

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

// verify checks X-Meiti-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + signed)>.
func verify(header string, signed []byte) bool {
	var t, v1 string
	for _, part := range strings.Split(header, ",") {
		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_AGENT_SECRET")))
	mac.Write([]byte(t + "."))
	mac.Write(signed)
	expected := hex.EncodeToString(mac.Sum(nil))
	age := time.Since(time.Unix(unix, 0))
	return err == nil && age <= 5*time.Minute && age >= -5*time.Minute && hmac.Equal([]byte(expected), []byte(v1))
}

type turn struct {
	Role string `json:"role"`
	Text string `json:"text"`
}

func meitiChat(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body) // the exact bytes meiti signed
	if !verify(r.Header.Get("X-Meiti-Signature"), body) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	var event struct {
		Type string `json:"type"`
		Data struct {
			Message struct {
				Text string `json:"text"`
			} `json:"message"`
			History  []turn `json:"history"`
			ReplyURL string `json:"replyUrl"`
			AutoReply struct {
				State string `json:"state"`
			} `json:"autoReply"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &event); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if event.Type != "message.received" {
		w.WriteHeader(http.StatusOK) // "ping" from the connection test
		return
	}
	if event.Data.AutoReply.State != "on" {
		w.WriteHeader(http.StatusNoContent) // your team has it — stay quiet (or answer anyway)
		return
	}

	answer := myAI(event.Data.Message.Text, event.Data.History)
	reply := map[string]any{"text": answer.Text, "action": "none"}
	if answer.NeedsPerson {
		reply["action"] = "handover" // your team takes over in the inbox
		reply["note"] = answer.Reason // only your team sees it
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(reply)
}

func main() {
	http.HandleFunc("POST /meiti/chat", meitiChat)
	http.ListenAndServe(":3000", nil)
}
```

```csharp
// ASP.NET Core minimal API (works from .NET 8 on). MyAI stands for your own model, prompt and data.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

var app = WebApplication.CreateBuilder(args).Build();
var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("MEITI_AGENT_SECRET")!);

// X-Meiti-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + signed)>
bool Verify(string header, byte[] signed)
{
    var sig = header.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 false;
    var expected = Convert.ToHexString(HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(t + ".").Concat(signed).ToArray())).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(v1));
}

app.MapPost("/meiti/chat", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var body = buffer.ToArray(); // the exact bytes meiti signed
    if (!Verify(request.Headers["X-Meiti-Signature"].ToString(), body))
        return Results.Unauthorized();

    using var evt = JsonDocument.Parse(body);
    if (evt.RootElement.GetProperty("type").GetString() != "message.received")
        return Results.Ok(); // "ping" from the connection test

    var data = evt.RootElement.GetProperty("data");
    if (data.GetProperty("autoReply").GetProperty("state").GetString() != "on")
        return Results.NoContent(); // your team has it — stay quiet (or answer anyway)

    var answer = await MyAI.AnswerAsync(data.GetProperty("message").GetProperty("text").GetString(), data.GetProperty("history"));
    return Results.Ok(new
    {
        text = answer.Text,
        action = answer.NeedsPerson ? "handover" : "none", // handover: your team takes over in the inbox
        note = answer.NeedsPerson ? answer.Reason : null,   // only your team sees it
    });
});

app.Run();
```

```php
<?php
// meiti-chat.php. myAi() stands for your own model, prompt and data.
$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_AGENT_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"] !== "message.received") { http_response_code(200); exit; } // "ping" from the connection test

$data = $event["data"];
if ($data["autoReply"]["state"] !== "on") { http_response_code(204); exit; } // your team has it — stay quiet (or answer anyway)

$answer = myAi($data["message"]["text"], $data["history"], $data["contact"] ?? null);
header("Content-Type: application/json");
echo json_encode([
    "text" => $answer["text"],
    "action" => $answer["needsPerson"] ? "handover" : "none", // handover: your team takes over in the inbox
    "note" => $answer["needsPerson"] ? $answer["reason"] : null, // only your team sees it
]);
```

### BYOAI chat: answer later (POST /v1/projects/{id}/agent-replies)

```curl
# Your endpoint answered 202 (or 204). Later, with an API key of the same inbox:
curl https://api.meiti.ai/v1/projects/67890/agent-replies \
  -H "X-Api-Key: $MEITI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "text": "Ihre neue Lieferadresse ist eingetragen.", "action": "none" }'
# 202: { "projectId": 67890, "messageId": 991262, "status": "sent", "autoReply": { "state": "on" } }
# "autoReply.state" other than "on" = your team took over in the meantime; the text went out anyway.
# 409 project_not_with_agent: no own-AI assistant answers this project — drop the answer.
```

```js
app.post("/meiti/chat", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.get("X-Meiti-Signature"), req.body)) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  res.sendStatus(202); // answer right away …
  if (event.type !== "message.received" || event.data.autoReply.state !== "on") return; // your team has it

  myAI(event.data).then(async answer => { // … and reply when your AI is done
    const r = await fetch(event.data.replyUrl, {
      method: "POST",
      headers: { "X-Api-Key": process.env.MEITI_API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({ text: answer.text, action: "none" }),
    });
    const accepted = r.status === 202 ? await r.json() : null;
    if (accepted && accepted.autoReply.state !== "on") console.log("your team took over meanwhile — the answer went out anyway");
  });
});
```

```python
import threading, requests

@app.post("/meiti/chat")
def meiti_chat():
    body = request.get_data()
    if not verify(request.headers.get("X-Meiti-Signature", ""), body):
        abort(401)
    event = json.loads(body)
    if event["type"] == "message.received" and event["data"]["autoReply"]["state"] == "on":
        def later():
            answer = my_ai(event["data"])  # takes as long as it takes
            res = requests.post(
                event["data"]["replyUrl"],
                headers={"X-Api-Key": os.environ["MEITI_API_KEY"]},
                json={"text": answer.text, "action": "none"},
                timeout=10,
            )
            if res.status_code == 202 and res.json()["autoReply"]["state"] != "on":
                print("your team took over meanwhile — the answer went out anyway")
        threading.Thread(target=later).start()
    return "", 202  # answer right away
```

```go
// In meitiChat above, instead of the direct reply (add "bytes" to its imports):
w.WriteHeader(http.StatusAccepted) // answer right away …
go func(replyURL, text string) {   // … and reply when your AI is done
	answer := myAI(text, nil)
	b, _ := json.Marshal(map[string]any{"text": answer.Text, "action": "none"})
	req, _ := http.NewRequest("POST", replyURL, bytes.NewReader(b))
	req.Header.Set("X-Api-Key", os.Getenv("MEITI_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err == nil {
		res.Body.Close() // 202 with "autoReply": { "state": "off" } = your team took over meanwhile, the text went out anyway
	}
}(event.Data.ReplyURL, event.Data.Message.Text)
```

```csharp
// Answer 202 and queue the event like the CRM callback worker above (the request is gone after the 202).
// In the worker: http from the quickstart — its X-Api-Key header authorises the reply; replyUrl is absolute.
var replyUrl = evt.GetProperty("data").GetProperty("replyUrl").GetString();
var res = await http.PostAsJsonAsync(replyUrl, new { text = answer.Text, action = "none" }, ct);
if (res.StatusCode == System.Net.HttpStatusCode.Accepted
    && (await res.Content.ReadFromJsonAsync<JsonElement>(ct)).GetProperty("autoReply").GetProperty("state").GetString() != "on")
    Console.WriteLine("your team took over meanwhile — the answer went out anyway");
```

```php
<?php
// … signature check as in the handler above …
$event = json_decode($body, 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
}
if ($event["type"] !== "message.received" || $event["data"]["autoReply"]["state"] !== "on") exit; // your team has it

$answer = myAi($event["data"]);
$ch = curl_init($event["data"]["replyUrl"]);
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(["text" => $answer["text"], "action" => "none"]),
]);
curl_exec($ch); // 202 with "autoReply": { "state": "off" } = your team took over meanwhile, the text went out anyway
```

### BYOAI phone: session.started frame

```json
{
  "type": "session.started",
  "protocol": "2026-10-11",
  "callId": "482113",
  "callSid": "CA5f0c2e…",
  "projectId": 67890,
  "workspaceId": 245,
  "agentId": 812,
  "from": "+491701234567",
  "to": "+4930123456789",
  "language": "de-DE",
  "contact": { "id": 18342, "name": "Ingrid Weber" },
  "skills": [
    { "id": 412, "key": "buero", "name": "Ins Büro durchstellen", "kind": "forward", "announcement": "Einen Moment, ich verbinde Sie mit dem Büro." }
  ]
}
```

### BYOAI phone: WebSocket endpoint

```js
// Node + ws (npm i ws). Serve it behind TLS: meiti only connects to wss:// addresses.
import http from "node:http";
import { WebSocketServer } from "ws";

const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });

// The upgrade has no body: the signature covers the X-Meiti-Call-Id header (verify() from the chat handler).
server.on("upgrade", (req, socket, head) => {
  if (!verify(req.headers["x-meiti-signature"], req.headers["x-meiti-call-id"] ?? "")) {
    socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
    return socket.destroy();
  }
  wss.handleUpgrade(req, socket, head, ws => wss.emit("connection", ws, req));
});

wss.on("connection", ws => {
  let call;
  const send = frame => ws.send(JSON.stringify(frame));
  ws.on("message", async raw => {
    const frame = JSON.parse(raw);
    switch (frame.type) {
      case "session.started": // callId, projectId, from, to, language, contact, resumed?
        call = frame;
        break;
      case "prompt": { // the caller finished a sentence
        const answer = await myAI(call, frame.text);
        if (answer.done) send({ type: "action", action: "end", text: answer.text }); // spoken, then hang up
        else if (answer.needsPerson) send({ type: "action", action: "handover", text: answer.text, note: answer.reason });
        else send({ type: "text", text: answer.text, last: true }); // or stream pieces; the last one has last: true
        break;
      }
      case "interrupt": // the caller talked over you: stop streaming the old answer
        break;
      case "session.ended":
        ws.close();
        break;
    }
  });
});

server.listen(8080);
```

```python
# Python + websockets 13+ (pip install websockets). Serve it behind TLS: meiti only connects to wss://.
import asyncio, json
from http import HTTPStatus
from websockets.asyncio.server import serve

def check(connection, request):
    # The upgrade has no body: the signature covers the X-Meiti-Call-Id header (verify() from the chat handler).
    call_id = request.headers.get("X-Meiti-Call-Id", "")
    if not verify(request.headers.get("X-Meiti-Signature", ""), call_id.encode()):
        return connection.respond(HTTPStatus.UNAUTHORIZED, "bad signature\n")

async def handle(ws):
    call = None
    async for raw in ws:
        frame = json.loads(raw)
        if frame["type"] == "session.started":  # callId, projectId, from, to, language, contact, resumed?
            call = frame
        elif frame["type"] == "prompt":  # the caller finished a sentence
            answer = await my_ai(call, frame["text"])
            if answer.done:  # spoken, then hang up
                await ws.send(json.dumps({"type": "action", "action": "end", "text": answer.text}))
            elif answer.needs_person:
                await ws.send(json.dumps({"type": "action", "action": "handover", "text": answer.text, "note": answer.reason}))
            else:  # or stream pieces; the last one has "last": True
                await ws.send(json.dumps({"type": "text", "text": answer.text, "last": True}))
        elif frame["type"] == "session.ended":
            break

async def main():
    async with serve(handle, "0.0.0.0", 8080, process_request=check) as server:
        await server.serve_forever()

asyncio.run(main())
```

```go
// net/http + github.com/gorilla/websocket. Serve it behind TLS: meiti only connects to wss:// addresses.
var upgrader = websocket.Upgrader{}

type frame struct {
	Type string `json:"type"`
	Text string `json:"text"`
}

func meitiVoice(w http.ResponseWriter, r *http.Request) {
	// The upgrade has no body: the signature covers the X-Meiti-Call-Id header (verify() from the chat handler).
	if !verify(r.Header.Get("X-Meiti-Signature"), []byte(r.Header.Get("X-Meiti-Call-Id"))) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		return
	}
	defer conn.Close()

	var call map[string]any // session.started: callId, projectId, from, to, language, contact, resumed?
	for {
		_, raw, err := conn.ReadMessage()
		if err != nil {
			return // meiti closed the socket: the call is over
		}
		var f frame
		_ = json.Unmarshal(raw, &f)
		switch f.Type {
		case "session.started":
			_ = json.Unmarshal(raw, &call)
		case "prompt": // the caller finished a sentence
			answer := myAI(call, f.Text)
			switch {
			case answer.Done: // spoken, then hang up
				conn.WriteJSON(map[string]any{"type": "action", "action": "end", "text": answer.Text})
			case answer.NeedsPerson:
				conn.WriteJSON(map[string]any{"type": "action", "action": "handover", "text": answer.Text, "note": answer.Reason})
			default: // or stream pieces; the last one has "last": true
				conn.WriteJSON(map[string]any{"type": "text", "text": answer.Text, "last": true})
			}
		case "session.ended":
			return
		}
	}
}

func main() {
	http.HandleFunc("GET /meiti/voice", meitiVoice)
	http.ListenAndServe(":8080", nil)
}
```

```csharp
// ASP.NET Core with WebSockets. Serve it behind TLS: meiti only connects to wss:// addresses.
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;

app.UseWebSockets();
app.Map("/meiti/voice", async (HttpContext context) =>
{
    // The upgrade has no body: the signature covers the X-Meiti-Call-Id header (Verify() from the chat handler).
    var callId = context.Request.Headers["X-Meiti-Call-Id"].ToString();
    if (!context.WebSockets.IsWebSocketRequest
        || !Verify(context.Request.Headers["X-Meiti-Signature"].ToString(), Encoding.UTF8.GetBytes(callId)))
    {
        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
        return;
    }

    using var ws = await context.WebSockets.AcceptWebSocketAsync();
    var ct = context.RequestAborted;
    Task Send(object frame) =>
        ws.SendAsync(new ArraySegment<byte>(JsonSerializer.SerializeToUtf8Bytes(frame)), WebSocketMessageType.Text, true, ct);

    var buffer = new byte[64 * 1024];
    JsonElement call = default;
    while (ws.State == WebSocketState.Open)
    {
        int count = 0;
        ValueWebSocketReceiveResult result;
        do
        {
            result = await ws.ReceiveAsync(buffer.AsMemory(count), ct);
            count += result.Count;
        } while (!result.EndOfMessage && result.MessageType != WebSocketMessageType.Close);
        if (result.MessageType == WebSocketMessageType.Close) break;

        using var doc = JsonDocument.Parse(buffer.AsMemory(0, count));
        var frame = doc.RootElement;
        switch (frame.GetProperty("type").GetString())
        {
            case "session.started": // callId, projectId, from, to, language, contact, resumed?
                call = frame.Clone();
                break;
            case "prompt": // the caller finished a sentence
                var answer = await MyAI.AnswerAsync(call, frame.GetProperty("text").GetString());
                if (answer.Done) await Send(new { type = "action", action = "end", text = answer.Text }); // spoken, then hang up
                else if (answer.NeedsPerson) await Send(new { type = "action", action = "handover", text = answer.Text, note = answer.Reason });
                else await Send(new { type = "text", text = answer.Text, last = true }); // or stream pieces; the last one has last = true
                break;
            case "session.ended":
                await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
                break;
        }
    }
});
```

### BYOAI skills as tools: executeSkills, the phone skill frame, POST /v1/projects/{id}/skills/{key}

```curl
# Run one skill now (chat semantics), e.g. from your own backend. Optional body and Idempotency-Key.
curl https://api.meiti.ai/v1/projects/67890/skills/notdienst \
  -H "X-Api-Key: $MEITI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c2a9e-notdienst" \
  -d '{ "note": "Wasser tritt aus der Decke, Kundin ist vor Ort." }'
# 200: { "key": "notdienst", "ok": true }
# 200: { "key": "notdienst", "ok": false, "reason": "outside-hours" }
# 409 project_not_with_agent: your team has the conversation — nothing ran.
```

```js
// In the phone handler (see above): let your AI decide, meiti does the transfer.
case "prompt": {
  const answer = await myAI(call, frame.text, call.skills); // call = session.started, skills listed there
  if (answer.forwardTo) {
    send({ type: "text", text: answer.text, last: true });
    send({ type: "skill", executeSkills: [answer.forwardTo] }); // e.g. "buero"
  } else {
    send({ type: "text", text: answer.text, last: true });
  }
  break;
}
case "skill.result": // { key, ok, reason }: tell the caller if a forward could not run
  if (!frame.ok) send({ type: "text", text: await myAI.explain(frame.reason), last: true });
  break;
```

```json
// Chat: the 200 answer of your chat endpoint (or the body of POST /v1/projects/{id}/agent-replies).
{
  "text": "Ich gebe unserem Notdienst Bescheid und verbinde Sie mit dem Büro.",
  "executeSkills": ["notdienst", "buero"],
  "note": "Wasser tritt aus der Decke, Kundin ist vor Ort."
}

// Phone: a frame on the WebSocket …
{ "type": "skill", "executeSkills": ["notdienst", "buero"], "note": "Wasser tritt aus der Decke." }
// … and meiti's answer per skill. After an ok forward the call leaves your AI (session.ended).
{ "type": "skill.result", "key": "notdienst", "ok": true }
{ "type": "skill.result", "key": "buero", "ok": true }
// Nobody answered: the caller is back on a new connection.
{ "type": "session.started", "resumed": "handover_failed", "resumedAfter": { "key": "buero", "ok": false, "reason": "no-answer" }, "…": "…" }
```

### Prompt for coding agents

```text
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>
```

## MCP: set meiti up from an AI client

- [MCP guide](https://meiti.app/docs/mcp): the setup MCP server for Claude Code, Codex, Grok or any client that speaks Streamable HTTP. Server URL: `/mcp/setup` on the meiti MCP host — copy the exact URL and a bearer token (valid 8 hours) from the app: Settings → Setup & operations → Connected systems → MCP server access (Enterprise plan or on request). It configures assistants, skills, knowledge, hours and tests; for your software's day-to-day traffic use the REST API.
- Account health and guided setup: `accounthealth_get`, `accounthealth_whatsapp`, `accounthealth_updatebusinessinfo`, `accounthealth_confirmwhatsapppayment`, `accounthealth_whatsappsignuplink`
- Assistants: `botprofile_getbotprofiles`, `botprofile_getbotprofile`, `botprofile_addbotprofile`, `botprofile_updatebotprofile`, `botprofile_setupvoice`, `botprofile_upsertsystemprompt`, `botprofile_setupchat`, `botprofile_setgreeting`, `botprofile_setupemail`, `botprofile_attachskill`, `botprofile_setsimpleconversation`, `botprofile_setforwardtarget`, `botprofile_copyprofile`, `botprofile_verifyversion`, `botprofile_getskillnet`, `botprofile_getreport`, `botprofile_getagentstatus`
- Skills and handovers: `skills_getskill`, `skills_getskills`, `skills_addskill`, `skills_addworkflowskill`, `skills_updateskilltitles`, `skills_updateskillprompt`, `skills_updateskilltrigger`, `skills_updateskillquestionlimit`, `skills_addtriggercondition`, `skills_updatetriggercondition`, `skills_deletetriggercondition`, `skills_getworkflowquestions`, `skills_setworkflowquestions`, `skills_setappointmentapproval`, `skills_setapprovalpolicy`, `skills_gethandovertargets`, `skills_updatehandovertarget`, `skills_getcallbacknotestatus`, `skills_ensurecallbacknote`
- Standard replies: `standardmessages_setreplycontent`, `standardmessages_setreplyapproval`, `standardmessages_setreplyguidance`, `standardmessages_setreplyskills`
- Knowledge: `knowledge_list`, `knowledge_upsert`, `knowledge_delete`, `corpus_list`, `corpus_create`, `corpus_set_enabled`, `corpus_delete`, `corpus_import`
- Hours, phone and forwarding: `businesshours_get`, `businesshours_postworkinghours`, `forwardtargets_get`, `forwardtargets_upsert`, `forwardtargets_updatedetails`, `forwardtargets_delete`, `voiceconfig_getconfig`, `voiceconfig_savevoiceonly`, `voicebotsettings_setactiveprofile`, `voicebotsettings_postsettings`, `channels_renamesipdevice`
- Tests: `assistanttests_get`, `assistanttests_upsert`, `assistanttests_delete`, `assistanttests_run`, `assistanttests_runs`, `assistanttests_rundraft`, `assistanttests_resolvetime`, `assistanttests_preflight`, `assistanttests_generate`, `assistanttests_extendtrigger`, `assistanttests_triggersuggestion`, `assistanttests_capturetrigger`, `assistanttests_disambiguate`, `assistanttests_applydisambiguation`, `assistanttests_clarifyfirst`, `assistanttests_answerinterview`, `assistanttests_graduate`, `assistanttests_voicedrivestart`, `assistanttests_voicedriveturn`, `assistanttests_voicedrivestop`, `run_persona_tests`
- Decision rules and insights: `decisionrules_get`, `decisionrules_delete`, `decisionrules_calibration`, `insights_refresh_outcomes`
- Inbox: `workspacefeatures_get`, `readreceiptmode_readreactionmode`
- Feedback to meiti: `report_capability_gap`
- Organisation (organisation-wide token only): `org_get_map`

## meiti: product, pricing, app, support, legal

- [meiti.ai](https://www.meiti.ai/)
- [Pricing](https://www.meiti.ai/preise)
- [Compare plans](https://www.meiti.ai/app-planvergleich)
- [Understanding credits](https://meiti.app/hilfe/credits-verstehen)
- [FAQ](https://www.meiti.ai/faq)
- [App releases](https://www.meiti.ai/app-releases)
- [App Academy](https://meiti.app/docs)
- [Use cases](https://meiti.app/docs/anwendungsfaelle)
- [API](https://meiti.app/docs/developers)
- [Webhooks](https://meiti.app/docs/developers/webhooks)
- [BYOAI](https://meiti.app/docs/developers/byoai)
- [MCP](https://meiti.app/docs/mcp)
- [Changelog](https://meiti.app/docs/changelog)
- [100% EU telephony](https://meiti.app/docs/academy/eu-routing)
- [How to get started](https://meiti.app/docs/academy/loslegen)
- [Start for free](https://meiti.app/go)
- [Book a compliance call](https://outlook.office365.com/book/booking@meiti.ai/)
- [App Store](https://apps.apple.com/app/id1596933887)
- [Google Play](https://play.google.com/store/apps/details?id=com.meitihandwerk.meiti)
- [Support on WhatsApp](https://wa.me/498962825222?text=Tippe+senden%2c+um+den+Chat+zu+starten)
- [Community](https://www.meiti.ai/community)
- [Partner program](https://partner.meiti.ai)
- [Legal notice](https://www.meiti.ai/impressum)
- [Privacy](https://www.meiti.ai/app-datenschutz)
- [Terms](https://www.meiti.ai/app-agb)
- [Data processing agreement](https://www.meiti.ai/app-avv)

# App Academy, use cases and customer stories

## Get started: meiti in four steps

https://meiti.app/docs/academy/loslegen?lang=en · Basics

The fastest way from "curious" to "running": get the app, create an API key for your systems, request MCP access if you want it — and have everything unlocked in a compliance call that carriers and Meta require paperwork for.

- Get the app — setting up your first inbox happens in the app.
- Create an API key per system; MCP access on request.
- The compliance call is the fastest way to verification, your own numbers and senders.

### The four steps


1. Create your account and get the app — on the web under "Start for free" (/go) with your mobile number or e-mail address, or straight in the app from the App Store or Google Play. The app guides you through your first inbox.
2. Create an API key: "Settings" → "Setup & operations" → "Connected systems" → "API access" → "Create key". One key per system (shop, CRM, Zapier …). It's shown only once.
3. Optional: request MCP access — set up inboxes and assistants straight from Claude, Codex or any other MCP client. We enable it for your organisation; then you'll find it under "Connected systems" → "MCP server access".
4. Book a compliance call — in one appointment we collect everything needed for verification, numbers and senders.

### API or MCP?


- API: your software sends messages, creates contacts and appointments and reads conversations — permanently, with one key per system.
- MCP: an AI like Claude sets meiti up for you — inboxes, assistants, skills, tests. Ideal for building, not for running your software.

### The compliance call


Own numbers, caller IDs, WhatsApp and named senders require paperwork — from carriers, from Meta and from regulation. In the compliance call we collect everything in one appointment and submit it for you. It's the fastest way to get verified and have numbers and senders unlocked.

**What we unlock** — Depending on what you need.
- Your own local or mobile number — ported too
- Clearance as caller ID — for the 100% EU path
- WhatsApp Business with Meta verification
- SMS sender and RCS with name and logo

**What to have ready** — We'll tell you beforehand exactly what your setup needs.
- Company details: name, legal form, business address
- A proof, e.g. trade registration or commercial register extract
- Logo and brand name if you want RCS
- Existing numbers if we should port them

> A local number needs a business address in that area — the carrier checks it. We sort this out with you on the call.

## How it all fits together

https://meiti.app/docs/academy/so-haengt-alles-zusammen?lang=en · Basics

meiti has six building blocks. Once you know how they connect, you'll find every setting straight away.

- A channel (number, WhatsApp, email, web chat) belongs to exactly one inbox.
- One assistant answers on each channel. An assistant can serve several channels.
- Skills are the assistant's abilities. Their result is a ticket — with a contact, appointment, task or alarm.

### One call, step by step


1. Ms Weber calls your number. The number is the Phone channel in the "Customer service" inbox.
2. The "Front desk" assistant is active on that channel. It greets her in its voice and knows the inbox's knowledge.
3. She wants a maintenance appointment. The assistant starts the "Book appointment" skill and offers free slots from your calendar.
4. After the call the wrap-up skills run: summary, a task for the office.
5. The "Inbox" tab now has a ticket with contact, appointment, task and transcript — for the whole team.

### The building blocks


| Block | What it is | Where in the app |
| --- | --- | --- |
| Organisation | Plan, credits, members, inboxes | Settings → Organization |
| Inbox | Workspace for a team or a person | Inbox name at the top / "Switch inbox" |
| Channel | Where customers reach you: phone, WhatsApp, email, web chat, SMS | Setup & operations → Where customers reach you |
| Assistant | Who answers — with persona, voice, knowledge | Settings → AI assistants |
| Skill | What the assistant can do: book, take details, put through … | Settings → Skills |
| Ticket | The result: a conversation with everything that belongs to it | "Inbox" tab |

> Knowledge belongs to the inbox and applies to all its assistants. Skills belong to the inbox too — you can give the same skill to several assistants.

## Inboxes: team or personal

https://meiti.app/docs/academy/postfaecher?lang=en · Basics

The inbox is where daily work happens: conversations, contacts, appointments, channels and assistants. The organisation above it pays and manages people.

- Organisation = plan, credits, members. Inbox = a workspace underneath.
- Team inbox: belongs to the business. Several people work in it, each with a role.
- Personal inbox: belongs to you alone. Colleagues see none of it — admins included.

### Two kinds of inbox


**Team inbox** — For work that belongs to the business.
- Several members with roles
- All channels: phone, WhatsApp, SMS, email, web chat
- Public booking page
- Keeps running when someone is on holiday or leaves

**Personal inbox** — For your own line — truly private.
- Exactly one owner, no other members
- No web chat, no booking page, no SMS
- Calls start in smart voicemail mode
- Doesn't count towards the organisation's inbox limit

Rule of thumb: may a colleague open these conversations at all? Then team. Otherwise personal. You can always hand single cases over to a team inbox.

### Switch between inboxes


1. Tap the inbox name at the top of the "Inbox" tab — or "Switch inbox" under "Settings".
2. Pick the target. The current inbox is labelled "Current".
3. Check the name in the header before you reply.

A counter on the switcher shows how many other inboxes have unread conversations. The bell next to an inbox mutes its notifications.

### Members and roles


A person becomes a member of the organisation once. Then they get access to exactly the inboxes they work in — with one role per inbox.

| Role | Sees | Can configure |
| --- | --- | --- |
| Owner | Everything | Everything, including the plan |
| Administrator | All enquiries | All settings of this inbox |
| Manager | All enquiries | No inbox settings |
| Employee | Only selected enquiries | No inbox settings |

1. "Settings" → "Organization" → "Members" → "Add employee".
2. Enter a mobile number or email and choose the "Role in the inbox" (default: Manager).
3. "Invite now" — or copy the invitation link and share it yourself.
4. Grant further inboxes later on the person's page under "Inbox access".

> Billing and reading are separate: whoever may "Manage organisation" handles plan, members and inboxes — but doesn't automatically read an inbox's conversations.

## AI assistants

https://meiti.app/docs/academy/assistenten?lang=en · Basics

An assistant is your business's voice. It has a name, a personality, a voice and skills — and it learns from real conversations.

- When you create one you choose what for: all channels, phone, chat or email. You can't change it later.
- Knowledge belongs to the inbox and applies to every assistant in it.
- Use "Try it as a customer" to test it before customers do.

### What should this assistant take care of?


- Simple – all channels: one assistant for everything, with one AI conversation.
- Phone: call mode, voice, greetings, skills.
- Chat: WhatsApp and web chat.
- Email: drafts replies.

### What you set on an assistant


- Persona and conversation goal: name, formal or informal, tone — and what it's there for.
- Voice and greetings for the phone, chat greeting for chats.
- Skills — see the next lesson.
- When meiti needs help: who takes over when it gets stuck.

### Knowledge


"AI Basics & Knowledge" holds the base rules (what always applies) and your knowledge: entries, Q&A, free text or an uploaded PDF. When the assistant doesn't know something, it creates an open question — you answer it later, and it knows next time.

### Learning progress


| Stage | Meaning |
| --- | --- |
| Setup | You're setting it up. |
| Training | It works, you correct. |
| Passed | It passed the tests. |
| Proven | At least 25 real conversations, at least 21 days in use and at most one hard correction among the last 25. Or you confirm it as proven yourself. |

## Skills: what your assistant can do

https://meiti.app/docs/academy/skills?lang=en · Basics

A skill is an ability with a clear start and a clear result. The assistant picks the right one during the conversation — and after it, the wrap-up skills run.

- Skills run during the conversation, wrap-up afterwards.
- Every skill answers four questions: when does it start? Does it need approval? What does it do? What happens afterwards?
- Skills belong to the inbox — you can give one skill to several assistants.

### Four questions per skill


1. When does the skill start? A start condition in plain words, e.g. "customer wants a maintenance appointment". Optionally only at certain times.
2. Does the team need to approve? Then a card appears on "Start" to confirm first.
3. What does the skill do? Book, ask questions, put through …
4. What happens afterwards? e.g. a closing message to the customer.

### Which skills there are


**During the conversation** — The assistant picks the right one.
- Book appointment
- AI intake — take the request freely
- Contact data form
- Questionnaire
- Forward call
- Live notification

**Wrap-up** — Runs after the call or when the chat pauses.
- Summarize
- Create task
- Create draft response
- Alert
- Assign
- Analyze image

> If the wrong skill starts, sharpen the start condition — short and distinct. If a request stays unclear, the fallback skill under "When meiti needs help" takes over.

### Switching a skill off


Under "Settings" → "AI assistants" → pick the assistant, tap ✕ next to the skill and then "Remove". The skill stays in the inbox and can be added again later. If only the alerts arrive the wrong way, pick another channel (SMS/RCS, WhatsApp, email) or "Push only" in the skill under "How your team finds out" → "Additional channel".

## Channels and senders

https://meiti.app/docs/academy/kanaele?lang=en · Channels & numbers

A channel is a way customers reach your inbox. A sender is the address meiti replies from — your WhatsApp number, your mailbox or your SMS short name.

- Find all channels under "Setup & operations" → "Where customers reach you".
- One assistant is active on each channel — with a schedule you can swap in another for a while.
- SMS is a pure sender: customers can't reply, unless you have a mobile number.

### The channels


- Phone: your meiti number or own number, optionally your phone system via SIP (beta).
- WhatsApp: try it for 14 days with the shared meiti test number, then connect your own WhatsApp number.
- Online enquiries: the chat for your website ("Your website") or as a link ("meiti link").
- Email accounts: as many mailboxes as you like. The assistant drafts, you review and send.
- SMS & RCS: messages after calls, reminders. RCS shows your name and logo.

### Who answers on which channel?


1. Open the channel → "Choose active assistant".
2. Pick the assistant. It must be ready for the channel: phone needs a voice, WhatsApp and web chat a chat greeting.
3. Optionally add a schedule — once or weekly. Afterwards the default assistant takes over again.

### Senders


| Sender | Customer can reply? | Note |
| --- | --- | --- |
| WhatsApp number | Yes | One per inbox |
| Mailbox | Yes | As many as you like per inbox |
| SMS short name | No | For replies: your own mobile number |
| RCS | Yes | With name and logo — we help with the paperwork |

For developers: GET /v1/senders lists an inbox's senders, and POST /v1/messages takes a priority list such as ["whatsapp", "sms"].

> Personal inboxes have no web chat and no SMS.

## Phone numbers: meiti number, local number, mobile number

https://meiti.app/docs/academy/nummern?lang=en · Channels & numbers

Every inbox gets a meiti number right away. You keep your familiar number — you simply forward it there. If you'd rather work without forwarding, we set up a number of your own.

- The meiti number (089) is rented and is the target of your call forwarding — not a number to publish.
- You request your own local number — new, or your existing one ported — or a mobile number in the app. meiti then becomes your phone provider, and your calls can stay 100% in the EU.
- You can verify your existing number as a caller ID, so customers see it when you call.

### The options at a glance


| Number | How you get it | Customers reach you | Shown when you call | SMS |
| --- | --- | --- | --- | --- |
| meiti number (089) | Automatic: "Activate voicemail" | Through your call forwarding | No | No |
| Your own local number | "Request a local number" — we set it up | Directly — you publish it | Yes, once cleared | Via a short name |
| Mobile number | On request — we set it up | Directly | Yes, once cleared | Yes, two-way |
| Ported number | On request — we move your existing number to meiti | Directly — your familiar number | Yes, once cleared | Via a short name |
| Your existing number | Stays with your provider | Forwarded to your meiti number | As a verified caller ID | Via a short name |

> The meiti number runs on meiti's address and doesn't belong to you. It takes calls, but doesn't call out and doesn't receive SMS. If you give up the inbox it goes back into our pool and may be reassigned later. So don't print it on business cards — publish your own number and forward it.

### Set up call forwarding


Call forwarding sends calls to your familiar number on to meiti. The app guides you through it and dials the code for you.

1. Open the number under "Forwards from you to your AI" and tap "Set up forwarding".
2. Choose what should go to meiti: all calls or missed calls (also with your own ring time).
3. Dial the code. On iOS you copy it and dial it yourself in the phone app.
4. Test with a call from another phone.

Tip from the guide: turn off Wi-Fi and Live Voicemail first. ##002# switches all forwarding off again.

### When is your own number worth it?


**Your own local number** — "Done with call forwarding?"
- New — or your existing number, ported
- meiti becomes your phone provider
- The path to 100% EU — in and out
- Handsets, softphones and your phone system get connected
- Is cleared as caller ID after a check
- Needs a business address in that area — the carrier checks it

**Mobile number** — When customers should reply by SMS.
- Calls and SMS both ways
- Replaces the short name as SMS sender
- Is cleared as caller ID after a check
- Costs more than a local number

Only the inbox owner can request one: "Request a local number" on the phone page, or "Request a local meiti number" in the call screen. To keep your existing number, say we should port it. Mention the mobile number in the SMS section or to support. We get in touch and set it up with you.

### Your existing number as caller ID


1. "Settings" → "Setup & operations" → "Phones & numbers" — or "Register as caller ID" on a forwarding line.
2. You get a call and enter the code shown.
3. When calling, pick it under "This is what your customer sees".

> Calls with a verified caller ID run through the US. How to stay 100% in the EU is in the lesson "100% EU".

## How meiti answers calls

https://meiti.app/docs/academy/anrufe?lang=en · Telephony

A call lands with the assistant that is active on your number. The call mode decides what the caller experiences. Everything else is fine-tuning.

- Five call modes — from a free AI conversation to "Forward immediately".
- "When meiti needs help" decides who takes over when the AI can't continue.
- You can put single contacts straight through, mark them personal or block them.

### The call mode


"Settings" → "AI assistants" → assistant → "Phone" → "Call mode".

| Mode | What the caller gets | Good for |
| --- | --- | --- |
| AI secretary | A free conversation like with a human secretary. Skills book appointments, take orders, put calls through. | Default, most businesses |
| IVR | A phone tree like at large companies. | Clearly separate topics |
| Smart voicemail | Greeting, leave a message, finish the request via WhatsApp. Optionally with a menu. | Personal inboxes, time away |
| Announcement, then hang up | Just an announcement, then the call ends. No message, no ticket. | Closures with a pointer to other channels |
| Forward immediately | No AI, no greeting: it rings straight away with a person or number. If nobody answers, the call ends. | When only people should take calls today |

### The key details


- Voice and greetings: per mode, your own text or a voice recording.
- Text automatically after the call: the caller gets a message — WhatsApp first, otherwise SMS. Default: on.
- Call recording: off by default. When on, meiti stores whole AI conversations. Check first that you're legally allowed to.
- Anonymous calls: handle normally, send to classic voicemail, or block.

### When meiti needs help


Set two moments. Without the second, meiti ends the conversation — a forward to you or an emergency number is recommended.

- Request stays unclear: after a few follow-up questions a fallback skill takes over, e.g. a callback note.
- AI must not continue: emergency, sensitive topic or abuse — a person takes over.

### Where meiti puts calls through


**A person** — meiti calls the person, announces the caller and connects.

**A ring group** — Several steps — one after another or at the same time.

**Another inbox** — The assistant there takes over the call.

**A number** — Any verified number, e.g. your emergency service.

Create ring groups under "Phones & numbers" → "Ring groups". Careful with ringing at the same time: a mailbox or forwarding on one target can end the call for everyone.

Whoever the call is put through to normally sees the caller's number. For a withheld number — or a number from outside the EEA when the forward goes to a number inside the EEA (e.g. a German mobile) — meiti shows your inbox's number instead. Forwards abroad cost our telephony provider's connection charges plus a small markup; meiti doesn't call special-rate numbers or destinations without a rate.

### Rules for single contacts


"Contacts" → open a contact → "Assistant" section:

| Setting | Effect |
| --- | --- |
| Put calls through | This contact's calls ring straight with you or a colleague — always, or just the next call. If nobody answers, they get voicemail. Overrides every call mode. |
| Personal communication | No AI conversation: classic voicemail takes messages. |
| Blocked | meiti ignores this contact's calls and messages. |

### Business hours, holidays, cover


- Business hours ("Setup & operations"): control the outside-hours greeting in smart voicemail.
- Schedule another assistant: on the assistant "Switch / schedule" → "Add schedule" — once or weekly. Afterwards the previous one takes over again.
- Holidays: schedule the prepared "Holiday" assistant for your time away.
- Your availability: "Customer calls to you" in the "Inbox" tab under "Internal" — always, working hours only, cover, or away.

## Calling with the meiti app

https://meiti.app/docs/academy/telefonieren-in-der-app?lang=en · Telephony

The app is your phone too: calls put through ring in it, you can take over a live AI call and call customers straight from the ticket.

- Turn on "Calls in the meiti app" once per device — then all your devices ring together.
- During an AI call you can pull it to you with "Take over call".
- Outbound: "meiti Call" dials in the app, "Via phone number" rings your mobile first.

### Take calls in the app


1. "Setup & operations" → "Phone" → "Calls in the meiti app".
2. Turn calls on for each device.
3. On a Windows computer you can also do it in the "team & phone" window: click your status under "Customer calls to you" → "Calls on this computer".
4. If nobody answers in the app, your mobile number rings next.

A device rings for one organisation at a time.

### Take over and transfer


- Take over call: the ticket shows the live call — one tap and you talk to the customer yourself.
- Forward during the call: talk to the colleague first (recommended — the caller waits, then you put through or go back) or put through immediately.
- During the call you also have keypad, minimise and hang up.

### Call customers


Tap "Call" in the ticket or on the contact. Then choose the path:

**meiti Call** — You talk directly in the app.
- Shows your own local meiti number
- iOS and Android

**Via phone number** — meiti rings your phone first, then the customer.
- Works without app calls
- Shows your meiti number or a verified caller ID
- Instead of a number, also your phone with the meiti app or your desk phone — e.g. dial on the computer and talk on the phone (with your meiti number on display)

- This is what your customer sees: your meiti number (once cleared) or a verified caller ID. Without either, no call is possible.
- Transcript and recording: recording "Off", "My side" or "Both sides". With the transcript or a recording of both sides on, the other side hears an announcement first, as long as "Announcement before the call" is on in the phone system settings (the default). An admin can replace the sentence there with their own – the pencil next to the announcement leads straight there.
- Emergency numbers are dialled by your phone, not by meiti.

> "Via phone number" to a phone number and calls with a verified caller ID run through the US. If your meiti app or desk phone rings first, the call stays in the EU. For EU-only calls see "100% EU".

## Team communication: chat, call, hand over

https://meiti.app/docs/academy/team?lang=en · Organise

Customer conversations and team conversations live in the same app — cleanly separated. Customers never see what you discuss internally.

- In the "Inbox" tab under "Internal": chats and app calls with colleagues.
- Assign = someone in the same inbox takes over. Hand over = the ticket moves to another inbox.
- As soon as a person replies, the AI steps back in that conversation.

### Internal: chats and calls


- Chats are person to person and the same in every inbox.
- Share a ticket: "Share with team" in the ticket. Sharing grants no access — without inbox access a colleague only sees the card.
- Team calls run app to app. They cost 3 credits per started minute; missed calls cost nothing. If your colleague doesn't answer, their own ring group rings next — there too only in the meiti app. If they decline, the call ends.
- Alarms come from alert skills and approvals. "Done" closes the alarm for everyone.

> "Internal" is in the meiti app, not yet in the web version.

### Assign or hand over?


|  | Assign | Hand over |
| --- | --- | --- |
| Target | A person in the same inbox | Another inbox in the same organisation |
| Ticket | Stays where it is | Moves — with history, notes and files |
| Customer notices | Nothing | Nothing |
| Where | Ticket → "Assigned" → "Who takes over?" | Same sheet → "Hand over · Another inbox" |

When handing over, choosing a person is optional — without one, the whole target team takes over. If you have no access to the target, you won't see the ticket afterwards.

### People and AI in one conversation


- Internal note (ticket → "Note"): neither the customer nor the AI sees it.
- You reply yourself: the AI stops replying fully in this conversation — at most it confirms receipt, and for 30 minutes after each of your messages not at all.
- Back to meiti: "Return fully to meiti" or "Mute for 30 minutes".
- Takeover request: when meiti needs a person you get a takeover request and tap to take over the chat.

### When customer calls ring for you


Under "Customer calls to you" you decide whether customer calls put through to you ring: always, only during your working hours, call a substitute, or away. Colleagues and alarms always reach you.

## Calendars and appointments

https://meiti.app/docs/academy/termine?lang=en · Organise

meiti books appointments into its own calendar — matching your team's working hours and the calendars you connect. Reminders and the customer appointment page do the rest.

- An appointment type sets duration, buffer, lead time and location — and who takes the appointment.
- You connect external calendars via an iCal link. They block times but aren't changed in meiti.
- When the AI books, it does so via the "Book appointment" skill.

### Switch on


"Setup & operations" → "Enable appointment features". Only then does the "Calendar" tab appear. meiti only books automatically if an assistant has the "Book appointment" skill.

### Appointment types


- Duration, buffer, lead time — default: 60 minutes, no buffer, at least 8 hours' notice.
- Location: in person, phone call or video call.
- Employee assignment: anyone, customer chooses, or a specific person.
- Confirm appointment immediately: off by default — then your team confirms.

### Connect calendars


"Setup & operations" → "Calendar & Appointments" → "Shared and employee calendars". Paste your calendar's iCal link:

**Subscribe to calendar** — Stays up to date — for your work calendar.

**Import** — A one-off snapshot — e.g. a public-holiday calendar.

Each calendar applies to everyone or to one person. The other way round, show meiti appointments in your calendar with "Activate online calendar". With HERO, meiti syncs appointments directly (under "CRM & industry software").

### What customers get


- Reminders 24 hours and 30 minutes before — once both customer and business have confirmed. You need your own WhatsApp number for this.
- Appointment page: customers see the appointment, add it to their calendar and can cancel.
- Another time: customers request it — your team sets the new time.

## Tasks

https://meiti.app/docs/academy/aufgaben?lang=en · Organise

Tasks capture what still needs doing after a conversation. You create them yourself — or meiti does it for you after the conversation.

- Find tasks on the "Start" tab and in every ticket.
- The "Create task" skill creates one after the conversation automatically — with title and due date.
- Without a set person, the task lands on the team's shared list.

### How to start


1. "Setup & operations" → switch on "Track tasks in meiti".
2. On "Start" → "New task" — or right in the ticket.
3. For automatic tasks give the assistant the "Create task" skill in wrap-up.

### Filters


- Due — what's up today.
- Unassigned — the team's shared list.
- Assigned to others, Snoozed, Recently finished and Private for your own.

> meiti doesn't create a duplicate task on a ticket while the previous one from the same skill is still open. If the team should agree first, turn on approval on the skill — then "Create task?" appears on "Start".

## 100% EU: keep your calls in Europe

https://meiti.app/docs/academy/eu-routing?lang=en · Privacy & compliance

There is a path on which your calls stay entirely in the EU: get a local number from meiti — new, or your existing one ported — and use it for everything. Telephony then runs with our carrier Twilio in Ireland, and meiti itself in Frankfurt.

- The 100% EU path: get a local number at meiti — new or ported — and use it, for incoming and outgoing calls.
- In: customers dial that number. Out: "meiti Call" with that number on display.
- Only three things run through the US: a verified caller ID, the "Via phone number" path when meiti first rings a phone number, and SMS. Leave them out.

### The 100% EU path in three steps


1. Get the number: "Request a local number" (on the phone page). We set up a new local number — or port your existing number to meiti. A mobile number works too.
2. Use the number: customers call it directly — without forwarding through another provider. If you ported, it's your familiar number anyway.
3. Call out with it: choose "meiti Call" in the call screen and pick this number under "This is what your customer sees".

> Don't use these if everything should stay in the EU: a verified caller ID, the "Via phone number" path, and SMS. Send after-call messages via WhatsApp instead.

### What runs where


**Stays in the EU** — Twilio region Ireland, meiti in Frankfurt.
- Calls to your meiti local or mobile number — new or ported
- "meiti Call" with that number on display
- "Via phone number" when your meiti app or desk phone rings first — with that number on display
- Incoming calls to the rented meiti number (089), forwarded ones too

**Runs through the US** — Twilio region US.
- Calls showing a verified caller ID (your number with another provider)
- Outbound calls "Via phone number" where meiti first rings a phone number — whatever number is shown
- SMS

Why? A number held at meiti belongs to your account and runs in its EU region. Twilio only accepts a verified caller ID — a number held by another provider — in its US region, and a "Via phone number" call to a phone number is set up there. If your meiti app or desk phone rings first, meiti sets it up in your account's EU region. Recordings sit in the region where the call ran and are also copied to meiti.

> With just the rented 089 number and call forwarding, incoming calls run in the EU too. But the 089 number doesn't call out — for the complete EU path you need your own number at meiti.

> This page covers the phone path. Which providers handle AI speech processing (speech recognition, voice, language model) and where is in our privacy policy and data processing agreement. Inboxes set up before the EU switch we check on request — just write to support.

## meiti on meiti: our support and sales run on the same tools as yours

https://meiti.app/docs/anwendungsfaelle/meiti-auf-meiti?lang=en · Use case

We are our own first customer. meiti's support, our sales and every message to customers run through the same inbox, the same assistants and the same public API as yours. That way we talk to every customer personally — and automate everything that can be automated.

- One inbox for support, sales and account care — on WhatsApp and phone.
- The assistant answers and sorts, our team takes over wherever it needs to be personal.
- Help requests from the app and signals from our systems come in through the public API — like for any integrator.

### One inbox for everything


- Support on WhatsApp and phone: our assistant answers, handles standard questions and opens a ticket. Each plan has its own entry point, so requests land in the right place.
- Developer support: a separate number for integrations — WhatsApp or call, no email.
- Help requests from the app: when a customer taps "Request a local number", a ticket opens on our side with their account as context. The app shows them the request is already in progress.

### Personal where it matters — automatic where it can be


**Automatic** — What the assistant and our systems handle.
- Answer, sort, handle standard questions
- Open a ticket with contact and account
- Summary and task after every conversation
- Signals from our systems arrive via the API

**Personal** — What our team takes over.
- Walk through setup together, call back
- Review every draft before it goes out
- Coordinate internally: team chat, notes, handover
- Call out — straight from the ticket

### Account care through our own API


We're building the next step right now: our systems spot accounts that need help — setup not finished, no call forwarding, WhatsApp not connected, trial ending, no calls or failing calls. For each account they call our own public API and drop a ready draft into our inbox. A team member reviews and sends it.

> It's the same API you use for your software. What works for us works for you — and the other way round.

### Why this fits SaaS support and outbound


- One timeline per customer: calls, WhatsApp, email and signals from your system in the same ticket.
- No tool zoo: no separate ticket system, dialer or SMS gateway.
- Automate without losing control: drafts instead of blind sending, the team approves.
- Your system stays current: webhooks report every conversation, the API writes back.

## BYOAI: your own AI, meiti's channels

https://meiti.app/docs/anwendungsfaelle/byoai?lang=en · Use case

Already have your own AI — with your model, your prompt, your data? Then add an assistant of the kind "Your own AI" in meiti. meiti sends every customer message and every call to your AI and passes its answer on to the customer. Channels, numbers, inbox, history and the handover to your team come from meiti, the answers from your AI.

- Your AI thinks, meiti talks: WhatsApp, web chat, Telegram and phone come from meiti.
- Chat via webhook: meiti sends every customer message, signed, to your AI. It answers right away — or later through the API.
- Phone via WebSocket: for every call meiti opens a connection to your AI. The caller's words go out as text, meiti speaks your answer in the voice you picked.

### Three ways to decide who answers


**meiti AI** — The default: meiti's assistant with skills.
- Persona, knowledge and skills in the app
- Appointments, intake, putting calls through

**meiti AI + your data** — meiti's assistant, fed from your system.
- Your webhook answer gives the assistant context on the customer
- Ideal for CRM, ERP or industry software

**Your AI (BYOAI)** — Your AI answers, meiti provides the channels.
- Your model, your prompt, your data
- Chat via webhook, phone via WebSocket
- Chat free, phone per minute

### How a conversation runs


- Chat: for every customer message meiti sends the event message.received to your AI's chat address — with the contact, the new message and the history so far. Your AI answers right in the body, or confirms with 202 and answers later through POST /v1/projects/{id}/agent-replies.
- Phone: meiti answers the call, greets and opens a wss connection to your AI. Every sentence of the caller goes out as text, your answer comes back as text — in pieces too — and meiti speaks it.
- Actions: with handover your AI hands over to the team, with end the request is done (on the phone meiti hangs up afterwards). A note is seen by your team only.
- Skills as tools: on the assistant's page you attach forwards and alerts under "Skills". Your AI triggers them itself by their key — meiti puts the call through or alerts the team, minding times and limits.

### What meiti takes care of


- Channels and numbers: WhatsApp, web chat, Telegram and phone — your AI needs no connection of its own.
- Speech on the phone: meiti recognises what the caller says and speaks your answers in the voice you pick on the assistant.
- Inbox and history: every conversation lands in the inbox with all messages and call turns — just like with meiti's own assistant.
- Handover to your team: your team can reply or "Take over call" at any time. Your AI then stays silent until someone hands the conversation back with "Hand to meiti".
- Never into the void: if your AI doesn't answer in time or answers with an error, the message waits for the team with a note. On the phone the caller leaves a message.

### How to set it up


1. "Settings" → "AI assistants" → "NEW" → pick "Your own AI" and give it a name. The new assistant's page opens.
2. Under "Chat (WhatsApp, website, Telegram)" enter your AI's https address, under "Phone" the wss address, and pick a voice. Save.
3. "Create secret": meiti signs every request with it. It is shown only once — hand it to your IT safely.
4. "Test connection" and assign the assistant to channels under "Who answers where?".

> Without a secret meiti sends nothing to your AI — chats then wait for the team, callers leave a message. Everything except creating the assistant also works via the API through /v1/agents.

```js
// Your chat endpoint (Node.js / Express). myAI stands for your own model, prompt and data.
app.post("/meiti/chat", express.raw({ type: "application/json" }), async (req, res) => {
  // Only meiti: check X-Meiti-Signature over the raw body (sample in the developer docs).
  if (!verify(req.get("X-Meiti-Signature"), req.body)) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  if (event.type !== "message.received") return res.sendStatus(200); // "ping" from the connection test

  const { message, history, contact } = event.data;
  const answer = await myAI({ text: message.text, history, contact });

  // Answer right away — or 202 now and POST to event.data.replyUrl later.
  res.json({ text: answer.text, action: answer.needsPerson ? "handover" : "none" });
});
```

### What it costs


In chat your AI's replies cost nothing — just like meiti's own AI. On the phone every started minute of the call with your AI counts. What meiti does on top for you, such as summaries after the conversation, costs as usual. You see the credits in the app.

> Want to keep meiti's assistant and just give it knowledge from your system? You don't need your own AI for that: the CRM webhook supplies context on the customer, the API sends messages from your system.

## Germania Inkasso: from 15 to 5 people on the phone — in full control

https://meiti.app/docs/kunden/germania-inkasso?lang=en · Customer story

Germania Inkasso replaced three systems with meiti: the Pascom phone system, the previous phone AI and a separate SMS API for messages from the Icarus collection system. Today 5 instead of 15 people handle the calls — and Germania stays in full control at all times.

- 3 systems → 1: telephony, phone AI and messaging run on meiti.
- 15 → 5 people for telephony. The assistant Clara sorts every caller and puts them through to the right team.
- No double entry: webhooks and the API keep Icarus current — even human outbound calls are logged automatically.

- **15 → 5** people handling calls
- **3 → 1** systems for phone, AI and messaging
- **0** double entries

### Before and after


**Before** — Three systems that knew nothing of each other.
- Pascom phone system
- A separate phone AI
- Its own SMS API for messages from Icarus
- 15 people for the calls

**With meiti** — One system, connected to Icarus.
- Telephony, AI and messages in one inbox
- Clara sorts, the right team takes over
- Icarus sends messages through the meiti API
- 5 people for the calls

### How Germania works today


- Clara answers: in a few sentences she sorts callers — debtors, creditors, lawyers, debt counselling, courts and bailiffs — and puts them through to the right team. A missing file number holds nothing up.
- Messages from Icarus: Germania informs creditors and debtors through the meiti API — in the right channel, from the same processes as before.
- Full control: every conversation and every message sits in the inbox. The team can take over at any time.
- Always current: webhooks report every conversation to Icarus, the API writes back. Even calls staff make themselves are logged automatically.

### What you can take from it


- Let the AI sort and put through, not give out file details — that keeps calls short and safe.
- Send messages from your system through meiti, so call and message share one timeline.
- Connect the webhook from day one — then there's no double entry.
