Troubleshooting 9 September 2026 · About 13 minutes

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:

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

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.

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:

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

  1. Alert log — is there an entry at all? If not, the problem is rung 1.
  2. Webhook status column — delivered, or an error string? If an error, rung 2.
  3. Your endpoint's log — did a request arrive, and what were the content type and raw bytes? If nothing arrived, back to rung 2.
  4. Did it parse? If not, your alert message is not valid JSON — rung 3.
  5. Print the parsed values. Placeholders unsubstituted, or a symbol in the wrong format? Rung 4.
  6. Alpaca's response body and X-Request-ID — is there a rejection message? Rung 5.
  7. Read the order back by ID. Is it queued rather than filled? Rung 6.
  8. 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 trial

No 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.