Make a DaleVoz agent talk from your code, embed it on your site, prove who is speaking, give it tools and drive it from an AI. Every route described here is the one production serves.
Every DaleVoz surface (the widget on your site, mobile apps, your servers) talks to the agent through the same route: POST https://dalevoz.ai/api/v1/interact. An action goes in (a message, a button click), traces come out (text, buttons, cards), and the conversation lives on our servers.
Every call carries a key in the header Authorization: Bearer …. A key belongs to a workspace, not to an agent: it opens every agent in that workspace, and no other. The server keeps only a hash of it, so it is displayed only once, when it is created.
| Key | Where it lives | What protects it | What it opens |
|---|---|---|---|
pk_… | In your site's HTML or in an app: it is public by nature, like a publishable payment key. | The key's list of allowed domains, checked against the Origin or Referer header of each call. A domain also opens its subdomains, and www. and the bare domain count as each other. Empty list: no restriction. | Talking to published agents: interact, messages, history, attachments, theme, voice, site. |
sk_… | On your server only. Never in a browser, an app or a code repository. | Its secret. Treat it like a password. | The conversation routes without origin checks (except voice, reserved for pk_ keys), plus administration: writing an agent, its knowledge base and its segments, reading statistics and exporting conversations. It also receives the cost of each turn (usage). A key can be restricted to scopes chosen when it is created (agents:read, kb:write, interact…): an out-of-scope call returns 403 with code “portee_manquante” and the scope's name in porteeRequise. |
Where to create them.
sk_: console, Settings, “ChatGPT and Claude” tab, collapsed section “For a developer: API keys”, “Create a server key” button. Restricted to people who administer the workspace.pk_: the agent's page, “Publish” tab, “Website” card, “For a technician” part. This is also where the list of allowed domains is set, and where the widget tag is copied, with the key already filled in.Replace the key and the agent name. The name is the one in the embed code (data-agent), and the agent's identifier as it appears in the console address (/agents/…) is accepted too. The API answers with the PUBLISHED version of the agent: an agent that has never been published returns 404.
curl -X POST https://dalevoz.ai/api/v1/interact \
-H "Authorization: Bearer YOUR_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent": "my-agent",
"sessionId": null,
"user": { "id": "client-42", "locale": "en" },
"action": { "type": "text", "payload": { "message": "Hello, what are your opening hours?" } }
}'const response = await fetch("https://dalevoz.ai/api/v1/interact", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DALEVOZ_SERVER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
agent: "my-agent",
sessionId: null, // null on the first message
user: { id: "client-42", locale: "en" },
action: { type: "text", payload: { message: "Hello, what are your opening hours?" } },
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error);
// Send it back on the next turn to continue the same conversation
const sessionId = data.sessionId;
for (const trace of data.traces) {
if (trace.type === "text") console.log(trace.payload.message);
}import os
import requests
response = requests.post(
"https://dalevoz.ai/api/v1/interact",
headers={"Authorization": f"Bearer {os.environ['DALEVOZ_SERVER_KEY']}"},
json={
"agent": "my-agent",
"sessionId": None, # None on the first message
"user": {"id": "client-42", "locale": "en"},
"action": {"type": "text", "payload": {"message": "Hello, what are your opening hours?"}},
},
timeout=60,
)
data = response.json()
response.raise_for_status()
session_id = data["sessionId"] # send it back on the next turn
for trace in data["traces"]:
if trace["type"] == "text":
print(trace["payload"]["message"])The response:
{
"sessionId": "0b6f7c1e-5d3a-4f1b-9a57-2c8e4d1a9f30",
"traces": [
{ "type": "text", "payload": { "message": "We are open Tuesday to Saturday, 9 am to 7 pm.", "markdown": true } },
{ "type": "choice", "payload": { "buttons": [ { "label": "📅 Book an appointment", "value": "rdv" } ] } }
],
"usage": { "tokensIn": 2140, "tokensOut": 58, "costMicroUsd": 812 }
}sessionId: send null on the first message, then send back the one from the response to continue the same conversation. An unknown identifier, or one from another workspace, is not an error: it opens a new conversation, and the response carries the new identifier.user.id: your identifier for this person. Keeping the same one from day to day lets the agent recognize them when its memory is on. With a pk_ key, memory also requires signed identity (see Signed identity). Never put a secret in this field.usage: only present with an sk_ key. A pk_ key never receives it, neither in JSON nor in the stream.The OpenAPI 3.1 description of the same routes can be downloaded here: /openapi.json.
https://dalevoz.ai. JSON bodies (Content-Type: application/json), except voice dictation, which is multipart.Authorization: Bearer pk_… or Bearer sk_…. Only the theme route also accepts the key as a parameter (?key=).slug), within the key's workspace. Only interact also accepts its identifier (UUID).{ "error": "…" }, sometimes supplemented with issues (validation details), code and detail. On the routes the widget shows to the visitor (interact, messages, history, attachments, voice), error is a sentence for the visitor, in their language: for a program, read the HTTP status and code.Origin rejection for a pk_ key (status 403):
{
"error": "(neutral sentence for the visitor, in their language)",
"code": "origine_non_autorisee",
"detail": "Origine non autorisée pour cette clé (boutique.exemple.fr). Ajoutez ce domaine à la clé dans la console."
}The site/* routes return this rejection in a shorter form, without code: { "error": "Origine non autorisee pour cette cle" }.
Keys: pk_ (origin checked) or sk_
Moves a conversation forward by one turn. This is the route used by the widget, apps and servers.
| Header | Purpose |
|---|---|
Authorization | Bearer pk_… or sk_…, required. |
x-dalevoz-channel | Optional. The channel, for statistics: widget, iframe, sdk_js, ios, android, api, zendesk, playground, whatsapp, messenger, instagram, voice. It takes precedence over channel in the body. Without it: widget for a pk_, api for an sk_. |
x-dalevoz-identity | Optional. The signed identity token, see Signed identity. |
| Body field | Type | Purpose |
|---|---|---|
agent | string | Required. Technical name or identifier of the agent. |
sessionId | uuid | null | null on the first turn, then the one from the response. Unknown: a new conversation opens, without error. |
action | object | Required. See the next table. |
user | { id?, name?, locale? } | Who is speaking, declared by you. locale chooses the language of service messages. Never a secret here. |
context | object | Free-form context, FROZEN when the conversation is created (source, app version…). context.segment chooses one of the agent's segments. |
contexte | object | What the page knows about the visitor, MERGED on every turn. Only variables declared on the agent (Context tab) get through: an undeclared key is ignored, text is truncated. |
stream | boolean | true: streamed response as text/event-stream (see below). |
consentement | ISO date | When the visitor accepted the widget's consent banner. Set once on the conversation, returned by the GDPR export. A date in the future or more than 400 days old is replaced by the server time. The widget sends it on its own. |
| action.type | payload | When |
|---|---|---|
launch | {} | Open or resume a conversation: the agent says its welcome message. |
text | { message, attachments?, parle? } | A message (8,000 characters at most, empty allowed if there are attachments). attachments: 5 at most. parle: true if the message is the transcript of speech; the reply is then written to be read aloud. |
choice | { value, label? } | A button click: send back the button's value, and its label for the history. |
event | { name, data? } | An event from the surface. The widget uses it for window.dalevoz.evenement (name “dv:evenement”). |
An attachment takes one of the following two forms:
{ kind: "image", mediaType, data, filename? }: mediaType image/png, image/jpeg, image/webp or image/gif; data in base64 WITHOUT the data: prefix. The image goes to the model as is.{ kind: "text", filename, text, truncated? }: the text of a PDF or DOCX, extracted beforehand by /api/v1/attachments.Attachments are only read if the agent accepts them (an agent setting); the public theme says so in attachmentsEnabled.
Response (200). { sessionId, traces[], usage?, avertissements? }. Traces are to be displayed in order. Their type:
| type | Content |
|---|---|
| text | payload.message (Markdown if payload.markdown is true). |
| choice | payload.buttons[]: { label, value, url? }. With url, the button opens the link instead of sending value. |
| multi_choice | Several checkbox buttons (min, max, validateLabel). |
| slider | A slider (label, min, max, step, value?, unit, validateLabel). |
| card | A card: title, description?, imageUrl?, buttons?… |
| carousel | payload.cards[]: several cards. |
| document | A file to open: url, filename, caption?. |
| audio | A voice note: url, transcript, seconds?, mime. |
| scheduler | A calendar to embed: url, label, height. |
| template | A WhatsApp message template (Meta channels). |
| handoff | The agent hands over to a human: reason?, message?. |
| systeme | A change of who is replying: etat (attente, repris, pause, absent) and message. |
| end | The conversation is over. |
Streaming (stream: true). The response is a text/event-stream of “data: {json}” lines. Order: session (the identifier, before anything else), meta (streaming: true if the text really arrives word by word), delta events (provisional text to display), tool events (a category: recherche, web, cartes, action or vocal, never the tool's name) and finally final, whose response is IDENTICAL to the JSON response: replace the provisional text with final.response.traces. Once the stream is open, the HTTP status is 200: an error arrives as an error { message, status? } event.
curl -N -X POST https://dalevoz.ai/api/v1/interact \
-H "Authorization: Bearer YOUR_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{ "agent": "my-agent", "sessionId": null,
"action": { "type": "text", "payload": { "message": "Hello" } },
"stream": true }'
data: {"type":"session","sessionId":"0b6f7c1e-5d3a-4f1b-9a57-2c8e4d1a9f30"}
data: {"type":"meta","streaming":true}
data: {"type":"delta","text":"Hello! "}
data: {"type":"tool","kind":"recherche","phase":"start"}
data: {"type":"tool","kind":"recherche","phase":"done"}
data: {"type":"delta","text":"We're open…"}
data: {"type":"final","response":{"sessionId":"0b6f7c1e-…","traces":[…]}}Errors.
| Status | Cause |
|---|---|
| 400 | Invalid JSON body, or invalid request (issues gives details). |
| 401 | Missing, invalid or revoked key; identity token rejected; key that requires a signed identity and receives user.id. |
| 403 | pk_ key called from a domain that is not in its list. |
| 404 | Unknown agent, or never published. |
| 423 | Agent paused. |
| 429 | Too many calls: key or visitor limit (see Limits). error is a sentence for the visitor, code is limite, reessayerDansS and the Retry-After header say when to retry. |
| 500 | Unexpected error (message for the visitor, in their language). |
| 502 | The model cut its reply off without a result (streaming). |
Keys: pk_ (origin checked) or sk_
What a human agent has written since the last call, to display on your surface while they are in charge. Poll every few seconds, sending cursor back as is in since.
{
"status": "human",
"messages": [ { "id": "…", "at": "2026-09-23T10:12:04.512Z", "from": "human_agent", "agentName": "Julie", "text": "…" } ],
"events": [ { "id": "…", "at": "…", "etat": "repris", "message": "…" } ],
"questions": [ { "id": "…", "at": "…", "traces": [ { "type": "text", … }, { "type": "choice", … } ] } ],
"cursor": "2026-09-23T10:12:04.513Z"
}let cursor = "";
setInterval(async () => {
const url = new URL("https://dalevoz.ai/api/v1/messages");
url.searchParams.set("sessionId", sessionId);
if (cursor) url.searchParams.set("since", cursor);
const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }).then((x) => x.json());
for (const m of r.messages) showTeamMemberMessage(m.agentName, m.text); // deduplicate by m.id
cursor = r.cursor ?? cursor;
}, 4000);Keys: pk_ (origin checked) or sk_
The thread of a conversation, to display it again when the person comes back. Returns { items: [ { from: "user" | "bot" | "human", agentName?, traces[] } ] }, 60 items at most, the most recent. Buttons are only returned on the last item; signals (handoff, end) are not replayed. An unknown session, one from another workspace or a malformed one: empty items, never an error. A missing or invalid key returns 401.
Keys: pk_ (origin checked) or sk_
Extracts the text from a PDF or DOCX. Body: { mediaType, data, filename? }, with mediaType application/pdf or application/vnd.openxmlformats-officedocument.wordprocessingml.document, data in base64 (15,000,000 characters at most, roughly an 11 MB file). Response: { filename, text, truncated }, the text being truncated at 20,000 characters. Errors: 400 (unsupported type, missing or oversized file), 401, 403, 422 (extraction failed). Images do not go through here: they go straight into interact.
// 1. Extract the text from the PDF
const extracted = await fetch("https://dalevoz.ai/api/v1/attachments", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ mediaType: "application/pdf", data: base64NoPrefix, filename: "quote.pdf" }),
}).then((r) => r.json());
// 2. Send it with the message
await fetch("https://dalevoz.ai/api/v1/interact", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
agent: "my-agent",
sessionId,
action: {
type: "text",
payload: {
message: "Can you check this quote?",
attachments: [{ kind: "text", filename: extracted.filename, text: extracted.text, truncated: extracted.truncated }],
},
},
}),
});The routes behind the widget's push-to-talk. They use the voice pipeline configured on the published agent.
Key: pk_ only (an sk_ gets 401)
Transcribes a spoken turn. Multipart body: agent, audio (the file), dureeMs?, locale?. Response: { texte, langue, secondes }, or { texte: "", vide: true } when nothing was heard. Limits: 8 MB, 120 seconds, 20 calls per minute per workspace. Errors: 400, 401, 403, 404 (agent not found or not published), 413 (audio too large or too long), 429, 503 (listening not configured).
Key: pk_ only (an sk_ gets 401)
Reads a text aloud in the agent's voice. Body: { agent, texte, conversationId?, locale? }. Response: the audio bytes (Content-Type given by the provider). The text is truncated at 1,200 characters. 30 calls per minute per workspace. Errors: 400, 401, 403, 404, 429, 503 (speech not configured).
No key: public address
The audio of one of the agent's voice notes, the address an audio trace carries. Public because an audio tag and Meta's servers cannot set a header. The note expires after thirty days; the cache is private, one hour.
Keys: pk_ or sk_, in the header or in ?key=
The public theme of an agent's published version, already resolved. Response: { resolved, agentName, title, textIdleMs, attachmentsEnabled, voiceMode, pttSilenceMs, conformite, credit }. No origin check. Public caching (60 s, 300 s on the CDN). Errors: 401 (missing key or agent), 404.
No key: public address
An image from a workspace's library (avatar, widget background). Immutable cache for one year; 404 not_found otherwise.
Write an agent and its knowledge base, and read its results, from your server. Every write is recorded in the agent's log, with the reason you give.
Key: sk_ only (a pk_ gets 403)
Creates the agent, or rewrites it if an agent with this slug already exists in the workspace. Response: { slug, cree, id }.
| Field | Rule |
|---|---|
| slug | Required. 2 to 60 characters: lowercase letters, digits, hyphens, starting with a letter or a digit. |
| name | Required, 120 characters at most. |
| systemPrompt | Required, 60,000 characters at most. |
| llmProvider | anthropic | openai | grok | gemini |
| llmModel | Rejected (400) on the free plan if it is not one of the models at 1 credit or less; only checked when it changes. |
| localeDefault | 2 to 5 characters (fr, es…). |
| status | draft | live | paused |
| maxTokens | 256 to 32,000. If absent, it is reset to 4,096, including on a rewrite. |
| greetingByLocale | Welcome message per language. If it carries buttons, it must end with a closed question. |
| launchButtonsByLocale | Opening buttons per language: 2 to 5, each starting with ONE emoji, 20 characters at most. |
| settings | Merged into the existing settings, never replaced. |
| motif | Why this write is being made, 500 characters at most. Logged. |
{
"slug": "store-welcome",
"name": "Store welcome",
"systemPrompt": "You are the store's assistant…",
"localeDefault": "en",
"status": "draft",
"greetingByLocale": { "en": "Hello! Shall we get started?" },
"launchButtonsByLocale": { "en": [ { "label": "🛍️ Products" }, { "label": "🚚 Shipping" } ] },
"motif": "Created from our back office"
}name and systemPrompt are required on every call, even for a rewrite. Errors: 400 (validation, format rule for the welcome message or the buttons, model not in the plan), 401, 403 (pk_), 500.
Key: sk_ only (a pk_ gets 403)
A segment adapts an agent to an audience (an organization, a campaign) without duplicating it. It is chosen at run time by context.segment in interact, or by the widget's data-segment attribute; when no segment is recognized, the default segment applies, then the agent as is.
GET ?agent=: the list, without the prompt contents: { agent, segments: [ { segment, champs, promptFragmentLongueur, systemPromptLongueur } ] }.POST { agent, segment, config, autoriserDefault?, autoriserVocal? }: creates or replaces. Response { agent, segment, champs }.DELETE ?agent=&segment=: deletes. Response { agent, segment, supprime: true }.Config fields (any other field returns 400): collections, carousels, carouselsDisabled, tools, promptFragment (appended to the prompt, 20,000 chars), systemPrompt (replaces the prompt, 20,000 chars), greeting, launchButtons (6 at most), welcomeVideoUrl, welcomeVoiceUrl.
Key: sk_ only (a pk_ gets 403)
Syncs rows into the knowledge base: one row becomes one entry. Body: { agent, collection?, rows[], motif? }, rows of 1 to 2,000 items { name, text, sourceUrl? }.
{
"agent": "store-welcome",
"collection": "catalog",
"rows": [
{ "name": "Olive vase, 24 cm", "text": "Glazed stoneware, €39, in stock.", "sourceUrl": "https://example.com/vase-olive" }
],
"motif": "Nightly catalog sync"
}Key: sk_ only (a pk_ gets 403)
Reads back an agent's entries, or those of a single batch: { agent, collection, count, rows: [ { name, sourceUrl, text } ] }. Call it before a partial sync-table: read, replace what your source owns, send everything back.
Key: sk_ only (a pk_ gets 403)
An agent's results over a period. Parameters:
| Parameter | Purpose |
|---|---|
| agent | Required (slug). |
| days | 1 to 90, 7 by default, counted back from now. |
| from, to | ISO dates; a calendar window that takes precedence over days. from must come before to. |
| channels | Channels to keep, comma-separated. |
| excludePrefix | Visitor identifier prefixes to exclude (your test accounts), comma-separated. |
Response: agent, periode, tronque, totaux, fichesOuvertes, questionsSansFiche, demandes, rendezVous, pagesOrigine, satisfaction, sansIssue, ventes?, langues, canaux, joursActivite, heuresActivite, conversations. Errors: 400 (missing agent, invalid date), 401, 403, 404.
Key: sk_ only (a pk_ gets 403)
The raw export of an agent's conversations, turns included, as they are stored. from and to are required; channels and excludePrefix as above; limit from 1 to 200 (100 by default); cursor for the next page.
{
"agent": "store-welcome",
"periode": { "from": "2026-09-01T00:00:00.000Z", "to": "2026-09-08T00:00:00.000Z" },
"conversations": [
{ "id": "…", "createdAt": "…", "updatedAt": "…", "channel": "widget", "status": "…", "locale": "fr",
"externalUserId": "client-42", "issue": "…", "satisfaction": "…", "sujet": "…", "classifieAt": "…",
"turns": [ { "role": "user", "content": { … }, "createdAt": "…" } ] }
],
"nextCursor": "2026-09-03T17:42:10.118Z"
}As long as nextCursor is not null, pass it back as cursor with the same from and to. Errors: 400, 401, 403, 404.
Three routes designed for landing pages: the visitor gives their site's address, the agent reads it, then the conversation can continue on WhatsApp. They only make sense on an agent with site reading turned on.
Keys: pk_ (origin checked) or sk_
Body: { agent, url, locale?, segment?, contexte? }. Checks within a few seconds that the site responds, creates the conversation and returns { sessionId, statut: "encours", url }; the reading (8 pages at most) carries on after the response. Six readings per hour per IP address. Errors: 400 (adresse-invalide), 401, 403 (origin, or lecture-desactivee on the agent), 404 (agent-inconnu), 422 (site-injoignable), 429 (trop-de-lectures).
Keys: pk_ (origin checked) or sk_
Poll during the reading: { statut, url?, pages?, titre?, message? }, with statut being aucune, encours, pret or echec. A reading still in progress after more than two minutes is returned as echec. Errors: 400, 401, 403, 404 (session-inconnue).
Keys: pk_ (origin checked) or sk_
Body: { sessionId, telephone }. The agent writes first on WhatsApp, already knowing the site it read. The agent needs an active WhatsApp channel; the message goes out through a Meta-approved template, in French. A number without a country code is read as French. One send per conversation, three per hour per IP address. Response: { ok: true, waId } or { ok: true, deja: true }. Errors: 400 (numero-invalide), 401, 403, 404 (session-inconnue), 409 (site-non-lu), 429 (trop-d-envois), 502 (envoi-refuse), 503 (canal-absent, canal-sans-jeton).
To answer an access or erasure request from your server. The visitor is identified by their identifier: the user.id you send, the sub of the signed token, the identifier passed to dalevoz.identify(), or the WhatsApp number. Everything is scoped to the key's workspace: two workspaces that each have their own “client-42” never see each other. ?agent= (technical name or UUID) scopes to one agent; without it, the whole workspace. URL-encode the identifier (encodeURIComponent).
Key: sk_ only (a pk_ gets 403)
Everything DaleVoz keeps about the person, in one JSON: their conversations with their turns (including messages removed from the thread by a human agent, marked deletedAt), the consent date of each one (consentAt), their memory record, their leads, their appointments and their orders. An unknown visitor returns empty lists, not a 404. Errors: 400 (visiteur_invalide), 401, 403 (pk_ key, or visiteurs:gdpr scope missing from the key), 404 (agent_introuvable).
curl "https://dalevoz.ai/api/v1/visiteurs/client-42/export" \
-H "Authorization: Bearer YOUR_SERVER_KEY"
{ "userId": "client-42", "agent": null, "exportedAt": "…",
"conversations": [ { "id": "…", "agent": "store-welcome", "channel": "widget",
"consentAt": "2026-09-24T10:12:03.000Z", "turns": [ { "role": "user", "content": { … }, "createdAt": "…" } ] } ],
"memories": [ { "agent": "store-welcome", "summary": "…" } ],
"leads": [], "bookings": [], "orders": [] }Key: sk_ only (a pk_ gets 403)
Irreversible. Erases the conversations (and their turns, leads and voice notes), the memory record, the leads carrying the identifier and the linked Instagram comments. Appointments and orders are records of your business: they stay, without name, email, phone or free text. Costs and sales remain counted, detached from the person. Response 200 with the counters, even at zero: an erasure requested twice is not an error. Same errors as the export.
curl -X DELETE "https://dalevoz.ai/api/v1/visiteurs/client-42" \
-H "Authorization: Bearer YOUR_SERVER_KEY"
{ "userId": "client-42", "agent": null,
"deleted": { "conversations": 3, "memories": 1, "leads": 1, "instagramComments": 0 },
"anonymized": { "bookings": 1, "orders": 0 } }By default, whoever talks to the agent declares themselves in user.id. With a pk_ key, public by nature, anyone could therefore set someone else's identifier and retrieve their conversation and what the agent remembers about them. Signed identity closes that door: your server signs a short-lived token for the user it knows, and DaleVoz verifies the signature.
x-dalevoz-identity of every call to interact, alongside the key: the key says which application is calling, the token says which person. A valid token REPLACES the user in the body.| Token element | Rule |
|---|---|
| Algorithm | HS256 only (alg header). Anything else, none included, is rejected. |
sub | Required. The person's stable identifier on your side, 200 characters at most. Becomes user.id. |
exp | Required. At most 15 minutes after iat when iat is present. Five minutes is enough. |
iat, nbf | Optional. A clock skew of 60 seconds is tolerated. |
name, locale | Optional. Become user.name and user.locale. |
| Size | 4,096 characters at most. |
import { createHmac } from "node:crypto";
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
// On YOUR server, for the user already signed in on your side.
export function daleVozToken(user) {
const now = Math.floor(Date.now() / 1000);
const header = b64url({ alg: "HS256", typ: "JWT" });
const payload = b64url({
sub: user.id, // becomes user.id
name: user.name, // optional, becomes user.name
locale: "en", // optional, becomes user.locale
iat: now,
exp: now + 5 * 60, // required, 15 minutes at most
});
const signature = createHmac("sha256", process.env.DALEVOZ_IDENTITY_SECRET) // dvid_…
.update(`${header}.${payload}`)
.digest("base64url");
return `${header}.${payload}.${signature}`;
}
// Same result with the jsonwebtoken library:
// jwt.sign({ sub: user.id, name: user.name }, process.env.DALEVOZ_IDENTITY_SECRET,
// { algorithm: "HS256", expiresIn: "5m" });await fetch("https://dalevoz.ai/api/v1/interact", {
method: "POST",
headers: {
Authorization: "Bearer pk_…",
"x-dalevoz-identity": token, // provided by your server
"Content-Type": "application/json",
},
body: JSON.stringify({
agent: "my-agent",
sessionId,
// no user.id: the token carries it
action: { type: "text", payload: { message } },
}),
});Possible rejections, all 401: malformed token, rejected algorithm, invalid signature, token expired or with too long a lifetime, not yet valid, no usable sub, too long; key without a secret receiving a token; key with a secret receiving user.id without a token. An invalid token is NEVER replaced by the user.id in the body.
A tag to paste before the end of the body. The widget lives in a shadow DOM: its styles do not affect your page, and yours do not affect it. The ready-made tag, key included, can be copied from the console (the agent's “Publish” tab, “Website” card).
<script src="https://dalevoz.ai/dalevoz-widget.js"
data-agent="my-agent"
data-key="pk_…"
defer></script>The widget calls the API at the address it is loaded from. It keeps the conversation in the browser's local storage: under 24 hours of inactivity and the person gets their thread back; beyond that a new conversation opens. It also assigns a stable visitor identifier per browser, unless you provide one (data-uid or dalevoz.identify).
| Attribute | Purpose |
|---|---|
data-agent | Required. The agent's technical name. |
data-key | Required. The workspace's pk_ key. |
data-mode | widget (floating bubble, the default), embed (inside your page, always open) or popover (centered window). |
data-target | In embed mode: the CSS selector of the container (otherwise the body). |
data-locale | Forces the language. Otherwise, the browser's (two letters). |
data-title | Overrides the panel title set in the console. |
data-accent | Overrides the accent color set in the console (a CSS color). |
data-uid | The visitor's identifier, when your site knows it (signed-in user). It becomes user.id, and each identifier has its own thread on the same browser. 120 characters at most. It remains declarative: without a signed token, the agent neither reads nor writes a memory record for this visitor (see dalevoz.identify and signed identity). |
data-contexte | A JSON object passed to dalevoz.contexte() on load, without a line of JavaScript. |
data-segment | The agent segment to apply (see the segments route), 60 characters at most. |
data-vars | A JSON object stored in the conversation's context when it is created (context.vars). |
data-session | A conversation identifier (UUID) to resume, for example the one returned by site/lire. |
data-reset | off removes the “new conversation” button from the header. |
data-close | off removes the close button (useful in an app WebView). |
data-host | The API address, if it differs from the script's. |
data-attribution | Ties an order in your store to the conversation that led to it. Absent by default: nothing is added. lien (recommended): links opened from the thread to your own site get dv=<conversation identifier>, other parameters (UTM) and the anchor stay intact, and no cookie is set; your store keeps the value in its session and copies it onto the order. on: the widget sets the dv_conv cookie (30 days, only the identifier), to declare in your consent banner. |
data-canal and data-plein are set by DaleVoz's iframe embed page; you do not need to write them. data-greeting is read but has no effect: the welcome message is set on the agent.
Both names refer to the same object. Each method is called directly (dalevoz.open()) or through the function (DaleVoz("open")), the form used by the queueing snippet below. The original French names remain valid.
| Function | What it does |
|---|---|
identify(objet) · identifier | Says who the visitor is. userId becomes user.id (200 characters at most) and each person gets back THEIR thread on the same browser; name becomes user.name; token (the token signed by your server, or a function that returns it) goes in the x-dalevoz-identity header and replaces user.id. Any other field (email, plan…) goes through contexte(). Call it before opening; called afterwards, the previous visitor's thread is left. identify(null) goes back to the anonymous visitor. |
open() · ouvrir() | Opens the panel. |
close() · fermer() | Closes the panel (no effect in embed mode). The conversation carries on when it is reopened. |
toggle() · basculer() | Opens it if closed, closes it if open. |
on(nom, fonction) · off(nom, fonction?) | Listens to a widget event (next table). off without a function removes all listeners for that name. An on("ready") set after loading is called right away. |
contexte(objet) | Declares what the page knows about the visitor. Merges with what is already there (a missing key stays, null erases), and goes out with the next message: nothing is sent as long as no conversation exists. Only variables declared on the agent (“Context” tab) reach it. Idempotent: can be called on every render. |
evenement(nom, donnees?) | Signals a moment (abandoned cart, step completed). The agent only reacts to events declared on it, and it is the agent's configuration that decides whether it speaks. With the panel closed, its reply appears as a bubble next to the launcher; the panel never opens on its own. Silent if the visitor chose “Stop interrupting me” or during a voice call. |
consentement() | Returns { requis, accepteLe }: whether the consent banner is on for the agent, and when this browser accepted it (ISO date or null). |
<!-- Before the widget tag: calls made before it loads are queued, then replayed. -->
<script>
window.DaleVoz = window.DaleVoz || function () {
(window.DaleVoz.q = window.DaleVoz.q || []).push(arguments);
};
// Usable right away, even if the script has not loaded yet:
DaleVoz("identify", { userId: "client-42", name: "Alexis" });
DaleVoz("on", "message", function (m) { console.log(m.role, m.text); });
</script>// Once loaded, window.DaleVoz and window.dalevoz are the same object.
dalevoz.open();
dalevoz.close();
dalevoz.toggle();
// What the page knows about the visitor. Merged; null erases a key.
dalevoz.contexte({ plan: "pro", cart_total: 129.9, page: "checkout" });
// A moment that can make the agent react, if it is declared on the agent.
dalevoz.evenement("cart_abandoned", { cart_total: 129.9 });
dalevoz.on("message", (m) => {
// m.role : "user" | "assistant" | "human_agent" ; m.text ; m.sessionId
if (m.role === "assistant") analytics.track("agent_reply");
});
dalevoz.on("open", () => console.log("panel open"));Signed-in visitor. Without a token, the identifier remains declarative: the conversation works, but the agent neither reads nor writes this visitor's memory record. With a token, the token proves who is speaking; a key that requires signed identity then rejects any unidentified visitor. Prefer a token FUNCTION: a token lives 15 minutes at most, a conversation longer. Signed identity
// User signed in on YOUR site. The token is signed by YOUR server (see Signed identity).
dalevoz.identify({
userId: "client-42",
name: "Alexis Martin",
email: "alexis@example.com", // goes through contexte(): declare the email variable on the agent
// A function is called again before each token expiry (15 minutes at most):
token: () => fetch("/api/dalevoz-token").then((r) => r.text()),
});
// Sign-out: back to this browser's anonymous visitor.
dalevoz.identify(null);Each event is received through dalevoz.on(name, function). The last five are also dispatched on window as a CustomEvent, with the same content in event.detail.
| on(…) | On window | When, and what it carries |
|---|---|---|
ready | dalevoz:ready | The widget is mounted. On window, emitted when the script finishes loading, then a second time (from the widget element, bubbling up) when its theme is applied: a window listener must cope with two calls. on("ready") is only called once. |
message | dalevoz:message | A message in the thread: { role, text, sessionId }, role being user (the visitor, typed or dictated text or a clicked button), assistant (the agent) or human_agent (a human agent, with agentName). Messages reloaded when a thread is resumed are not emitted again. Real-time voice calls do not go through here. |
open · close | dalevoz:open · dalevoz:close | When the panel opens and closes, only when the state changes. |
consent | dalevoz:consent | The visitor has just accepted the banner: { acceptedAt }. |
identify | dalevoz:identify | After identify(): { userId, signed }. |
| - | dalevoz:panneau | Legacy: on every opening or closing, with event.detail.ouvert (boolean). |
An HTTP tool is an action the agent triggers itself: sending a callback request to your CRM, creating a ticket, booking a slot. It is created on the agent's page, “Actions” tab, or by an AI connected over MCP. You give it a name, a sentence saying WHEN to use it, the public address that receives the call, the method (POST by default, PUT or GET), the information to collect (an asterisk makes it required: telephone*) and headers.
The information collected is sent as a JSON body, or as query parameters for a GET. An edit affects the working version: nothing goes live before the agent is published.
An API key is never written in plain text in a tool. It is stored in the workspace's vault: console, Settings, “ChatGPT and Claude” tab, “For a developer: secrets vault”. The tool's header then refers to it by name:
Authorization: Bearer {{secret:CRM_API_KEY}}
X-Api-Key: {{secret:AGENDA_CLE}}CRM_KEY). Value: 4,000 characters at most. 50 secrets per workspace.{{env:TOOL_SECRET_NOM}} is still understood: it reads the secret NOM from the same vault, never a server variable.DaleVoz calls your server (POST, JSON) when something happens in a conversation, instead of you having to ask. A subscription is created in the console, Settings, “ChatGPT and Claude” tab, “For a developer: webhooks”: an https address, the events to receive, all agents or just one. Its secret, whsec_…, is shown only once; “Send a test” sends it a test.ping, and the log keeps 30 days of deliveries.
| Header | Content |
|---|---|
Dale-Voz-Signature | t=<unix>,v1=<hex>: v1 is the HMAC-SHA256, in hexadecimal, of “<t>.<corps>” with your secret. |
Dale-Voz-Event | The event type. |
Dale-Voz-Delivery | The delivery identifier, the same on every retry: keep it so you only process an event once. |
The body, always the same envelope:
{
"id": "evt_…",
"type": "lead.qualifie",
"cree_le": "2026-09-24T10:12:04.000Z",
"espace": { "id": "…", "slug": "my-store" },
"agent": { "id": "…", "slug": "store-welcome" },
"donnees": {
"lead_id": "…",
"conversation_id": "…",
"canal": "widget",
"nouveau": true,
"nom": "Camille Martin",
"email": "camille@example.com",
"besoin": "Quote for 40 people"
}
}An empty field is omitted. Conversations from “Test” in the console emit nothing.
| Event | When | donnees fields |
|---|---|---|
conversation.demarree | A conversation starts. | conversation_id, canal, langue, contact_id, nom |
conversation.terminee | A conversation is closed and classified. | conversation_id, canal, terminee_le, issue, resume, sujet, satisfaction, messages_visiteur, messages_agent, duree_s, humain_intervenu |
transfert.demande | The visitor asks for a human. | conversation_id, canal, contact_id, nom, raison |
lead.qualifie | A contact is created or completed (nouveau: true or false). | lead_id, conversation_id, canal, nouveau, nom, email, telephone, besoin, secteur, preference_contact |
rdv.pris | An appointment is booked. | rdv_id, conversation_id, canal, debut, fin, duree_min, fuseau, nom, email, telephone, mode, sujet, invitation_envoyee |
satisfaction.recue | The visitor gives feedback. | conversation_id, canal, source, note, commentaire, resume |
test.ping | A test send, from the console. | message, abonnement_id |
mode is appel, ecrit or visio; note is satisfied, partial, need_detail or not_resolved.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_S = 5 * 60;
function signatureValide(corpsBrut, entete, secret) {
const champs = {};
for (const morceau of (entete ?? "").split(",")) {
const i = morceau.indexOf("=");
if (i > 0) champs[morceau.slice(0, i).trim()] = morceau.slice(i + 1).trim();
}
const t = Number(champs.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false;
const attendue = createHmac("sha256", secret).update(`${t}.${corpsBrut}`).digest();
const recue = Buffer.from(champs.v1 ?? "", "hex");
return recue.length === attendue.length && timingSafeEqual(recue, attendue);
}
const app = express();
// The RAW body: the signature covers the bytes received, not re-parsed JSON.
app.post("/webhooks/dale-voz", express.raw({ type: "application/json" }), (req, res) => {
const corps = req.body.toString("utf8");
if (!signatureValide(corps, req.get("Dale-Voz-Signature"), process.env.DALEVOZ_WEBHOOK_SECRET)) {
return res.status(400).end();
}
const livraison = req.get("Dale-Voz-Delivery");
if (dejaTraitee(livraison)) return res.status(200).end(); // a retry
const evenement = JSON.parse(corps);
traiter(evenement.type, evenement.donnees);
marquerTraitee(livraison);
res.status(200).end();
});import hashlib
import hmac
import os
import time
from flask import Flask, request
TOLERANCE_S = 5 * 60
app = Flask(__name__)
def signature_valide(corps: bytes, entete: str, secret: str) -> bool:
champs = dict(m.split("=", 1) for m in (entete or "").split(",") if "=" in m)
try:
t = int(champs.get("t", ""))
except ValueError:
return False
if abs(time.time() - t) > TOLERANCE_S:
return False
attendue = hmac.new(secret.encode(), f"{t}.".encode() + corps, hashlib.sha256).hexdigest()
return hmac.compare_digest(attendue, champs.get("v1", ""))
@app.post("/webhooks/dale-voz")
def webhook_dale_voz():
corps = request.get_data() # the raw body, as bytes
if not signature_valide(corps, request.headers.get("Dale-Voz-Signature", ""), os.environ["DALEVOZ_WEBHOOK_SECRET"]):
return "", 400
livraison = request.headers.get("Dale-Voz-Delivery")
if deja_traitee(livraison):
return "", 200
evenement = request.get_json()
traiter(evenement["type"], evenement["donnees"])
marquer_traitee(livraison)
return "", 200DaleVoz exposes a remote MCP server. Connected to Claude, ChatGPT, Claude Code or Codex, it lets the AI create and configure your agents in conversation, with YOUR permissions in the workspace you authorize. No key to paste: authorization goes through OAuth, on a DaleVoz screen where you choose the workspace.
https://dalevoz.ai/api/mcpThe illustrated step by step, tool by tool, is in the console: Connect my AI. One authorization opens a single workspace: an agency adds one connection per client.
Claude (every plan, Free included, with one custom connector on Free; on Team or Enterprise, the organization owner adds it; on the web, the desktop app or your phone): the link below opens the “Add custom connector” window with the name and address already filled in. Keep the suggested options and click “Add” at the bottom of the window, then “Connect” on the connector page, and allow your workspace on the DaleVoz page that opens. Without the link: claude.ai/customize/connectors, “Add custom connector”, paste the address. The link: add DaleVoz to Claude.
ChatGPT (Plus, Pro, Business or Enterprise/Edu plan; not available on Free or Go; on Business or Enterprise, the admin of the ChatGPT workspace creates the app). Only on chatgpt.com, in a computer's browser: MCP apps exist neither in the app nor on the phone.
If ChatGPT asks you to turn on developer mode before creating an MCP app: Settings, the search bar at the top of settings, type “developer”, then turn on Developer mode.
Claude Code : one command, then your browser opens for authorization.
claude mcp add --transport http dale-voz https://dalevoz.ai/api/mcpCodex : the same logic, in two commands.
codex mcp add dale-voz --url https://dalevoz.ai/api/mcp
codex mcp login dale-voz2026-07-28, 2025-11-25, 2025-06-18.To start, paste this message into a new conversation: your AI opens DaleVoz, tells you where you stand and guides you step by step.
I'm starting with DaleVoz: run dalevoz_demarrer, tell me in three lines where I stand, then guide me step by step, one question at a time, no jargon.
dalevoz_documentation. Without a connector, it all fits in one address: https://dalevoz.ai/llms-full.txt?lang=en.The number of visible tools depends on your role and your plan. On self-service, your AI sees the essentials (create, feed, test, publish, install, read conversations) and opens the rest on demand, without reconnecting anything. Every write is recorded in the agent's log, under your name.
In what order to use them to build an agent that holds up: the method.
| Where | Limit |
|---|---|
| interact | Message: 8,000 characters. Attachments: 5 per message. |
| attachments | 15,000,000 base64 characters (roughly 11 MB); extracted text truncated at 20,000 characters. |
| messages | 50 items per call. |
| history | The 60 most recent items. |
| voice/dictee | 8 MB, 120 seconds; 20 calls per minute per workspace. |
| voice/lecture | 1,200 characters read; 30 calls per minute per workspace. |
| site/lire | 6 readings per hour per IP address; 8 pages read. |
| site/pont-whatsapp | 3 sends per hour per IP address; only one per conversation. |
| kb/sync-table | 2,000 rows per upload. |
| analytics | 90 days at most with days. |
| conversations/export | 200 conversations per page. |
| agents | Prompt: 60,000 characters; maxTokens from 256 to 32,000. |
| segments | promptFragment and systemPrompt: 20,000 characters each. |
| Signed identity | Token of 4,096 characters, 15-minute lifetime at most. |
| Secrets vault | 50 secrets per workspace, 4,000 characters per value. |
| /api/mcp | 240 calls per minute per account (a person in a workspace, or the workspace for an sk_ key); 3,000 per IP address, including at most 120 authentication failures (429, with Retry-After: 60). |
The limits above per workspace or per IP address (dictation, speech, site reading, WhatsApp bridge, MCP) are counted in memory per server instance: the limit actually reached can be a multiple of the one stated, so do not rely on it as an exact quota.
A pk_ key lives in your site's HTML: without a limit, anyone who copies it could drain your credits. Every public route therefore counts its calls in fixed windows (minute, hour, day), in the database, for the KEY (all visitors combined) and, behind a pk_ key only, for the VISITOR, recognized by their IP address. The thresholds are 10 to 20 times above the peaks measured at our customers: they stop a script, not a website. Beyond them: 429, with code limite, portee (cle or visiteur), reessayerDansS and the Retry-After header; error is a sentence for the visitor, in their language, which the widget shows once without retrying. A workspace that needs it (a conference where the whole room talks to the agent over the same Wi-Fi) can have them raised: write to us.
| Route | Per key | Per visitor (pk_) |
|---|---|---|
| interact | 300 per minute, 3,000 per hour, 20,000 per day | 40 per minute, 600 per hour |
| attachments | 60 per minute, 600 per hour, 3,000 per day | 10 per minute, 60 per hour |
| voice/dictee, voice/lecture | 600 per hour, 4,000 per day (both together) | 20 per minute, 200 per hour |
| site/lire | 60 per hour, 400 per day | - |
| site/pont-whatsapp | 30 per hour, 200 per day | - |
These limits come on top of those of the workspace's plan (conversations per month, turns per conversation), which do not answer with a 429 but with a 200 whose message tells the visitor (see the interact route).
| Status | Meaning in the API |
|---|---|
| 200 | Success. Also for a plan limit reached on interact (the message is in the traces). |
| 400 | Unreadable body, invalid field (issues), missing parameter, format rule not met. |
| 401 | Missing, unknown or revoked key; identity token rejected; wrong type of key on the voice routes. |
| 403 | pk_ key on a route reserved for sk_ keys; origin not allowed; site reading turned off. |
| 404 | Agent, session or resource not found in the key's workspace; agent never published. |
| 409 | Knowledge base batch synced from elsewhere; site not read yet. |
| 413 | Audio too large or too long. |
| 422 | Unreadable attachment; site unreachable. |
| 423 | Agent paused. |
| 429 | Too many calls (see the two tables above). code is limite for a per-key limit; Retry-After says when to retry. |
| 500, 502, 503 | Server error, model response interrupted, voice or channel not configured. |