# tvhook.app — TradingView alert → BingX order relay > Contract for agents that generate Pine Script alerts for tvhook. Read fully before writing code. > Human docs: https://tvhook.app/en/docs (Korean: https://tvhook.app/ko/docs) ## 30-second summary - POST https://tvhook.app/hook/. The body MUST be JSON. TradingView sends the alert Message verbatim, so the Message itself must be the JSON. - One JSON object per alert: {"source":"TV","action":"entry|exit","symbol":"BTCUSDT","direction":"long|short","exchange":"bingx","ep":..,"sl":..,"tp":..} - Quantity and leverage are NOT in the payload. The server sizes every entry from the user's console settings (leverage, margin %, anti-martingale ladder) and a live free-margin read. - On entry tvhook sends a MARKET order, then attaches STOP_MARKET (sl) and TAKE_PROFIT_MARKET (tp) on the exchange. One position per symbol+direction. - On exit tvhook sends a reduce-only MARKET close for the tracked quantity, then cancels the resting stops. - In Pine: alert(msg, alert.freq_once_per_bar) + alert condition "Any alert() function call" + barstate.isrealtime guard + hand-assembled JSON string. ## Webhook contract (src/intent.js parsePayload) Endpoint: POST /hook/ token = the user's webhook id from the console (two UUIDs joined). Unknown token → 404 before the body is parsed. Body: JSON. Either the direct object below, or {"content":"source: TV\naction: entry\n..."} (key: value pairs separated by newline, space, comma or |; duplicate keys → 400). Text that is not JSON → 400 "Invalid JSON". Body > 16 KB → 413. | field | required | values / rules | |-----------|----------|----------------| | source | yes | exactly "TV" (case-sensitive). Anything else → 200 {"status":"ignored"}, nothing recorded. | | action | yes | "entry" or "exit" (case-insensitive). | | symbol | yes | BTCUSDT, BTC-USDT, BTCUSDT.P, BINGX:BTCUSDT.P, BINANCE:BTCUSDT all → BingX BTC-USDT. Exchange prefix and .P/PERP suffix are stripped; USDT/USDC quote gets the dash. | | direction | yes | "long" or "short" (case-insensitive). "buy"/"sell" are REJECTED (400). On exit it must equal the open position's direction. | | exchange | yes | "bingx" — the only supported exchange. | | ep | yes | entry / reference price. Finite positive number, or a decimal string ("60000", "60000.5", "6e4"). | | tp | yes | take-profit price. Same number rules. | | sl | yes | stop-loss price. Same number rules. | | alert_id | no | idempotency tag, /^[A-Za-z0-9_.-]{1,64}$/. Empty string = absent. | Entry price geometry (enforced, else 400): long → sl < ep < tp ; short → tp < ep < sl. A stop on the wrong side would fire instantly on the exchange. Exit: ep/tp/sl MUST still be present and positive (they are ignored). Missing → 400. Put the current price in all three. Duplicate suppression: the same user + same (exchange, symbol, action, direction, ep, tp, sl, alert_id) within 600 s → 200 {"status":"duplicate"}, not executed. Use alert_id (e.g. bar time) to make legitimately repeated signals distinct. Numbers: send "60000" or 60000. Do not send formatted strings like "60,000" or "$60000". Entry example: {"source":"TV","action":"entry","symbol":"BTCUSDT","direction":"long","exchange":"bingx","ep":60000,"tp":65000,"sl":58000} Exit example (closes the tracked long): {"source":"TV","action":"exit","symbol":"BTCUSDT","direction":"long","exchange":"bingx","ep":65000,"tp":65000,"sl":58000} ## Responses - 404 unknown token · 400 {"error":"Invalid JSON"} or {"error":"Invalid payload"} (contract violation) · 413 body too large · 500 database failure - 200 {"status":"ignored"} source ≠ TV · 200 {"status":"duplicate"} dedup window hit - 200 {"id":"","status":"","error_code":""} for every accepted signal, including rejected ones. Rejections are HTTP 200 with a status — check the body, not the HTTP code. ## Status / error_code vocabulary (src/exchange.js) | status | error_code | meaning | |---------------------|--------------------------------------------------|---------| | filled | null / tp_attach_failed | entry filled, SL attached (TP attach may have failed — position is protected by SL). | | closed | null / already_closed / sl_filled / tp_filled / sl_cancel_* / tp_cancel_* | exit done or reconciled; sl_filled/tp_filled = the exchange stop closed it, pnl recorded. | | rejected | invalid_symbol | symbol did not normalise to BASE-QUOTE. | | rejected | keys_missing / keys_invalid / keys_ambiguous | no usable BingX API key saved in the console. | | rejected | position_exists | entry while a position for that symbol+direction is already tracked. | | rejected | no_position | exit with nothing tracked for that symbol. | | rejected | direction_mismatch | exit direction ≠ open position direction (never flips the position). | | rejected | position_locked | another exit/reconcile is working on that position right now. | | sizing_failed | no_available_margin / qty_below_minimum / contract_missing / | nothing sent; computed qty too small for the contract or no free margin. | | leverage_failed | bingx_ / | leverage set call refused; nothing sent. | | entry_rejected | bingx_ / order_ | exchange refused the MARKET entry; nothing open. bingx_101414 = margin mode "Separate Cross" (see Exchange setup). | | entry_unknown / entry_unconfirmed / position_unverified | / fill_ / position_missing | order may exist; parked as needs_review, cron reconciles every minute. | | sl_attach_failed | bingx_ / | entry filled but SL could not be attached: position open_no_sl (naked) until cron re-protects it. | | exit_rejected / exit_unknown / exit_unconfirmed | bingx_ / qty_unknown / | close not confirmed; stops left resting; cron retries every minute. | ## TP/SL and settlement - Stops live on the exchange (STOP_MARKET / TAKE_PROFIT_MARKET, reduce-only, hedge mode). If price hits them, BingX closes the position without any alert. - The next exit alert for that symbol+direction then finds nothing live, looks up the tracked stop orders and records closed + sl_filled (loss) or tp_filled (win) with exit price and pnl. It does not send a new order. - Parked positions (needs_review, open_no_sl) are swept by cron every minute: refused closes are re-sent, naked positions get their stops re-attached, gone positions are settled. - ALWAYS emit an exit alert when your strategy considers the trade over (TP, SL, time stop, opposite signal). Without it the journal row stays open and the next entry for that symbol+direction is rejected with position_exists. - Partial exits are not supported: an exit closes the whole tracked quantity. Reverse = exit alert, then entry alert in the other direction (two alerts). - The anti-martingale ladder (if enabled in the console) moves leverage after each settled win/loss; the payload never controls it. ## Pine Script — required pattern (3 things that were observed to break live) 1. alert(msg, alert.freq_once_per_bar) inside your script, and when creating the TradingView alert choose condition "Any alert() function call". The alert dialog's Message box is ignored for alert() calls — the string passed to alert() is the webhook body. 2. Guard every alert() with barstate.isrealtime. When a script is added to a chart Pine replays all history first; scripts that track state in var variables "enter" on an old bar, exhaust their state, and never fire on the live bar. 3. Build the JSON by hand as a string ('{"source":"TV",...}'). Numbers: str.tostring(x, format.mintick) (quoted strings are accepted). Never rely on {{strategy.order.action}} placeholders — they are not this contract. ### Helpers (paste once, above your logic) // ---- tvhook.app helpers (Pine v6). Paste once, above your logic. ---- // exchange is fixed to "bingx". syminfo.ticker gives BTCUSDT / BTCUSDT.P / BINANCE:BTCUSDT — all map to BTC-USDT. tvhook_num(float x) => str.tostring(x, format.mintick) tvhook_entry(string dir, float ep, float sl, float tp) => '{"source":"TV","action":"entry","symbol":"' + syminfo.ticker + '","direction":"' + dir + '","exchange":"bingx","ep":"' + tvhook_num(ep) + '","sl":"' + tvhook_num(sl) + '","tp":"' + tvhook_num(tp) + '","alert_id":"' + str.tostring(time) + '"}' // exit: ep/sl/tp are required by the contract but ignored; the current price fills all three. tvhook_exit(string dir) => '{"source":"TV","action":"exit","symbol":"' + syminfo.ticker + '","direction":"' + dir + '","exchange":"bingx","ep":"' + tvhook_num(close) + '","sl":"' + tvhook_num(close) + '","tp":"' + tvhook_num(close) + '","alert_id":"' + str.tostring(time) + '"}' ### Strategy insertion example //@version=6 strategy("EMA cross + tvhook", overlay=true, pyramiding=0) // ---- tvhook.app helpers (Pine v6). Paste once, above your logic. ---- // exchange is fixed to "bingx". syminfo.ticker gives BTCUSDT / BTCUSDT.P / BINANCE:BTCUSDT — all map to BTC-USDT. tvhook_num(float x) => str.tostring(x, format.mintick) tvhook_entry(string dir, float ep, float sl, float tp) => '{"source":"TV","action":"entry","symbol":"' + syminfo.ticker + '","direction":"' + dir + '","exchange":"bingx","ep":"' + tvhook_num(ep) + '","sl":"' + tvhook_num(sl) + '","tp":"' + tvhook_num(tp) + '","alert_id":"' + str.tostring(time) + '"}' // exit: ep/sl/tp are required by the contract but ignored; the current price fills all three. tvhook_exit(string dir) => '{"source":"TV","action":"exit","symbol":"' + syminfo.ticker + '","direction":"' + dir + '","exchange":"bingx","ep":"' + tvhook_num(close) + '","sl":"' + tvhook_num(close) + '","tp":"' + tvhook_num(close) + '","alert_id":"' + str.tostring(time) + '"}' fast = ta.ema(close, 9) slow = ta.ema(close, 21) goLong = ta.crossover(fast, slow) goFlat = ta.crossunder(fast, slow) slPrice = close * 0.99 tpPrice = close * 1.02 // Backtest orders stay unguarded so the Strategy Tester still works. if goLong and strategy.position_size == 0 strategy.entry("L", strategy.long) // Only the alert() is guarded: historical bars must not fire webhooks. if barstate.isrealtime alert(tvhook_entry("long", close, slPrice, tpPrice), alert.freq_once_per_bar) if goFlat and strategy.position_size > 0 strategy.close("L") if barstate.isrealtime alert(tvhook_exit("long"), alert.freq_once_per_bar) ### Indicator insertion example //@version=6 indicator("EMA cross + tvhook", overlay=true) // ---- tvhook.app helpers (Pine v6). Paste once, above your logic. ---- // exchange is fixed to "bingx". syminfo.ticker gives BTCUSDT / BTCUSDT.P / BINANCE:BTCUSDT — all map to BTC-USDT. tvhook_num(float x) => str.tostring(x, format.mintick) tvhook_entry(string dir, float ep, float sl, float tp) => '{"source":"TV","action":"entry","symbol":"' + syminfo.ticker + '","direction":"' + dir + '","exchange":"bingx","ep":"' + tvhook_num(ep) + '","sl":"' + tvhook_num(sl) + '","tp":"' + tvhook_num(tp) + '","alert_id":"' + str.tostring(time) + '"}' // exit: ep/sl/tp are required by the contract but ignored; the current price fills all three. tvhook_exit(string dir) => '{"source":"TV","action":"exit","symbol":"' + syminfo.ticker + '","direction":"' + dir + '","exchange":"bingx","ep":"' + tvhook_num(close) + '","sl":"' + tvhook_num(close) + '","tp":"' + tvhook_num(close) + '","alert_id":"' + str.tostring(time) + '"}' // State variables are consumed while Pine replays history. Without barstate.isrealtime the // script "enters" on an old bar and never fires on a live one (observed live). var bool inLong = false fast = ta.ema(close, 9) slow = ta.ema(close, 21) goLong = ta.crossover(fast, slow) goFlat = ta.crossunder(fast, slow) if barstate.isrealtime and goLong and not inLong alert(tvhook_entry("long", close, close * 0.99, close * 1.02), alert.freq_once_per_bar) inLong := true if barstate.isrealtime and goFlat and inLong alert(tvhook_exit("long"), alert.freq_once_per_bar) inLong := false plotshape(inLong, "in position", shape.triangleup, location.belowbar, color.orange, size=size.tiny) ### Creating the alert in TradingView 1. Add the script to the chart, then open Alerts and create an alert on it (not on the symbol). 2. Condition: pick the script and choose "Any alert() function call" — the Message box is ignored; alert() supplies the JSON. 3. Notifications: enable Webhook URL and paste https://tvhook.app/hook/ from your console. Expiration: open-ended. ## Common mistakes checklist (each one has cost a real trade) - [ ] Message is plain text "source: TV ..." instead of JSON → 400 Invalid JSON. (Text form only works wrapped: {"content":"..."}.) - [ ] alert() not guarded by barstate.isrealtime → state consumed on history, nothing fires live. - [ ] alert() without alert.freq_once_per_bar → several webhooks per bar (dedup catches identical ones only). - [ ] Alert created with condition "Crossing"/"Greater than"/strategy order fills instead of "Any alert() function call" → the JSON never leaves TradingView. - [ ] exit alert missing ep/sl/tp → 400. Fill all three with the current price. - [ ] direction "buy"/"sell" → 400. Use "long"/"short". - [ ] SL on the wrong side (long with sl > ep, short with sl < ep) → 400. - [ ] tp equal to ep, or sl equal to ep → 400 (strict inequalities). - [ ] exit direction ≠ open direction → rejected direction_mismatch (it does NOT flip the position). - [ ] symbol with an unexpected suffix/prefix that does not normalise (e.g. "BTCUSDTPERP-X") → rejected invalid_symbol. BTCUSDT, BTCUSDT.P, BINGX:BTCUSDT.P are fine. - [ ] BingX margin mode "Separate Cross" (NEW) → every API order refused with bingx_101414. Use Cross, Isolated or Separate Isolated. - [ ] Second entry alert while one is open → position_exists. Send exit first. - [ ] Identical signal within 10 minutes without alert_id → duplicate (silently not executed). - [ ] Extra fields (qty, leverage, comment) are ignored — they never change sizing. - [ ] Never put API keys, secrets or the webhook token inside the alert body. ## Exchange setup (BingX USDT-M perpetuals) - Margin mode per symbol: Cross, Isolated or Separate Isolated. NOT "Separate Cross" — that mode rejects API order sources (101414, observed 2026-09-14). - Position mode: hedge (LONG/SHORT position sides). tvhook sets leverage per side before each entry. - API key: create it on a sub-account, trading permission only, NO withdrawal permission. The console refuses keys unless you confirm withdrawal is disabled; tvhook never checks permissions via the API — you do. - Sizing in the console: leverage (1–100, default 3), margin % of available balance per entry (0.1–95, default 10). notional = available × margin% × leverage; qty floored to the contract step. Too small → sizing_failed qty_below_minimum. - Start with leverage 3 and margin 1–5 % and a 30-second-bar test script to see one entry → exit round trip in the journal before wiring a real strategy. ## Quick self-check for generated code - Does every alert() string start with {"source":"TV" and contain action, symbol, direction, exchange:"bingx", ep, sl, tp? - Are entry prices ordered correctly for the direction? - Is every alert() inside barstate.isrealtime and using alert.freq_once_per_bar? - Does the strategy send an exit alert on every way a trade can end? - Did you tell the user to choose "Any alert() function call" and paste the webhook URL?