Authenticating
One mechanism for the API, outgoing webhooks and the MCP server: a bearer token. You create it in your settings, API tab, and it is shown once — we keep only a SHA-256 fingerprint, and nobody here can read it back.
A token always starts with “cvk_”, followed by forty random characters. The prefix is not decorative: GitHub and others sweep public repositories for known patterns, and a secret published by mistake with a recognisable prefix gets spotted and reported.
Every call carries the header:
Authorization: Bearer cvk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx Accept: application/json
curl :
curl -s https://convosia.app/api/v1/me \ -H "Authorization: Bearer $CONVOSIA_TOKEN"
{
"organization": { "id": "01K5...", "name": "Boutique Awa" },
"token": {
"name": "Intégration Zapier",
"prefix": "cvk_a4f2c1",
"scopes": ["conversations:read", "knowledge:read"],
"expires_at": null
}
}
Scopes
A token opens only what you tick. The scope is declared on the route itself, not buried in a controller: you open the routes file and see at a glance what a token opens.
conversations:read
List conversations, read a full thread with its messages.
contacts:read
List contacts and their channel identities.
knowledge:read
Query the merchant’s knowledge base.
`/v1/me` requires no scope: “does this token work?” must have an answer, even for a token that opens nothing.
Endpoints
Five, all read-only, all version-prefixed. The version number costs four characters today and avoids a dead end later: an API published without one can never change shape again without breaking someone.
GET /api/v1/me
—
GET /api/v1/conversations
conversations:read
GET /api/v1/conversations/{id}
conversations:read
GET /api/v1/contacts
contacts:read
GET · POST /api/v1/knowledge/search
knowledge:read
Search accepts GET and POST: a three-hundred-character question in a URL gets truncated by intermediaries.
curl -s "https://convosia.app/api/v1/conversations?status=open&limit=10" \ -H "Authorization: Bearer $CONVOSIA_TOKEN"
{
"data": [
{
"id": "01K5...",
"contact_id": "01K5...",
"status": "open",
"awaits_human": true,
"last_inbound_at": "2026-08-23T21:14:07+00:00",
"last_outbound_at": "2026-08-23T21:14:31+00:00"
}
],
"next_cursor": null
}
curl -s https://convosia.app/api/v1/knowledge/search \
-H "Authorization: Bearer $CONVOSIA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"q":"quels sont vos délais de livraison ?","limit":3}'
{
"data": [
{
"source_id": "01K5...",
"source_title": "Conditions de vente.pdf",
"ordinal": 4,
"content": "Livraison à Abidjan sous 24 h…",
"score": 0.8123,
"corroborated": true
}
]
}
Filters and pagination
Pagination is by cursor, never by page number: page 3 changes content the moment a conversation arrives, and you silently skip a row.
limit
limit
limit
status
cursor
q
`next_cursor` is `null` as soon as the page is not full: a half-filled page is the last one, and making you call again to learn that costs one request on every pass.
Errors
Always the same shape: a stable code a program tests, a message a human reads. An integrator who has to match French sentences to tell two errors apart writes code that breaks on the first rewording.
{ "error": { "code": "insufficient_scope", "message": "…" } }
401
missing_token
401
invalid_token
403
insufficient_scope
404
not_found
422
missing_query
429
—
Rate limiting
One hundred and twenty calls per minute per token. Twenty per minute per IP address for requests with no token — they are refused anyway, and without a limit someone could try tokens at random as fast as the network allows.
The limit is on the TOKEN, not the address: two merchants behind the same corporate network must not get in each other’s way, and an integrator who changes address must not bypass their limit. The counter key is a fingerprint of the token, never the token itself — a secret in a cache key is a secret in a store nobody watches.
Outgoing webhooks
You declare an endpoint in your settings. Every send is signed, timestamped, and retried up to eight times on failure.
Events
You pick which ones interest you, endpoint by endpoint.
conversation.opened
escalation.opened
escalation.resolved
order.placed
order.confirmed
appointment.booked
appointment.cancelled
contact.created
form.completed
The payload
{
"id": "evt_01K5...",
"type": "escalation.opened",
"created_at": "2026-08-23T21:14:07+00:00",
"organization_id": "01K5...",
"data": { }
}
`id` is there so you can discard a duplicate: a queue retry can replay a delivery, and a well-written receiver keeps the ids it has seen and handles each one once.
Headers
Convosia-Signature
t={timestamp},v1={HMAC-SHA256}
Convosia-Event
The event type, so you can route without decoding the body.
Convosia-Delivery
The id of THIS delivery attempt.
User-Agent
Convosia-Webhooks/1.0
Verifying the signature
The signature covers “timestamp.body”, not the body alone: without the timestamp inside what is signed, an intercepted payload would stay valid forever. Compare in constant time, and refuse anything older than five minutes.
// Node.js
const crypto = require('crypto');
function verifie(corpsBrut, entete, secret) {
const parts = Object.fromEntries(
entete.split(',').map((p) => p.split('='))
);
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return false;
const attendu = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${corpsBrut}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(attendu),
Buffer.from(parts.v1 ?? '')
);
}
# PHP
$parts = [];
parse_str(str_replace(',', '&', $_SERVER['HTTP_CONVOSIA_SIGNATURE']), $parts);
if (abs(time() - (int) $parts['t']) > 300) {
http_response_code(400);
exit;
}
$attendu = hash_hmac('sha256', $parts['t'] . '.' . $corpsBrut, $secret);
if (! hash_equals($attendu, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
Retries and shutdown
Eight attempts spread with exponential backoff. An address that fails twenty times in a row is disabled automatically — not as punishment: a dead endpoint we keep calling ends up getting our domain treated as a nuisance. The address is re-checked BEFORE EVERY SEND, not only when saved: a hostname can start pointing at a private network between two deliveries.
The MCP server
A single POST endpoint speaking JSON-RPC 2.0. It lets a model — Claude, or any client that speaks the protocol — query the merchant’s account directly.
POST https://convosia.app/mcp
· JSON-RPC 2.0 · 2025-06-18
Outside the `/v1/` prefix, and that is not an oversight: its version is the PROTOCOL’s, negotiated at the handshake. Giving it a version of ours would mean two versions to reconcile for one surface.
Methods
initialize
The handshake. Returns the protocol version, the capabilities and the server name.
ping
Returns an empty result. Tells you the connection holds.
tools/list
The tool catalogue, FILTERED by the token’s scopes.
tools/call
Runs a tool. Expects `name` and `arguments`.
curl -s https://convosia.app/mcp \
-H "Authorization: Bearer $CONVOSIA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": { "listChanged": false } },
"serverInfo": { "name": "convosia", "version": "1.0.0" }
}
}
Tools
A model sees in `tools/list` only what it can actually call. A tool that is visible but refused on call teaches the model to retry, and it retries.
lister_conversations
conversations:read
Recent conversations. Arguments: `statut` (open, pending, closed), `limite` (1 to 100).
lire_conversation
conversations:read
The full thread of one conversation. Argument: `id`.
lister_contacts
contacts:read
Contacts and their channel identities. Argument: `limite` (1 to 200).
chercher_connaissances
knowledge:read
Searches the merchant’s documents. Arguments: `question`, `limite` (1 to 20).
curl -s https://convosia.app/mcp \
-H "Authorization: Bearer $CONVOSIA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "chercher_connaissances",
"arguments": { "question": "délais de livraison", "limite": 3 }
}
}'
Connecting a client
Most MCP clients accept a remote HTTP server with a header. Here is the expected shape:
{
"mcpServers": {
"convosia": {
"url": "https://convosia.app/mcp",
"headers": {
"Authorization": "Bearer cvk_…"
}
}
}
}
JSON-RPC errors
-32600
Invalid JSON-RPC request.
-32601
Unknown method.
-32602
Missing expected parameter — `name` on `tools/call`.
No tool writes into the merchant’s account, and that is not a step to be taken absent-mindedly later: letting a model send messages to real customers needs guardrails this server does not have — human confirmation, per-contact rate limiting, a separate log. The day it is done, it will be a project of its own.
What protects you from other merchants
The token designates an organisation, and the tenant is set BEFORE the controller runs. After that, every database query is bounded: even a carelessly written controller cannot read another organisation’s conversations, because there is no reachable “other organisation” any more. The isolation is structural, not vigilant — and twelve test suites check it.