Receiving lifecycle webhooks¶
The gateway records every vetting decision in the ledger. Webhooks push those
same decisions outward, so CI, chat and inventory systems learn about a snapshot
the moment it is ingested, waiting for a review, approved or rejected instead of
polling /api/v1/marketplaces.
Events¶
Every event name says what it is about. marketplace.snapshot.* is what happened
to one snapshot; marketplace.* is what happened to the marketplace itself.
Snapshot events¶
| Event | Emitted when |
|---|---|
marketplace.snapshot.ingested |
An ingestion succeeded and produced a snapshot. |
marketplace.snapshot.approved |
A held snapshot was approved and published. |
marketplace.snapshot.rejected |
A held snapshot was rejected. |
marketplace.snapshot.soft_deleted |
A snapshot was marked deleted, by an administrator or by a retention policy. |
marketplace.snapshot.restored |
A soft-deleted snapshot's marks were cleared. |
marketplace.snapshot.vetted |
A vetting chain run finished. The verdicts are readable at GET /api/v1/snapshots/{id}/vetting. |
marketplace.snapshot.approval_pending |
A chain run finished and the snapshot is still held: it is waiting for a person. Carries a vetting summary — see Driving approvals from your own system. |
marketplace.snapshot.revet_violation |
A re-vetting run found a violation on a snapshot that is already approved. |
marketplace.snapshot.revoked |
A snapshot was retroactively quarantined; the facade no longer serves it. |
Deletion is orthogonal to the snapshot state machine, which is why the retention events carry the snapshot's unchanged vetting state.
Marketplace events¶
These are about the estate rather than about content: what exists, what triggers its ingestion, and which vetters run against it. They carry their own, shorter body.
| Event | Emitted when |
|---|---|
marketplace.registered |
A marketplace was registered, upstream or hosted. |
marketplace.updated |
A registered marketplace changed. Today that is its sync mode; the detail names the new one. |
marketplace.vetter_toggled |
A vetter was enabled or disabled, for one marketplace or across the gateway. |
marketplace.removed |
A marketplace was removed. The detail says how many approved snapshots it withdrew, never the reason; each withdrawal is also announced as marketplace.snapshot.revoked. |
Those are all of them.
An action taken by a scheduled pass rather than by a person carries a policy
actor — retention-policy for deletions, revet-policy for re-vetting — so a
receiver can tell the two apart. See
Reclaiming snapshot storage and
Re-vetting approved content.
marketplace.snapshot.revet_violation is the one that needs a receiver
It says content a team is already using has stopped being acceptable, and
in the default warn mode it is the only signal — nothing is unpublished, so
nothing breaks to announce it. Read the payload's state to tell the two
apart: approved means it is still being served and someone has to act;
revoked means enforcement already retracted it and
marketplace.snapshot.revoked follows.
Registering and deleting subscribers require admin; the subscriber and delivery listings require auditor (or admin). See Delegated administration.
1. Register a subscriber¶
Integrations → Webhooks → New subscriber → fill in Subscriber name and Target URL, tick the Events to receive (all of them by default; the box above the list narrows it) → Add subscriber. The signing secret appears in a dialog with a copy button.
| Field | Rule |
|---|---|
name |
^[a-z0-9][a-z0-9_-]*$, unique. A bad name is 422, a duplicate is 409. |
url |
The scheme must be on skills-gateway.allowed-url-schemes. Unparseable or scheme-less URLs are rejected — the check fails closed. 400. |
events |
An array of event names, or the single element * for every event. Omitted or empty means *. An unknown name is 400, not silently dropped. GET /api/v1/webhooks/events answers the names this gateway accepts. |
The filter is exact-match per name: * is the only wildcard, and neither
marketplace.* nor marketplace.snapshot.* is a valid filter. A subscriber only
ever receives events its filter lists.
Registering an outbound target is an egress decision, which is why it is an authenticated administrative act behind the same scheme allowlist as marketplace registration.
The signing secret is shown exactly once
whsec_… is returned only in the creation response and by no other endpoint.
Unlike a personal access token it is stored recoverably — signing needs the
key — but nothing ever reads it back over the API. A lost secret means
deleting the subscriber and registering it again.
2. Handle the delivery¶
Each delivery is a POST of a JSON body with four headers:
| Header | Contents |
|---|---|
X-Skills-Gateway-Event |
The event name. |
X-Skills-Gateway-Delivery |
The delivery id — your de-duplication key. |
X-Skills-Gateway-Timestamp |
ISO-8601 time of this attempt, so it differs between retries. |
X-Skills-Gateway-Signature |
sha256=<lowercase hex> — HMAC-SHA256 over the exact body bytes, keyed with the subscriber's secret. |
{"event":"marketplace.snapshot.approved","occurredAt":"2026-08-15T09:14:22.481Z",
"marketplace":"acme","snapshotId":42,
"sha":"3f9c2ab9d1e4c7b6a5f80c3d2e1b0a9f8c7d6e5f",
"state":"approved","actor":"alice@example.com"}
state is the snapshot state after the event, and actor is the principal
that performed the admin action.
What a marketplace event carries¶
A marketplace.* event has no snapshot to name, so its body is the four fields
every event shares plus one:
{"event":"marketplace.registered","occurredAt":"2026-08-15T09:14:22.481Z",
"marketplace":"acme","actor":"alice@example.com","detail":"origin=upstream"}
| Field | Meaning |
|---|---|
marketplace |
The marketplace the change is about, or - when the change applies to the whole gateway — a vetter disabled everywhere rather than for one marketplace. |
detail |
What changed, as key=value pairs: origin=upstream, mode=scheduled, vetter=secret-scan scope=global enabled=false. |
detail carries gateway-side configuration values only. It never carries
snapshot content, and it never carries operator-supplied free text — the reason
an administrator types when switching a vetter off stays in the ledger, where an
authenticated caller reads it. The event announces; the API discloses.
Not a webhook: fetches¶
There is no event for "someone fetched this marketplace", and there will not be one. Fetch volume is orders of magnitude above administrative change, the ledger already records every fetch with the identity behind it, and an audit sink already pushes those ledger entries to a receiver in signed, retried batches. Anything a per-fetch webhook could tell you is derivable from a sink today, over a delivery path built for that volume. Configure a sink instead.
The shape is published, and it only grows¶
Every delivery above is described in the gateway's own OpenAPI document, under
its top-level webhooks object — one entry per event, carrying the four headers
and the body schema, rendered alongside the REST surface in the
API reference. GET /api/v1/webhooks/events serves the
same vocabulary with a worked example of each body, so a receiver can be
generated or hand-written against the contract rather than against a sample
somebody pasted into a ticket.
Those descriptions are generated from the registry and the types the dispatcher actually sends, so they cannot drift from what arrives.
What you may rely on, and what you must not:
- Every field shown above is always present. The schema marks them required.
- Fields are only ever added, and an added field is added optional — a receiver written today keeps working, and a receiver cannot assume a field that only exists from some release onward. Parse permissively: ignore keys you do not recognise.
- Removing or renaming an event, a field or a header is a breaking change,
refused by the same gate that guards
/api/**unless it is declared. See Compatibility. - The signature scheme is not in the document. A schema can say the header exists and constrain its shape; how the HMAC is computed and compared is written out below, and that is where it stays.
3. Verify the signature¶
Compute the HMAC over the raw request body — the bytes as received, before any JSON parsing or re-serialization. The payload is serialized once when the event is emitted and stored, so every retry sends byte-identical content.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, body, header) {
const expected =
"sha256=" + createHmac("sha256", secret).update(body).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && timingSafeEqual(a, b);
}
Compare in constant time, and reject before parsing
Use hmac.compare_digest / timingSafeEqual rather than ==, and verify
before the body reaches any application logic. An unsigned or wrongly signed
request is an unauthenticated request.
Driving approvals from your own system¶
marketplace.snapshot.approval_pending exists so the review can happen where your
organization already does reviews — a ticketing system, a change-approval board,
a bot — instead of in the portal. It fires when a vetting chain run finishes and
the snapshot is still held, which is exactly the moment a person is needed, and
it carries enough to open a review item without a follow-up call:
{"event":"marketplace.snapshot.approval_pending","occurredAt":"2026-08-15T09:14:22.481Z",
"marketplace":"acme","snapshotId":42,
"sha":"3f9c2ab9d1e4c7b6a5f80c3d2e1b0a9f8c7d6e5f",
"state":"held","actor":"vetting",
"vetting":{"runId":17,"outcome":"BLOCKED","recordedOutcome":"BLOCKED",
"blockingVetters":["secret-scan"],
"uncoveredFindings":2,"waivedFindings":0}}
The first seven fields are the ones every event carries, unchanged. The
vetting object is this event's own:
| Field | Meaning |
|---|---|
runId |
The chain run being reported. Correlates with GET /api/v1/snapshots/{id}/vetting. |
outcome |
The effective outcome, the one that gates approval: CLEAR, CLEAR_WITH_WAIVERS or BLOCKED. |
recordedOutcome |
What the vetters concluded before any waiver was applied: CLEAR or BLOCKED. |
blockingVetters |
Names of the vetters that are the reason it blocks. Empty when nothing objects. |
uncoveredFindings |
How many blocking finding groups no active waiver covers — the reviewer's worklist size. A group is one rule on one line of identical content, however many copies of it the snapshot holds. |
waivedFindings |
How many findings an active waiver is currently suppressing. |
outcome is what tells your system what to offer. CLEAR means
POST /api/v1/snapshots/{id}/approve will succeed;
CLEAR_WITH_WAIVERS means it will, and only because someone accepted a risk;
BLOCKED means it will be refused until every uncovered finding is
waived or fixed upstream. Either decision goes
back through the ordinary API, which emits marketplace.snapshot.approved or
marketplace.snapshot.rejected in turn — so the round trip closes on the same webhook
stream your system is already reading.
The event announces; the API discloses
The payload carries counts, vetter names and identifiers — never a
finding's message, rule id or location, and never a file name from the
snapshot. A webhook target is authorized by a URL scheme allowlist, not by
an identity, and the point of quarantine is that unapproved content does not
leave it. Read the detail from
GET /api/v1/snapshots/{id}/vetting as an authenticated caller; the
snapshotId and runId in the payload are what address it.
Two things not to assume:
- It is not
marketplace.snapshot.vetted. That one fires for every chain run, including runs against content that is already approved, and says nothing about a pending decision. Subscribe tomarketplace.snapshot.approval_pendingif what you want is "someone has to look at this". - It says nothing about a revocation. A snapshot that re-vetting revoked is
decidable again, but it is announced by
marketplace.snapshot.revokedand means something else: content that was already in use has been retracted.
Delivery, retry and backoff¶
Emission is enqueue-only. The admin action writes one delivery row per matching subscriber and returns; an unreachable receiver can never fail or slow down an approval. A background dispatcher polls for due deliveries, claims each one with an atomic conditional update, and POSTs it.
sequenceDiagram
autonumber
actor Reviewer
participant API as AdminController
participant DB as webhook_deliveries
participant Disp as WebhookDispatcher
participant Rcv as Subscriber endpoint
Reviewer->>API: POST /api/v1/snapshots/42/approve
API->>DB: enqueue marketplace.snapshot.approved (state=pending)
API-->>Reviewer: 200 (never waits for the receiver)
loop every poll-interval (5s)
Disp->>DB: claim due pending deliveries
end
Disp->>Rcv: POST payload + X-Skills-Gateway-Signature
Rcv-->>Disp: 503
Disp->>DB: attempts=1, next_attempt_at = now + 10s (state=pending)
Disp->>Rcv: POST identical bytes, same delivery id
Rcv-->>Disp: 200
Disp->>DB: state=delivered, last_status=200
A 2xx marks the delivery delivered. Everything else — a non-2xx status or a
transport error — is retried, because a 4xx from a receiver is usually a
misconfiguration an operator will fix and the attempt budget bounds the cost.
Attempt n schedules the next attempt at base × 2^(n-1), capped:
| Attempt | Delay before it, with the defaults |
|---|---|
| 1 | immediate (within one poll interval) |
| 2 | 10s |
| 3 | 20s |
| 4 | 40s |
| 5 | 80s |
After the fifth attempt the delivery is failed and never retried. A delivery
whose subscriber has been deleted fails immediately with
subscriber no longer exists.
Tune all of this under
skills-gateway.webhooks.*.
Delivery is at-least-once
A receiver that returns 2xx after a network timeout will be retried, and a
process restart mid-attempt makes the delivery due again once its lease
expires. De-duplicate on X-Skills-Gateway-Delivery — it is stable
across every retry of the same delivery — and treat handlers as idempotent.
Order is not promised, and one case makes that concrete
Deliveries are claimed in batches and retried independently, so nothing
guarantees the order two events for the same snapshot arrive in. One
ingestion shows it plainly: the chain runs inside the ingestion, so
marketplace.snapshot.vetted and marketplace.snapshot.approval_pending are queued before
marketplace.snapshot.ingested for the same snapshot. Treat every event as
self-describing — the payload carries the marketplace, the snapshot, the SHA
and the state — rather than as a step in a sequence.
Watch what happened¶
The Webhooks page lists recent delivery attempts with their event, subscriber, state, attempt count and last response — see Admin portal.
When retention is enabled, delivered and failed deliveries older than 30 days are removed, so the listing covers the last 30 days.
Each row carries state, attempts, lastStatus and lastError, which is
enough to tell a receiver that is down from one that is rejecting the payload.
Remove a subscriber¶
204 on success, 404 if it never existed. The subscriber and its delivery
history go with it, and no further events are queued for it. Subscriber creation
and deletion are themselves recorded in the ledger as
webhook-subscriber-created and webhook-subscriber-deleted.
An audit export sink delivers
through a subscriber of its own. GET /api/v1/webhooks lists it with
auditSink set to the sink's name, and deleting it here is 409: the
answer names the sink and DELETE /api/v1/audit/sinks/{id}, which removes
both. A declared webhook in the estate whose
name is a sink's fails its entry for the same reason, rather than rewriting
the sink's channel.