# HeimPulse API - full reference text Generated from apps/docs/src/content/guides/*.md and public/openapi.json - see apps/docs/scripts/generate-llms-full.mjs. Do not edit this file by hand. ## Endpoints - GET /v1/incidents - List the org's incidents, paginated. `section` (`active` or `resolved`) is required; optionally narrow by `q` (matches title or display id, e.g. `INC-42`), `severity`, or one or more `service_id` params. Never returns maintenance windows, even though they share the same underlying store - this endpoint is incidents-only. - POST /v1/incidents - Open a new incident against one or more services - use this from your own alerting/ChatOps tooling to report an outage HeimPulse's own monitoring didn't catch, or to keep a customer-facing incident timeline in sync with an internal one. Requires a read_write key. `kind` is always forced to `"incident"` server-side regardless of what's sent - maintenance windows aren't part of this API. See the Incidents guide (docs.heimpulse.com/guides/incidents) for the full request/response field shape. - GET /v1/incidents/{id} - Fetch one incident by id, including every update posted to it in order - the full timeline, not just the current status. Returns `404` (not the maintenance window itself) if the id belongs to a maintenance window rather than a real incident. - POST /v1/incidents/{id}/updates - Append an update to an incident's timeline and, optionally, move it to a new status. Requires a read_write key. There is no separate "resolve" endpoint - posting an update with `"status": "resolved"` *is* how an incident is resolved; every other status (`investigating`, `identified`, `monitoring`) just adds a note without closing it. Rejected with `404` if `id` belongs to a maintenance window rather than a real incident. - GET /v1/org - Confirm which org the API key belongs to, its plan, and the key's own scope (read_only or read_write). Use this as a quick sanity check that a key works before wiring it into an integration - same purpose as Stripe's `GET /v1/account` or GitHub's `GET /user` for a token. - GET /v1/regions - List every region a service can be monitored from - the valid values for a service's own `regions` array (see `POST /v1/services`). Not org-scoped: this is the same fixed, platform-wide list for every caller, and doesn't require any particular plan beyond API access itself. - GET /v1/services - List every service in the org the API key belongs to - the same services shown on the dashboard's Services page. No pagination or filtering; a full replace of this list happens through `PATCH /v1/services/{service_id}`, not this endpoint. - POST /v1/services - Create a new monitored service with one or more HTTP endpoints and their assertions - the same operation the dashboard's "New service" form performs. Requires a read_write key. Immediately starts monitoring: a `StartServiceMonitoring` command is published for every region listed, and `checker` begins running the configured checks on the given `interval_secs`. Subject to the org's plan limits (service/endpoint count, minimum check interval, allowed regions) - a create that would exceed one is rejected with `403`, not silently clamped. - GET /v1/services/{service_id} - Fetch one service by id, including its full endpoint/assertion configuration. Only ever resolves a service belonging to the authenticated org - there's no way to reference another org's service, even by guessing its id. - DELETE /v1/services/{service_id} - Permanently delete a service and stop monitoring it - publishes an `EndServiceMonitoring` command for every region it was running in before removing the record, and updates any status page that lists it. Requires a read_write key. Irreversible: there's no undo or trash/recycle-bin state for a deleted service. - PATCH /v1/services/{service_id} - Replace a service's entire definition (name, interval, regions, and every endpoint/assertion) with the request body - despite the HTTP method name, this is a full replace, not a merge-style partial patch, so omitting a field resets it rather than leaving it untouched. Requires a read_write key. If any region was removed from the new definition, an `EndServiceMonitoring` is published for it before the updated definition is republished to every region in the new set. A paused service stays paused - editing it doesn't implicitly resume monitoring; use the pause/resume endpoints for that. - POST /v1/services/{service_id}/pause - Stop actively monitoring a service without deleting it - publishes an `EndServiceMonitoring` command for every region it was running in, and any status page listing it shows its last-known status as "Paused" rather than going stale/unknown. Requires a read_write key. Idempotent: pausing an already-paused service just returns its current state. - POST /v1/services/{service_id}/resume - Start actively monitoring a paused service again - republishes a `StartServiceMonitoring` command for every one of its configured regions. Requires a read_write key. Re-validated against the org's *current* plan limits, not whatever was in effect when the service was paused: if the org's plan changed in the meantime and the service no longer fits (e.g. too many active services, or a region no longer allowed), resuming is rejected with `403` instead of silently exceeding the limit. Idempotent: resuming an already-active service just returns its current state. --- ## API keys vs. webhooks --- title: API keys vs. webhooks description: Two different, complementary mechanisms it's easy to conflate. order: 6 --- HeimPulse has two ways to connect external systems, and they're opposites, not alternatives: - **API keys** (this API, `/v1`) are how **you call HeimPulse** - you hold the credential, you make the request, on your own schedule. Use them to create a service as part of infrastructure-as-code, or open/resolve an incident from your own alerting or ChatOps tooling. - **Webhooks** (notification channels, configured in the dashboard) are how **HeimPulse calls you** - HeimPulse holds the credential (a signing secret) and pushes an event to a URL you configured, the moment an incident is created, updated, or a certificate is about to expire. A concrete example of using both together: you might use a webhook to get notified the instant an incident opens, and an API key in the same automation to post a status update to it a few minutes later once your on-call engineer has confirmed root cause. They don't share credentials, scopes, or configuration - setting up one doesn't affect the other. --- ## Authentication and scopes --- title: Authentication and scopes description: How API keys work, the two scopes, and what each request is allowed to do. order: 2 --- ## Key format A key looks like `hpk_` followed by 64 hex characters (32 bytes of randomness) - the same entropy budget HeimPulse uses for its own session ids. Send it as a bearer token: ``` Authorization: Bearer hpk_... ``` Keys are hashed (SHA-256) at rest. HeimPulse cannot look up, recover, or re-display a key's plaintext after the moment it's created - if you lose it, revoke it and mint a new one. ## Scopes Every key has exactly one scope, chosen at creation: - **`read_write`** (the default) - can read and mutate anything the API surface allows. - **`read_only`** - every `GET` still works; any `POST`/`PATCH`/`DELETE` is rejected with `403`. There's no finer-grained, per-resource permission model today (e.g. "services only, not incidents") - if you need that separation, mint separate keys per integration and give each only the scope it needs. ## What a key can see A key is scoped to exactly the org it was minted in. There is no `{org_id}` in any `/v1` URL - the org is entirely implied by which key authenticated the request, so a key can never be pointed at another org's data by editing a URL. ## What a key can't do API keys are deliberately narrow. They cannot: - Manage billing or change the org's plan. - Invite, remove, or manage org members. - Manage other API keys (minting/revoking keys is dashboard-only, owner-gated - the same reason a Stripe or GitHub token can't mint other tokens through the API it itself authenticates). - Read or configure notification channels (webhooks/Slack/email). - Touch anything outside `/v1` - the session-cookie-authenticated dashboard API is a separate surface entirely. See [API keys vs. webhooks](/guides/api-keys-vs-webhooks) if you're trying to decide which mechanism fits what you're building. --- ## Getting started --- title: Getting started description: Authenticate a request and make your first call to the HeimPulse API. order: 1 --- The HeimPulse API (`/v1`) lets you manage monitored services, create and resolve incidents, and list available check regions programmatically - the same things you can do from the dashboard, callable from a script, a CI pipeline, or your own infrastructure-as-code tooling. ## Base URL ``` https://api.heimpulse.com/v1 ``` ## Authenticate Every request needs an `Authorization: Bearer` header carrying an API key: ```sh curl https://api.heimpulse.com/v1/org \ -H "Authorization: Bearer hpk_..." ``` Mint a key from the dashboard: **Settings → API keys**. A key belongs to your organization, not to you personally - it keeps working even after the member who created it leaves the org. Keys are shown in full exactly once, at creation - HeimPulse never stores or can re-display the plaintext key, only its last 4 characters. API keys require a **Business** plan. A key stops authenticating anything the moment the org's plan no longer includes API access - no separate revocation step needed if you downgrade. ## Your first call ```sh curl https://api.heimpulse.com/v1/org \ -H "Authorization: Bearer hpk_..." ``` ```json { "id": "5b1e...", "name": "Acme Inc", "plan": "business", "scope": "read_write" } ``` This confirms which org the key belongs to and what it's allowed to do. From here, see the [API reference](/reference.html) for the full `/v1/services`, `/v1/incidents`, and `/v1/regions` surface (the [Services](/guides/services) guide covers the few fields the reference can't show a fixed schema for), or read on for [authentication and scopes](/guides/authentication) and [rate limits and errors](/guides/rate-limits-and-errors). --- ## Incidents --- title: Incidents description: The real request/response field shape for POST /v1/incidents and its /updates route. order: 5 --- `/v1/incidents` is a thin proxy in front of the same engine that powers the dashboard's incidents tab - so its request/response bodies are plain JSON objects, not one of the reference's typed schemas (see the [API reference](/reference.html) for why those two endpoints show up there as a bare `object`). This page is the field-by-field shape. ## Create an incident ``` POST /v1/incidents ``` ```json { "title": "Elevated error rates", "service_ids": ["5b1e...", "8a2f..."], "message": "We're seeing elevated error rates on checkout and are investigating.", "severity": "partial_outage" } ``` | Field | Type | Required | Notes | | --- | --- | --- | --- | | `title` | string | yes | Non-empty. | | `service_ids` | array of uuid | yes | Non-empty; every id must belong to your org. | | `message` | string | yes | The incident's first update. | | `severity` | string | yes | One of `degraded_performance`, `partial_outage`, `major_outage`. | `kind` is not a field you send - every incident created through `/v1/incidents` is forced to kind `incident` server-side, even if you send something else. Maintenance windows aren't part of this API surface. A `201` response is the full incident, including its initial update: ```json { "id": "c4e1...", "kind": "incident", "title": "Elevated error rates", "status": "investigating", "severity": "partial_outage", "created_by": null, "created_at": "2026-09-05T12:00:00Z", "resolved_at": null, "service_ids": ["5b1e...", "8a2f..."], "display_id": "INC-42", "updated_at": "2026-09-05T12:00:00Z", "auto_managed": false, "updates": [ { "id": "...", "body": "We're seeing elevated error rates...", "status_at_update": "investigating", "created_at": "..." } ] } ``` `created_by` is always `null` for an incident created through the API - there's no human session behind an API key, so it's attributed to the system, the same way an auto-detected incident is. ## Post an update (including resolving) ``` POST /v1/incidents/{id}/updates ``` ```json { "body": "Root cause found, deploying a fix.", "status": "identified" } ``` | Field | Type | Required | Notes | | --- | --- | --- | --- | | `body` | string | yes | Non-empty. | | `status` | string | yes | One of `investigating`, `identified`, `monitoring`, `resolved`. | | `severity` | string | no | Change the severity alongside this update; same allowed values as create. | There's no separate "resolve" endpoint. **Resolving an incident is posting an update with `"status": "resolved"`** - that's the one status this API treats as terminal (it sets `resolved_at` and stops counting the incident as active). ```json { "body": "Fixed and confirmed stable.", "status": "resolved" } ``` ## Listing and reading ``` GET /v1/incidents?section=active GET /v1/incidents/{id} ``` `section` is required on the list endpoint: `active` or `resolved`. Optional filters: `q` (searches title and display id, e.g. `INC-42`), `severity`, one or more `service_id` params, and `page`/`limit` for pagination. --- ## Rate limits and errors --- title: Rate limits and errors description: How /v1 throttles requests, and what every error response looks like. order: 3 --- ## Rate limits Every `/v1` request - reads included, not just mutations - is rate-limited per API key: a burst of **20 requests**, refilling at **1 every 200ms** (5/sec sustained). The limit is keyed by the key itself, not by IP address, so requests from a shared CI runner or NAT gateway don't get lumped together with anyone else's traffic. A request over the limit gets: ```http HTTP/1.1 429 Too Many Requests ``` Back off and retry - there's currently no `max_api_keys`-style ceiling on how many keys an org can mint, so if 20 requests/burst is genuinely too tight for your use case, consider splitting work across a second key rather than hammering one. ## Errors Every error response is a JSON object with a single `error` field: ```json { "error": "unauthorized" } ``` | Status | Meaning | | --- | --- | | `400` | The request body failed validation - `error` describes what's wrong. | | `401` | Missing, malformed, or revoked API key. | | `403` | Either a `read_only` key attempted a mutation, or the org's plan doesn't include API access (Business only). | | `404` | No such resource in this org - or, for `/v1/incidents/{id}`, that id belongs to a maintenance window, not an incident (see [Incidents](/guides/incidents)). | | `429` | Rate limited - see above. | | `500`/`502` | An internal error. Safe to retry; nothing on your end caused it. | --- ## Services --- title: Services description: The real shape of an endpoint's call and assertions, including the few leaves the reference documents as opaque objects. order: 4 --- `POST`/`PATCH /v1/services` accept full endpoint definitions - most of it is typed exactly in the [API reference](/reference.html) (see `EndpointInput`, `MonitoringCallInput`, and `MonitoringAssertionDef`). A handful of deeply-nested leaves fall back to opaque objects in the generated schema because they can't be expressed as fixed OpenAPI types: this page is their real shape. ## The call Today only `Http` is supported (`GRPC`/`DNS` are accepted and rejected with a clear error, not silently ignored): ```json { "url": "https://example.com/health", "name": "Health check", "call": { "Http": { "method": "GET", "body": null, "headers": { "Authorization": { "value": "Bearer secret-token", "sensitive": true } }, "max_redirects_to_follow": 3, "with_dns": true, "timeout": null } }, "assertions": [{ "StatusCode": { "expected": { "Eq": 200 } } }] } ``` - **`method`**: `"GET"` or `"POST"`. - **`body`** / a header value: `{ "value": "...", "sensitive": false }`. Set `"sensitive": true` to have HeimPulse encrypt it at rest - once saved, a sensitive value is never redisplayed (not even its ciphertext) in any read response; only a `{"key_id": "..."}` marker comes back so you know one is set. - **`timeout`**: `null`, or `{"secs": , "nanos": }` - this is Rust's `std::time::Duration` serialized by serde's own default, not a plain millisecond/second number. A 30-second timeout is `{"secs": 30, "nanos": 0}`. ## Assertions `assertions` is an array of tagged objects, one of five kinds: ```json [ { "StatusCode": { "expected": { "Eq": 200 } } }, { "Latency": { "l_type": "TotalLatency", "expected": { "LessThan": { "secs": 1, "nanos": 0 } } } }, { "DnsResoltion": { "expected": { "LessOrEqual": { "secs": 0, "nanos": 200000000 } } } }, { "Header": { "name": "content-type", "matcher": { "Contains": "application/json" } } }, { "Body": { "matcher": { "Regex": "\"status\"\\s*:\\s*\"ok\"" } } } ] ``` ### The comparison operators `StatusCode`'s `expected` field is a **`Comparison`** - one of: | Variant | Shape | Meaning | | --- | --- | --- | | `Eq` / `NotEq` | `{"Eq": 200}` | equals / does not equal | | `GreaterThan` / `GreaterOrEqual` / `LessThan` / `LessOrEqual` | `{"GreaterThan": 500}` | ordering | | `Between` | `{"Between": [200, 299]}` | inclusive range (two values) | | `OneOf` | `{"OneOf": [200, 201, 204]}` | any of a set | `Latency` and `DnsResoltion` use a narrower **`DurationComparison`** instead - a status code is a discrete set of legal values (`Eq`/`OneOf` make sense there), but a timing measurement is continuous, so only the ordering-style comparisons apply: | Variant | Shape | | --- | --- | | `LessThan` / `LessOrEqual` | `{"LessThan": {"secs": 1, "nanos": 0}}` | | `Between` | `{"Between": [{"secs": 0, "nanos": 0}, {"secs": 2, "nanos": 0}]}` | `l_type` (on `Latency` only) is one of `HeadersLatency`, `BodyLatency`, `TotalLatency`. ### String matching `Header` and `Body` assertions both use a **`StringMatcher`**: | Variant | Shape | | --- | --- | | `Equals` | `{"Equals": "ok"}` | | `Contains` | `{"Contains": "application/json"}` | | `StartsWith` / `EndsWith` | `{"StartsWith": "2."}` | | `Regex` | `{"Regex": "^ok$"}` | ## Why these particular pieces are opaque in the reference The generated OpenAPI spec derives real, named schemas for almost everything above - the one exception is the specific comparison *value* itself (`Comparison`'s operand and `DurationComparison`, both shown as a bare object in the reference). `Comparison` is a Rust generic used with a single concrete type parameter (`u16`, for a status code) mixing single-value and array-shaped variants in one enum - a real limitation of the OpenAPI-generation tooling for that specific shape, not a gap in what the API itself accepts. `DurationComparison` and the raw `timeout` field both bottom out in `std::time::Duration`, which has no fixed schema representation of its own beyond the `{secs, nanos}` shape documented above. Everything else in a service or endpoint definition - including which five assertion kinds exist and every other field on each - is fully typed in the reference itself.