Guides
CLI
@heysteff/cli gives you the heysteff command to use the API from your terminal: send a test message, check conversations or test your webhook without writing code. It is built on the TypeScript SDK and needs Node 20 or newer.
Package: @heysteff/cli 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.
Install#
One line: the installer checks that you have Node 20 or newer and npm, installs @heysteff/cli with npm install -g and tells you the next step. If Node is missing, it explains how to install it (nvm, Homebrew or winget) and changes nothing. You can read the script before running it.
curl -fsSL https://developers.heysteff.com/install.sh | bashOr with npm directly: npm install -g @heysteff/cli. Without installing: npx @heysteff/cli setup.
Set up with heysteff setup#
heysteff setup takes you from zero to a sent message: it opens your browser so you authorize the CLI in the console (just like heysteff login), stores the API key it receives, checks it and offers to send a test message with the same POST /v1/messages as always. With an sk_test_ key the message goes through the sandbox and only reaches a phone that joined it; with an sk_live_ key you pick the channel (WhatsApp, Instagram, Messenger, SMS or email). If you already have a key and would rather paste it, use heysteff setup --key.
heysteff setup
# Paste a key you already have, skipping the browser
heysteff setup --keyWithout an interactive terminal (CI, a script, a coding agent with no person around) the key goes in --api-key or HEYSTEFF_API_KEY, and the rest is driven with flags:
# Store the key, check it and send a test message, no questions asked
heysteff setup --api-key "$HEYSTEFF_API_KEY" --to +56912345678 --yes
# Only store and check the key, without opening the browser or sending anything
heysteff setup --api-key "$HEYSTEFF_API_KEY" --no-open --skip-test --json| Flag | What it does |
|---|---|
--key | Asks for a key you already have, without echoing it, instead of opening the browser. |
--api-key <key> | Uses that key: it neither opens the browser nor asks for it. |
--no-open | Does not open the browser: it only prints the URL for you to open. |
--channel <channel>, --to <recipient>, --text <text> | The test message. With --to it is sent without asking; the default channel is whatsapp_cloud. |
--skip-test | Does not offer the test message. |
--yes, -y | Asks no confirmation questions. Without --api-key it uses the stored key or HEYSTEFF_API_KEY. |
If the API rejects the key, it is not stored and the command exits with code 3. If the key is stored but the test message fails, it exits with code 1 and prints the error code (for example sandbox_not_a_member).
Log in#
heysteff login connects the CLI to your account through the browser, like gh auth login: you don't create or copy an API key. The CLI messages are in Spanish.
- The CLI asks heysteff for a code, opens
https://developers.heysteff.com/console/device?code=ABCD-EFGHand shows the same code (ABCD-EFGH) in the terminal. - On that page, signed in to the console, check that the code matches your terminal and pick the workspace (a developer one, or a heysteff one if you have it), the mode (production, with an
sk_live_key, or test, with ansk_test_key for the sandbox) and the permissions. The default permissions come checked. - Press Authorize, or Deny if it wasn't you. An API key named after
CLIand your computer is created; the terminal receives it only once and stores it in~/.config/heysteff/config.jsonwith 600 permissions (only your user can read it), in the profile you are using.
The code expires after 10 minutes and works only once. The key shows up in the console under API keys, where you can revoke it at any time. heysteff whoami tells you which key is in use, where it came from, whether it is a test key and whether the API accepts it.
heysteff login # opens the browser and waits for you to authorize
heysteff login --no-browser # only prints the URL: for SSH or a container
heysteff whoami
# Paste a key you already have (the old way)
heysteff login --key
heysteff login --api-key sk_test_…
echo "$HEYSTEFF_API_KEY" | heysteff login --key
# Several keys: one per profile
heysteff login --profile test
heysteff whoami --profile test
heysteff logout| Flag | What it does |
|---|---|
--no-browser | Does not open the browser: prints the URL and the code so you can open them on another machine. |
--key | Asks for a key you already have, without echoing it, or reads it from standard input. |
--api-key <key> | Stores that key without asking. |
--skip-verify | With --key or --api-key: stores the key without checking it against the API. |
--profile <name> | Stores the key in that profile instead of the default one. |
On CI and servers don't use login: pass the key in HEYSTEFF_API_KEY. The key is looked up in this order:
| Source | Example |
|---|---|
The --api-key option | heysteff whoami --api-key sk_test_… |
The HEYSTEFF_API_KEY variable | export HEYSTEFF_API_KEY=sk_test_… |
| The saved profile | --profile, HEYSTEFF_PROFILE or the default one |
Change the API base with --base-url or HEYSTEFF_BASE_URL, and the config folder with HEYSTEFF_CONFIG_DIR.
Authorize from your own tool#
Browser login uses two public endpoints, in the style of RFC 8628 (device authorization grant). Your own tool can use them to get a key just like the CLI. First ask for a code:
curl -X POST https://app.heysteff.com/api/cli/device/code \
-H "Content-Type: application/json" \
-d '{ "client_name": "heysteff-cli", "hostname": "my-mac" }'{
"device_code": "…",
"user_code": "ABCD-EFGH",
"verification_uri": "https://developers.heysteff.com/console/device",
"verification_uri_complete": "https://developers.heysteff.com/console/device?code=ABCD-EFGH",
"expires_in": 600,
"interval": 5
}Show user_code, open verification_uri_complete and poll for the token every interval seconds until the person authorizes or denies:
curl -X POST https://app.heysteff.com/api/cli/device/token \
-H "Content-Type: application/json" \
-d '{ "device_code": "…" }'| Response | What to do |
|---|---|
400 authorization_pending | The person hasn't answered yet: poll again after interval seconds. |
400 slow_down | You polled too early: add 5 seconds to the interval. |
400 access_denied | The person pressed Deny. Stop polling. |
400 expired_token | The 10 minutes are up: ask for a new code. |
400 invalid_grant | The device_code does not exist or was already used. |
| 200 | Authorized. The body carries the key; it only comes in this response, so store it. |
Errors use the usual format, { "error": { "code": "authorization_pending" } }. The success response:
{
"api_key": "sk_live_…",
"workspace": { "slug": "my-store", "name": "My store" },
"scopes": ["messages:write", "conversations:read", "events:read"],
"mode": "live"
}mode is live (an sk_live_ key) or test (an sk_test_ sandbox key).
Send messages#
Text, WhatsApp templates and files. With --media and a file on disk, the CLI uploads it first with POST /v1/media and infers the type (image, audio, video or document) from the extension. Types and limits are in Message types.
heysteff messages send --channel whatsapp_cloud --to +56912345678 --text "Hi Ana, your order is on its way"
heysteff messages send --channel whatsapp_cloud --to +56912345678 \
--template order_shipped --language en --param Ana --param "#1042" \
--idempotency-key order-1042-shipped
heysteff messages send --channel whatsapp_cloud --to +56912345678 \
--media ./catalog.pdf --text "Here is the November catalog"Buttons, locations, status, read receipts and reactions:
heysteff messages send --channel whatsapp_cloud --to +56912345678 \
--text "Confirm your booking?" --buttons "yes:Yes,no:No"
heysteff messages send --channel instagram --to 17841400000000000 \
--text "Downtown store" --location -33.4489,-70.6693
heysteff messages get 1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f
heysteff messages read 4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b
heysteff messages react 4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b 👍Conversations, templates and contacts#
Lists show one page and how to ask for the next one; --all walks every page.
heysteff conversations list --status open --limit 20
heysteff conversations messages 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
heysteff templates list --status all
heysteff contacts list --q ana
heysteff contacts create --name "Ana Pérez" --phone 912345678 --phone-country CL --tag vip
heysteff media upload ./photo.jpgTo hand a conversation to a person on the team, pause or resume the bot and resolve it (what your own AI uses, see Bring your own AI):
heysteff conversations get 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
heysteff conversations escalate 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d --reason "Wants a refund" --urgency high
heysteff conversations pause 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
heysteff conversations resume 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
heysteff conversations resolve 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6dheysteff WhatsApp numbers#
heysteff numbers reserve reserves a number and gives you the link to finish the signup in Meta's window (the API can't skip that step). Details in Get a number.
heysteff numbers list
heysteff numbers reserve # prints the connect_url
heysteff numbers display-name <id> "Aurora Store"
heysteff numbers release <id> # only a reservation nobody connectedWebhooks from the terminal (the secret is shown only once):
heysteff webhooks create --url https://api.tutienda.com/heysteff \
--event message:received --event message.read
heysteff webhooks list
heysteff webhooks delete 6f1c2a8e-3b4d-4e5f-9a0b-1c2d3e4f5a6bCustomers and setup links#
heysteff customers link prints a single-use URL where your customer connects their own WhatsApp, Instagram, Messenger or Gmail. Details in Customers and setup links.
heysteff customers create --external-id acme-42 --name "Acme Inc." --metadata '{"plan":"pro"}'
heysteff customers list --external-id acme-42
heysteff customers get <id>
heysteff customers link <id> --channels whatsapp_cloud,instagram --redirect-url https://yourapp.com/done
heysteff customers channels <id>
heysteff customers delete <id> # their channels stay connected
heysteff messages send --channel whatsapp_cloud --customer <id> --to +15550100199 --text "Hi"Live events with heysteff listen#
heysteff listen shows you, live in your terminal, what happens in your workspace: every message in or out, every delivery or read receipt, every escalation. No tunnel or public webhook needed: the CLI opens an outbound connection to heysteff (GET /v1/events/stream) with your API key. You get the same events as a webhook (message:received, message:sent, message.delivered, message.read, message.failed, conversation.escalated and the rest), from every channel: WhatsApp (official API and QR), Instagram, Messenger, SMS and Gmail.
heysteff listen14:03:52 message:received 5d1f0c7e whatsapp_cloud +56912345678: Thanks, what time does it arrive?
14:03:58 message:sent 8b0f6a1e whatsapp_cloud +56912345678: Between 3 and 5 pm
14:04:10 message.read 2a9c4d7b whatsapp_cloud 1c2d3e4fEach line has the time, the event, its id and a summary. It needs a key with the events:read scope. With an sk_test_ key you only get the events of sandbox conversations, and its conversations:read scope is enough.
Forward to your local server#
With --forward-to, besides printing each event, the CLI posts it to your server with the same envelope ({ id, tenant_id, event, ts, data }) and the same headers as a real webhook: X-Heysteff-Event, X-Heysteff-Timestamp, X-Heysteff-Signature: v1=… and X-Heysteff-Delivery-Id. You test your receiver with real messages (text your number from your phone) without exposing it to the internet.
heysteff listen --forward-to http://localhost:3000/webhooks/heysteffSigning secret: whsec_local_…
14:03:52 message:received 5d1f0c7e whatsapp_cloud +56912345678: Thanks, what time does it arrive?
→ 200 OK (12 ms)The CLI signs with a local secret it prints on startup (whsec_local_…), not with your webhooks' secret: set your local receiver to that value, or pin your own with --secret (for example, the one already in your .env). Under each event you see the status your server answered and how long it took. A full receiver with the SDK is in Test locally without a tunnel.
heysteff listen --forward-to http://localhost:3000/webhooks/heysteff --secret "$HEYSTEFF_WEBHOOK_SECRET"
# Only some events
heysteff listen --events message:received,message.read
heysteff listen --events message.failed --events conversation.escalated
# One JSON line per event (NDJSON), for scripts
heysteff listen --json | jq -r 'select(.event == "message:received") | .data.content'| Flag | What it does |
|---|---|
--forward-to <url> | Forwards each event to that URL, signed like a webhook, and prints the status and response time. |
--secret <whsec_…> | Secret used to sign what it forwards. Without it the CLI generates one (whsec_local_…) and prints it on startup. |
--events <list> | Only those events, comma separated. Can be repeated. |
--json | One JSON line per event (NDJSON) with the full envelope. |
If the connection drops, the CLI reconnects on its own and catches up on the events it missed: heysteff keeps the last 10 minutes while someone is listening. Press Ctrl+C to quit.
The stream without the CLI#
GET /v1/events/stream is a Server-Sent Events stream you can read from any language with your API key, or with the SDK's heysteff.events.stream(). It needs the same scopes as listen; filter with ?events=message:received,message.read.
curl -N "https://app.heysteff.com/api/v1/events/stream?events=message:received" \
-H "Authorization: Bearer $HEYSTEFF_API_KEY"retry: 3000
event: ready
data: {"tenant_id":"2c7e9b1a-3d4f-4a5b-8c6d-7e8f9a0b1c2d","mode":"live"}
id: 5d1f0c7e-8a2b-4c3d-9e4f-0a1b2c3d4e5f
event: message:received
data: {"id":"5d1f0c7e-8a2b-4c3d-9e4f-0a1b2c3d4e5f","tenant_id":"2c7e9b1a-3d4f-4a5b-8c6d-7e8f9a0b1c2d","event":"message:received","ts":"2026-11-14T14:03:52.104Z","data":{…}}
: ping- On connect you get
retry: 3000and areadyevent with{ tenant_id, mode }. - Each event carries
id,event(the name) anddata: the same JSON envelope as a webhook body. - Every 25 seconds a
: pingcomment keeps the connection alive. - The server closes the connection after an hour. Reconnect with the
Last-Event-IDheader set to the lastidyou got: you receive what happened in between, up to 10 minutes back.
Test your webhook on localhost#
heysteff webhooks trigger builds a sample event with the same envelope (id, tenant_id, event, ts, data) and the same signed headers heysteff sends (X-Heysteff-Signature, X-Heysteff-Timestamp, X-Heysteff-Event, X-Heysteff-Delivery-Id), and posts it to your server. You test your receiver and its verification without exposing it to the internet. The default event is message:received; --data takes JSON or @file.json.
heysteff webhooks trigger http://localhost:3000/webhooks/heysteff --secret whsec_test_123
heysteff webhooks trigger http://localhost:3000/webhooks/heysteff --secret whsec_test_123 \
--event message:sent --data @event.jsonheysteff webhooks verify checks the signature of an event you received. Pass the raw body in a file or on standard input, without reformatting it or adding a newline. To check a saved event older than 5 minutes, use --tolerance 0.
heysteff webhooks verify --secret whsec_test_123 --timestamp 1730000000 \
--signature v1=… --tolerance 0 body.jsonTo receive your workspace's real events on your machine, use heysteff listen --forward-to.
JSON output and errors#
Every command accepts --json and returns what the API returns, ready for jq. An error shows the code, the message and, when the API knows it, how to fix it; with --json it goes to standard error as { "error": { … } }. The codes are in Errors and limits.
heysteff conversations list --all --json | jq -r '.[] | select(.channel == "whatsapp_cloud") | .id'| Exit code | Meaning |
|---|---|
| 0 | All good. |
| 1 | The API returned an error, the network failed or your server did not answer 2xx in webhooks trigger. |
| 2 | Missing options or an invalid value. |
| 3 | No API key, or the API rejected it (401). |
| 4 | webhooks verify: the signature is not valid. |