Skip to main content

Hooks & webhooks

There are two ways a webhook reaches the CLI:

  • Hosted webhooks — TradingView (or any sender) posts to your Tealstreet webhook URL, and Tealstreet relays the alert to whichever listener you designated: this CLI, or a browser tab. The alert message is CLI commands. This is the supported path.
  • Local hooks — a fixed command fired by POST /hook/<id> on the loopback listener. Only reachable from this machine.

Hosted webhooks​

How it works​

TradingView alert --POST--> your Tealstreet webhook URL
(Settings -> Webhooks)
|
v
Tealstreet relay --> the ONE listener you claimed (this CLI or a web tab)
|
v
the alert message runs here as CLI commands

The CLI connects to the relay automatically when the REPL starts, as long as you are logged in (login) and listener.remoteExec is not false. It runs nothing until you choose it: no listener receives your webhooks until you run webhook claim (or choose one in Settings → Webhooks). That is deliberate — a TradingView URL you set up before this existed may still be posting.

The alert message is CLI commands​

Put the commands you want to run in the TradingView alert's Message box. The full grammar works — ; / & chaining, aliases, @vars, retry, sleep and set ec. To try an alert without trading, turn simulation on in the REPL first (a webhook cannot change it):

buy $250 SOLUSDT at -0.5%
close longs; buy $500 BTCUSDT
my-entry-alias

Aliases resolve on the listener that runs the alert. An alias you defined in this CLI exists here; one defined in a browser does not.

Each alert runs in order behind anything you are typing, against the account the REPL is currently on. Name the symbol in each order (buy $100 SOLUSDT); a webhook cannot switch the account or symbol.

Which listener runs it — the claim​

Exactly one listener receives your webhooks. You can run the CLI and the web app at the same time, possibly on different accounts, so Tealstreet never picks for you after the first one:

webhook # who receives your webhooks, and every listener you have
webhook claim # make THIS CLI the receiver
webhook release # stop receiving here; alerts expire until something claims

You can also move the claim from Settings → Webhooks in the web app. Whenever it moves, the CLI tells you.

If the claimed listener is offline, an alert waits (60 seconds by default, set in Settings) and then expires without running. It is never sent to a different listener.

What a webhook may not run​

A webhook message is text from whoever holds your webhook URL. It may trade; it may not reconfigure the CLI. These are refused, and the refusal is recorded as the alert's result:

login, logout, auth/whoami, update, exit, quit, clients, hook, webhook, logs/log/clearlogs, audit, footer, export, import, script/scripts (running script:<name> is allowed), the watch modes, and these commands under any of their aliases: account, accounts, symbol, set, sets, unset, track, untrack, alias, unalias, when, simulation, fatfinger, unfatfinger, budget, whitelist, unwhitelist, history, wait. Reading a var you set yourself (buy @size SOLUSDT) works; changing one does not, and neither does a capture (@o = buy …).

A webhook also may not hold the CLI for long, since it runs in line with what you type: at most 50 steps, at most 60 seconds of sleep in total, and one repeat. An alert that waits behind your own commands past its expiry is not run.

These rules are for hosted webhooks only. Local hooks (hook create) run a command you saved yourself and are not restricted.

Delivery guarantees​

  • At most once. The CLI confirms with the relay before running an alert, and the relay confirms each alert to one listener exactly once. A dropped connection can cost an alert; it can never run one twice.
  • Results are recorded. Each alert's output and done/failed status show in the delivery log under Settings → Webhooks.
  • Your URL is the secret. Anyone with it can trade on the receiving listener's accounts. Rotate it from Settings → Webhooks if it leaks.

Local hooks​

A hook is a registered command body that fires over HTTP when the local listener receives a POST /hook/<id> with a matching X-Hook-Token. The listener is local to the machine running the standalone CLI, so external senders need a route to that machine before the hook can fire. Standalone-only — hooks live inside the listener subsystem, see Listener for the underlying transport.


REPL commands​

CommandSyntaxNotes
hook / hook list / hooksbareList registered hooks. Secrets shown as <hidden> — use hook show to reveal.
hook createhook create <name> "<command>" [--account <name>]Mint a new hook. Prints the fire URL + X-Hook-Token. Quotes: " or ', no escapes.
hook show <id|prefix>hook show <id|prefix>Reveal the stored secret and re-print fire instructions.
hook rotate <id|prefix>hook rotate <id|prefix>Mint a new secret in place. Old secret stops working immediately.
hook revoke <id|prefix|--all>hook revoke <id|prefix|--all>Delete one or all hooks. Prefix must be ≥ 8 chars; ambiguous prefixes are refused.

Quoting rule for the command body: use "…" or '…'. There is no escape syntax inside the quotes — pick whichever quote your body doesn't contain.

hook create alert-long "buy $250 SOLUSDT at -0.5%"
hook create flatten "nuke positions" --account mybybit
hook revoke 4f3a1c8e
hook rotate 4f3a1c8e

Token model​

FieldShapePurpose
id16 random bytes → 32 hex charsGoes in the URL path: /hook/<id>. Non-secret.
secret16 random bytes → 32 hex charsSent as the X-Hook-Token HTTP header. Stored in hooks.json.

Verification uses crypto.timingSafeEqual, but the implementation length-checks first. The unequal-length call to timingSafeEqual would throw RangeError and bubble up as HTTP 500; the length guard keeps the wrong-token path returning a clean 401. Don't remove the guard if you're touching verifyHookToken().


Per-account scope​

hook create … --account <name> pins the hook to a specific account. When the hook fires it sets accountOverride on the run's AsyncLocalStorage, so the command executes against that account regardless of what the REPL is currently focused on.

Hooks created without --account execute against whatever account the REPL has focused at fire time. That's fine for hooks you want to follow your manual context, but explicit pinning is the safer default for unattended fires.


HTTP fire shape​

POST http://127.0.0.1:53219/hook/<id>
X-Hook-Token: <32-hex secret>

The request body is ignored — the command is whatever was registered at hook create time. Useful because most webhook senders (TradingView, alerting tools) only let you customize URL and headers, not control which command runs.

StatusBodyMeaning
200{ ok: true, runId, command, account? }Accepted. Command runs asynchronously; HTTP returns immediately.
401{ ok: false, error: 'invalid token' }Token missing, wrong length, or wrong value. Length-guarded before constant-time compare.
404{ ok: false, error: 'unknown hook' }No hook with that id.
423{ ok: false, error: 'remoteExec disabled' }listener.remoteExec is false. See Listener → kill switches.
503{ ok: false, error: 'listener disabled' }listener.enabled is false or the server isn't bound.

Fire-and-forget: the 200 returns the moment the hook is accepted. The actual run streams cmd:output frames on the hook-output WS topic, then a final cmd:done. Web clients subscribed to that topic see the run as if it were a cmd:exec.

$ curl -X POST http://127.0.0.1:53219/hook/4f3a1c8e... \
-H "X-Hook-Token: 8b9d2e4a..."
{"ok":true,"runId":"r_01HK…","command":"buy $250 SOLUSDT at -0.5%"}

TradingView (and other senders)​

127.0.0.1 is unreachable from the public internet, so external senders cannot hit a local hook. Use hosted webhooks instead — see Webhook from TradingView.


Local mirror​

Every cmd:output frame a hook produces is also printed to the local REPL with a [hook:<name>] prefix in the matching chalk color. You see exactly the same output a paired web client would see, without needing one attached. Useful for debugging the command body without flipping back to the web tab.


Lifecycle​

  • Hooks persist to ~/.tealstreet/hooks.json on every mutation. The file contains shared secrets — back it up the same way you'd back up any credential file.
  • Hooks survive REPL restart. Revoking a hook is the only way to invalidate it; restarting the CLI does not rotate secrets.
  • Hook fires are gated by listener.remoteExec. Toggling that flag to false is the fastest way to disable every hook at once without deleting them — the kill switch returns HTTP 423 and the hook records stay intact.

Related: Listener for the underlying server, Trusted clients for WS-level access control, Pairing for the end-to-end web setup.