live · mainnetoc · docs
specs · api · guides
docs / webhooks

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_typemeaning + 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: id · project_id · delegation_id · agent_address · scope_exercised · content_hash · content_length · content_mime · signed_at

delegation.registered

A new delegation envelope (kind 30083) — single-address or federation — has been accepted by /api/delegations or /api/delegations/federation.

payload: id · project_id · principal_address · agent_address · scopes · issued_at · expires_at · status

revocation.registered

A revocation envelope (kind 30085) has been accepted by /api/revocations. The referenced delegation transitions status -> 'revoked'.

payload: id · project_id · delegation_id · signer_address · reason · signed_at

subdelegation.registered

A sub-delegation envelope (kind 30086, OC Agent v1.1) has been accepted by /api/subdelegations.

payload: id · project_id · parent_id · principal_address · agent_address · scopes · issued_at · expires_at

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:

HeaderValue
Content-Typeapplication/json
X-OrangeCheck-Eventdelegation.registered, action.registered, etc
X-OrangeCheck-Deliveryopaque per-attempt id
X-OrangeCheck-Idempotency-Keystable per-event-fanout id (use this for receiver-side dedup)
X-OrangeCheck-Payload-SHA256sha256 hex of the raw body bytes
X-OrangeCheck-Signaturesha256=<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.