Management API — integration guide
The Management API (https://openapi.stevo.chat) is the official entry point to integrate systems with Stevo: instances, messaging, webhooks, Stevo AI, campaigns, StevoVoice, GHL and billing. This guide shows how to use it — the full list of endpoints, with a playground, is in the Management API reference.
The stevo-sdk SDK does all of this for you — safe retry, webhook verification and types. This guide is for integrating in another language (PHP, Python, Go, n8n, Make...) or for understanding what happens under the hood.
1. Authentication
Create an API Key in the panel (profile menu → API Keys) and send it on every request:
curl https://openapi.stevo.chat/v1/me \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY'
GET /v1/me shows the account, the scopes and the key restriction — it's the best endpoint to test your setup.
Scopes
Each key only does what its scopes allow (403 insufficient_scope otherwise):
| Area | Scopes |
|---|---|
| Instances | instances:read, instances:write |
| Messages | messages:send, messages:read |
| Account webhooks | webhooks:read, webhooks:write |
| Stevo AI | ai:read, ai:write |
| StevoVoice | voice:read, voice:manage |
| Campaigns | dispatch:read, dispatch:manage |
| Billing | billing:read, billing:purchase |
| GHL / Agency / Links | ghl:manage, agency:read, agency:manage, links:generate |
Older keys with instances:manage and ai:manage keep working (they are synonyms of :write).
instances:write includes destructive operationsTotal recreate, logout and instance deletion use the same scope. There is no separate scope for them — grant instances:write only to who needs it.
Instance-restricted key
A key can be limited to some instances (allowed_instance_ids in /v1/me; null = all). Outside the list, the API answers 404 not_found, exactly as for another account's instance. Account operations (creating an instance, account webhooks) answer 403 key_restricted / 403 instance_restricted_key. Use one restricted key per customer whenever the integration serves a single customer.
2. Errors, correlation and rate limit
Every error has the same shape:
{ "error": { "code": "not_ready", "message": "instance not connected", "request_id": "req_3f9c...", "retryable": false } }
retryable:truewhen repeating the same call makes sense (429,5xx, upstream failure);falseon other4xx— fix the request before repeating.X-Request-Id: every response carries this header (and the same value inerror.request_id). You can send your own (up to 128 characters[A-Za-z0-9._:-]) and it's echoed back. Give it to support when reporting a problem.- Per-key rate limit: every response carries
X-RateLimit-LimitandX-RateLimit-Remaining. When exceeded,429 rate_limitedwithRetry-After(seconds).
3. Instances
3.1. Tie the instance to your system: external_ref and metadata
# Create in a free slot, already with your customer's reference
curl https://openapi.stevo.chat/v1/instances \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY' \
--header 'Content-Type: application/json' \
--data '{ "name": "downtown-store", "external_ref": "WSP-000123", "metadata": { "plan": "pro" } }'
# Find by your reference
curl --get https://openapi.stevo.chat/v1/instances \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY' \
--data-urlencode 'external_ref=WSP-000123'
# Update (metadata REPLACES the whole object; external_ref: null clears it)
curl -X PATCH https://openapi.stevo.chat/v1/instances/INSTANCE_UUID \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY' \
--header 'Content-Type: application/json' \
--data '{ "metadata": { "plan": "enterprise" } }'
external_ref: up to 128 characters, unique per account (409 external_ref_conflict). metadata: up to 16 text→text pairs. Both travel in every event of the account webhooks.
3.2. Reconciliation — only what changed
GET /v1/instances with updated_since becomes an incremental query, including deleted instances:
curl --get https://openapi.stevo.chat/v1/instances \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY' \
--data-urlencode 'updated_since=2026-09-22T00:00:00Z' \
--data-urlencode 'limit=100'
{
"data": [ { "id": "...", "external_ref": "WSP-000123", "connected": true, "updated_at": "..." } ],
"deleted": [ { "id": "...", "external_ref": "WSP-000099", "metadata": null, "deleted_at": "..." } ],
"next_cursor": "eyJ1...",
"has_more": true,
"deleted_has_more": false
}
Follow next_cursor (send it back as cursor) while has_more is true. If deleted_has_more is true, repeat the call with updated_since = the last deleted_at received. Without updated_since, the response is the same as always ({ data, count }).
3.3. Connection, QR and deletion
| Route | Engine | What it does |
|---|---|---|
GET /v1/instances/{id}/connection | both | connected, logged_in, name and number |
GET /v1/instances/{id}/health | both | unified health (doesn't fail if the server is down) |
GET /v1/instances/{id}/qr | SM v2 | current QR (qr as data-URI) and pairing_code |
POST /v1/instances/{id}/qr/refresh | SM v2 | forces a new QR |
POST /v1/instances/{id}/disconnect | SM v2 | disconnects; { "logout": true } + ?confirm=true discards the session (irreversible) |
DELETE /v1/instances/{id}?confirm=true | both | deletes the instance and frees the slot (irreversible, idempotent) |
Without ?confirm=true on irreversible operations, the API answers 400 confirmation_required and does nothing. The deleted instance shows up in deleted during reconciliation and fires the instance.deleted event.
4. Sending messages
POST /v1/instances/{id}/messages (scope messages:send) sends one message, on SM v2 or the Official API, without you dealing with the instance server:
curl https://openapi.stevo.chat/v1/instances/INSTANCE_UUID/messages \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: order-9f21' \
--data '{ "to": "5511999999999", "text": "Hello!" }'
- Text:
text. Media:media_url+media_type(image,video,audio,document), with optionalcaption/filename. Official API only:cloud_apiwith the raw Cloud API payload (e.g. template). 201response withengine,sentandmessage_id. Disconnected instance →409 not_ready.- For volume, prefer campaigns or the instance server directly.
4.1. Idempotency-Key — never duplicate
Send Idempotency-Key (1–200 characters, unique per instance, valid for 24h). Repeating the same request does not resend: the API returns the original response with the Idempotent-Replayed: true header.
| Response | Meaning | What to do |
|---|---|---|
409 idempotency_conflict | same key, different body | use another key |
409 idempotency_in_progress | the same key is still being processed | wait and retry |
504 upstream_timeout | the sending server didn't answer — it may have sent | check the status before resending |
502 upstream_unavailable | couldn't reach the instance server — nothing was sent | safe to retry |
4.2. Message status
# By message id
curl https://openapi.stevo.chat/v1/instances/INSTANCE_UUID/messages/MESSAGE_ID \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY'
# By idempotency key — the right lookup after a timeout
curl --get https://openapi.stevo.chat/v1/instances/INSTANCE_UUID/messages \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY' \
--data-urlencode 'idempotency_key=order-9f21'
States: queued → sent → delivered → read, or failed (scope messages:read). Transitions never go backwards and match the message.* webhook events. After an upstream_timeout, the status stays queued — and moves on its own to sent/delivered if the message actually went out.
5. Account webhooks
An account webhook receives the events of all instances in a single envelope, normalized across SM v2 and the Official API, signed and with reliable delivery. It's the recommended option for new integrations (the per-instance webhook, /v1/instances/{id}/webhook, still exists, unsigned).
5.1. Create
curl https://openapi.stevo.chat/v1/webhooks \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY' \
--header 'Content-Type: application/json' \
--data '{
"url": "https://my-system.com/webhooks/stevo",
"events": ["message.received", "message.failed", "instance.connected", "instance.disconnected"],
"description": "My CRM",
"max_in_flight": 16
}'
The response carries the secret (whsec_...) — only in this response; afterwards, only secret_last4. Keep it in a vault/environment variable.
| Field | Rule |
|---|---|
url | https:// and a public destination; redirects are not followed |
events | list of types, or ["*"] for all |
max_in_flight | concurrent deliveries to this destination: 1 to 64, default 8. Raise it for endpoints that absorb volume (e.g. an agency with hundreds of instances) |
active | false pauses it; the API also deactivates it on its own after 410 Gone or 5 consecutive exhausted deliveries (disabled_reason: gone / too_many_failures) |
Manage with GET/PATCH/DELETE /v1/webhooks/{id}. Limit of 10 webhooks per account. Requires a key without instance restriction.
Events: instance.created, instance.updated, instance.connecting, instance.connected, instance.disconnected, instance.auth_failed, instance.qr.updated, instance.deleted, message.received, message.sent, message.delivered, message.read, message.failed, message.edited, message.deleted, message.reaction.
5.2. What reaches your endpoint
POST /webhooks/stevo HTTP/1.1
Content-Type: application/json
X-Stevo-Event-Id: evt_01J...
X-Stevo-Timestamp: 1790190000
X-Stevo-Attempt: 1
X-Stevo-Signature: sha256=5b1c...
{
"event_id": "evt_01J...",
"type": "message.received",
"attempt": 1,
"created_at": "2026-09-22T14:03:11.000Z",
"account_id": "ACCOUNT_UUID",
"instance": { "id": "INSTANCE_UUID", "external_ref": "WSP-000123", "metadata": { "plan": "pro" }, "engine": "smv2" },
"data": { "message_id": "3EB0...", "chat": "5511999999999", "from": "5511999999999", "from_me": false, "is_group": false, "type": "text", "text": "Hello!" }
}
- Answer
2xxwithin 10 seconds (process in a queue if it takes longer). Without a2xx, Stevo redelivers: up to 8 attempts over about 21h. - Delivery is "at least once": a redelivery arrives with the same
event_idand a higherattempt. Deduplicate byevent_id. - Answering
410 Gonedeactivates the webhook.
5.3. Verify the signature
X-Stevo-Signature = sha256= + HMAC-SHA256(secret, timestamp + "." + rawBody) in hexadecimal, where secret is the full whsec_... value and timestamp is X-Stevo-Timestamp. Also reject timestamps more than 5 minutes off (replay protection).
import hmac, hashlib, time
def valid_signature(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature.removeprefix("sha256="), expected)
function valid_signature(string $rawBody, string $signature, string $timestamp, string $secret): bool {
if (abs(time() - (int) $timestamp) > 300) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, preg_replace('/^sha256=/', '', $signature));
}
Compute the HMAC over the exact bytes received, before any JSON parsing. Re-serializing the JSON changes the body and the signature won't match.
5.4. Secret rotation
POST /v1/webhooks/{id}/rotate-secret generates a new secret (returned once). During the overlap_seconds window (0–86400, default 86400 = 24h), each delivery also carries X-Stevo-Signature-Previous, signed with the previous secret — accept either one while you swap the configuration. overlap_seconds: 0 is the emergency rotation (the old one stops working immediately).
5.5. Delivery history and redelivery
| Route | What it does |
|---|---|
GET /v1/webhooks/deliveries | lists deliveries; filters webhook_id, instance_id, status (pending, delivered, failed, exhausted), event_type, since, cursor, limit |
GET /v1/webhooks/deliveries/{id} | detail with the log of each attempt |
POST /v1/webhooks/deliveries/{id}/retry | redelivers now, with the same event_id (up to 5 manual redeliveries) |
Was your endpoint down? List the exhausted deliveries since the incident and redeliver them — deduplication by event_id guarantees nothing is processed twice.
6. Recipe: syncing an external system with Stevo
- Create an API Key with
instances:read,messages:send,messages:read,webhooks:readandwebhooks:write. - Create the instances with
external_ref= the customer id in your system. - Create one account webhook and store the
secret. - In the endpoint: verify the signature, deduplicate by
event_id, answer200quickly and process in a queue — useinstance.external_refto know which customer the event belongs to. - Periodically (e.g. every hour), run reconciliation with
updated_sinceto catch any missed change, including deletions. - Send with
Idempotency-Key; on504 upstream_timeout, check the status by key before resending.
External integrations use only the API (or the SDK). Supabase/Postgres, service_role and internal tables are not part of the contract and may change without notice.
7. MCP — the same API for AI agents
The same backend exposes an MCP server at https://openapi.stevo.chat/mcp (Streamable HTTP), with the same operations as tools and authenticated by the same API key. Plug it into n8n (MCP Client node), Claude or Cursor.
References
- 📖 Management API reference — all endpoints, schemas and playground
- 📦
stevo-sdkSDK (Node.js) - 📄 OpenAPI specification:
https://openapi.stevo.chat/openapi.yaml