Webhooks vs polling for trading apps
When to poll, when to take webhooks and when to stream: latency, rate limits, retries, duplicates, signature checks and the reconcile loop that catches what all three miss.
A trading app spends a lot of its life waiting for news. Did the order fill? Did the stop loss close the position? Did the account lose its broker connection? There are three ways to find out: ask again and again (polling), have the server call you (webhooks), or keep a line open and listen (streaming).
Each one fails in its own way. Polling is late and burns rate limit. Webhooks arrive twice, out of order, or occasionally not at all. Streams drop when the network blinks. A sturdy app picks the right tool for each job and keeps a slow safety net underneath.
This post compares the three for MetaTrader 4 (MT4) and MetaTrader 5 (MT5) apps, then shows a webhook receiver that verifies signatures and ignores duplicates.
Three ways to hear about a change
- Polling. Your code calls a read endpoint on a timer, say
GET /positionsevery five seconds, and compares the answer with the last one. - Webhooks. You register an HTTPS address. When something happens, the server sends an HTTP POST to it with a small JSON event. A webhook is a phone call to your server.
- Streaming. Your code opens one WebSocket (a long-lived, two-way connection) and the server pushes frames down it as things change.
| Polling | Webhooks | Streaming | |
|---|---|---|---|
| Who starts the call | You, on a timer | The server, on an event | You open it once, the server pushes |
| Delay | Up to one full interval | Seconds to a minute, longer while retrying | Lowest of the three |
| Cost against rate limits | Every call counts | Deliveries don’t count | Opening costs a little, frames are free |
| Needs a public HTTPS address | No | Yes | No |
| When your app is down | Nothing piles up; you read the state when you are back | Retried for a while, then lost | You reconnect, then resume or resync |
| Typical home | Scripts, notebooks, reconcile jobs | Alerts, back-office jobs, serverless functions | Trading logic, live dashboards |
Rule of thumb: a bot that reacts to prices should stream (see streaming MT4 and MT5 prices over WebSocket). A service that should react to rare events (an account disconnecting, a stop-out, a failed login) without running all day is a good fit for webhooks. Polling is the simplest of all and the right choice for scripts, reports and checking the truth now and then. Whatever you pick, keep a slow reconcile loop underneath, as shown at the end of this post.
What polling really costs
Polling is honest. Each answer is the current state, read directly. Its weaknesses are delay and volume.
Delay. With a 5 second poll, you learn about a fill on average 2.5 seconds after it happens and at worst 5 seconds after. Halve the interval and you double the calls.
Volume. Rate limits are usually a bucket of units that refills at a fixed speed. As a worked example with Tickwire’s limits: each key has a bucket of 100 units refilling at 50 per second, and each trading account has 40 refilling at 20 per second. A dashboard that reads positions, orders and account information once a second costs 3 units a second per account. That is fine for 10 accounts. For 20 accounts on one key it is 60 units a second, more than the key refills, so the dashboard starts getting 429 answers. History reads cost 5 units each, so polling history is the fastest way to run out.
Missed states. Polling sees snapshots, not changes. A position that opens and closes between two polls never shows up in the positions list. You only find it in the deal history.
If you poll, read the rate limit headers (RateLimit-Remaining, Retry-After), back off with jitter (a small random delay, so that many clients don’t retry at the same instant), and never let a polling loop share a budget with order placement.
What webhooks give you, and what they don’t
A webhook saves you the polling, but it brings delivery problems you have to handle yourself.
- At least once, not exactly once. If your server answers slowly or with an error, the sender tries again. If your server did the work but the reply got lost, you receive the same event twice.
- Out of order. A retry of an older event can arrive after a newer one. A
position.modifiedcan land after theposition.closedfor the same position. - Retries have a limit. Senders retry with growing gaps and then give up. If your receiver is broken for longer than that, events are gone.
- Some events can be lost at the source. If the system that produces them restarts or overflows its queue, events can be dropped before they are ever sent.
- Anyone can call your URL. It’s public. Without a signature check, a stranger can post a fake
order.filledto it.
None of this makes webhooks bad. It means a receiver must do four things: verify the signature, answer fast, ignore duplicates, and treat each event as a hint to update state rather than as the state itself.
A receiver that verifies and dedupes
Tickwire signs each delivery with HMAC-SHA256, a keyed hash that only someone holding your webhook secret can produce. The signature covers the X-Tickwire-Timestamp value, a dot, and the raw request body. X-Tickwire-Signatures carries one sha256=<hex> value for every active secret, comma-separated, which lets you rotate secrets without downtime. The event id in X-Tickwire-Event-Id stays the same on every retry and every resend, so it is the key to dedupe on.
This small Flask app checks all of that and stores each event once:
import hashlib, hmac, json, os, sqlite3, time
from flask import Flask, request
SECRET = os.environ["TW_WEBHOOK_SECRET"] # whsec_...
DB = "inbox.db"
TOLERANCE_S = 300 # reject old timestamps (replays)
app = Flask(__name__)
def init_db():
with sqlite3.connect(DB) as con:
con.execute("""CREATE TABLE IF NOT EXISTS inbox (
event_id TEXT PRIMARY KEY, type TEXT, created_at TEXT,
body TEXT, done INTEGER NOT NULL DEFAULT 0)""")
def verify(headers, raw_body: bytes) -> bool:
ts = headers.get("X-Tickwire-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE_S:
return False
expected = hmac.new(SECRET.encode(), f"{ts}.".encode() + raw_body,
hashlib.sha256).hexdigest()
given = headers.get("X-Tickwire-Signatures") or headers.get("X-Tickwire-Signature", "")
return any(hmac.compare_digest(expected, s.strip().removeprefix("sha256="))
for s in given.split(","))
@app.post("/hooks/tickwire")
def receive():
raw = request.get_data() # the raw bytes, before any parsing
if not verify(request.headers, raw):
return "", 401
event = json.loads(raw)
event_id = request.headers.get("X-Tickwire-Event-Id") or event["id"]
con = sqlite3.connect(DB)
try:
with con: # one transaction
con.execute("INSERT OR IGNORE INTO inbox (event_id, type, created_at, body) "
"VALUES (?, ?, ?, ?)",
(event_id, event["type"], event["created_at"], raw.decode()))
finally:
con.close()
return "", 204 # a duplicate also gets a 2xx
init_db()
A few details matter here:
- Verify the raw bytes. If you parse the JSON and serialise it again, spacing or key order can change and the signature no longer matches.
- Compare in constant time.
hmac.compare_digestdoesn’t leak how many characters matched. - Answer duplicates with a 2xx too. A
401or500for a duplicate only causes more retries. - Store first, work later. Tickwire wants a
2xxwithin 5 seconds and counts anything else as a failure to retry. A separate worker reads rows wheredone = 0and does the slow part: updating your database, messaging you, pausing a strategy.
To test on your own machine, expose the receiver over HTTPS with a tunnel, then send a webhook.test event from the dashboard.
Ordering, duplicates and idempotent handlers
The inbox stops the same event id from being processed twice. That’s necessary but not enough, because two different events can describe overlapping news. Make the worker’s handlers idempotent: running a handler twice on the same input must leave the same result as running it once.
Patterns that work:
- Set state, don’t add to it. “Position 4687 is closed” is safe to apply twice. “Subtract one from the open count” isn’t.
- Ignore older news. Keep the
created_atof the last event you applied per position or per account. If a new event is older, record it and skip it. - Read the source when it matters. An event says something changed. Before acting on money, such as closing a hedge or flattening an account, read the current positions and act on what they show.
- Treat “unknown” as unknown. Tickwire sends
position.closedwith"evidence": "unknown"when a position vanished without a closing deal in history, andtrade.outcome_unknownwhen an order could not be confirmed either way. Neither means “safe to resend”. Check history and the order’s status first. See why a retry should never place a second trade.
Keep a reconcile loop as a safety net
Every push channel can miss something. The fix is cheap: a slow loop that reads the real state now and then, compares it with what your app believes, and repairs the difference. It is polling, but rare, so its rate limit cost is tiny.
import requests
API = "https://api.tickwire.io"
ACC = "<ACCOUNT_ID>"
H = {"auth-token": "tw_live_..."}
def reconcile(believed: dict[str, dict]) -> dict[str, dict]:
"""believed: position id -> position, as built from webhooks or the stream."""
r = requests.get(f"{API}/users/current/accounts/{ACC}/positions", headers=H, timeout=10)
r.raise_for_status()
actual = {p["id"]: p for p in r.json()}
for pid in actual.keys() - believed.keys():
print("missed an open:", pid) # add it to your state
for pid in believed.keys() - actual.keys():
print("missed a close:", pid) # confirm in deal history, then remove it
return actual # the truth wins
Run it every minute or two, after every reconnect, and after any webhook outage. If it keeps finding differences, something upstream is broken and you want to know.
Checklist
- Each job uses the channel that suits it, not one channel for everything.
- Polling loops back off with jitter and never compete with order placement.
- The webhook receiver verifies the signature on the raw body and rejects old timestamps.
- Events are stored once, keyed on the event id, and acknowledged within a few seconds.
- Handlers set state, ignore older news, and read the source before acting on money.
- “Unknown” outcomes are checked, never retried with a new idempotency key.
- A reconcile loop compares believed state with real state on a timer and after outages.
- Practise on a demo account before you risk money: stop the receiver for an hour and watch everything recover.
Doing this with Tickwire
Tickwire runs one real MT4 or MT5 terminal per account and offers all three channels for both platforms. Webhook deliveries are signed, carry a stable event id, and are retried with backoff for up to 72 hours. Every attempt is stored, and you can resend an event for 30 days. Webhook deliveries and stream frames don’t count against your request limits.
- Webhooks lists every event type, the headers, the retry schedule and secret rotation.
- Streaming covers the WebSocket protocol and resuming after a disconnect.
- Idempotency explains why an order sent twice with the same key is placed only once.
Trading carries risk, and you can lose money. This post explains software and how it behaves; it is not investment advice.