Options Alerts API - GEX, Gamma Flip & 0DTE Webhook Alerts | FlashAlpha
Lab API Alerts
OPEN BETA GROWTH+ ALPHA

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.

OPEN BETA Every plan can arm alerts right now

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 →

New here? An alert rule is a screener condition with a name, a scope, and a delivery channel. If you have never written one, read the Live Screener reference first: the condition grammar is the same, minus the nested expiry, strike, and contract levels.

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:

1. Edge-triggered. A rule fires once per symbol on the transition from not matching to matching, then goes quiet. It re-arms when the symbol stops matching. There is no resolved notification.
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

POST /v1/alerts
Auth required (X-Api-Key) Rate Limited: Yes Returns: 201 + webhookSecret

Request Body (JSON)

Field Type Required Description
namestringnoRule name, max 120 chars. Leads the email subject. Omitted names are derived from the template or the condition
templateKeystringeitherOne of the 10 template keys. Mutually exclusive with condition
templateParamsobjectnoNumeric parameters for the template. Unknown names are rejected, they do not fall back to defaults
conditionobjecteitherCustom condition tree. Growth+. Mutually exclusive with templateKey
formulasarraynoNamed arithmetic expressions (Alpha). Cannot be combined with a template
scopeobjectno{"type":"symbols","symbols":[...]} or {"type":"universe"}. Default type is symbols
emailEnabledboolnoDefault true
emailTostringnoOverride recipient, max 256 chars. Null or blank uses the account email
webhookUrlstringnohttps:// only, public host only, max 500 chars. Growth+
cooldownMinutesnumbernoClamped to 5-1440. Default 60
Important: supply exactly one of templateKey or condition. Supplying both, or neither, returns 400 validation_error.

Full endpoint list

MethodPathSuccessDescription
GET/v1/alerts200All your rules, newest first, with count
POST/v1/alerts201Create a rule. The only response that returns webhookSecret unconditionally
PUT/v1/alerts/{id}200Full replace. Revives an AutoPaused or TierSuspended rule and clears today’s fire count
POST/v1/alerts/{id}/pause200Status becomes Paused. Frees a rule slot
POST/v1/alerts/{id}/resume200Status becomes Active, clears statusReason and today’s fire count
DELETE/v1/alerts/{id}204Permanent. Fired events for the rule are removed with it
POST/v1/alerts/{id}/delete204CORS alias for DELETE, for browser clients that cannot preflight DELETE
GET/v1/alerts/templates200The 10 templates with params, defaults, min tier, and an available flag for your plan
GET/v1/alerts/{id}/events200Fire 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.

Store 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

OPEN BETA Alerts are open to every plan while the beta runs

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.

Growth $239 / mo billed annually
Active rules: 20
Cadence: every 5 minutes
Scope: up to 20 symbols per rule, from the 20-symbol Growth universe
All 10 templates
Custom condition DSL
HMAC-signed webhooks
No formulas, no universe scope
Alpha & Enterprise $1,199 / mo billed annually
Active rules: 100
Cadence: every evaluator tick
Scope: up to 20 named symbols, or the whole ~250-symbol universe
Everything in Growth
Formulas in conditions (atm_iv / rv_20d)
scope: {"type":"universe"} whole-market scans
Alpha-only fields: dealer_flow_risk, vrp_regime, days_to_earnings

Capability matrix

CapabilityFree / Basic (beta)GrowthAlpha / Enterprise
Active rules220100
Evaluation cadence15 min5 minEvery tick
TemplatesYes, up to the template’s own min tierAll 10All 10
Custom condition DSLNoYesYes
Formulas in conditionsNoNoYes
WebhooksNoYesYes
Email deliveryYesYesYes
scope: symbolsTier 1 symbols onlyPlan universePlan universe
scope: universeNoNoYes
Alpha-gated fields in conditionsNoNoYes

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} returns 400, it does not quietly use 40.
KeyWhat it watchesParams (default)Min tier
price_above_call_wallSpot trades above the call wallnoneFree
price_below_put_wallSpot trades below the put wallnoneFree
regime_flip_negativeDealer gamma regime turns negativenoneFree
regime_flip_positiveDealer gamma regime turns positivenoneFree
iv_spikeATM implied vol above a levelthreshold (40)Basic
vrp_rich20d variance risk premium above a levelthreshold (2.5)Basic
zero_dte_magnet_nearSpot within X percent of the 0DTE magnetpct (0.5)Basic
pc_volume_extremePut/call volume ratio above a levelratio (2.0)Growth
vix_aboveVIX above a level (macro field)level (25)Growth
earnings_moveEarnings near and implied move largedays (3), move_pct (5)Alpha
Units matter. 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

Rejected: 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

OperatorMeaningReads asValue typeExample
eqEquals (tolerance 1e-9)field = valuenumber"value": 0
neqNot equalfield valuenumber"value": 0
gtGreater thanfield > valuenumber"value": 25
gteGreater than or equalfield valuenumber"value": 2.5
ltLess thanfield < valuenumber"value": -500000000
lteLess than or equalfield valuenumber"value": 3
betweenWithin range (inclusive)min field maxarray [min, max]"value": [15, 25]
inMatches any value in listfield [list]array of numbers"value": [0, 1, 2]
is_nullNo value availablefield is nullomit{"field":"max_pain","operator":"is_null"}
is_not_nullHas a valuefield is not nullomit{"field":"call_wall","operator":"is_not_null"}

String operators

OperatorMeaningValue typeExample
eqEquals (case-insensitive)string"value": "negative_gamma"
neqNot equalstring"value": "positive_gamma"
inMatches any string in listarray of strings"value": ["backwardation", "mixed"]
is_nullNo value availableomit{"field":"vrp_regime","operator":"is_null"}
is_not_nullHas a valueomit{"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)

CategoryFieldsTier
Priceprice, bid, ask, midGrowth
Exposureregime, net_gex, net_dex, net_vex, net_chexGrowth
Key Levelsgamma_flip, call_wall, put_wall, max_positive_gamma, max_negative_gamma, highest_oi_strike, max_pain, zero_dte_magnetGrowth
Zero-DTEzero_dte_net_gex, zero_dte_pct_of_totalGrowth
Volatilityatm_iv, rv_5d, rv_10d, rv_20d, rv_30d, rv_60dGrowth
VRP (basic)vrp_5d, vrp_10d, vrp_20d, vrp_30d, vrp_assessmentGrowth
Skewskew_25d, skew_25d_put, skew_25d_callGrowth
Term Structureterm_near_slope_pct, term_far_slope_pct, term_stateGrowth
Liquidityatm_spread_pct, wing_spread_pct, atm_contracts, wing_contractsGrowth
OI Concentrationtop_3_pct, top_5_pct, herfindahlGrowth
Flowtotal_call_oi, total_put_oi, total_call_volume, total_put_volume, pc_ratio_oi, pc_ratio_volumeGrowth
IV Dispersioniv_dispersion_cross_expiry, iv_dispersion_cross_strikeGrowth
GEX by DTEgex_0to7_dte, gex_8to30_dte, gex_31to60_dte, gex_61plus_dteGrowth
VRP (extended)variance_risk_premium, convexity_premium, fair_vol, vrp_z_score, vrp_percentile, vrp_regimeAlpha
Directional VRPput_wing_iv_25d, call_wing_iv_25d, downside_rv_20d, upside_rv_20d, downside_vrp, upside_vrpAlpha
Strategy Scoresharvest_score, net_harvest_score, dealer_flow_risk, short_put_spread_score, short_strangle_score, iron_condor_score, calendar_spread_scoreAlpha
Volatility Forecastewma_vol, harrv_vol, garch_vol_1d, garch_vol_longrun, garch_persistence, garch_half_life_daysAlpha
Earningsnext_earnings_date, days_to_earnings, earnings_implied_move_pct, expected_iv_crush_pctAlpha

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

ElementSupported
Operators+, -, *, / and parentheses, with standard precedence
Unary minus-net_gex, -(price - max_pain)
Numeric literals100, 0.5, 10000
IdentifiersLowercase snake_case numeric stock fields only
FunctionsNone. No abs(), min(), max(), sqrt(), or log()
Max expression length200 characters
Max nesting depth10 parenthesised levels
Division by zeroYields null
Null operandPropagates null through the whole expression
Null resultNever matches, so the symbol is silently skipped

The macro asymmetry

Macro fields work in conditions but not inside formulas. {"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.
  • formulas cannot be combined with templateKey; that returns Formulas cannot be combined with a template.
Formula-only rules produce thin snapshots. The snapshot attached to every fire captures the values of the fields the condition names, plus 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"}.

TypeShapeTierNotes
symbols{"type":"symbols","symbols":["SPY","QQQ"]}All1 to 20 symbols. Uppercased, trimmed, de-duplicated. Every symbol must be in your plan universe
universe{"type":"universe"}AlphaEvery symbol in the live store, currently around 250 names
watchlistnot availablen/aReserved. 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.

BehaviourDetail
RecipientemailTo if set, otherwise the account email on the API key
SubjectFlashAlpha Alert: {rule name}. Matched symbols are in the body, not the subject, so long names stay readable on a phone
BodyRule name, matched symbols, ET timestamp, and a table per symbol of the field values captured at trigger time
UnsubscribeHonours the account-level alert email preference. Unsubscribing stops the mail but not the fire record or the webhook
emailTo validationMust parse as an email address and contain a dot. Max 256 chars
Retries2 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).

Verify the raw bytes before parsing. Frameworks that JSON-decode and re-encode a request body will change whitespace, key order, or number formatting, and the signature will no longer match. Read the raw body first, verify, then parse. And always compare with a constant-time function.
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

RuleValue
Attempts per delivery4 (initial + 3 retries)
Backoff between attempts1s, then 5s, then 25s
Timeout per attempt5 seconds
SuccessAny 2xx
RedirectsNot followed. A 3xx counts as a failure, so point the rule at the final URL
Schemehttps:// only. http:// is rejected at create time
HostPublic 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
ConcurrencyUp to 4 deliveries in flight across the fleet
Auto-disableAfter 20 consecutive failed deliveries the URL is cleared from the rule
OrderingNot guaranteed. Use firedAt if order matters
Idempotency keyruleId + 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.

TargetApproach
DiscordRelay that maps the payload to {"content": "..."} or an embed, then POSTs to the Discord webhook URL. One relay can serve every rule
SlackSame shape, mapping to {"text": "..."} or Block Kit and posting to the Slack incoming webhook
TelegramRelay calls sendMessage on the Bot API with your chat id. Format symbols and the snapshot table as the message text
n8n / Make / ZapierUse a Webhook trigger node as the webhookUrl, add an HMAC verification step, then branch on symbols and snapshot fields
Your own bot or OMSConsume 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.

GET /v1/alerts/{id}/events
Auth required (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

StateMeaning
NoneChannel not configured for this rule (no webhook URL, for example)
PendingQueued, outcome not yet written back
SentDelivered and accepted
FailedAll attempts exhausted without a 2xx
SkippedDeliberately not sent, for example the account unsubscribed from alert email
DisabledThe 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

StatusEvaluated?Consumes a slot?How you get hereHow you get out
ActiveYesYesCreate, PUT, or resumePause, or an automatic transition
PausedNoNoPOST /{id}/pausePOST /{id}/resume
AutoPausedNoYes5 fires in one ET dayPOST /{id}/resume or PUT
TierSuspendedNoYesThe owner’s plan no longer permits the ruleUpgrade, then resume
Only 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

StatusReason
AutoPausedAuto-paused: fired 5 times today. Widen your threshold and resume.
TierSuspendedAlerts require the Growth plan or higher.
TierSuspendedCustom alert conditions require the Growth plan or higher.
TierSuspendedUniverse-scope alerts require the Alpha plan.
TierSuspendedOwner account not found.
ActiveWebhook 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 Active and clears statusReason, 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 (null otherwise).

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

CodeWhen
200List, update, pause, resume, templates, events
201Rule created. The body carries webhookSecret
204Rule deleted
400validation_error or formula_error
401Missing or invalid X-Api-Key
404Rule id unknown, or owned by another account
429Daily request budget exhausted, shared with every other FlashAlpha endpoint

Common validation errors

TriggerMessage
Feature gated (post-beta, below Growth)Alerts require the Growth plan or higher.
Rule quota reachedAlert limit reached for your plan (20 active alerts).
Both or neither of template and conditionSpecify exactly one of ‘templateKey’ or ‘condition’.
Bad template keyUnknown alert template ‘regime_flip’.
Template above your tierTemplate ‘earnings_move’ requires the Alpha plan or higher.
Typo in a template paramTemplate ‘iv_spike’ has no parameter ‘treshold’.
Formulas plus a templateFormulas cannot be combined with a template.
Custom DSL below GrowthCustom alert conditions require the Growth plan or higher. Use a template instead.
Formulas below AlphaFormula expressions require the Alpha plan.
Chain-level field in a conditionAlert conditions support stock-level and macro fields only.
Alpha field on a lower planField ‘dealer_flow_risk’ requires the Alpha plan or higher.
Too deepFilter nesting exceeds maximum depth of 3.
Too many leavesToo many conditions (24). Maximum is 20.
not groupUnknown logical operator ‘not’. Use ‘and’ or ‘or’.
Empty groupGroup operator ‘and’ requires at least one condition.
Universe scope below AlphaUniverse-scope alerts require the Alpha plan.
Watchlist scopeWatchlist scope is not available yet. Use scope ‘symbols’ (up to 20) or ‘universe’ (Alpha).
Empty or oversized symbol listScope ‘symbols’ requires 1-20 symbols.
Symbol off-planSymbol(s) not available on your plan: HIMS.
Webhook below GrowthWebhook delivery requires a paid plan.
Non-HTTPS webhookWebhook URL must be a valid https:// URL.
Private-address webhookWebhook URL must resolve to a public address.
Bad emailToAlert 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:

ExpressionMessage
atm_iv / vixUnknown field ‘vix’ at position 9.
abs(price - max_pain)Unknown field ‘abs’ at position 0.
atm_iv / (rv_20dExpected ‘)’ at position 16.
ATM_IV * 2Invalid character ‘A’ at position 0. Field names are lowercase
201+ charactersFormula expression exceeds maximum length of 200 characters.

Limits

Every hard number in the alerting system, in one place.

LimitValue
Active rules, Free / Basic (open beta)2
Active rules, Growth20
Active rules, Alpha / Enterprise100
Evaluation cadence, Free / Basic (open beta)15 minutes
Evaluation cadence, Growth5 minutes
Evaluation cadence, Alpha / EnterpriseEvery evaluator tick
Evaluation window09:30 to 16:00 ET, US trading days only
Cooldown, default60 minutes
Cooldown, minimum5 minutes (values below are clamped up)
Cooldown, maximum1,440 minutes (values above are clamped down)
Auto-pause threshold5 fires per ET day, per rule
Symbols per scope.symbols1 to 20
Max condition nesting depth3
Max leaf conditions per rule20
Max formula expression length200 characters
Max formula parenthesis depth10
Addressable fields94 (84 stock-level + 10 macro)
Rule nameMax 120 characters, truncated not rejected
emailToMax 256 characters
webhookUrlMax 500 characters, https:// only, public host only
webhookSecret64 lowercase hex characters (32 random bytes)
Webhook attempts per delivery4 (initial + 3 retries)
Webhook backoff1s, 5s, 25s
Webhook timeout per attempt5 seconds
Webhook auto-disable20 consecutive failed deliveries
Email attempts per fire2 (initial + 1 retry after 5s)
Events per GET /{id}/eventslimit clamped to 1-200, default 50
Templates10

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.

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, and gamma_flip
  • VRP Analytics - the volatility risk premium behind vrp_20d and vrp_regime
  • Zero-DTE - the same-day exposure behind zero_dte_magnet and zero_dte_net_gex

Ready to build?

Get your free API key and start pulling live options data in 30 seconds.