Duplicate Alpaca Orders from a TradingView Webhook: Making the Bridge Idempotent
You wanted one position. You have two. The alert looks correct in TradingView, the bridge looks correct in your logs, and the broker shows two fills a few seconds apart. This is one of the most common ways a working webhook bridge loses money, and the fix is not a retry setting — it is deciding, deliberately, what makes two requests "the same request".
Two orders from one intention
Duplicate orders are not one bug. They are a family of bugs that all arrive looking identical from the broker's side — two orders, same symbol, same side, seconds apart — and the cause determines the fix. It is worth separating them before writing any code, because three of them live on the chart and three live in your own infrastructure.
| Cause | Where it lives | What it looks like |
|---|---|---|
| Alert fires repeatedly within one bar | Chart | Several deliveries inside one candle, each with a different fire time |
| A condition true mid-bar, false at the close | Chart | An order you would never have taken, often reversed minutes later |
| Two alerts on the same condition | Chart | Exact duplicates, usually from editing a script and creating a second alert |
| Delivery ambiguity after a timeout | Network | One delivery in the alert log, two orders at the broker — or the reverse |
| Your own queue redelivering a job | Your bridge | Two orders from a single inbound request |
| A retry loop after a failed request | Your bridge | Several orders arriving together once the broker recovers |
The chart-side causes are a Pine Script and alert-configuration problem, and they are covered
properly in
Pine Script Strategy Alerts to
Alpaca — the alert frequency constants, the calc_on_every_tick override that
silently ignores the frequency you asked for, and TradingView's own position on repainting.
Fix those first. They are the cheapest fix available, and no amount of work on the receiving
end makes an intrabar condition trustworthy.
This article is about everything after that. Because even with a well-behaved alert that fires exactly once per bar close, you can still end up with two orders — and the reason is a property of the delivery itself.
The part you cannot fix from the chart
TradingView documents a hard limit on how long your server may take: "If a remote server takes longer than three seconds to process a request, the request will be cancelled." It also tells you that delivery is best-effort — "webhooks may occasionally fail to reach the specified URL" — and points you at the Webhook status column in the alert log to see what happened.
Now read what is not there. TradingView's webhook documentation and its page on webhook errors between them enumerate timeouts, DNS failures, TLS failures, refused connections and invalid responses. Neither states whether a failed request is retried. There is no published at-least-once or at-most-once guarantee either way.
This is the important bit, and it is an absence rather than a fact. Because the retry behaviour is undocumented, you cannot design around it. You cannot assume a cancelled request will be resent, so you cannot treat delivery as reliable. And you cannot assume it will not be resent, so you cannot treat delivery as unique. The only safe assumption is that the same instruction may arrive more than once, and that handling it is your responsibility.
That is uncomfortable, but it is also freeing, because it collapses six causes into one requirement. You do not need to know why a second identical request arrived. You need the second one to be harmless. That property has a name: idempotency. An endpoint is idempotent when handling the same request twice has the same effect as handling it once.
Note the knock-on effect of the three-second budget. Placing the order inline, inside the
request handler, risks exceeding it under load, and a cancelled request is a delivery
failure with nothing behind it. So the robust shape is: validate, acknowledge with a
200 immediately, and place the order outside the request cycle. That is correct
— and it introduces a second place where the same instruction can be processed twice, since
almost every queue worth using delivers at least once. The deduplication has to cover both.
Designing a key that actually holds
Idempotency needs a key: a short string that is identical for two deliveries of the same intention, and different for two deliveries of genuinely different intentions. Nearly every broken implementation gets one of those two halves wrong.
Why hashing the request body fails
The obvious move is to hash the raw POST body and remember the hash. It fails in both directions.
It is too strict if your payload contains anything that changes between
deliveries. Include the current price or the fire time and two deliveries of the same alert
hash differently, so the duplicate sails through. It is too loose in the
opposite case: a simple payload like {"symbol":"AAPL","action":"buy"} hashes
identically every time, so a legitimate second entry tomorrow is silently discarded as a
duplicate. Both failures are quiet. One costs you an unwanted position, the other costs you
a trade you meant to take.
A key has to be built from chosen fields, not from whatever happens to be in the body.
The placeholder choice that decides everything
TradingView offers two time placeholders, and the difference between them is the whole article. From TradingView's placeholder documentation:
-
{{time}}is the time of the bar on which the alert has been triggered, in UTC, formattedyyyy-MM-ddTHH:mm:ssZ— for example2019-08-27T09:56:00Z. -
{{timenow}}is the current fire time of the alert, to the nearest second, "regardless of the resolution".
So {{timenow}} is different on every delivery, including every repeat of the
same alert. It is useless as an idempotency key and actively harmful, because it makes
duplicates look unique. {{time}} is stable for every delivery relating to the
same bar, and changes when a genuinely new bar produces a genuinely new trigger. That is
exactly the property you want.
One documented wrinkle on granularity. TradingView notes that alerts on
indicators, non-standard charts and drawings depend on the resolution, while
"simple price alerts (e.g., price crossing some value) are always calculated on
1-minute bars". So on a plain price alert, {{time}} advances every
minute no matter what timeframe your chart is showing. Your key's granularity is the
alert's granularity, not the chart's.
So the alert message carries the fields the key is built from:
{
"strategy": "ema-cross-v3",
"symbol": "{{ticker}}",
"action": "{{strategy.order.action}}",
"qty": "{{strategy.order.contracts}}",
"bar_time": "{{time}}",
"secret": "a-long-random-string"
}
Because that message is valid JSON, TradingView sends it with an
application/json content-type — plain text otherwise, which is a common cause
of a parser rejecting the body outright. Include strategy explicitly: it keeps
two different scripts trading the same symbol on the same timeframe from collapsing into one
key and cancelling each other out.
A receiver that refuses to act twice
The key is a hash of the chosen fields, normalised so that trivial formatting differences do not produce a different key.
import hashlib
def idempotency_key(payload: dict) -> str:
parts = [
payload["strategy"],
payload["symbol"],
payload["action"],
payload["bar_time"],
]
raw = "|".join(str(p).strip().lower() for p in parts)
return hashlib.sha256(raw.encode()).hexdigest()[:32]
Now it needs somewhere to live. A Python set in module scope is the usual first
attempt, and it is wrong in two ways that only show up in production: it is lost on every
restart or redeploy, and it is not shared if anything ever runs more than one worker
process. A restart in the middle of a volatile session is precisely when you least want the
memory of what you already did to vanish.
SQLite is enough for a single-host bridge, and it gives you the one property that matters here for free.
import sqlite3, time
db = sqlite3.connect("seen.db", check_same_thread=False)
db.execute("""
CREATE TABLE IF NOT EXISTS seen (
key TEXT PRIMARY KEY,
claimed_at REAL NOT NULL,
status TEXT NOT NULL
)
""")
db.commit()
def claim(key: str) -> bool:
"""Return True only for the first caller to claim this key."""
try:
db.execute(
"INSERT INTO seen (key, claimed_at, status) VALUES (?, ?, 'in_flight')",
(key, time.time()),
)
db.commit()
return True
except sqlite3.IntegrityError:
return False
The detail that makes this correct is that the claim happens before the
order is placed, and it is a single atomic insert against a primary key. The naive version —
check whether the key exists, then place the order, then record the key — has a gap between
the check and the record. Two requests arriving a few milliseconds apart both find nothing,
both proceed, and you get the exact duplicate you were trying to prevent. Letting the
database enforce uniqueness closes the gap, because only one INSERT can
succeed.
Wiring it together:
import hmac
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/webhook")
def webhook():
payload = request.get_json(force=True, silent=True)
if not payload:
return jsonify(error="unparseable body"), 400
if not hmac.compare_digest(payload.get("secret", ""), SHARED_SECRET):
return jsonify(error="unauthorised"), 401
key = idempotency_key(payload)
if not claim(key):
# Already seen. Acknowledge so TradingView records a success,
# and do nothing else.
app.logger.info("duplicate ignored key=%s", key)
return jsonify(status="duplicate_ignored", key=key), 200
queue.enqueue(place_once, payload, key) # off the request path
return jsonify(status="accepted", key=key), 200
Returning 200 for the duplicate is deliberate. A non-2xx would show up as a
failed delivery in the alert log and send you hunting for a problem that does not exist. The
request was handled correctly; the correct handling was to do nothing.
Let the broker be the second line of defence
Everything above lives in your process. If that process is replaced, scaled out or restarted, a deduplication layer you own can lose its memory at the worst moment. Alpaca offers a backstop that survives all of that, and it is underused.
Every Alpaca order accepts a client_order_id — an identifier supplied by you
rather than by the broker, with a documented maximum length of 128 characters. Most bridges
either leave it empty or fill it with a random UUID, which wastes it. Make it
deterministic and it becomes a deduplication token that the broker holds on
your behalf:
client_order_id = f"tv-{idempotency_key(payload)}" # stable across retries
Alpaca also exposes GET /v2/orders:by_client_order_id, which looks an order up
by that identifier — get_order_by_client_id() in the Python SDK. That endpoint
is what turns a deterministic id into a real safety mechanism, because it lets you ask the
only authoritative question available: did this order already reach the broker?
Test the duplicate case yourself rather than trusting a blog. Alpaca
documents that the identifier is unique and documents its maximum length, but does not
publish the exact response when you submit the same client_order_id twice,
and does not state the scope or lifetime of that uniqueness. Submit one twice on a paper
account, record what comes back, and write your error handling against what you actually
observed. This article deliberately does not quote a status code for it.
The gap where retries do real damage
Here is the failure that justifies all of this. You POST an order to Alpaca. The connection times out. You have no response — and no response is not the same as no order. The request may have been received and executed, with only the reply lost.
The instinctive fix, a retry loop, is how one intended position becomes three. During a broker incident, blind retries queue up and all land together when service returns. Reconcile instead of retrying:
import requests
BASE = "https://paper-api.alpaca.markets"
HEADERS = {"APCA-API-KEY-ID": KEY, "APCA-API-SECRET-KEY": SECRET}
def order_exists(client_order_id: str) -> bool:
r = requests.get(
f"{BASE}/v2/orders:by_client_order_id",
params={"client_order_id": client_order_id},
headers=HEADERS, timeout=5,
)
return r.status_code == 200
def place_once(payload: dict, key: str):
coid = f"tv-{key}"
body = {
"symbol": payload["symbol"],
"qty": payload["qty"],
"side": payload["action"],
"type": "market",
"time_in_force": "day",
"client_order_id": coid,
}
try:
r = requests.post(f"{BASE}/v2/orders", json=body,
headers=HEADERS, timeout=5)
except requests.exceptions.RequestException:
# Ambiguous: the order may or may not exist. Ask, do not resend.
if order_exists(coid):
mark(key, "already_placed")
return
mark(key, "failed_unsent")
notify_human("Order status unknown after timeout", coid)
return
if r.status_code >= 400:
mark(key, "rejected")
notify_human(f"Alpaca rejected {coid}: {r.text}", coid)
return
mark(key, "placed")
Two things are worth noticing. The ambiguous branch asks the broker rather than guessing, which converts an unknown into a fact. And where it cannot establish a fact, it halts and tells a human instead of trying again. Fail safe, not fail open — an automation that cannot tell you it failed is worse than no automation, because you will act as though a position exists when it does not.
One more constraint to design around: Alpaca rejects orders it reads as wash trades, and
documents it plainly — "If we detect a possible wash trade, we reject the order and send
back an error message with the HTTP status code 403 (Forbidden)", a protection that
applies to crypto as well as equities. A duplicate that arrives on the opposite side of an
order you already have working is a realistic way to trip it. A 403 here is the
broker catching something your bridge should have caught.
The orders you must never deduplicate
This is the part that turns a safety feature into a hazard if you get it wrong, and it is
the reason the key includes action.
Never let deduplication or throttling swallow an exit. Suppressing a second entry protects you. Suppressing the order that closes a losing position is how a manageable loss becomes an unmanageable one. Classify every instruction as opening or closing, and apply suppression only to the opening side.
A closing instruction that arrives twice is usually harmless anyway: the second one finds a flat position and is rejected by the broker, which is a safe outcome. A closing instruction that is wrongly suppressed leaves you holding something you believed you had sold. The asymmetry is severe, and it should be reflected directly in the code.
Keep the distinction honest about what each control is for. Deduplication answers "have I already acted on this exact instruction?". It is not a frequency limit. If you also want "no more than one new position per hour whatever the chart says", that is a separate rate limit with a separate rule — and it, too, must exempt exits.
Proving it works
Duplicate handling is easy to test and almost nobody does, because the failure only appears when you are not watching. Point everything at an Alpaca paper account and run these.
-
Replay the identical body twice. Send the same JSON with
curltwo or three times in a row. Expect one order at the broker and aduplicate_ignoredresponse for the rest. -
Replay it concurrently. Fire several at once —
curl ... & curl ... & wait— to exercise the race the atomic claim exists to close. A check-then-insert implementation usually fails this one and passes the first test, which is why the first test alone is not enough. -
Change only
bar_time. This must produce a second order. If it does not, your key is too loose and you are discarding real triggers. -
Restart the bridge between two identical deliveries. This is the one that
exposes in-memory state. With SQLite and a deterministic
client_order_id, the duplicate is still caught. - Simulate the ambiguous timeout. Point the client at an address that blackholes the connection, confirm the reconciliation path runs, and confirm nothing is resent blindly.
- Send an exit twice. Confirm it is not suppressed by your own logic — rejection by the broker on a flat position is the correct outcome, silence is not.
Test two and test six are the ones that find real bugs. Everything else usually passes on the first attempt.
What this actually costs to own
Read back what the complete version requires: a durable store that survives restarts, an atomic claim, a queue so the three-second budget is never at risk, deduplication inside the worker as well as at the edge, deterministic broker-side identifiers, a reconciliation path for ambiguous failures, an opening-versus-closing classifier, and alerting for the cases where it gives up. Plus key rotation, hosting and monitoring for all of it.
None of that is difficult in isolation. The cost is that it is permanent. It is yours to patch when a dependency changes, and yours to debug at an inconvenient hour, in return for total control over the behaviour. For plenty of people that is the right trade, and the code above is a reasonable foundation — you now know what to add to it.
Or let someone else own the deduplication layer
TradingMixer receives your TradingView or TrendSpider alert and places the corresponding order in your own Alpaca account. It is non-custodial: your money stays with your broker, you keep your own strategy, and we do not decide what to trade. What we maintain is the plumbing described in this article — duplicate protection, the halt-and-report behaviour when the broker is unreachable, failure-alert emails and a kill switch.
One plan, $9/month: 3 webhooks, up to 5 Alpaca accounts, 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, for exactly the reason described above.
It is not for you if you are scalping sub-minute timeframes, need a broker other than Alpaca, trade options, or want to control the deduplication rules themselves — if the key design is something you want to own, build it.
Start a 30-day free trialNo credit card required. Works with an Alpaca paper account, so you can watch it handle a replayed alert before any money is involved.
The short version
- Fix the chart-side causes first. They are free to fix, and nothing downstream repairs an intrabar condition.
- TradingView does not document whether it retries a failed webhook, so assume the same instruction can arrive more than once.
- Build the key from chosen fields — strategy, symbol, action and
{{time}}. Never{{timenow}}, and never a hash of the whole body. - Claim the key atomically before placing the order, in storage that survives a restart.
- Send a deterministic
client_order_idso the broker can catch what you miss, and verify the duplicate behaviour yourself on paper. - After an ambiguous failure, reconcile with
GET /v2/orders:by_client_order_id. Never retry blindly. - Exempt closing orders from every suppression rule you write.
Worth stating plainly at the end: trading carries a substantial risk of loss, and automating it does not reduce that risk. Deduplication removes one specific failure — acting twice on a single instruction. It says nothing about whether the instruction was any good. Test on an Alpaca paper account until the boring cases are genuinely boring, and automate only a strategy you would have been willing to trade by hand.