v1 · stable
Contingency API
Automate check-ins, read status, get told when something changes, and have a release call your own systems. Small on purpose: three authenticated endpoints, one public one, five webhook events.
https://contingency.ccJSON, UTF-8Authorization: Bearer cty_…RFC 3339, UTCTokens and webhooks are created in Developer settings. Questions or a missing feature? [email protected].
Authentication
Every /api/v1 request carries a bearer token. Tokens are shown once at creation, stored only as a hash, and carry one or both scopes:
| Scope | Allows | Use it for |
|---|---|---|
checkin | POST /api/v1/contingencies/{id}/checkin and nothing else | Crontabs, CI secrets, anything that lives on a machine. If it leaks, the holder can extend a deadline but can't read or change anything. |
read | Listing and reading your contingencies | Dashboards, status pages, monitoring. |
curl https://contingency.cc/api/v1/contingencies \
-H "Authorization: Bearer cty_3fK9…"
A checkin-only token calling a read endpoint gets 403, not 401: the token is valid, it just isn't allowed to do that.
Rate limits & errors
60 requests per minute per token, 120 per minute per IP address. The IP limit is checked before the token is looked up, so a stream of invalid tokens can't turn into free database lookups. Exceeding either returns 429.
Every response is JSON with Cache-Control: no-store. Errors are a single object:
{"error": "this token does not have the \"read\" scope"}
| Status | Meaning |
|---|---|
401 | Missing, malformed, unknown or revoked token. |
403 | Token is valid but lacks the scope this endpoint needs. |
404 | Not yours, doesn't exist, or deleted. Deliberately the same answer for all three. |
409 | The contingency isn't in a state that allows this action (for example, checking in on a draft). |
429 | Rate limit exceeded. Back off and retry. |
500 | Something on our side. Safe to retry a check-in: it's idempotent for your purposes. |
GET /api/v1/contingencies
Scope read. Returns every non-deleted contingency on the account, newest first.
Request
curl https://contingency.cc/api/v1/contingencies \
-H "Authorization: Bearer cty_3fK9…"
Response 200
[
{
"id": "01a08ac7-2482-7e9f-ace5-1b9c82bf7e94",
"name": "Personal contingency",
"state": "armed",
"last_checkin_at": "2026-09-10T10:05:02Z",
"next_deadline_at": "2026-10-10T10:05:02Z"
},
{
"id": "01a08b3e-91c0-7d4a-b8f1-0c2e6d7a5f33",
"name": "Server watchdog",
"state": "draft",
"last_checkin_at": null,
"next_deadline_at": null
}
]
The contingency object
| Field | Type | Notes |
|---|---|---|
id | UUID | Stable for the life of the contingency. |
name | string | Your label. Never exposed on the public status endpoint. |
state | string | One of the states below. |
last_checkin_at | timestamp or null | null until the first check-in. |
next_deadline_at | timestamp or null | null unless armed. |
States
A contingency moves through an explicit state machine. Nothing releases from any state other than release_pending, and nothing releases twice.
draftcreated, never armedinactivecancelled and available to re-armarmedwatching for check-insremindera reminder email has gone outoverduethe deadline passedgrace_periodstill cancellable by checking inrelease_pendinggrace ended; release delay runningreleasingnotifications and actions in flightreleaseddonecancelledstopped by the owner or via shared secretsuspended,failed,administrative_holdrare operator states
GET /api/v1/contingencies/{id}
Scope read. One contingency object, or 404.
Request
curl https://contingency.cc/api/v1/contingencies/01a08ac7-2482-7e9f-ace5-1b9c82bf7e94 \
-H "Authorization: Bearer cty_3fK9…"
Response 200
{
"id": "01a08ac7-2482-7e9f-ace5-1b9c82bf7e94",
"name": "Personal contingency",
"state": "armed",
"last_checkin_at": "2026-09-10T10:05:02Z",
"next_deadline_at": "2026-10-10T10:05:02Z"
}
POST /api/v1/contingencies/{id}/checkin
Scope checkin. Records a check-in, resets the deadline to a full interval, and returns the updated object. The check-in is audited with method api and the caller's IP. No request body.
Allowed from armed, reminder, overdue, grace_period and release_pending. Anything else is 409.
Request
curl -X POST \
https://contingency.cc/api/v1/contingencies/01a08ac7-2482-7e9f-ace5-1b9c82bf7e94/checkin \
-H "Authorization: Bearer cty_3fK9…"
Response 200
{
"id": "01a08ac7-2482-7e9f-ace5-1b9c82bf7e94",
"name": "Personal contingency",
"state": "armed",
"last_checkin_at": "2026-09-12T19:41:07Z",
"next_deadline_at": "2026-10-12T19:41:07Z"
}
Response 409 when not armed
{"error": "contingency: cannot checkin from state draft"}
Automated check-ins prove a script is running, not that you are. Use them for a contingency whose purpose is "release if this machine stops". For a personal plan, check in by hand.
GET /{id} no token
Public status, for monitoring scripts and status pages. Off by default; the owner turns it on per contingency from its detail page. When off, or for a deleted or nonexistent ID, the answer is 404 with no body difference, so this endpoint can't be used to discover which IDs exist.
Request
curl https://contingency.cc/01a08ac7-2482-7e9f-ace5-1b9c82bf7e94 \
-H "Accept: application/json"
Response 200
{
"id": "01a08ac7-2482-7e9f-ace5-1b9c82bf7e94",
"state": "armed",
"state_label": "Armed",
"last_checkin_at": "2026-09-12T19:41:07Z"
}
last_checkin_at appears only if the owner ticked "also include the last check-in time". The name, description, contacts and content are never exposed here.
Webhooks
A webhook endpoint is an https:// URL, optionally scoped to one contingency, subscribed to any of these events. Private and loopback addresses are refused at connect time, every attempt. Redirects are not followed.
| Event | Fires when | Payload fields |
|---|---|---|
contingency.armed | Armed from draft or inactive | contingency_id, next_deadline_at |
contingency.checkin | A check-in is recorded (web or API) | contingency_id, method (web or api), next_deadline_at |
contingency.cancelled | Cancelled by the owner or via the shared secret | contingency_id, from_state |
contingency.released | Release completed | contingency_id |
contingency.deleted | Deleted by the owner, from any state | contingency_id, from_state |
What a delivery looks like
POST /hooks/contingency HTTP/1.1
Host: example.com
Content-Type: application/json
X-Contingency-Event: contingency.checkin
X-Contingency-Delivery: 01a08b12-7c31-7b0e-9d55-4f2a1c9e8b60
X-Contingency-Signature: t=1757706067,v1=5f1d2c…e9a0
{"contingency_id":"01a08ac7-2482-7e9f-ace5-1b9c82bf7e94","method":"api","next_deadline_at":"2026-10-12T19:41:07Z"}
X-Contingency-Delivery is unique per delivery attempt series. Store it and ignore repeats: a slow 2xx from your side can look like a failure to us and be retried.
Retries
Any 2xx counts as delivered. Anything else, or a connection failure, is retried with exponential backoff (2, 4, 8 … minutes) up to eight attempts, roughly eight hours, then abandoned. Respond quickly and do the work afterwards.
Verifying signatures
Each delivery is signed with the whs_… secret shown once when you created the webhook. The signed string is the timestamp, a dot, and the raw request body, so a captured request can't be replayed later and the body can't be altered in transit.
- Read
tandv1fromX-Contingency-Signature. - Compute
HMAC-SHA256(secret, t + "." + body)over the raw bytes, before any JSON parsing. - Compare the hex digest to
v1in constant time. - Reject if
tis more than five minutes from now.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, now = Date.now() / 1000) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
if (Math.abs(now - Number(parts.t)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(parts.t + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
Python
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes, now: float | None = None) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
if abs((now or time.time()) - int(parts["t"])) > 300:
return False
msg = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Go
func verify(secret, header string, body []byte, now time.Time) bool {
var t int64
var v1 string
for _, kv := range strings.Split(header, ",") {
k, v, _ := strings.Cut(kv, "=")
switch k {
case "t":
t, _ = strconv.ParseInt(v, 10, 64)
case "v1":
v1 = v
}
}
if d := now.Unix() - t; d > 300 || d < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", t)
mac.Write(body)
want, _ := hex.DecodeString(v1)
return hmac.Equal(mac.Sum(nil), want)
}
Rotating a secret
There is no in-place rotation yet. Create a second webhook with the same URL and events, switch your receiver to the new secret, then delete the old one.
HTTP release actions
Besides notifying contacts, a contingency can make exactly one HTTP request when it releases: GET or POST, your https:// URL, your headers, your body (POST only, up to 64 KiB, Content-Type: application/json unless you set one). Configured per contingency from its detail page. A failed or non-2xx attempt is retried on later ticks, up to five times.
Every request also carries these headers, which can't be overridden:
User-Agent: Contingency/7dec424
X-Contingency-Id: 01a08ac7-2482-7e9f-ace5-1b9c82bf7e94
The Send a test request button sends the same request with one extra header, X-Contingency-Test: 1, so your endpoint can acknowledge a rehearsal without acting on it. Host, Content-Length, Transfer-Encoding, Connection, User-Agent and X-Contingency-* are reserved. Redirects are not followed.
Example receiver (Node.js)
app.post("/release", (req, res) => {
if (req.get("X-Contingency-Test") === "1") return res.sendStatus(204);
const id = req.get("X-Contingency-Id");
rotateCredentialsFor(id); // whatever "release" means for you
res.sendStatus(200);
});
SSH release actions
A contingency can also run a single command on a server you control. Contingency generates an Ed25519 keypair per contingency and shows you the public key; you install it with a command= restriction so it can only ever run that one thing, and pin the server's host key fingerprint from the detail page so a spoofed server is refused.
# ~/.ssh/authorized_keys on your server
command="/usr/local/bin/on-release",no-port-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3… contingency
The command runs once, after contact notifications, and its exit status is recorded in the release report.
Recipes
Check in from cron
For a contingency that should release if this machine stops. Create a checkin-only token first.
# crontab -e (every day at 08:00)
0 8 * * * curl -fsS -X POST -H "Authorization: Bearer $CONTINGENCY_TOKEN" \
https://contingency.cc/api/v1/contingencies/01a08ac7-2482-7e9f-ace5-1b9c82bf7e94/checkin >/dev/null
Check in from a systemd timer
# /etc/systemd/system/contingency-checkin.service
[Service]
Type=oneshot
EnvironmentFile=/etc/contingency-checkin.env
ExecStart=/usr/bin/curl -fsS -X POST -H "Authorization: Bearer ${CONTINGENCY_TOKEN}" \
https://contingency.cc/api/v1/contingencies/${CONTINGENCY_ID}/checkin
# /etc/systemd/system/contingency-checkin.timer
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
Check in from GitHub Actions
name: contingency check-in
on:
schedule: [{cron: "0 8 * * *"}]
jobs:
checkin:
runs-on: ubuntu-latest
steps:
- run: |
curl -fsS -X POST \
-H "Authorization: Bearer ${{ secrets.CONTINGENCY_TOKEN }}" \
https://contingency.cc/api/v1/contingencies/${{ vars.CONTINGENCY_ID }}/checkin
Alert if a deadline is close
#!/usr/bin/env bash
# Exit non-zero (and let your monitoring page you) if any contingency
# is due within 24 hours or is no longer simply "armed".
set -euo pipefail
curl -fsS -H "Authorization: Bearer $CONTINGENCY_READ_TOKEN" \
https://contingency.cc/api/v1/contingencies |
jq -e --arg soon "$(date -u -d '+24 hours' +%FT%TZ)" '
map(select(.state != "armed" or (.next_deadline_at != null and .next_deadline_at < $soon)))
| length == 0'
Versioning
The API is versioned in the path. Fields are only ever added to /api/v1 responses, never removed or renamed; new webhook events are opt-in, so an existing endpoint never receives an event type it didn't subscribe to. A breaking change would ship as /api/v2 alongside v1.
Something missing? [email protected].