Fleet webhooks
Subscribe an HTTPS endpoint at /settings § 04 · webhook endpoints and fleet
will POST a signed delivery on every event your endpoint subscribes to. Same
wire format as Stripe / GitHub / BTCPay — the
@orangecheck/webhook-verify
package is a drop-in receiver for any Node / Edge / Workers runtime.
Event catalog
The full set of subscribable events. Auto-generated at build time from
https://fleet.ochk.io/api/webhook-events
— the typed source of truth lives at src/lib/webhooks/events.ts in
orangecheck/oc-fleet-web, and a
CI contract test asserts every dispatchEvent() call uses an id from this
catalog.
| event_type | meaning + payload top-level fields |
|---|---|
action.registered | An action envelope (kind 30084) has been accepted by /api/actions. Fires once per ingested action; OTS confirmation is a separate phase 2b event. payload: |
delegation.registered | A new delegation envelope (kind 30083) — single-address or federation — has been accepted by /api/delegations or /api/delegations/federation. payload: |
revocation.registered | A revocation envelope (kind 30085) has been accepted by /api/revocations. The referenced delegation transitions status -> 'revoked'. payload: |
subdelegation.registered | A sub-delegation envelope (kind 30086, OC Agent v1.1) has been accepted by /api/subdelegations. payload: |
4 subscribable event types · auto-generated from /api/webhook-events at build time. Source of truth: src/lib/webhooks/events.ts in orangecheck/oc-fleet-web.
Subscribe to any subset when you create the endpoint. New event types will be added; the subscription field is open-string so customers don't need a re-register on each addition (your endpoint just receives nothing for events you didn't subscribe to).
Payload shape
{
"id": "0123…64-hex",
"project_id": "proj_…",
"kind": "agent-action",
"envelope": {
/* full canonical envelope JSON */
},
"delegation_id": "0123…64-hex"
}
envelope is the same byte-identical canonical envelope you'd get from
/api/audit/export or from a Nostr relay subscribed to the project's kind.
Verify offline with @orangecheck/agent-core — no
fleet-side trust.
Headers
Every delivery includes:
| Header | Value |
|---|---|
Content-Type | application/json |
X-OrangeCheck-Event | delegation.registered, action.registered, etc |
X-OrangeCheck-Delivery | opaque per-attempt id |
X-OrangeCheck-Idempotency-Key | stable per-event-fanout id (use this for receiver-side dedup) |
X-OrangeCheck-Payload-SHA256 | sha256 hex of the raw body bytes |
X-OrangeCheck-Signature | sha256=<HMAC-SHA256-hex of raw body> |
X-OrangeCheck-Attempt | (only on retries) attempt count |
X-OrangeCheck-Redelivery | (only on retries) "true" |
X-OrangeCheck-Manual-Retry | (only on operator-triggered retries) "true" |
Verifying the signature
npm install @orangecheck/webhook-verify
import { verify } from '@orangecheck/webhook-verify';
import express from 'express';
const app = express();
// CRITICAL: get the RAW body bytes, not the JSON-parsed object.
app.use(express.raw({ type: 'application/json' }));
app.post('/webhooks/orangecheck', (req, res) => {
const ok = verify({
secret: process.env.OC_WEBHOOK_SECRET!, // shown ONCE at create
signature: req.header('X-OrangeCheck-Signature') ?? '',
rawBody: req.body, // Buffer
});
if (!ok) return res.status(401).send('bad signature');
const event = JSON.parse(req.body.toString('utf8'));
// handle event.event_type / event.id / event.envelope
res.status(200).send('ok');
});
The verifier uses Node's timingSafeEqual so a malicious server can't
byte-by-byte probe the expected signature.
The signing secret
When you create an endpoint, fleet returns the signing secret once in the 201 response. Store it in your CI's secrets manager immediately; we never echo it again.
Internally, secrets are derived deterministically from a server-side master
key + the endpoint id
(HMAC-SHA256(WEBHOOK_MASTER_KEY, "oc-webhook-v1:" + endpoint_id)). The DB
stores only sha256(secret) for ad-hoc verification — a database leak alone is
insufficient to forge a delivery.
To rotate: delete the endpoint and re-register. The new secret is emitted at create.
Retry semantics
A delivery whose response is non-2xx (or times out after 8 seconds) is queued for retry with exponential backoff:
attempt 1 → 2: 1m
attempt 2 → 3: 5m
attempt 3 → 4: 30m
attempt 4 → 5: 2h
attempt 5 → 6: 8h
attempt 6+: give up
After give-up the delivery row stays in the log with
error_message: 'http_<status>' (or the connect-error text). You can trigger a
manual retry from the dashboard at any time via the retry now button — that
runs the same logic immediately.
The delivery log
/settings § 04 surfaces the last 25 deliveries inline under the endpoints
list, plus the row-level retry now button. Programmatic access:
GET /api/webhooks/deliveries?project_id=proj_…[&endpoint_id=…&limit=N]
Returns each attempt with status_code, attempt_count, next_retry_at, succeeded_at, and the per-event-fanout idempotency key.
Idempotency on your receiver
Use X-OrangeCheck-Idempotency-Key as the dedup key. The same logical
event-fanout (e.g. one accepted action triggers two subscribed endpoints each
retrying once) shares the idempotency key across attempts to the same endpoint,
but differs across endpoints. So your receiver should:
if (await alreadyProcessed(req.header('X-OrangeCheck-Idempotency-Key'))) {
return res.status(200).send('already processed');
}
A 200 with no body is the canonical "I got it, don't retry" response. Any non-2xx (or no response within 8 seconds) triggers retry.