developers
ES Sign up

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.

Terminal
curl -fsSL https://developers.heysteff.com/install.sh | bash

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

Terminal
heysteff setup

# Paste a key you already have, skipping the browser
heysteff setup --key

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

Terminal
# 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
FlagWhat it does
--keyAsks 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-openDoes 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-testDoes not offer the test message.
--yes, -yAsks 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.

  1. The CLI asks heysteff for a code, opens https://developers.heysteff.com/console/device?code=ABCD-EFGH and shows the same code (ABCD-EFGH) in the terminal.
  2. 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 an sk_test_ key for the sandbox) and the permissions. The default permissions come checked.
  3. Press Authorize, or Deny if it wasn't you. An API key named after CLI and your computer is created; the terminal receives it only once and stores it in ~/.config/heysteff/config.json with 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.

Terminal
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
FlagWhat it does
--no-browserDoes not open the browser: prints the URL and the code so you can open them on another machine.
--keyAsks for a key you already have, without echoing it, or reads it from standard input.
--api-key <key>Stores that key without asking.
--skip-verifyWith --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:

SourceExample
The --api-key optionheysteff whoami --api-key sk_test_…
The HEYSTEFF_API_KEY variableexport 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
curl -X POST https://app.heysteff.com/api/cli/device/code \
  -H "Content-Type: application/json" \
  -d '{ "client_name": "heysteff-cli", "hostname": "my-mac" }'
JSON
{
  "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
curl -X POST https://app.heysteff.com/api/cli/device/token \
  -H "Content-Type: application/json" \
  -d '{ "device_code": "…" }'
ResponseWhat to do
400 authorization_pendingThe person hasn't answered yet: poll again after interval seconds.
400 slow_downYou polled too early: add 5 seconds to the interval.
400 access_deniedThe person pressed Deny. Stop polling.
400 expired_tokenThe 10 minutes are up: ask for a new code.
400 invalid_grantThe device_code does not exist or was already used.
200Authorized. 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:

JSON
{
  "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.

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

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

Terminal
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.jpg

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

Terminal
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-1e0f9a8b7c6d

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

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

Webhooks from the terminal (the secret is shown only once):

terminal
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-1c2d3e4f5a6b

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

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

Terminal
heysteff listen
Output
14: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 1c2d3e4f

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

Terminal
heysteff listen --forward-to http://localhost:3000/webhooks/heysteff
Output
Signing 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.

Terminal
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'
FlagWhat 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.
--jsonOne 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
curl -N "https://app.heysteff.com/api/v1/events/stream?events=message:received" \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY"
Stream
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

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.

Terminal
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.json

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

Terminal
heysteff webhooks verify --secret whsec_test_123 --timestamp 1730000000 \
  --signature v1=… --tolerance 0 body.json

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

Terminal
heysteff conversations list --all --json | jq -r '.[] | select(.channel == "whatsapp_cloud") | .id'
Exit codeMeaning
0All good.
1The API returned an error, the network failed or your server did not answer 2xx in webhooks trigger.
2Missing options or an invalid value.
3No API key, or the API rejected it (401).
4webhooks verify: the signature is not valid.