# Create Alert

```
POST 
/v1/alerts
```

Create an alert on a dashboard you can edit — from five fields, or in full.

**The five fields.** `when` / `say` / `every` / `capture` / `to` are the five things a caller actually decides, and one call is enough::

when: SELECT region, ROUND(margin\*100,1) AS margin\_pct FROM margins WHERE margin < 0.10 ORDER BY margin LIMIT 5 say: Profit margin {margin\_pct}% is below 10% in {region} every: 0 7 \* \* \* (or "15m", "2h", "1d") capture: await page.goto('?f.region={region}'); await expect(page.getByTestId('chart-rendered')).toBeVisible(); await page.locator('\[data-capture-root]').screenshot(); to: \["<finance@acme.com>"]

They are NAMING, DEFAULTS and one round trip over the structured fields — not a second model. Each is rewritten onto `condition` / `schedule` / `captureRecipe` / `recipients` before the row is written, so the alert you read back is the same shape as one created the long way, and `capturePlaywright` is the normalised `capture` you wrote.

**`{column}` binds the picture to the breach.** A placeholder in `capture` is a VALUE: `?f.region={region}` captures the region that actually fired, not the default view, and the link in the message carries the same filters. A placeholder naming a column `when` does not SELECT is refused HERE, with the column named and the available ones listed — an agent can still fix it; a recipient cannot.

**Every refusal names the fix.** An unknown column, an address that is neither an email nor a Slack webhook, a duration that does not tile its unit (`7m` does not mean "every 7 minutes" in cron), a payload with neither a clock nor a condition, and a cron expression that is VALID and can never occur (`0 0 30 2 *`) each come back as a 400 saying what to do instead.

**The long form is unchanged.** `condition`, `schedule`, `captureRecipe` / `capturePlaywright`, `recipients` and `viewState` are still accepted exactly as before — `viewState` is the only way to pin a page, a date window or a measure without writing a recipe. Passing both spellings of the same thing is a 400 naming the pair, never a precedence rule.

**The response carries a `backtest`: how often this condition would have fired over history.** A threshold that can never fire used to look exactly like a healthy service — `status: "active"`, `not_fired`, `rowCount: 0`, byte-for-byte what a working alert returns. `backtest.crossings` is the number that separates them and `backtest.scope` says over what history it was counted; `backtest.note` says it in one sentence. **`crossings: 0` is never a refusal** — watching for a line nobody has crossed yet is often the point, so the alert is created either way, and a backtest that could not run (a cold SQL worker) reports `warming_up` rather than failing the creation. Ask again with `POST /v1/alerts/{alert_id}/backtest`.

**An inline condition may only read the DASHBOARD'S OWN datasets.** There is nowhere else for it to name data: the alert hangs off a viz, and that viz's `dataset_ids` are the scope the SQL worker registers. A `FROM` naming a table bound to some other dashboard resolves to nothing, and the engine says so — `Catalog Error: Table with name X does not exist! Did you mean "Y"?`.

**`status` is honest from the moment the row exists.** That refusal used to arrive days later, on the alert's first scheduled run, while the create response said `status: "active"` — an alarm reported as armed that was not. The backtest above runs the real query through the real executor, so the refusal is already in hand here: when it comes back, the alert is created with `status: "error"` and `statusReason` carrying the engine's own sentence, exactly what the first evaluation would have written. It is still a creation and never a 400 — a refusal decided by how warm the SQL worker happened to be would not be a gate — and it clears itself on the next evaluation that succeeds. `warming_up` leaves `status` alone: nothing was measured, so nothing is known.

**Evaluation, capture AND DELIVERY are all live.** `POST /v1/alerts/\{id\}/evaluate` runs the condition, the tick runs it on the schedule, and a fire is delivered — email, Slack and in-app all send, and the outcome is written back onto the row (`lastFiredAt` moves only once a recipient was reached). This paragraph denied that for weeks while the product was sending their mail, and because these docstrings are codegen input the denial travelled from here into the OpenAPI spec, both SDKs and the docs site; do not reintroduce the denial — a gate asserts its absence (`packages/mcp/tests/test_alert_delivery_claims_agree.py`).

## Request[​](#request "Link direto para request")

## Responses[​](#responses "Link direto para Responses")

* 201
* 404
* 422

Successful Response

Not found

Validation Error
