DE

BYOAI

Your own AI as the assistant

Already have an AI — with your model, your prompt, your data? Then add an assistant of the kind “Your own AI” in meiti. meiti sends every chat message via a signed webhook and every call via WebSocket to your AI and passes its answer on to the customer. Numbers and channels, speech recognition and voice, the inbox with the stored history and the handover to your team stay with meiti.

Should meiti's assistant answer and just know who is writing or calling? The CRM webhook is enough for that. You need BYOAI when your AI should answer itself.

What it means for your business, without the tech: BYOAI use case.

ChatPOST to your https address (chatUrl), event message.received — WhatsApp, web chat, Telegram
Phoneone WebSocket connection per call to your wss address (voiceUrl)
SecurityX-Meiti-Signature (HMAC-SHA256) with the assistant's secret
ProtocolX-Meiti-Agent-Protocol: 2026-10-11
Billingchat free · phone per minute

Set up

  1. In the meiti app: Settings → AI assistants → NEW → “Your own AI”, give it a name. The new assistant's page opens. Like the API, this is available in every inbox.
  2. Enter the chat address (https) and the waiting time for a direct answer (default 10 seconds, 2–30), the phone address (wss) and a voice, then save. Both addresses must be publicly reachable.
  3. “Create secret”: it is shown only once — store it as MEITI_AGENT_SECRET. Without a secret meiti sends nothing to your AI. “Replace secret” invalidates the old one immediately.
  4. “Test connection”: a signed ping to the chat address (any 2xx answer passes), then the phone WebSocket is opened and closed once.
  5. Assign it to channels under “Who answers where?” — like any other assistant.

Everything except creating the assistant also works via the API, with a key of the same inbox:

# 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 }
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 }
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
// 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"])
// 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
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
EndpointWhat for
GET /v1/agents, GET /v1/agents/{id}The own-AI assistants: agentId, name, chatUrl, voiceUrl, timeoutSeconds, hasSecret, voiceId and the connection status lastSuccessAt, lastFailureAt, lastError, consecutiveFailures.
PATCH /v1/agents/{id}chatUrl (https), voiceUrl (wss), timeoutSeconds (kept within 2–30), voiceId (ElevenLabs voice; empty = meiti's default voice). Omitted = unchanged, "" = disconnect the channel. 400 invalid_chat_url or invalid_voice_url when an address is not allowed.
POST /v1/agents/{id}/secretNew signing secret { secret } — readable only in this response. The previous one stops working at once.
POST /v1/agents/{id}/testSigned ping to chatUrl, then one connect to voiceUrl: { success, statusCode, durationMs, error }.
POST /v1/projects/{id}/agent-repliesThe later answer to a message.received — see below.

Chat: message.received

When a customer writes on WhatsApp, in the web chat or on Telegram and your own-AI assistant answers the conversation, meiti sends the event as a POST with a JSON body to your chatUrl:

HeaderContent
X-Meiti-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)> — over the unchanged body.
X-Meiti-Eventmessage.received or ping (connection test, without data).
X-Meiti-Delivery-IdUnique per delivery, same as id in the body (evt_…) — for logging and de-duplication.
X-Meiti-Agent-ProtocolProtocol revision, currently 2026-10-11; also sent as protocol in the body.
What message.received looks like
{
  "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" }
  }
}
  • channel: whatsapp, webchat, telegram or chat. Ignore unknown values and new fields — more will be added.
  • contact: id, plus name, phone and email where known — missing fields are left out.
  • history: up to 30 earlier turns, oldest first, without the new message. role: customer, agent (AI replies), team (someone from your team) or system (notes from meiti).
  • autoReply: the conversation's auto-reply as your team set it in the app — state (on, paused, off) and, during a pause, pausedUntil. See When your team takes over.
  • Times without a zone (2026-10-14T09:14:02.947) are UTC.
  • A chat with your own AI starts without meiti's greeting and privacy notice: the first answer comes from your AI.

The smallest complete endpoint: verify the signature, read the event, answer right away.

// 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 + 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
// 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)
}
// 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
// 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
]);

Answering: now or later

Your answerWhat meiti does
200 + { text, action, note }Sends text right away on the customer's channel and runs the action.
202, 204 or 200 without a bodyWaits. Your answer follows later via POST /v1/projects/{id}/agent-replies — the full address is in data.replyUrl.
Anything elseNo 2xx, timeout, answer over 64 KB, invalid JSON, unknown action or text over 4000 characters: the message waits for your team with a note (see below).
FieldMeaning
textThe answer to the customer, at most 4000 characters. Empty = send nothing.
actionnone (default): your AI stays on the conversation. handover: the conversation goes to your team — it waits in the inbox, your team is notified, and the following message.received carry autoReply.state: off until someone hands it back with “Hand to meiti”. end: the request is done; in chat this changes nothing besides the text.
noteA note for your team only: it shows in the conversation, the customer never sees it. At most 500 characters, longer ones are cut. Meant for handover.

If your AI needs longer, answer 202 right away and send the answer afterwards — with the same body and an API key of the same inbox:

# 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.
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");
  });
});
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
// 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)
// 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
// … 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
  • 202 { projectId, messageId, status, autoReply }: status is sent (text went out), handed_over (passed to your team) or noop (nothing to do); messageId is null when only an action came. autoReply is the state when the answer arrived — anything but on means your team had taken over in the meantime (see When your team takes over).
  • 409 project_not_with_agent: the conversation is not answered by an own-AI assistant (e.g. assigned to another assistant) — drop the answer.
  • 422 not_deliverable: the text could not be delivered, the conversation went to your team. 404 project_not_found: no such conversation in this inbox. 400 invalid_reply: unknown action or text too long.
  • The answer goes out on the channel the customer last wrote on. It counts towards the limit of 60 requests per minute per key.

When your team takes over

When someone from your team replies, takes the conversation over, or switches auto-reply off or mutes it there, meiti's own assistant would stay silent. Your AI keeps getting message.received — with the auto-reply in data.autoReply — and decides itself whether to answer:

autoReply.stateMeaning
onYour AI is expected to answer.
pausedMuted for this conversation until pausedUntil; after that on again.
offYour team answers: someone replied or took over, a handover is waiting, or auto-reply is off (or receipt confirmation only) for it. It stays so until someone picks “Hand to meiti”.
  • To stay quiet, answer 204 when state is not on. The samples above do exactly that.
  • If your AI answers anyway, meiti delivers the text — directly or via agent-replies. On the first text after your team stepped in, meiti writes a note into the conversation that your AI answered although auto-reply is off; once someone from the team writes again, the same goes for the next text.
  • meiti's own steps follow the auto-reply: while it is not on, data.skills is empty, executeSkills answers not-allowed per skill, POST /v1/projects/{id}/skills/{key} answers 409 project_not_with_agent, and handover has nothing left to hand over.
  • If your AI fails while your team has the conversation, meiti writes no note and pages nobody — your team is on it already. The error shows on the assistant's page.

When your AI does not answer

  • meiti waits timeoutSeconds (default 10, 2–30) for the direct answer and tries exactly once — no retry.
  • A failure never ends in silence: the message stays in the inbox, meiti adds the reason as a note and hands over to your team. Without a chat address or without a secret every message goes straight to your team.
  • The status is shown on the assistant's page (“Last reached …”, “Not reachable since …”) and by GET /v1/agents/{id}: lastSuccessAt, lastFailureAt, lastError, consecutiveFailures.

Phone: one WebSocket per call

For every call your assistant answers, meiti opens a WebSocket connection to your voiceUrl. meiti turns the caller's words into text, sends every finished sentence to your AI and speaks your text answers in the voice you picked. meiti speaks the greeting before the first sentence goes to your AI. The connection request carries these headers:

HeaderContent
X-Meiti-Call-Idmeiti's id of the call, also sent as callId in session.started.
X-Meiti-SignatureAs in chat, but over the call id instead of a body: hex HMAC-SHA256(secret, t + "." + callId). Refuse the connection without a valid signature.
X-Meiti-Agent-Protocol2026-10-11
FrameMeaning
meiti → your AI
session.startedprotocol, callId, callSid, projectId, workspaceId, agentId, from, to, language (de-DE or en-US), contact { id, name } and resumed: "handover_failed" when a takeover by your team did not work out and your AI carries on.
prompt { text }The caller finished a sentence.
dtmf { digit }The caller pressed a key.
interrupt { spokenUntilInterrupt }The caller talked over your answer; spokenUntilInterrupt is what had been spoken until then. Stop streaming the old answer.
session.endedThe call is over for your AI; meiti closes the connection afterwards.
your AI → meiti
text { text, last }Text to speak, streamed in pieces if you like; last: true closes the answer. token instead of text is accepted too — agents built for Twilio ConversationRelay fit.
action { action, text, note }end: text is spoken to the end, then meiti hangs up. handover: text is spoken, your team is notified and the caller leaves a message; note shows as a note in the conversation.
What session.started looks like
{
  "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." }
  ]
}
// 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 + 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())
// 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)
}
// 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;
        }
    }
});
  • Put last: true on the last piece that has text — an empty piece is not passed on.
  • meiti waits 5 seconds for the connection. If none comes about, or the wss address or the secret is missing, the caller leaves a message and your team sees the reason in the conversation.
  • If your AI closes the connection mid-call without an action, the caller leaves a message as well. meiti ignores frames that are not JSON and unknown actions; a frame over 64 KB ends the connection.
  • “Take over call” in the app works as with meiti's own assistant: your AI gets session.ended. If nobody picks up, meiti connects the caller back to your AI — with resumed: "handover_failed".
  • Every sentence of the caller and every answer of your AI lands in the inbox as the transcript.

Skills as tools: forward and alert

Your AI brings the logic, meiti the plumbing: on the assistant's page, under “Skills” → “Add skill”, attach forwarding and alert skills. They have no AI trigger — your AI triggers them itself by their key. meiti keeps the hard rules: the skill's times, the assistant's skill limits, your plan.

SkillWhat happens
forwardOn the phone meiti puts the call through to the skill's target (team member, number, ring group — with its “If nobody answers” chain). In chat your team is notified like any handover; the conversation is then with the team and your AI gets no further message.received until it is handed back.
alertYour team gets an alert (push, SMS or WhatsApp, as set on the skill). Its text is your note, else your text, else meiti's line naming the conversation and contact.
TriggerHow
ChatIn the answer (200 body or agent-replies): executeSkills: ["buero"] — key or id as a string, at most 5. Your text goes out first, then the alerts run, the forward last; only one forward per answer.
PhoneFrame { "type": "skill", "executeSkills": ["buero"], "note"?, "announcement"? } (or "key" for one). meiti answers per skill with skill.result { key, ok, reason }. After a forward with ok: true comes session.ended; if nobody answers, meiti opens a new connection with resumed: "handover_failed" and resumedAfter: { key, ok: false, reason: "no-answer" }.
RESTPOST /v1/projects/{id}/skills/{key} with an optional body { note, announcement } and an optional Idempotency-Key → 200 { key, ok, reason }. Always runs as in chat — only the phone frame puts a live call through. 409 project_not_with_agent when your team has the conversation.
# 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.
// 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;
// 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" }, "…": "…" }
  • Which skills your AI may use right now is in data.skills of message.received and in skills of session.started: { id, key, name, description, kind, announcement } — only those allowed now. GET /v1/agents lists all of them with hoursLimited and the limits skillsPerHourLimit and forwardsPerCallLimit.
  • Announcement: what meiti tells the customer when the skill runs is set on the skill on the assistant's page (it is listed as announcement — you don't need to say it yourself). announcement in the answer replaces it for this run, "" suppresses it. On the phone, without an announcement meiti speaks its standard line before putting the call through.
  • Result: agent-replies answers with skills: [{ key, ok, reason }]. For the direct 200 answer the outcome is in the history of the next message.received — one system turn per skill with the key in square and the reason in round brackets, e.g. … [buero] … (outside-hours).
  • Reasons (reason): unknown-skill (no skill of this assistant), outside-hours (outside its times), limit-reached (skill limit per hour and conversation, or forwards per call), not-allowed (plan, target type, named twice), no-target, no-recipient, no-answer, one-forward-per-turn, failed. pending-approval comes with ok: true: the alert waits for the team's approval.
  • Every executed skill costs like any skill run; a call that is put through additionally like any forward. Refused skills cost nothing.

Verify the signature

  • The same scheme as the API webhooks: X-Meiti-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + signed)>. In chat “signed” is the unchanged body, on the WebSocket the value of X-Meiti-Call-Id.
  • Reject timestamps more than 5 minutes off and compare in constant time — the samples above do both.
  • The secret starts with whsec_. Use it as text (UTF-8), as it is — not base64-decoded.

Billing and limits

  • 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 runs on top, such as post-processing skills, costs as usual.
  • If no connection to your AI comes about on the phone, meiti takes a message — that counts as a normally answered call, not as a BYOAI minute.
  • Channels: phone, WhatsApp, web chat and Telegram. An own-AI assistant does not answer email.
  • handover on the phone takes a message. Your AI puts a call through with a forwarding skill (Skills as tools).
  • After the conversation, summary and notifications run as for any assistant; what happens during the conversation is up to your AI — including which skills it triggers.
  • message.received and the phone frames only go to your assistant's addresses — not to the CRM webhook and not to API webhooks.
  • Limits: text 4000 characters, note 500, answer body and phone frame 64 KB each, history 30 turns, waiting time 2–30 seconds, phone connect 5 seconds.

Questions about your integration?

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