developers
ES Sign up

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
npm install @heysteff/sdk

It works with import (ESM) and require (CommonJS), types included. This page covers version 0.7.0.

JavaScript
import { Heysteff } from '@heysteff/sdk';        // ESM
const { Heysteff } = require('@heysteff/sdk');   // CommonJS

Send a message#

Without apiKey, the client reads HEYSTEFF_API_KEY. With an sk_test_ key the message goes out through the sandbox.

TypeScript
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):

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

TypeScript
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',
});
TypeScript
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.

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

OptionDefault
apiKeyHEYSTEFF_API_KEY
maxRetries2
timeoutMs30000 per attempt
baseUrlhttps://app.heysteff.com/api
fetchthe global one

Lists and pages#

list returns one page with its cursor; listAll walks all of them:

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

categoryWhen
authThe key is missing, mistyped, revoked or expired (401).
permissionThe key lacks a scope, the IP is not allowed or the operation can't run with an sk_test_ key (403).
validationThe body or the parameters are invalid (400, 422 and any other 4xx).
rate_limitYou hit the request limit (429): honor Retry-After.
quotaThe plan quota or a daily cap ran out (402, or a 403 or 429 with a quota code).
conflictA state or idempotency clash (409), for example wa_window_closed or idempotency_conflict.
not_foundThe resource does not exist or belongs to another workspace (404).
providerThe channel provider failed: Meta, the SMS carrier or Google (424).
serverA heysteff error (5xx).
networkNo response: a network failure or a timeout.
TypeScript
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.

Next.js (App Router)
// 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);
    }
  },
});
Hono
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));
Express
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.

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

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

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

TypeScript
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 connected

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

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

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