Skip to content

The HTTP API

The versioned automation API, its envelope, and the OpenAPI document.

Two API surfaces

Pinggy has two HTTP APIs, and they are not the same surface. The browser API (/api/monitors and its siblings) is session-and-CSRF authenticated and is what the server-rendered screens themselves call. The versioned automation API, under /api/v1, is bearer-authenticated by a scoped automation credential and is what a script or a pipeline writes code against. The two share their underlying mutation logic and nothing else — different authentication, different envelope, different stability contract.

One envelope

Every /api/v1 response is one of two shapes, both carrying a request ID for correlation:

{"data": <resource or [resources]>, "meta": {"request_id": "...", "limit": 50, "next_cursor": "...", "has_more": true}}
{"error": {"code": "...", "message": "...", "fields": [...]}, "meta": {"request_id": "..."}}

meta.request_id and the X-Request-ID response header echo the request ID you sent when it is valid; they are non-authoritative compatibility metadata. Every response also carries X-Pinggy-Correlation-ID, the ID the server generated for the request. It is the ID in the server's access log and in durable audit records for that request, so quote it when asking an operator to trace a call. It covers the server's own log and audit records only, not a proxy, load balancer or APM tool in front of it.

A field that is meaningful only sometimes is always present and null when it does not apply, never omitted; a list is always present and [] when empty. Every instant is UTC RFC 3339, and an instant accepted without an explicit offset is refused rather than guessed at.

Status codes

The machine-readable code beside a status is the stable half of a failure; its message is for a human and may be reworded. 404 is identical for a resource that does not exist and one that belongs to another tenant — an absent identifier and someone else's identifier are indistinguishable, by design.

Resources

Every resource family is scoped: a read scope and, where the family allows mutation, a separate write scope. Some families are read-only by policy, not by omission — an incident exists because the control plane confirmed a failure from evidence, so there is nothing for a client to open or close by hand, and a status page's publication stays an act a person takes on a CSRF-protected screen. GET /api/v1 itself answers with the resource table, each family's sort and filter vocabulary, and the deployment's bounds, rendered from the same route table and the same specifications the API enforces.

Pagination

Lists are keyset-paginated: a page names the last row it saw rather than an offset, so a row inserted underneath an in-progress walk cannot make a client see one row twice or skip another. The cursor is opaque and bound to the resource, sort, direction, and filters it was issued for; presenting it to a different query is refused rather than resumed somewhere it does not mean the same thing.

Conditional writes and idempotency

Every update and delete requires an entity tag read from a prior response; a stale tag is refused as a version conflict rather than silently overwritten. Routes that create something accept an idempotency key, so a retried request after a dropped connection is answered from the original attempt's record rather than performed twice.

Bounds

Admission is bounded per automation credential — a request rate, a concurrency limit, and a per-request deadline — checked before the route itself is resolved, so probing what exists costs the same budget as using it.

The machine-readable document

The full contract — every resource, every field, every status code — is published live at /openapi.json, generated from the same route table and the same validation this API enforces, so the document cannot drift from the behavior it describes.

This article is part of this repository and is served by the same binary, so what you read here is the documentation for the version you are running.