TradingView Webhook Fired but No Alpaca Order: A Debugging Ladder
The alert triggered. The webhook went out. Your Alpaca account shows nothing. This failure is frustrating precisely because it is quiet — nothing crashed, nothing paged you, the position simply does not exist. Here is a systematic way to find out which of seven links in the chain broke, with the checks and code that prove each one.
Nobody owns this problem. TradingView's documentation ends the moment it sends the HTTP request. Alpaca's documentation begins the moment it receives a valid order. Everything between those two points is yours, and it is where almost all of the failures live. If you have not built the bridge yet, start with our walkthrough of turning TradingView alerts into Alpaca orders and come back here when it misbehaves.
The chain, and why order matters
Debug this from the top down, not by guessing. There are seven distinct places the trade can disappear, and the symptom looks identical from your account balance in every case:
1. The alert did not actually trigger
2. TradingView could not deliver the webhook
3. Your endpoint received it but could not read the body
4. The body was read but the values were not what you assumed
5. Alpaca rejected the order
6. Alpaca accepted the order but it never filled
7. Alpaca itself was degraded
Work down that list in order. Each rung has a cheap, definitive test, and skipping to rung five because "it must be the broker" is how people lose an afternoon.
Do all of this on a paper account. Every check below is safe to run against Alpaca paper, and several of them involve deliberately submitting orders you expect to fail. Do not debug against live money.
Rung 1: Did the alert actually trigger?
Open the alert log in TradingView — the Alerts panel, Log tab. If there is no entry, the webhook was never sent and nothing downstream matters. Four documented behaviours cause an alert to stop firing without telling you:
- Alerts expire. TradingView states that the maximum lifetime of a standard alert is two months, after which it stops automatically. An open-ended option is available on the Premium and Ultimate plans.
- Old alerts are deactivated. TradingView deactivates an alert that was created over a year ago, has not triggered for over a year, and has not been edited for over a year.
- There is a spam guard. TradingView documents that a script alert triggering more than 15 times within a three-minute window will automatically stop. A noisy indicator can therefore silence itself, permanently, in under three minutes.
- Alerts only trigger on the realtime bar. Historical bars never fire. This catches people who backtest, see forty entries, and expect the same density live.
Editing your script does not change a running alert. TradingView saves a mirror image of the script and its inputs when the alert is created, along with the chart's symbol and timeframe. Later changes to the script or the chart do not affect alerts already running. If you fixed your alert message template and nothing changed, this is why — you have to delete the alert and create a new one.
One more trap specific to strategies. Strategies are recalculated at the bar's close by
default, so alert() calls fire no more often than once at bar close unless you
enable calc_on_every_tick. If you expected intrabar entries and are getting one
per bar, that is a Pine setting rather than a webhook problem.
Rung 2: Did TradingView deliver it?
The alert log has a Webhook status column — the authoritative answer to "did it leave the building", and the most under-used field in this workflow. TradingView publishes a taxonomy of what those statuses mean. The ones that matter in practice:
| What you see | What it usually is |
|---|---|
| 4xx from your server | Endpoint path wrong, auth rejected, or your parser refused the body as invalid JSON |
| 5xx from your server | Your code threw. The alert is gone; your logs have the stack trace |
| Request took too long and timed out | You did not respond within three seconds. See below — this one is structural |
| This URL isn't allowed / couldn't find this domain | Typo, an unconfigured domain, or localhost / an internal IP |
| IP isn't allowed | Your domain resolves to a private or otherwise restricted address |
| Server responded over HTTP instead of HTTPS | TLS misconfiguration, or a redirect landing on plain HTTP |
| 3xx redirect | Your host redirects — often to an auth page, or from bare domain to www |
| Connection was closed due to inactivity | Long processing time on your side, again |
That 3xx row deserves emphasis. TradingView does not follow your redirect into a successful
delivery — it records the redirect as the outcome. A webhook URL that quietly 301s because
you moved to www looks fine in a browser and is dead as a webhook target.
The three-second rule is an architecture constraint
TradingView states that if a remote server takes longer than three seconds to process a request, the request will be cancelled. People read this as a performance note. It is a design constraint, and the root cause of a large share of intermittent, unreproducible failures.
Submitting an order to a broker involves at least one network round trip, sometimes several if you look up the asset or check buying power first. On a good day that is 200ms. On a bad day, during a volatile open, it is four seconds — and then TradingView cancels the request while your code carries on and places the order anyway. You now have an alert log showing a timeout and a broker account showing a fill. If you "fix" the timeout by re-firing the alert, you get two positions.
Acknowledge first, trade second. Validate the payload, write it to a queue or a table, return 200 immediately, and place the order in a separate worker. A webhook receiver that talks to a broker synchronously is a receiver that will time out eventually, and timeouts on a trading path do not fail cleanly.
Reachability constraints worth checking once
- Only ports 80 and 443 are accepted. Requests to any other port are rejected outright, so a service you are running on
:8080is not reachable. - IPv6 is not currently supported for webhooks. An IPv6-only endpoint will never receive anything, and this failure is invisible if you only ever tested from your own machine.
- Private and internal addresses are refused. No
localhost, no192.168.x.x, no split-horizon DNS pointing at an internal box. -
Requests come from a published set of addresses. At the time of writing:
52.89.214.238,34.212.75.30,54.218.53.128,52.32.178.7. Useful for firewall rules — and worth re-checking, because an allow-list that goes stale produces exactly this silent failure.
Rung 3: Did your endpoint read the body?
This is a frequent cause of "it works in Postman but not from TradingView", and it is a
genuinely non-obvious mechanic. TradingView's documentation states it directly: if the
alert message is valid JSON, the request includes an application/json
content-type header; otherwise the request uses text/plain.
The consequence is severe. Your alert message is a free-text box: a trailing comma, a smart
quote pasted from a document, an unquoted placeholder that resolves to a word, or a Pine
value rendering as NaN all make the body invalid JSON. TradingView then sends
it as text/plain, and every strict JSON body parser in common use hands your
handler nothing at all.
- Flask:
request.get_json()returnsNone - Express with
express.json():req.bodyis an empty object - FastAPI with a Pydantic model: 422 before your code runs
Your handler then logs "bad payload", returns 400, and you spend an hour testing the same
JSON in Postman — where it works perfectly, because Postman sets
Content-Type: application/json for you.
The fix is to stop trusting the header and parse the raw body yourself, while logging what actually arrived:
import json
import logging
from flask import Flask, request, jsonify
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
@app.post("/webhook")
def webhook():
raw = request.get_data(as_text=True)
# Log this before anything else. It is the evidence you will need.
logging.info("content-type=%s len=%s body=%r",
request.headers.get("Content-Type"), len(raw), raw)
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
logging.error("Body was not valid JSON at char %s: %s", exc.pos, exc.msg)
# Still return 200 so TradingView does not record a delivery failure
# that masks the real problem, then alert yourself out of band.
return jsonify(status="rejected", reason="invalid json"), 200
logging.info("Parsed: %s", payload)
return jsonify(status="ok"), 200
Returning 200 on a malformed body is deliberate. A 4xx makes the alert log say "your server rejected it", sending you to look at TradingView. A 200 plus your own out-of-band alert tells you the truth: the message arrived and your template is broken.
Deploy that handler, fire the alert once, and read the log line. You now know whether the request arrived and exactly what bytes it contained. Most debugging sessions end here.
Rung 4: Were the values what you assumed?
The body arrived and parsed. That does not mean the fields mean what you think. Three documented placeholder behaviours cause orders to be built wrongly:
-
{{ticker}}is TradingView's symbol, not Alpaca's. TradingView documents the output format asAAPL,BTCUSDand so on. Alpaca's current crypto symbology uses a slash:BTC/USD. The two vendors disagree, and you are the translation layer. -
{{strategy.*}}placeholders only work in strategy alerts. Put{{strategy.order.action}}in an alert created on an indicator and the literal text arrives, unsubstituted. Your parser then sees the string{{strategy.order.action}}where it expectedbuy. -
{{interval}}can return1. TradingView notes that for technical reasons this placeholder returns "1" in some cases rather than the chart's timeframe. If you route or size by timeframe, do not trust it blindly.
Add an explicit normalisation step and a hard failure when something does not match, rather than passing a plausible-looking string to the broker:
CRYPTO_QUOTES = ("USDT", "USDC", "USD", "BTC")
def to_alpaca_symbol(tv_symbol, asset_class):
"""TradingView sends BTCUSD; Alpaca's canonical form is BTC/USD."""
symbol = tv_symbol.strip().upper()
# Strip an exchange prefix if the template used {{exchange}}:{{ticker}}
if ":" in symbol:
symbol = symbol.split(":", 1)[1]
if asset_class != "crypto" or "/" in symbol:
return symbol
for quote in CRYPTO_QUOTES:
if symbol.endswith(quote) and len(symbol) != len(quote):
base = symbol[: -len(quote)]
return base + "/" + quote
raise ValueError("Unrecognised crypto symbol: " + tv_symbol)
def validate_action(action):
if action not in ("buy", "sell"):
# Catches an unsubstituted {{strategy.order.action}} placeholder
raise ValueError("Unusable action from alert: " + repr(action))
return action
Alpaca does accept the legacy BTCUSD form on the orders endpoint for backwards
compatibility, which is exactly why this bug hides so well: some paths work, others do not.
Note the inversion on the assets endpoint, where the documentation says to use the old
symbology and a slash must be URL-encoded — /v2/assets/BTC%2FUSDT.
Rung 5: Did Alpaca reject it?
Now we are on the broker's side. The cardinal rule: log the full response body, not the status code. Alpaca's rejections are specific and readable, and most DIY bridges throw that information away.
import requests
resp = requests.post(
ALPACA_BASE + "/orders", json=order, headers=ALPACA_HEADERS, timeout=10
)
if resp.status_code != 200:
logging.error(
"Alpaca rejected order: status=%s request_id=%s body=%s payload=%s",
resp.status_code,
resp.headers.get("X-Request-ID"),
resp.text,
order,
)
Capture X-Request-ID. Alpaca returns it on every response and asks you to
supply it with support requests, and it cannot be queried after the fact — if you did not
persist it, it is gone.
The rejections you will actually hit
Wrong environment. Paper is paper-api.alpaca.markets, live is
api.alpaca.markets, and the credentials are not interchangeable — Alpaca
states you cannot use a live account's credentials against the paper API or the reverse.
The result is a 403 that looks like an outage. Market data lives on
data.alpaca.markets for both. And resetting a paper account invalidates its
credentials, which produces a bridge that worked yesterday and returns 403 today.
Insufficient buying power. If your sizing logic and your account balance ever disagree, this is the rejection you will meet. It comes back as HTTP 403 with a body like:
{ "buying_power": "173.84",
"code": 40310000,
"cost_basis": "536.97",
"message": "insufficient buying power" }
It is completely diagnostic — provided somebody reads it. That example comes from a public thread on Alpaca's own forum, posted by a user whose TradingView alerts fired correctly and whose orders never appeared.
Crypto rules differ from equities. Alpaca documents crypto as supporting
market, limit and stop_limit order types only, with
time_in_force restricted to gtc or ioc. A bridge that
hard-codes "time_in_force": "day" — the natural choice for stocks — fails on
every crypto order. Crypto also cannot be bought on margin or sold short, and is assessed
against non-marginable buying power, so the balance you think you have may not be the
balance that counts.
Fractional constraints. time_in_force must be
day. Sending both qty and notional is rejected with
a 400. A non-fractionable asset returns "requested asset is not fractionable", so check the
fractionable flag rather than assuming. Alpaca's own pages disagree on which
order types fractional supports — the fractional-trading page lists market, limit, stop and
stop-limit, while the Trading API overview still says market orders only. Treat
day as mandatory and test your order type on paper.
Self-trade prevention. If a new order would trade against your own resting order, Alpaca rejects it with a 403. This surprises people running two alerts on the same symbol in opposite directions, and it applies on paper too.
Rate limiting. Every response carries
X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset; exceeding your quota returns 429. Read those headers rather
than hard-coding a number — Alpaca no longer publishes a fixed figure for the trading API,
and the number quoted around forums is years old. Back off exponentially with jitter, and
never blind-retry an order submission.
Rung 6: Accepted, but never filled
You got a success response and an order ID, and there is still no position. Accepted is not filled, and the gap between them has one dominant explanation for equities:
Orders not eligible for extended hours submitted after 4:00pm ET are queued for
release the next trading day. They are not rejected. Your code sees success, the
order sits in accepted overnight, and it executes at tomorrow's open at a
price that has nothing to do with the alert that produced it.
If you genuinely want to trade outside the regular session, Alpaca requires you to set
extended_hours: true, and the order must be a limit order with
time_in_force of day or gtc. Any other type or TIF is
rejected. Bracket orders cannot use extended hours at all. The current sessions are
pre-market 4:00am–9:30am ET, after-hours 4:00pm–8:00pm ET, and overnight 8:00pm–4:00am ET
Sunday to Friday.
So always read the order back rather than trusting the submission response:
order_id = resp.json()["id"]
check = requests.get(
ALPACA_BASE + "/orders/" + order_id, headers=ALPACA_HEADERS, timeout=10
).json()
logging.info(
"order=%s status=%s filled_qty=%s filled_avg_price=%s",
order_id, check["status"], check["filled_qty"], check["filled_avg_price"],
)
A status of accepted, new or pending_new minutes
after a market order means something is holding it. That is information you want in an
email, not in a log file nobody opens.
Two paper-specific behaviours matter before you conclude your logic is broken: Alpaca's paper environment gives partial fills at a random size roughly 10% of the time, and it does not check order quantity against available liquidity. Paper proves your plumbing works. It is not a faithful simulation of execution.
Rung 7: Is it Alpaca?
Sometimes it genuinely is the broker, and you should check status.alpaca.markets before rewriting your code. Real incidents look exactly like your bug. On 31 August 2026, Alpaca's status page reported that order creation and account detail requests were returning 5xx errors intermittently, traced to a brief database issue and resolved the same morning. Anyone debugging a webhook that day would have found nothing wrong with it.
The distinction matters because the two causes demand opposite responses. If your order is wrong, retrying is pointless. If the broker is degraded, retrying is dangerous — during a partial outage a request can succeed at the broker while failing to reach you, and a blind retry loop is how one intended position becomes three. Halt, report what was not done, and let a human decide.
A ten-minute triage
- Alert log — is there an entry at all? If not, the problem is rung 1.
- Webhook status column — delivered, or an error string? If an error, rung 2.
- Your endpoint's log — did a request arrive, and what were the content type and raw bytes? If nothing arrived, back to rung 2.
- Did it parse? If not, your alert message is not valid JSON — rung 3.
- Print the parsed values. Placeholders unsubstituted, or a symbol in the wrong format? Rung 4.
- Alpaca's response body and
X-Request-ID— is there a rejection message? Rung 5. - Read the order back by ID. Is it queued rather than filled? Rung 6.
- Check the status page. Rung 7.
Do this once with instrumentation in place and you will rarely need it again, because the instrumentation is the actual deliverable. This failure is common not because any individual step is hard, but because the default build has no visibility at any of the seven points, so every failure presents identically as silence.
Build the diagnostic endpoint before you need it
Keep a second route deployed permanently that only records what arrived. When an alert misbehaves, repoint it there for one firing and you have the raw evidence in seconds.
@app.post("/echo")
def echo():
logging.info("headers=%s", dict(request.headers))
logging.info("raw=%r", request.get_data(as_text=True))
return jsonify(status="ok"), 200
Pair it with an out-of-band notification — an email, or a message to your phone — on every rejection and every parse failure. An automation that cannot tell you it failed is worse than no automation, because you will believe a position exists when it does not, and size your next trade accordingly.
Or let someone else own the seven rungs
Everything above is work you do once and then maintain forever. TradingMixer receives your TradingView or TrendSpider alert and places the corresponding order in your own Alpaca account, with the diagnostics in this article built in: fast acknowledgement that avoids the three-second timeout, symbol normalisation between the two vendors, the full broker rejection emailed to you rather than buried in a log, duplicate protection, and halt-and-report behaviour when Alpaca is degraded.
It is non-custodial. Your money stays at your broker, we hold no funds, and we do not decide what you trade — you keep your own strategy and your own alerts, and we run the plumbing between them. One plan, $9/month, stocks and crypto, paper or live. New positions are rate-limited to one per webhook per 15 minutes, which suits swing and position trading on hourly-or-slower charts; orders that close a position are never rate-limited.
It is not for you if you are scalping sub-minute timeframes, need a broker other than Alpaca, trade options, or want more than three webhooks or five broker accounts. It is also the wrong choice if your entry logic needs custom code between the alert and the order — in that case, build it, and use this article as your checklist.
Start a 30-day free trialNo credit card required. Works with an Alpaca paper account, so you can watch it run before risking anything.
One last thing
Trading involves substantial risk of loss, and automating it does not reduce that risk. An automated strategy can lose money faster and more consistently than a manual one, because it does not hesitate and it does not get bored. Fix the plumbing described here on a paper account, watch it handle a rejection, a queued order and an outage, and only then point it at money you can afford to lose.