Interactive reference. All worker endpoints require an X-NSO-Signature HMAC-SHA256 header — see Settings → Webhooks for the secret.
Stored locally in your browser and attached to every Try it out request as the X-NSO-Signature header. Because the signature is computed over the exact request body, recompute it whenever you change the payload.
The /api/public/svc/dispatch route serves the cross-app service mesh. When a peer (such as hire) returns SPA HTML at that path, the probe reports HTTP 200 but no pong, because the route has not been deployed on their side. Verify a peer actually routes to the handler before re-running node scripts/crosspeer-probe.mjs. hire update: hire's dispatcher lives at the Supabase Edge Function https://yxjkfzrnhychklrezvyy.supabase.co/functions/v1/svc-dispatch. nso's SVC_PEER_URL_HIRE is set to that transport URL; nso's callPeer() appends the signed logical path /api/public/svc/dispatch and hire's function accepts the combined path. Signing is always over the logical path, never the transport URL.
The signature — canonical string, encoding, and example output
Every call to /api/public/svc/dispatch must carry x-svc-caller, x-svc-timestamp, and x-svc-signature. The signature is HMAC-SHA256 computed over a canonical string joined with \n (newline). This is the same algorithm as signCanonical in src/lib/svc-server.ts.
caller (as sent in x-svc-caller), timestamp (ISO8601 UTC, within 5 min), method (uppercased), path (the URL pathname, e.g. /api/public/svc/dispatch), and rawBody (the exact bytes of the request body).bodyHash = SHA-256(rawBody).hexdigest(). For POST /api/public/svc/dispatch with the ping body below this is f7e996b121a073c437a1d23543a940afa4e1478e3be8120aeb642fdda54853e0(hex, lowercase, 64 chars). An empty body hashes to the SHA-256 of the empty string.canonical = caller + "\n" + timestamp + "\n" + METHOD + "\n" + path + "\n" + bodyHash
signature = HMAC-SHA256(key = sharedSecret, message = canonical).hexdigest() — hex, lowercase, 64 chars, sent in x-svc-signature. The receiver verifies with a constant-time comparison.Worked example — inputs for a signed ping
caller = "hire"
timestamp = "2026-08-11T14:04:00.000Z" # ISO8601 UTC, within 5 min of now
method = "POST"
path = "/api/public/svc/dispatch"
rawBody = {"flow":"ping","payload":{"nonce":"probe-curl"}}
secret = $SVC_SECRET_FROM_HIRE
bodyHash = sha256(rawBody) -> f7e996b121a073c437a1d23543a940afa4e1478e3be8120aeb642fdda54853e0
canonical = "hire\n2026-08-11T14:04:00.000Z\nPOST\n/api/public/svc/dispatch\nf7e996b121a073c437a1d23543a940afa4e1478e3be8120aeb642fdda54853e0"
signature = hmac_sha256(secret, canonical).hexdigest()
# -> 3c5e0f2b9d1a4c8e7f6b0a3d2e1f4c5b9a8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e3f
# (example only — recompute per request)Because rawBody feeds the hash, any change to the body changes the signature — recompute on every request. The full flow is also implemented in scripts/verify-peer-dispatch.mjs.
Timestamp skew and clock drift — handling it
The x-svc-timestamp is part of the signed canonical string, so it must be generated fresh for each request and signed in the same step as the body — do not reuse a signature. nso accepts requests whose timestamp is within SVC_MAX_DRIFT_MS = 5 minutes of the receiver's clock (see src/lib/svc-shared.ts); anything older is rejected with 401 stale_timestamp and anything unparseable with 401 bad_timestamp.
Z suffix (e.g. 2026-08-11T14:04:00.000Z). Avoid local time and offsets like +02:00, which invite off-by-hour skew. In shell use date -u +%Y-%m-%dT%H:%M:%S.000Z.callPeer), regenerate the timestamp and the signature.Date.now() on a trusted time source rather than a cached local value.Date.now() before sending; if it differs by more than the 5-minute window, the request will fail regardless of the signature.In practice this is easy to get wrong in shell scripts that cache the output of date or reuse a signature across retries. The copy-paste examples above regenerate TS and the HMAC on every run — keep that pattern. A 401 bad_signature with a correct secret usually means the timestamp on your side is outside the window, not that the key is wrong.
Idempotency troubleshooting — how x-svc-request-id works, what 409 means, and when replayed: true occurs
Mutating flows (today: event_report) are keyed on (caller, x-svc-request-id) so a safe retry of /api/public/svc/dispatch never runs the side effect twice. The id is not part of the signed canonical string, so every retry still regenerates its timestamp and signature while the request id stays fixed. Read-only flows (ping, health_check) are never idempotency-gated.
What the header actually does
(caller, request id), runs the handler, records the response as completed.200 with "replayed": true. The side effect is not re-run; you get the original result back.409 because the key was already used for something else.409 request_in_progress. A claim abandoned by a crashed attempt expires after SVC_IDEMPOTENCY_STALE_MS = 60s.500 handler_failed and the claim is released, so the same id may be retried cleanly.Diagnosing 409
409 idempotency_key_reuse — the id was already used with a different payload. Fix: mint a new id for the new request. Never recycle an old id across logically different calls.409 request_in_progress — an earlier attempt with this id is still running (or its claim hasn't expired). Fix: wait and retry with the same id; once the first attempt completes you'll get the original response (or a replay) instead of a second side effect.When you see "replayed": true
x-svc-request-id, and only when that id was already completed. It signals: "the original call already succeeded; here is its result again — no side effect ran twice."replayed: true should not re-attempt the side effect — it already happened.Why omitting the header gives you no protection
With no x-svc-request-id, the dispatcher generates a per-attempt id for logging only, so a retry is treated as a brand-new call and the side effect can run again. Always send one for mutating flows, and keep it identical across every retry of the same logical request. nso's outbound callPeer() already does this: it mints one id per logical call and reuses it across its automatic 5xx/525 retries.
# Generate ONCE, reuse for every retry of this request
RID=$(uuidgen)
curl -sS -X POST "$PEER/api/public/svc/dispatch" \
-H "content-type: application/json" \
-H "x-svc-caller: nso" \
-H "x-svc-request-id: $RID" \
-H "x-svc-timestamp: $TS" \
-H "x-svc-signature: $SIG" \
-d "$BODY"
# retry with the SAME $RID and SAME $BODY
# -> {"...":"...","replayed":true}
# retry with the SAME $RID but different $BODY
# -> 409 idempotency_key_reusePOST to the dispatch route should return a 401 JSON error like {"error":"missing_signature"} — it must never return a browser page.ping and confirm a pong JSON body with "pong":true.text/html, the dispatch route is not deployed — ask the peer to deploy the route, then re-run the probe.Step 1 — confirm the route returns JSON, not HTML
Use the direct transport URL for hire; a missing-signature response proves the function is live. Other peers that use TanStack Start can be probed at {peerUrl}/api/public/svc/dispatch.
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
-X POST https://yxjkfzrnhychklrezvyy.supabase.co/functions/v1/svc-dispatch \
-H "Content-Type: application/json" \
-d '{"flow":"ping","payload":{"nonce":"probe"}}'Expect 401 application/json (no valid signature). Any text/html means the route isn't deployed.
Step 2 — signed ping returns a pong
# Copy-paste, replace the URL below. Export SVC_SECRET_TO_HIRE in your
# shell first (nso's own copy) so the HMAC is signed correctly.
# nso's callPeer() appends /api/public/svc/dispatch to this transport URL.
PEER_URL="https://yxjkfzrnhychklrezvyy.supabase.co/functions/v1/svc-dispatch"
SECRET="$SVC_SECRET_TO_HIRE"
TS="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" # ISO8601, must be within 5 min drift
BODY='{"flow":"ping","payload":{"nonce":"probe-curl"}}'
HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $2}')"
CANON="$(printf 'nso\n%s\nPOST\n/api/public/svc/dispatch\n%s' "$TS" "$HASH")"
SIG="$(printf '%s' "$CANON" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -sS -i \
-X POST "$PEER_URL/api/public/svc/dispatch" \
-H "Content-Type: application/json" \
-H "x-svc-caller: nso" \
-H "x-svc-timestamp: $TS" \
-H "x-svc-signature: $SIG" \
-d "$BODY"Expect a 200 application/json body containing "pong":true (plus self, flow:"ping", and your nonce echoed back, wrapped with a requestId):{"pong":true,"self":"hire",...,"nonce":"probe-curl"}. A 401 JSON error means the signature/timestamp/secret is wrong on your side; a text/html body means the route isn't deployed on hire's side. You can also run the full sweep:
node scripts/verify-peer-dispatch.mjs hire # single command, asserts pong:true
node scripts/crosspeer-probe.mjs # hire should show PASS ping
A successful ping pong returns {"pong":true,"self":...,"flow":"ping","nonce":"..."}. If the peer serves HTML at dispatch, the route is missing on their side and the probe will show HTTP 200 with the HTML as the body — that is not a live mesh leg.
HTTP 525 — Cloudflare/edge TLS handshake blip (transient, retry)
525 is a Cloudflare-origin TLS handshake failure — the edge and the origin (Worker) failed to complete a TLS handshake for that one request. It is not a signature problem and not a missing route: the request never reached the dispatch handler. It is typically transient (a momentary edge hiccup) and clears on retry, so it needs no secret rotation and no redeploy.
Retry the request (or re-run the probe); a signed retry gets a fresh timestamp and signature. The cross-peer client already retries automatically on 5xx and Cloudflare-specific codes such as 520–527. If a peer repeatedly reports 525 from nso's edge, keep retrying — a persistent 525 across many attempts usually indicates an edge issue on the origin side (check again later) rather than anything about the mesh secrets or routes.
How to tell the three failures apart:
525 (transient edge blip) — no HTTP-level error JSON from the handler at all; the request never reached the app. Response is an edge error page/HTML. Fix: retry.401 bad_signature (secret mismatch) — the handler ran and rejected the HMAC. Response is application/json with bad_signature (or missing/invalid timestamp). Fix: re-paste byte-identical secrets on both sides and verify the timestamp is within 5 minutes.200 text/html (no JSON pong). Fix: ask the peer to deploy the dispatch route, then re-run the probe.# Retry a 525 (transient). Rerun the same signed ping; you should get a pong. # If you still see 525, check again later — do NOT change secrets or routes for a 525. node scripts/verify-peer-dispatch.mjs hire # retry-safe; asserts pong:true node scripts/crosspeer-probe.mjs # retry and re-report all peers
Copy-paste curl — interpret 525 vs bad_signature vs HTML
# 1) Unsigned probe — confirms the route returns JSON (not HTML).
# For hire, use the direct Supabase Edge Function URL:
# Expect: 401 application/json {"error":"missing_svc_headers"} -> route deployed
# Expect: 200 text/html (SPA page) -> route NOT deployed
curl -sS -o /dev/null -w "%{http_code} %{content_type}\n" \
-X POST https://yxjkfzrnhychklrezvyy.supabase.co/functions/v1/svc-dispatch \
-H "Content-Type: application/json" \
-d '{"flow":"ping","payload":{"nonce":"probe"}}'# 2) Signed ping — builds the HMAC inline. Source nso's own copy of the secret.
# nso appends /api/public/svc/dispatch to this transport URL.
PEER_URL="https://yxjkfzrnhychklrezvyy.supabase.co/functions/v1/svc-dispatch"
SECRET="$SVC_SECRET_TO_HIRE"
TS="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" # must be within 5 min drift
BODY='{"flow":"ping","payload":{"nonce":"probe-curl"}}'
HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $2}')"
CANON="$(printf 'nso\n%s\nPOST\n/api/public/svc/dispatch\n%s' "$TS" "$HASH")"
SIG="$(printf '%s' "$CANON" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -sS -i \
-X POST "$PEER_URL/api/public/svc/dispatch" \
-H "Content-Type: application/json" \
-H "x-svc-caller: nso" \
-H "x-svc-timestamp: $TS" \
-H "x-svc-signature: $SIG" \
-d "$BODY"
# Expect: 200 application/json {"pong":true,...} -> all good
# Expect: 401 application/json {"error":"bad_signature"} -> secret/timestamp mismatch
# Expect: 525 (edge TLS handshake failure) -> transient, just retry
# Expect: 200 text/html (SPA page) -> route NOT deployedThe platform cron endpoint that triggers scheduled scans is a separate public route and does not use the HMAC signature above. It must be called with Authorization: Bearer $CRON_SECRET or the x-cron-secret header. The secret is configured as the CRON_SECRET environment variable.
Production request
curl -X POST https://nso.jackiepoole.com/api/public/hooks/scan-schedules-tick \
-H "Authorization: Bearer ${CRON_SECRET}" \
-H "Content-Type: application/json"Local request
curl -X POST http://localhost:3000/api/public/hooks/scan-schedules-tick \
-H "Authorization: Bearer ${CRON_SECRET}" \
-H "Content-Type: application/json"A successful response returns {"ok":true,"dispatched":0,"results":[]}. Missing or invalid auth returns 401 {"error":"Unauthorized"}.