Development · 12 min read · 2026-10-26
TradingView Alert → MT4/MT5 Order: A 40-Line Webhook Recipe
By MetaTrader API Editorial Team

Turn TradingView strategy alerts into MT4 and MT5 orders with a 40-line Node.js webhook: alert JSON, symbol mapping, duplicate-safe orders, exits, and security.
Short answer: set the TradingView alert's message to a small JSON object, point the alert's webhook URL at a 40-line server you run, and have that server call /OpenedOrders, /OrderClose and /OrderSend on the MetaTrader API REST API. No MQL, no Expert Advisor, no MetaTrader desktop app running on a Windows box. The webhook checks a secret, maps TradingView's symbol to your broker's, answers TradingView straight away and then makes the MetaTrader account match the strategy's position, so a duplicate alert can never open a second trade.
This is the recipe from our onboarding email, expanded: the full code in Node.js 22 and Python, and the details that bite once real money is on the line. It works the same on MT4 and MT5 accounts.
What you are building
- A TradingView alert on a strategy. Every time the strategy fills an order, TradingView sends an HTTP POST with your message to your webhook URL.
- Your webhook. A small HTTP server behind HTTPS. It verifies the request, replies within TradingView's time limit, and queues the trade.
- The MetaTrader API API. Your webhook calls it with an API key (header
x-api-key) and the account id (?id=). Base URL:https://api.metatraderapi.net. - Your MT4 or MT5 account at your broker, connected once through the dashboard. Every trade appears in your MetaTrader app as usual.
You need a connected account, its account id (the UUID on the seat card) and an API key. If you do not have a key yet, how to get an MT5 API key walks through it.
Step 1: the alert message
In TradingView, open the strategy's alert dialog, enable Webhook URL under Notifications and paste your endpoint, for example https://hooks.example.com/tv-webhook. Then set the message to JSON:
{
"secret": "LONG_RANDOM_STRING",
"symbol": "{{exchange}}:{{ticker}}",
"action": "{{strategy.order.action}}",
"position": "{{strategy.market_position}}",
"id": "{{strategy.order.id}}",
"time": "{{time}}",
"lots": 0.10
}
What each placeholder becomes when the alert fires, per TradingView's placeholder and strategy alert documentation:
| Placeholder | Value | Used for |
|---|---|---|
{{exchange}}:{{ticker}} |
OANDA:EURUSD |
symbol mapping. {{ticker}} alone is just EURUSD, so send the exchange too if you chart the same pair from several feeds |
{{strategy.order.action}} |
buy or sell |
part of the order's idempotency key |
{{strategy.market_position}} |
long, short or flat |
what the webhook acts on: the position the strategy holds after this fill |
{{strategy.order.id}} |
the id from strategy.entry() / strategy.close() |
the order comment, for humans |
{{time}} |
bar time in UTC, 2026-10-26T14:00:00Z |
part of the idempotency key |
Two things TradingView requires: webhooks only work with two-factor authentication enabled on your TradingView account, and they are only sent to ports 80 and 443 (IPv4 only). If the message is valid JSON, TradingView sends it with Content-Type: application/json. All three are on its webhook help page.
Why act on market_position instead of order.action? Because "sell" is ambiguous: it can close a long, open a short, or reverse from long to short in one fill. "The strategy is now short" is not. The webhook compares that target with what the account actually holds and does whatever closes the gap.
Keep the lot size in the message (or in the webhook) rather than using {{strategy.order.contracts}}. On forex charts TradingView usually counts units, not lots: 10,000 units is 0.10 lots, and sending 10000 as volume asks for 10,000 lots.
Step 2: the webhook (Node.js 22, no dependencies)
import { createServer } from "node:http";
import { timingSafeEqual } from "node:crypto";
const API = "https://api.metatraderapi.net";
const { MT_API_KEY: KEY, MT_ACCOUNT_ID: ID, WEBHOOK_SECRET: SECRET } = process.env;
const MAGIC = "7001"; // marks this strategy's positions
const MAP = { "OANDA:EURUSD": "EURUSD.m", "OANDA:XAUUSD": "XAUUSD.m" }; // from /SymbolList, step 3
async function call(path, params = {}) {
const u = new URL(API + path);
for (const [k, v] of Object.entries({ id: ID, ...params })) if (v != null) u.searchParams.set(k, v);
const r = await fetch(u, { headers: { "x-api-key": KEY }, signal: AbortSignal.timeout(75000) });
if (!r.ok) throw new Error(path + " " + r.status + " " + (await r.text()));
return r.json();
}
async function handle(a) {
const symbol = MAP[a.symbol];
if (!symbol) throw new Error("unmapped symbol " + a.symbol); // never guess
const mine = (await call("/OpenedOrders")).filter((p) =>
p.symbol === symbol && String(p.expertId) === MAGIC && p.orderType <= 1); // 0 buy, 1 sell
const want = { long: 0, short: 1 }[a.position] ?? null; // null = flat
for (const p of mine.filter((p) => p.orderType !== want)) {
const c = await call("/OrderClose", { ticket: p.ticket });
console.log("close", p.ticket, c.state);
}
if (want === null || mine.some((p) => p.orderType === want)) return; // flat, or already in
const o = await call("/OrderSend", {
symbol, operation: want === 0 ? "Buy" : "Sell", volume: a.lots, magic: MAGIC,
stoploss: a.sl, takeprofit: a.tp, comment: ("tv " + (a.id ?? "")).slice(0, 31),
clientOrderId: ["tv", a.symbol, a.time, a.action].join("-").replace(/[^A-Za-z0-9_.:-]/g, "").slice(0, 64),
});
console.log("open", symbol, o.state, o.ticket); // 10009 = done, else refused
}
const ok = (s) => typeof s === "string" && s.length === SECRET.length && timingSafeEqual(Buffer.from(s), Buffer.from(SECRET));
let chain = Promise.resolve();
createServer(async (req, res) => {
let body = "";
for await (const chunk of req) body += chunk;
let a;
try { a = JSON.parse(body); } catch { res.writeHead(400); return res.end(); }
if (!ok(a.secret)) { res.writeHead(401); return res.end(); }
res.end("ok"); // reply inside TradingView's 3 s
chain = chain.then(() => handle(a)).catch((e) => console.error("alert failed:", e.message));
}).listen(8080, "127.0.0.1");
Run it with MT_API_KEY=… MT_ACCOUNT_ID=… WEBHOOK_SECRET=… node tv-webhook.mjs behind a reverse proxy that terminates HTTPS on port 443 (step 5).
What the pieces do:
- It replies before it trades. TradingView cancels a webhook request if your server takes longer than three seconds.
/OrderSendwaits for the broker's answer, which is usually far quicker than that but not always. So the handler answers200as soon as the secret checks out, then works the alert off a queue. The price of that: TradingView's alert log shows only that you received the alert, not whether the trade worked. Your own log (stateon every line) is the record. - One alert at a time.
chainruns alerts strictly in order, so an exit and the next entry on the same bar cannot overtake each other. - It makes the account match the strategy. Read your open positions, close any on the wrong side, open one on the right side if it is not there yet. A reversal (long to short) is a close plus an open. A flat alert only closes. An alert that arrives twice finds the work already done.
- The magic number scopes it. Only positions with
expertId7001 on that symbol are touched, so manual trades and other strategies on the same account are left alone. Give every strategy its own magic number. - A broker refusal is HTTP 200. The API returns
200whenever the broker answered, including when it said no. Readstate:10009means done; anything else is the broker's reason (on MT4, the MT4 error number). The orders reference lists the codes.
Step 3: symbol mapping
TradingView says OANDA:EURUSD. Your broker may call it EURUSD, EURUSD.m, EURUSDpro or EURUSD.a, and gold might be XAUUSD, GOLD or XAUUSDm. There is no universal rule, so build the map once from the account's own symbol list:
const names = await call("/SymbolList"); // every symbol name on this account
console.log(names.filter((n) => /^(EURUSD|XAUUSD|GOLD)/i.test(n)));
const p = await call("/SymbolParams", { symbol: "EURUSD.m" });
const g = p.symbolGroup; // volumes are lots x 10000
console.log("min", g.minVolume / 10000, "max", g.maxVolume / 10000, "step", g.volumeStep / 10000);
console.log("digits", p.symbolInfo.digits, "stops level (points)", p.symbolInfo.stopsLevel);
Then hard-code the result in MAP. The webhook throws on any symbol that is not in the map, and that is deliberate: an alert for a pair you never mapped should fail loudly in your log, not trade the closest match.
While you are there, check the volume rules. If your alert sends 0.10 lots and the account's step is 0.10 with a minimum of 0.10, you are fine. If the minimum is higher, the broker refuses the order with 10014 (invalid volume). Note that /Symbols returns only the number of symbols; the names come from /SymbolList.
Step 4: duplicates and idempotency
The same alert can reach your webhook more than once, or not at all. TradingView says webhooks "may occasionally fail to reach the specified URL"; you may re-trigger an alert by hand after a failure that in fact went through; you may run two copies of the webhook during a deploy. The recipe has two independent guards:
- State, not events. Before opening anything, the webhook reads
/OpenedOrdersand only opens a position when none with its magic number exists on that side. A second delivery of the same entry alert finds the position and does nothing. clientOrderIdon every/OrderSend. The id is built from the symbol, the bar time and the action, so the same alert always produces the same id. If two deliveries race each other past the first check (two webhook instances, for example), the API sends the order once and returns the original result to the second request, with the headerIdempotent-Replay: true. Ids are remembered per account for 24 hours.
The order comment carries the strategy's order id (tv Long, for example) so you can recognise the trade in your MetaTrader app. Don't build the duplicate check on the comment alone: MT5 keeps at most 31 characters, and some brokers overwrite comments, for example with [sl] when a stop loss is hit. The magic number is stored exactly as you send it.
If /OrderSend answers 409 IDEMPOTENCY_UNCERTAIN, the first attempt timed out after it reached the broker, and nobody can know yet whether it filled. The webhook logs the error and stops; the next alert reads /OpenedOrders and sees the truth. Close and modify calls are not de-duplicated by the API, which is one more reason the webhook decides from the current positions instead of replaying commands.
Step 5: security
Anyone who can reach your webhook URL can trade your account. Use both of these:
A secret in the message, compared in constant time. Generate a long random string (openssl rand -hex 32), put it in the alert message and in WEBHOOK_SECRET. TradingView's help page asks you not to put sensitive information such as login credentials or passwords in a webhook body, so treat the secret as what it is: a token that lets someone call this one endpoint. Never put your MetaTrader API API key or your MetaTrader password in an alert. If the secret leaks, change it in both places.
An IP allow-list. TradingView publishes the IP addresses its webhooks come from on its webhook help page. On 26 October 2026 the list was 52.89.214.238, 34.212.75.30, 54.218.53.128 and 52.32.178.7. Check that page before you deploy, because the list can change. Enforce it where the connection arrives, in your reverse proxy or firewall, for example in nginx:
location = /tv-webhook {
allow 52.89.214.238;
allow 34.212.75.30;
allow 54.218.53.128;
allow 52.32.178.7;
deny all;
proxy_pass http://127.0.0.1:8080;
}
Checking req.socket.remoteAddress inside Node only works when nothing sits in front of it; behind a proxy that address is the proxy's own.
Two more habits that cost nothing:
- Use an account-scoped key. A key from the dashboard works for every account in your login through
?id=. For the webhook, create a key withaccountScoped: truethroughPOST /v1/seats/{id}/keys(with a management key), so a leaked key can only reach this one account. The authentication docs explain both key types. - Bind the app to
127.0.0.1as in the code above, so only the proxy can reach it.
Step 6: exits, stops and targets
Strategy exits need no extra code. When strategy.close() or an exit order fills, {{strategy.market_position}} becomes flat and the webhook closes every position it owns on that symbol with /OrderClose?ticket=. Leaving out lots closes the whole position; send lots for a partial close.
Stops and targets at entry. Add "sl" and "tp" to the alert message. The usual way is {{strategy.order.alert_message}}, which carries whatever your Pine code passes as alert_message: with alert_message = str.tostring(stopPrice) in Pine, the message line "sl": {{strategy.order.alert_message}} becomes "sl": 1.0812. The webhook forwards them to /OrderSend as stoploss and takeprofit, absolute prices. Round them to the symbol's digits and keep them outside its stopsLevel, or the broker refuses the order with 10016 (invalid stops).
Moving stops later (trailing, break-even) is one more message type. Send an alert like {"secret":"…","symbol":"{{exchange}}:{{ticker}}","type":"modify","sl":1.0841} and add these lines to handle, right after mine is read:
if (a.type === "modify") {
for (const p of mine) await call("/OrderModify", { ticket: p.ticket, stoploss: a.sl, takeprofit: a.tp });
return;
}
/OrderModify keeps any value you leave out, so a message with only sl leaves the take profit where it is. Everything the webhook does shows up in your MetaTrader app, in /OpenedOrders and later in /OrderHistory.
Netting accounts. On an MT5 netting account there is one position per symbol, and /OrderClose on it closes it. The webhook works unchanged, as long as this strategy is the only thing trading that symbol on that account.
The same webhook in Python (Flask)
Same logic, same guards. pip install flask requests:
import hmac, os, queue, re, threading
import requests
from flask import Flask, request
API = "https://api.metatraderapi.net"
KEY, ID, SECRET = os.environ["MT_API_KEY"], os.environ["MT_ACCOUNT_ID"], os.environ["WEBHOOK_SECRET"]
MAGIC = 7001
MAP = {"OANDA:EURUSD": "EURUSD.m", "OANDA:XAUUSD": "XAUUSD.m"}
jobs = queue.Queue()
app = Flask(__name__)
def call(path, **params):
params = {"id": ID, **{k: v for k, v in params.items() if v is not None}}
r = requests.get(API + path, params=params, headers={"x-api-key": KEY}, timeout=75)
r.raise_for_status()
return r.json()
def handle(a):
symbol = MAP[a["symbol"]] # KeyError on an unmapped symbol: fail loudly
mine = [p for p in call("/OpenedOrders")
if p["symbol"] == symbol and int(p["expertId"]) == MAGIC and p["orderType"] <= 1]
want = {"long": 0, "short": 1}.get(a.get("position")) # None = flat
for p in mine:
if p["orderType"] != want:
print("close", p["ticket"], call("/OrderClose", ticket=p["ticket"])["state"])
if want is None or any(p["orderType"] == want for p in mine):
return
cid = re.sub(r"[^A-Za-z0-9_.:-]", "", "-".join(["tv", a["symbol"], a["time"], a["action"]]))[:64]
o = call("/OrderSend", symbol=symbol, operation="Buy" if want == 0 else "Sell",
volume=a["lots"], magic=MAGIC, stoploss=a.get("sl"), takeprofit=a.get("tp"),
comment=("tv " + a.get("id", ""))[:31], clientOrderId=cid)
print("open", symbol, o["state"], o["ticket"]) # 10009 = done, anything else = refused
def worker(): # one alert at a time, in order
while True:
a = jobs.get()
try:
handle(a)
except Exception as e:
print("alert failed:", e)
@app.post("/tv-webhook")
def hook():
a = request.get_json(force=True, silent=True) or {}
if not hmac.compare_digest(str(a.get("secret", "")).encode(), SECRET.encode()):
return "", 401
jobs.put(a)
return "ok" # reply inside TradingView's 3 seconds
threading.Thread(target=worker, daemon=True).start()
Serve it with a single process so there is one queue, for example PYTHONUNBUFFERED=1 gunicorn -w 1 --threads 4 -b 127.0.0.1:8080 tv_webhook:app, behind the same reverse proxy and allow-list. The modify branch from step 6 translates line for line.
Step 7: test on a demo account first
There is no sandbox: requests always go to a real broker account. That is a good thing, because the code you test is the code you run. Test with a demo account instead of your live one:
- Open a demo account with your broker and connect it like any other account. It uses a normal seat, and
/AccountSummaryreports"type": "demo". - Test the webhook without TradingView. On the server, post a fake alert straight to the app, bypassing the proxy and its allow-list:
curl -s -X POST http://127.0.0.1:8080/tv-webhook -H "content-type: application/json" \
-d '{"secret":"LONG_RANDOM_STRING","symbol":"OANDA:EURUSD","action":"buy","position":"long","id":"Long","time":"2026-10-26T14:00:00Z","lots":0.01}'
Send it twice: the second time the log should show nothing opened. Then send "position":"short" (one close, one open) and "position":"flat" (one close).
3. Test the refusal path. Send a volume below the symbol's minimum and check that the log shows state 10014 rather than "open".
4. Then connect TradingView. Put the strategy on a 1-minute chart so it trades often, create the alert, and watch both TradingView's alert log (its "Webhook status" column) and your own log.
5. Go live by switching MT_ACCOUNT_ID and the key to the live account. Nothing else changes.
How fast is it?
Three legs add up. TradingView's delivery time is outside your control and not published. Your webhook adds next to nothing. On our side, a trade operation took 80 to 106 ms in our measurements, including the round trip to the broker in those tests; the broker's own execution time varies by broker and dominates the total. In practice, alert to filled position usually takes under a second, and the webhook's reply to TradingView never waits for any of it.
Frequently asked questions
Do I need MetaTrader running on a VPS for this? No. The account is connected through the API, so nothing has to run on your side except the webhook itself. It needs a public HTTPS URL on port 443 (or plain HTTP on port 80) and very little memory.
Does this work with indicator alerts, not only strategies?
Yes. The {{strategy.*}} placeholders only exist on strategy alerts, so for an indicator create one alert per state and write the position into the message yourself, for example "position":"long" on the buy condition and "position":"flat" on the exit condition. Or build the whole JSON in Pine and send it with alert().
Why does TradingView show a failed webhook when the order was filled? Usually because your server took longer than three seconds to answer, so TradingView cancelled the request even though your code carried on. That is why the recipe replies first and trades afterwards.
Can one alert trade several MetaTrader accounts?
Yes: run handle once per account, each with its own account id and its own key, in parallel. For more than a handful of accounts, read executing one signal on hundreds of MT5 accounts, which covers per-account symbol names, lot sizing and rate limits.
Is it the same for MT4 and MT5?
Yes. The endpoints and response shapes are the same. On MT4 a refused order returns the MT4 error number in state instead of an MT5 return code; success is 10009 on both.
Is it safe to put a secret in the alert message? It is the standard way to authenticate TradingView webhooks, as long as the secret only unlocks your webhook. Never put the API key or your MetaTrader password in the message, and combine the secret with the IP allow-list.
What does it cost? One connected account uses one API seat: $14 per month, $13 per seat from the 5th seat and $12 from the 20th. The number of alerts and orders does not change the price. The webhook runs on your own server.
Related guides
- How to build a copy trading platform with the MT5 REST API
- Execute a trading signal on hundreds of MT5 accounts safely
- How to build an AI forex trading bot with Python and MT5
- MetaTrader API orders reference:
/OrderSend,clientOrderId, return codes, modify and close - MetaTrader API testing guide: demo accounts and safe test trades
- Full API reference
Ready to wire up your first alert? Create an account, connect your MT4 or MT5 account, and point your webhook at it. A seat costs $14 per month.
Ready to integrate the MetaTrader API?
REST, WebSocket and gRPC for MT4 and MT5. Pay per connected account.
Create account