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.

Base URLhttps://contingency.cc
FormatJSON, UTF-8
AuthAuthorization: Bearer cty_…
TimestampsRFC 3339, UTC

Tokens 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:

ScopeAllowsUse it for
checkinPOST /api/v1/contingencies/{id}/checkin and nothing elseCrontabs, CI secrets, anything that lives on a machine. If it leaks, the holder can extend a deadline but can't read or change anything.
readListing and reading your contingenciesDashboards, 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"}
StatusMeaning
401Missing, malformed, unknown or revoked token.
403Token is valid but lacks the scope this endpoint needs.
404Not yours, doesn't exist, or deleted. Deliberately the same answer for all three.
409The contingency isn't in a state that allows this action (for example, checking in on a draft).
429Rate limit exceeded. Back off and retry.
500Something 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

FieldTypeNotes
idUUIDStable for the life of the contingency.
namestringYour label. Never exposed on the public status endpoint.
statestringOne of the states below.
last_checkin_attimestamp or nullnull until the first check-in.
next_deadline_attimestamp or nullnull unless armed.

States

A contingency moves through an explicit state machine. Nothing releases from any state other than release_pending, and nothing releases twice.

  • draft created, never armed
  • inactive cancelled and available to re-arm
  • armed watching for check-ins
  • reminder a reminder email has gone out
  • overdue the deadline passed
  • grace_period still cancellable by checking in
  • release_pending grace ended; release delay running
  • releasing notifications and actions in flight
  • released done
  • cancelled stopped by the owner or via shared secret
  • suspended, failed, administrative_hold rare 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.

EventFires whenPayload fields
contingency.armedArmed from draft or inactivecontingency_id, next_deadline_at
contingency.checkinA check-in is recorded (web or API)contingency_id, method (web or api), next_deadline_at
contingency.cancelledCancelled by the owner or via the shared secretcontingency_id, from_state
contingency.releasedRelease completedcontingency_id
contingency.deletedDeleted by the owner, from any statecontingency_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.

  1. Read t and v1 from X-Contingency-Signature.
  2. Compute HMAC-SHA256(secret, t + "." + body) over the raw bytes, before any JSON parsing.
  3. Compare the hex digest to v1 in constant time.
  4. Reject if t is 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].