Options Alerts API
The Live Screener that watches instead of returning. Arm a rule on gamma regime flips, call and put wall breaks, 0DTE magnets, GEX levels, IV spikes, VRP richness, or VIX, and FlashAlpha evaluates it against the live in-memory store through the session. Delivery is edge-triggered by email and by HMAC-signed HTTPS webhook: one notification when a symbol starts matching, silence while it keeps matching.
Free and Basic accounts get 2 rules on a 15-minute cadence. At general availability the feature becomes Growth and above. Paid capabilities stay tier-gated throughout the beta: custom DSL and webhooks are Growth+, formulas and universe scope are Alpha.
Get an API key ·
Compare plans →
Overview
An alert rule is stored server-side and evaluated by a background worker against the same in-memory store that backs the Live Screener. Each rule has:
- a condition, either a one-click template or a custom DSL tree;
- a scope, either an explicit symbol list or your whole plan universe;
- a cooldown, the minimum spacing between two fires of the same rule;
- delivery, an email plus an optional HMAC-signed webhook.
Three properties are worth internalising before you build on this, because they surprise people:
2. Cooldown drops, it does not queue. Edges that occur inside the cooldown window are discarded. They are not delivered late.
3. Stock-level and macro fields only.
expiries.*, strikes.*, and contracts.* are rejected. Alerts watch the symbol, not the chain.
Everything below is a REST API. Base URL https://lab.flashalpha.com/v1/alerts, authenticated with the X-Api-Key header, same key as every other FlashAlpha endpoint.
Endpoints
X-Api-Key)
Rate Limited: Yes
Returns: 201 + webhookSecret
Request Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | Rule name, max 120 chars. Leads the email subject. Omitted names are derived from the template or the condition |
templateKey | string | either | One of the 10 template keys. Mutually exclusive with condition |
templateParams | object | no | Numeric parameters for the template. Unknown names are rejected, they do not fall back to defaults |
condition | object | either | Custom condition tree. Growth+. Mutually exclusive with templateKey |
formulas | array | no | Named arithmetic expressions (Alpha). Cannot be combined with a template |
scope | object | no | {"type":"symbols","symbols":[...]} or {"type":"universe"}. Default type is symbols |
emailEnabled | bool | no | Default true |
emailTo | string | no | Override recipient, max 256 chars. Null or blank uses the account email |
webhookUrl | string | no | https:// only, public host only, max 500 chars. Growth+ |
cooldownMinutes | number | no | Clamped to 5-1440. Default 60 |
templateKey or condition. Supplying both, or neither, returns 400 validation_error.
Full endpoint list
| Method | Path | Success | Description |
|---|---|---|---|
| GET | /v1/alerts | 200 | All your rules, newest first, with count |
| POST | /v1/alerts | 201 | Create a rule. The only response that returns webhookSecret unconditionally |
| PUT | /v1/alerts/{id} | 200 | Full replace. Revives an AutoPaused or TierSuspended rule and clears today’s fire count |
| POST | /v1/alerts/{id}/pause | 200 | Status becomes Paused. Frees a rule slot |
| POST | /v1/alerts/{id}/resume | 200 | Status becomes Active, clears statusReason and today’s fire count |
| DELETE | /v1/alerts/{id} | 204 | Permanent. Fired events for the rule are removed with it |
| POST | /v1/alerts/{id}/delete | 204 | CORS alias for DELETE, for browser clients that cannot preflight DELETE |
| GET | /v1/alerts/templates | 200 | The 10 templates with params, defaults, min tier, and an available flag for your plan |
| GET | /v1/alerts/{id}/events | 200 | Fire history. ?limit= clamped to 1-200, default 50 |
Every path is scoped to the calling API key’s account. A rule id belonging to someone else returns 404, not 403.
Quick Start
Arm a gamma-regime-flip alert on SPY and QQQ. No condition to write: regime_flip_negative is a template.
curl -X POST "https://lab.flashalpha.com/v1/alerts" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "SPY / QQQ gamma flips negative",
"templateKey": "regime_flip_negative",
"scope": { "type": "symbols", "symbols": ["SPY", "QQQ"] },
"emailEnabled": true,
"cooldownMinutes": 60
}'
import requests
rule = {
"name": "SPY / QQQ gamma flips negative",
"templateKey": "regime_flip_negative",
"scope": {"type": "symbols", "symbols": ["SPY", "QQQ"]},
"emailEnabled": True,
"cooldownMinutes": 60,
}
resp = requests.post(
"https://lab.flashalpha.com/v1/alerts",
headers={"X-Api-Key": "YOUR_API_KEY"},
json=rule,
)
resp.raise_for_status()
created = resp.json()
print("rule id:", created["alert"]["id"])
# Store this now. It is never returned by GET.
print("webhook secret:", created["webhookSecret"])
const rule = {
name: "SPY / QQQ gamma flips negative",
templateKey: "regime_flip_negative",
scope: { type: "symbols", symbols: ["SPY", "QQQ"] },
emailEnabled: true,
cooldownMinutes: 60
};
const resp = await fetch("https://lab.flashalpha.com/v1/alerts", {
method: "POST",
headers: {
"X-Api-Key": "YOUR_API_KEY",
"Content-Type": "application/json"
},
body: JSON.stringify(rule)
});
const created = await resp.json();
console.log("rule id:", created.alert.id);
// Store this now. It is never returned by GET.
console.log("webhook secret:", created.webhookSecret);
Response (201 Created)
{
"alert": {
"id": "8f1c4d2e-3a77-4c19-9b0e-2d6f5a11c743",
"name": "SPY / QQQ gamma flips negative",
"templateKey": "regime_flip_negative",
"scope": { "type": "symbols", "symbols": ["SPY", "QQQ"] },
"condition": { "field": "regime", "operator": "eq", "value": "negative_gamma" },
"formulas": null,
"emailEnabled": true,
"emailTo": null,
"webhookUrl": null,
"cooldownMinutes": 60,
"status": "Active",
"statusReason": null,
"lastFiredAt": null,
"createdAt": "2026-08-06T13:45:12.4471083Z"
},
"webhookSecret": null
}
The template has been compiled into a stored condition. The evaluator never knows templates exist, it only ever sees the compiled tree, which is why you can read exactly what your rule is watching.
webhookSecret immediately. It is returned only on create and on a PUT that changes the webhook URL. GET /v1/alerts deliberately omits it, and there is no endpoint that reveals it later. If you lose it, PUT the URL again to rotate to a fresh secret.
Plans & Limits
During the beta, Free and Basic accounts can arm 2 rules and are evaluated every 15 minutes, and their scope is restricted to the Tier 1 symbol set. Paid capabilities are not part of the giveaway: the custom condition DSL and webhooks still require Growth, formulas and universe scope still require Alpha.
When the beta ends, the whole feature moves to Growth and above. Rules owned by an account below Growth at that point move to TierSuspended with an upgrade message rather than being deleted.
Capability matrix
| Capability | Free / Basic (beta) | Growth | Alpha / Enterprise |
|---|---|---|---|
| Active rules | 2 | 20 | 100 |
| Evaluation cadence | 15 min | 5 min | Every tick |
| Templates | Yes, up to the template’s own min tier | All 10 | All 10 |
| Custom condition DSL | No | Yes | Yes |
| Formulas in conditions | No | No | Yes |
| Webhooks | No | Yes | Yes |
| Email delivery | Yes | Yes | Yes |
scope: symbols | Tier 1 symbols only | Plan universe | Plan universe |
scope: universe | No | No | Yes |
| Alpha-gated fields in conditions | No | No | Yes |
Downgrades do not delete rules. If the owning account drops below the tier a rule needs, the evaluator flips it to TierSuspended with a statusReason naming the missing capability. Upgrading and calling POST /{id}/resume brings it straight back.
Templates
Ten server-authored conditions. You supply a templateKey and, where applicable, numeric templateParams. The server compiles the template into a stored condition at create time and the compiled JSON is returned in the response, so nothing is hidden from you.
Two behaviours to know:
- Templates are validated as if you were on Alpha, because the condition is server-written and only the numeric parameters were user input. That is how a Free account can use a template whose internals contain a formula.
- An unrecognised parameter name is a hard error, not a silent fallback to the default.
{"treshold": 45}returns400, it does not quietly use40.
| Key | What it watches | Params (default) | Min tier |
|---|---|---|---|
price_above_call_wall | Spot trades above the call wall | none | Free |
price_below_put_wall | Spot trades below the put wall | none | Free |
regime_flip_negative | Dealer gamma regime turns negative | none | Free |
regime_flip_positive | Dealer gamma regime turns positive | none | Free |
iv_spike | ATM implied vol above a level | threshold (40) | Basic |
vrp_rich | 20d variance risk premium above a level | threshold (2.5) | Basic |
zero_dte_magnet_near | Spot within X percent of the 0DTE magnet | pct (0.5) | Basic |
pc_volume_extreme | Put/call volume ratio above a level | ratio (2.0) | Growth |
vix_above | VIX above a level (macro field) | level (25) | Growth |
earnings_move | Earnings near and implied move large | days (3), move_pct (5) | Alpha |
atm_iv and vrp_20d are expressed in percentage points, the same as in the screener response (atm_iv: 20.7, vrp_20d: 1.72). Set iv_spike’s threshold in the same units, for example {"threshold": 45} for “ATM IV above 45”, not 0.45.
Compiled conditions
This is exactly what each template stores. Every one of them is something you could have written yourself with the custom DSL, given the right plan.
price_above_call_wall · price_below_put_wall
// price_above_call_wall
{
"op": "and",
"conditions": [
{ "field": "call_wall", "operator": "is_not_null" },
{ "formula": "price - call_wall", "operator": "gt", "value": 0 }
]
}
// price_below_put_wall
{
"op": "and",
"conditions": [
{ "field": "put_wall", "operator": "is_not_null" },
{ "formula": "price - put_wall", "operator": "lt", "value": 0 }
]
}
The is_not_null guard is not decorative: without it a symbol whose wall has not been computed would evaluate the formula to null, which is treated as a non-match, but the guard makes the intent explicit and keeps the field in the snapshot.
regime_flip_negative · regime_flip_positive
{ "field": "regime", "operator": "eq", "value": "negative_gamma" }
{ "field": "regime", "operator": "eq", "value": "positive_gamma" }
The word “flip” comes from the edge trigger, not from the condition. The condition is a plain state test; the evaluator only notifies you on the transition into that state.
iv_spike · vrp_rich · pc_volume_extreme · vix_above
// iv_spike, threshold = 45
{ "field": "atm_iv", "operator": "gte", "value": 45 }
// vrp_rich, threshold = 2.5
{ "field": "vrp_20d", "operator": "gte", "value": 2.5 }
// pc_volume_extreme, ratio = 2.0
{ "field": "pc_ratio_volume", "operator": "gte", "value": 2 }
// vix_above, level = 25 (macro field, same value for every symbol)
{ "field": "vix", "operator": "gte", "value": 25 }
zero_dte_magnet_near
// pct = 0.5 -> value is pct * pct = 0.25
{
"op": "and",
"conditions": [
{ "field": "zero_dte_magnet", "operator": "is_not_null" },
{
"formula": "(price - zero_dte_magnet) * (price - zero_dte_magnet) * 10000 / (price * price)",
"operator": "lte",
"value": 0.25
}
]
}
This is the reference example of the squared-distance idiom. The formula grammar has no abs(), so “within X percent, either side” is expressed as “squared percentage distance is at most X squared”. Multiplying by 10000 converts the squared fraction into squared percentage points, and the comparison value is pct * pct. Reuse the shape for any two-sided proximity test: see Formulas.
earnings_move ALPHA
// days = 3, move_pct = 5
{
"op": "and",
"conditions": [
{ "field": "days_to_earnings", "operator": "lte", "value": 3 },
{ "field": "earnings_implied_move_pct", "operator": "gte", "value": 5 }
]
}
Discovering templates at runtime
curl -H "X-Api-Key: YOUR_API_KEY" \
"https://lab.flashalpha.com/v1/alerts/templates"
{
"templates": [
{
"key": "iv_spike",
"name": "ATM IV above level",
"description": "Fires when ATM implied volatility rises above your threshold.",
"minTier": "Basic",
"available": true,
"params": [ { "name": "threshold", "type": "number", "default": 0.4 } ]
}
]
}
available already accounts for both the feature gate and the template’s own minimum tier, so a UI can render the catalogue without duplicating the ladder.
Custom Conditions GROWTH+
The condition field takes the same recursive tree as the Live Screener’s filters. Each node is a group (and or or with nested conditions) or a leaf (field or formula, plus an operator and a value).
Leaf node
{ "field": "net_gex", "operator": "lt", "value": -2000000000 }
Group node
{
"op": "and",
"conditions": [
{ "field": "regime", "operator": "eq", "value": "negative_gamma" },
{ "field": "atm_iv", "operator": "gte", "value": 25 }
]
}
Nesting
Groups nest up to 3 levels deep, with a maximum of 20 leaf conditions in the whole rule. Exceeding either returns 400 validation_error.
{
"op": "and",
"conditions": [
{ "field": "regime", "operator": "eq", "value": "negative_gamma" },
{
"op": "or",
"conditions": [
{ "field": "vix", "operator": "gte", "value": 22 },
{ "field": "atm_iv", "operator": "gte", "value": 40 }
]
}
]
}
Reads as: regime = "negative_gamma" AND (vix >= 22 OR atm_iv >= 40).
There is no not
The only group operators are and and or. {"op": "not", ...} returns Unknown logical operator 'not'. Use 'and' or 'or'. Negate at the leaf instead, with neq, an inverted comparison, or is_null:
// NOT (regime = positive_gamma)
{ "field": "regime", "operator": "neq", "value": "positive_gamma" }
// NOT (atm_iv >= 30)
{ "field": "atm_iv", "operator": "lt", "value": 30 }
Stock-level and macro fields only
expiries.*, strikes.*, contracts.*. Any leaf with one of those prefixes fails validation with Alert conditions support stock-level and macro fields only.
An alert watches a symbol, not a chain node. A rule that has to re-walk every expiry and strike on every tick for every user does not belong on a shared evaluator. Use POST /v1/screener for contract-level and expiry-level queries, and alert on the stock-level aggregate that the chain rolls up into:
zero_dte_net_gex, gex_0to7_dte, pc_ratio_volume, total_call_oi, and so on.
Custom condition example
{
"name": "Short gamma with a real vol bid",
"condition": {
"op": "and",
"conditions": [
{ "field": "regime", "operator": "eq", "value": "negative_gamma" },
{ "field": "net_gex", "operator": "lt", "value": -1000000000 },
{ "field": "atm_iv", "operator": "gte", "value": 22 },
{ "field": "gamma_flip", "operator": "is_not_null" }
]
},
"scope": { "type": "symbols", "symbols": ["SPY", "QQQ", "IWM"] },
"cooldownMinutes": 120
}
Operators
Numeric operators
| Operator | Meaning | Reads as | Value type | Example |
|---|---|---|---|---|
eq | Equals (tolerance 1e-9) | field = value | number | "value": 0 |
neq | Not equal | field ≠ value | number | "value": 0 |
gt | Greater than | field > value | number | "value": 25 |
gte | Greater than or equal | field ≥ value | number | "value": 2.5 |
lt | Less than | field < value | number | "value": -500000000 |
lte | Less than or equal | field ≤ value | number | "value": 3 |
between | Within range (inclusive) | min ≤ field ≤ max | array [min, max] | "value": [15, 25] |
in | Matches any value in list | field ∈ [list] | array of numbers | "value": [0, 1, 2] |
is_null | No value available | field is null | omit | {"field":"max_pain","operator":"is_null"} |
is_not_null | Has a value | field is not null | omit | {"field":"call_wall","operator":"is_not_null"} |
String operators
| Operator | Meaning | Value type | Example |
|---|---|---|---|
eq | Equals (case-insensitive) | string | "value": "negative_gamma" |
neq | Not equal | string | "value": "positive_gamma" |
in | Matches any string in list | array of strings | "value": ["backwardation", "mixed"] |
is_null | No value available | omit | {"field":"vrp_regime","operator":"is_null"} |
is_not_null | Has a value | omit | {"field":"term_state","operator":"is_not_null"} |
gt, gte, lt, lte, and between are not defined for string fields and return Operator 'gt' not supported for string fields.
How nulls behave
Missing data is not a match. If a field is null, every comparison operator other than is_null returns false for that symbol, so a symbol with no computed vrp_20d never trips a vrp_20d >= 2.5 rule. That is the safe default for alerting: you get silence, not a spurious page.
Field Reference
Alert conditions address 94 fields: 84 stock-level fields plus 10 market-wide macro fields. Definitions, units, and tiers for all of them live in the Screener Field Taxonomy; the tables below are the alert-addressable subset in summary form.
Stock level (84 fields, one value per symbol)
| Category | Fields | Tier |
|---|---|---|
| Price | price, bid, ask, mid | Growth |
| Exposure | regime, net_gex, net_dex, net_vex, net_chex | Growth |
| Key Levels | gamma_flip, call_wall, put_wall, max_positive_gamma, max_negative_gamma, highest_oi_strike, max_pain, zero_dte_magnet | Growth |
| Zero-DTE | zero_dte_net_gex, zero_dte_pct_of_total | Growth |
| Volatility | atm_iv, rv_5d, rv_10d, rv_20d, rv_30d, rv_60d | Growth |
| VRP (basic) | vrp_5d, vrp_10d, vrp_20d, vrp_30d, vrp_assessment | Growth |
| Skew | skew_25d, skew_25d_put, skew_25d_call | Growth |
| Term Structure | term_near_slope_pct, term_far_slope_pct, term_state | Growth |
| Liquidity | atm_spread_pct, wing_spread_pct, atm_contracts, wing_contracts | Growth |
| OI Concentration | top_3_pct, top_5_pct, herfindahl | Growth |
| Flow | total_call_oi, total_put_oi, total_call_volume, total_put_volume, pc_ratio_oi, pc_ratio_volume | Growth |
| IV Dispersion | iv_dispersion_cross_expiry, iv_dispersion_cross_strike | Growth |
| GEX by DTE | gex_0to7_dte, gex_8to30_dte, gex_31to60_dte, gex_61plus_dte | Growth |
| VRP (extended) | variance_risk_premium, convexity_premium, fair_vol, vrp_z_score, vrp_percentile, vrp_regime | Alpha |
| Directional VRP | put_wing_iv_25d, call_wing_iv_25d, downside_rv_20d, upside_rv_20d, downside_vrp, upside_vrp | Alpha |
| Strategy Scores | harvest_score, net_harvest_score, dealer_flow_risk, short_put_spread_score, short_strangle_score, iron_condor_score, calendar_spread_score | Alpha |
| Volatility Forecast | ewma_vol, harrv_vol, garch_vol_1d, garch_vol_longrun, garch_persistence, garch_half_life_days | Alpha |
| Earnings | next_earnings_date, days_to_earnings, earnings_implied_move_pct, expected_iv_crush_pct | Alpha |
String fields are regime, vrp_assessment, term_state, vrp_regime, and next_earnings_date. The other 79 are numeric.
Macro fields (10, identical for every symbol)
Use these as context gates: “only alert me on this name when VIX is above 22”.
vix, vvix, skew, spx, move, vix_3m, vix_term_slope, dgs10, fed_funds, hy_spread
Because a macro field has the same value for every symbol, a rule whose condition is only macro fires for every symbol in scope at once. That is usually what you want for a vix_above style regime alert, and usually not what you want inside a symbol-specific rule, where the macro leaf should be an and gate alongside a per-symbol leaf.
Formulas ALPHA
A formula leaf compares an arithmetic expression instead of a bare field. Use it inline, or name it in the formulas array and reference the alias.
// inline, no declaration needed
{ "formula": "atm_iv / rv_20d", "operator": "gte", "value": 1.35 }
// or declared and referenced by alias
{
"formulas": [ { "alias": "iv_rv_ratio", "expression": "atm_iv / rv_20d" } ],
"condition": { "formula": "iv_rv_ratio", "operator": "gte", "value": 1.35 }
}
Grammar
| Element | Supported |
|---|---|
| Operators | +, -, *, / and parentheses, with standard precedence |
| Unary minus | -net_gex, -(price - max_pain) |
| Numeric literals | 100, 0.5, 10000 |
| Identifiers | Lowercase snake_case numeric stock fields only |
| Functions | None. No abs(), min(), max(), sqrt(), or log() |
| Max expression length | 200 characters |
| Max nesting depth | 10 parenthesised levels |
| Division by zero | Yields null |
| Null operand | Propagates null through the whole expression |
| Null result | Never matches, so the symbol is silently skipped |
The macro asymmetry
{"field": "vix", "operator": "gte", "value": 22} is valid. {"formula": "atm_iv / vix", ...} is not: the formula parser resolves identifiers against the per-symbol numeric field map only, so vix comes back as Unknown field 'vix' at position 9. with error: "formula_error".
Work around it by splitting the test into two leaves inside an
and group: one formula leaf on the per-symbol side, one plain field leaf on the macro side.
No abs(): the squared-distance idiom
Two-sided proximity (“price is within X percent of level L, above or below”) cannot be written with abs(). Square it instead. This is exactly how the zero_dte_magnet_near template is built:
// "price is within 0.5% of zero_dte_magnet"
//
// want: abs(price - m) / price * 100 <= 0.5
// square: ((price - m) / price * 100)^2 <= 0.5^2
// expand: (price - m) * (price - m) * 10000 / (price * price) <= 0.25
{
"formula": "(price - zero_dte_magnet) * (price - zero_dte_magnet) * 10000 / (price * price)",
"operator": "lte",
"value": 0.25
}
The same shape works for any level: swap zero_dte_magnet for max_pain, gamma_flip, call_wall, or highest_oi_strike, and set value to your tolerance squared. Squaring is monotonic on non-negative numbers, so the inequality direction is preserved; just remember the comparison value is pct * pct, not pct.
Alias rules
- Non-empty and unique within the rule.
- Must not collide with a built-in field name.
formulascannot be combined withtemplateKey; that returnsFormulas cannot be combined with a template.
price and regime as context. Formula leaves are skipped, because an alias is not a store field. A rule whose only leaf is {"formula": "atm_iv / rv_20d", ...} therefore emails and posts a snapshot containing just price and regime.
Fix: add a redundant field leaf for anything you want to see.
{"field": "atm_iv", "operator": "is_not_null"} alongside the formula costs nothing at evaluation time and puts atm_iv into every notification.
Scope
scope decides which symbols a rule is evaluated against. It defaults to {"type": "symbols"}.
| Type | Shape | Tier | Notes |
|---|---|---|---|
symbols | {"type":"symbols","symbols":["SPY","QQQ"]} | All | 1 to 20 symbols. Uppercased, trimmed, de-duplicated. Every symbol must be in your plan universe |
universe | {"type":"universe"} | Alpha | Every symbol in the live store, currently around 250 names |
watchlist | not available | n/a | Reserved. Returns 400 |
"type": "watchlist" is not usable. It is rejected at create time with:
Watchlist scope is not available yet. Use scope 'symbols' (up to 20) or 'universe' (Alpha).
The rejection is deliberate. The evaluator resolves a watchlist scope to an empty universe, so accepting one would hand you a rule that never fires and never errors, which is the worst possible failure mode. It is a hard
400 until the resolver ships.
Symbols must be on your plan
A symbol outside your plan universe is rejected at create time, not silently ignored:
{
"status": "ERROR",
"error": "validation_error",
"message": "Symbol(s) not available on your plan: HIMS, RGTI."
}
Below Growth, that universe is the 20-symbol Tier 1 list. This matches what the evaluator would actually select, so you can never arm a rule that is structurally incapable of firing.
Universe scans
{
"name": "Any name flips to short gamma with elevated risk",
"condition": {
"op": "and",
"conditions": [
{ "field": "regime", "operator": "eq", "value": "negative_gamma" },
{ "field": "dealer_flow_risk", "operator": "gte", "value": 65 }
]
},
"scope": { "type": "universe" },
"cooldownMinutes": 240
}
Universe rules are the ones most likely to hit the auto-pause cap, because a single evaluation can newly match a dozen symbols at once. Give them a long cooldown, and gate them tightly enough that the whole market does not cross the threshold together.
Delivery Semantics
This section is the one to read twice. The semantics are chosen to make alerts survivable in an inbox and on a pager, and they are not what most people assume.
1. Edge-triggered, per rule and per symbol
The evaluator keeps, for each rule, the set of symbols that matched last time. It notifies only on the false to true transition. While a symbol keeps matching, nothing further is sent. When it stops matching, it silently re-arms.
tick SPY matches? action
1 no (baseline)
2 yes FIRE false -> true
3 yes silent
4 yes silent
5 no re-armed, no notification
6 yes FIRE false -> true again
There is no resolved or all-clear notification. If your downstream system needs to know when a condition clears, poll POST /v1/screener with the same condition; alerts tell you when something started, not when it stopped.
2. The first observation always re-baselines silently
When a rule is created, edited, resumed, or when the evaluator restarts, the first evaluation records the current matching set as the baseline and sends nothing, even if half your scope already matches. A condition that was already true when watching began is pre-existing state, not an event.
Practical consequence: after POST /v1/alerts you may wait one full cadence interval plus one edge before the first email. If you create a rule at 14:00 on Growth and SPY is already in negative gamma, you will not hear about SPY until it leaves and re-enters the regime. This is the anti-storm guarantee, and it is why a deploy or restart never floods anyone.
3. Cooldown drops edges, it does not queue them
Cooldown is per rule, not per symbol. After a fire, the rule will not fire again until cooldownMinutes have elapsed. Crucially, edges that occur inside that window are discarded: the baseline still advances, so those symbols count as already seen and will not be re-delivered when the window closes.
cooldownMinutes = 60
10:00 SPY false -> true FIRE (email + webhook)
10:07 QQQ false -> true DROPPED (inside cooldown, never delivered)
10:31 IWM false -> true DROPPED
11:02 AMD false -> true FIRE (cooldown expired)
If you need per-symbol independence, create one rule per symbol. If you would rather have coarse batching, keep the multi-symbol rule and accept that you learn about the first mover only. Defaults: cooldownMinutes is 60, clamped to 5 minimum and 1440 maximum. Values outside that range are clamped, not rejected.
4. Auto-pause after 5 fires in one ET day
A rule that fires 5 times in a single Eastern-time day flips to AutoPaused with:
"status": "AutoPaused",
"statusReason": "Auto-paused: fired 5 times today. Widen your threshold and resume."
The counter is keyed to the ET date and resets on rollover. POST /{id}/resume clears it immediately, so a resumed rule is not instantly re-paused by the same day’s history. Editing the rule with PUT does the same. This cap is a circuit breaker on a noisy threshold, not a quota: fix the threshold rather than resuming in a loop.
5. Market hours only
Rules are evaluated only on US trading days between 09:30 and 16:00 ET. Nothing runs overnight, at weekends, or on exchange holidays. There is no pre-market or post-market evaluation, so a gap that opens and closes outside RTH is never seen. Alpha rules become due on every evaluator tick within that window, Growth rules every 5 minutes, open-beta Free and Basic rules every 15 minutes.
6. At-least-once, not exactly-once
Both channels are at-least-once. Deduplicate on ruleId plus firedAt, which is stable across all four webhook attempts of the same delivery. See Webhooks.
Email Delivery
Email is on by default (emailEnabled: true) and needs no configuration.
| Behaviour | Detail |
|---|---|
| Recipient | emailTo if set, otherwise the account email on the API key |
| Subject | FlashAlpha Alert: {rule name}. Matched symbols are in the body, not the subject, so long names stay readable on a phone |
| Body | Rule name, matched symbols, ET timestamp, and a table per symbol of the field values captured at trigger time |
| Unsubscribe | Honours the account-level alert email preference. Unsubscribing stops the mail but not the fire record or the webhook |
emailTo validation | Must parse as an email address and contain a dot. Max 256 chars |
| Retries | 2 attempts: the initial send, then one retry after 5 seconds |
Because the rule name leads the subject, name your rules for the inbox. An unnamed custom rule is auto-named from its own condition (for example regime = negative_gamma + dealer_flow_risk >= 50) rather than a generic label, so even lazily created rules stay distinguishable, but an explicit name is better.
Delivery outcome per fire is visible on GET /{id}/events as emailState: Pending, Sent, Failed, or Skipped.
Webhooks GROWTH+
Set webhookUrl on a rule and every fire is POSTed to it as JSON, signed with HMAC-SHA256.
Payload
POST https://your-endpoint.example.com/fa-alerts
Content-Type: application/json; charset=utf-8
X-FlashAlpha-Signature: 4c1a9f0e7b2d... (64 lowercase hex chars)
{
"ruleId": "8f1c4d2e-3a77-4c19-9b0e-2d6f5a11c743",
"name": "SPY / QQQ gamma flips negative",
"firedAt": "2026-08-06T14:32:00.0000000Z",
"symbols": ["SPY"],
"snapshot": {
"SPY": {
"price": 656.01,
"regime": "negative_gamma"
}
}
}
Exactly five top-level keys: ruleId, name, firedAt (ISO 8601 round-trip UTC), symbols (only the newly matched ones), and snapshot (symbol to field to value, holding the values the rule was judged on plus price and regime).
Signature
X-FlashAlpha-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed by the webhookSecret string as returned (that is, the UTF-8 bytes of the 64-character hex string, not the 32 bytes it decodes to).
import hashlib
import hmac
import json
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "the-64-hex-char-webhookSecret-from-create"
@app.post("/fa-alerts")
def fa_alerts():
# 1. Raw bytes FIRST. Never re-serialize before verifying.
raw = request.get_data()
sent = request.headers.get("X-FlashAlpha-Signature", "")
expected = hmac.new(
WEBHOOK_SECRET.encode("utf-8"), # key = the secret STRING
raw, # message = the raw body bytes
hashlib.sha256,
).hexdigest() # lowercase hex
# 2. Constant-time compare.
if not hmac.compare_digest(expected, sent):
abort(401)
# 3. Only now is it safe to parse.
payload = json.loads(raw)
key = (payload["ruleId"], payload["firedAt"]) # idempotency key
if already_processed(key):
return "", 200
for symbol in payload["symbols"]:
handle(symbol, payload["snapshot"].get(symbol, {}))
return "", 200 # 2xx = delivered
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const WEBHOOK_SECRET = 'the-64-hex-char-webhookSecret-from-create';
// Capture the RAW body. express.json() alone would parse and discard it.
app.post('/fa-alerts', express.raw({ type: '*/*' }), (req, res) => {
const raw = req.body; // Buffer
const sent = req.get('X-FlashAlpha-Signature') || '';
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET) // key = secret STRING
.update(raw) // message = raw bytes
.digest('hex'); // lowercase hex
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(sent, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const payload = JSON.parse(raw.toString('utf8'));
const key = `${payload.ruleId}:${payload.firedAt}`; // idempotency key
if (alreadyProcessed(key)) return res.sendStatus(200);
for (const symbol of payload.symbols) {
handle(symbol, payload.snapshot[symbol] ?? {});
}
res.sendStatus(200); // 2xx = delivered
});
using System.Security.Cryptography;
using System.Text;
const string WebhookSecret = "the-64-hex-char-webhookSecret-from-create";
app.MapPost("/fa-alerts", async (HttpRequest req) =>
{
// 1. Raw bytes FIRST.
using var reader = new StreamReader(req.Body, Encoding.UTF8);
var raw = await reader.ReadToEndAsync();
var sent = req.Headers["X-FlashAlpha-Signature"].ToString();
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(WebhookSecret));
var expected = Convert
.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(raw)))
.ToLowerInvariant(); // lowercase hex
// 2. Constant-time compare.
var ok = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(sent.PadRight(expected.Length).Substring(0, expected.Length)));
if (!ok || sent.Length != expected.Length) return Results.Unauthorized();
// 3. Parse only after verifying.
var payload = System.Text.Json.JsonDocument.Parse(raw).RootElement;
var key = payload.GetProperty("ruleId").GetString()
+ ":" + payload.GetProperty("firedAt").GetString();
if (AlreadyProcessed(key)) return Results.Ok();
foreach (var s in payload.GetProperty("symbols").EnumerateArray())
Handle(s.GetString()!);
return Results.Ok(); // 2xx = delivered
});
Delivery rules
| Rule | Value |
|---|---|
| Attempts per delivery | 4 (initial + 3 retries) |
| Backoff between attempts | 1s, then 5s, then 25s |
| Timeout per attempt | 5 seconds |
| Success | Any 2xx |
| Redirects | Not followed. A 3xx counts as a failure, so point the rule at the final URL |
| Scheme | https:// only. http:// is rejected at create time |
| Host | Public addresses only. Loopback, RFC1918, CGNAT, link-local, unique-local, and multicast are blocked both at create time and again at connect time, which also defeats DNS rebinding |
| Concurrency | Up to 4 deliveries in flight across the fleet |
| Auto-disable | After 20 consecutive failed deliveries the URL is cleared from the rule |
| Ordering | Not guaranteed. Use firedAt if order matters |
| Idempotency key | ruleId + firedAt, identical across all attempts of one delivery |
Auto-disable and recovery
The consecutive-failure counter resets to zero on any successful delivery. When it reaches 20, the rule’s webhookUrl is set to null and its statusReason becomes:
"webhookUrl": null,
"statusReason": "Webhook disabled after 20 consecutive delivery failures."
The rule itself stays Active and keeps emailing; only the webhook is switched off. To recover, PUT the rule with the webhookUrl set again. That is treated as a URL change, so it rotates the signing secret and returns the new one in the response. Update your endpoint before the next fire.
Local development
Because private addresses are blocked, https://localhost:5001/... will not work. Use a public tunnel with an HTTPS endpoint and point the rule at that hostname.
Integrations
FlashAlpha posts its own JSON schema with a signature header. Chat platforms expect their own schemas, so an incoming-webhook URL from Slack or Discord pasted straight into webhookUrl will be rejected by their side and count as a failed delivery. Put a small relay in between: receive, verify the signature, reshape, forward.
| Target | Approach |
|---|---|
| Discord | Relay that maps the payload to {"content": "..."} or an embed, then POSTs to the Discord webhook URL. One relay can serve every rule |
| Slack | Same shape, mapping to {"text": "..."} or Block Kit and posting to the Slack incoming webhook |
| Telegram | Relay calls sendMessage on the Bot API with your chat id. Format symbols and the snapshot table as the message text |
| n8n / Make / Zapier | Use a Webhook trigger node as the webhookUrl, add an HMAC verification step, then branch on symbols and snapshot fields |
| Your own bot or OMS | Consume the payload directly. Verify, deduplicate on ruleId + firedAt, and act on snapshot[symbol] |
Three rules for any relay: verify the signature before you trust the body; return 2xx as soon as you have durably accepted the message, not after your downstream work finishes, because the timeout is 5 seconds; and deduplicate, because delivery is at-least-once.
Prefer not to run a relay at all? Leave emailEnabled: true and point emailTo at a channel email address, which most chat platforms can issue.
Fire History
Every fire is recorded, whether or not delivery succeeded. This is the audit trail, and the place to look when someone says they never got an alert.
X-Api-Key)
limit: 1-200, default 50
Newest first
curl -H "X-Api-Key: YOUR_API_KEY" \
"https://lab.flashalpha.com/v1/alerts/8f1c4d2e-3a77-4c19-9b0e-2d6f5a11c743/events?limit=5"
{
"events": [
{
"firedAt": "2026-08-06T14:32:00.0000000Z",
"symbols": ["SPY"],
"snapshot": {
"SPY": { "price": 656.01, "regime": "negative_gamma" }
},
"emailState": "Sent",
"webhookState": "Sent"
}
],
"count": 1
}
Delivery states
| State | Meaning |
|---|---|
None | Channel not configured for this rule (no webhook URL, for example) |
Pending | Queued, outcome not yet written back |
Sent | Delivered and accepted |
Failed | All attempts exhausted without a 2xx |
Skipped | Deliberately not sent, for example the account unsubscribed from alert email |
Disabled | The failure that tripped the 20-strike webhook auto-disable |
symbols holds only the newly matched symbols for that fire, and snapshot holds the values captured at trigger time, so a later look-up shows what the rule actually saw rather than what the market has since become.
Deleting a rule deletes its history. Export anything you need to keep before calling DELETE.
Rule Lifecycle
| Status | Evaluated? | Consumes a slot? | How you get here | How you get out |
|---|---|---|---|---|
Active | Yes | Yes | Create, PUT, or resume | Pause, or an automatic transition |
Paused | No | No | POST /{id}/pause | POST /{id}/resume |
AutoPaused | No | Yes | 5 fires in one ET day | POST /{id}/resume or PUT |
TierSuspended | No | Yes | The owner’s plan no longer permits the rule | Upgrade, then resume |
Paused frees a rule slot. AutoPaused and TierSuspended rules still count against your plan’s active-rule limit. If creates start returning Alert limit reached for your plan (20 active alerts)., list your rules and look for stalled ones, then pause or delete them rather than assuming the limit is wrong.
statusReason values you will see
| Status | Reason |
|---|---|
AutoPaused | Auto-paused: fired 5 times today. Widen your threshold and resume. |
TierSuspended | Alerts require the Growth plan or higher. |
TierSuspended | Custom alert conditions require the Growth plan or higher. |
TierSuspended | Universe-scope alerts require the Alpha plan. |
TierSuspended | Owner account not found. |
Active | Webhook disabled after 20 consecutive delivery failures. |
Editing a rule
PUT /v1/alerts/{id} is a full replace validated exactly like a create, not a patch. Fields you omit revert to their defaults. It also:
- sets status back to
Activeand clearsstatusReason, reviving an AutoPaused or TierSuspended rule; - clears today’s fire count, so the same-day cap does not immediately re-pause it;
- re-baselines the edge state, so the first evaluation after the edit is silent;
- rotates the webhook secret and resets the failure counter if and only if the URL changed, returning the new secret in the response (
nullotherwise).
Example Rules
Ten ready-to-post rule bodies. Each is a complete POST /v1/alerts request.
1. 0DTE magnet proximity on SPY (template)
{
"name": "SPY inside 0.3% of the 0DTE magnet",
"templateKey": "zero_dte_magnet_near",
"templateParams": { "pct": 0.3 },
"scope": { "type": "symbols", "symbols": ["SPY"] },
"cooldownMinutes": 30
}
Pin risk into the close. A 30-minute cooldown is deliberate: the magnet is a level, and price will oscillate around it.
2. Call-wall break with a webhook (template)
{
"name": "Call wall broken",
"templateKey": "price_above_call_wall",
"scope": { "type": "symbols", "symbols": ["SPY", "QQQ", "NVDA", "TSLA"] },
"webhookUrl": "https://hooks.example.com/flashalpha/call-wall",
"emailEnabled": false,
"cooldownMinutes": 60
}
Response carries webhookSecret. Store it before doing anything else. With emailEnabled: false the webhook is the only channel, so a broken endpoint means silence: keep an eye on webhookState in the event history.
3. Gamma regime flips negative, confirmed (custom DSL)
{
"name": "Short gamma flip, confirmed by dealer flow risk",
"condition": {
"op": "and",
"conditions": [
{ "field": "regime", "operator": "eq", "value": "negative_gamma" },
{ "field": "dealer_flow_risk", "operator": "gte", "value": 55 },
{ "field": "gamma_flip", "operator": "is_not_null" }
]
},
"scope": { "type": "symbols", "symbols": ["SPY", "QQQ", "IWM"] },
"cooldownMinutes": 90
}
dealer_flow_risk is Alpha-gated. On Growth, drop that leaf and use the plain regime_flip_negative template instead.
4. Net GEX deeply negative (custom DSL)
{
"name": "SPX net GEX below -3bn",
"condition": {
"op": "and",
"conditions": [
{ "field": "net_gex", "operator": "lt", "value": -3000000000 },
{ "field": "zero_dte_pct_of_total","operator": "gte", "value": 35 }
]
},
"scope": { "type": "symbols", "symbols": ["SPX", "SPY"] },
"cooldownMinutes": 120
}
The second leaf is the interesting one: deeply negative GEX and a third of it concentrated in same-day expiry is a very different tape from the same GEX spread across LEAPS.
5. VRP rich into positive gamma (custom DSL)
{
"name": "Premium-selling window",
"condition": {
"op": "and",
"conditions": [
{ "field": "vrp_20d", "operator": "gte", "value": 4 },
{ "field": "regime", "operator": "eq", "value": "positive_gamma" },
{ "field": "atm_spread_pct", "operator": "lte", "value": 1.5 }
]
},
"scope": { "type": "symbols", "symbols": ["SPY", "QQQ", "IWM", "AAPL", "MSFT", "NVDA"] },
"cooldownMinutes": 240
}
The liquidity leaf keeps the alert honest: rich VRP you cannot get filled in is not an opportunity.
6. IV spike, gated on the macro tape (custom DSL)
{
"name": "Single-name IV spike while the index is calm",
"condition": {
"op": "and",
"conditions": [
{ "field": "atm_iv", "operator": "gte", "value": 55 },
{ "field": "vix", "operator": "lt", "value": 18 }
]
},
"scope": { "type": "symbols", "symbols": ["NVDA", "TSLA", "AMD", "COIN", "MSTR", "PLTR"] },
"cooldownMinutes": 60
}
An idiosyncratic vol bid with no index stress behind it. vix is a macro field: legal as a condition leaf, illegal inside a formula.
7. VIX regime break (template + universe) ALPHA
{
"name": "VIX above 25",
"templateKey": "vix_above",
"templateParams": { "level": 25 },
"scope": { "type": "universe" },
"cooldownMinutes": 720
}
Because vix is market-wide, every symbol in scope crosses the threshold on the same tick. With universe scope that is one fire naming a couple of hundred symbols, so keep the cooldown long. On Growth, use scope: {"type":"symbols","symbols":["SPY"]} for the same signal with one symbol named.
8. Dealer-flow-risk universe scan ALPHA
{
"name": "Any name entering hostile dealer flow",
"condition": {
"op": "and",
"conditions": [
{ "field": "dealer_flow_risk", "operator": "gte", "value": 70 },
{ "field": "regime", "operator": "eq", "value": "negative_gamma" },
{ "field": "atm_contracts", "operator": "gte", "value": 500 }
]
},
"scope": { "type": "universe" },
"webhookUrl": "https://hooks.example.com/flashalpha/risk-scan",
"cooldownMinutes": 180
}
The atm_contracts leaf filters out illiquid names whose risk score is technically true but untradeable. Universe scans are the classic auto-pause victim: three hours of cooldown keeps this inside the 5-fires-per-day cap.
9. Earnings IV crush setup ALPHA
{
"name": "Fat implied move with a big expected crush",
"condition": {
"op": "and",
"conditions": [
{ "field": "days_to_earnings", "operator": "lte", "value": 2 },
{ "field": "earnings_implied_move_pct", "operator": "gte", "value": 7 },
{ "field": "expected_iv_crush_pct", "operator": "gte", "value": 30 }
]
},
"scope": { "type": "universe" },
"cooldownMinutes": 1440
}
A superset of the earnings_move template with the crush estimate added. A 1,440-minute cooldown makes it effectively once per day, which suits a field that only moves on the earnings calendar.
10. IV/RV regime shift (formula) ALPHA
{
"name": "IV/RV ratio above 1.4",
"formulas": [
{ "alias": "iv_rv_ratio", "expression": "atm_iv / rv_20d" }
],
"condition": {
"op": "and",
"conditions": [
{ "formula": "iv_rv_ratio", "operator": "gte", "value": 1.4 },
// Not redundant: these put atm_iv and rv_20d into every
// email and webhook snapshot. Formula leaves contribute
// nothing to the snapshot.
{ "field": "atm_iv", "operator": "is_not_null" },
{ "field": "rv_20d", "operator": "is_not_null" }
]
},
"scope": { "type": "symbols", "symbols": ["SPY", "QQQ", "IWM", "NVDA", "TSLA"] },
"cooldownMinutes": 120
}
Note the two is_not_null leaves. Without them the notification would contain price and regime and nothing else, because the ratio that actually triggered the alert lives in a formula alias, not in a store field.
Error Handling
Validation failures return HTTP 400 with a structured JSON body:
{
"status": "ERROR",
"error": "validation_error",
"message": "Custom alert conditions require the Growth plan or higher. Use a template instead."
}
Status codes
| Code | When |
|---|---|
200 | List, update, pause, resume, templates, events |
201 | Rule created. The body carries webhookSecret |
204 | Rule deleted |
400 | validation_error or formula_error |
401 | Missing or invalid X-Api-Key |
404 | Rule id unknown, or owned by another account |
429 | Daily request budget exhausted, shared with every other FlashAlpha endpoint |
Common validation errors
| Trigger | Message |
|---|---|
| Feature gated (post-beta, below Growth) | Alerts require the Growth plan or higher. |
| Rule quota reached | Alert limit reached for your plan (20 active alerts). |
| Both or neither of template and condition | Specify exactly one of ‘templateKey’ or ‘condition’. |
| Bad template key | Unknown alert template ‘regime_flip’. |
| Template above your tier | Template ‘earnings_move’ requires the Alpha plan or higher. |
| Typo in a template param | Template ‘iv_spike’ has no parameter ‘treshold’. |
| Formulas plus a template | Formulas cannot be combined with a template. |
| Custom DSL below Growth | Custom alert conditions require the Growth plan or higher. Use a template instead. |
| Formulas below Alpha | Formula expressions require the Alpha plan. |
| Chain-level field in a condition | Alert conditions support stock-level and macro fields only. |
| Alpha field on a lower plan | Field ‘dealer_flow_risk’ requires the Alpha plan or higher. |
| Too deep | Filter nesting exceeds maximum depth of 3. |
| Too many leaves | Too many conditions (24). Maximum is 20. |
not group | Unknown logical operator ‘not’. Use ‘and’ or ‘or’. |
| Empty group | Group operator ‘and’ requires at least one condition. |
| Universe scope below Alpha | Universe-scope alerts require the Alpha plan. |
| Watchlist scope | Watchlist scope is not available yet. Use scope ‘symbols’ (up to 20) or ‘universe’ (Alpha). |
| Empty or oversized symbol list | Scope ‘symbols’ requires 1-20 symbols. |
| Symbol off-plan | Symbol(s) not available on your plan: HIMS. |
| Webhook below Growth | Webhook delivery requires a paid plan. |
| Non-HTTPS webhook | Webhook URL must be a valid https:// URL. |
| Private-address webhook | Webhook URL must resolve to a public address. |
Bad emailTo | Alert email recipient must be a valid email address. |
Formula errors
Formula problems return error: "formula_error" with a character position, so you can point at the offending token:
| Expression | Message |
|---|---|
atm_iv / vix | Unknown field ‘vix’ at position 9. |
abs(price - max_pain) | Unknown field ‘abs’ at position 0. |
atm_iv / (rv_20d | Expected ‘)’ at position 16. |
ATM_IV * 2 | Invalid character ‘A’ at position 0. Field names are lowercase |
| 201+ characters | Formula expression exceeds maximum length of 200 characters. |
Limits
Every hard number in the alerting system, in one place.
| Limit | Value |
|---|---|
| Active rules, Free / Basic (open beta) | 2 |
| Active rules, Growth | 20 |
| Active rules, Alpha / Enterprise | 100 |
| Evaluation cadence, Free / Basic (open beta) | 15 minutes |
| Evaluation cadence, Growth | 5 minutes |
| Evaluation cadence, Alpha / Enterprise | Every evaluator tick |
| Evaluation window | 09:30 to 16:00 ET, US trading days only |
| Cooldown, default | 60 minutes |
| Cooldown, minimum | 5 minutes (values below are clamped up) |
| Cooldown, maximum | 1,440 minutes (values above are clamped down) |
| Auto-pause threshold | 5 fires per ET day, per rule |
Symbols per scope.symbols | 1 to 20 |
| Max condition nesting depth | 3 |
| Max leaf conditions per rule | 20 |
| Max formula expression length | 200 characters |
| Max formula parenthesis depth | 10 |
| Addressable fields | 94 (84 stock-level + 10 macro) |
| Rule name | Max 120 characters, truncated not rejected |
emailTo | Max 256 characters |
webhookUrl | Max 500 characters, https:// only, public host only |
webhookSecret | 64 lowercase hex characters (32 random bytes) |
| Webhook attempts per delivery | 4 (initial + 3 retries) |
| Webhook backoff | 1s, 5s, 25s |
| Webhook timeout per attempt | 5 seconds |
| Webhook auto-disable | 20 consecutive failed deliveries |
| Email attempts per fire | 2 (initial + 1 retry after 5s) |
Events per GET /{id}/events | limit clamped to 1-200, default 50 |
| Templates | 10 |
Rule management calls (create, list, update, pause, resume, delete) draw on the same daily request budget as every other FlashAlpha endpoint and carry the usual X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. Evaluation and delivery are server-side and do not consume your quota, so a rule that fires all day costs you nothing beyond the calls you made to create it.
FAQ
What is the FlashAlpha Alerts API?
The Live Screener that watches instead of returning. You POST a rule describing a condition over dealer positioning, volatility, and macro fields, and FlashAlpha evaluates it against the in-memory screener store during market hours. When the condition becomes true for a symbol you get an email and, optionally, a signed HTTPS webhook.
Which plans can create alerts?
Alerts are in open beta, so every plan can arm rules today. Free and Basic accounts are capped at 2 rules on a 15-minute cadence. At general availability the feature becomes Growth and above. Paid capabilities stay tier-gated throughout the beta: custom condition DSL and webhooks need Growth, formulas and universe scope need Alpha.
How often are alert rules evaluated?
Alpha and Enterprise rules on every evaluator tick, Growth rules every 5 minutes, open-beta Free and Basic rules every 15 minutes. Evaluation runs only on US trading days between 09:30 and 16:00 ET, so nothing fires overnight, at weekends, or on market holidays.
Will an alert keep firing while the condition stays true?
No. Alerts are edge-triggered per rule and per symbol: a rule fires once when a symbol goes from not matching to matching, then stays silent for as long as the condition holds. It re-arms only after the symbol stops matching. There is no resolved or all-clear notification.
How do I verify a FlashAlpha webhook signature?
Every webhook carries an X-FlashAlpha-Signature header: the lowercase hex HMAC-SHA256 of the raw request body, keyed by the webhookSecret string returned when the rule was created. Compute the same HMAC over the raw bytes before parsing the JSON, and compare with a constant-time function such as hmac.compare_digest in Python or crypto.timingSafeEqual in Node. Worked examples in three languages are in Webhooks.
Can I alert on a single option contract or expiry?
No. Alert conditions accept stock-level and macro fields only. Any field prefixed with expiries., strikes., or contracts. is rejected with a validation error. Use POST /v1/screener for contract-level and expiry-level queries, and alert on the stock-level aggregate instead.
What happens if my webhook endpoint goes down?
Each delivery gets 4 attempts with 1s, 5s, and 25s backoff and a 5-second timeout per attempt. Redirects are not followed, so a 3xx counts as a failure. After 20 consecutive failed deliveries the webhook URL is cleared from the rule and a statusReason explains why. Re-enable it by sending the URL again with PUT, which also rotates the signing secret.
Why did my alert stop firing?
Three usual suspects. The rule is inside its cooldown window, in which case edges are dropped rather than queued. The rule hit the auto-pause cap of 5 fires in one ET day and is now AutoPaused. Or the owning account changed plan and the rule is TierSuspended because it uses a capability the new plan does not include. Check status and statusReason on GET /v1/alerts, then read the fire history.
Arm your first alert
Alerts are in open beta and available on every plan right now. Start with a template on the symbols you already watch, then move up to custom conditions, webhooks, and universe scans as you need them.
Related
Complementary endpoints
- Live Screener - the same condition grammar, returning rows instead of watching
- Screener Field Taxonomy - definitions, units, and tiers for all 94 alert-addressable fields
- Screener Cookbook - recipes that translate directly into alert conditions
- Gamma Exposure (GEX) - the per-symbol detail behind
net_gex,call_wall, andgamma_flip - VRP Analytics - the volatility risk premium behind
vrp_20dandvrp_regime - Zero-DTE - the same-day exposure behind
zero_dte_magnetandzero_dte_net_gex
Ready to build?
Get your free API key and start pulling live options data in 30 seconds.