How to

Every service, end to end.

Copy-paste, all of it. The service is nothing but a set of HTTP endpoints, so the examples use curl because it reads well on a page. Reach for whatever client you already have.

Setup

Take a key. No form, no verification mail, nobody to ask:

curl -X POST https://pulse.nano-api.com/v1/signup \
  -H 'content-type: application/json' -d '{"email":"you@example.com"}'

The key is in the response and nowhere else, since only its hash is stored. Three signups per address per hour, in case you were wondering where the limit is. Then, in the shell you are about to work in:

export KEY=np_live_...              # the key you were sent
export PULSE=https://pulse.nano-api.com
export RELAY=https://relay.nano-api.com
export LOCK=https://lock.nano-api.com
export CONFIG=https://configmaps.nano-api.com
export COUNT=https://count.nano-api.com
export UNIQ=https://uniq.nano-api.com
auth=(-H "Authorization: Bearer $KEY" -H 'content-type: application/json')

Every hostname serves the same API, so these variables are interchangeable. They exist only so the URLs read correctly in your own scripts.

Pulse: get told when a job stops

The first ping creates the monitor. interval is how often you expect it and grace is how late it may be, in seconds, read only at creation.

curl -X POST "$PULSE/v1/ping/nightly-backup?interval=86400&grace=3600" "${auth[@]}"

In real life that line lives at the end of the job:

0 3 * * * /usr/local/bin/backup.sh && curl -fsS -X POST \
  -H "Authorization: Bearer $KEY" \
  https://pulse.nano-api.com/v1/ping/nightly-backup

The && is the trick: a failed script never pings, the deadline passes, you hear about it. Failure is the default.

A ping can carry up to 2 KB of context, which shows up in the monitor's history:

curl -X POST "$PULSE/v1/ping/nightly-backup" "${auth[@]}" \
  -d '{"rows_exported": 12043, "duration_s": 94}'

Send alerts somewhere. Any HTTPS webhook works; Slack needs no configuration:

curl -X PATCH "$PULSE/v1/monitors/nightly-backup" "${auth[@]}" \
  -d '{"alert_webhook_url": "https://hooks.slack.com/services/..."}'

Look at what happened: status, last ping, and the most recent pings and events:

curl -s "$PULSE/v1/monitors/nightly-backup" "${auth[@]}" | jq

If the job knows it failed, it can say so instead of waiting for the deadline. This marks the monitor down immediately:

curl -X POST "$PULSE/v1/ping/nightly-backup?status=fail" "${auth[@]}"

Going on holiday with the job disabled? Pause it so it does not page you:

curl -X PATCH "$PULSE/v1/monitors/nightly-backup" "${auth[@]}" -d '{"paused": true}'

Prove the alert works

Do this once, on setup day, not during a real outage. /v1/checks/run is the same sweep the cron runs, triggered by hand:

# 1. a monitor with the shortest allowed window
curl -X POST "$PULSE/v1/monitors" "${auth[@]}" \
  -d '{"slug":"alert-test","expected_interval_seconds":30,"grace_period_seconds":0,
       "alert_webhook_url":"https://hooks.slack.com/services/..."}'

# 2. one ping, so it has something to miss
curl -X POST "$PULSE/v1/ping/alert-test" "${auth[@]}"

# 3. let the window pass, then run the sweep yourself
sleep 35
curl -X POST "$PULSE/v1/checks/run" "${auth[@]}"      # -> 🔴 in Slack

# 4. ping again to recover, then clean up
curl -X POST "$PULSE/v1/ping/alert-test" "${auth[@]}"  # -> 🟢 in Slack
curl -X DELETE "$PULSE/v1/monitors/alert-test" "${auth[@]}"

notified: true in recent_events means the receiver answered 2xx. false means it never arrived.

Relay: run the job for you

A cron expression, a timezone and a request. The timezone is the point: 0 3 * * * in Europe/Oslo stays at 03:00 across daylight saving, where a UTC-only cron drifts.

curl -X POST "$RELAY/v1/schedules" "${auth[@]}" -d '{
  "slug": "nightly-report",
  "cron": "0 3 * * *",
  "timezone": "Europe/Oslo",
  "url": "https://api.example.com/jobs/nightly-report",
  "body": {"source": "relay"},
  "max_attempts": 3,
  "timeout_seconds": 30,
  "alert_webhook_url": "https://hooks.slack.com/services/..."
}'

Run it immediately to check the target is reachable. This does not move the schedule:

curl -X POST "$RELAY/v1/schedules/nightly-report/run" "${auth[@]}" | jq .run
{
  "outcome": "ok",
  "status_code": 200,
  "duration_ms": 412,
  "response_excerpt": "{\"ok\":true}",
  "error": null,
  "attempts": 1,
  "run_id": "752cb057-e1f9-4512-812d-8100ea1cee8d"
}

Every run is kept: outcome, status code, duration, how many attempts it took:

curl -s "$RELAY/v1/schedules/nightly-report" "${auth[@]}" | jq .recent_runs

Change it, pause it, remove it:

curl -X PATCH "$RELAY/v1/schedules/nightly-report" "${auth[@]}" -d '{"paused": true}'
curl -X PATCH "$RELAY/v1/schedules/nightly-report" "${auth[@]}" -d '{"cron": "*/15 * * * *"}'
curl -X DELETE "$RELAY/v1/schedules/nightly-report" "${auth[@]}"

Up to 3 attempts with 2s and 6s backoff. Alerts on transitions only. Delivery is at-least-once and every request carries X-NanoRelay-Run-Id, so make the endpoint idempotent.

Lock: run it only once

201 with a token, or 409 if somebody else holds it. curl -sf exits non-zero on 409 so the second instance stops, and trap releases however the script ends. Capture the response before parsing: piped into jq, the || would test jq's exit status, and a refused lock would become an empty token and a job that runs anyway.

RESPONSE=$(curl -sf -X POST "$LOCK/v1/locks/nightly-import?ttl=60&owner=$HOSTNAME" \
  "${auth[@]}") || { echo "someone else has it"; exit 0; }

TOKEN=$(jq -r .token <<< "$RESPONSE")
trap 'curl -sf -X DELETE "$LOCK/v1/locks/nightly-import?token=$TOKEN" "${auth[@]}" > /dev/null' EXIT

./import.sh

Longer than the lease? Renew while you work instead of asking for a huge TTL:

curl -X POST "$LOCK/v1/locks/nightly-import/renew?token=$TOKEN&ttl=60" "${auth[@]}"

See who holds what, without anyone's token being revealed:

curl -s "$LOCK/v1/locks" "${auth[@]}" | jq
# [{ "name": "nightly-import", "held": true, "owner": "pod-a", "fence": 8, "expires_at": "..." }]

Only the holder can release or renew; a wrong token gets 409. fence only ever increases, so hand it downstream to reject a stalled holder that wakes up late.

Config: change it without a deploy

A config is a small JSON document. Write the whole thing, or merge keys into it:

curl -X PUT "$CONFIG/v1/configs/checkout" "${auth[@]}" \
  -d '{"maintenance": false, "max_items": 50}'

Flip a switch mid-incident. If-Match makes it conditional, so two people reacting at once cannot silently overwrite each other:

curl -X PATCH "$CONFIG/v1/configs/checkout?note=incident-4711" "${auth[@]}" \
  -H 'if-match: "7"' -d '{"maintenance": true}'
# 412 if someone wrote first, with both version numbers in the body

Reading is cheap, and a single key is one call:

curl -s "$CONFIG/v1/configs/checkout" "${auth[@]}" -H 'if-none-match: "8"' -o /dev/null -w '%{http_code}\n'
# 304 while nothing changes, so poll as often as you like

curl -s "$CONFIG/v1/configs/checkout/keys/maintenance" "${auth[@]}"

Every write is kept, with the note you passed. Rollback writes forward:

curl -s "$CONFIG/v1/configs/checkout/revisions" "${auth[@]}" | jq '.revisions[] | {version, note}'
curl -X POST "$CONFIG/v1/configs/checkout/rollback" "${auth[@]}" -d '{"to": 7}'

Restoring v7 becomes v9, never v7 again. A client caching by version number can then trust that one number means one thing forever.

Count: keep a number, show it anywhere

Counters are created on first use, and increments are atomic:

curl -X POST "$COUNT/v1/counters/downloads?label=downloads" "${auth[@]}"
# {"counter":{"value":1,"badge_url":"$COUNT/b/pub_…svg", …}}

curl -X POST "$COUNT/v1/counters/downloads?by=5" "${auth[@]}"    # 6
curl -X PUT  "$COUNT/v1/counters/downloads?value=12300" "${auth[@]}"

There are two ways to read it back. The badge needs no key, so it works anywhere you cannot send a header: a README, a status page, a wiki.

![downloads]($COUNT/b/pub_….svg)

?label= and ?color= restyle it per request. Or take the number itself. The JSON endpoint is CORS-open, so a browser can read it directly:

curl -s "$COUNT/b/pub_….json"
# {"name":"downloads","value":12300}

Reads never increment, either way. A caching proxy in front of a hit counter would otherwise freeze the number or inflate it.

Uniq: have I seen this before?

You supply the key. It is a delivery id, an event id, an order number: something you already hold. Nothing is generated here; the product is the memory.

# the key is the delivery id you already have, not one we invent
curl -X POST "$UNIQ/v1/uniq/evt_1JK2nX8s?ttl=86400" "${auth[@]}"
# 201 {"key":"evt_1JK2nX8s","first_time":true,"hits":1, …}

curl -X POST "$UNIQ/v1/uniq/evt_1JK2nX8s?ttl=86400" "${auth[@]}"
# 200 {"key":"evt_1JK2nX8s","first_time":false,"hits":2, …}

201 the first time, 200 after, so the status code is the answer. The insert is the check, not a SELECT before one: forty simultaneous claims on a key produce exactly one first_time: true.

Claim, work, and hand it back if the work failed:

RESPONSE=$(curl -sf -X POST "$UNIQ/v1/uniq/$DELIVERY_ID" "${auth[@]}")

if [ "$(jq -r .first_time <<< "$RESPONSE")" = "true" ]; then
  ./process.sh "$DELIVERY_ID" || curl -sf -X DELETE "$UNIQ/v1/uniq/$DELIVERY_ID" "${auth[@]}"
fi

curl -s "$UNIQ/v1/uniq/$DELIVERY_ID" "${auth[@]}"   # look without claiming
curl -s "$UNIQ/v1/uniq" "${auth[@]}" | jq            # the 100 most recent

Keys expire after 24 hours by default and 30 days at most, so set the ttl above your upstream's retry horizon. Without the DELETE on failure, a crash between claiming and finishing means every retry is told it is late and the work never happens.

All of them together

Relay runs it, Lock runs it once, Pulse notices if it stops.

# 1. Relay calls your endpoint every hour.
curl -X POST "$RELAY/v1/schedules" "${auth[@]}" -d '{
  "slug": "hourly-sync",
  "cron": "0 * * * *",
  "url": "https://api.example.com/jobs/sync"
}'

# 2. Inside that endpoint: take the lock, or return early.
#    3. And ping Pulse at the end, so silence is still an alert
#       even if Relay itself stops calling.
curl -X POST "$PULSE/v1/ping/hourly-sync?interval=3600&grace=600" "${auth[@]}"

Keys and quota

The key is the account. No login, so key management is an API too:

curl -s "$PULSE/v1/keys" "${auth[@]}" | jq            # prefixes, scopes, last used
curl -X POST "$PULSE/v1/keys" "${auth[@]}" -d '{"name":"CI"}'  # new key, shown once
curl -X DELETE "$PULSE/v1/keys/key_abc123" \
  -H "Authorization: Bearer $NEW_KEY"                          # revoke the old one

Revoking the key you are using is refused, so nobody locks themselves out. New one first, then revoke the old with it.

Read-only keys

A key is write unless you say otherwise. Pass scope at creation to get one that can look but not touch:

curl -X POST "$PULSE/v1/keys" "${auth[@]}" \
  -d '{"name":"dashboard","scope":"read"}'

# that key can now GET, and nothing else
curl -s   "$PULSE/v1/monitors" -H "Authorization: Bearer $READ_KEY"   # 200
curl -X POST "$PULSE/v1/ping/x" -H "Authorization: Bearer $READ_KEY"  # 403

The scope is fixed once the key exists. To narrow a key you issue a new one and revoke the old, which is the same move as rotation. Every read here is a GET and every write is not, so that is the whole rule: a read key gets 403 on anything else, including POST /v1/keys. That last one is the point. A read key cannot mint itself a wider one.

Monitors and schedules share one quota. Check where you stand:

curl -s "$PULSE/v1/whoami" "${auth[@]}" | jq .usage
# { "monitors": 3, "schedules": 2, "used": 5, "limit": 5, "remaining": 0 }

At the limit, new monitors and schedules get quota_exceeded. Pings and runs for existing ones never do.