developers
ES Sign up

Get started

Customers and setup links

If you run a SaaS, your customers can connect their own WhatsApp, Instagram, Messenger or Gmail to your integration without signing in to heysteff. You create a customer, generate a setup link, your customer opens it and connects their account on a heysteff-hosted page, and from then on you send messages on their behalf with the same POST /v1/messages.

What a customer is#

A customer is a business that uses your product: a store, a clinic, an agency. You identify it with your own external_id (the id it already has in your database) and can store free-form metadata. The channels it connects live in your heysteff workspace, tagged with that customer, and you use them with your API key.

One link works for any of the four channels: WhatsApp (Meta Embedded Signup), Instagram, Messenger and Gmail. Your customer picks one and connects it; for another channel, generate another link. Instagram can be connected two ways: through the Facebook Page the account is linked to, or without a Facebook Page (Instagram login). Both create the same instagram channel.

The full flow#

  1. Create the customer with POST /v1/customers, once per business in your SaaS.
  2. Generate a link with POST /v1/customers/{id}/setup-links. It returns url (https://developers.heysteff.com/connect/hsl_…).
  3. Your customer opens it, picks the channel and connects their account: Facebook login for WhatsApp, Instagram or Messenger, Instagram login if their account has no Facebook Page, Google login for Gmail. The page shows your name and logo, not heysteff's.
  4. You find out two ways: your customer is sent back to your success_redirect_url with the channel id in the URL, and your webhook receives customer.channel_connected.
  5. You send messages on their behalf with POST /v1/messages and customer_id.

1. Create the customer#

curl
curl -X POST https://app.heysteff.com/api/v1/customers \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "acme-42",
    "name": "Acme Inc.",
    "metadata": { "plan": "pro" }
  }'
Response
{
  "data": {
    "id": "6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b",
    "external_id": "acme-42",
    "name": "Acme Inc.",
    "metadata": { "plan": "pro" },
    "channels_count": 0,
    "created_at": "2026-11-13T12:00:00.000Z",
    "updated_at": "2026-11-13T12:00:00.000Z"
  }
}

external_id is unique in your workspace: reusing it returns 409 customer_external_id_taken. To look a customer up by your id, use GET /v1/customers?external_id=acme-42.

curl
curl -X POST https://app.heysteff.com/api/v1/customers/6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b/setup-links \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "allowed_channels": ["whatsapp_cloud", "instagram"],
    "expires_in_hours": 72,
    "success_redirect_url": "https://yourapp.com/integrations/heysteff/done",
    "failure_redirect_url": "https://yourapp.com/integrations/heysteff/error",
    "brand_name": "YourApp",
    "brand_logo_url": "https://yourapp.com/logo.png"
  }'
Response
{
  "data": {
    "id": "0b8f3c2d-1e4a-4b5c-9d6e-7f8a9b0c1d2e",
    "customer_id": "6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b",
    "url": "https://developers.heysteff.com/connect/hsl_…",
    "allowed_channels": ["whatsapp_cloud", "instagram"],
    "expires_at": "2026-11-16T12:00:00.000Z",
    "used_at": null,
    "channel_connection_id": null,
    "success_redirect_url": "https://yourapp.com/integrations/heysteff/done",
    "failure_redirect_url": "https://yourapp.com/integrations/heysteff/error",
    "brand_name": "YourApp",
    "brand_logo_url": "https://yourapp.com/logo.png",
    "created_at": "2026-11-13T12:00:00.000Z"
  }
}
FieldWhat it does
allowed_channelsChannels your customer can pick: whatsapp_cloud, instagram, facebook (Messenger) and gmail. Default: all four.
expires_in_hoursHow long the link lasts, 1 to 2160 hours (90 days). Default 720 (30 days).
locale, themeLanguage and colors of the page. See Customize the page.
whatsapp_modesHow they can connect WhatsApp: own number, coexistence with the phone app or a heysteff number. See WhatsApp.
reconnect_channel_idReconnects a customer channel that went down. See Reconnect.
success_redirect_urlWhere your customer goes after connecting. https, or http://localhost to test on your machine.
failure_redirect_urlWhere they go if something fails. Without it, they return to success_redirect_url with status=error.
brand_name, brand_logo_urlWhat your customer sees on the page. Without them, your workspace name.

url only comes in this response: heysteff stores a hash of the token and can't show it again. Save it or send it to your customer right away. If it gets lost, generate another one.

3. Back to your system#

When your customer finishes, heysteff sends them back to your URL with the result in the query string (any query your URL already has is kept):

Connected
https://yourapp.com/integrations/heysteff/done?status=connected&channel_id=9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d&customer_id=6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b&channel_type=whatsapp_cloud
Error
https://yourapp.com/integrations/heysteff/error?status=error&error=<code>&customer_id=6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b

Without URLs, the page shows "Done" and your customer can close it. Use the query to tell your customer they're done, but don't treat it as proof: anyone can type that URL. Confirm with the webhook or with GET /v1/customers/{id}/channels.

4. The customer.channel_connected webhook#

Subscribe to customer.channel_connected like any other event (see Webhooks). It arrives signed, in the usual envelope, every time a customer finishes connecting a channel with a link:

JSON
{
  "id": "5d1f0c7e-8a2b-4c3d-9e4f-0a1b2c3d4e5f",
  "tenant_id": "2c7e9b1a-3d4f-4a5b-8c6d-7e8f9a0b1c2d",
  "event": "customer.channel_connected",
  "ts": "2026-11-13T12:07:41.000Z",
  "data": {
    "customer_id": "6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b",
    "external_id": "acme-42",
    "channel_connection_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "channel_type": "whatsapp_cloud",
    "display_name": "Acme",
    "setup_link_id": "0b8f3c2d-1e4a-4b5c-9d6e-7f8a9b0c1d2e"
  }
}

5. Send messages on your customer's behalf#

Add customer_id to POST /v1/messages. The message goes out through that customer's active channel of type channel (if they have more than one, the most recently connected). If you also send channel_connection_id, that channel must belong to the customer.

curl
curl https://app.heysteff.com/api/v1/messages \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp_cloud",
    "customer_id": "6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b",
    "to": "+15550100199",
    "content": "Hi Ana, your appointment is confirmed for tomorrow at 10:00"
  }'

Specific errors: 404 customer_not_found if the customer doesn't exist and 422 customer_channel_not_connected if they have no active channel of that type. Replies from end users reach your message:received webhook with their channel_connection_id, so you know which customer each conversation belongs to.

Customize the link page#

The page your customer sees can look like your product: your name, your logo, your colors and its language.

curl
curl -X POST https://app.heysteff.com/api/v1/customers/6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b/setup-links \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "allowed_channels": ["whatsapp_cloud"],
    "whatsapp_modes": ["coexistence", "own_number"],
    "locale": "en",
    "brand_name": "YourApp",
    "brand_logo_url": "https://yourapp.com/logo.png",
    "theme": {
      "primary_color": "#4f46e5",
      "background_color": "#f8fafc",
      "text_color": "#0f172a",
      "border_color": "#e2e8f0"
    }
  }'

WhatsApp: own number, coexistence or a heysteff number#

With whatsapp_modes you choose how your customer can connect WhatsApp. If you allow more than one, the page asks which one they want:

ModeFor whom
own_numberA number not yet in the WhatsApp Business app. It is registered on Meta's official API.
coexistenceThe number your customer already uses in the WhatsApp Business app on their phone. It stays there: their team keeps replying from the app and you get the same chats through the API, with their contacts and up to 6 months of history.
heysteff_numberA heysteff number already verified on Meta, from your plan's number quota. If your plan includes no numbers, creating the link answers 402 plan_limit.

Default: own_number and coexistence.

Reconnect a channel that went down#

If one of your customer's channels got disconnected (they changed their Facebook password, the Google access expired), create a link with reconnect_channel_id: the channel id from GET /v1/customers/{id}/channels. The link only allows that channel type and the page tells your customer which account to reconnect. The customer.channel_connected webhook carries reconnect_channel_id and reconnected (true if the same channel came back; another account leaves a new channel).

Creating a new link revokes the previous ones of the same customer that allow any of the same channels, so an old forwarded link connects nothing. GET /v1/setup-links lists every link in your workspace with its state (active, used, expired, revoked), and POST /v1/setup-links/{id}/revoke revokes one by hand.

Usage per customer#

GET /v1/customers/{id}/usage?from=2026-11-01&to=2026-11-30 gives you what went through a customer's channels, to bill them: total, inbound, outbound (failed included) and failed messages, plus the conversations with messages, in total, by channel and by day. Days are UTC and the range includes both dates; without dates, the last 30 days (up to 366).

Response
{
  "data": {
    "customer_id": "6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b",
    "from": "2026-11-01",
    "to": "2026-11-30",
    "totals": { "messages": 1840, "inbound": 1012, "outbound": 828, "failed": 6, "conversations": 214 },
    "by_channel": [
      { "channel": "whatsapp_cloud", "messages": 1500, "inbound": 820, "outbound": 680, "failed": 5, "conversations": 170 },
      { "channel": "instagram", "messages": 340, "inbound": 192, "outbound": 148, "failed": 1, "conversations": 44 }
    ],
    "by_day": [
      { "date": "2026-11-01", "messages": 52, "inbound": 30, "outbound": 22, "failed": 0 }
    ]
  }
}

Embedded inbox#

Put the heysteff inbox inside your product: your users see and reply on WhatsApp, Instagram, Messenger, SMS and Gmail without leaving your app. The view shows the conversation list with each channel's icon, the thread, replying with text or files (and with an approved template when the WhatsApp 24 h window is closed), marking as read or unread, and taking the conversation or handing it back to the bot, in real time.

curl
curl -X POST https://app.heysteff.com/api/v1/inbox-embeds \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme inbox",
    "scope": "customer",
    "customer_id": "6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b",
    "allowed_origins": ["https://app.yourapp.com"],
    "theme": "system",
    "expires_in_seconds": 3600
  }'

The response carries token, embed_url and snippet (the ready iframe) only that once. Paste the iframe in your product:

HTML
<iframe
  src="https://developers.heysteff.com/embed/inbox?token=hse_…&status=open&unread=1"
  style="width:100%;height:640px;border:0;border-radius:12px"
  title="Inbox"></iframe>

With the SDK and the CLI#

TypeScript
import { Heysteff } from '@heysteff/sdk';

const heysteff = new Heysteff({ apiKey: process.env.HEYSTEFF_API_KEY });

const customer = await heysteff.customers.create({ external_id: 'acme-42', name: 'Acme Inc.' });
const link = await heysteff.customers.createSetupLink(customer.id, {
  allowed_channels: ['whatsapp_cloud', 'instagram'],
  success_redirect_url: 'https://yourapp.com/integrations/heysteff/done',
});
console.log(`Send this to your customer: ${link.url}`);

// When customer.channel_connected arrives:
const channels = await heysteff.customers.listChannels(customer.id);
await heysteff.messages.send({
  channel: 'whatsapp_cloud',
  customer_id: customer.id,
  to: '+15550100199',
  content: 'Hi Ana, your appointment is confirmed',
});

// This month's usage, to bill your customer
const usage = await heysteff.customers.usage(customer.id, { from: '2026-11-01', to: '2026-11-30' });

// Embedded inbox: one token per user session
const embed = await heysteff.inboxEmbeds.create({
  name: 'Acme inbox',
  scope: 'customer',
  customer_id: customer.id,
  allowed_origins: ['https://app.yourapp.com'],
  expires_in_seconds: 3600,
});
// embed.embed_url goes in your iframe src
Terminal
heysteff customers create --external-id acme-42 --name "Acme Inc."
heysteff customers link 6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b --channels whatsapp_cloud,instagram --redirect-url https://yourapp.com/done
heysteff customers channels 6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b
heysteff messages send --channel whatsapp_cloud --customer 6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6b --to +15550100199 --text "Hi"

Endpoints and scopes#

EndpointScopeWhat it does
POST /v1/customerscustomers:writeCreates a customer (external_id, name, optional metadata up to 16 KB). 409 customer_external_id_taken; 402 plan_limit if you reached your plan's quota.
GET /v1/customerscustomers:readYour customers. Filter with external_id; paginate with limit (1 to 100, default 50) and cursor (the response's next_cursor).
GET /v1/customers/{id}customers:readOne customer, with channels_count. 404 customer_not_found.
PATCH /v1/customers/{id}customers:writeChanges name, external_id or metadata (at least one).
DELETE /v1/customers/{id}customers:writeDeletes the customer. Their channels stay connected in your workspace, without a customer: deleting doesn't disconnect anything.
POST /v1/customers/{id}/setup-linkscustomers:writeCreates a setup link and returns url. Revokes the active ones of the same customer and channel.
GET /v1/customers/{id}/setup-linkscustomers:readThe customer's links with their state.
GET /v1/setup-linkscustomers:readEvery link in the workspace; filter with status and customer_id.
POST /v1/setup-links/{id}/revokecustomers:writeRevokes an unused link.
GET /v1/customers/{id}/usagecustomers:readThe customer's messages and conversations by channel and by day (from, to).
POST /v1/inbox-embedsinbox:embedCreates an embedded inbox token; returns embed_url and snippet.
GET /v1/inbox-embeds, GET/PATCH/DELETE /v1/inbox-embeds/{id}inbox:embedLists, reads, changes (name, origins, theme, language) and revokes tokens.
GET /v1/customers/{id}/channelscustomers:readChannels the customer connected: id (your channel_connection_id), channel_type, display_name, is_active.
POST /v1/messagesmessages:writeWith customer_id, sends through that customer's channel.
GET /v1/conversations?customer_id=conversations:readOnly the conversations on that customer's channels: one inbox per customer (the inbox template does it with HEYSTEFF_CUSTOMER_ID).

Security#

Quotas by plan#

PlanCustomers
Developer Free1, to try the full flow.
Developer10.
Developer PlatformUnlimited.

Past the quota, POST /v1/customers returns 402 plan_limit with details.resource = "developer_customers". Details in Pricing.

In the console#

At developers.heysteff.com/console, the Customers section lists your customers with their channels, usage and links, lets you create a customer and generate a link with every option without writing code, and shows every link in the workspace with its state. The Embedded inbox section creates tokens and gives you the iframe to copy.

From your AI (MCP)#