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.
POST to your https address (chatUrl), event message.received — WhatsApp, web chat, Telegramwss address (voiceUrl)X-Meiti-Signature (HMAC-SHA256) with the assistant's secretX-Meiti-Agent-Protocol: 2026-10-11Set up
- 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.
- 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. - “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. - “Test connection”: a signed
pingto the chat address (any2xxanswer passes), then the phone WebSocket is opened and closed once. - 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
| Endpoint | What 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}/secret | New signing secret { secret } — readable only in this response. The previous one stops working at once. |
POST /v1/agents/{id}/test | Signed ping to chatUrl, then one connect to voiceUrl: { success, statusCode, durationMs, error }. |
POST /v1/projects/{id}/agent-replies | The 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:
| Header | Content |
|---|---|
X-Meiti-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)> — over the unchanged body. |
X-Meiti-Event | message.received or ping (connection test, without data). |
X-Meiti-Delivery-Id | Unique per delivery, same as id in the body (evt_…) — for logging and de-duplication. |
X-Meiti-Agent-Protocol | Protocol 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,telegramorchat. Ignore unknown values and new fields — more will be added.contact:id, plusname,phoneandemailwhere 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) orsystem(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 answer | What 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 body | Waits. Your answer follows later via POST /v1/projects/{id}/agent-replies — the full address is in data.replyUrl. |
| Anything else | No 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). |
| Field | Meaning |
|---|---|
text | The answer to the customer, at most 4000 characters. Empty = send nothing. |
action | none (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. |
note | A 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 }:statusissent(text went out),handed_over(passed to your team) ornoop(nothing to do);messageIdisnullwhen only an action came.autoReplyis the state when the answer arrived — anything butonmeans 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: unknownactionortexttoo 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.state | Meaning |
|---|---|
on | Your AI is expected to answer. |
paused | Muted for this conversation until pausedUntil; after that on again. |
off | Your 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
204whenstateis noton. 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.skillsis empty,executeSkillsanswersnot-allowedper skill,POST /v1/projects/{id}/skills/{key}answers409 project_not_with_agent, andhandoverhas 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:
| Header | Content |
|---|---|
X-Meiti-Call-Id | meiti's id of the call, also sent as callId in session.started. |
X-Meiti-Signature | As 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-Protocol | 2026-10-11 |
| Frame | Meaning |
|---|---|
| meiti → your AI | |
session.started | protocol, 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.ended | The 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: trueon 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 — withresumed: "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.
| Skill | What happens |
|---|---|
forward | On 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. |
alert | Your 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. |
| Trigger | How |
|---|---|
| Chat | In 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. |
| Phone | Frame { "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" }. |
| REST | POST /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.skillsofmessage.receivedand inskillsofsession.started:{ id, key, name, description, kind, announcement }— only those allowed now.GET /v1/agentslists all of them withhoursLimitedand the limitsskillsPerHourLimitandforwardsPerCallLimit. - 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).announcementin 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-repliesanswers withskills: [{ key, ok, reason }]. For the direct200answer the outcome is in the history of the nextmessage.received— onesystemturn 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-approvalcomes withok: 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 ofX-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.
handoveron 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.receivedand the phone frames only go to your assistant's addresses — not to the CRM webhook and not to API webhooks.- Limits:
text4000 characters,note500, answer body and phone frame 64 KB each,history30 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.