Guides
TypeScript SDK
@heysteff/sdk wraps the v1 API with types, retries, a ready-made webhook receiver and live events. It has no dependencies: it uses fetch, so it runs on Node 20+, Deno, Bun and Cloudflare Workers.
Package: @heysteff/sdk on npm · published code · heysteff on GitHub · MIT license. The package README includes a prompt ready to set it up with your AI. Every package and repo: Packages and repositories.
npm install @heysteff/sdkIt works with import (ESM) and require (CommonJS), types included. This page covers version 0.7.0.
import { Heysteff } from '@heysteff/sdk'; // ESM
const { Heysteff } = require('@heysteff/sdk'); // CommonJSSend a message#
Without apiKey, the client reads HEYSTEFF_API_KEY. With an sk_test_ key the message goes out through the sandbox.
import { Heysteff } from '@heysteff/sdk';
const heysteff = new Heysteff({ apiKey: process.env.HEYSTEFF_API_KEY });
const message = await heysteff.messages.send({
channel: 'whatsapp_cloud',
to: '+56912345678',
content: 'Hi Ana, your order is on its way',
});
console.log(message.id, message.status);Buttons and lists, message status, read receipts, reactions, webhooks and templates (since 0.5.0):
await heysteff.messages.send({
channel: 'whatsapp_cloud',
to: '+56912345678',
content_type: 'interactive',
interactive: {
type: 'buttons',
body: 'Confirm your booking?',
buttons: [{ id: 'yes', title: 'Yes' }, { id: 'no', title: 'No' }],
},
});
const msg = await heysteff.messages.get(messageId); // msg.status: queued | sent | delivered | read | failed
await heysteff.messages.markRead(inboundMessageId); // blue ticks / "seen"
await heysteff.messages.react({ message_id: inboundMessageId, emoji: '👍' });
const { secret } = await heysteff.webhooks.create({ url: 'https://api.tutienda.com/heysteff', events: ['message.read'] });
await heysteff.templates.create({ name: 'order_shipped', language: 'en', category: 'UTILITY', body_text: 'Hi {{1}}', body_examples: ['Ana'] });Files and templates#
media.upload takes a Blob, a Buffer or a Uint8Array and returns the url for the message. Types and limits are in Message types.
import { readFile } from 'node:fs/promises';
const file = await heysteff.media.upload({
file: await readFile('catalogo.pdf'),
filename: 'catalogo.pdf',
contentType: 'application/pdf',
});
await heysteff.messages.send({
channel: 'whatsapp_cloud',
to: '+56912345678',
content_type: 'file',
media: { url: file.url, filename: 'catalogo.pdf' },
content: 'Here is the November catalog',
});const [template] = await heysteff.templates.list();
await heysteff.messages.send(
{
channel: 'whatsapp_cloud',
to: '+56912345678',
content_type: 'template',
template: { name: 'pedido_despachado', language: 'es', body_params: ['Ana', '#1042'] },
},
{ idempotencyKey: 'pedido-1042-despacho' },
);Hand a conversation to a person#
If your own AI answers, these methods hand the conversation to the team and give it back (see Bring your own AI). They need conversations:write; get, conversations:read.
await heysteff.conversations.get(id);
await heysteff.conversations.escalate(id, { reason: 'Wants a refund', urgency: 'high' });
await heysteff.conversations.pauseBot(id);
await heysteff.conversations.resumeBot(id);
await heysteff.conversations.resolve(id);Retries and idempotency#
On a 429, a 5xx or a network failure, the SDK retries up to twice with growing backoff and honors Retry-After. On the endpoints that accept Idempotency-Key (messages, campaigns, flows, charges and refunds) it generates one key per call and repeats it on every retry: a message or a charge never goes out twice. Pass your own key with idempotencyKey so it also holds across runs of your system. A POST without idempotency, such as creating a contact, is not retried.
| Option | Default |
|---|---|
apiKey | HEYSTEFF_API_KEY |
maxRetries | 2 |
timeoutMs | 30000 per attempt |
baseUrl | https://app.heysteff.com/api |
fetch | the global one |
Lists and pages#
list returns one page with its cursor; listAll walks all of them:
for await (const conversation of heysteff.conversations.listAll({ status: 'open' })) {
console.log(conversation.id, conversation.contact_name);
}Errors#
Every error response arrives as a HeysteffError, with status, code, message, requestId and, when the API knows it, fix. It also carries category, so you can decide without memorizing every code, and retryable, which tells you whether trying again later makes sense (the SDK already retries the safe cases, see Retries). The codes are in Errors and limits.
| category | When |
|---|---|
auth | The key is missing, mistyped, revoked or expired (401). |
permission | The key lacks a scope, the IP is not allowed or the operation can't run with an sk_test_ key (403). |
validation | The body or the parameters are invalid (400, 422 and any other 4xx). |
rate_limit | You hit the request limit (429): honor Retry-After. |
quota | The plan quota or a daily cap ran out (402, or a 403 or 429 with a quota code). |
conflict | A state or idempotency clash (409), for example wa_window_closed or idempotency_conflict. |
not_found | The resource does not exist or belongs to another workspace (404). |
provider | The channel provider failed: Meta, the SMS carrier or Google (424). |
server | A heysteff error (5xx). |
network | No response: a network failure or a timeout. |
import { HeysteffError } from '@heysteff/sdk';
try {
await heysteff.messages.send({ channel: 'whatsapp_cloud', to: '+56912345678', content: 'Hi' });
} catch (err) {
if (!(err instanceof HeysteffError)) throw err;
if (err.code === 'wa_window_closed') {
// Outside the 24-hour window: send a template.
} else if (err.category === 'permission') {
console.error('The key is missing a permission:', err.message, err.fix);
} else if (err.retryable) {
// Requeue the job: the SDK already used up its retries.
} else {
throw err;
}
}Receive webhooks#
createWebhookHandler builds the whole receiver in one line: it reads the raw body, checks the X-Heysteff-Signature signature and that X-Heysteff-Timestamp is less than 5 minutes old, and hands the typed event to your onEvent. It answers 200 when all went well, 400 when the signature is invalid and 500 when your onEvent throws; in that case heysteff retries the delivery (see Retries). It takes a standard Request and returns a Response, so it works as is on Next.js, Hono, Deno, Bun and Cloudflare Workers.
// app/webhooks/heysteff/route.ts
import { createWebhookHandler } from '@heysteff/sdk';
export const POST = createWebhookHandler({
secret: process.env.HEYSTEFF_WEBHOOK_SECRET!,
onEvent: async (event) => {
if (event.event === 'message:received') {
console.log(event.data.external_user_id, event.data.content);
}
},
});import { Hono } from 'hono';
import { createWebhookHandler } from '@heysteff/sdk';
const handler = createWebhookHandler({ secret: process.env.HEYSTEFF_WEBHOOK_SECRET!, onEvent });
const app = new Hono();
app.post('/webhooks/heysteff', (c) => handler(c.req.raw));import express from 'express';
import { createWebhookHandler } from '@heysteff/sdk';
const handler = createWebhookHandler({ secret: process.env.HEYSTEFF_WEBHOOK_SECRET!, onEvent });
const app = express();
// express.raw: the signature is computed over the raw body.
app.post('/webhooks/heysteff', express.raw({ type: 'application/json' }), handler.toExpress());If you already have a client, heysteff.webhooks.handler({ secret, onEvent }) does the same. heysteff waits up to 10 seconds for the response: queue the heavy work and do it afterwards. A delivery can arrive more than once, so process each event idempotently.
To try it on your machine with real events, without a tunnel, use heysteff listen --forward-to http://localhost:3000/webhooks/heysteff. Locally the CLI signs with the whsec_local_… secret it prints on startup: that is the HEYSTEFF_WEBHOOK_SECRET of your local environment, or pass yours with --secret.
Typed events#
WebhookEvent is a union discriminated by event: inside each case, TypeScript knows what data carries. WebhookEnvelope is the generic envelope ({ id, tenant_id, event, ts, data }) and WEBHOOK_EVENT_NAMES lists every event name.
import { WEBHOOK_EVENT_NAMES, type WebhookEvent } from '@heysteff/sdk';
async function onEvent(event: WebhookEvent) {
switch (event.event) {
case 'message:received':
if (!event.data.bot_paused) console.log(event.data.channel, event.data.content);
break;
case 'message.failed':
console.log(event.data.message_id, event.data.error.code);
break;
case 'conversation.escalated':
console.log('Handed to a person:', event.data.conversation_id);
break;
}
}
console.log(WEBHOOK_EVENT_NAMES); // ['message:received', 'message:sent', …]Verify by hand#
If you build your own receiver, verifyWebhook only does the verification: it checks the X-Heysteff-Signature signature and that X-Heysteff-Timestamp is less than 5 minutes old, and returns the event. Pass it the raw body, before parsing the JSON:
import express from 'express';
import { verifyWebhook, WebhookVerificationError } from '@heysteff/sdk';
const app = express();
app.post('/webhooks/heysteff', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await verifyWebhook({
payload: req.body, // the raw body, not the parsed JSON
headers: req.headers,
secret: process.env.HEYSTEFF_WEBHOOK_SECRET!,
});
console.log(event.event, event.data);
res.sendStatus(200);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.sendStatus(400);
throw err;
}
});Live events#
heysteff.events.stream() receives the same events as a webhook, live, without exposing a URL: it opens GET /v1/events/stream (Server-Sent Events) and returns an async iterable of WebhookEvent. If the connection drops, it reconnects on its own with Last-Event-ID and catches up on what happened in between, up to 10 minutes back. It needs the events:read scope; with an sk_test_ key you only get sandbox events. It is what heysteff listen uses.
const controller = new AbortController();
process.on('SIGINT', () => controller.abort());
for await (const event of heysteff.events.stream({
events: ['message:received', 'message.read'],
signal: controller.signal,
})) {
if (event.event === 'message:received') console.log(event.data.content);
}It suits scripts, tests and processes without a public URL. In production prefer a webhook: heysteff retries it for hours if your server is down, while the stream only keeps the last 10 minutes.
heysteff WhatsApp numbers#
heysteff.numbers reserves a number from the heysteff pool and returns the link to Meta's window where the signup ends. See Get a number.
const { number, connect_url, connect_url_expires_at } = await heysteff.numbers.reserve();
// Someone opens connect_url in a browser and picks the number in Meta.
const { data, plan, offer } = await heysteff.numbers.list();
await heysteff.numbers.requestDisplayName(number.id, 'Aurora Store');
await heysteff.numbers.release(number.id); // 409 wa_pool_number_connected if it's already connectedCustomers and setup links#
heysteff.customers creates customers of your SaaS and generates a link for them to connect their own channel; then you send on their behalf with customer_id. See Customers and setup links.
const customer = await heysteff.customers.create({ external_id: 'acme-42', name: 'Acme Inc.' });
const { url } = await heysteff.customers.createSetupLink(customer.id, { allowed_channels: ['whatsapp_cloud'] });
// Your customer opens url and connects their WhatsApp.
const channels = await heysteff.customers.listChannels(customer.id);
await heysteff.messages.send({ channel: 'whatsapp_cloud', customer_id: customer.id, to: '+15550100199', content: 'Hi' });
for await (const c of heysteff.customers.listAll()) console.log(c.external_id, c.channels_count);AI agent#
heysteff.agent configures the workspace’s AI agent, saves your provider key and manages custom tools; heysteff.insights reads the insights. See AI agent.
const agent = await heysteff.agent.get(); // state, trial quota and keys (last 4 only)
await heysteff.agent.setKey({ provider: 'anthropic', api_key: process.env.ANTHROPIC_API_KEY! });
await heysteff.agent.update({ enabled: true, provider: 'anthropic', model: 'claude-haiku-5-5', channels: [{ id: channelId, enabled: false }] });
await heysteff.agent.tools.create({ slug: 'order_status', name: 'Order status', description: 'Looks up an order by its number.', spec });
const { agent_gaps } = await heysteff.insights.get({ days: 7 });Everything else#
Every endpoint in the reference has its method (contacts, contactLists, campaigns, events, conversions, charges, refunds, reviews, sales, automationFlows). heysteff.call(operationId, …) calls any of them by its OpenAPI operationId.