Skip to content

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.

$ curl -X POST localhost:8080/api/v1/webhooks \
    -H 'Content-Type: application/json' \
    -d '{"name":"ci-bot","url":"https://ci.example.com/hooks/skills-gateway",
         "events":["marketplace.snapshot.approved","marketplace.snapshot.rejected"]}'
{"id":1,"name":"ci-bot","url":"https://ci.example.com/hooks/skills-gateway",
 "events":["marketplace.snapshot.approved","marketplace.snapshot.rejected"],
 "secret":"whsec_...","createdAt":"..."}
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 hashlib, hmac

def verify(secret: str, body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header)
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 to marketplace.snapshot.approval_pending if 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.revoked and 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.

$ curl 'localhost:8080/api/v1/webhooks/deliveries?limit=20'

Most recent first. limit defaults to 100 and is clamped to 500.

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

$ curl -X DELETE localhost:8080/api/v1/webhooks/1

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.