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#
- Create the customer with
POST /v1/customers, once per business in your SaaS. - Generate a link with
POST /v1/customers/{id}/setup-links. It returnsurl(https://developers.heysteff.com/connect/hsl_…). - 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.
- You find out two ways: your customer is sent back to your
success_redirect_urlwith the channel id in the URL, and your webhook receivescustomer.channel_connected. - You send messages on their behalf with
POST /v1/messagesandcustomer_id.
1. Create the customer#
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" }
}'{
"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.
2. Generate the setup link#
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"
}'{
"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"
}
}| Field | What it does |
|---|---|
allowed_channels | Channels your customer can pick: whatsapp_cloud, instagram, facebook (Messenger) and gmail. Default: all four. |
expires_in_hours | How long the link lasts, 1 to 2160 hours (90 days). Default 720 (30 days). |
locale, theme | Language and colors of the page. See Customize the page. |
whatsapp_modes | How they can connect WhatsApp: own number, coexistence with the phone app or a heysteff number. See WhatsApp. |
reconnect_channel_id | Reconnects a customer channel that went down. See Reconnect. |
success_redirect_url | Where your customer goes after connecting. https, or http://localhost to test on your machine. |
failure_redirect_url | Where they go if something fails. Without it, they return to success_redirect_url with status=error. |
brand_name, brand_logo_url | What 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):
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_cloudhttps://yourapp.com/integrations/heysteff/error?status=error&error=<code>&customer_id=6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6bWithout 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:
{
"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 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 -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"
}
}'theme:#RRGGBBcolors for the main button, the background, the text and the borders. Those you leave out use heysteff's.locale:es,enorptfixes the language (the URL carries?lang=);auto, the default, uses your customer's browser language.
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:
| Mode | For whom |
|---|---|
own_number | A number not yet in the WhatsApp Business app. It is registered on Meta's official API. |
coexistence | The 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_number | A 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).
One active link per customer and 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).
{
"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 -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:
<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>- Scope.
scope:all(your whole workspace),channel(one channel, withchannel_connection_id) orcustomer(a customer's channels, withcustomer_id). The view never shows a conversation outside its scope: not in the list, the thread or the real-time stream. - Who can display it.
allowed_origins(1 to 20):https://app.yourapp.com, with a subdomain wildcardhttps://*.yourapp.com, orhttp://localhost:3000to test. They become the CSPframe-ancestors: no other site can frame it. - URL filters.
status(open,closed,snoozed,all),search,unread=1andchannel; alsothemeandlang. - Theme and language.
theme:system,lightordark;locale:auto,es,enorpt. - Per-session tokens. Whoever has the token can read and reply within its scope. Create it from your backend for each user with a short
expires_in_seconds; never put a non-expiring one in a public page.DELETE /v1/inbox-embeds/{id}revokes it and the view stops loading. - Key scope.
inbox:embed. Test keys cannot create tokens.
With the SDK and the CLI#
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 srcheysteff 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#
| Endpoint | Scope | What it does |
|---|---|---|
POST /v1/customers | customers:write | Creates 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/customers | customers:read | Your 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:read | One customer, with channels_count. 404 customer_not_found. |
PATCH /v1/customers/{id} | customers:write | Changes name, external_id or metadata (at least one). |
DELETE /v1/customers/{id} | customers:write | Deletes the customer. Their channels stay connected in your workspace, without a customer: deleting doesn't disconnect anything. |
POST /v1/customers/{id}/setup-links | customers:write | Creates a setup link and returns url. Revokes the active ones of the same customer and channel. |
GET /v1/customers/{id}/setup-links | customers:read | The customer's links with their state. |
GET /v1/setup-links | customers:read | Every link in the workspace; filter with status and customer_id. |
POST /v1/setup-links/{id}/revoke | customers:write | Revokes an unused link. |
GET /v1/customers/{id}/usage | customers:read | The customer's messages and conversations by channel and by day (from, to). |
POST /v1/inbox-embeds | inbox:embed | Creates an embedded inbox token; returns embed_url and snippet. |
GET /v1/inbox-embeds, GET/PATCH/DELETE /v1/inbox-embeds/{id} | inbox:embed | Lists, reads, changes (name, origins, theme, language) and revokes tokens. |
GET /v1/customers/{id}/channels | customers:read | Channels the customer connected: id (your channel_connection_id), channel_type, display_name, is_active. |
POST /v1/messages | messages:write | With customer_id, sends through that customer's channel. |
GET /v1/conversations?customer_id= | conversations:read | Only the conversations on that customer's channels: one inbox per customer (the inbox template does it with HEYSTEFF_CUSTOMER_ID). |
Security#
- Single use. Each link connects one channel. Once done, it's marked used and no longer opens the connection.
- Expires. An unused link stops working after
expires_in_hours(30 days by default). - One active. A new link for the same customer and channel revokes the previous ones.
- No exposed secrets. heysteff stores only a hash of the token. Your customer never sees your API key, and you never see their credentials: the access Meta or Google grants stays encrypted in heysteff and never leaves through the API.
- Controlled redirects. Return URLs are https (or
http://localhost), with no username or password in the URL. - Live keys only. Test keys (
sk_test_) can't create links: they get 403test_key_not_allowed. The other customer endpoints work with them.
Quotas by plan#
| Plan | Customers |
|---|---|
| Developer Free | 1, to try the full flow. |
| Developer | 10. |
| Developer Platform | Unlimited. |
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)#
list_developer_customersandget_developer_customer: your customers, or one.create_developer_customer,update_developer_customeranddelete_developer_customer: create, change and delete.create_customer_setup_link: generates a setup link and returns the URL.list_customer_channels: the channels a customer connected.list_setup_linksandrevoke_customer_setup_link: the links with their state, and revoking one.get_customer_usage: a customer's usage by channel and by day.list_inbox_embeds,get_inbox_embed,create_inbox_embed,update_inbox_embedandrevoke_inbox_embed: the embedded inbox tokens.