# TradingView webhook example in Python: a tested receiver

URL: https://fincodelabs.com/en/blog/tradingview-webhook-python · Publisher: Fincode (Fincode Yazılım ve Teknoloji A.Ş.), Istanbul, Turkey · Category: TradingView automation · Published: 2026-11-05 · Updated: 2026-11-05

TradingView webhook example in Python: a tested FastAPI server with secret check, schema validation, duplicate filter, quick reply, JSON logs and dry-run mode.

**Short answer:** A **TradingView webhook Python** receiver is a small web service that accepts the HTTP POST TradingView sends when an alert fires. It checks a shared secret and the message schema, records the alert once and replies inside the 3-second limit, leaving any trading to a background worker. The FastAPI example here adds an SQLite duplicate filter and queue, JSON logs and a dry-run mode that places no order; it was run locally on 4 and 5 November 2026 with synthetic alerts.

This guide gives you a complete server with a test client, output and failure drills. The route from alert to exchange order is in [TradingView webhook bot](https://fincodelabs.com/en/blog/tradingview-webhook-bot), and message templates are in [TradingView alert message JSON](https://fincodelabs.com/en/blog/tradingview-alert-message-json).

The code ran on Python 3.11 on one local machine, with FastAPI 0.115, pydantic 2.9 and uvicorn 0.30, and again with the versions pip installed that day: FastAPI 0.162, pydantic 2.14 and uvicorn 0.54. After a field rename it ran again on the newer set on 5 November 2026. It was never connected to TradingView or an exchange, and it contains no exchange code.

## Does TradingView have an API?

TradingView has no general public API for market data or orders; alerts reach your code through outbound webhooks.

**TradingView's interfaces, per its own pages on 4 November 2026**

| Interface | What it does | Who it is for |
| --- | --- | --- |
| Webhook alerts | Posts the alert message to your URL | Paid plans with two-factor authentication |
| Data API | None; the Help Center says no API gives access to data | Nobody |
| Broker REST API | Puts a broker on TradingView's trading platform; specification only after signing an agreement | Brokers |
| Chart libraries | Charts for your product; no market data included | Companies; Lightweight Charts is open source |
| Widgets | Embeds showing TradingView's data only | Website owners |
| MCP Server (beta) | AI clients read data and manage simple alerts; no webhook alerts, no order tools listed | Essential plan and above |

So a TradingView webhook API is not an endpoint you call: you publish the endpoint and TradingView calls you.

TradingView's [Terms of Use](https://www.tradingview.com/policies/), read on 5 November 2026, describe alerts and webhooks as licensed for display use and prohibit using them for automated trading, including through third-party products or services. Read those terms and take legal advice before relying on this route; it is your agreement with TradingView.

## What must a TradingView webhook server do?

A TradingView webhook server must answer fast, tolerate repeats and trust nothing in the request until it has been checked.

**TradingView's rules (Help Center, read 4 November 2026) and this server's response**

| TradingView rule | What this server does |
| --- | --- |
| Cancels the request after 3 seconds | Stores the alert and replies; a worker thread does the rest |
| Resends after a 500 to 599 reply, except 504, after 5 seconds, up to 3 times | Filters on alert_id; replies 503 only when storing fails |
| Sends application/json only for valid JSON, otherwise text/plain | Parses the body itself; replies 400 to anything else |
| Warns against credentials in the body | Expects a separate random secret and never logs it |
| Limits alert() messages to 40,960 characters | Replies 413 to larger bodies |

## A TradingView webhook example in FastAPI

The service is one file: an endpoint that authenticates, validates and stores each alert, and a worker thread that acts on stored alerts.

It expects a message like this hypothetical template, from rules of your own; the symbol and size are examples, not recommendations:

```json
{"secret": "replace-with-a-long-random-string", "v": 1,
 "alert_id": "{{ticker}}-{{interval}}-{{time}}-buy", "symbol": "{{ticker}}",
 "side": "buy", "qty": "0.01", "sent_at": "{{timenow}}"}
```

Keep the side in alert_id. A buy alert and a sell alert on one bar would otherwise share an ID, and the second would be dropped as a duplicate.

```python
import asyncio, hmac, json, logging, os, sqlite3, threading, time
from contextlib import asynccontextmanager
from decimal import Decimal
from typing import Literal

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, ValidationError

SECRET = os.environ["TV_WEBHOOK_SECRET"].encode()
if not SECRET:                                     # empty would pass a missing secret
    raise SystemExit("TV_WEBHOOK_SECRET is empty")
DB = os.environ.get("TV_DB", "alerts.db")
DRY_RUN = not os.environ.get("DRY_RUN", "1") == "0"    # orders only with an explicit DRY_RUN=0
MAX_BODY, MAX_AGE_S = 40_960, 60
wake = threading.Event()
logging.basicConfig(level=logging.INFO, format="%(message)s")

def log(event, **fields):
    logging.info(json.dumps({"event": event, **fields}))

class Alert(BaseModel):
    model_config = ConfigDict(extra="forbid")
    v: Literal[1]
    alert_id: str = Field(min_length=1, max_length=128)
    symbol: Literal["BTCUSDT", "ETHUSDT"]          # your allowlist
    side: Literal["buy", "sell"]
    qty: Decimal = Field(gt=0, allow_inf_nan=False)
    sent_at: AwareDatetime

def connect():
    con = sqlite3.connect(DB, timeout=1)           # fail fast, well inside 3 seconds
    con.execute("PRAGMA journal_mode=WAL")
    con.execute("CREATE TABLE IF NOT EXISTS alerts (alert_id TEXT PRIMARY KEY, "
                "payload TEXT, received REAL, status TEXT DEFAULT 'queued')")
    return con

def store(alert):                                  # the primary key is the duplicate filter
    con = connect()
    try:
        with con:
            cur = con.execute("INSERT OR IGNORE INTO alerts (alert_id, payload, received) "
                              "VALUES (?, ?, ?)", (alert.alert_id, alert.model_dump_json(), time.time()))
        return cur.rowcount == 1
    finally:
        con.close()

def place_order(alert):
    raise NotImplementedError("add your own exchange client here, tested on a testnet first")

def process(con, alert_id, payload, received):
    alert = json.loads(payload)
    if time.time() - received > MAX_AGE_S:
        status = "expired"                         # after downtime: never trade old alerts
    elif DRY_RUN:
        status = "dry_run"                         # logs the intent, sends nothing
    else:
        with con:                                  # a crash here leaves 'sending' for a human
            con.execute("UPDATE alerts SET status = 'sending' WHERE alert_id = ?", (alert_id,))
        try:
            place_order(alert)
            status = "sent"
        except Exception as exc:
            log("order_error", alert_id=alert_id, error=repr(exc))
            status = "failed"                      # no blind retry: reconcile first
    with con:
        con.execute("UPDATE alerts SET status = ? WHERE alert_id = ?", (status, alert_id))
    log("alert_done", status=status, **alert)

def worker():
    con = connect()
    while True:
        try:
            for row in con.execute("SELECT alert_id, payload, received FROM alerts "
                                   "WHERE status = 'queued' ORDER BY received").fetchall():
                process(con, *row)
        except sqlite3.Error as exc:
            log("worker_error", error=repr(exc))
        wake.wait(1)
        wake.clear()

@asynccontextmanager
async def lifespan(_):
    connect().close()
    threading.Thread(target=worker, daemon=True).start()
    yield

app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)

@app.post("/tv-hook")
async def tv_hook(request: Request):
    start = time.perf_counter()

    def reply(code, result, alert_id=None):
        log("webhook", http=code, result=result, alert_id=alert_id, ip=request.client.host,
            ms=round((time.perf_counter() - start) * 1000, 1))
        return JSONResponse({"result": result}, status_code=code)

    body = await request.body()
    if len(body) > MAX_BODY:
        return reply(413, "too_large")
    try:
        msg = json.loads(body)
    except (ValueError, RecursionError):
        return reply(400, "not_json")              # a broken template arrives as text/plain
    if not isinstance(msg, dict) or not hmac.compare_digest(
            str(msg.pop("secret", "")).encode(errors="replace"), SECRET):
        return reply(401, "bad_secret")
    try:
        alert = Alert.model_validate(msg)
    except ValidationError:
        return reply(422, "bad_schema")
    if abs(time.time() - alert.sent_at.timestamp()) > MAX_AGE_S:
        return reply(422, "stale", alert.alert_id)
    try:
        is_new = await asyncio.to_thread(store, alert)
    except sqlite3.Error:
        return reply(503, "storage_error", alert.alert_id)   # TradingView resends after 5 s
    wake.set()
    return reply(200, "queued" if is_new else "duplicate", alert.alert_id)
```

The design choices that matter:

- **One table, two jobs.** The row is both duplicate filter and queue, so an acknowledged alert survives a restart. SQLite's INSERT OR IGNORE skips a primary key conflict without an error.
- **Old alerts expire.** After downtime, rows older than 60 seconds are marked expired. That window is a placeholder to set yourself.
- **No blind retries.** A row stays in sending if the process dies mid-order, and a failed order stays failed, so a person checks the exchange first.
- **One process.** Each uvicorn worker would start its own thread, and two could pick up the same row.

## How to test the server locally with synthetic payloads

Save the service as receiver.py, start it in dry-run mode and post made-up alerts to it, because TradingView itself cannot reach localhost.

```bash
pip install fastapi uvicorn requests
export TV_WEBHOOK_SECRET=local-test-secret
uvicorn receiver:app --host 127.0.0.1 --port 8000 --workers 1 --no-access-log
```

In a second terminal with the same secret, run this client:

```python
import json, os
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone

import requests

URL = "http://127.0.0.1:8000/tv-hook"

def stamp(age=0):
    t = datetime.now(timezone.utc) - timedelta(seconds=age)
    return t.strftime("%Y-%m-%dT%H:%M:%SZ")

def post(body):
    r = requests.post(URL, data=body, timeout=3)
    return r.status_code, r.json()["result"]

good = {"secret": os.environ["TV_WEBHOOK_SECRET"], "v": 1, "alert_id": "BTCUSDT-60-" + stamp() + "-buy",
        "symbol": "BTCUSDT", "side": "buy", "qty": "0.01", "sent_at": stamp()}   # synthetic

with ThreadPoolExecutor(4) as pool:                # one alert delivered four times at once
    print(sorted(pool.map(post, [json.dumps(good)] * 4)))
for change in ({"secret": "guess"}, {"symbol": "DOGEUSDT"}, {"qty": "0"}, {"sent_at": stamp(120)}):
    print(list(change), post(json.dumps({**good, **change})))
print("plain text", post("BTCUSDT crossed up"))
```

Output on 5 November 2026:

```
[(200, 'duplicate'), (200, 'duplicate'), (200, 'duplicate'), (200, 'queued')]
['secret'] (401, 'bad_secret')
['symbol'] (422, 'bad_schema')
['qty'] (422, 'bad_schema')
['sent_at'] (422, 'stale')
plain text (400, 'not_json')
```

Four simultaneous deliveries produced one queued alert and three duplicates, and every bad message was refused before storage. We also ran failure drills:

**Failure drills, one local machine, 4 November 2026, repeated on 5 November 2026**

| Drill | Result |
| --- | --- |
| Database locked by another connection | 503 after 1.2 to 1.6 seconds; the resend was queued |
| Buy and sell alerts sharing one alert_id | Second reply is duplicate, nothing queued |
| Body of 40,000 opening brackets | 400 not_json, no crash |
| Secret with an unpaired Unicode surrogate | 401 bad_secret, no crash |
| Empty TV_WEBHOOK_SECRET | Server refuses to start |
| DRY_RUN=true or empty | Stays in dry run |
| Restart with a queued row 10 minutes old | Marked expired, nothing sent |
| Restart with a row left in sending | Left untouched |
| DRY_RUN=0 with no exchange client | One order_error, status failed, no retry |
| 200 new alerts in sequence | On 4 November, median reply 16 to 21 ms and maximum under 75 ms across runs; on a busier machine on 5 November, median 21 to 45 ms and maximum 56 to 546 ms; without TLS or a network |

> **Hypothetical example.** An alert arrives while the disk is busy. The server replies 503, TradingView resends 5 seconds later, the row is written and the reply is 200. One order intent exists throughout.

## Structured logs and dry-run mode

Each request writes one JSON line, and in dry-run mode the worker logs what it would do and stops there.

```json
{"event": "webhook", "http": 200, "result": "queued", "alert_id": "BTCUSDT-60-2026-11-05T06:03:39Z-buy", "ip": "127.0.0.1", "ms": 197.3}
{"event": "alert_done", "status": "dry_run", "v": 1, "alert_id": "BTCUSDT-60-2026-11-05T06:03:39Z-buy", "symbol": "BTCUSDT", "side": "buy", "qty": "0.01", "sent_at": "2026-11-05T06:03:39Z"}
```

The secret is removed before validation and never reaches a log call. Orders need an explicit DRY_RUN=0 and an exchange client you add yourself; until then place_order deliberately raises an error. Watch for replies other than 200, any order_error, rows stuck in sending and long silences, and compare your log with the alert log, as described in [TradingView alerts not working](https://fincodelabs.com/en/blog/tradingview-alerts-not-working).

## What is the TradingView webhook URL?

The TradingView webhook URL is the public address of your server, such as a hypothetical https://hooks.example.com/tv-hook, pasted into the Webhook URL option in the alert's notification settings.

- **Public host.** TradingView's error page lists localhost and internal IP addresses as invalid, and only ports 80 and 443 are accepted, with no IPv6 (read 4 November 2026).
- **TLS.** Keep uvicorn on 127.0.0.1 behind a reverse proxy that terminates HTTPS on port 443, because plain HTTP would expose the secret. In our test uvicorn took the client address from X-Forwarded-For on a local connection, so the ip field shows what the proxy reports. Cap the body size at the proxy too, because the 413 check runs after the body is read.
- **Source filter.** At the proxy or firewall, allow only 52.89.214.238, 34.212.75.30, 54.218.53.128 and 52.32.178.7, the addresses in TradingView's Help Center on 4 November 2026; recheck the list from time to time.

Send the first real alert with dry run still on. Our tests stopped short of that step, so check both logs yourself.

## Before you add an exchange client

A clean dry run proves the plumbing, not the strategy or the exchange code. TradingView's Help Center also says [its alerts are not designed for automated trading](https://www.tradingview.com/support/solutions/43000722015-using-credentials-for-webhooks/) (read 4 November 2026).

- **Your own alerts.** Drive the service from rules you wrote, on your own account; paid third-party signal feeds raise legal questions and are outside this guide.
- **Exchange side.** Write place_order against a testnet, with a client order ID derived from alert_id; [connecting TradingView to exchanges](https://fincodelabs.com/en/blog/connect-tradingview-to-exchanges) shows what differs per platform.
- **Keys and limits.** Keep trade-only keys on the server ([API key security](https://fincodelabs.com/en/blog/api-key-security-trading-bots)) and put size and loss limits before the exchange call ([risk management](https://fincodelabs.com/en/blog/trading-bot-risk-management)).
- **Jurisdiction.** Rules depend on where you live and the platform's terms. In Turkey, Article 99/A of Capital Markets Law No. 6362 treats activity by foreign-based crypto-asset platforms directed at Turkish residents as unauthorized service provision.

Automated orders can lose money, and backtests do not guarantee future results. Commissioned builds of this kind are the scope of the [TradingView automation](https://fincodelabs.com/en/services/tradingview-automation) service, which works from alerts in the customer's own account and supplies no strategies or signals.

## Frequently asked questions

### Can I receive TradingView webhooks on localhost?

No. TradingView rejects localhost and internal addresses, so real alerts need a public host on port 80 or 443. Local tests use synthetic requests.

### Should I use Flask or FastAPI for a TradingView webhook server?

Either works, because the logic is the same. FastAPI includes pydantic for the schema check; a Flask version adds its own validation.

### What status code should a webhook receiver return?

Reply 200 for accepted alerts and duplicates, 4xx for rejected messages, and 5xx only when you want a resend, since TradingView resends after 500 to 599 except 504.

### Can I put my exchange API key in the alert message?

No. TradingView warns against credentials in the webhook body. The message carries a separate secret, and exchange keys stay on your server.

## Sources

- [TradingView Help Center: I need access to your API in order to get data or indicator values](https://www.tradingview.com/support/solutions/43000474413-i-need-access-to-your-api-in-order-to-get-data-or-indicator-values/)
- [TradingView: Brokerage integration](https://www.tradingview.com/brokerage-integration/)
- [TradingView Charting Library docs: Connecting data](https://www.tradingview.com/charting-library-docs/latest/connecting_data/)
- [TradingView: Charting libraries (licensing)](https://www.tradingview.com/free-charting-libraries/)
- [TradingView Widgets: Data FAQ](https://www.tradingview.com/widget-docs/faq/data/)
- [TradingView MCP Server documentation](https://www.tradingview.com/mcp/docs)
- [TradingView Help Center: How to configure webhook alerts](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/)
- [TradingView Help Center: Webhook resubmission](https://www.tradingview.com/support/solutions/43000735201-webhook-resubmission/)
- [TradingView Help Center: What do errors mean when sending webhooks?](https://www.tradingview.com/support/solutions/43000776894-what-do-errors-mean-when-sending-webhooks/)
- [TradingView Help Center: Alert name and message size limits](https://www.tradingview.com/support/solutions/43000773947-alert-name-and-message-size-limits/)
- [TradingView: Plans and pricing (webhook notifications by plan)](https://www.tradingview.com/pricing/)
- [FastAPI documentation: Lifespan events](https://fastapi.tiangolo.com/advanced/events/)
- [Pydantic documentation: Types (AwareDatetime)](https://pydantic.dev/docs/validation/latest/api/pydantic/types/)
- [SQLite documentation: The ON CONFLICT clause](https://www.sqlite.org/lang_conflict.html)
- [Capital Markets Law No. 6362, Article 99/A (Turkish)](https://www.mevzuat.gov.tr/mevzuatmetin/1.5.6362.pdf)

> This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the [editorial policy](https://fincodelabs.com/en/editorial-policy).

---

Published by Fincode (Fincode Yazılım ve Teknoloji A.Ş.), a software company in Istanbul, Turkey: https://fincodelabs.com/en/blog/tradingview-webhook-python

Cite as: Fincode, "TradingView webhook example in Python: a tested receiver", updated November 5, 2026, https://fincodelabs.com/en/blog/tradingview-webhook-python
