Skip to main content

Integrations

Developer API

Authenticate, create and manage monitors, acknowledge incidents, and read uptime, checks, status page state, and the audit log from Pingara programmatically using the v1 REST Developer API.

36 min readUpdated October 1, 2026

apideveloperrestintegrationautomation

The Pingara Developer API gives you programmatic access to your monitors, incidents, and check results over HTTPS. It's designed for CI/CD pipelines, internal dashboards, ChatOps tools, and any other system that needs to read monitoring state, or create, pause, resume, and delete monitors, and acknowledge and update incidents, without going through the UI.

At a glance

  • Base URL: https://api.pingara.io/api/v1
  • Auth: Bearer token (Authorization: Bearer pgr_…)
  • Format: JSON, UTF-8
  • Scope: a key is scoped to a single organization and carries a read/write scope pair, plus an optional audit:read scope that nothing else implies. Reading monitors, incidents, check results, uptime, and status page state needs read; reading the audit log needs both read and audit:read, on a key created by an owner or admin; creating, updating, pausing, resuming, or deleting a monitor, and acknowledging or updating an incident, needs write, see Key scopes for the full grant, including policy routing
  • Rate limit: 600 requests per minute, per key, reported on every response in X-RateLimit-*. A second, separate limit of 1,200 requests per minute applies per client IP address, and monitor creation carries a further per-organization brake, see Rate limiting
  • Versioning: All endpoints live under /api/v1. Breaking changes ship under /api/v2.

1. Create an API key

  1. Sign in to Pingara and switch to the organization you want to script against.
  2. Open Settings → API & Webhooks (the mobile navigation still labels this tab API).
  3. Click Create key, give it a recognisable name (for example CI deploy or Grafana sync), and submit.
  4. Copy the full key immediately. It is shown exactly once and starts with the prefix pgr_. Pingara only stores a SHA-256 hash, so a lost key cannot be recovered. Revoke it and create a new one.

Only owners and admins can create or revoke keys. Reading through the API is available on every plan. A key with the write scope needs the Pro plan: asking for one on Free is refused when you create the key. Audit log access has no plan requirement. The number of active keys an organization can hold at once is also plan-gated: 2 on Free, 25 on Pro.

To let a key read the audit log, tick Audit log access when you create it. It is off by default, it is available on every plan, and a key that has it shows an Audit log badge beside its scope in the keys list. You cannot create a key with only that scope: it is always added to Read only or Read & write.

Storing keys safely

  • Treat the key like a password. Anyone with it can read every monitor, incident, and check result in your organization.
  • Give a key Audit log access only if it needs to read the audit log, which holds your members' IP addresses.
  • Store keys in your secrets manager (1Password, Vault, AWS Secrets Manager, GitHub Actions secrets, etc.), never in source control.
  • Use a dedicated key per integration so you can revoke one without breaking the rest.
  • Some keys are revoked for you. If Pingara support deactivates a user account, every key that user created is revoked. If a member undoes a sign-in email change (see Managing Your Profile), every key that account created at or after the moment the change was requested is revoked, in any organization. A revoked key gets 401, and each one shows in the audit log as API key revoked with System as the actor. Create a new key if an integration stops working after one of these.

2. Authentication

Every request must include an Authorization header:

curl https://api.pingara.io/api/v1/me \
  -H "Authorization: Bearer pgr_abcdef0123456789..."

Successful responses return JSON with HTTP 200. Authentication failures return 401:

{ "error": "Unauthorized" }

This message is deliberately identical for every failure mode, whether that's a missing header, the wrong scheme, a malformed key, or a key that doesn't match any hash in your organization. Pingara never returns a more specific reason, so a caller can't probe which case they hit to learn whether a given key exists. Don't branch your error handling on the message text; treat any 401 as "not authenticated" and re-check the header.

Key scopes

Every key carries a list of scopes, returned by GET /api/v1/me. There are three. read and write are coarse: they apply to the whole API, not per resource. audit:read is a separate grant for one endpoint:

ScopeGrants
readReading monitors, incidents, check results, uptime, and status page state. It does not include the audit log
writeEverything read grants, plus creating, updating, pausing, resuming, and deleting monitors, acknowledging and updating incidents, and attaching and detaching a monitor's alert policies. write implies read, so a write-scoped key never needs both listed to read
audit:readReading the audit log, GET /api/v1/audit-logs, in addition to read. Nothing implies it and it implies nothing: neither read nor write includes it, and it never replaces read. The key's creator must also currently be an owner or admin

Every GET endpoint documented in this article requires only read, with one exception: GET /api/v1/audit-logs also requires audit:read, and a key created by an owner or admin. Every endpoint that creates or changes something, see Writing: monitors and incidents, requires write. Keys created before scopes were enforced carry ["read"] and are unaffected for reading, except for the audit log, which they cannot read. Attempting a write with one of those keys gets the 403 below until you add the write scope, and calling the audit log gets one naming audit:read. A scope cannot be added to an existing key: create a new key with the scopes you need.

If a key lacks the scope an endpoint requires, the API returns 403, not 401:

{ "error": "This API key does not have the required \"write\" scope." }

The distinction is deliberate and safe to rely on. A 401 means Pingara could not authenticate you at all, and its message is uninformative by design. A 403 means the key is valid and you are holding it, so naming the missing scope tells you nothing you didn't already know, and it is the only way to diagnose a misconfigured integration. Branch on the status code, not on the message: a 401 may be worth retrying with a different key, but a 403 never succeeds on retry. Fix the key's scopes instead.

3. Resources

GET /api/v1/me

Returns metadata about the organization the key belongs to.

{
  "organization": {
    "id": "j5k2…",
    "name": "Acme Inc",
    "slug": "acme",
    "plan": "pro"
  },
  "apiKey": {
    "id": "abcd…",
    "scopes": ["read"]
  }
}

Useful as a health-check at the start of a CI job.

GET /api/v1/monitors

List monitors in the organization, most recently updated first.

Query paramTypeDescription
limitinteger (1–200)Maximum monitors to return. Default 50.
statusstringFilter to one of up, down, degraded, pending, paused.
cursorstringOpaque pagination token from a previous response's cursor field. Omit for the first page.

A present-but-empty query parameter (?status=) means the same as omitting it entirely. You don't need to strip empty values out of a URL you're building from variables. ?from= and ?to= are not accepted on this endpoint and return 400 naming the endpoints that do support a time window (see GET /api/v1/uptime below). monitors has no time-ordered index to filter through, and silently ignoring the parameters would return the unfiltered list as though your window had been honoured.

Example response:

{
  "count": 2,
  "data": [
    {
      "id": "m1aa…",
      "name": "Marketing site",
      "url": "https://www.example.com",
      "type": "http",
      "port": null,
      "tcpProtocol": null,
      "method": "GET",
      "interval": "1m",
      "timeout": 10000,
      "regions": ["us-east-1", "eu-west-1"],
      "expectedStatusCodes": [200, 301, 302],
      "keywordCheck": null,
      "keywordCheckEnabled": false,
      "apdexThreshold": 500,
      "tags": ["public", "marketing"],
      "environment": "production",
      "service": "web",
      "criticality": "important",
      "status": "up",
      "isEnabled": true,
      "isPaused": false,
      "lastCheckedAt": "2026-05-14T16:24:09.123Z",
      "lastStatusChange": "2026-05-12T09:11:02.000Z",
      "sslExpiresAt": "2026-06-16T23:59:59.000Z",
      "sslDaysUntilExpiry": 33,
      "createdAt": "2026-01-04T12:00:00.000Z",
      "updatedAt": "2026-05-14T16:24:09.123Z"
    }
  ]
}

GET /api/v1/monitors/{id}

Returns a single monitor. Responds with 404 if the monitor cannot be resolved: it does not exist, the identifier is not valid, or it belongs to a different organization.

GET /api/v1/monitors/{id}/checks

Most recent check results for a monitor (newest first). Paginated, see Response envelope.

Query paramTypeDescription
limitinteger (1–200)Default 50.
cursorstringOpaque pagination token from a previous response.
from, tostringISO 8601, inclusive window, see ?from= and ?to=.

Each result includes the regional probe, the timing breakdown (DNS / TCP / TLS / TTFB / total), response metadata, certificate state, and ICMP packet loss.

FieldTypeDescription
idstringCheck result ID.
monitorIdstringID of the monitor this check belongs to.
regionstringProbe region that performed the check (for example us-east-1).
timestampstringISO 8601 UTC timestamp when the check ran.
dnsLookupTimenumberDNS resolution time, in milliseconds.
tcpConnectTimenumberTCP connection time, in milliseconds.
tlsHandshakeTimenumberTLS handshake time, in milliseconds. 0 for checks that never reach TLS.
ttfbnumberTime to first byte, in milliseconds.
totalDurationnumberTotal check duration, in milliseconds.
responseSizenumberResponse body size, in bytes.
statusCodenumber | nullHTTP status code, or null for non-HTTP checks and failures that never received a response.
isUpbooleanWhether this individual check passed.
errorTypestring | nullShort error classification, or null if the check succeeded. Current values: timeout, dns_failure (the hostname didn't resolve), connection_refused, connection_reset, tls_error (covers an expired, self-signed, or hostname-mismatched certificate, a failed handshake, not a soft warning), host_unreachable, network_error, redirect_error, status_code (the response code wasn't in the monitor's expected list, the most common failure of all), keyword_not_found, keyword_present (the must NOT contain mode's failure), keyword_indeterminate (the body couldn't be decoded to search it), invalid_url, ping_failed, tcp_failed, dns_nxdomain, dns_no_records, dns_servfail, dns_timeout, dns_unexpected_value (the record resolved but didn't match the monitor's assertion), websocket_handshake, websocket_protocol, websocket_closed, unknown_monitor_type, and ssrf_blocked (the target resolved to a private or internal address, see Getting started). Historical only: dns_error, an earlier name for the same DNS-resolution failure, retired 2026-07-28 and no longer written by any check. Check results from before that date may still carry it (older rows aren't rewritten, so this is expected on historical data, not a bug); it means exactly what dns_failure means. Treat this as an open string. New values may be added without notice.
errorMessagestring | nullHuman-readable error detail, or null if the check succeeded.
sslExpiresAtstring | nullISO 8601 UTC expiry of the certificate presented during this check, or null. See Certificate fields below.
sslDaysUntilExpirynumber | nullInteger days until expiry, computed against the probe's clock at check time, or null. See Certificate fields below.
sslChainValidboolean | nullWhether the certificate chain validated, or null. See Certificate fields below.
sslChainErrorstring | nullValidation failure detail (an OpenSSL/Node TLS error code, for example CERT_HAS_EXPIRED or ERR_TLS_CERT_ALTNAME_INVALID, capped at 500 characters), or null. For display and logging. Branch on sslChainValid, not this string. See Certificate fields below.
sslWeakSignatureboolean | nulltrue if the certificate's key is below the floor for its key type or its signature algorithm is MD5/SHA-1; false if both were checked and both are sound; null if it couldn't be judged. Three states, see TLS posture fields below.
tlsVersionstring | nullNegotiated TLS protocol version as the peer's stack named it (for example TLSv1.3), capped at 64 characters, or null when no handshake completed. See TLS posture fields below.
cipherSuitestring | nullNegotiated cipher suite as the peer's stack named it (for example TLS_AES_256_GCM_SHA384), capped at 120 characters, or null when no handshake completed. See TLS posture fields below.
icmpPacketLossnumber | nullNon-negative integer percentage of ICMP echo replies lost, or null when loss wasn't measured. See Packet loss below.

Certificate fields

Four fields carry certificate state, and they only make sense read together. None of them is meaningful on its own:

sslChainValidsslExpiresAtMeaning
truetimestampCertificate valid. Use sslDaysUntilExpiry for runway.
falsenullValidation failed. Expired, self-signed, untrusted, or a hostname mismatch. See sslChainError.
nullnullNo TLS handshake was attempted. A tcp or ping monitor, or a plain HTTP monitor.

The negative you'll never see: sslDaysUntilExpiry is an integer and may in principle be negative. The API doesn't clamp it. In practice, Pingara's probes validate certificates strictly, so an expired certificate fails the TLS handshake before any certificate data is read: sslExpiresAt and sslDaysUntilExpiry both become null, and sslChainValid becomes false. 0, meaning "expires within 24 hours", is therefore the last numeric reading you'll see before a certificate lapses.

  • Prefer sslExpiresAt over sslDaysUntilExpiry. The days figure is a Math.floor snapshot against the probe's clock at check time. A check from a day ago reporting 7 actually means 6 today. If you're building a renewal dashboard, recompute runway from the absolute timestamp.
  • On a monitor, the same field is a bigger trap. monitors[].sslDaysUntilExpiry is a snapshot as of lastCheckedAt, not now. A paused monitor keeps its last reading indefinitely, so it can still report 30 a year later. Compute live runway from the monitor's sslExpiresAt instead.
  • SSL expiry alerts are milestone-based, per certificate. You get one notification per threshold crossed (the monitor's sslExpiryWarningDays) for the certificate currently installed, not one per threshold forever. Milestones re-arm when the installed certificate's expiry timestamp changes: a renewal, a replacement, a re-issue, or a host change all count. A replacement certificate with an identical expiry does not re-arm. Same expiry means the same milestones, so nothing is missed. During a multi-region certificate rotation, regions can briefly disagree about which certificate they see, so a milestone can repeat a small number of times until they converge. This is self-limiting, ending once every region sees the new certificate, and it's reachable on any rotation where a region still observing the outgoing certificate sees it inside a configured threshold. Treat SSL expiry notifications as at-least-once, not exactly-once, and track state on your side if you need continuous coverage rather than threshold-crossing events.
  • A monitor already stuck silent under the older bug doesn't self-heal on upgrade. If a monitor went permanently quiet before this fix shipped, it stays quiet for the remainder of its currently installed certificate and resumes normal milestone alerts at that certificate's next renewal. There's no backfill. A correctly notified monitor and a stuck one are indistinguishable in the database.
  • Both monitor-level SSL fields are null until the monitor's next TLS check runs. There's no backfill for monitors that existed before this field shipped.
  • TLS posture is reported but never alerted on. sslWeakSignature, tlsVersion and cipherSuite are on the check-result response (see TLS posture fields below), and none of them drives an alert or an incident. A certificate with a weak signature, or a handshake on an outdated protocol, that otherwise validates is reported to you and not paged on. Only two certificate conditions reach you as a notification at all: an approaching expiry, through the milestone notifications described above (ssl.expiring), and a certificate that fails the handshake outright (expired, self-signed, or hostname-mismatched), which surfaces as errorType: "tls_error" above. Neither arrives as a separate chain-validation or TLS-policy alert, so if you want to be told about a weak signature or a legacy protocol version, poll these fields and decide for yourself.

The webhook carries less, not the same in a different shape: the webhook payload nests certificate data under an ssl block, but that block carries only daysUntilExpiry, issue and recommendation. There is no ssl.expiresAt. The absolute expiry timestamp is Developer-API-only (sslExpiresAt, flat), so a client that needs it has to read it from here rather than derive it from a delivery.

TLS posture fields

Three fields describe the handshake, where the four certificate fields above describe the certificate. All three are null for the same population: a tcp or ping monitor, a plain HTTP monitor, or any check that never completed a TLS handshake.

  • sslWeakSignature has three states, and false is not the absence of true. true means the certificate is weak. The public key is below the floor for its key type (2048 bits for RSA, 224 for elliptic curve), or the signature algorithm is MD5 or SHA-1. false is a positive claim that it is sound, and Pingara only makes that claim with evidence on both axes: the key size was judged against the right floor for a key type it could identify, and a signature algorithm was actually read off the certificate. null means neither claim could be made, because the key type was unidentified or no signature algorithm was available. sslWeakSignature === false is the only value that means "checked and sound". Treating null as false in your integration reports an unjudged certificate as a clean one.
  • RSA and elliptic-curve keys are judged against different floors. A P-256 ECDSA key is 256 bits and strong; measuring it against the RSA floor of 2048 would mark every modern ECDSA certificate weak. If the key type can't be identified, the key size isn't judged at all rather than judged against the wrong floor.
  • tlsVersion and cipherSuite are free text from the peer's own TLS stack, not a closed vocabulary of ours. They are sanitised and length-capped on ingest (64 and 120 characters), and they are for display and logging: escape them at render time and match on them defensively, the same way you would treat sslChainError. Expect TLSv1.3, TLSv1.2 and the OpenSSL-style suite names, but don't assume the set is fixed.
  • A weak signature does not make a check fail. isUp is unaffected, errorType stays null, and no incident opens. These fields are reporting, not detection.

Packet loss

icmpPacketLoss is telemetry from a 3-packet ICMP sample, not a status signal. It never affects isUp.

  • The sample is 3 packets. Every check sends exactly three echo requests, so a normal reading is one of four values: 0, ~33, ~67, or 100. There's no such thing as "5% loss" in a Pingara check result. The API doesn't clamp icmpPacketLoss to 100, so a value above that isn't a guarantee violation. It indicates a probe fault, and you should treat it as suspect rather than as a loss percentage.
  • Don't alert on a single check. One check reporting 33% loss is one dropped packet out of three. The 95% confidence interval for "1 of 3" runs roughly 0.8%–91%. On a path with a true 5% loss rate, 14.3% of individual checks will show at least one drop. At a 1-minute interval, paging on any nonzero reading produces on the order of 205 false-positive alerts per day, per region.
  • The right pattern. Require at least 3 consecutive checks with loss greater than 0 in the same region, and a multi-region quorum, at least half the monitor's regions and never fewer than two, the same quorum rule Pingara's own down/up detection uses, except for a single-region monitor, where that one region decides. Better still, aggregate instead of counting checks: every sample is exactly 3 packets, so a plain window mean is unbiased. A 15-minute window across 4 regions at a 1-minute interval is 180 packets, 0.56% resolution. A reasonable starting point: window mean ≥5% sustained across two consecutive windows in at least 2 regions opens a ticket, not a page. Short of 100% loss, this is degradation, and degradation alone doesn't justify waking someone up.
  • Don't compare with ===. GNU ping reports integers; Pingara's fallback probe computes floats. Both are rounded before serialization, but agreement between the two is only ±1. Compare with a range, or convert to packet counts: packetsLost = Math.round(icmpPacketLoss * 3 / 100). This assumes an in-range (0–100) reading; an out-of-range value means a probe fault, not a packet count.
  • icmpPacketLoss ?? 0 is a bug in your integration. null means "not measured," not "no loss." ping monitors always set it. http monitors set it only when the probe has ICMP enabled and the hostname resolves to something pingable. tcp monitors never set it.
  • Treat loss as a precursor signal, cautiously. Sustained cross-region packet loss means something on the path is dropping packets, and that's worth investigating. It does not mean an outage is coming. Most loss episodes never turn into one. Two caveats: routers commonly deprioritize and rate-limit ICMP, so low single-digit loss to an otherwise healthy host is normal and not worth chasing; and for http monitors, packet loss is pure telemetry that never affects isUp. If HTTP is clean and ICMP is lossy, believe HTTP.
  • How it's measured. Pingara measures loss with ICMP echo where the probe can send it, and falls back to an equivalent TCP-reachability probe where it can't. The field doesn't tell you which method produced a given reading.
  • This field doesn't move with latency. ttfb and totalDuration only average the packets that actually returned, so they don't rise with loss, and can trend downward as dropped attempts fall out of the average.
  • Nothing in Pingara's own alerting reads icmpPacketLoss for status today. The Developer API is currently the only way to see partial ping loss at all.

GET /api/v1/monitors/{id}/incidents

Recent incidents for a single monitor, newest first. Paginated, see Response envelope.

Query paramTypeDescription
limitinteger (1–200)Default 50.
statusstringFilter to investigating, identified, monitoring, or resolved.
errorTypestringFilter to an exact errorType value. No fixed vocabulary, see below.
cursorstringOpaque pagination token from a previous response.
from, tostringISO 8601, inclusive window, see ?from= and ?to=.

GET /api/v1/monitors/{id}/uptime

Uptime, Apdex, and average latency for one monitor over a fixed window.

Query paramTypeDescription
periodstringOne of 24h, 7d, 30d. Required.
{
  "data": {
    "monitorId": "m1aa…",
    "name": "Marketing site",
    "period": "24h",
    "resolution": "hourly",
    "from": "2026-05-13T16:00:00.000Z",
    "to": "2026-05-14T15:59:59.999Z",
    "asOf": "2026-05-14T15:59:59.999Z",
    "uptimePct": 99.98,
    "apdex": 0.97,
    "avgLatencyMs": 214,
    "totalChecks": 1440,
    "successfulChecks": 1439,
    "sla": {
      "available": true,
      "target": 99.9,
      "status": "met",
      "marginPct": 0.08
    }
  }
}

24h is sourced from hourly rollups; 7d and 30d are sourced from daily ones. resolution on the response tells you which, and 30d can therefore be up to a day stale at the boundary. sla is null-safe rather than zeroed: available: false means your plan doesn't include SLA export (Free); target: null beside available: true means this monitor has no slaTarget configured. The two are different facts and are never collapsed into one.

The most important field on this response is asOf, and it is not the window you asked for. from/to are the window the request resolved to, computed deterministically from period. They exist whether or not any data landed inside them. asOf is the end of the newest rollup bucket actually found, or null if none were. An empty window answers uptimePct: 100 and apdex: 1, not because everything was healthy, but because nothing was measured. Those are the "no data" values (0 would read as total failure, which is the opposite of the truth). If you alert on uptimePct or apdex dropping below a threshold, check asOf and totalChecks first: a total ingestion outage on a monitor you're watching would otherwise report as a perfect score, silently, for the whole window.

GET /api/v1/uptime

The same shape, aggregated across every monitor in the organization (paused monitors included in monitorCount, since a paused monitor writes no checks and can't move the numbers either way).

Query paramTypeDescription
periodstringOne of 24h, 7d, 30d. Required.
{
  "data": {
    "period": "7d",
    "resolution": "daily",
    "from": "2026-05-07T00:00:00.000Z",
    "to": "2026-05-13T23:59:59.999Z",
    "asOf": "2026-05-13T23:59:59.999Z",
    "uptimePct": 99.95,
    "apdex": 0.96,
    "avgLatencyMs": 231,
    "totalChecks": 30240,
    "successfulChecks": 30225,
    "monitorCount": 12,
    "sla": {
      "available": true,
      "monitorsWithTarget": 4,
      "met": 3,
      "atRisk": 1,
      "breached": 0
    }
  }
}

The same empty-window caveat applies here, and one further residual is worth knowing: this endpoint's asOf is the newest bucket found anywhere in the org, so 49 current monitors mask one that's an hour behind. Use this response for a headline number; use the per-monitor endpoint above to diagnose which monitor is actually stale.

GET /api/v1/status-pages

Your organization's own status pages, public or not, unlike the separate GET /api/v1/status/{slug} public status API, which only ever serves what an unauthenticated visitor may see. This endpoint returns ids rather than slugs, and includes pages a visitor could never reach at all. This collection is not paginated and takes no limit or cursor, unlike every other list endpoint in this article, because it's already bounded by your plan's status-page cap.

{
  "data": [
    {
      "id": "sp1a…",
      "name": "Acme Status",
      "slug": "acme",
      "isPublic": true,
      "isServable": true,
      "downgradedAt": null,
      "updatedAt": "2026-05-14T16:24:09.123Z"
    }
  ],
  "count": 1
}

isPublic is what you configured; isServable is what the public status API would actually serve right now. The two differ when the platform-wide status-page kill switch is off. An org has no other way to find out its page went dark.

GET /api/v1/status-pages/{id}/state

One page's current component state, with the same isPublic/isServable pair plus, per component, both a normalized status (agrees with the public API) and the raw monitorStatus (the underlying monitor's status before the paused-reads-as-maintenance, unknown-reads-as-operational normalization the public page applies). monitorStatus is what surfaces the single most misleading state in the product on your own key-authenticated read: a page reporting operational because three of its monitors stopped being checked entirely. Never render monitorStatus on anything a visitor can see. That's the one field this endpoint carries that the public status API never will.

GET /api/v1/incidents

Recent incidents across the whole organization. Paginated, see Response envelope.

Query paramTypeDescription
limitinteger (1–200)Default 50.
statusstringFilter to investigating, identified, monitoring, or resolved.
monitorstringFilter to one monitor by name, exact and case-sensitive match. See below.
errorTypestringFilter to an exact errorType value (the same open vocabulary documented on check results, above). No fixed list. An unrecognized value returns an empty page, not an error.
cursorstringOpaque pagination token from a previous response.
from, tostringISO 8601, inclusive window, see ?from= and ?to=.

A request filtered by ?monitor= can paginate at times the unfiltered collection can't. A monitor-filtered read runs on a different, always-ready index; the unfiltered collection route can briefly answer 409 during an internal index migration (naming the condition and pointing you at GET /api/v1/monitors/{id}/incidents as the unaffected alternative). If you hit that, filtering by monitor is also your workaround, not only your way to narrow the result.

?monitor=<name> resolves against your monitor list and then runs the equivalent of GET /api/v1/monitors/{id}/incidents. Monitor names aren't unique in an organization, so the match has to be exact, and two refusals exist rather than a silently wrong answer. An unknown name returns 404, never an empty list. During an outage, an empty list reading as "this monitor had no incidents" is the single most expensive wrong answer this API can give, and it's what one mistyped character would otherwise produce silently. An ambiguous name (more than one monitor shares it) returns 400 naming the count, because a merge across several monitors' incident histories can't paginate. Page per monitor via GET /api/v1/monitors/{id}/incidents instead.

GET /api/v1/incidents/{id}

Returns a single incident with start/resolve timestamps, the affected regions, error metadata, and the AI-generated root-cause hint (when available). Responds with 404 if the incident cannot be resolved: it does not exist, the identifier is not valid, or it belongs to a different organization.

FieldTypeDescription
idstringIncident ID.
monitorIdstringID of the monitor this incident belongs to.
statusstringOne of investigating, identified, monitoring, resolved.
startedAtstringISO 8601 UTC timestamp when the incident opened.
resolvedAtstring | nullISO 8601 UTC timestamp when the incident resolved, or null while open.
acknowledgedAtstring | nullISO 8601 UTC timestamp when a team member acknowledged the incident, or null if unacknowledged.
errorTypestring | nullShort error classification captured at incident open. The same value set as check results' errorType above, plus performance_degradation, the value carried by a degraded incident opened by the slow-response rule (a degraded incident opened by a keyword mismatch carries that check's own keyword error type instead), and simulated_test, carried by the self-resolving incident a Send test alert click creates. Test incidents are hidden from public status pages but are not filtered out of this endpoint, so a client that switches on errorType will meet simulated_test in ordinary use. Includes the dns_failure-current / dns_error-historical-only distinction described there. It's an open string, so treat unrecognized values as informational.
errorMessagestring | nullHuman-readable error detail captured at incident open.
rootCauseHintstring | nullAI-generated root-cause suggestion, or null if not yet generated.
affectedRegionsstring[]Regions that reported the failure. Empty array if none were recorded.
avgResponseTimenumber | nullAverage response time (ms) across affected checks, or null if unavailable.

Example response:

{
  "data": {
    "id": "i9zz…",
    "monitorId": "m1aa…",
    "status": "resolved",
    "startedAt": "2026-05-12T09:11:02.000Z",
    "resolvedAt": "2026-05-12T09:26:47.500Z",
    "acknowledgedAt": "2026-05-12T09:13:10.000Z",
    "errorType": "timeout",
    "errorMessage": "Request timed out after 10000ms",
    "rootCauseHint": "Upstream DNS latency spiked across two regions, consistent with a provider-side issue rather than an application error.",
    "affectedRegions": ["us-east-1", "eu-west-1"],
    "avgResponseTime": 9800
  }
}

GET /api/v1/alert-policies

List the calling organization's alert policies. Takes no ?limit= or ?cursor=: the collection is plan-capped (1 on Free, 20 enabled policies on Pro) and the response carries no paging protocol, the same shape as GET /api/v1/status-pages.

This endpoint is the only way to get a policy id through this API, and it's a prerequisite for the attach endpoints below, not just a companion to them. Nothing else under /api/v1 returns one, and the product's own settings UI never puts a policy id in a URL either.

The response carries the policy row only, never the channels behind it. An alert policy's channel configuration (a Slack webhook URL, a PagerDuty routing key) is never returned by this API.

FieldTypeDescription
idstringAlert policy ID.
namestringPolicy name.
isEnabledbooleanWhether the policy is currently active. A disabled policy still counts toward this endpoint's count, but not toward your plan's enabled-policy cap.
alertOnDown, alertOnDegraded, alertOnRecovery, alertOnSslExpiry, alertOnPausebooleanWhether this policy dispatches on that trigger, not a per-policy channel filter, and not scoped to this policy alone. If every enabled policy governing a monitor has a trigger off, that alert type is suppressed for the monitor entirely, down to the no-policy email floor. See below.
escalateAfterMinutesnumber | nullMinutes before escalation starts, or null if this policy never escalates.
repeatIntervalMinutesnumber | nullMinutes between repeat escalation notices, or null.
maxEscalationsnumber | nullCap on escalation notices, or null for no configured cap.
createdAt, updatedAtstringISO 8601 UTC timestamps.

An enabled policy with a trigger switched off can make a monitor quieter than attaching no policy at all. Pingara's no-policy email floor, where every organization member who hasn't opted out for themselves (notifyOnDown or the matching per-type flag, and notifyViaEmail, both read off their own membership row) gets an email when an incident opens, applies whenever a monitor has zero enabled governing policies, not merely zero attached ones: a monitor carrying only a disabled policy still has zero enabled governing policies, so the floor applies to it exactly as it would to a monitor with nothing attached at all. The moment at least one enabled policy governs the monitor, that policy's toggles decide instead: dispatch needs one of those enabled governing policies to have the trigger on, and a disabled policy's toggles never count, present or not. Attach a single enabled policy with, say, alertOnDown: false, and a down incident on that monitor reaches nobody, not even the floor email a completely unrouted monitor would have gotten. Attaching a second enabled policy with the trigger on clears the suppression immediately, so this isn't a common state, but it's an easy one to reach by routing a monitor through a policy meant for something else, like SSL-only notices or a recovery digest, and nothing else. This is deliberate product behavior, not a bug to route around, see Setting Up Alerts for the full mechanics of how policy toggles, multiple governing policies, and per-member preferences interact.

Example response:

{
  "count": 1,
  "data": [
    {
      "id": "p2xx…",
      "name": "Production paging",
      "isEnabled": true,
      "alertOnDown": true,
      "alertOnDegraded": false,
      "alertOnRecovery": true,
      "alertOnSslExpiry": true,
      "alertOnPause": false,
      "escalateAfterMinutes": 15,
      "repeatIntervalMinutes": 30,
      "maxEscalations": 3,
      "createdAt": "2026-02-01T00:00:00.000Z",
      "updatedAt": "2026-04-10T11:02:00.000Z"
    }
  ]
}

GET /api/v1/audit-logs

List your organization's audit log, newest first. Paginated, see Response envelope. It returns the same entries as Settings → Audit Logs, plus filters the settings tab doesn't have: actor, email address, IP address, and a date range.

A key reads only the organization it was created in, even when its creator is an owner or admin of other organizations too.

This endpoint needs a key with the audit:read scope, created by someone who is currently an owner or admin. Both conditions apply on every request.

The first is the scope. The key must carry audit:read as well as read (a write key satisfies read, but still needs audit:read). Neither read nor write includes it, so a key without it is refused with a 403 that names it:

{ "error": "This API key does not have the required \"audit:read\" scope." }

Pingara checks the scope first, before it reads anything else about the key's creator, so this answer is the same whatever the creator's role is. The 403 carries the usual X-RateLimit-* headers.

The second is the creator's role. Pingara checks it on every request, not just when the key is minted. If the creator has since been demoted to editor or viewer, or removed from the organization, the key stops working here and the API answers 403:

{ "error": "Audit logs require an API key created by an organization owner or admin." }

The key itself is still valid for every other read, and the 403 clears as soon as the creator is an owner or admin again. A key whose creator has been deactivated is a 401, as everywhere else.

Existing keys lost audit log access. audit:read did not exist when keys created before it were minted, and nothing grants it to them, so they now get the first 403 above. To keep an integration reading the log, create a new key with Audit log access ticked, see Key scopes. The log holds your members' IP addresses, so check Settings → API & Webhooks, and revoke any key you no longer need, particularly any with the Audit log badge. A Free downgrade removes the write scope from a key but leaves audit:read in place.

Query paramTypeDescription
limitinteger (1–100)Default 50. The maximum is 100, not the 200 other lists allow. A value out of range or not an integer is 400, never clamped.
eventTypestringOne event type, for example api_key.created. An unknown value is 400. See Event types.
categorystringOne of access, subscription, settings. An unknown value is 400.
actorUserIdstringOnly events caused by this user. An id that matches nobody returns an empty list, not 404. At most 64 characters; a longer value is 400.
emailstringOnly events whose actor has this email address. Exact match, case-insensitive. Must contain @, no whitespace, at most 254 characters.
ipstringOnly events from this IP address. Exact match (case-insensitive for IPv6). Must be an IPv4 or IPv6 address, not a hostname or a range.
excludeSessionCreatedbooleantrue hides sign-in events (session.created) and nothing else. false is the same as omitting it. Any other value is 400.
from, tostringISO 8601, inclusive window on each entry's createdAt, see ?from= and ?to=.
cursorstringOpaque pagination token from a previous response.

Filters combine freely and never produce a 400 for a combination. A combination that can't match anything, such as an eventType from a different category, or eventType=session.created with excludeSessionCreated=true, returns an empty last page straight away: data is [] and hasMore is false. A category that matches the eventType's own, or excludeSessionCreated=true beside any other eventType, filters nothing more and is dropped.

A page can hold fewer than limit entries, even none, while hasMore is true. Some filters (category, excludeSessionCreated, and any second filter alongside another one) are applied after Pingara has read a bounded stretch of the log, so a run of entries that don't match can fill a page before any that do. Keep following cursor until hasMore is false, and keep every other parameter identical on each request. Don't treat a short page as the end of the data. A page that stopped because it reached Pingara's read limit always has hasMore set to true, so following cursor until hasMore is false never skips an entry.

Each entry in data:

FieldTypeDescription
idstringEntry ID.
createdAtstringISO 8601 UTC timestamp of the event.
eventTypestringWhat happened, for example member.role_changed.
categorystringaccess, subscription, or settings.
actorobjectWho it is attributed to: kind (user, stripe, or system), plus userId, email, and name. The last three are null for stripe and system. They are a snapshot taken when the event happened, so they keep showing the person's name and email at that time even if they have since changed it or left. system covers anything the platform did on its own, including changes made by Pingara support, whose staff are never named.
viaApiKeyobject | nullSet when the change was made through the Developer API: id and name of the API key, where name is the key's name when the request was made (never its prefix or hash). The actor is then the person who created the key, not someone acting in the app. null for everything else. Today only webhook subscription changes carry it.
ipAddressstring | nullThe IP address the event came from, or null.
ipSourcestringHow far to trust ipAddress: request is the IP of the request that caused the event (including creating and revoking an API key), session is the IP of the actor's most recently active session (used for changes made in the browser and for subscription.portal_opened), and none means there is no address. A session address is usually the same machine, but it is not proof of where the request came from, so do not rely on it as evidence.
countryCode, countryNamestring | nullThe country the address geolocates to, for example GB and United Kingdom. null when there is no address, the address isn't on the public internet, or the lookup hasn't succeeded yet. Lookups for audit entries share a platform-wide hourly budget and a failed lookup is retried at most once a day, so a recent entry can stay null for a while before the country fills in. When the lookup service answers with no country for an address, that answer is kept for 30 days and not retried, so the entry stays null.
targetobject | nullWhat the event acted on: type, id, and label (label can be null). null when the event has no target.
changesarrayFor a change, one item per field: field, before, after. Values are null, a string, a number, a boolean, or an array of strings or numbers. Empty for events that change nothing.

Entries never contain a webhook URL, an alert channel's address or routing key, API key material beyond its name, a signing secret, a password, or a two-factor secret or code. The one kind of address an entry can hold is on the four email address events (account.email_change_requested, account.email_changed, account.email_change_cancelled, account.email_change_reverted and account.email_undo_link_cancelled), where changes lists the member's old email address and the one they asked to move to, or moved to. Only account.email_changed shows both addresses in full. On account.email_change_requested and account.email_change_cancelled the second address was only requested and may never have been confirmed, so it is masked to its first character and its domain (j***@example.com), and the entry has a second change, {"field": "cause", "before": null, "after": "<cause>"}, where the cause is cancel_link, settings, password_change, password_reset, sign_out_everywhere, email_change_reverted or support_two_factor_removed. On account.email_change_reverted the address the account moved away from is masked and the restored address is in full. On account.email_undo_link_cancelled the address the cancelled undo link would have restored is masked, and after is null. An undo also revokes API keys, each with an api_key.revoked entry whose actor kind is system and whose changes has the cause email_change_reverted.

When Pingara support removes an account's two-factor authentication at the owner's request, each organization the member belongs to gets two entries whose actor kind is system, with the member as the target: an auth.two_factor_disabled entry whose changes has the cause support_request, and an auth.password_reset_requested entry whose changes has the cause console_two_factor_removal. If an email address change was waiting, an account.email_change_cancelled entry with the cause support_two_factor_removed is written too, also with the system actor kind. The staff member and their reason are never in any of them.

Failed sign-ins (auth.login_failed, auth.two_factor_failed) are recorded only when the address was typed exactly as the account holds it, so an address with different capitals or stray spaces, or one that belongs to no account, leaves no entry. They are written a moment after the attempt, not in the same instant.

Entries are kept for 90 days. The same X-RateLimit-* headers and the 600 requests per minute per-key limit apply as on every other read.

Example response:

{
  "count": 2,
  "hasMore": false,
  "data": [
    {
      "id": "k17c…",
      "createdAt": "2026-09-30T12:00:00.000Z",
      "eventType": "member.role_changed",
      "category": "settings",
      "actor": {
        "kind": "user",
        "userId": "jd7a…",
        "email": "ada@acme.example",
        "name": "Ada Lovelace"
      },
      "viaApiKey": null,
      "ipAddress": "203.0.113.24",
      "ipSource": "session",
      "countryCode": "GB",
      "countryName": "United Kingdom",
      "target": { "type": "user", "id": "jh3b…", "label": "Grace Hopper" },
      "changes": [{ "field": "role", "before": "editor", "after": "admin" }]
    },
    {
      "id": "k17d…",
      "createdAt": "2026-09-30T11:42:10.000Z",
      "eventType": "webhook_subscription.created",
      "category": "settings",
      "actor": {
        "kind": "user",
        "userId": "jd7a…",
        "email": "ada@acme.example",
        "name": "Ada Lovelace"
      },
      "viaApiKey": { "id": "jx9e…", "name": "deploy-pipeline" },
      "ipAddress": "198.51.100.7",
      "ipSource": "request",
      "countryCode": null,
      "countryName": null,
      "target": { "type": "webhook_subscription", "id": "jm2c…", "label": "hooks.acme.example" },
      "changes": []
    }
  ]
}

Audit log event types

eventType is one of these values, grouped by category. New values can be added under v1, as an additive change (see Versioning), so treat an unrecognised one as valid rather than as an error in your parser. The label you see in the settings tab is shown beside each value.

CategoryEvent types
accesssession.created (Signed in), session.signed_out (Signed out), session.revoked (Session revoked), session.revoked_all (All sessions revoked), auth.login_failed (Failed sign-in attempt), auth.two_factor_failed (Failed two-factor code), auth.password_changed (Password changed), auth.password_reset_requested (Password reset requested), auth.password_reset_completed (Password reset completed), auth.two_factor_enabled (Two-factor authentication turned on), auth.two_factor_disabled (Two-factor authentication turned off), auth.backup_codes_regenerated (Backup codes regenerated), account.email_change_requested (Email address change requested), account.email_changed (Email address changed), account.email_change_cancelled (Email address change cancelled), account.email_change_reverted (Email address change undone), account.email_undo_link_cancelled (Email change undo link cancelled)
subscriptionsubscription.activated (Subscription started), subscription.updated (Subscription changed), subscription.payment_failed (Payment failed), subscription.downgraded (Downgraded to Free), subscription.restored (Subscription restored), subscription.portal_opened (Billing portal opened)
settingsorg.updated (Organization settings changed), org.ownership_transferred (Ownership transferred), member.invited (Member invited), member.invitation_cancelled (Invitation cancelled), member.joined (Member joined), member.role_changed (Member role changed), member.removed (Member removed), member.left (Member left), api_key.created (API key created), api_key.revoked (API key revoked), alert_policy.created (Alert policy created), alert_policy.updated (Alert policy changed), alert_policy.deleted (Alert policy deleted), alert_channel.created (Alert channel created), alert_channel.updated (Alert channel changed), alert_channel.deleted (Alert channel deleted), webhook_subscription.created (Webhook subscription created), webhook_subscription.updated (Webhook subscription changed), webhook_subscription.deleted (Webhook subscription deleted), webhook_subscription.secret_rotated (Webhook signing secret rotated)

Sign-in, sign-out, session, failed sign-in, password, email address change, and two-factor events belong to a person, so the same event appears in the audit log of every organization that person belongs to. If you pull the logs of several organizations into one place, deduplicate on actor.userId, eventType, and createdAt, not on id, which is different in each organization.

Example, everything one person did since 24 September, skipping sign-ins:

curl -G https://api.pingara.io/api/v1/audit-logs \
  -H "Authorization: Bearer pgr_abcdef0123456789..." \
  --data-urlencode "email=ada@acme.example" \
  --data-urlencode "from=2026-09-24" \
  --data-urlencode "excludeSessionCreated=true"

4. Writing: monitors and incidents

Every endpoint in this section requires the write scope (see Key scopes) and answers 403 without it. Each one calls the exact same mutation the Pingara UI calls. There's no separate API-only code path, so a monitor or incident change made through the API behaves identically to the same change made by clicking through the app, with the same validation, the same plan gates, and the same side effects.

Idempotency-Key

Every write endpoint accepts an optional Idempotency-Key header: 8–255 characters from A-Za-z0-9._:+/=- (UUIDs, ULIDs, nanoids, and base64/base64url all fit). Send the same key on a retry of the same request and you get back the original response rather than a second write, for up to one hour.

curl -X POST https://api.pingara.io/api/v1/monitors \
  -H "Authorization: Bearer $PINGARA_API_KEY" \
  -H "Idempotency-Key: create-marketing-site-2026-05-14" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Marketing site", "url": "https://www.example.com", "method": "GET", "environment": "production", "keywordCheckEnabled": false, "followRedirects": true, "maxRedirects": 5 }'

Creating a monitor is the genuinely unsafe write to retry without one. A network failure after Pingara processed your request but before you saw the response leaves you not knowing whether it succeeded, and blindly retrying a bare POST /monitors makes a second monitor, silently, with no error to tell you it happened. On the other writes an unkeyed retry is comparatively cheap: acknowledging an incident twice is a no-op (see below), and pausing, resuming, updating, or deleting something that's already in that state is either a no-op or a 404/409 you'll notice. If a create returns a 5xx and you aren't using an idempotency key, don't retry blind. List your monitors and check whether the one you meant to create is already there before sending it again.

POST /api/v1/monitors

Creates a monitor. Returns 201 with the same representation GET /api/v1/monitors/{id} returns.

Seven fields are required, and nothing here is defaulted for you:

FieldWhy it has no default
name, urlNothing to default to.
methodGET would be a reasonable guess, but it's a guess this API declines to make on your behalf.
environmentYour own dashboards filter on this. A defaulted "production" would be a mislabel that reads as fact.
keywordCheckEnabledWhether you want a body check at all is a decision, not a fallback.
followRedirects, maxRedirectsThese decide whether a probe follows a redirect Location header it doesn't control. Say what you want rather than inherit a default.

Every other field is optional and, where the product has a platform default (for example expectedStatusCodes), omitting it resolves to that same default. The same mutation the New Monitor form calls resolves it the same way regardless of caller.

Two fields that exist on the resource are deliberately not accepted here, and both are security decisions, not scope gaps.

  • isPaused. Monitor creation only pays your plan's monitor-count check when the new monitor is active; a paused create doesn't, because a paused monitor was never meant to count against a limit meant for things actually being checked. Accepting isPaused on this endpoint would let a script mint monitors past your plan limit for as long as they stayed paused. If you want to stage a paused monitor before pointing it at a real URL, create it active (which pays the count check, so it never puts your organization over its plan limit) and then call POST /monitors/{id}/pause. That costs one extra request and keeps the guarantee: your plan limit is never bypassed, only reached in two calls instead of one.
  • copyAlertRulesFrom. The "Duplicate monitor" affordance in the UI isn't exposed here; POST /monitors is a plain create, not a clone. Clone also routes by example, copying whatever alert rules some other monitor happens to carry, which is the wrong first move in a provisioning run anyway: the first monitor you create has no other monitor to point at, and every one after it would inherit routing that depends on which row you picked rather than on what you actually want. alertPolicyIds, below, is the real routing surface; clone is largely redundant on this API now that it exists.

alertPolicyIds is accepted, and attaches policies atomically with the create. Send an array of alert policy IDs (from GET /api/v1/alert-policies) and the new monitor is placed on all of them in the same transaction as the create, not a second request. That atomicity is the point, not a convenience: if a create-then-attach were two separate calls and the second one failed, you'd be left with an active, unrouted monitor you believe is routed, which is worse than knowing you configured nothing. A policy ID that doesn't exist or belongs to a different organization gets you a 404 naming it and the monitor is not created. You can attach at most 20 policies this way. Send more and the whole create is refused with 400 before anything is written; an organization that genuinely needs more than 20 on one monitor creates with 20 and attaches the rest with POST /monitors/{id}/alert-policies afterward. Attaching a policy requires the same role as changing alert routing in the product, owner, admin, or editor, and a key whose creator doesn't hold one of those roles gets 403, naming the permission, whether or not the create itself would otherwise have succeeded.

Unrecognized fields are refused with 400, not silently dropped. Send a field this endpoint doesn't know, such as orgId, status, pausedReason, or a typo, and you get an error naming it rather than a 201 that quietly ignored part of what you sent. orgId and similar identity fields aren't "accepted and overridden" either: they're derived entirely from your API key, and there's no request shape in which supplying them yourself would be correct.

A monitor created without alertPolicyIds has no alert routing

Omit alertPolicyIds and the new monitor is not attached to any alert policy. Creating a monitor never copies or assigns alert rules on its own. The same is true of a plain "New monitor" in the product UI. In practice this means no Slack, Teams, Discord, PagerDuty, or webhook routing, and no escalation, for that monitor, until you attach a policy, either with alertPolicyIds on the next create or with POST /monitors/{id}/alert-policies on this one.

This is easy to miss because it isn't silent. Every organization member gets an email when an incident opens on an unrouted monitor, Pingara's floor for a monitor with no policy attached, unless they've opted out for themselves (notifyOnDown or the matching per-type flag, and notifyViaEmail, both read off their own membership row). So a script that creates fifty monitors without alertPolicyIds looks like it's alerting correctly right up until the outage that needed to page someone at 3 a.m. and only sent an email nobody was watching for. If you're provisioning monitors in bulk through this API, pass alertPolicyIds on the create, or attach a policy immediately after, but attaching a policy is not automatically louder than attaching none. An enabled policy whose relevant trigger is off is quieter than no policy at all (see above), so confirm the policy id you're attaching actually has the trigger you're relying on switched on, on at least one enabled policy governing the monitor, before you rely on it for the page that has to come at 3 a.m.

That check only covers monitors this script creates going forward. This API has no endpoint that reads back a monitor's attached policies, so it can't tell you which of your existing monitors, created before you added alertPolicyIds, or through the UI without a policy attached, are still running on the email-only floor today; that's a check on each monitor's detail page, where the alert settings card states whether any enabled policy covers it and which triggers are suppressed, not something to script against yet.

A policy routes through this API exactly as it routes through the product, including one gap that predates this feature and isn't fixed by it: per-member notification preferences (notifyOnDown and the rest) gate a channel only when that channel carries a userId to match the preference against. Today, only a legacy alertChannels row does. The same preferences also gate the no-policy floor, matched against the recipient's own membership row rather than a channel's userId at all, so the floor email still honors them, whether it goes out for an unrouted monitor or to a member a routed monitor's channels don't cover. What those preferences don't gate is a newer-model policy channel (alertPolicyChannels → alertChannelConfigs): those are org-level destinations with no userId of their own, so every recipient the channel names is notified on every dispatch, regardless of their own notification settings. This is a known, accepted state, not something this API introduces or can route around; if your organization depends on per-member routing, check which model your policy's channels use before you rely on it for a monitor you provision here.

PATCH /api/v1/monitors/{id}

Updates a monitor. Accepts the same fields as create, plus isEnabled and isPaused (see below), minus type.

A monitor's type is immutable and type is refused, not ignored, on this endpoint. There's no conversion path from an HTTP monitor to a ping one. If you send type on a PATCH, you get a 400 naming it rather than a 200 that silently left the monitor's type untouched while you believed you'd changed it.

isPaused is accepted here, and POST /monitors/{id}/pause still exists separately. They resolve to the same underlying update with the same resume-time plan re-check and the same alert-dispatch side effects, so use whichever shape fits your integration: a full PATCH that happens to include isPaused: true, or the dedicated pause endpoint below. They differ only in their Idempotency-Key replay bucket, which is correct. A PATCH carrying four other field changes and a bare pause are different requests and should be retried independently.

Unrecognized fields are refused here too, for the same reason as create.

DELETE /api/v1/monitors/{id}

Deletes a monitor. The response body carries historyPurge: "queued":

{ "data": { "id": "m1aa…", "historyPurge": "queued" } }

The monitor is gone immediately; its history is not, and that's expected behaviour rather than a failed delete. The monitor row and its checks stop existing to every ordinary read the moment this call returns. It won't appear in GET /monitors, and its dashboard, checks, and incidents are all gone. Its historical check results, however, are purged in the background in batches, not inside this request. If you run a usage or history report immediately after deleting a monitor, it may still count checks against a monitor id that no longer resolves to anything. That's the purge finishing, not a bug. There's no endpoint to poll for purge completion; if you need to know it's done, wait a few minutes rather than building a retry loop around it.

POST /api/v1/monitors/{id}/pause and POST /api/v1/monitors/{id}/resume

Pause and resume a monitor. Both take no body. Resuming a monitor re-runs the same plan-count check a create does. You can't resume your way past your plan's monitor limit either.

POST /api/v1/monitors/{id}/alert-policies and DELETE /api/v1/monitors/{id}/alert-policies/{policyId}

Attach and detach one alert policy on one monitor. The policy is in the body on attach and in the path on detach. A DELETE with a request body is poorly supported by intermediaries and HTTP clients, so the pair follows the usual POST /collection + DELETE /collection/{member} convention rather than mirroring each other's shape exactly.

curl -X POST https://api.pingara.io/api/v1/monitors/m1aa…/alert-policies \
  -H "Authorization: Bearer $PINGARA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "policyId": "p2xx…" }'

Both return 200 with the post-condition, not the change:

{ "data": { "monitorId": "m1aa…", "policyId": "p2xx…", "attached": true } }

attached states whether the monitor is on the policy after this call, not whether this particular call is what changed it. Both endpoints are idempotent at the data layer. A repeat attach doesn't create a second link, and detaching a monitor that was never on the policy is a 200 with attached: false, not a 404. Read attached as the answer to "is this monitor on this policy now?", not "did I just do something." One consequence worth knowing if you script against this: no join-row ID is ever returned. A repeat attach resolves to the same existing link rather than a new one, so an ID here couldn't mean "I created this" without being wrong on every retry. There's nothing to fetch by it either, so it's left out rather than published and ignored.

An Idempotency-Key still buys you something here despite the convergent behaviour above: a truthful replay of your original request's response, rather than a fresh answer derived from state a concurrent attach or detach may have changed since. Attach and detach use separate replay namespaces from each other, so retrying an attach never replays a detach's response or vice versa.

Both endpoints require the same role as alertPolicyIds on create, owner, admin, or editor, and return 403 naming the permission otherwise. The monitor and the policy must both exist and both belong to your organization (404 if not); the membership between them doesn't have to, which is what makes a detach of an unattached policy a 200 rather than an error.

POST /api/v1/incidents/{id}/acknowledge

Acknowledges an incident. Takes no body.

First-acknowledger-wins, and a 200 here means "this incident is acknowledged," not "you acknowledged it." If the incident was already acknowledged, by anyone, through the UI or the API, this call is a silent no-op: it returns the incident unchanged and 200, without recording a second acknowledgement or overwriting who acknowledged it first. That's deliberate, not a missed edge case: a client retrying a call whose first attempt actually succeeded must not get an error for it. Read the response's acknowledgedAt to see when the incident was first acknowledged, not whether this particular call was the one that did it.

PATCH /api/v1/incidents/{id}

Updates an incident's status, notes, or rootCauseHint. Any organization member can acknowledge or update an incident through this API, including a viewer, the same membership-only rule the product itself applies; attaching or detaching a monitor's alert policies is a separate, role-gated capability, documented above under POST /monitors/{id}/alert-policies. notes is capped at 10,000 characters and rootCauseHint at 2,000 on this endpoint specifically, a tighter bound than the product's own incident-notes field, added because this is the path that makes an unbounded write scriptable and repeatable.

Write authority is re-checked on every request

A key's ability to write is re-resolved from your current organization membership and role on every single write call, not cached from when the key was created. If the person who created a key is removed from the organization, or has their role changed, the very next write through that key reflects it immediately: no separate revocation step, no propagation delay. (Reads don't yet carry this same live check. A removed member's key keeps reading their former organization's data until someone revokes it directly.)

Response envelope

List endpoints return:

{ "data": [ ... ], "count": <number> }

A paginated list endpoint (monitors, incidents, audit-logs, and their per-monitor variants) also carries hasMore, and cursor when another page exists:

{ "data": [ ... ], "count": <number>, "hasMore": true, "cursor": "…" }

cursor is present if and only if another page exists, so you can stop on hasMore === false or on an absent cursor and never disagree with yourself. Pass it back unchanged as ?cursor= and keep every other filter on the request identical. Changing a filter mid-pagination is undefined. /api/v1/status-pages is the one collection documented in this article that is not paginated: it takes no limit or cursor, because it is already bounded by your plan's status-page cap.

Single-resource endpoints return:

{ "data": { ... } }

Timestamps

Every REST response's timestamps are ISO 8601 strings in UTC, for example "2026-05-14T16:24:09.123Z". The webhook subscription envelope is the one exception: its own createdAt, and data.ssl.expiresAt / data.domain.expiresAt on the two expiry events, are epoch milliseconds, a number, not a string. data.incident inside that same envelope is unaffected. It's the REST incident object, so its timestamps stay ISO. REST Monitor.sslExpiresAt is the monitor's most recently reported certificate expiry, in ISO form; during a certificate rollout it can name a different certificate than an event's data.ssl.expiresAt.

?from= and ?to=

GET /api/v1/monitors/{id}/checks, GET /api/v1/incidents, GET /api/v1/monitors/{id}/incidents, and GET /api/v1/audit-logs accept an inclusive time window on these two parameters. Supply either, both, or neither. Three rules apply everywhere they're accepted:

  • A bare calendar date works (2026-01-01); a date-time must carry an explicit UTC offset (Z or ±HH:MM). A date-time with no offset is 400 rather than silently interpreted as UTC or as the server's local time, because those two readings can return different results for the same request.
  • An impossible calendar date is 400, not rounded forward. 2026-02-30 does not become 2026-03-02.
  • An inverted window (from after to) is 400, not an empty result. An empty list is indistinguishable from "nothing happened in this window," and during an outage that's the most expensive wrong answer this API can give.

GET /api/v1/monitors does not accept these two parameters and returns 400 if you send them. It has no time-ordered index to filter through, and a silently-ignored parameter would return the unfiltered list as though your window had been honoured.

Errors

Errors use standard HTTP status codes. The body is always { "error": string }, never a code, a nested object, or a list of field errors, so you can display the message directly or match on the status code alone.

StatusMeaning
400Bad request - invalid query parameter or body
401Missing, malformed, or revoked API key
403Valid key, but it lacks the scope this endpoint requires, see Key scopes (on GET /api/v1/audit-logs, that includes a missing audit:read), or (on that endpoint only) the key's creator is no longer an owner or admin
404Resource not found (or not in your organization)
409The request conflicts with current state, such as a plan limit, acknowledging an already-resolved incident, a cursor that can't be paged from right now, or an Idempotency-Key whose original request is still in flight
422An Idempotency-Key was reused with a request that doesn't match the original
429Rate limit exceeded, or a per-resource brake, see Retry-After and Rate limiting
503Writes are temporarily unavailable. Retry with backoff. Reads are unaffected
5xxAny other server error. Retry with exponential backoff

Rate limiting

There are two independent limits on the transport, and a request has to clear both. Some write endpoints add a third kind of limit, on the resource itself, see Per-resource brakes below.

LimitApplies toCeiling
Per keyEach API key600 requests per minute
Per client IPThe calling IP address, checked before the key is looked up1,200 requests per minute

The per-IP limit exists to make guessing at API keys expensive. It is normally invisible, but it does bind on shared infrastructure: a CI runner, a NAT gateway, or one host driving several keys can reach 1,200 while no individual key is anywhere near 600.

Rate-limit headers

Every response to a successfully authenticated request carries the key's current budget, not only a 429:

HeaderMeaning
X-RateLimit-LimitThe per-key ceiling, 600
X-RateLimit-RemainingRequests left in the current window. This is what the next request will actually find. The request you just made is already counted
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 587

Three things to know about them.

They describe the per-key limit only. There is no header for the per-IP limit, deliberately. Publishing the remaining budget of a limit whose purpose is to slow down callers who don't hold a valid key would tell an attacker exactly how much room they have left.

They appear only once a key has authenticated. A 401 carries none, and neither does a 429 raised by the per-IP limit. Read together with X-RateLimit-Remaining, that tells you which 429 you hit:

  • No X-RateLimit-* headers at all - the per-IP limit. Look at what else is calling from the same address, not at your own request rate.
  • X-RateLimit-Remaining: 0 - your key's own 600-per-minute limit.
  • X-RateLimit-* present with budget still on it - a per-resource brake. Your request rate is fine; the particular thing you were creating is what is capped, and Retry-After can be far longer than a minute.

There is no X-RateLimit-Reset. Pingara's limiter is a sliding window: each request expires 60 seconds after it was made, individually, so there is no single moment at which your quota "resets" and no honest value to put in that header. Use Retry-After instead.

Per-resource brakes

A few write endpoints carry a further limit of their own, on the resource rather than on the transport. Today there is exactly one: creating monitors is capped at 100 per hour, per organization, and separately at 300 per hour per human across every organization they belong to. Since an API key belongs to exactly one organization, the org limit, 100/hour, is the one a single key can actually reach. It exists to bound how fast rows can accumulate, not to pace your requests. In practice it's rarely the binding constraint: every create through this API pays your plan's monitor-count check the same way the UI does (1 on Free, 50 on Pro), so a Free-plan key can never get anywhere near 100 in the first place, and a Pro-plan key hitting 100 creates in an hour needs roughly 50 deletions inside that same hour to keep making room. The plan cap bounds how many monitors you can have; this brake bounds how fast you can create and delete them.

A request refused by one of these is also a 429, and it is still safe to retry. The identical request succeeds once the window rolls, with nothing for you to change. Two things make it different from the limits above: your key's own budget is usually untouched, so X-RateLimit-Remaining still shows room; and the wait is measured in minutes or hours rather than seconds. Read Retry-After for the wait and the message in the body for what was capped.

Do not confuse this with a 409. A 409 from a write endpoint is a refusal that will not clear on its own, such as a plan limit or a resource whose current state forbids the change, and retrying it unchanged will be refused again.

Retry-After

Every 429 carries Retry-After, in seconds, and it is computed rather than fixed.

For the two transport limits it is the time until your oldest counted request falls out of the sliding window, which is the earliest moment a slot actually frees, and it is never more than 60. Waiting that long and retrying once is the correct behaviour; a fixed sleep of a full minute is safe but usually longer than necessary.

For a per-resource brake it is that brake's own window instead, so it can be up to an hour. Treat it the same way, since it is still the answer to "when may I retry", but do not assume a ceiling of 60 seconds.

Build clients that respect Retry-After and back off further if a retry is also rejected.

CORS

The Developer API responds to cross-origin requests from any origin and answers preflight OPTIONS for GET, POST, PATCH, and DELETE, allowing the Authorization, Content-Type, and Idempotency-Key request headers, so you can call it directly from browser-based dashboards if you're comfortable exposing the key (in general you should proxy through your backend instead).

X-RateLimit-Limit, X-RateLimit-Remaining and Retry-After are listed in Access-Control-Expose-Headers, so browser code can read them. Response headers are not readable cross-origin unless the server names them there, so a client calling from a different origin will see them; one calling through your own backend proxy will only see whatever that proxy forwards.

Machine-readable contract

Everything in this article, every resource, request body, and error shape, is also published as an OpenAPI 3.1 document at https://api.pingara.io/api/v1/openapi.json. It's public: no API key needed to fetch it. Point a code generator, an API client, or an agent at it directly.

It covers every Developer API operation described above, plus the public status reads documented in Public Status Page API under a separately tagged Public status API section. Those operations declare security: [] since they take no key. preview and scan, the two same-origin endpoints behind the homepage certificate checker and the site scan starter, are deliberately not customer API surface and are not in the document.

5. Webhook subscriptions

This is a different feature from the "Webhook" alert channel in the Slack Integration and Webhook Integration articles. An alert channel pages a human (or a paging tool) through a policy. A webhook subscription is a standing HTTP receiver you register once, on the API, and it gets a signed POST for every event it's subscribed to, built for a system that reconciles state (a status dashboard, an incident-management tool, a data pipeline), not for paging.

At a glance:

  • Plan: Pro only. Free allows zero subscriptions.
  • Cap: 10 per organization, and the cap counts every subscription, suspended and disabled ones included.
  • Scope: listing and reading subscriptions (the two GET endpoints) needs read; every other operation needs write.
  • Role: creating, editing, enabling/disabling, resuming, rotating, and testing a subscription need owner, admin, or editor; any organization member, including a viewer, can list and read subscriptions. That's broader than an API key itself, which only an owner or admin may create or revoke (see "Create an API key" above). An editor can register a webhook subscription without either.
  • Receiver URL: https:// only. Plain http:// is refused.
  • Incident payloads carry the acknowledging and resolving member's name and email. data.incident.acknowledgedBy and data.incident.resolvedBy (each { name, email } | null) name whoever acknowledged or resolved the incident, see The envelope below. That's the same information any organization member can already see browsing incidents in the app, so registering a subscription doesn't expose anything a member with write access couldn't already read, but it does mean that data is now pushed to whatever URL an editor, admin, or owner points a subscription at.

Operations

EndpointWhat it does
GET /api/v1/webhook-subscriptionsList. Every subscription the organization holds, suspended and disabled ones included. Not paginated.
POST /api/v1/webhook-subscriptionsCreate. Registers a receiver and returns its signing secret, see The signing secret is shown once below.
GET /api/v1/webhook-subscriptions/{id}Get. One subscription.
PATCH /api/v1/webhook-subscriptions/{id}Update. Edit the URL, events, monitor filter, or description, or flip enabled.
DELETE /api/v1/webhook-subscriptions/{id}Delete. Removes it. Works even while webhook delivery is switched off platform-wide.
POST /api/v1/webhook-subscriptions/{id}/rotate-secretRotate secret. Mints a new signing secret.
POST /api/v1/webhook-subscriptions/{id}/testSend test. Sends one signed webhook.ping and reports the outcome.

None of these seven endpoints ever return the receiver URL or a signing secret in a GET. The list and get responses carry only urlHost, the URL's hostname and any non-default port, never the path, which routinely carries a bearer token of its own.

Create a subscription

curl -X POST https://api.pingara.io/api/v1/webhook-subscriptions \
  -H "Authorization: Bearer $PINGARA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/pingara",
    "events": ["incident.created", "incident.degraded", "incident.resolved"],
    "description": "Incident sync"
  }'
  • url - required, https:// only.
  • events - required, at least one, from the event vocabulary below.
  • monitorIds - optional array of monitor IDs. Omit it (or send null on update) to receive events for every monitor in the organization. An empty array is refused. That would mean "no monitor ever matches," not "every monitor."
  • statusChangedTo - optional array of "up", "degraded", "down". Duplicates are removed (so the stored filter holds at most three values), and the values you get back are stored in canonical order, not necessarily the order you sent. Omit it (or send null on update) to receive every status change; an empty array is refused with 400, the same as monitorIds. The filter is valid only alongside monitor.status_changed in events. Pingara checks this on create and on the merged row after a PATCH, and refuses with 400 in both directions, whether you send the filter without the event or, on PATCH, remove the event from a subscription that still carries the filter. On PATCH, null clears the filter; it's part of the "send at least one field" rule and the nullable-field set, the same as every other optional field.
  • description - optional, at most 200 characters.

A successful create answers 201 with the subscription and its signing secret.

The signing secret is shown once

create and rotate-secret are the only two responses that ever carry the plaintext signing secret. There is no "reveal" endpoint. Store it the moment you receive it, the same as an API key.

Both of these writes are deliberately not replayable. A retry of POST /webhook-subscriptions or POST /webhook-subscriptions/{id}/rotate-secret with the same Idempotency-Key does not replay the original response. It answers 409 with:

This Idempotency-Key has already been used and its original response is no longer available. The original request was performed; do not retry it.

That's intentional: replaying either response would hand the secret to anyone who could send the same idempotency key, and the Idempotency-Key replay cache is shared by every key in your organization, not scoped to the one that made the original request.

If you lose a create response: list your subscriptions (GET /webhook-subscriptions), find the one you just created by its urlHost or description, and rotate its secret.

If you lose a rotate-secret response: rotate again. Your receiver's current secret keeps working until you install the new one, see Rotating the signing secret below for exactly what happens to it when you rotate a second time.

Status codes

StatusMeaning
400A malformed request, such as an invalid URL, an unknown event name, an empty or oversized monitor filter, a description over 200 characters, or a statusChangedTo filter that's empty, naming an unknown status, or present without monitor.status_changed in events
401Missing, malformed, or revoked API key
403Valid key, but the caller's role isn't owner, admin, or editor
404Subscription not found (or not in your organization), or a monitor ID in the filter doesn't belong to your organization
409The plan's subscription cap is reached; the subscription is suspended (on /test); the secret changed mid-rotation, so retry; the subscription changed while you were updating it (for example it was auto-disabled or suspended mid-resume), so retry; or an Idempotency-Key was reused on create or rotate-secret (see above)
429More than 5 test sends in a minute on this subscription. Retry-After is 60.
503Webhook delivery is switched off for this deployment, or the platform's signing-secret encryption key is unavailable. Retry later. While delivery is switched off, turning a subscription off (PATCH with only "enabled": false) and deleting one still work.

Changes appear in the audit log

Creating, changing, deleting, and rotating the secret of a subscription are recorded in your audit log, whether you make them in the settings tab or through the API. An entry made through the API shows the person who created the key as the actor, the key's name in viaApiKey, and the IP address of your request. See GET /api/v1/audit-logs.

The event vocabulary

EventFires when
incident.createdA monitor is confirmed down, as a brand-new incident, a incident.degraded incident promoted to down, or a resolved incident reopened by hand while its monitor is down
incident.degradedA monitor is confirmed degraded (a new incident, distinct from a down one), or a resolved incident reopened by hand while its monitor isn't down
incident.resolvedAn incident recovers (resolutionSource: "system") or is resolved by hand (resolutionSource: "user")
monitor.pausedA monitor is paused
monitor.resumedA monitor is resumed
monitor.status_changedA monitor's status changes between up, degraded and down (never for unknown or pending)
ssl.expiringA monitored certificate crosses one of the monitor's SSL expiry warning thresholds. A notice, not an incident, see Certificate and domain expiry notices below for the payload shape, which differs from the alert-channel event of the same name
domain.expiringA monitored domain's registration crosses one of the monitor's domain expiry warning thresholds, or has already lapsed (milestone: 0). A notice, not an incident
webhook.pingSent only by "Send test". It's never subscribable, and every subscription receives it on request

Escalation notices are not in this vocabulary. An escalation notice re-announces a fact this stream already sent, so it isn't sent again. Only ssl.expiring is subscribable for certificate state. Nothing in Pingara produces the SSL incident error types this stream would otherwise carry. A certificate that fails its TLS handshake outright, whether expired, self-signed, or hostname-mismatched, surfaces as incident.created with data.incident.errorType: "tls_error" instead, see below.

monitor.status_changed fires each time a monitor's status, the same field GET /api/v1/monitors/{id} returns, changes to a different one of up, degraded and down than Pingara last published. The payload carries data.transition: { from, to }, present only on this event, see The envelope below, where to is always one of those three values and from is the same set, or null.

  • unknown (a full probe blackout) and pending never emit an event, in either direction. Entering one of those states emits nothing, and leaving one emits nothing either, unless the monitor's status on the way out differs from what was last published. A blackout that resolves back to the same state it was in before the blackout emits nothing at all; one that resolves to a different state emits exactly one event, with from set to whatever state was last published, never to unknown or pending.
  • from: null means no earlier status is known, usually a new monitor's first status. Treat it as "no previous status known", not as "this monitor was just created", and don't expect every monitor's first event to carry null.
  • from is whatever state Pingara last published for that monitor, not whatever your integration last actually received. Those can genuinely differ: a subscription created after that publish, one excluded by monitorIds at the time, one whose statusChangedTo filter excluded the event that would have delivered it, one that was disabled or suspended, or one whose delivery ultimately failed all see a from they never actually got. Under a statusChangedTo filter, a from you never received is routine, not an edge case. It's the previous status the filter's own to values excluded. Treat it as the platform's record of what it last sent, and reconcile, rather than assume an unbroken chain, whenever it doesn't match what you last actually processed.
  • Nothing emits while a monitor is paused, and nothing emits for a monitor covered by a maintenance window that suppresses alerts. Once the pause or the window ends, the next check result emits one catch-up event if the monitor's status differs from what was last published (per the point above). Pausing or resuming a monitor never itself emits monitor.status_changed; that's what monitor.paused/monitor.resumed are for, and the two are independent.
  • Existing subscriptions are not opted into monitor.status_changed automatically. PATCH the subscription and add it to events yourself.
  • Filter by monitor with monitorIds, and by destination status with statusChangedTo (see Create a subscription above). There's still no server-side filter on from; apply that on your side after a delivery arrives.
  • Order events by createdAt, not by arrival order. A retried delivery can arrive after a later event was already delivered. Pingara-Delivery-Id tells you a delivery is a retry of one you've seen, but not where it sits relative to others. createdAt is when Pingara sent the event, so a catch-up after a pause or maintenance window carries the time it was sent, not the time the status changed. On an unfiltered subscription, treat the most recent event's to as the monitor's current status; on one filtered by statusChangedTo, don't. Under ["down"], the latest event you receive is always down, even after the monitor has recovered. Read GET /api/v1/monitors/{id} for the monitor's actual current status instead.

incident.created can fire twice for the same incident, and that's correct. Upsert on data.incident.id, never on the event id alone. A monitor that degrades and is later confirmed down sends incident.degraded and then incident.created for the same data.incident.id: the severity rose. Do not deduplicate on (incident id, event type). Deduplicate deliveries on Pingara-Delivery-Id (below), and reconcile incident state by upserting on data.incident.id.

A monitor's url, as carried in data.monitor.url, is the value stored on the monitor. For an HTTP or WebSocket monitor that's a full URL; for a ping, TCP, or DNS monitor it's a bare hostname (for example db.internal.example.com), not a URL. Don't pass it to a URL parser unconditionally.

A manual resolve or reopen emits too, distinctly from a platform-detected one. Resolving an incident by hand, through the UI or PATCH /api/v1/incidents/{id}, sends incident.resolved, with data.incident.resolutionSource set to "user" rather than "system". Reopening one sends incident.created if the monitor is currently down, or incident.degraded otherwise; if the monitor later goes down, you get incident.created for the same data.incident.id, the same severity-rise shape described above, so keep deduplicating on the delivery id, never on (incident id, event type). data.incident carries no severity field. The event type is the only severity signal, so an incident.created or incident.degraded for an incident id you hold as resolved means someone reopened it. A reopen while the monitor is up is reported as incident.degraded, and the next healthy check result resolves the incident again, so expect incident.resolved with resolutionSource: "system" within about one check interval, unless a maintenance window that suppresses alerts covers the monitor, in which case that resolve is suppressed like any other platform-detected event (see Delivery semantics). Neither a manual resolve nor a manual reopen is suppressed by a maintenance window. Maintenance suppression, described below, only ever applies to a platform-detected transition. A no-op update, or one that only edits notes/rootCauseHint, emits nothing.

A human resolve or reopen's webhook emit is capped at 10 per incident, per rolling hour, and 100 per organization, per rolling hour, and both caps count only emits Pingara schedules, never toggles. Toggling one incident's status by hand, through the UI or PATCH /api/v1/incidents/{id}, never stops the toggle itself: the incident's status and timeline in Pingara keep updating every time, the same as always. What the caps limit is the webhook side, and the count only advances when an emit is actually scheduled. A resolve or reopen that gets skipped by either cap doesn't count against that cap, or the other one, at all. For example, an incident that sends its full hourly allowance of emits within the first few minutes of the hour, then has several more resolves and reopens skipped over the next half hour, doesn't get a fresh emit until roughly an hour after that first batch, not an hour after the last skipped toggle, because the skipped toggles never touched the counter. A skipped emit is never sent later: once the window clears room, only the next human resolve or reopen actually gets one. The organization cap works the same way, combined across every incident in it: once the organization has used its full hourly allowance of human-transition emits, any more are skipped too, however far their own incident is from its own per-incident limit, so an automation that bulk-resolves or bulk-reopens enough incidents within an hour to exceed that allowance (for example, cleaning up after a wide outage) sees the extra incidents update correctly with no corresponding webhook emit. Both caps are shared between the app and the API. A toggle counts the same way regardless of which one made it. One automatic change counts against them too: when a manually reopened incident is resolved again by monitoring and Pingara withholds the repeat recovery notice to your alert channels (because it already announced that incident's recovery), its incident.resolved emit is charged to both caps and skipped the same way once either is reached. Every other automatic status change is never braked, though a maintenance window that suppresses alerts still withholds it (see Delivery semantics). Because either cap can withhold the emit, don't rely on webhook delivery alone to know an incident's current status. Treat GET /api/v1/incidents/{id} as the source of truth, and poll it if a resolve or reopen matters and you haven't seen the corresponding event.

Certificate and domain expiry notices

  • One event per milestone, not per threshold. If several of your warning thresholds are crossed at once, such as when you first turn warnings on or after a maintenance window, you get one event, and milestone is the lowest threshold crossed. daysUntilExpiry is the actual count and can be lower than milestone.
  • Events are per monitor. Several monitors on the same domain each send their own event. For domain.expiring, collapse them on (data.domain.name, data.domain.expiresAt, data.domain.milestone). For ssl.expiring, deduplicate on (data.monitor.id, data.ssl.expiresAt, data.ssl.milestone). That key collapses repeats from one monitor, not events from different monitors, because the payload doesn't say which host served the certificate. Always include expiresAt: after a renewal the same milestone is crossed again for the new expiry date, and that event is a real warning. Deduplicate retries of one delivery on Pingara-Delivery-Id, as for every event.
  • For a monitor that follows redirects, data.ssl describes the certificate served at the end of the redirect chain. That certificate can be on a different host from data.monitor.url, and an http:// monitor that redirects to https:// can send ssl.expiring too. No field names the host that served the certificate, so don't parse one out of data.monitor.url to decide what to renew.
  • While a certificate is being rolled out, regions can see the old and new certificates at the same time, and you can receive ssl.expiring more than once for the old certificate's milestone. The key above collapses these. Check data.ssl.expiresAt before acting: it names the certificate that check measured, which for a redirecting monitor is the one at the end of the chain.
  • A maintenance window that suppresses alerts delays ssl.expiring; it doesn't drop it. The milestone is sent on the first check after the window ends, unless the certificate was renewed in the meantime. domain.expiring is never suppressed by a maintenance window. A registration deadline isn't related to the work a window covers.
  • A policy that switches off SSL expiry alerts doesn't stop ssl.expiring. It only stops the alert-channel notice; the subscription event is still sent, once per milestone.
  • Pausing a monitor stops new expiry checks, so it stops new events, but one check already in progress when you paused it can still send one.
  • There's no ssl.expiring event for a certificate that has already expired. An expired certificate fails the TLS handshake, so the monitor goes down and you receive incident.created with data.incident.errorType set to tls_error instead (subscribe to incident.created to get it). tls_error also covers self-signed and hostname-mismatched certificates. A lapsed domain registration is different: it has no handshake to fail, so domain.expiring is sent with milestone: 0.

The envelope

Every delivery's body has this shape:

{
  "id": "3f2b8c1e-9a4d-4c6b-8e2f-1a7d5b9c0e34",
  "type": "incident.created",
  "apiVersion": "v1",
  "createdAt": 1758000000000,
  "data": {
    "monitor": { "id": "k17...", "name": "Marketing site", "url": "https://www.example.com" },
    "incident": { "id": "k29...", "status": "investigating", "...": "same shape as GET /api/v1/incidents/{id}" }
  }
}
  • id is the delivery id, the same value as the Pingara-Delivery-Id header, and it's identical across every retry of one delivery and across every subscription one event fans out to. Deduplicate on it.
  • data.incident is present only on incident.* events, and is the same object GET /api/v1/incidents/{id} returns.
  • data.transition is present only on monitor.status_changed, and is always { from, to }. to is one of up/degraded/down; from is the same set, or null (see the event vocabulary above for what null means).
  • data.ssl is present only on ssl.expiring, and is always { daysUntilExpiry, expiresAt, milestone }, numbers only, no issue or recommendation prose. This is not the same shape as the alert-channel webhook's ssl.expiring payload, which has no expiresAt.
  • data.domain is present only on domain.expiring, and is always { name, daysUntilExpiry, expiresAt, milestone }. milestone: 0 means the domain registration has already lapsed. Same "not the alert-channel shape" caveat as data.ssl above.
  • Neither expiry event carries data.incident. The two expiry events are notices, not incidents, and never include one.
  • expiresAt on both data.ssl and data.domain is epoch milliseconds, not an ISO string, see Timestamps above.
  • The body is serialized as 7-bit ASCII: every character above U+007E is escaped as \uXXXX. JSON.parse gives you back the identical value; the only effect is that the bytes on the wire never depend on your framework's default text encoding.

Delivery semantics

  • Up to 4 attempts per delivery, at roughly 1, 5, and 15 minutes after the first failure, with ±20% random jitter on the retry delays (never on the first attempt). A delivery that keeps failing is abandoned after the fourth attempt; a failure Pingara classes as permanent, such as a blocked destination, isn't retried at all.
  • A redirect response is never followed. Point the URL directly at your receiver.
  • Pingara-Delivery-Id is stable across every retry of one delivery. Use it, not the timing of the request, to recognize a retry of something you already processed.
  • A maintenance window that suppresses alerts also suppresses the platform-detected incident.* and monitor.status_changed events (a manual resolve or reopen is never suppressed). For incident.* events there is no catch-up when the window ends, which has two consequences:
    • An incident opened inside a suppressing window and resolved after it produces an incident.resolved for an incident id you never received an incident.created for.
    • An incident opened before a window and resolved inside it never gets an incident.resolved at all. Reconcile it with GET /api/v1/incidents/{id} once the window ends.
    • monitor.status_changed catches up: a status change suppressed during the window is sent once, on the next check result after the window ends, if the monitor's status still differs from what was last published.
    • The two expiry notices work differently. ssl.expiring is delayed rather than dropped. Its milestone is sent on the first check after the window ends, unless the certificate was renewed in the meantime. domain.expiring is never suppressed by a maintenance window.
    • Maintenance suppression only applies to a monitor that's on a status page with a maintenance window covering it; a monitor on no status page is never suppressed this way.
  • An incident.* event superseded by a later transition before its delivery runs is dropped, not delivered stale. Pingara loads the incident row when a delivery's fan-out actually runs, not when the transition happened, so a resolve immediately followed by a reopen, or the reverse, can leave an already-scheduled event's payload disagreeing with the incident's row by the time it's due to send. Rather than deliver incident.resolved for a row that isn't resolved, or incident.created/incident.degraded for a row that is, Pingara drops that event. The later transition's own event, if one is emitted, reflects the row's current state, but that event can itself be withheld, by either cap on human resolve and reopen emits or by a maintenance window that suppresses alerts, so a dropped event isn't always replaced. So you can receive incident.resolved for an incident id you never saw incident.created/incident.degraded for, and the reverse. An incident created and resolved before the first event's delivery runs delivers only incident.resolved, for an id you never saw open. GET /api/v1/incidents/{id} stays authoritative; don't assume every incident.resolved was preceded by a create or degrade you actually received.
  • A subscription is automatically disabled after both of two thresholds are crossed: at least 20 consecutive failed deliveries, and a failure streak of at least 72 hours. (A brief, high-volume failure alone doesn't trip it. A receiver sharing the same outage your monitor is reporting needs to keep receiving the eventual incident.resolved.) Disabling never deletes the row, and every manager (owner, admin, or editor) on the organization gets one email when it happens. Re-enable it from Settings → API & Webhooks or with PATCH .../{id} and { "enabled": true }.
  • A plan downgrade suspends every subscription on the organization, and upgrading back to Pro does not resume them automatically. Re-enable each one individually the same way as an auto-disabled subscription. The suspension lands in the same transaction that pauses any monitor over your new plan's limit, so a downgrade's own monitor pausing never reaches your receiver as a monitor.paused event. A suspended subscription is filtered out before an event is even matched against it, the same way an auto-disabled one is. After re-upgrading, resume the subscription first, then the monitors. Resuming a monitor doesn't wait for its subscriptions to come back: it fires monitor.resumed regardless, and that event reaches nobody while the subscription is still suspended. monitor.status_changed doesn't replay what the suspension hid. Re-enabling a subscription sends nothing by itself, and a status change published while it was suspended, for example on a monitor that stayed active on your new plan, isn't re-sent: Pingara counts it as published, so your next event's from can be a status you never received (see the event vocabulary above). A monitor the downgrade paused is different: after you resume it, the first check result after which the monitor's status differs from the last one Pingara published for that monitor sends one monitor.status_changed, and it reaches you only if the subscription is already active, the second reason to resume the subscription first. After re-enabling, read GET /api/v1/monitors to reconcile current status.

Verifying deliveries

Every delivery, including webhook.ping test sends, carries these headers:

HeaderValue
Pingara-Signaturet=<unix-seconds>,v1=<hex>[,v1=<hex>]
Pingara-Eventthe body's type, for routing before you parse JSON
Pingara-Delivery-Idthe body's id

Act on the fields inside the verified body, not on these headers. Pingara-Event and Pingara-Delivery-Id are routing conveniences and aren't themselves signed.

Pingara-Signature carries two v1 values only during a secret rotation's overlap window, one for the new secret, one for the old one you may not have installed yet. Accept a match against any v1 value present.

What's signed: HMAC-SHA256 over the ASCII string t + "." + rawBody, where rawBody is the exact, unparsed request body bytes. The key is the whole signing secret string, whsec_... prefix included, taken as UTF-8 bytes, not the bytes you'd get by base64url-decoding what follows whsec_. That's deliberate: it removes a decode step most cross-language HMAC mismatches turn out to be, and it costs nothing, since any string is a valid HMAC key.

Verify it like this:

  1. Read the raw request body bytes, before parsing JSON.
  2. Split Pingara-Signature on ,. Split each part at its first =, trimming ASCII whitespace from both sides. Skip a part with no =.
  3. There must be exactly one t. It must match ^[0-9]{1,12}$, ASCII digits only. (Don't use a locale-aware "is this a digit" check. Some accept characters like ² that this pattern must reject.)
  4. There must be at least one v1. Ignore any other key. That leaves room for us to add a v2 scheme later without breaking your verifier.
  5. Reject if t is more than 300 seconds from your own clock, in either direction.
  6. Compute expected = HMAC-SHA256(secret, t + "." + rawBody) as lowercase hex.
  7. Accept if any v1 value is 64 lowercase hex characters and equals expected, compared in constant time, never with == or ===.
  8. Only now parse the JSON body and act on it. Deduplicate on its id field; keep seen ids for at least an hour to cover the full retry window plus the 300-second clock skew allowance.

Node.js:

import crypto from "node:crypto";

function verifyPingaraSignature(rawBody, header, secret, nowS = Math.floor(Date.now() / 1000)) {
  if (typeof header !== "string") return false;
  let t;
  const v1 = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i < 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t") {
      if (t !== undefined) return false;
      t = value;
    } else if (key === "v1") {
      v1.push(value);
    }
  }
  if (t === undefined || !/^[0-9]{1,12}$/.test(t) || v1.length === 0) return false;
  if (Math.abs(nowS - Number(t)) > 300) return false;
  const expected = crypto
    .createHmac("sha256", Buffer.from(secret, "utf8"))
    .update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]))
    .digest();
  return v1.some(
    (sig) =>
      /^[0-9a-f]{64}$/.test(sig) &&
      crypto.timingSafeEqual(expected, Buffer.from(sig, "hex")),
  );
}

// Express: app.use(express.raw({ type: "application/json" })) on this route
// so req.body is the raw Buffer verifyPingaraSignature expects.
const ok = verifyPingaraSignature(
  req.body,
  req.headers["pingara-signature"],
  process.env.PINGARA_WEBHOOK_SECRET,
);

Python:

import hmac, hashlib, re, time

def verify_pingara_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    t, sigs = None, []
    for part in (header or "").split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t":
            if t is not None:
                return False
            t = value
        elif key == "v1":
            sigs.append(value)
    if t is None or not re.fullmatch(r"[0-9]{1,12}", t) or not sigs:
        return False
    if abs(int(time.time()) - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode("utf-8"), t.encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(re.fullmatch(r"[0-9a-f]{64}", s) and hmac.compare_digest(expected, s) for s in sigs)

# Flask: request.get_data(cache=True) before request.get_json() reads the
# raw bytes verify_pingara_signature expects.
ok = verify_pingara_signature(
    request.get_data(),
    request.headers.get("Pingara-Signature", ""),
    os.environ["PINGARA_WEBHOOK_SECRET"],
)

Worked example, so you can check your own implementation against a known answer:

secret   : whsec_example_signing_secret_do_not_use
t        : 1727470800
rawBody  : {"id":"evt_example_0001","type":"webhook.ping","apiVersion":"v1","createdAt":1727470800000,"data":{}}
v1       : 23bc8a4129e25f7c1c7f8f790ddc61147302bf9667c58fa5e0bf782b1c75f53f
header   : t=1727470800,v1=23bc8a4129e25f7c1c7f8f790ddc61147302bf9667c58fa5e0bf782b1c75f53f

Both reference implementations above verify this vector correctly, and correctly reject it if you change a single character of rawBody.

Rotating the signing secret

curl -X POST https://api.pingara.io/api/v1/webhook-subscriptions/wsub1aa…/rotate-secret \
  -H "Authorization: Bearer $PINGARA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "overlapSeconds": 3600 }'

overlapSeconds is optional, a whole number from 0 to 86400 (24 hours), defaulting to 86400. It controls how long your previous secret keeps signing deliveries alongside the new one, so you have time to install the new secret in your receiver before the old one stops working. overlapSeconds: 0 retires the old secret immediately.

Rotating a second time while an overlap from the first rotation is still open replaces the current secret only. It does not extend or restart the overlap for the secret your receiver may already hold. The previously-retained secret's expiry can only move earlier from a second rotation, never later. If you rotate twice in quick succession, the very first secret may already be gone; install each new secret promptly rather than batching rotations.

Create an API key and manage subscriptions

Webhook subscriptions live in the same place as your API keys: Settings → API & Webhooks (the mobile navigation still labels this tab API). The section lists every subscription, its delivery status, and a "Send test" action, and lets you create, edit, pause, resume, and rotate a subscription's secret without the API.

6. Examples

curl: list every down monitor

curl -sS "https://api.pingara.io/api/v1/monitors?status=down" \
  -H "Authorization: Bearer $PINGARA_API_KEY" | jq '.data[].name'

Node.js / TypeScript

const base = process.env.PINGARA_API_BASE!; // https://api.pingara.io/api/v1
const key = process.env.PINGARA_API_KEY!;

async function listOpenIncidents() {
  const res = await fetch(`${base}/incidents?status=investigating&limit=100`, {
    headers: { Authorization: `Bearer ${key}` },
  });
  if (!res.ok) throw new Error(`Pingara API ${res.status}: ${await res.text()}`);
  const body = (await res.json()) as { data: Array<{ id: string; monitorId: string; startedAt: string }> };
  return body.data;
}

Python

import os, requests

base = os.environ["PINGARA_API_BASE"]
key = os.environ["PINGARA_API_KEY"]

resp = requests.get(
    f"{base}/monitors",
    headers={"Authorization": f"Bearer {key}"},
    params={"limit": 200},
    timeout=15,
)
resp.raise_for_status()
for m in resp.json()["data"]:
    print(m["status"], m["name"], m["url"])

GitHub Actions deploy gate

- name: Block deploy if any monitor is down
  env:
    PINGARA_API_KEY: ${{ secrets.PINGARA_API_KEY }}
  run: |
    count=$(curl -sS \
      "$PINGARA_API_BASE/monitors?status=down" \
      -H "Authorization: Bearer $PINGARA_API_KEY" | jq '.count')
    if [ "$count" -gt 0 ]; then
      echo "::error::$count monitors currently down - aborting deploy"
      exit 1
    fi

7. Versioning & deprecation policy

  • The v1 surface is stable. We will not remove fields or change types without bumping the major version.
  • Additive changes (new fields on existing responses, new endpoints, new query parameters with safe defaults) ship under v1 and do not constitute a breaking change.
  • When v2 ships, v1 will continue to operate for at least 12 months after the v2 GA announcement.

8. Feedback

Have feedback or a use case we haven't covered? Email support@pingara.io. We'd love to hear from you.

Related Articles

Integrations5 min

Webhook Integration

Configure custom webhook integrations to connect Pingara with any external system. Understand payload formats, event types, authentication, and testing strategies.

Integrations5 min

Slack Integration

Set up Slack webhook integration to receive rich, formatted Pingara alerts directly in your Slack channels with incident details and quick action links.

Alerts6 min

Setting Up Alerts

Learn how to create alert policies, configure notification rules, and ensure your team is always informed when monitors detect issues.