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/writescope pair, plus an optionalaudit:readscope that nothing else implies. Reading monitors, incidents, check results, uptime, and status page state needsread; reading the audit log needs bothreadandaudit:read, on a key created by an owner or admin; creating, updating, pausing, resuming, or deleting a monitor, and acknowledging or updating an incident, needswrite, 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
- Sign in to Pingara and switch to the organization you want to script against.
- Open Settings → API & Webhooks (the mobile navigation still labels this tab API).
- Click Create key, give it a recognisable name (for example
CI deployorGrafana sync), and submit. - 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:
| Scope | Grants |
|---|---|
read | Reading monitors, incidents, check results, uptime, and status page state. It does not include the audit log |
write | Everything 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:read | Reading 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 param | Type | Description |
|---|---|---|
limit | integer (1–200) | Maximum monitors to return. Default 50. |
status | string | Filter to one of up, down, degraded, pending, paused. |
cursor | string | Opaque 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 param | Type | Description |
|---|---|---|
limit | integer (1–200) | Default 50. |
cursor | string | Opaque pagination token from a previous response. |
from, to | string | ISO 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.
| Field | Type | Description |
|---|---|---|
id | string | Check result ID. |
monitorId | string | ID of the monitor this check belongs to. |
region | string | Probe region that performed the check (for example us-east-1). |
timestamp | string | ISO 8601 UTC timestamp when the check ran. |
dnsLookupTime | number | DNS resolution time, in milliseconds. |
tcpConnectTime | number | TCP connection time, in milliseconds. |
tlsHandshakeTime | number | TLS handshake time, in milliseconds. 0 for checks that never reach TLS. |
ttfb | number | Time to first byte, in milliseconds. |
totalDuration | number | Total check duration, in milliseconds. |
responseSize | number | Response body size, in bytes. |
statusCode | number | null | HTTP status code, or null for non-HTTP checks and failures that never received a response. |
isUp | boolean | Whether this individual check passed. |
errorType | string | null | Short 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. |
errorMessage | string | null | Human-readable error detail, or null if the check succeeded. |
sslExpiresAt | string | null | ISO 8601 UTC expiry of the certificate presented during this check, or null. See Certificate fields below. |
sslDaysUntilExpiry | number | null | Integer days until expiry, computed against the probe's clock at check time, or null. See Certificate fields below. |
sslChainValid | boolean | null | Whether the certificate chain validated, or null. See Certificate fields below. |
sslChainError | string | null | Validation 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. |
sslWeakSignature | boolean | null | true 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. |
tlsVersion | string | null | Negotiated 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. |
cipherSuite | string | null | Negotiated 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. |
icmpPacketLoss | number | null | Non-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:
sslChainValid | sslExpiresAt | Meaning |
|---|---|---|
true | timestamp | Certificate valid. Use sslDaysUntilExpiry for runway. |
false | null | Validation failed. Expired, self-signed, untrusted, or a hostname mismatch. See sslChainError. |
null | null | No TLS handshake was attempted. A tcp or ping monitor, or a plain HTTP monitor. |
The negative you'll never see:
sslDaysUntilExpiryis 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:sslExpiresAtandsslDaysUntilExpiryboth becomenull, andsslChainValidbecomesfalse.0, meaning "expires within 24 hours", is therefore the last numeric reading you'll see before a certificate lapses.
- Prefer
sslExpiresAtoversslDaysUntilExpiry. The days figure is aMath.floorsnapshot against the probe's clock at check time. A check from a day ago reporting7actually means6today. If you're building a renewal dashboard, recompute runway from the absolute timestamp. - On a monitor, the same field is a bigger trap.
monitors[].sslDaysUntilExpiryis a snapshot as oflastCheckedAt, not now. A paused monitor keeps its last reading indefinitely, so it can still report30a year later. Compute live runway from the monitor'ssslExpiresAtinstead. - 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
nulluntil 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,tlsVersionandcipherSuiteare 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 aserrorType: "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
sslblock, but that block carries onlydaysUntilExpiry,issueandrecommendation. There is nossl.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.
sslWeakSignaturehas three states, andfalseis not the absence oftrue.truemeans 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.falseis 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.nullmeans neither claim could be made, because the key type was unidentified or no signature algorithm was available.sslWeakSignature === falseis the only value that means "checked and sound". Treatingnullasfalsein 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.
tlsVersionandcipherSuiteare 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 treatsslChainError. ExpectTLSv1.3,TLSv1.2and the OpenSSL-style suite names, but don't assume the set is fixed.- A weak signature does not make a check fail.
isUpis unaffected,errorTypestaysnull, 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, or100. There's no such thing as "5% loss" in a Pingara check result. The API doesn't clampicmpPacketLossto 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
===. GNUpingreports 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 ?? 0is a bug in your integration.nullmeans "not measured," not "no loss."pingmonitors always set it.httpmonitors set it only when the probe has ICMP enabled and the hostname resolves to something pingable.tcpmonitors 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
httpmonitors, packet loss is pure telemetry that never affectsisUp. 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.
ttfbandtotalDurationonly 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
icmpPacketLossfor 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 param | Type | Description |
|---|---|---|
limit | integer (1–200) | Default 50. |
status | string | Filter to investigating, identified, monitoring, or resolved. |
errorType | string | Filter to an exact errorType value. No fixed vocabulary, see below. |
cursor | string | Opaque pagination token from a previous response. |
from, to | string | ISO 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 param | Type | Description |
|---|---|---|
period | string | One 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 param | Type | Description |
|---|---|---|
period | string | One 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 param | Type | Description |
|---|---|---|
limit | integer (1–200) | Default 50. |
status | string | Filter to investigating, identified, monitoring, or resolved. |
monitor | string | Filter to one monitor by name, exact and case-sensitive match. See below. |
errorType | string | Filter 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. |
cursor | string | Opaque pagination token from a previous response. |
from, to | string | ISO 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.
| Field | Type | Description |
|---|---|---|
id | string | Incident ID. |
monitorId | string | ID of the monitor this incident belongs to. |
status | string | One of investigating, identified, monitoring, resolved. |
startedAt | string | ISO 8601 UTC timestamp when the incident opened. |
resolvedAt | string | null | ISO 8601 UTC timestamp when the incident resolved, or null while open. |
acknowledgedAt | string | null | ISO 8601 UTC timestamp when a team member acknowledged the incident, or null if unacknowledged. |
errorType | string | null | Short 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. |
errorMessage | string | null | Human-readable error detail captured at incident open. |
rootCauseHint | string | null | AI-generated root-cause suggestion, or null if not yet generated. |
affectedRegions | string[] | Regions that reported the failure. Empty array if none were recorded. |
avgResponseTime | number | null | Average 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.
| Field | Type | Description |
|---|---|---|
id | string | Alert policy ID. |
name | string | Policy name. |
isEnabled | boolean | Whether 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, alertOnPause | boolean | Whether 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. |
escalateAfterMinutes | number | null | Minutes before escalation starts, or null if this policy never escalates. |
repeatIntervalMinutes | number | null | Minutes between repeat escalation notices, or null. |
maxEscalations | number | null | Cap on escalation notices, or null for no configured cap. |
createdAt, updatedAt | string | ISO 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 param | Type | Description |
|---|---|---|
limit | integer (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. |
eventType | string | One event type, for example api_key.created. An unknown value is 400. See Event types. |
category | string | One of access, subscription, settings. An unknown value is 400. |
actorUserId | string | Only 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. |
email | string | Only events whose actor has this email address. Exact match, case-insensitive. Must contain @, no whitespace, at most 254 characters. |
ip | string | Only events from this IP address. Exact match (case-insensitive for IPv6). Must be an IPv4 or IPv6 address, not a hostname or a range. |
excludeSessionCreated | boolean | true hides sign-in events (session.created) and nothing else. false is the same as omitting it. Any other value is 400. |
from, to | string | ISO 8601, inclusive window on each entry's createdAt, see ?from= and ?to=. |
cursor | string | Opaque 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:
| Field | Type | Description |
|---|---|---|
id | string | Entry ID. |
createdAt | string | ISO 8601 UTC timestamp of the event. |
eventType | string | What happened, for example member.role_changed. |
category | string | access, subscription, or settings. |
actor | object | Who 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. |
viaApiKey | object | null | Set 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. |
ipAddress | string | null | The IP address the event came from, or null. |
ipSource | string | How 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, countryName | string | null | The 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. |
target | object | null | What the event acted on: type, id, and label (label can be null). null when the event has no target. |
changes | array | For 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.
| Category | Event types |
|---|---|
access | session.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) |
subscription | subscription.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) |
settings | org.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:
| Field | Why it has no default |
|---|---|
name, url | Nothing to default to. |
method | GET would be a reasonable guess, but it's a guess this API declines to make on your behalf. |
environment | Your own dashboards filter on this. A defaulted "production" would be a mislabel that reads as fact. |
keywordCheckEnabled | Whether you want a body check at all is a decision, not a fallback. |
followRedirects, maxRedirects | These 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. AcceptingisPausedon 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 callPOST /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 /monitorsis 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 (Zor±HH:MM). A date-time with no offset is400rather 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-30does not become2026-03-02. - An inverted window (
fromafterto) is400, 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.
| Status | Meaning |
|---|---|
400 | Bad request - invalid query parameter or body |
401 | Missing, malformed, or revoked API key |
403 | Valid 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 |
404 | Resource not found (or not in your organization) |
409 | The 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 |
422 | An Idempotency-Key was reused with a request that doesn't match the original |
429 | Rate limit exceeded, or a per-resource brake, see Retry-After and Rate limiting |
503 | Writes are temporarily unavailable. Retry with backoff. Reads are unaffected |
5xx | Any 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.
| Limit | Applies to | Ceiling |
|---|---|---|
| Per key | Each API key | 600 requests per minute |
| Per client IP | The calling IP address, checked before the key is looked up | 1,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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The per-key ceiling, 600 |
X-RateLimit-Remaining | Requests 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, andRetry-Aftercan 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
GETendpoints) needsread; every other operation needswrite. - 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. Plainhttp://is refused. - Incident payloads carry the acknowledging and resolving member's name and email.
data.incident.acknowledgedByanddata.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
| Endpoint | What it does |
|---|---|
GET /api/v1/webhook-subscriptions | List. Every subscription the organization holds, suspended and disabled ones included. Not paginated. |
POST /api/v1/webhook-subscriptions | Create. 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-secret | Rotate secret. Mints a new signing secret. |
POST /api/v1/webhook-subscriptions/{id}/test | Send 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 sendnullon 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 sendnullon update) to receive every status change; an empty array is refused with400, the same asmonitorIds. The filter is valid only alongsidemonitor.status_changedinevents. Pingara checks this on create and on the merged row after aPATCH, and refuses with400in both directions, whether you send the filter without the event or, onPATCH, remove the event from a subscription that still carries the filter. OnPATCH,nullclears 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
| Status | Meaning |
|---|---|
400 | A 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 |
401 | Missing, malformed, or revoked API key |
403 | Valid key, but the caller's role isn't owner, admin, or editor |
404 | Subscription not found (or not in your organization), or a monitor ID in the filter doesn't belong to your organization |
409 | The 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) |
429 | More than 5 test sends in a minute on this subscription. Retry-After is 60. |
503 | Webhook 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
| Event | Fires when |
|---|---|
incident.created | A 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.degraded | A 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.resolved | An incident recovers (resolutionSource: "system") or is resolved by hand (resolutionSource: "user") |
monitor.paused | A monitor is paused |
monitor.resumed | A monitor is resumed |
monitor.status_changed | A monitor's status changes between up, degraded and down (never for unknown or pending) |
ssl.expiring | A 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.expiring | A 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.ping | Sent 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) andpendingnever 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, withfromset to whatever state was last published, never tounknownorpending.from: nullmeans 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 carrynull.fromis 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 bymonitorIdsat the time, one whosestatusChangedTofilter excluded the event that would have delivered it, one that was disabled or suspended, or one whose delivery ultimately failed all see afromthey never actually got. Under astatusChangedTofilter, afromyou never received is routine, not an edge case. It's the previous status the filter's owntovalues 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 whatmonitor.paused/monitor.resumedare for, and the two are independent. - Existing subscriptions are not opted into
monitor.status_changedautomatically.PATCHthe subscription and add it toeventsyourself. - Filter by monitor with
monitorIds, and by destination status withstatusChangedTo(see Create a subscription above). There's still no server-side filter onfrom; 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-Idtells you a delivery is a retry of one you've seen, but not where it sits relative to others.createdAtis 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'stoas the monitor's current status; on one filtered bystatusChangedTo, don't. Under["down"], the latest event you receive is alwaysdown, even after the monitor has recovered. ReadGET /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
milestoneis the lowest threshold crossed.daysUntilExpiryis the actual count and can be lower thanmilestone. - 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). Forssl.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 includeexpiresAt: 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 onPingara-Delivery-Id, as for every event. - For a monitor that follows redirects,
data.ssldescribes the certificate served at the end of the redirect chain. That certificate can be on a different host fromdata.monitor.url, and anhttp://monitor that redirects tohttps://can sendssl.expiringtoo. No field names the host that served the certificate, so don't parse one out ofdata.monitor.urlto 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.expiringmore than once for the old certificate's milestone. The key above collapses these. Checkdata.ssl.expiresAtbefore 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.expiringis 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.expiringevent for a certificate that has already expired. An expired certificate fails the TLS handshake, so the monitor goes down and you receiveincident.createdwithdata.incident.errorTypeset totls_errorinstead (subscribe toincident.createdto get it).tls_erroralso covers self-signed and hostname-mismatched certificates. A lapsed domain registration is different: it has no handshake to fail, sodomain.expiringis sent withmilestone: 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}" }
}
}
idis the delivery id, the same value as thePingara-Delivery-Idheader, and it's identical across every retry of one delivery and across every subscription one event fans out to. Deduplicate on it.data.incidentis present only onincident.*events, and is the same objectGET /api/v1/incidents/{id}returns.data.transitionis present only onmonitor.status_changed, and is always{ from, to }.tois one ofup/degraded/down;fromis the same set, ornull(see the event vocabulary above for whatnullmeans).data.sslis present only onssl.expiring, and is always{ daysUntilExpiry, expiresAt, milestone }, numbers only, noissueorrecommendationprose. This is not the same shape as the alert-channel webhook'sssl.expiringpayload, which has noexpiresAt.data.domainis present only ondomain.expiring, and is always{ name, daysUntilExpiry, expiresAt, milestone }.milestone: 0means the domain registration has already lapsed. Same "not the alert-channel shape" caveat asdata.sslabove.- Neither expiry event carries
data.incident. The two expiry events are notices, not incidents, and never include one. expiresAton bothdata.sslanddata.domainis 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.parsegives 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-Idis 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.*andmonitor.status_changedevents (a manual resolve or reopen is never suppressed). Forincident.*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.resolvedfor an incident id you never received anincident.createdfor. - An incident opened before a window and resolved inside it never gets an
incident.resolvedat all. Reconcile it withGET /api/v1/incidents/{id}once the window ends. monitor.status_changedcatches 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.expiringis 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.expiringis 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 opened inside a suppressing window and resolved after it produces an
- 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 deliverincident.resolvedfor a row that isn't resolved, orincident.created/incident.degradedfor 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 receiveincident.resolvedfor an incident id you never sawincident.created/incident.degradedfor, and the reverse. An incident created and resolved before the first event's delivery runs delivers onlyincident.resolved, for an id you never saw open.GET /api/v1/incidents/{id}stays authoritative; don't assume everyincident.resolvedwas 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 withPATCH .../{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.pausedevent. 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 firesmonitor.resumedregardless, and that event reaches nobody while the subscription is still suspended.monitor.status_changeddoesn'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'sfromcan 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 onemonitor.status_changed, and it reaches you only if the subscription is already active, the second reason to resume the subscription first. After re-enabling, readGET /api/v1/monitorsto reconcile current status.
Verifying deliveries
Every delivery, including webhook.ping test sends, carries these headers:
| Header | Value |
|---|---|
Pingara-Signature | t=<unix-seconds>,v1=<hex>[,v1=<hex>] |
Pingara-Event | the body's type, for routing before you parse JSON |
Pingara-Delivery-Id | the 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:
- Read the raw request body bytes, before parsing JSON.
- Split
Pingara-Signatureon,. Split each part at its first=, trimming ASCII whitespace from both sides. Skip a part with no=. - 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.) - There must be at least one
v1. Ignore any other key. That leaves room for us to add av2scheme later without breaking your verifier. - Reject if
tis more than 300 seconds from your own clock, in either direction. - Compute
expected = HMAC-SHA256(secret, t + "." + rawBody)as lowercase hex. - Accept if any
v1value is 64 lowercase hex characters and equalsexpected, compared in constant time, never with==or===. - Only now parse the JSON body and act on it. Deduplicate on its
idfield; 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
Webhook Integration
Configure custom webhook integrations to connect Pingara with any external system. Understand payload formats, event types, authentication, and testing strategies.
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.
Setting Up Alerts
Learn how to create alert policies, configure notification rules, and ensure your team is always informed when monitors detect issues.