---
title: "Create or upsert a check"
method: POST
path: "/api/v1/checks"
tags: ["Checks"]
---

# Create or upsert a check

`POST /api/v1/checks`

## Request

- Base URL: `https://app.lastping.dev`
- URL: `https://app.lastping.dev/api/v1/checks`
- Auth: API key in header `X-Api-Key`

Creates a new check and returns 201.

**Upsert:** if `slug` is non-empty and a check with that slug already
exists in the project, the existing check's config is updated and 200 is
returned instead of 201.

**Create-only (`If-None-Match: *`):** send the request header
`If-None-Match: *` to opt out of the upsert behaviour above (RFC 9110
§13.1.2 — apply only if the resource does not already exist). If a
check with the given `slug` already exists, the request is rejected
with `412 Precondition Failed` and the existing check is left
completely unmodified, instead of being silently adopted/updated.
This is intended for IaC clients (e.g. a Terraform provider) where a
slug collision must surface as an error rather than take over a
monitor the client did not create. The header is opt-in: omitting it
preserves the upsert behaviour above for existing callers (dashboard,
MCP tools). Any other `If-None-Match` value (e.g. an ETag) is ignored.

## Headers

- `If-None-Match` string

## Request body

- CheckCreate: Body for creating a check (POST /api/v1/checks). The update body is a separate schema — see `CheckPatch` for PATCH /api/v1/checks/{id}. **Monitor types:** - `heartbeat` (default) — expects your service to ping the `ping_url` on schedule. - `ci` — like heartbeat but semantically used for CI/CD pipeline pings. - `http` — LastPing actively probes the `probe_url` on the given interval. When `monitor_type` is `http` the `probe_*` fields configure the probe and the schedule is driven by `probe_interval_s`. **Every field is validated against the monitor's SHAPE.** A field the chosen `monitor_type` (and `schedule_kind`) has no place for is rejected with 400 (`FIELD_NOT_IN_SHAPE`) instead of being accepted and silently discarded. Examples: `schedule_kind`/`period_s`/`cron_expr`/`tz` or any `ci_*` field on an `http` monitor; any `probe_*` field on a `heartbeat`; `ci_workflow`/`ci_branch` with no `ci_provider`; `period_s`/`cron_expr`/`tz` on an `on_demand` schedule. `null`, `""`, `0`, `false` and empty collections are treated as "no value" and never trigger this rejection, so a client that serialises a full document with zero values for the fields it does not use is unaffected. An unrecognised `monitor_type` is rejected with 400 (`UNKNOWN_MONITOR_TYPE`); it used to create a `heartbeat`. **Upsert:** if `slug` is non-empty and a check with that slug already exists in the project, the config is updated and 200 is returned instead of 201. That branch does **not** write `ci_workflow`/`ci_branch` — see those two properties for the full statement, and use `PATCH /api/v1/checks/{id}` to set them.
  - `name` string, required: Human-readable name for the check: at most 200 characters (not bytes) and no control or invisible characters (a tab, newline, zero-width character or non-ASCII space is rejected with 400 `INVALID_NAME`; an ordinary space is fine). Bytes that are not valid UTF-8 are replaced with U+FFFD when the request is decoded, and accepted.
  - `slug` string: Optional stable identifier. If provided and a check with this slug already exists in the project, the request performs an upsert (config update) and returns 200 instead of 201. Input is trimmed and lowercased before validation and before the uniqueness lookup, so `" Nightly-Backup "` and `"nightly-backup"` are the same slug. Must then match `^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$` (3-50 chars, lowercase alphanumeric and hyphens, starting and ending alphanumeric); a 400 is returned otherwise. A UUID-shaped slug is rejected — the Terraform provider resolves an import ID by trying to read it as a UUID first, so a UUID-shaped slug could never be imported by slug and could collide with another monitor's real id. An empty slug means "no slug" — it is exempt from the uniqueness check, is not validated, and multiple unslugged monitors are allowed in the same project.
  - `monitor_type` 'heartbeat' | 'ci' | 'http': Monitor mode. Defaults to `heartbeat` when omitted. Any other value is rejected with 400 (`UNKNOWN_MONITOR_TYPE`). Use `http` to have LastPing actively probe a URL instead of waiting for an inbound ping. `ci` is a label, not a binding. A CI monitor is stored as a `heartbeat` row with a CI binding attached, so it is `ci_provider` that makes a monitor a CI monitor — `monitor_type: ci` on its own creates an ordinary heartbeat, and its `ci_workflow`/`ci_branch` are then rejected with 400 (`FIELD_NOT_IN_SHAPE`).
  - `schedule_kind` 'simple' | 'cron' | 'on_demand': `simple` uses a fixed period; `cron` uses a cron expression; `on_demand` means the check has no cadence at all — it is driven entirely by run events (`start`/`success`/`fail`/…), never arms an absence deadline, and silence between runs never alerts. Run-scoped detection (`max_runtime_s`, `step_timeout_s`) still applies once a run starts, and `expect_every_s` — the silence floor — is the only way to detect absence between runs. Required for `heartbeat` and `ci` monitor types. **Rejected with 400 (`FIELD_NOT_IN_SHAPE`) when `monitor_type` is `http`**, together with `period_s`, `cron_expr` and `tz`: an http monitor's schedule is derived from `probe_interval_s`, so all four were previously accepted and discarded.
  - `period_s` integer: Expected ping interval in seconds. Required when schedule_kind is `simple`. **Must be absent when schedule_kind is `on_demand`** — supplying it alongside `on_demand` is rejected with 400 (`ON_DEMAND_SCHEDULE_CONFLICT`).
  - `cron_expr` string: Cron expression (5-field). Required when schedule_kind is `cron`. **Must be absent when schedule_kind is `on_demand`** — supplying it alongside `on_demand` is rejected with 400 (`ON_DEMAND_SCHEDULE_CONFLICT`).
  - `tz` string: IANA timezone for cron evaluation. Defaults to `UTC`.
  - `grace_s` integer: Grace period in seconds after a ping is due before the check is considered late. This is the **silence** tolerance and the first-run (`monitor_from`) deadline. It is also the fallback run-duration budget when `max_runtime_s` is not set.
  - `max_runtime_s` integer, nullable: Run-duration budget in seconds, measured from an unmatched `/start` ping. It replaces `grace_s` for the **overrun deadline only** — the silence rule (`alert_after = due_at + grace_s`) and the first-run seed (`alert_after = monitor_from + grace_s`) keep using `grace_s`. This is what makes "alert if silent for more than 10 minutes, but allow a 4-hour run" expressible. Time a run spends `blocked` (waiting on a person) does not count toward this budget: the run's clocks pause at the `blocked` ping, and when the next ping or step ends the wait, the waited time is added to the run's budget and the `step_timeout_s` clock starts again from that moment. Each single wait is bounded by `blocked_timeout_s`, but the total a run can add this way has no cap: a run that keeps alternating waits shorter than `blocked_timeout_s` with progress never overruns. On a `simple` or `cron` monitor the added time never postpones a missed scheduled run: when the next run's silence deadline comes sooner, that deadline still pages. Null or omitted falls back to `grace_s`, so a monitor that never sets it behaves exactly as before this field existed. **Rejected with 400 (`MAX_RUNTIME_NOT_SUPPORTED`) when `monitor_type` is `http`.** An HTTP probe has no start/success pair — the prober mints a fresh run id for every probe — so no start is ever outstanding and the overrun rule can never fire. Use `probe_timeout_s` to bound a single probe.
  - `step_timeout_s` integer, nullable: Progress budget in seconds: how long an armed run may go without posting a `/step` before a `stalled` incident opens. The anchor is the LATER of the run's `/start` and its most recent `/step`, so a run that wedges before its first step is caught too. Null or omitted disables stall detection entirely, which is how every monitor behaved before this field existed. Stall detection needs your job to call /step: the Claude Code hook and the reporting prompt do not send steps, so leave step_timeout_s unset for them. A step resets the stall clock ONLY — it never extends `max_runtime_s`, so an agent that reports progress forever still overruns. **Must be strictly less than the effective run budget, `COALESCE(max_runtime_s, grace_s)`**, or the request is rejected with 400 (`STEP_TIMEOUT_EXCEEDS_BUDGET`). The stall rule fires only inside that budget — past it the run is an overrun and `overrun` is the truer cause — so a step timeout at or above the budget leaves an empty window and the rule could never fire. **Rejected with 400 (`STEP_TIMEOUT_NOT_SUPPORTED`) when `monitor_type` is `http`.** An HTTP probe never arms a run, so there is nothing for a step to report progress within. Use `probe_timeout_s` to bound a single probe.
  - `expect_every_s` integer, nullable: **Silence floor**: open a `silence` incident if **no ping of any kind** — `success`, `start`, `fail`, `step` — has arrived within this many seconds, regardless of the schedule. It is anchored on the monitor's last activity (`last_ping_at`), never on the schedule: it is a floor on silence, not a cadence. Null or omitted turns it off, which is exactly how every monitor behaved before this field existed. **This is the only absence rule an `on_demand` monitor can have.** That schedule kind arms no deadlines between runs by design, so without `expect_every_s` an `on_demand` monitor reads `up` indefinitely however long its agent stays dark. It never fires mid-run: while a `/start` is outstanding the floor stands down entirely and the run clock owns detection (`max_runtime_s`, `step_timeout_s`). A `blocked` ping also suppresses it, bounded by `blocked_timeout_s`. On `simple`/`cron` monitors it is a **backstop**: it joins the schedule's own deadline as whichever is *sooner*, so it can tighten detection under a long cadence but can never loosen it. Accepted on every `monitor_type` and every `schedule_kind` — unlike `max_runtime_s`/`step_timeout_s` it has no run-scoped precondition that would make it a provable no-op on an HTTP monitor.
  - `notify_min_run_s` integer, nullable: **Notification duration floor**: a run **shorter** than this many seconds does not produce an INFO-CLASS notification (`success`, `started`, `every-run`, `note`). This is what stops a trivial, fast agent run — "what's 2+2" — from paging a destination routed to `success` exactly like a real, hours-long job would. **It never suppresses `down`, `fail`, `recovery` or `blocked`** — those are alert-class and are never affected by this field, however short the run. A run that failed in two seconds is exactly what a user needs to hear about. It never suppresses `started` (a run's duration does not exist yet the moment it begins), and it never suppresses an event whose duration could not be measured at all — an unknown duration always means "notify", never "suppress". Null or omitted turns it off, which is exactly how every monitor behaved before this field existed. **Rejected with 400 (`NOTIFY_MIN_RUN_NOT_SUPPORTED`) when `monitor_type` is `http`.** The floor only ever suppresses a notification once a run's duration is measured, and that measurement comes exclusively from a matched `/start` + `/success` pair — which an HTTP probe never produces (the prober mints a fresh run id per probe).
  - `blocked_timeout_s` integer, nullable: How long (in seconds) a check may sit `blocked` — reported via a `blocked` ping, meaning the run is waiting on a human — before a `blocked` incident opens. **Null or omitted does NOT mean "wait forever".** It falls back to the default, which is **24 hours**, after which the `blocked` incident opens regardless. There is no way to disable the blocked timeout entirely. Accepted on every `monitor_type`, including `http` — unlike `max_runtime_s`/`step_timeout_s` it has no run-scoped precondition that would make it a provable no-op on an HTTP monitor.
  - `failure_threshold` integer: Number of **consecutive explicit failures** required before an incident opens — retry-before-alert. Applies to the `fail` cause ONLY: `silence`, `overrun`, `never_started`, `runaway`, `stalled` and `blocked` are time- or rate-based, so a consecutive-failure count is meaningless for them and never gates them. A run that ends on the model provider's API error (`upstream`) neither counts toward it nor resets it: those page on their own count of runs in a row, which is 3 or this threshold, whichever is higher. Below the threshold nothing alerts and the status does not change, but the ping is still recorded, an outstanding run is still ended, and deadlines are still recomputed — the failed run did happen, only the alert is deferred. Any success resets the counter to zero; a cancel does not. Defaults to `1` (open on the first failure) when omitted, which is the pre-existing behaviour. Applies to `http` monitors too: a failed probe is a real `fail` signal.
  - `monitor_from` string, date-time, nullable: RFC 3339 timestamp. Deadlines are not computed before this time, useful for checks that should start alerting only from a future date.
  - `probe_url` string, uri: Absolute `http` or `https` URL for LastPing to probe. Required when `monitor_type` is `http`.
  - `probe_method` 'GET' | 'HEAD' | 'POST': HTTP method for the probe request. Defaults to `GET` when omitted. Valid values: `GET`, `HEAD`, `POST`.
  - `probe_expected_body` string: Substring that must appear in the response body for the probe to be considered successful. Leave empty to skip body matching.
  - `probe_interval_s` integer: How often (in seconds) LastPing should probe the URL. Must be between 30 and 86400. Required when `monitor_type` is `http`.
  - `probe_expected_status` integer: HTTP status code the probe must receive to count as success. No default is applied on create: an omitted (or `0`) value means "any 2xx response counts as success". Set it explicitly to require one exact status. On PATCH, an omitted field retains the stored value.
  - `probe_timeout_s` integer: Probe HTTP request timeout in seconds. Must be between 1 and 30. Defaults to 10 when omitted.
  - `probe_follow_redirects` boolean: Whether the probe should follow HTTP redirects. Defaults to `false` when omitted on create. On PATCH, an omitted field retains the stored value.
  - `ci_provider` 'github' | 'gitlab' | 'jenkins': CI provider to bind on create. When supplied (and valid), LastPing generates a webhook secret and binds it. The secret is returned ONCE in the `201` response (`ci_secret`) and is NEVER returned again — use the `POST /api/v1/checks/{id}/ci/regenerate` endpoint to rotate it. **Rejected with 400 (`FIELD_NOT_IN_SHAPE`) when `monitor_type` is `http`.** An http monitor is never bound to CI; the request used to be accepted, create no binding, and report success. Fixed at creation. On the slug upsert of a monitor that already has a binding, the stored provider is accepted as a no-op (the provider and secret stay) and a different one is rejected with 400 (`CI_PROVIDER_IMMUTABLE`); on one created without a binding it is rejected with 400 (`FIELD_NOT_IN_SHAPE`), because a binding cannot be added to an existing monitor.
  - `ci_workflow` string: Optional CI workflow name to filter inbound webhooks by (provider-specific). **Requires `ci_provider`, and this is enforced:** with no CI binding the filter is stored nowhere and matches nothing, so the request is rejected with 400 (`FIELD_NOT_IN_SHAPE`). It was previously accepted and discarded — a Terraform configuration setting `ci_workflow` without `ci_provider` applied clean and filtered nothing. **On the slug upsert** (when `slug` names a check that already exists, this POST updates it and returns 200) the CI binding is the stored one: a CI-bound monitor accepts `ci_workflow` and `ci_branch` without `ci_provider` being repeated, and a monitor created without a binding still refuses them. A filter is written only when it is sent with a non-empty value; omitted or `""` keeps the stored value, so an upsert can set a filter but never clear one. Clear a filter with `PATCH /api/v1/checks/{id}` and `null`.
  - `ci_branch` string: Optional branch name to filter inbound webhooks by. **Requires `ci_provider`, and this is enforced** — see `ci_workflow`. Rejected with 400 (`FIELD_NOT_IN_SHAPE`) when the monitor has no CI binding, and follows the same slug-upsert rule: set when sent, kept when omitted or empty, cleared only by PATCH.
  - `runaway_ceiling` integer, nullable: Maximum pings allowed in a rolling 1-hour window. Exceeding it opens a `runaway`-cause incident, which detects a looping agent or job pinging abnormally fast. Null or omitted disables the check.
  - `tags` string[]: Optional labels for namespace scoping and auditing. Max 20 tags; each tag max 50 characters. Duplicate tags are silently deduplicated. The `agent:` prefix is a convention for AI-provisioned monitors.
  - `agent_id` string: Optionally attaches this monitor to an agent at creation time, by either the agent's `id` or its `slug`. Omitted or empty means "no attachment" — it never creates an agent implicitly. **An unknown or foreign agent reference is rejected with 400 (`UNKNOWN_AGENT`)** — this is what keeps Terraform (or any IaC client) authoritative over the agents it owns: naming an agent that does not exist can never conjure one into being. Create the agent first with `POST /api/v1/agents`.
  - `source_kind` string: **Discovery source identity, part 1 of 2.** The scanner that found this thing: `crontab`, `github-actions`, `k8s-cronjob`, `systemd-timer`. Lowercase, dash-separated — it is compared byte-for-byte, so `GitHub Actions` and `github-actions` would be two different identities for one scanner. Together with `source_ref` and the project, this is the **reconcile key** that makes a discovery scan safe to re-run: the second run diffs against what already exists instead of creating every monitor a second time. Omit both fields for a hand-made monitor. Monitors with no source are never reconciled, so discovery can never adopt, modify or delete something a human created.
  - `source_ref` string: **Discovery source identity, part 2 of 2.** A stable path within `source_kind` — `.github/workflows/nightly.yml#build`, `/etc/cron.d/backup#0 3 * * *`. Must be valid UTF-8 with no control characters. **Must be sent together with `source_kind`** — exactly one of the two is rejected with 400 (`SOURCE_INCOMPLETE`). Half an identity reconciles against nothing. **A pair this project already monitors is rejected with 409 (`SOURCE_ALREADY_MONITORED`).** That rejection is the feature: it is what stops a re-run of discovery from duplicating every monitor. **On a slug upsert these two fields are read-only.** `POST /api/v1/checks` with a `slug` that already exists never rewrites a monitor's source — that would let a slug collision silently adopt a hand-made monitor into discovery — and returns 409 (`SOURCE_IMMUTABLE_ON_UPSERT`) if the body offers a different pair. Change a source deliberately with `PATCH /api/v1/checks/{id}`.
  - `trace_content` 'redacted' | 'dropped': What this monitor's traces keep of prompt, command and tool content. `dropped` (the default) removes the content attributes (prompts, completions, commands, tool arguments, results and definitions, retrieved documents, working directories) of every recognised vocabulary entirely; lengths such as `user_prompt_length`, token counts and cost are kept. Error text (`error`, `error.message`, `exception.message`, `exception.stacktrace` and the span's status message) is kept at `dropped`, redacted and then cut to 512 characters, and a cut marks the span or log event truncated. `redacted` opts in to storing that content, with every secret-shaped value replaced by `[REDACTED:<class>]` at ingest, before anything is queued or stored: API keys (LastPing, Anthropic, OpenAI, GitHub, GitLab, npm, AWS access key ids, Slack, Stripe, Google, Hugging Face, Perplexity, Fireworks, Supabase, New Relic, Airtable, Terraform Cloud, Firebase), HashiCorp Vault tokens, age secret keys, Discord bot tokens, Slack, Discord and Teams webhook URLs, `Authorization` header values, JWTs, private key blocks, `password=`/`token=`/`api_key=` style assignments (subscripted ones such as `os.environ["API_KEY"] = ...` and Dockerfile `ENV`/`ARG` lines included), SQL `PASSWORD '...'` and `IDENTIFIED BY` clauses, the Redis `AUTH` command, `.netrc` passwords, command-line password flags and the argument after them (any secret-named `--flag`, `-p` after a login subcommand, `redis-cli -a`), cookies, credentials in URLs, the value of any attribute or JSON field whose name is secret-named (password, token, api_key, secret, the same word in several other languages, an `Authorization` or `X-Api-Key` header and the like; `Idempotency-Key` and request, correlation and trace ids are not secrets), including JSON carried inside another JSON string such as tool-call arguments, and long high-entropy tokens. A bytes attribute that is valid UTF-8 is scanned and stored as its text. A span that had a value redacted carries `lastping.redacted_values`. A request whose redaction work exceeds a fixed budget stores `[REDACTED:over_budget]` for the values it could not scan, never the values themselves. In both modes every remaining value is redacted the same way. There is no setting that stores content unredacted. A change reaches ingest within 60 seconds: ingest caches each monitor's setting for up to a minute, so an export sent just after the change may still be handled the old way. Omitted means `dropped` on create. On a slug upsert an omitted value leaves the stored setting alone, so an upsert can never undo a choice made earlier; any other value is rejected with 400 (`INVALID_TRACE_CONTENT`).

## Response `200`

Existing check (matched by slug) updated; config replaced.

- Check: A check resource returned by the API.
  - `id` string, uuid, required: Unique check identifier.
  - `name` string, required: Human-readable name.
  - `slug` string, required: Stable project-scoped identifier (may be empty string if not set).
  - `schedule_kind` 'simple' | 'cron' | 'on_demand', required: `on_demand` means the check has no cadence: it never arms an absence deadline, so silence between runs never alerts. Run-scoped detection (`max_runtime_s`, `step_timeout_s`) still applies once a run starts, and `expect_every_s` — the silence floor — is the only way to detect absence between runs.
  - `period_s` integer, required: Period in seconds (only meaningful for schedule_kind=simple).
  - `cron_expr` string: Cron expression (only present when schedule_kind=cron).
  - `tz` string, required: IANA timezone.
  - `grace_s` integer, required: Grace period in seconds — the silence tolerance and the first-run deadline, and the overrun budget when `max_runtime_s` is unset.
  - `max_runtime_s` integer, nullable: Run-duration budget in seconds for the overrun deadline only. **Omitted from the response when unset**, in which case the overrun rule falls back to `grace_s`. Never set on `http` monitors.
  - `step_timeout_s` integer, nullable: Progress budget in seconds for the stall rule. **Omitted from the response when unset**, in which case stall detection is disabled. Never set on `http` monitors.
  - `expect_every_s` integer, nullable: Silence floor in seconds. **Omitted from the response when unset**, in which case absence is detected only by the schedule — and, for an `on_demand` monitor, not at all between runs.
  - `notify_min_run_s` integer, nullable: Notification duration floor in seconds. **Omitted from the response when unset**, in which case every info-class notification (`success`, `started`, `every-run`, `note`) fires regardless of how short the run was. Never set on `http` monitors. Never suppresses `down`, `fail`, `recovery` or `blocked`.
  - `blocked_timeout_s` integer, nullable: How long (in seconds) the check may sit `blocked` before a `blocked` incident opens. **Omitted from the response when unset** — that does NOT mean the check waits forever, it means the 24-hour default applies.
  - `failure_threshold` integer, required: Consecutive explicit failures required before an incident opens (`fail` cause only). Always present; `1` means "open on the first failure", the default.
  - `monitor_from` string, date-time, nullable: RFC 3339 timestamp from which deadlines are computed.
  - `status` 'new' | 'up' | 'late' | 'down', required: Current check status derived from ping history.
  - `last_ping_at` string, date-time, nullable: RFC 3339 timestamp of the most recent ping received.
  - `due_at` string, date-time, nullable: RFC 3339 timestamp when the next ping is expected.
  - `alert_after` string, date-time, nullable: RFC 3339 timestamp after which an alert is sent if no ping arrives.
  - `paused` boolean, required: When true the check does not alert regardless of ping status.
  - `maintenance_until` string, date-time, nullable: RFC 3339 timestamp until which the check is snoozed. Alerts are suppressed while this is in the future; null or absent when the check is not snoozed.
  - `created_at` string, date-time, required: RFC 3339 UTC timestamp when the check was created.
  - `ping_url` string, uri, required: The URL your service should HTTP-GET to record a successful ping.
  - `monitor_type` 'heartbeat' | 'ci' | 'http': Monitor mode. Omitted on responses when the value is the default `heartbeat`.
  - `probe_url` string, uri: URL being probed (HTTP monitors only; omitted otherwise).
  - `probe_method` 'GET' | 'HEAD' | 'POST': HTTP method used for the probe (HTTP monitors only; omitted otherwise).
  - `probe_expected_body` string: Expected response body substring (HTTP monitors only; omitted otherwise).
  - `probe_interval_s` integer: Probe interval in seconds (HTTP monitors only; omitted otherwise).
  - `probe_expected_status` integer: Expected HTTP status code (HTTP monitors only; omitted otherwise).
  - `probe_timeout_s` integer: Probe timeout in seconds (HTTP monitors only; omitted otherwise).
  - `probe_follow_redirects` boolean: Whether the probe follows redirects (HTTP monitors only; omitted otherwise).
  - `next_probe_at` string, date-time, nullable: RFC 3339 UTC timestamp when the next probe is scheduled (HTTP monitors only; omitted otherwise).
  - `ci_provider` 'github' | 'gitlab' | 'jenkins': CI provider bound to this check (omitted when no CI binding is set).
  - `ci_configured` boolean: True when a CI binding is set on this check (omitted when false).
  - `ci_webhook_url` string, uri: Webhook URL for this CI check (omitted when no CI binding is set). POST this URL from your CI provider with the `ci_secret` as the HMAC secret.
  - `ci_secret` string: **WRITE-ONCE** — returned ONLY in the `201` create response and the `POST /api/v1/checks/{id}/ci/regenerate` response. NEVER returned on GET or list. Copy it immediately; it cannot be retrieved again (use regenerate to rotate).
  - `ci_workflow` string: Optional workflow/job name filter narrowing which CI runs this check watches (omitted when unset, which watches every workflow).
  - `ci_branch` string: Optional branch filter narrowing which CI runs this check watches (omitted when unset, which watches every branch).
  - `ci_ignored` object: Finished CI runs (succeeded or failed) that `ci_workflow` or `ci_branch` filtered out since the last finished run that matched them, or since the filters last changed. Omitted when none were. A count that keeps growing with no recent match usually means a filter no longer matches anything, for example after a workflow rename or a default-branch change. A matching finished run and a change to either filter both reset it. A repository webhook sends the runs of every workflow, so with `ci_workflow` set, `count` includes other workflows' runs, which the filter is meant to drop; `same_workflow_count` counts only the runs of `ci_workflow` (or of any workflow when it is not set) that `ci_branch` dropped. `new_workflow` names the workflow that first appeared among the ignored runs since the last match and has run most often since, which is how a workflow renamed after it matched shows up (GitHub and Jenkins monitors; always empty on GitLab, which names an unnamed pipeline after its branch). `last_workflow`, `last_branch`, `same_workflow_last_branch` and `new_workflow` are copied from the CI provider's payload: treat them as untrusted text.
    - `count` integer, required: Number of finished runs the filters ignored.
    - `last_at` string, date-time, required: RFC 3339 UTC time the most recent ignored run arrived.
    - `last_workflow` string, required: Workflow name of the most recent ignored run, as the CI provider sent it.
    - `last_branch` string, required: Branch of the most recent ignored run, as the CI provider sent it.
    - `last_matched_at` string, date-time, nullable, required: RFC 3339 UTC time of the most recent finished run that matched the filters, or null when none has been recorded.
    - `filters_changed_at` string, date-time, nullable, required: RFC 3339 UTC time `ci_workflow` or `ci_branch` last changed, or null when that is not known.
    - `same_workflow_count` integer, required: Number of the ignored finished runs that belong to `ci_workflow` (every ignored run when no workflow filter is set): the runs the branch filter alone dropped. 0 when every ignored run was another workflow's.
    - `same_workflow_last_at` string, date-time, nullable, required: RFC 3339 UTC time the most recent of those runs arrived, or null when `same_workflow_count` is 0.
    - `same_workflow_last_branch` string, required: Branch of the most recent of those runs, as the CI provider sent it; an empty string when `same_workflow_count` is 0.
    - `new_workflow` string, required: Name of the workflow, other than `ci_workflow`, that first appeared after the last matching run and has the most ignored runs since (ties to the one that appeared first), as the CI provider sent it; an empty string when none did. A name first seen in the monitor's first 7 days of recording workflow names is never new. Always empty on GitLab monitors.
    - `new_workflow_first_at` string, date-time, nullable, required: RFC 3339 UTC time `new_workflow` first appeared, or null when `new_workflow_count` is 0.
    - `new_workflow_count` integer, required: Number of the ignored finished runs that carried `new_workflow`; 0 when no new workflow appeared.
  - `runaway_ceiling` integer, nullable: Maximum pings allowed in a rolling 1-hour window. Exceeding it opens a `runaway`-cause incident, which detects a looping agent or job pinging abnormally fast. Omitted from the response when disabled for this monitor.
  - `tags` string[], required: Labels attached to this monitor. Always returned as an array (never null); empty array when no tags are set.
  - `agent_id` string, uuid, nullable: Id of the agent that owns this monitor. Omitted/null when the monitor is unattached — including right after its owning agent was deleted: `DELETE /api/v1/agents/{id}` unowns the monitors it owned, it does not delete them.
  - `source_kind` string: The scanner that discovered this monitor (`crontab`, `github-actions`, `k8s-cronjob`, `systemd-timer`). **Omitted when a human created the monitor**, which is the state of every monitor that predates discovery — and such a monitor is never reconciled, so a scan can neither adopt nor delete it.
  - `source_ref` string: The stable path within `source_kind` that identifies exactly what was found. Together with `source_kind` and the project it is the discovery reconcile key. `source_kind` and `source_ref` are always either both present or both omitted: a half-set identity is never stored.
  - `trace_content` 'redacted' | 'dropped', required: What this monitor's traces keep of prompt, command and tool content. `dropped` (the default) removes the content attributes (prompts, completions, commands, tool arguments, results and definitions, retrieved documents, working directories) of every recognised vocabulary entirely; lengths such as `user_prompt_length`, token counts and cost are kept. Error text (`error`, `error.message`, `exception.message`, `exception.stacktrace` and the span's status message) is kept at `dropped`, redacted and then cut to 512 characters, and a cut marks the span or log event truncated. `redacted` opts in to storing that content, with every secret-shaped value replaced by `[REDACTED:<class>]` at ingest, before anything is queued or stored: API keys (LastPing, Anthropic, OpenAI, GitHub, GitLab, npm, AWS access key ids, Slack, Stripe, Google, Hugging Face, Perplexity, Fireworks, Supabase, New Relic, Airtable, Terraform Cloud, Firebase), HashiCorp Vault tokens, age secret keys, Discord bot tokens, Slack, Discord and Teams webhook URLs, `Authorization` header values, JWTs, private key blocks, `password=`/`token=`/`api_key=` style assignments (subscripted ones such as `os.environ["API_KEY"] = ...` and Dockerfile `ENV`/`ARG` lines included), SQL `PASSWORD '...'` and `IDENTIFIED BY` clauses, the Redis `AUTH` command, `.netrc` passwords, command-line password flags and the argument after them (any secret-named `--flag`, `-p` after a login subcommand, `redis-cli -a`), cookies, credentials in URLs, the value of any attribute or JSON field whose name is secret-named (password, token, api_key, secret, the same word in several other languages, an `Authorization` or `X-Api-Key` header and the like; `Idempotency-Key` and request, correlation and trace ids are not secrets), including JSON carried inside another JSON string such as tool-call arguments, and long high-entropy tokens. A bytes attribute that is valid UTF-8 is scanned and stored as its text. A span that had a value redacted carries `lastping.redacted_values`. A request whose redaction work exceeds a fixed budget stores `[REDACTED:over_budget]` for the values it could not scan, never the values themselves. In both modes every remaining value is redacted the same way. There is no setting that stores content unredacted. A change reaches ingest within 60 seconds: ingest caches each monitor's setting for up to a minute, so an export sent just after the change may still be handled the old way. Always present.
  - `kind` 'cron' | 'ci' | 'agent' | 'http', required: The console's four-way grouping of this monitor, derived server-side so every surface groups it the same way. `agent` wins over the rest: an agent's heartbeat is the agent's. A CI monitor is one with a `ci_provider`, **not** one with `monitor_type: "ci"` — nothing in the product writes that value.
  - `uptime_90d` number, double, nullable, required: Uptime over the last 90 days, or since the monitor was created if that is more recent, as a percentage with two decimals. For an HTTP monitor this is the share of probes that succeeded; for every other type it is derived from incident downtime over the window. Null when an HTTP monitor has no probe history yet, and null for any other monitor that has never been pinged. A monitor created three days ago reports on those three days, not on 90 days of assumed success, so the window varies per monitor and the console says so beside the figure.
  - `latency_ms` integer, nullable, required: The most recent probe's latency. HTTP monitors only; null for every other type and for an HTTP monitor with no probe yet.
  - `routes_count` integer, required: How many distinct alert destinations would hear about this monitor FAILING: the destinations routed on `down`, `fail`, `blocked` or `every-run`. Distinct destinations, not route rows, so one destination routed on both `down` and `fail` is one place the alerts go. `every-run` counts because it fires for every completed run, a failed one included. A route on `recovery`, `success`, `started` or `note` is not counted: routes are resolved per event with no fallback, and none of those four ever fires for a failure, so a monitor routed only on them has nowhere for its bad news to go. 0 means failures would be silent, which the console flags as "Not routed" and Home counts in `overview.alerts.monitors_unrouted`.
  - `recent_outcomes` string[], required: This monitor's last 20 run outcomes, newest first, for the console's run strip. Always an array, empty when the monitor has not completed a run yet. Only terminal outcomes appear: a `start` says a run began and is not one.
  - `hook_version` integer, nullable, required: Version of the Claude Code hook script last seen on a start ping; null when no hook that reports a version has sent a start. Older than hook_version_current means re-run set-up.
  - `hook_version_current` integer, required: The hook script version set-up installs today. Compare it with hook_version.
  - `hook_seen_at` string, date-time, nullable, required: When hook_version was last recorded, from a start ping. Refreshed at most once an hour while the version is unchanged. Null when no hook that reports a version has sent a start.
  - `claude_code_seen_at` string, date-time, nullable, required: Latest time this monitor received a Claude Code run (a hook start that reported a version, or a trace whose source is Claude Code). With hook_version null and a recent value here, the machine runs an older hook or none.

## Other responses

- `201`: Check created.
- `400`: Invalid request body or schedule validation failed. For an `http` monitor, `SSRF_BLOCKED_URL` means the `probe_url` host does not resolve to a publicly routable address (it resolves only to private, loopback or otherwise blocked addresses, or it could not be resolved); check the URL and try again.
- `401`: Missing or invalid API key.
- `409`: A discovery source conflict. Two codes are returned here: - `SOURCE_ALREADY_MONITORED` — this project already has a monitor for the given `source_kind`/`source_ref`. This is the reconcile key doing its job: it is what stops a second run of a discovery scan from creating every monitor twice. Nothing is created. - `SOURCE_IMMUTABLE_ON_UPSERT` — the request matched an existing check by `slug` and offered a `source_kind`/`source_ref` pair different from the one stored (including offering a pair for a monitor that has none). A slug upsert never rewrites a monitor's source, because that would let a slug collision silently adopt a hand-made monitor into discovery. Nothing is updated. Use `PATCH /api/v1/checks/{id}` to change a source deliberately.
- `412`: `If-None-Match: *` was sent and a check with the given `slug` already exists. The existing check is left unmodified.
- `503`: `MONITOR_BUSY` — an upsert of an existing `slug` could not get the monitor in time: another update or a ping was in progress for longer than the request will wait, or the monitor was deleted while the request ran. Nothing was changed; retry after the `Retry-After` interval (a retry after a deletion creates the monitor).

---

[API](https://skmtc.dev/lastping/apis/lastping-management-api.md) · [All operations](https://skmtc.dev/lastping/apis/lastping-management-api/llms.txt) · [OpenAPI document](https://skmtc.dev/lastping/apis/lastping-management-api/revisions/bbc6eda91c46?raw)
