Pular para o conteúdo principal

Official SDK — stevo-sdk

stevo-sdk is Stevo's official SDK (TypeScript/JavaScript). One package with everything the platform offers — neatly split by area:

AreaAccessWhat it does
Account managementstevo.me, stevo.instances, stevo.links, stevo.ghl, stevo.billing, stevo.ghlAgencyinstances (create, connect, reconcile, delete), access links, GHL, purchases and GHL Agency
Account webhooksstevo.webhooksone endpoint receives events from all instances, signed with HMAC-SHA256, with history and redelivery
Messagingstevo.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.aiagents, funnel, provider keys, FAQ, tools, follow-up, RAG, memory
Bulk campaignsstevo.dispatchWhatsApp campaigns with instance rotation, scheduling and variations
StevoVoicestevo.voiceAI 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

What's new in 2.x
  • Signed account webhooks with reliable delivery, event_id deduplication, history and redelivery (stevo.webhooks) — plus max_in_flight to control throughput (2.1.0).
  • Safe sending: Idempotency-Key on send and message status (queued → sent → delivered → read/failed).
  • Instances tied to your system: external_ref and metadata, reconciliation with listChanges and deletion with delete.
  • 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.

Rather not write code? Use AI + MCP

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.

Not using Node.js?

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​

OptionDefaultNotes
baseUrlhttps://openapi.stevo.chat—
timeoutMs60000per-request timeout
retrysee Retryautomatic retry rules
maxRetries2legacy — extra retries (maxRetries: 2 = maxAttempts: 3)
onResponse—hook called on every attempt (metrics/logs)
credentialsCacheMs0cache 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' });
Irreversible operations

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' });
  • metadata in update replaces the object (no merge) — read the current one before changing a single field.
  • Duplicate external_ref in the account → 409 external_ref_conflict.
  • external_ref and metadata also 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 }. iterateChanges follows the cursor for you.
  • Deduplicate deleted by id + deleted_at. If the same id shows up again in data with updated_at after deleted_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.webhooks is 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 2xx within 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_reason says why (gone / too_many_failures). Reactivate with update(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 body must be RAW

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.

Deduplicate by event_id

A 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:

PathWhen 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.

Official API token

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:

SituationDefault behavior
429 (rate limit)always retries, honoring Retry-After
5xx / connection failure on GET, PATCH, DELETE...retries
5xx / connection failure on POSTdoes not retry (avoids duplicating a purchase, campaign or send)
POST with Idempotency-Keyretries only if the API answers retryable: true; connection failure becomes result_unknown
response with retryable: falsenever retries, on any method
the SDK's own timeoutnever 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
}
}
}
FieldWhat it carries
status / code / messageHTTP, business code and message (status: 0 = network/timeout)
retryablewhether retrying makes sense — comes from the API itself when it says so
requestIdcorrelation id (X-Request-Id) — give it to support
attempt, latencyMs, retryAfter, rateLimitattempt 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:

  • POST is no longer retried automatically on 5xx or connection failure (only on 429, or with Idempotency-Key and retryable: true from the API). If you relied on the implicit retry in billing.purchase, dispatch.createCampaign or sends, handle the StevoError.
  • Timeouts are never retried, not even on GET (turn it on with retry: { retryTimeout: true } if you want).
  • The SDK honors retryable: false from the API, and a connection failure with idempotencyKey becomes result_unknown (check messages.getByIdempotencyKey).
  • messages.send and the whatsapp() sends validate the response: a 2xx without a recognizable body becomes a StevoError invalid_response.
  • Sends with idempotencyKey return idempotencyKey (echo) instead of idempotent: true.
  • SM v2 types are now strict and JID is now a string (was an object) — code with a wrongly typed field no longer compiles.
  • createWhatsApp and createInstanceLifecycle are no longer exported — use stevo.whatsapp(id) and stevo.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 constructEvent over the raw body.
  • The instance token and 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 with idempotencyKey.

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+). Instantiate new Stevo(process.env.STEVO_API_KEY) with the stevo_sk_... key from the Stevo panel's API Keys tab. Management: me, instances (list/get/create/update with external_ref and metadata, 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) and voice. Account webhooks: stevo.webhooks.create({ url, events, max_in_flight }) returns the secret once; in the handler, use stevo.webhooks.constructEvent({ rawBody, signature: X-Stevo-Signature, timestamp: X-Stevo-Timestamp, secret }) over the RAW body and deduplicate by event_id; webhooks.deliveries lists and redelivers deliveries. Sending: stevo.whatsapp(id) (sendText/sendMedia/markRead/sendReaction/getStatus on both engines), stevo.messages.send(id, params, { idempotencyKey }) + messages.get/getByIdempotencyKey for status, stevo.smv2(id) (SM v2 server, 114 operations) and stevo.oficial(id) (Meta Cloud API). The SDK resolves server_url + token on its own. Errors are StevoError with .status, .code, .retryable and .requestId; on result_unknown/upstream_timeout check 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​