---
title: "Create monitor"
method: POST
path: "/api/v1/monitors"
tags: ["Monitors"]
---

# Create monitor

`POST /api/v1/monitors`

## Request

- Base URL: `https://api.uptimemonitoring.com`
- URL: `https://api.uptimemonitoring.com/api/v1/monitors`
- Auth: HTTP bearer

Creates a new uptime monitor. `type` must be `http`, `tcp`, or
`ping`; `dns` and `ssl` are defined in the wire format but rejected
with `400` (issue #209 — see "Invalid request" below). Max 100
monitors per account. Duplicate URLs within the same account are
rejected.

`recipient_ids` (issue #329) selects which recipients the new
monitor is created with — see `MonitorCreateInput`. Omitted or
`null` attaches the account's default recipients (`recipients.is_default`),
unchanged from pre-#329 behavior. An explicit array (including `[]`)
attaches exactly that set instead: validated like
`PUT /monitors/{id}/recipients` (positive ids, no duplicates — `400`
before any write) and ownership-checked against the calling account
(`400` on a foreign or nonexistent id, before the monitor-limit and
duplicate-URL checks, so a bad id is reported even for an account
already at its cap).

`recipients` (issue #338) is `recipient_ids`'s alternative form,
letting each attached pair set its own `down_delay_s` at create
time — see `MonitorRecipients`. Mutually exclusive with
`recipient_ids`: specifying both is `400`. `recipients: null` is
`400` (unlike `recipient_ids: null`, which keeps its
"attach defaults" meaning).

The dispatcher synchronously runs an immediate test check against
the configured URL before returning, so the create response carries
`state.status` of `up` or `down` instead of `unknown`. The check
uses the same probe path as `POST /api/v1/monitors/{id}/test`. If
the probe is unavailable, the kill switch is engaged, or the URL
fails the dispatch-time SSRF guard (DNS rebind between validation
and dispatch), the create still succeeds and `state.status` is
`unknown` — the next scheduled tick will populate it.

Per-account scan heuristic: a sliding window (10 minutes by
default; `SCAN_WINDOW`) tracks the (host, port) tuples of each
newly-created monitor — and, identically, every
`PUT /api/v1/monitors/{monitorID}` that changes `url` or `type`
(issue #335; see that endpoint's description) — and branches on two
signatures:
- **Port-fan** (≥6 distinct ports, by default — `SCAN_PORTFAN_THRESHOLD`
  — on ≤2 hosts, by default — `SCAN_PORTFAN_HOSTS`): scanner-shaped →
  account suspended (403), all monitors purged, security alert fired.
  A `tcp` monitor's port — already checked against the TCP
  allowlist below — counts toward a separate, wider ceiling,
  `SCAN_PORTFAN_TCP_THRESHOLD` (default 12, issue #331 — unset
  tracks the greater of 12 and the effective
  `SCAN_PORTFAN_THRESHOLD`); `http`/`ping` targets always trip at
  the stricter `SCAN_PORTFAN_THRESHOLD` (6), even mixed with `tcp`
  targets on the same host.
- **Host-fan** (≥8 distinct registrable domains, by default —
  `SCAN_HOSTFAN_THRESHOLD` — eTLD+1; IP literals count individually;
  subdomains of one site fold into a single domain): ambiguous — also
  matches an agent provisioning a real multi-service stack → rejected
  with 429 + Retry-After (seconds until the oldest entry leaves the
  window). Account is NOT suspended; retry after Retry-After seconds.
  The response always carries the actual limit and window enforced
  (`X-RateLimit-Limit`/`X-RateLimit-Window` headers, `limit`/
  `window_seconds` body fields) — these defaults are examples, not
  hard-coded values a client should assume.

Subject to two independent per-account rate limits. A request consumes
one token from each:
  - Global cap: 200 requests/minute on any authed `/api/v1/*` route
    (envelope: `account rate limit exceeded; retry in <N>s`).
  - Creation cap: 20 `POST /api/v1/monitors` per minute
    (envelope: `monitor creation rate limit exceeded; retry in <N>s`).
Either rejection responds 429 with an integer `Retry-After` header.

## Request body

- MonitorCreateInput: All string fields must be valid UTF-8 with no null bytes. URL fields must additionally be free of ASCII control characters (U+0000..U+001F and U+007F). Violations return HTTP 400.
  - `name` string
  - `url` string: Target URL. Format depends on `type`: * `http` — full http(s) URL. * `tcp` — `host:port` or `tcp://host:port`, port restricted to a fixed allowlist (issue #331): 21, 22, 25, 80, 110, 143, 443, 465, 587, 993, 995, 2222, 3000, 3306, 5000, 5432, 6379, 8000, 8080, 8443, 8888, or 27017. Every create, and every update whose body includes `url` or `type`, stores the canonical `tcp://host:port` form (lowercase, no path) regardless of which shape was sent — `example.com:443`, `tcp://example.com:443`, and `tcp://EXAMPLE.com:443/` all store as `tcp://example.com:443`. An update that changes neither `url` nor `type` leaves the stored URL as it is. A bracketed IPv6 literal is required (`[2001:db8::1]:443`); an unbracketed one is rejected as ambiguous. * `ping` — a bare hostname or IP literal (e.g. `api.example.com`, `1.1.1.1`) — no scheme, path, query, userinfo, or port. * `dns` / `ssl` — reserved formats (a bare hostname for `dns`) for a `type` that is defined but not yet serviced by the check engine; see the `type` field.
  - `type` 'http' | 'tcp' | 'ping': `http`, `tcp`, and `ping` are accepted on create or update. `dns` and `ssl` are defined in the wire format but rejected with `400` (issue #209): `type must be one of: http, tcp, ping, dns, ssl (currently available: http, ping, tcp)` for an unrecognized value, or `type '<type>' is not available yet; the check engine currently supports: http, ping, tcp` for one of those two.
  - `interval_sec` integer: Check interval in seconds. Default 60.
  - `expected_status` string: HTTP status range. Default: 200-399. Accepted but silently ignored for `type: tcp` or `type: ping` (neither has an HTTP response to evaluate it against).
  - `body_match` string: Optional string to match in the response body. HTTP only — rejected with `400` (`body_match is not applicable to tcp monitors`) when `type` is `tcp`. Accepted but silently ignored (a harmless no-op; wopher's ping check never reads it) when `type` is `ping`.
  - `enabled` boolean: Default true.
  - `maintenance` MaintenanceWindow: A monitor's single alert-silencing window (issue #361). Always present on a `Monitor`; `{"type":"none"}` (the default) silences nothing. Accepted on `MonitorInput`/`MonitorCreateInput` as one of three discriminated shapes — each accepts ONLY the fields listed for it, plus `type`; any other field (including one that belongs to a different shape) is `400` naming it, e.g. `maintenance.timezone: not allowed for type "once"`. * `none` — no other fields. * `daily` — `start_time`, `end_time`, `timezone` all required. Repeats every day in that IANA zone; may span midnight (`start_time > end_time`). `start_time` must differ from `end_time`. The zone must be a real IANA name — empty, `Local`, and anything `LoadLocation` can't resolve are all rejected. * `once` — `starts_at`, `ends_at` both required, `ends_at` after `starts_at`. A single absolute span; no zone, since both instants are already pinned by their offsets. Both timestamps are normalized to UTC at microsecond precision (PostgreSQL's `timestamptz` precision) before being validated or stored, so two timestamps that differ only below a microsecond collapse to equal and are rejected as `ends_at` not being after `starts_at`. On `PUT`, omitting `maintenance` entirely leaves the stored window unchanged; an explicit JSON `null` is `400` (`maintenance must not be null`) — the same rule `recipients` follows elsewhere. `POST` omitting it defaults to `{"type":"none"}`.
    - `type` 'none' | 'daily' | 'once'
    - `start_time` string: "HH:MM", daily only. Canonical: zero seconds, below 24:00.
    - `end_time` string: "HH:MM", daily only.
    - `timezone` string: IANA zone name, daily only.
    - `starts_at` string, date-time: RFC3339, once only. Echoed back UTC-normalized at microsecond precision.
    - `ends_at` string, date-time: RFC3339, once only. Echoed back UTC-normalized at microsecond precision.
  - `method` 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS': HTTP request method (issue #364). Default `GET`. HTTP only — sending this field at all (including its default value) on a `tcp` or `ping` create/update is `400` (`method is only applicable to http monitors`). On update, omitting it leaves the stored method unchanged; an explicit `null` is `400` (`method must not be null`) — but only when `null` is the value the request body actually decodes to: exact-duplicate JSON keys (same spelling) resolve via `encoding/json`'s last-value-wins before this check ever runs, so `{"method":null,"method":"POST"}` is accepted as `POST`; only a differently-cased duplicate (e.g. `Method` alongside `method`) is itself rejected, as more than one key for the same field. The same caveat applies to `body`/`timeout_sec` below, which share this rejection rule.
  - `body` string: Request body (issue #364). Default `""` — a bodiless POST is allowed. Must be valid UTF-8 with no null bytes. HTTP only, same rejection rule as `method`. Cleared by sending `""` on update.
  - `headers` object[]: Custom request headers (issue #364), at most 20 entries and 8192 bytes total (`len(name)+2+len(value)` summed). HTTP only, same rejection rule as `method`. Values must be valid UTF-8 with no CR, LF, NUL, or other control character except horizontal tab. On update, sending `url` with a different scheme, lowercased host, or effective port than the stored URL requires every resulting header to carry a `value` in THIS request — an omitted `headers` field (when headers are stored) or any kept-by-name entry is `400` (`headers must be resent with their values when the URL's scheme, host, or port changes`), closing the path where one account member could repoint another member's monitor (with its still-live secret header) at a host they control. An empty array (`[]`) clears every header.
    - `name` string, required: An RFC 7230 token. `Host`, `Content-Length`, `Transfer-Encoding`, and `Connection` are rejected case-insensitively, as are two entries with the same name (case-insensitive). This is a check across the headers array's entries, not of this field's own JSON key spelling within one entry — see this schema's own description above for that.
    - `value` string: On `PUT`, an entry with `value` omitted keeps the currently stored value for that name (case-insensitive match) — the array as a whole still REPLACES the full stored list, so omitting `headers` entirely (not sending an entry at all) is what leaves every header untouched. A name with no stored value and no `value` sent is `400`. An explicit `null` is `400` (`header "<name>" value must not be null`) when `null` is the value `encoding/json` resolves this field to — see this schema's own description above for how a same- or differently-cased duplicate `value`/`Value` key within one header object resolves instead (last occurrence wins, never rejected). On `POST`, every entry must carry a `value` — there is nothing stored yet to keep.
  - `timeout_sec` integer: Request timeout in seconds (issue #364). Default 30. HTTP only, same rejection rule as `method`.
  - `recipient_ids` integer[], nullable: Selects which recipients to attach at create time. Three cases: * Omitted or `null` — attach the account's default recipients (`recipients.is_default`). Identical to pre-#329 behavior. * `[]` — attach none. * A non-empty array — attach exactly those ids. Validated like `PUT /monitors/{id}/recipients`: every id must be positive and the array must not contain duplicates (`400` before any write), and every id must be owned by the calling account (`400`, checked before the monitor-limit and duplicate-URL checks). Mutually exclusive with `recipients` — both present is `400`.
  - `recipients` object[]: `recipient_ids`'s alternative form (issue #338): attach exactly these pairs, each with its own `down_delay_s`. Mutually exclusive with `recipient_ids` — both present is `400`. Unlike `recipient_ids`, `null` is `400` rather than "attach defaults" — there is no sensible delay to default an implicit set to.
    - `id` integer, required
    - `down_delay_s` 0 | 60 | 120 | 180 | 240 | 300 | 600 | 900 | 1200 | 1500 | 1800 | 3600 | 7200 | 10800 | 14400: Seconds to hold a "down" alert before sending it. Optional; defaults to `0`.

## Response `201`

Monitor created. `state.status` carries `up` or `down` from the
immediate test check. `unknown` indicates a best-effort skip
(probe unavailable, kill switch active, or SSRF rebind detected
at dispatch time); the next scheduled tick populates it.

Soft-cap notice: when the post-insert per-account monitor count
is in the 51..100 window, the response carries an additional
`notice` object (`code: soft_cap_reached`). Creates at or below
50 produce a response with no `notice` field at all (omitempty).
The 101st create returns 409 (see "Monitor limit reached").

- MonitorDetail
  - `monitor` Monitor
    - `id` integer
    - `account_id` integer
    - `name` string
    - `url` string: Target URL. Format depends on `type`: * `http` — full http(s) URL, e.g. `https://api.example.com/health`. * `tcp` — always returned/stored as `tcp://host:port`, port restricted to a whitelist (see `MonitorInput.url` for the accepted input shapes). * `ping` — a bare hostname or IP literal (e.g. `api.example.com`, `1.1.1.1`) — no scheme, path, query, userinfo, or port. * `dns` / `ssl` — reserved formats (a bare hostname for `dns`) for a `type` that is defined but not yet serviced by the check engine; see the `type` field.
    - `type` 'http' | 'tcp' | 'ping': `http`, `tcp`, and `ping` can appear here. `dns` and `ssl` are reserved for a check engine that does not exist yet (issue #209); no monitor of one of those two types can be created, updated, or persisted.
    - `interval_sec` integer
    - `expected_status` string: Single HTTP status code, expressed as a string, 100–599. Defaults to "200".
    - `body_match` string, nullable
    - `enabled` boolean
    - `response_time_threshold_ms` integer, nullable: Alert if response time exceeds this (ms). Null = disabled.
    - `maintenance` MaintenanceWindow: A monitor's single alert-silencing window (issue #361). Always present on a `Monitor`; `{"type":"none"}` (the default) silences nothing. Accepted on `MonitorInput`/`MonitorCreateInput` as one of three discriminated shapes — each accepts ONLY the fields listed for it, plus `type`; any other field (including one that belongs to a different shape) is `400` naming it, e.g. `maintenance.timezone: not allowed for type "once"`. * `none` — no other fields. * `daily` — `start_time`, `end_time`, `timezone` all required. Repeats every day in that IANA zone; may span midnight (`start_time > end_time`). `start_time` must differ from `end_time`. The zone must be a real IANA name — empty, `Local`, and anything `LoadLocation` can't resolve are all rejected. * `once` — `starts_at`, `ends_at` both required, `ends_at` after `starts_at`. A single absolute span; no zone, since both instants are already pinned by their offsets. Both timestamps are normalized to UTC at microsecond precision (PostgreSQL's `timestamptz` precision) before being validated or stored, so two timestamps that differ only below a microsecond collapse to equal and are rejected as `ends_at` not being after `starts_at`. On `PUT`, omitting `maintenance` entirely leaves the stored window unchanged; an explicit JSON `null` is `400` (`maintenance must not be null`) — the same rule `recipients` follows elsewhere. `POST` omitting it defaults to `{"type":"none"}`.
      - `type` 'none' | 'daily' | 'once'
      - `start_time` string: "HH:MM", daily only. Canonical: zero seconds, below 24:00.
      - `end_time` string: "HH:MM", daily only.
      - `timezone` string: IANA zone name, daily only.
      - `starts_at` string, date-time: RFC3339, once only. Echoed back UTC-normalized at microsecond precision.
      - `ends_at` string, date-time: RFC3339, once only. Echoed back UTC-normalized at microsecond precision.
    - `method` 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS': HTTP request method (issue #364). Always `GET` for `tcp`/`ping`/ `dns`/`ssl` monitors, regardless of what might once have been stored — a non-http monitor's custom-request fields are always reset to their defaults on write.
    - `body` string: Request body, verbatim (issue #364). Always `""` for a non-http monitor. Unlike `headers`, the body is NOT write-only — it is returned verbatim on every read to anyone who can read the monitor. It must not contain credentials or other sensitive data unless that exposure is acceptable; `headers` is the write-only place for secrets.
    - `headers` RequestHeader[]: Custom request headers (issue #364), write-only: each entry reports only `name` and `value_set`, never the value itself. Always `[]` for a non-http monitor.
      - `name` string, required
      - `value_set` boolean, required: Whether this header carries a non-empty value.
    - `timeout_sec` integer: Request timeout in seconds (issue
    - `created_at` string, date-time
    - `updated_at` string, date-time
  - `state` MonitorState
    - `monitor_id` integer
    - `status` 'up' | 'down' | 'unknown' | 'flapping'
    - `last_check_at` string, date-time, nullable
    - `last_transition_at` string, date-time, nullable
    - `suspect_since` string, date-time, nullable
    - `flap_count_1h` integer
    - `flapping` boolean
    - `evidence_buffer` CheckResult[]
      - `timestamp` string, date-time
      - `region` string
      - `status_code` integer
      - `error_class` string
      - `dns_ms` number
      - `tls_ms` number
      - `connect_ms` number
      - `ttfb_ms` number
      - `download_ms` number
      - `content_length` integer
      - `response_hash` string
      - `packet_loss_pct` number: Ping-only packet loss percentage (0-100). Omitted (not present in the object) when not measured — every non-ping check result, and a ping probe-side failure that never reached the target. A measured 0% loss is present with value 0, distinct from omitted.
    - `primary_region` 'EU' | 'US-E' | 'US-W' | 'ASIA' | 'OCE' | 'SA' | 'null', nullable: The region assigned as primary for this monitor's checks. Null until the first check is dispatched.
  - `notice` SoftCapNotice: Advisory attached to `POST /api/v1/monitors` when the post-insert per-account monitor count falls in the soft-cap window (51..100). Clients should branch on `code` rather than parsing `message`.
    - `code` 'soft_cap_reached', required
    - `message` string, required: Human-readable advisory text. Wording is set by SPEC and may evolve.
    - `monitors_current` integer, required: Per-account monitor count after the create.
    - `soft_cap` integer, required: Soft cap threshold; crossing it triggers the notice.
    - `hard_cap` integer, required: Hard cap; reached → 409 on subsequent creates.
  - `last_check_skipped` string: Populated only on `POST /monitors` when the create-time initial check did not produce a usable result — either short-circuited before any probe ran, or the probe started but did not finish within the create-time budget, leaving the first check pending. The state row's `last_check_at` remains `null` in either case; clients distinguish "no data yet" from "skipped on purpose" via this field. Vocabulary: * `domain_rate_limited` — the per-domain dispatch cap (`DomainLimiter`) consumed the slot; the next scheduled tick will retry once the bucket refills. * `initial_check_timeout` — the create-time probe did not return within the server's bounded wait; the monitor is registered normally and the next scheduled tick resolves it.
  - `recipient_ids` integer[]: Attached recipient ids (issue #212). Always present, `[]` for none. On `POST /monitors`, reflects any account default recipients attached inside the create transaction — though a monitor born down does not notify them at creation; see `Recipient.default`.

## Other responses

- `400`: Invalid request — including an unsupported `type` (issue #209), a `tcp` URL using a non-whitelisted port or wrong scheme, a `body_match` on a `tcp` monitor, and URLs that match the public hostname blocklist (`.gov`, `.mil`, major social platforms, known critical infrastructure).
- `401`: Missing or invalid credentials.
- `403`: Account is currently suspended. On `POST /api/v1/monitors`, and on a `PUT /api/v1/monitors/{monitorID}` that changes `url` or `type` (issue #335), this can also be returned by the per-account port-scan heuristic when the triggering request crossed the port-fan threshold inside the sliding window (10 minutes by default; `SCAN_WINDOW`). A host-fan trip does NOT suspend the account — see the 429 response instead.
- `409`: Monitor limit reached or duplicate URL.
- `413`: Request body exceeds the 64 KB hard limit. Per docs/SPEC.md §Hard Limits the dispatcher rejects any payload larger than 65,536 bytes before it is decoded.
- `415`: The request `Content-Type` is not `application/json`. Write operations (POST, PUT, PATCH) require that header; a missing or non-JSON media type is rejected before the body is read. The `charset` parameter is accepted and ignored, so `application/json; charset=utf-8` is valid.
- `429`: Rate limit exceeded. Three possible causes, distinguishable by `X-RateLimit-Reason` header (absent on token-bucket rejections): 1. **Global cap** (200/min per account): `error` contains `"account rate limit exceeded; retry in <N>s"`. `X-RateLimit-Limit: 200`. 2. **Creation cap** (20/min per account): `error` contains `"monitor creation rate limit exceeded; retry in <N>s"`. `X-RateLimit-Limit: 20`. 3. **Host-fan** (≥8, by default — `SCAN_HOSTFAN_THRESHOLD` — distinct registrable domains in 10 min, by default — `SCAN_WINDOW`): body is the `HostFanError` schema with `retry_after_seconds` and `limit`. `X-RateLimit-Reason: host_fan`. Account is NOT suspended. `X-RateLimit-Limit`/ `X-RateLimit-Window` and the body's `limit`/`window_seconds` always reflect the actual configured values. The `Retry-After` header is present in all three cases (integer >= 1). Retry after that many seconds; for case 3 the account will accept the request once the oldest qualifying entry ages out.

---

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