# Deadline monitors: scheduled work must finish on time

> Register each run with an ingest key or the API, finish it with a span, log or check-in; a run without a success by its deadline alerts.

A **deadline monitor** watches work your app schedules for a specific time: a post set to publish at 08:30, an invoice run due on the 1st, a report promised by 09:00. Your app **registers each run (an occurrence) before it is due**; if no success arrives by the due time plus the grace period, Belvador alerts `MONITOR_DEADLINE_MISSED`.

> A run that was **never registered** cannot be reported as missed. Register when the work is scheduled, not when it starts.

## 1. Create the monitor

**Monitors → Create monitor → Deadline**. Choose:

- **Grace after the due time** — 0 to 1440 minutes. The deadline of each run is its due time + grace.
- **Also alert when a started run goes quiet** (optional) — see [stalled runs](https://belvador.dev/en/docs/monitors/signals#progress-stalled-runs).
- **Environments** — each is judged on its own.

The monitor's **slug** is what your app sends.

## 2. Get an ingest key

In **Project → Settings → Ingest keys**, create a key with the **monitors** scope (limit it to environments if you like) and store it as a server secret, for example `BELVADOR_INGEST_KEY`. Ingest keys are secrets: register from your **server**, never from a browser — requests with an `Origin` header are refused.

## 3. Register each run

```bash
curl -fsS -m 10 --retry 3 -X POST "https://ingest.belvador.dev/api/<project id>/occurrences/" \
  -H "Authorization: Bearer $BELVADOR_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"monitor_slug":"scheduled-posts","occurrence_id":"post_8f2a91","due_at":"2026-11-02T08:30:00Z","environment":"production"}'
```

Or from TypeScript on your server:

```ts
// Server only: the ingest key is a secret. Call when the job is scheduled, before it is due.
export async function expectRun(occurrenceId: string, dueAt: Date, environment = "production") {
  const res = await fetch("https://ingest.belvador.dev/api/<project id>/occurrences/", {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.BELVADOR_INGEST_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      monitor_slug: "scheduled-posts",
      occurrence_id: occurrenceId, // the same id again moves its due time
      due_at: dueAt.toISOString(),
      environment,
    }),
    signal: AbortSignal.timeout(10_000),
  });
  // 200 registered | rescheduled | duplicate. 429 and 503 carry Retry-After: try again later.
  if (!res.ok) throw new Error(`Belvador occurrence: HTTP ${res.status}`);
}
```

| Field | Rule |
|---|---|
| `monitor_slug` | The deadline monitor's slug |
| `occurrence_id` | Your id for this run, 1–128 characters of `A–Z a–z 0–9 . _ : -`. Use a UUID if the run finishes with a check-in |
| `due_at` | ISO 8601 time with an offset, from 5 minutes ago up to 35 days ahead |
| `environment` | Optional, `production` when absent; must be one the monitor watches and the key allows |

The body may be up to 4 KB. Answers:

| Status | Meaning |
|---|---|
| `200` `registered` / `rescheduled` / `duplicate` | Accepted. The same id again with a new `due_at` moves a waiting run |
| `400` | The body does not match the fields above |
| `401` / `403` | Missing or wrong key, a key without the monitors scope or for another project, a browser request, or an environment the key does not allow |
| `404` | No deadline monitor with this slug in the project |
| `422` | `due_at` out of range, an environment the monitor does not watch, or a run that already finished |
| `429` | Too many waiting runs (plan default 10,000 per monitor, 100,000 per project). Retry after `Retry-After` (300 s); each refusal is counted on the monitor page |
| `503` | Your node is unreachable right now. Nothing was registered — retry after `Retry-After` |

The same call is available as `POST /v1/occurrences` with an API token (scope `monitors:write`), for tools and AI agents.

## 4. Finish the run

Any of these settles a run with the same id:

- a **span** or **log** with `belvador.run.id` = the occurrence id and `belvador.outcome` = `success` (or `failure`):

```ts
import { trace } from "@opentelemetry/api";

// Inside the job: the span that does the work names the run and its outcome.
await trace.getTracer("jobs").startActiveSpan("publish-post", async (span) => {
  span.setAttribute("belvador.run.id", occurrenceId);
  try {
    await publishPost();
    span.setAttribute("belvador.outcome", "success");
  } catch (err) {
    span.setAttribute("belvador.outcome", "failure");
    throw err;
  } finally {
    span.end();
  }
});
```

- a **check-in** `ok` (or `error`) whose `check_in_id` is the occurrence id — check-in ids are UUIDs, so use UUID occurrence ids with this path.

A success before the deadline is **on time**. A failure alerts `MONITOR_FAILED`. A success that arrives after a missed deadline is recorded as **late** (up to 7 days later); the missed alert stays in the history and the monitor recovers.

## Cancel a run

If your app moves or drops the work, cancel the waiting run so it never alerts:

```bash
curl -fsS -m 10 --retry 3 -X DELETE "https://ingest.belvador.dev/api/<project id>/occurrences/" \
  -H "Authorization: Bearer $BELVADOR_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"monitor_slug":"scheduled-posts","occurrence_id":"post_8f2a91","environment":"production"}'
```

`DELETE /v1/occurrences/<monitor_slug>/<occurrence_id>` does the same through the API.

## What the monitor page shows

**Next due** with a countdown, a strip of the last 24 hours (on time · late · missed · failed · cancelled · waiting), and the table of occurrences. Occurrence ids are shown exactly as sent and are never copied out of your node; alerts never include them.
