--- title: "Webhooks" description: "Provision encrypted webhook subscriptions on a connection. Signature verification and ingest belong to the gateway." --- These endpoints configure *where* signed vendor evidence comes from and *which key* verifies it. They do not receive webhooks. Vendors post to the gateway, which verifies original bytes, timestamps, and active key versions against a closed signature registry before anything is admitted (ADR-0021, ADR-0041): ```text POST {gateway}/webhooks/{workspace_id}/{connection_id}/{subscription_id} ``` Control accepts only *already encrypted* signing material. It never holds a signing key in plaintext, never verifies a signature, and neither ingestion nor persistence changes a receipt or an effect conclusion. ## Authentication `Authorization: Bearer `, verified as [/api-reference/overview](/api-reference/overview) describes. Reading subscriptions needs `workspace.read`; writing and deleting need `connection.manage`. The write endpoints deliberately do not use the framework's own validation errors. FastAPI's `422` body echoes the rejected input, which would reflect encrypted material and URLs back to the caller, so the body is parsed only after authorization and every refusal collapses to one message: ```json { "detail": "webhook configuration is not allowed" } ``` ## List subscriptions `GET /api/v1/workspaces/{workspace_id}/connections/{connection_id}/webhooks` Connection to read. `404 connection not found` when it is not in this workspace. Public subscription projections. Encrypted credential material is never returned. `wsub_`-prefixed id. The exact registered HTTPS callback, or `null`. It is configuration, never derived from `Host` or forwarded headers. The bundle-declared shape: `signature_profile`, `header`, `timestamp_tolerance_s`, `event_types`, `provider_event_id`, and optional `upstream_id`. Metadata only — `{version, not_before, not_after}`. The ciphertext, wrapped DEK, and scheme are dropped on the way out. The same metadata for the outgoing key during rotation, or `null`. ```bash Request curl https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/connections/conn_9f2c1d/webhooks \ -H "Authorization: Bearer $OBOL_SESSION" ``` ```json Response { "subscriptions": [ { "subscription_id": "wsub_github_pushes", "notification_url": null, "spec": { "signature_profile": "github_sha256", "header": "X-Hub-Signature-256", "timestamp_tolerance_s": 300, "event_types": ["issues", "issue_comment", "pull_request", "repository"], "provider_event_id": { "source": "response_header", "header": "X-GitHub-Delivery" } }, "current_key": { "version": "2026-09", "not_before": "2026-09-01T00:00:00Z", "not_after": "2026-09-30T00:00:00Z" }, "previous_key": null } ] } ``` ## Create or replace a subscription `PUT /api/v1/workspaces/{workspace_id}/connections/{connection_id}/webhooks/{subscription_id}` The body is a `WebhookSubscription` document (`packages/proto/webhook_subscription.schema.json`), validated with unknown properties closed off. Must match the path segment and the `^wsub_` pattern, and must be 1–128 characters of `[A-Za-z0-9_.-]`. Must equal the expected spec exactly — the connector bundle's `webhook` block for a native connection, or the reviewed profile's `spec` for a federated one. `signature_profile` must exist in the reviewed registry (`packages/connectors/catalog/webhook-profiles.json`) and its `provider` must match the connector slug, or the broker for a federated connection. `timestamp_tolerance_s` must be greater than 0 and at most 900, and `event_types` must hold 1–32 entries matching `[A-Za-z0-9_.:/-]{1,128}`. `{version, credential, not_before, not_after}`. `version` is a 1–128 character safe label, the validity window is greater than zero and at most 30 days, and `credential` is an `EncryptedCredential` whose scheme is exactly `{"type": "bearer"}`, whose `last4` is at most 4 characters, and whose `ciphertext_b64` and `dek_wrapped_b64` decode to 28–16384 bytes. The outgoing key. Its version must differ from the current key, it must start before the current key does, it must not outlive it, and its overlap past the current key's start is capped at 24 hours. Required for the `square_sha256`, `hubspot_v3`, and `contentful_sha256` profiles, which sign the destination. Must be HTTPS, at most 2048 characters, with no userinfo, query, or fragment. ### Constraints - Bodies over 128 KiB are `413 webhook configuration is too large`. - A connection holds at most 16 subscriptions. - A native connection must be `obol` custody with an `open_api` target; a federated connection must carry a `nango` or `composio` broker and a well-formed upstream connection id. - Replacing an existing subscription cannot change its `notification_url`, redefine retained key versions or their encryption envelopes, move a validity start, or extend a validity end. Rotate with new secret material rather than re-encrypting the same plaintext. Answers `200` with the connection's full subscription list, in the same public projection the read returns. A `webhook.configure` audit event is recorded. ```bash Request curl -X PUT https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/connections/conn_9f2c1d/webhooks/wsub_github_pushes \ -H "Authorization: Bearer $OBOL_SESSION" \ -H "Content-Type: application/json" \ -d @subscription.json ``` ```json Response { "subscriptions": [ { "subscription_id": "wsub_github_pushes", "notification_url": null, "spec": { "...": "..." }, "current_key": { "version": "2026-09", "not_before": "2026-09-01T00:00:00Z", "not_after": "2026-09-30T00:00:00Z" }, "previous_key": null } ] } ``` Sealing happens outside this API. The gateway ships an `obol-gateway seal-webhook-key` command that seals bounded, non-interactive stdin under the gateway KEK without starting a service; its output is what goes in `credential`. A dashboard secret-entry flow and remote sealing are separate work (ADR-0041). ## Delete a subscription `DELETE /api/v1/workspaces/{workspace_id}/connections/{connection_id}/webhooks/{subscription_id}` Removes one subscription and answers `200` with the remaining list. Records a `webhook.delete` audit event. ## What ingest does with this The gateway loads the published subscription from the snapshot, rate-limits known-subscription traffic, caps bodies at 256 KiB, decrypts the signing key in-process, and verifies the signature before parsing. Verified events are appended to a workspace-qualified evidence stream with replay markers; invalid signatures and replay collisions produce bounded telemetry and never evidence or a contradiction. Keys and raw payloads never enter the evidence stream, a receipt, or control-plane memory. Because GitHub authenticates neither its event-family header nor a timestamp, the gateway derives supported families from the signed payload structure, rejects conflicting unsigned family headers, requires a UUID-shaped delivery id, and deduplicates the signed body digest as well as the provider id. That does not prove freshness of a previously unseen payload. See [/receipts/webhooks](/receipts/webhooks) for the evidence model and [/receipts/evidence](/receipts/evidence) for what each trust class means. ## Receipt delivery recovery Webhook evidence and receipts reach control over a durable at-least-once audit stream. ADR-0042 makes that recovery lossless: pending deliveries are reclaimed in bounded pages from a persisted scan position, each receipt is projected with a single upsert conditioned on a strictly greater revision, and receipt listings page on a versioned opaque cursor over `(occurred_at, receipt_id)` rather than a timestamp alone. There is no HTTP endpoint for that recovery — it is a worker. What you see of it is the `next_cursor` and `next_before` contract on [/api-reference/operator](/api-reference/operator#list-receipts) and [/api-reference/routing](/api-reference/routing#list-route-decisions).