# Evaluate Alert

```
POST 
/v1/alerts/:alert_id/evaluate
```

Evaluate this alert's condition NOW, outside its schedule, and say what it concluded.

This is a REAL evaluation, not a dry run: it runs the condition's query on the same worker the scheduler uses, records the verdict, and — when the verdict TRANSITIONS into the breach — queues the capture, mails every recipient, and moves `lastFiredAt`. Calling it twice in a row on a still-breaching alert answers `still_firing` the second time and sends nothing; that is the hysteresis rule, not a failure.

**`lastEvaluatedAt` DOES NOT MOVE.** It reports SCHEDULED runs only — the same rule `POST /v1/subscriptions/{id}/test` has always stated for `lastRun` / `lastStatus`: writing it here would claim the schedule ran when it did not, and a customer reading the row back a week later cannot tell a cron firing from somebody's poke. It also feeds `is_due` and `registration.nextRun`, so a poke that stamped it moved the schedule.

`lastFiredAt` is different and DOES move on a fire here, because it answers "when did this alert last tell somebody" — and this route really did tell them. Which runs were scheduled and which were hand-asked is answerable from `GET /v1/alerts/{alert_id}/deliveries`, where every send carries its own `trigger`.

Read `outcome`:

* `fired` — the condition holds and did not hold before. `deliveryId` is the capture row queued for it.

* `still_firing` — it holds and already held. Audited, deliberately quiet.

* `not_fired` — it does not hold; the alert is re-armed. **Read `verdict.observation` with it**, because `not_fired` is two different facts: `measured` means a number came back and was compared (proof a row was examined, and the number is on the verdict), while `nothing` means the query returned no rows and nothing else — which is ALSO what an alert whose scan set is empty returns (a mistyped filter value, a date nobody has data for yet, a feed that stopped). The truthy shape cannot tell those apart; the sugar shape can, and reports the empty scan as `error`. `note` says all of this in words, derived from the verdict.

* `error` — it could NOT be measured (bad SQL, a table the dashboard does not bind, the question was deleted, the owner lost dataset access, or — for a SUGAR condition only — a result that is not one numeric cell). `verdict` is null, `error` names the failure, and the alert's `status` is now `error` so it is visibly broken rather than quietly silent. A failed measurement is never reported as `fired: false`.

* `warming_up` — **202, not 200, and not an error.** No SQL worker answered inside this request's budget, so the condition never ran. The alert is untouched: no verdict, no `lastEvaluatedAt`, no `error` status. A warm-up was kicked, so a retry in \~a minute (or the next scheduled tick) measures it. Before this outcome existed the request simply waited past the `core` lambda's 30 s timeout and the caller got an empty **503** — measured on prod 2026-09-09 — which said nothing and left the row equally untouched without saying so.

400 when the alert has no condition — there is nothing to evaluate; use `POST /v1/alerts/{alert_id}/test` to exercise its capture instead.

## Request[​](#request "Direct link to request")

## Responses[​](#responses "Direct link to Responses")

* 200
* 202
* 404
* 422

Successful Response

The SQL engine was cold and the condition was not measured (`outcome: "warming_up"`). Nothing is broken and nothing was written to the alert; retry, or let the schedule take it.

Not found

Validation Error
