Payload version 1 · Last updated: September 18, 2026
ThetaFlow can post every notification to a URL of yours as plain JSON:
fills, orders, opened and closed positions, rolls, settlements, errors, auto-pauses,
and P&L reports. There is no Discord, Telegram or Slack formatting baked in. Point it at your own
glue (n8n, Zapier, Make, a Cloudflare Worker, a few lines of Python), filter on
type and data.strategy, and reshape it however you like.
Set the URL under Settings → Webhook, press Send test to
post a sample and see your endpoint's answer, then save. Every kind is posted whatever
the notification toggles on that page say — those govern email and push only — so your
receiver decides what matters by filtering on type. Clear the field to turn
the webhook off.
POST per event, body application/json, UTF-8.X-ThetaFlow-Event carries the event type and
X-ThetaFlow-Delivery the event id. The user agent is
ThetaFlow-Webhook/1.2xx status within 10 seconds. The response body is
ignored. Redirects are not followed.5xx, a 408 or a
429 is retried with growing backoff, up to 8 attempts over roughly an
hour and a quarter. A retry carries the same event id, so if you record ids you can
drop duplicates. A redirect or any other 4xx is treated as a
configuration problem and not retried. The settings page shows the outcome of the
latest attempt.
Requests are not signed. Like a Discord or Telegram webhook, the URL itself is the
secret: use a long random path, keep it out of screenshots and shared configs, and
rotate it by saving a new one. Any https:// or http:// URL
is accepted, including one on your own network.
Every event is wrapped the same way, so one handler can route on type:
{
"id": "evt_5f1c9b2a7d3e4c6b8a9f0e1d",
"type": "position-closed",
"version": 1,
"created_at": "2026-09-18T14:02:41Z",
"data": { "strategy": "spy-em-breakout", "strategy_name": "SPY EM breakout", "underlying": "SPY", "realized": "+37.72", ... }
}
| Field | Meaning |
|---|---|
id | Stable id for this event. A retry of the same event keeps its id; a new event, even with identical data, gets a new one. |
type | Which notification this is; one of the types below. Filter on it: every kind is posted. |
version | Payload schema version, currently 1. New fields may be added without bumping it; a renamed or removed field would. |
created_at | When ThetaFlow queued the event, RFC 3339, UTC. |
test | Present and true only on the sample the Send test button posts. Absent on real events. |
data | The event body; its shape depends on type. |
Across every type, data.strategy is the strategy's slug (the identifier in
its URL and order tags), "" when the event isn't tied to one strategy, such
as a manual order, and data.strategy_name is its display name as shown in
the app, resolved when the event is sent ("" when the slug is unknown). Money and size fields are strings so nothing is lost to floating
point; quantity is a plain integer.
A strategy's order executed. One event per execution, opening and closing alike: a
two-leg spread is two events, a leg that fills in pieces is several, and a round trip
is at least two more. action is the broker's side (Buy to Open,
Sell to Open, Buy to Close, Sell to Close): filter
on it to tell entries from exits. Fills that belong to a roll are folded into
roll-executed and do not fire this event. The name is historical; the
event covers every fill, not just purchases.
"data": {
"strategy": "spy-em-breakout",
"strategy_name": "SPY EM breakout",
"underlying": "SPY",
"symbol": "SPY 260918C00650000",
"action": "Buy to Open",
"quantity": 2,
"price": "0.55",
"account": "5WX12345"
}
The moment a strategy (or you, from the app) submits an order. summary is the ticket in words.
"data": {
"strategy": "spy-em-breakout",
"strategy_name": "SPY EM breakout",
"summary": "Buy to Open 2× SPY $650C 0DTE @ 0.55"
}
Sent once per position, after its entry's first fill lands. legs lists
the fills known at that moment, folded per symbol and action, and net is
their dollars after fees (on Alpaca, before its pass-through exchange fees, which it
charges as one daily total), signed like realized: negative for a debit you
paid, positive for a credit you collected. Both are a snapshot: a leg whose fill hasn't
posted yet isn't included and isn't revisited, and a position that opened and closed
within one sync includes its exit, so net then reads as the round trip; position_id is the same value the matching
position-closed carries. Each event stands on its own, so you don't
have to keep one to render the other; the id is there if you want to correlate them,
and a position opened and closed within one sync can deliver its two events in
either order.
"data": {
"strategy": "spy-em-breakout",
"strategy_name": "SPY EM breakout",
"underlying": "SPY",
"account": "5WX12345",
"position_id": "7Kd2pQ9m",
"net": "-111.30",
"legs": [
{ "symbol": "SPY 260918C00650000", "action": "Buy to Open", "quantity": "2", "price": "0.55" }
]
}
Sent once per position, when its fills net flat. It describes the whole round trip
on its own, so you never need to have kept the position-opened event:
legs holds every fill, opening and closing, folded per symbol and action;
opened_at is the first fill's execution time and closed_at
when ThetaFlow marked the position closed; realized is the net dollars
after fees (on Alpaca, before its daily pass-through exchange fees), signed.
"data": {
"strategy": "spy-em-breakout",
"strategy_name": "SPY EM breakout",
"underlying": "SPY",
"account": "5WX12345",
"position_id": "7Kd2pQ9m",
"realized": "+37.72",
"opened_at": "2026-09-18T13:31:02Z",
"closed_at": "2026-09-18T14:02:11Z",
"legs": [
{ "symbol": "SPY 260918C00650000", "action": "Buy to Open", "quantity": "2", "price": "0.55" },
{ "symbol": "SPY 260918C00650000", "action": "Sell to Close", "quantity": "2", "price": "0.74" }
]
}
event is the human label, e.g. expired or assigned.
"data": {
"strategy": "spy-covered-call",
"strategy_name": "SPY covered call",
"underlying": "SPY",
"symbol": "SPY 260918C00650000",
"quantity": 1,
"event": "expired",
"account": "5WX12345"
}
One event per roll. Its closing and opening fills do not also fire stock-purchase.
"data": {
"strategy": "spy-tail-hedge",
"strategy_name": "SPY tail hedge",
"underlying": "SPY",
"summary": "Rolled tail hedge (held put at 5 DTE), bought 2× SPY $560P Nov 20 @ 3.10",
"account": "5WX12345"
}
"data": {
"strategy": "spy-em-breakout",
"strategy_name": "SPY EM breakout",
"message": "couldn't fill the entry within the 09:30–09:45 window"
}
A liquidation that left positions open adds "still_open": true; its
message names each leg still open. It is not retried — close them at the broker.
Each such failure is its own event, with its own id.
A failure that also paused the strategy adds "paused": true: it isn't
retried until the strategy is resumed. A failure of an order you placed yourself from the
app (a roll from the strategy's page) adds "hand_placed": true: it isn't
retried, and it doesn't count toward pausing the strategy.
"data": {
"strategy": "spy-em-breakout",
"strategy_name": "SPY EM breakout",
"reason": "It failed 3 times in a row. Last error: couldn't fill the entry within the 09:30–09:45 window."
}
Weekly, monthly, and annual. overall and each line's realized
are formatted, signed dollars, and lines carry the strategy's display name
rather than its slug. A report that covers two periods at once (the annual alongside
the monthly) carries the second under also with the same shape.
app_url is the app's origin, for building links.
"data": {
"cadence": "Weekly",
"period": "Sep 8 – Sep 12, 2026",
"overall": "+$412.30",
"trades": 9,
"win_rate": "67%",
"empty": false,
"lines": [
{ "name": "SPY EM breakout", "realized": "+$287.10", "trades": 5 },
{ "name": "QQQ scalp", "realized": "+$125.20", "trades": 4 }
],
"app_url": "https://app.thetaflow.app"
}
Account messages, which email always sends too. welcome carries name;
brokerage-reset carries name, reconnect_url and
broker (the connection, e.g. Alpaca · Paper; absent for Tastytrade)
and means that brokerage connection needs re-linking.
Anything that answers 200 works. For a Discord channel, for example:
// Cloudflare Worker: forward closed positions to a Discord webhook
export default {
async fetch(request) {
const ev = await request.json();
if (ev.type === "position-closed" && !ev.test) {
const d = ev.data;
await fetch(DISCORD_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ content: `${d.strategy} closed ${d.underlying}: ${d.realized}` }),
});
}
return new Response("ok");
},
};