DEV Community

Nyria
Nyria

Posted on Originally published at nyria.io

Send alerts from your Python script to your broker

I run a couple of scanners in Python. For a long time their output was a Telegram message to myself, then me opening the broker app and typing the order by hand. The script knew the trade thirty seconds before I did, and I was still the slow part.

Disclosure up front: I build Nyria, and the webhook in this post is its webhook. I also use it exactly this way, which is why the gotchas below are specific.

What the script sends

One HTTPS POST per alert, to a URL that belongs to one strategy:

https://trades.nyria.io/webhooks/{token}
Enter fullscreen mode Exit fullscreen mode

The token is 64 lowercase hex characters and it is the only authentication. No Authorization header, no HMAC signature, no IP allowlist. Treat the URL as a secret. If it ends up in a screenshot or a repo, rotate it from the strategy's Integration tab. The old URL dies instantly, so every sender you pointed at it needs the new one.

The body is JSON. The smallest one that places a trade is two fields:

{"ticker": "AAPL", "action": "buy"}
Enter fullscreen mode Exit fullscreen mode

I don't send that one from a script. buy is read against the strategy's state: on a long-only strategy it opens a long, but on a strategy that trades both sides it covers an open short first and only otherwise opens a long. From code I want to say exactly what I mean:

{"ticker": "AAPL", "action": "OPEN", "direction": "LONG", "order_type": "market"}
Enter fullscreen mode Exit fullscreen mode

action is OPEN or CLOSE (MODIFY and CANCEL exist for working limit orders). direction is LONG or SHORT. order_type is market or limit, and it is only read on an alert that names action and direction like this one; on anything else the strategy's default applies. price is the limit price when you send limit.

There is no quantity field. I tried. quantity and notional are ignored on an entry; size always comes from the bot's budget per entry. Duration (Day, GTC, FOK) is a strategy setting too, not an alert field. The one exception is MODIFY, which can re-size a working order on Tradier and Alpaca.

Options are a separate case. If the strategy is set to "From my rules", the alert is the same as above and the strategy picks the strike and expiry. If it is set to "Provided in alert", you send a legs array with symbol, side, action, strike and expiration per leg, up to 4 legs.

The sender

import requests

WEBHOOK_URL = "https://trades.nyria.io/webhooks/REPLACE_WITH_YOUR_TOKEN"

payload = {
    "ticker": "AAPL",
    "action": "OPEN",
    "direction": "LONG",
    "order_type": "market",
}

r = requests.post(WEBHOOK_URL, json=payload, timeout=10)
r.raise_for_status()
body = r.json()
if body.get("status") == "processed":
    print("alert accepted, handed to the bots")
else:
    print("not acted on:", body)
Enter fullscreen mode Exit fullscreen mode

A good response looks like this:

{
  "status": "processed",
  "webhook": true,
  "originalAlert": "...",
  "structuredAlert": {"instrument": "AAPL", "action": "OPEN", "direction": "LONG"}
}
Enter fullscreen mode Exit fullscreen mode

Two things in that script I got wrong the first time.

raise_for_status() only catches 4xx and 5xx. A rejected alert and a duplicate both come back as 200 with a status that is not processed. Check status too, or your script logs a green tick on a trade that never happened.

timeout=10. The endpoint answers fast, but a hung socket on your side stalls the whole scanner loop and the next three alerts queue behind it.

To close: same ticker, "action": "CLOSE", "direction": "LONG". It closes the whole tracked position. There is no partial close through the webhook.

What the responses mean

  • 200 with status: processed. Read and handed to the bots. Not a fill. The bot's Logs tab shows what the broker did.
  • 200 with rejected: true. The strategy is not Active (draft, paused, archived).
  • 200 with status: rejected. Ticker not in the strategy's list, that direction turned off, or nothing open to close. The reason is in the body.
  • 200 with status: duplicate. See below.
  • 400 or 404. Bad or unknown token. Copy the URL again.
  • 413. Body over 256 KB.
  • 422. Unusable alert: missing field, unknown symbol, no strike in the DTE range, more than 4 legs. userMessage says which.
  • 429. Burst limit is 10 at once, then 1 per second. Honor Retry-After.
  • 503. Temporary, usually market data. Retrying is safe.
  • 500. Everything else. The strategy's Logs tab has the reason.

If there is no row on the Logs tab at all, the request never arrived: wrong host, http instead of https, or a proxy ate it. One exception: a duplicate is only logged once every 5 minutes, so a missing row can also be a repeat.

The duplicate rule, and the scale-in it ate

An identical body within 30 seconds is a duplicate. So is an identical body within 5 minutes if nothing else came in between. Only the first one trades.

That saved me when requests retried after a flaky connection. It also silently dropped a second scale-in I sent 90 seconds after the first with the same payload. The response was 200 with status: duplicate, which my script was not checking at the time.

The fix is a unique metadata.message_id per intended order:

payload["metadata"] = {"message_id": f"scalein-{int(time.time())}"}
Enter fullscreen mode Exit fullscreen mode

Dedup matches on that id when it is present, and on a hash of the whole body when it is not.

The broker sandbox gotcha

I test against three paper accounts and they behave differently, which matters when a test "passes".

The built-in paper broker fills or refuses every order instantly. A limit order never rests there, so MODIFY and CANCEL have nothing to act on. Test those against a real broker's paper account.

Alpaca paper: generate the keys with the dashboard toggled to Paper. A paper key starts with PK, a live key with AK, and each only works on its own endpoint. Paste a live key into the paper slot and the connection check fails; Alpaca answers 401 to a key on the wrong host. Outside market hours an Alpaca paper market order sits as accepted until the open, which is correct, and which confused me the first Sunday I tried this.

Tradier sandbox: it connects with the sandbox account number and access token from developer.tradier.com, not a tradier.com login. Its data is 15 minutes delayed, and on weekends it marks resting limit orders as filled at prices that never printed. A Friday 3:59 PM limit can show as filled on Saturday morning. The API reports it as a fill, so the bot records a fill and waits for an exit. Judge Tradier sandbox runs on weekday regular hours only.

Before the first real post

Paste the body into the alert tester. It shows the order it would build, or the exact refusal text. No account, nothing traded. Then paste the same body on the strategy's Integration tab as the example for that trade type and select Check format. The strategy only goes Active after that passes, and an inactive strategy answers every post with rejected.

Then point the script at a paper bot, send one OPEN, open the bot, and look at the quantity on the order. That number is your budget per entry divided by the price, floored to whole shares. If it is what you expected, you are done.

Every field and a curl example per broker is in the webhook payload reference. Paper bots are free, up to 10, no card. Start on Nyria.

Top comments (0)