Official SDK — stevo-sdk
stevo-sdk is Stevo's official SDK (TypeScript/JavaScript). One package with everything the platform offers — neatly split by area:
| Area | Access | What it does |
|---|---|---|
| Account management | stevo.me, stevo.instances, stevo.links, stevo.ghl, stevo.billing, stevo.ghlAgency | instances (create, connect, reconcile, delete), access links, GHL, purchases and GHL Agency |
| Account webhooks | stevo.webhooks | one endpoint receives events from all instances, signed with HMAC-SHA256, with history and redelivery |
| Messaging | stevo.whatsapp(id) · stevo.messages · stevo.smv2(id) · stevo.oficial(id) | unified layer, proxy with idempotency and status, or the instance server directly (114 SM v2 operations / Meta Cloud API) |
| Stevo AI (v2) | stevo.ai | agents, funnel, provider keys, FAQ, tools, follow-up, RAG, memory |
| Bulk campaigns | stevo.dispatch | WhatsApp campaigns with instance rotation, scheduling and variations |
| StevoVoice | stevo.voice | AI voice calls, recordings, activation and widget |
📦 npm: npm.im/stevo-sdk · current version 2.1.0 · Requires Node.js 18+ · ESM + CommonJS + types included · zero runtime dependencies
- Signed account webhooks with reliable delivery,
event_iddeduplication, history and redelivery (stevo.webhooks) — plusmax_in_flightto control throughput (2.1.0). - Safe sending:
Idempotency-Keyon send and message status (queued→sent→delivered→read/failed). - Instances tied to your system:
external_refandmetadata, reconciliation withlistChangesand deletion withdelete. - Full lifecycle through the API: QR, connection, disconnection and health.
- Unified layer
stevo.whatsapp(id), configurable retry and observability (X-Request-Id,onResponse).
Coming from 1.x? See Upgrading from 1.x to 2.x — the migration is small.
The same API ships an MCP server at https://openapi.stevo.chat/mcp. Plug it into n8n (MCP Client node), Claude or Cursor with your API key and the AI manages your account, sends messages, creates campaigns and schedules calls without a line of code.
Everything the SDK does goes through the Management API (REST). To use it directly over HTTP, in any language, see Management API — integration guide.
1. Create your API Key
In the Stevo panel: profile menu → API Keys → New API Key. Tick the scopes (permissions) this key will have — from instances:read (read-only) up to sending, webhooks, campaigns, voice, AI and billing. The key is shown only once — store it safely and never expose it in the browser/front-end.
A key can also be restricted to some instances: outside the list, the API answers 404 as if the instance did not exist. Prefer a restricted key when the integration talks to a single customer.
2. Install and connect
npm install stevo-sdk
import { Stevo } from 'stevo-sdk';
const stevo = new Stevo(process.env.STEVO_API_KEY!); // stevo_sk_...
const instances = await stevo.instances.list();
console.log(`${instances.filter((i) => i.connected).length} connected`);
const me = await stevo.me.get(); // account, scopes and key restriction (allowed_instance_ids)
The constructor validates the key immediately: empty or without the stevo_sk_ prefix throws Error right away.
Constructor options
| Option | Default | Notes |
|---|---|---|
baseUrl | https://openapi.stevo.chat | — |
timeoutMs | 60000 | per-request timeout |
retry | see Retry | automatic retry rules |
maxRetries | 2 | legacy — extra retries (maxRetries: 2 = maxAttempts: 3) |
onResponse | — | hook called on every attempt (metrics/logs) |
credentialsCacheMs | 0 | cache for the credentials smv2(id)/oficial(id) resolve; invalidated automatically on 401, restart, recreateTotal and delete |
3. Account management
// Create an instance in a free plan slot (does not buy a new slot)
const { instance } = await stevo.instances.create({ name: 'my-instance' });
const official = await stevo.instances.create({ engine: 'official' }); // returns onboarding_url
await stevo.instances.restart(id); // reconnect (scan QR afterwards)
await stevo.instances.recreateTotal(id); // DESTRUCTIVE: wipes everything
await stevo.instances.recreateTotalBatch([id1, id2, id3]); // in bulk (up to 50)
// Fusion: links the Official API instance to the SM v2 instance of the SAME number — STChat,
// transmitter and AI treat both as a single contact (the panel's "Merge instances")
await stevo.instances.fuse(officialId, smv2Id); // 400 phone_mismatch/engine_mismatch, 409 already_fused
const fusion = await stevo.instances.getFusion(officialId); // { instance, fused, partner }
await stevo.instances.unfuse(officialId); // clears both sides
// Links, GHL and purchases
const link = await stevo.links.whiteLabel(id, { permanent: true });
await stevo.ghl.connect(id, { mode: 'oauth' });
const catalog = await stevo.billing.plans();
if (catalog.has_saved_card) await stevo.billing.purchase({ plan: 'stevo3' }); // charges the saved card
// No saved card (or to send the link to the end customer): hosted Stripe Checkout.
// Doesn't charge right away — returns checkout_url; after payment, provisioning is automatic.
const { checkout_url } = await stevo.billing.checkout({ plan: 'stevo3' });
recreateTotal, recreateTotalBatch, instances.delete, disconnect with logout: true and ai.conversations.clear cannot be undone. Ask for explicit confirmation in any screen or automation that exposes them.
3.1. external_ref and metadata — tie the instance to your system
Use external_ref (opaque reference, unique per account, up to 128 characters) and metadata (text→text pairs, up to 16 keys) to link the instance to the customer, store or workspace in your system — no need to store Stevo's UUID:
const { instance } = await stevo.instances.create({
name: 'downtown-store',
external_ref: 'WSP-000123',
metadata: { workspace: 'WSP-000123', plan: 'pro' },
});
await stevo.instances.update(instance.id, { metadata: { plan: 'enterprise' } }); // REPLACES the whole object
await stevo.instances.update(instance.id, { external_ref: null }); // clears the reference
const mine = await stevo.instances.findByExternalRef('WSP-000123'); // Instance | null
const forTenant = await stevo.instances.list({ external_ref: 'WSP-000123' });
metadatainupdatereplaces the object (no merge) — read the current one before changing a single field.- Duplicate
external_refin the account →409 external_ref_conflict. external_refandmetadataalso arrive in every account webhook event (instance.external_ref), so your handler knows which customer the event belongs to without any lookup.
3.2. Reconciliation — listChanges / iterateChanges
To keep your database in sync without re-reading everything, ask only for what changed since last time — including deleted instances:
const since = await myDb.lastSync(); // Date
for await (const page of stevo.instances.iterateChanges(since)) {
for (const instance of page.data) await myDb.upsertInstance(instance);
for (const deleted of page.deleted) await myDb.removeInstance(deleted.id); // { id, external_ref, metadata, deleted_at }
}
await myDb.saveSync(new Date());
listChanges({ updated_since, cursor?, limit? })returns one page:{ data, deleted, next_cursor, has_more, deleted_has_more }.iterateChangesfollows the cursor for you.- Deduplicate
deletedbyid+deleted_at. If the sameidshows up again indatawithupdated_atafterdeleted_at, the slot was reused by a new instance.
3.3. Deleting an instance — delete
await stevo.instances.delete(id); // throws Error BEFORE calling the API: missing { confirm: true }
await stevo.instances.delete(id, { confirm: true }); // { deleted: true, deleted_at }
Frees the slot, clears external_ref/metadata and records the deletion (it shows up in listChanges and fires the instance.deleted event). It is idempotent: deleting an already empty slot returns { deleted: false }.
4. Connecting an instance: create → QR → connected
getConnection and health work on both engines; QR and disconnect are SM v2 only (the Official API connects through onboarding_url and answers 400 unsupported_engine to those two):
const { instance } = await stevo.instances.create({ name: 'support', external_ref: 'WSP-000123' });
// Set up the webhook BEFORE scanning, so you don't miss instance.connected
await stevo.webhooks.create({
url: 'https://my-system.com/webhooks/stevo',
events: ['instance.connected', 'instance.disconnected', 'instance.qr.updated', 'message.received'],
});
// QR — it may take a few seconds for the server to generate it
let qr = await stevo.instances.getQrCode(instance.id);
while (!qr.qr) {
await new Promise((r) => setTimeout(r, 2_000));
qr = await stevo.instances.getQrCode(instance.id);
}
console.log(qr.qr); // data:image/png;base64,... — render it in an <img>, or show qr.pairing_code
// Confirm via the connection (or via the instance.connected event on your webhook)
const connection = await stevo.instances.getConnection(instance.id); // { connected, logged_in, name?, phone_number? }
await stevo.instances.refreshQrCode(instance.id); // QR expired? generates a new one (restores the saved webhook)
await stevo.instances.health(instance.id); // doesn't throw if the server is down: server_reachable: false
await stevo.instances.disconnect(instance.id); // disconnects keeping the session
await stevo.instances.disconnect(instance.id, { logout: true, confirm: true }); // IRREVERSIBLE: discards the session
Instance webhook vs. account webhook
stevo.instances.setWebhook(id, ...)is the webhook of one instance — the same as Settings → Webhook in the panel, in the engine's native format and unsigned. It keeps working:
await stevo.instances.setWebhook(id, { url: 'https://my-system.com/webhook/stevo', events: ['MESSAGE', 'CONNECTION'] }); // SM v2
await stevo.instances.setWebhook(officialId, { url: 'https://my-system.com/webhook/official' }); // Official API (all events)
const webhook = await stevo.instances.getWebhook(id); // { engine, url, events }
await stevo.instances.deleteWebhook(id);
stevo.webhooksis the account webhook — recommended for new integrations: one endpoint receives the events of all instances in a single, normalized, signed envelope, with redelivery and history. See the next section.
5. Account webhooks
5.1. Create and manage
const created = await stevo.webhooks.create({
url: 'https://my-system.com/webhooks/stevo', // https:// required, public destination
events: ['message.received', 'message.failed', 'instance.connected', 'instance.disconnected'], // or ['*']
description: 'My CRM',
max_in_flight: 16, // concurrent deliveries: 1 to 64, default 8
});
console.log(created.secret); // whsec_... — store it NOW: the API won't return the full value again
await stevo.webhooks.list(); // only secret_last4
await stevo.webhooks.update(created.id, { active: false }); // partial PATCH (including max_in_flight)
await stevo.webhooks.rotateSecret(created.id, { overlap_seconds: 86_400 }); // the old one stays valid for 24h; 0 = emergency
await stevo.webhooks.delete(created.id);
- Reliable delivery: up to 8 attempts over about 21h. Answer
2xxwithin 10 s; redirects are not followed. - Throughput (
max_in_flight): how many deliveries can be in flight at once to this destination (default 8, from 1 to 64). Raise it for endpoints that absorb volume — for example, an agency with hundreds of instances. - Automatic deactivation: answering
410 Gone, or 5 consecutive exhausted deliveries, deactivates the webhook —disabled_reasonsays why (gone/too_many_failures). Reactivate withupdate(id, { active: true }). - Up to 10 webhooks per account (
409 webhook_limit_reached). An instance-restricted key cannot manage account webhooks (403 instance_restricted_key).
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. The envelope
{
"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!" }
}
data is normalized across SM v2 and the Official API: messages use message_id, chat, from, from_me, type, text, media...; read/delivery receipts carry message_ids[]; instance.qr.updated carries qr_code_image; the other instance.* events carry reason/reason_code. All typed in the package (StevoWebhookEnvelope, StevoMessageEventData, StevoReceiptEventData, StevoQrEventData, StevoConnectionEventData).
5.3. Verify the signature
Each POST carries X-Stevo-Signature: sha256=<hex>, X-Stevo-Timestamp, X-Stevo-Event-Id and X-Stevo-Attempt. constructEvent checks the signature (with a 5-minute replay tolerance) and returns the typed event:
import express from 'express';
import { Stevo, StevoError } from 'stevo-sdk';
const stevo = new Stevo(process.env.STEVO_API_KEY!);
const app = express();
app.post(
'/webhooks/stevo',
express.raw({ type: 'application/json' }), // RAW body — do NOT use express.json() here
async (req, res) => {
let event;
try {
event = stevo.webhooks.constructEvent({
rawBody: req.body,
signature: req.header('x-stevo-signature'),
timestamp: req.header('x-stevo-timestamp'),
secret: process.env.STEVO_WEBHOOK_SECRET!, // or [current, previous] during rotation
});
} catch (e) {
return res.status(e instanceof StevoError ? e.status : 401).end();
}
if (await alreadyProcessed(event.event_id)) return res.status(200).end(); // "at least once" delivery
res.status(200).end(); // answer fast; process in a queue
await enqueue(event);
},
);
With the Fetch API (Next.js Route Handler, Cloudflare Workers, Bun, Deno) it's the same: const rawBody = await request.text() and stevo.webhooks.constructEvent({ rawBody, signature: request.headers.get('x-stevo-signature'), ... }). There are also verify (returns boolean), assert (throws), parse (format only) and sign (for tests).
The signature is HMAC-SHA256(secret, timestamp + "." + rawBody). If the framework runs JSON.parse and you re-serialize, the HMAC won't match. Always use a raw body parser.
event_idA redelivery arrives with the same event_id and a higher attempt. Store the processed event_ids (e.g. UNIQUE(event_id) in your database) and ignore repeats.
5.4. History and redelivery — webhooks.deliveries
const failures = await stevo.webhooks.deliveries.list({ status: 'failed', webhook_id: created.id, limit: 50 });
for await (const page of stevo.webhooks.deliveries.iterate({ status: 'exhausted', since: '2026-09-20T00:00:00Z' })) {
for (const delivery of page.data) await stevo.webhooks.deliveries.retry(delivery.id); // same event_id, attempt++
}
const detail = await stevo.webhooks.deliveries.get(deliveryId); // with the attempt_log of each attempt
Status: pending, delivered, failed, exhausted. Filters: webhook_id, instance_id, status, event_type, since. Up to 5 manual redeliveries per delivery (409 manual_retry_limit_reached).
6. Sending messages
There are four paths — pick by what you need:
| Path | When to use |
|---|---|
stevo.whatsapp(id) | a single layer for both engines: sendText, sendMedia, markRead, sendReaction, getStatus |
stevo.messages.send(id, params) | API proxy: one message per call, with Idempotency-Key and queryable status |
stevo.smv2(id) | the SM v2 server directly — 114 operations (chats, groups, labels, newsletter, voice…) |
stevo.oficial(id) | Meta's Cloud API gateway — HSM templates, profile, received media |
Unified layer — stevo.whatsapp(id)
const wa = await stevo.whatsapp(instanceId);
console.log(wa.engine); // 'smv2' | 'official' | 'proxy'
const r = await wa.sendText({ to: '5511999999999', text: 'Hello!', idempotencyKey: 'order-9f21' });
console.log(r.messageId);
await wa.sendMedia({ to: '5511999999999', url: 'https://...', type: 'image', caption: 'Here you go!' });
await wa.markRead({ messageId: r.messageId!, chat: '5511999999999' });
await wa.sendReaction({ messageId: r.messageId!, chat: '5511999999999', emoji: '👍', fromMe: true });
const st = await wa.getStatus(); // { engine, connected, loggedIn? }
API proxy — stevo.messages
const r = await stevo.messages.send(
instanceId,
{ to: '5511999999999', text: 'Hello!' },
{ idempotencyKey: 'order-9f21' }, // 1–200 characters, chosen by you
);
// { engine, sent, message_id?, status?, replayed, idempotencyKey }
await stevo.messages.send(instanceId, { to: '5511999999999', media_url: 'https://...', media_type: 'image', caption: 'Here you go!' });
// Official API only: raw Cloud API payload (e.g. template)
await stevo.messages.send(officialId, {
to: '5511999999999',
cloud_api: { type: 'template', template: { name: 'hello_world', language: { code: 'en_US' } } },
});
// Status: queued → sent → delivered → read (or failed)
const status = await stevo.messages.get(instanceId, r.message_id!);
const byKey = await stevo.messages.getByIdempotencyKey(instanceId, 'order-9f21');
The proxy counts against your key's rate limit. For volume, use stevo.dispatch, stevo.smv2() or stevo.oficial().
Direct server — stevo.smv2(id) and stevo.oficial(id)
Each instance runs on its own server, with its own token — the SDK resolves that for you from the instanceId:
// SM v2 (unofficial WhatsApp) — 114 server operations
const sm = await stevo.smv2(instanceId);
const sent = await sm.sendText({ body: { number: '5511999999999', text: 'Hello!' } });
console.log(sent.data.Info.ID); // message id on SM v2
const groups = await sm.getGroupList();
// Meta Official API — Cloud API format + HSM templates
const meta = await stevo.oficial(officialInstanceId);
await meta.sendMessage({ to: '5511999999999', type: 'text', text: { body: 'Hello!' } });
const templates = await meta.listTemplates();
// RECEIVED media (the webhook only carries the id) — no Meta token needed
const media = await meta.downloadMedia('1436196501752591'); // { data, mimeType, fileSize, sha256, fileName }
const stream = await meta.downloadMediaStream('1436196501752591'); // raw Response, for large files
The SM v2 client has 114 operations generated from the official swagger, all with autocomplete and types. Reference: StevoManager v2 API · Official API.
For Official API instances, the token returned by the API is the Stevo gateway key (sk_of_...), valid only on the gateway — the Meta access token is never exposed. Treat it as a secret.
6.1. Sending without duplicates: Idempotency-Key
Repeating the same key with the same body (for 24h, per instance) does not resend: the API returns the original response with replayed: true. Different body → 409 idempotency_conflict; key still being processed → 409 idempotency_in_progress.
Works in messages.send, whatsapp(id).sendText/sendMedia and smv2(id).sendText/sendMedia ({ idempotencyKey, body }). On the Official API with a direct token the key is ignored.
6.2. A timeout doesn't mean "not sent"
If the connection drops or the sending server doesn't answer (504 upstream_timeout), the message may have gone out. With idempotencyKey, the SDK doesn't retry on its own — it throws a StevoError with code: 'result_unknown'. Check before resending:
import { StevoError, type SendMessageParams } from 'stevo-sdk';
async function sendSafely(instanceId: string, params: SendMessageParams, idempotencyKey: string) {
try {
return await stevo.messages.send(instanceId, params, { idempotencyKey });
} catch (e) {
const uncertain = e instanceof StevoError && (e.code === 'result_unknown' || e.code === 'upstream_timeout');
if (!uncertain) throw e;
try {
const status = await stevo.messages.getByIdempotencyKey(instanceId, idempotencyKey);
if (status.status !== 'failed') return status; // already sent (or on its way) — do NOT resend
} catch (lookup) {
if (!(lookup instanceof StevoError) || lookup.code !== 'not_found') throw lookup;
}
return stevo.messages.send(instanceId, params, { idempotencyKey }); // same key: safe
}
}
502 upstream_unavailable is different: the proxy couldn't reach the instance server, so nothing was sent and it's safe to retry (retryable: true).
7. Bulk campaigns
You pass the instance_ids that will send — server credentials are resolved internally (you never send a token):
const camp = await stevo.dispatch.createCampaign({
instance_ids: [instanceId],
name: 'July Promo',
messages: ['Hi! We have news for you 🎉'],
recipients: [{ phone: '5511999999999', name: 'Maria' }],
config: { minDelay: 5, maxDelay: 15 }, // seconds between sends
start: true,
});
await stevo.dispatch.getCampaign(camp.campaign_id); // status/progress
await stevo.dispatch.pause(camp.campaign_id);
await stevo.dispatch.resume(camp.campaign_id);
With more than one instance in instance_ids, rotation is automatic. messages accepts variations with media and buttons.
8. StevoVoice — AI voice calls
Requires StevoVoice subscribed on the instance:
// One call with an AI agent (ElevenLabs)
await stevo.voice.scheduleCall(instanceId, {
to_number: '5511999999999',
agent_id: 'agent_xyz',
scheduled_at: '2026-07-20T14:00:00Z', // optional (default: now)
});
// In batch, with an interval between calls
await stevo.voice.batchCalls(instanceId, {
numbers: ['5511999999999', '5511888888888'],
agent_id: 'agent_xyz',
interval_seconds: 60,
});
const scheduled = await stevo.voice.listScheduled(instanceId);
await stevo.voice.cancelCall(callId);
// Recordings of native calls — requires the voice:read scope
const page = await stevo.voice.listRecordings(instanceId, {
limit: 50,
direction: 'outbound',
has_audio: true,
});
const recording = await stevo.voice.getRecording(instanceId, page.recordings[0].id);
const response = await stevo.voice.downloadRecording(instanceId, recording.id);
const mp3 = await response.arrayBuffer();
// Post-call shortcut: accepts UUID, display name or instance_name
const latest = await stevo.voice.getLatestRecording('my-test-instance', {
call_id: 'call-id', // optional, but recommended with simultaneous calls
});
Ready-made recipe: download the latest recording via the REST API
You don't need to find the instance UUID or the recording ID first. Create an
API Key with the voice:read scope and pass the instance name:
curl --get 'https://openapi.stevo.chat/v1/voice/recordings/latest' \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY' \
--data-urlencode 'instance=instance-name'
The response already includes the resolved instance, the latest recording and the authenticated download URL:
{
"data": {
"instance": {
"id": "INSTANCE_UUID",
"name": "My instance",
"instance_name": "instance-name"
},
"recording": {
"id": "RECORDING_UUID",
"duration_seconds": 42,
"format": "mp3",
"download_available": true,
"download_url": "/v1/instances/INSTANCE_UUID/voice/recordings/RECORDING_UUID/download"
}
}
}
Copy download_url and make the second GET with the same key. -L is
required because the API validates ownership and answers with a 307 redirect
to the MP3:
curl -L \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY' \
'https://openapi.stevo.chat/v1/instances/INSTANCE_UUID/voice/recordings/RECORDING_UUID/download' \
--output call.mp3
To list the history, use the data.instance.id returned in the first step:
curl --get 'https://openapi.stevo.chat/v1/instances/INSTANCE_UUID/voice/recordings' \
--header 'Authorization: Bearer stevo_sk_YOUR_KEY' \
--data-urlencode 'limit=20' \
--data-urlencode 'has_audio=true'
In automations, also pass call_id to the latest endpoint when several
calls may end at the same time. The recording only shows up after the call ends
and the upload finishes; retry briefly if the first attempt returns 404.
If the name matches more than one instance, the API returns
409 ambiguous_instance; in that case, use the UUID.
Enable, configure and embed the webphone (widget)
// StevoVoice status on the SM v2 instance: engine available? enabled? quota/channels
const st = await stevo.voice.status(instanceId); // { voice_available, enabled, unlimited, remaining, channels, active_calls }
// Enable WITHOUT charging (panel rules): a regular account activates the 50-call trial;
// an account with a StevoVoice package uses a package slot. For channels/unlimited:
await stevo.voice.enable(instanceId);
await stevo.billing.purchase({ product: 'stevovoice', instance_id: instanceId, tier: 5 }); // saved card
const { checkout_url } = await stevo.billing.checkout({ product: 'stevovoice', instance_id: instanceId, tier: 5 }); // payment link
// StevoVoice tab settings
await stevo.voice.updateSettings(instanceId, {
summary_enabled: true, summary_emails: ['manager@company.com'], // call summary by email
coach_enabled: true, // AI coach during the call
receive_enabled: true, // receive calls on the webphone
});
// Widget: webphone embeddable on the customer's website
const w = await stevo.voice.createWidget(instanceId, { name: 'Website', allowed_origins: ['https://mysite.com'] });
console.log(w.embed_snippet); // <script src="https://call.shurima.cloud/stevophone/widget.js" data-widget="wgt_..."></script>
await stevo.voice.updateWidget(w.id, { video_enabled: true });
await stevo.voice.deleteWidget(w.id);
9. Stevo AI (v2)
Set up multi-provider agents, a stage funnel, a knowledge base and more:
// Base config + provider key (write-only: never returned)
await stevo.ai.updateSettings(instanceId, { enabled: true, timezone: 'America/Sao_Paulo' });
await stevo.ai.setProviderKeys(instanceId, { openai: 'sk-...' });
// Agent + funnel
const agent = await stevo.ai.agents.create(instanceId, {
name: 'Assistant',
provider: 'openai',
model: 'gpt-4o-mini',
system_prompt: 'You are a friendly support agent.',
is_primary: true,
});
await stevo.ai.stages.create(agent.id, { name: 'Welcome', objective: 'Collect the name' });
// FAQ, follow-up and knowledge (RAG)
await stevo.ai.faq.create(instanceId, { question: 'What are your hours?', answer: '9am to 6pm.' });
await stevo.ai.followup.set(instanceId, { enabled: true, max_followups: 3 });
10. Retry
The SDK retries on its own only when it's safe:
| Situation | Default behavior |
|---|---|
429 (rate limit) | always retries, honoring Retry-After |
5xx / connection failure on GET, PATCH, DELETE... | retries |
5xx / connection failure on POST | does not retry (avoids duplicating a purchase, campaign or send) |
POST with Idempotency-Key | retries only if the API answers retryable: true; connection failure becomes result_unknown |
response with retryable: false | never retries, on any method |
| the SDK's own timeout | never retries |
const stevo = new Stevo(process.env.STEVO_API_KEY!, {
retry: {
maxAttempts: 3, // TOTAL attempts, including the first one
retry429: true,
retry5xx: true,
retryNetwork: true,
retryTimeout: false,
retryNonIdempotent: false, // true allows retrying POST without a key (may duplicate!)
},
});
Already have a queue with backoff in your worker? Turn off the SDK's (retry: { enabled: false }) and use StevoError.retryable to decide — so only one layer retries.
11. Errors and observability
Every failure becomes a StevoError:
import { StevoError } from 'stevo-sdk';
try {
await stevo.billing.purchase({ plan: 'stevo3' });
} catch (e) {
if (e instanceof StevoError) {
console.error(e.status, e.code, e.requestId, e.retryable);
if (e.code === 'no_saved_card') {
// account without a saved card — use billing.checkout or ask the user to add one in the panel
}
}
}
| Field | What it carries |
|---|---|
status / code / message | HTTP, business code and message (status: 0 = network/timeout) |
retryable | whether retrying makes sense — comes from the API itself when it says so |
requestId | correlation id (X-Request-Id) — give it to support |
attempt, latencyMs, retryAfter, rateLimit | attempt that failed, duration, Retry-After and key limits |
toJSON() | safely serializes everything for logging |
Common codes: not_ready (instance not connected), insufficient_scope, key_restricted, external_ref_conflict, idempotency_conflict, idempotency_in_progress, upstream_timeout, upstream_unavailable, result_unknown, invalid_response, webhook_signature_invalid, webhook_timestamp_expired, no_saved_card, no_available_slot.
For metrics, pass onResponse — it's called on every attempt and never changes the result:
const stevo = new Stevo(process.env.STEVO_API_KEY!, {
onResponse: (info) => {
// { method, path, status, ok, attempt, latencyMs, requestId, rateLimit?, willRetry, errorCode? }
metrics.histogram('stevo_request_ms', info.latencyMs, { path: info.path, status: String(info.status) });
},
});
12. Upgrading from 1.x to 2.x
2.0.0 changed a few behaviors so that a send or a purchase is never duplicated:
POSTis no longer retried automatically on5xxor connection failure (only on429, or withIdempotency-Keyandretryable: truefrom the API). If you relied on the implicit retry inbilling.purchase,dispatch.createCampaignor sends, handle theStevoError.- Timeouts are never retried, not even on
GET(turn it on withretry: { retryTimeout: true }if you want). - The SDK honors
retryable: falsefrom the API, and a connection failure withidempotencyKeybecomesresult_unknown(checkmessages.getByIdempotencyKey). messages.sendand thewhatsapp()sends validate the response: a2xxwithout a recognizable body becomes aStevoErrorinvalid_response.- Sends with
idempotencyKeyreturnidempotencyKey(echo) instead ofidempotent: true. - SM v2 types are now strict and
JIDis now astring(was an object) — code with a wrongly typed field no longer compiles. createWhatsAppandcreateInstanceLifecycleare no longer exported — usestevo.whatsapp(id)andstevo.instances.
In practice: npm install stevo-sdk@latest, run tsc and review the catch blocks around your POSTs.
13. Security
- Never use the API key on the frontend. Keep it on the backend, in an environment variable.
- Minimal scopes and, when possible, a key restricted to the integration's instances.
- Verify every account webhook with
constructEventover the raw body. - The instance
tokenand WebSocket URLs (?apikey=) are secrets — don't log them or send them to the frontend. - Never access Stevo's database. External integrations use only the API and the SDK: instances by
external_ref, sync via account webhooks +listChanges, and sending withidempotencyKey.
14. Handing it to your AI
If an AI (Claude, Cursor, Copilot, ChatGPT...) is writing the integration, paste this block into its context:
Use the npm package
stevo-sdk(2.x, TypeScript, types included, Node 18+). Instantiatenew Stevo(process.env.STEVO_API_KEY)with thestevo_sk_...key from the Stevo panel's API Keys tab. Management:me,instances(list/get/create/update withexternal_refandmetadata, findByExternalRef, listChanges/iterateChanges to reconcile including deletions, delete with{ confirm: true }, getQrCode/refreshQrCode/getConnection/disconnect/health, restart, recreateTotal, setWebhook, fuse),links,ghl,billing(purchase/checkout),ghlAgency,ai,dispatch(campaigns with instance_ids) andvoice. Account webhooks:stevo.webhooks.create({ url, events, max_in_flight })returns thesecretonce; in the handler, usestevo.webhooks.constructEvent({ rawBody, signature: X-Stevo-Signature, timestamp: X-Stevo-Timestamp, secret })over the RAW body and deduplicate byevent_id;webhooks.deliverieslists and redelivers deliveries. Sending:stevo.whatsapp(id)(sendText/sendMedia/markRead/sendReaction/getStatus on both engines),stevo.messages.send(id, params, { idempotencyKey })+messages.get/getByIdempotencyKeyfor status,stevo.smv2(id)(SM v2 server, 114 operations) andstevo.oficial(id)(Meta Cloud API). The SDK resolvesserver_url+tokenon its own. Errors areStevoErrorwith.status,.code,.retryableand.requestId; onresult_unknown/upstream_timeoutcheck the status by key before resending. Default retry: 429 always; 5xx/network only outside POST; timeout never. Full reference: https://tutorial.stevo.chat/public-api-reference
References
- 📘 Management API — integration guide (plain HTTP, any language)
- 📖 Management API reference (endpoints, errors and scopes)
- 📖 StevoManager v2 API and Official API
- 📦 Package on npm
- 🤖 MCP server:
https://openapi.stevo.chat/mcp(same API key)