Skip to main content
Version: v5.7.0

Inbound webhooks

Included from Basic tier

An inbound webhook connection is a registered, signed HTTP entry point: the door an external system — GitHub, Jira, a CI server, a monitoring stack — fires OpsChain triggers through. Each connection serves POST /api/hooks/<code>, unauthenticated by design except the HMAC signature, which is the authentication: a verified delivery lands as one event on the events stream, triggers match it from there, and every firing it causes executes as the connection's declared service account — an inbound call never carries ambient authority. The lane is base platform (no pack gates it); the admin surface is InboundWebhookService (see opschain docs api-v2), the CLI family is opschain admin inbound-webhooks (opschain docs cli/admin-inbound-webhooks), the GUI page is Administration → Inbound webhooks (list, create with the service-account picker and the secret revealed once, the receive URL with copy, secret rotation, delete, and a link to the connection's events), and the field-by-field resource shape is opschain docs resources/inbound_webhook_connection.

There is exactly one inbound lane. The GitHub and Jira integrations do not carry endpoints of their own — their provisioning verbs (opschain docs cli/admin-github-connections-provision-webhook, opschain docs cli/admin-jsm-connections-provision-webhook) register an ordinary connection on this lane (addressed github-<code> / jsm-<code>), which stays manageable here afterwards: rotate its secret, change its rate, disable it. See integrations-github and integrations-jsm for what each provisioner adds on top.

The connection, attribute by attribute​

AttributeMeaning
codeThe estate address: deliveries POST to /api/hooks/<code>. Immutable after create; the events it lands carry it as data.connection.
secretThe HMAC-SHA256 signing secret — write-only (at least 16 characters, encrypted at rest; no response, audit row or error ever echoes it — reads serve secret_set). Supplying a new one on update rotates it.
service_accountThe username (ordinarily a dedicated service account) every trigger firing this connection causes executes — and is authorisation-checked — as.
replay_window_secondsThe replay window (default 300, range 30–86400): a signed timestamp outside it refuses by name.
rate_per_minuteThe per-connection request budget: requests beyond it answer 429 before any verification or event is spent, so an unauthenticated flood cannot write events. 1–6000 is this connection's own budget; 0 inherits the estate default webhooks.default_rate_per_minute (opschain docs settings-reference), itself unset = 60; -1 is unlimited at this connection even when the estate default is bounded. Anything else is refused by name.
enabledWhether the endpoint accepts deliveries; a disabled connection refuses by name.
hook_pathThe served endpoint path, /api/hooks/<code>.
git_remoteThe Git remote every verified delivery hints (<project>/<name>): fetched now, with its ref moves recorded as info:git_remote:ref_moved for triggers — "A delivery as a fetch hint" below.

The security model​

The signing secret is the endpoint's whole authentication. The endpoint accepts two signature shapes, verified constant-time, with no raw signature material in responses, events or logs:

  • GitHub-style — X-Hub-Signature-256: sha256=<hex>, an HMAC-SHA256 over the raw request body. Jira Data Center's single-name X-Hub-Signature header is honoured when it carries the sha256= shape; a sha1= value never authenticates.
  • Generic — X-OpsChain-Signature: v1=<hex>, an HMAC-SHA256 over <timestamp>.<body> with the timestamp in X-OpsChain-Timestamp. This is the outbound notifier's own signing idiom (see notifications), so two OpsChain instances can feed each other. A generic-shape delivery must carry the timestamp, and any supplied timestamp must sit inside the connection's replay window.

On top of the signature:

  • Replay protection. Each verified delivery is claimed exactly once per connection on the digest of its verified signature — the one header a captured delivery cannot be re-sent with a fresh value of without the secret. A repeat answers 409 replayed_delivery (this delivery was already accepted) whatever delivery id it carries, so a captured GitHub-shape delivery replayed under a new X-GitHub-Delivery is refused like one replayed under the old id; a provider's own "redeliver" of the same bytes dedups the same way. The delivery id (X-GitHub-Delivery or X-OpsChain-Delivery, else the SHA-256 digest of the body) names the delivery in the event and the 202 body — it is unsigned and never the claim. Two genuinely distinct deliveries with byte-identical bodies on the timestamp-less GitHub shape therefore collide; the generic shape signs the timestamp, so they do not. The dedup ledger retains claims for 30 days — deliberately far beyond the largest replay window, so a delivery stays refused long after its window closed; past that retention, the residual replay of a captured delivery is closed by rotating the secret or by the signed-timestamp shape.
  • Rate limiting. A per-connection token bucket answers 429 before any verification work. A delivery naming a code no connection has is budgeted by the resolved client address instead — 10 per minute per address — checked before the connection is even looked up, so a flood of invented codes neither reads the store nor writes a refusal event beyond that budget (each admitted unknown code still lands its warn:webhooks:refused event). A code the server has served since it started is never gated by that address budget — a spent budget only skips the look-up for codes this process has not served yet, so a connection created moments ago delivers from that address once the budget refills (one request every six seconds). The client address is the one server.trusted_proxies resolves: behind a proxy you have not listed there, every caller shares the proxy's address and its budget. The bucket table is bounded and fails closed: at its hard cap a never-seen key answers 429 rather than being admitted untracked.
  • Payload bounds. The body is capped at 1 MiB and must be a JSON document.
  • Loud refusals. Every named refusal — missing or bad signature, stale timestamp, replayed delivery, unknown or disabled connection, missing secret, oversized or non-JSON payload — answers a typed 4xx and lands a warn:webhooks:refused event naming the reason. Nothing is silently dropped, and there is no 200-on-failure. (An unknown code's refusal event keeps the requested code out of the event path — attacker-supplied text belongs in the event data, not the path.)
  • Encryption keys required. A composition without the server's encryption keys refuses to store or use signing secrets by name.

Lifecycle​

Create registers the connection (code immutable, defaults applied); update changes any other field — a supplied secret rotates the stored one; delete removes it, after which the endpoint answers not-found from the next request, and triggers bound to its events settle failed by name if a delivery is still in flight. A duplicate code is refused by name. The verbs: opschain admin inbound-webhooks list | show | create | update | delete (opschain docs cli/admin-inbound-webhooks).

What a delivery becomes​

A verified delivery is accepted with 202 (echoing delivery_id and event_id) and lands as one external:webhook:received event on the platform path /platform/inbound-webhooks/<code>, opening its own event chain at depth 0. The event's data carries:

  • connection — the connection code;
  • event — the provider's envelope event type (X-GitHub-Event / X-OpsChain-Event), the natural trigger-condition filter value;
  • delivery_id — the delivery's id (the dedup claim is on the verified signature, not on this id);
  • payload — the raw JSON payload (the ${{ event.data.payload.* }} input idiom);
  • data.github — for deliveries carrying GitHub's envelope headers, a normalised typed field set (repository, ref, sha, action, conclusion…) for the common GitHub events;
  • data.jira — for Jira payloads (Jira names its event in the body's webhookEvent, not a header), the normalised issue field set (key, project, status, summary…).

A delivery as a fetch hint​

A delivery about a repository is a hint, never the fact: the platform does not believe the SHA a sender claims, it fetches and sees. A verified delivery to a connection whose git_remote names a remote schedules that remote's fetch now; a delivery carrying GitHub's X-GitHub-Event: push envelope schedules the fetch of every active remote on the repository the payload names (repository.clone_url / ssh_url, else full_name on github.com), whether or not the connection names one. Seconds later the fetch records info:git_remote:ref_moved for the watched refs that moved, and that event is what a git-driven trigger matches — with the observed SHAs, on any host (opschain docs triggers, "The git ref-move source"). Hinted fetches are rate-limited per remote (one in flight, hints during it coalesced into one follow-up, at least thirty seconds apart), so a misconfigured or hostile sender cannot fetch a remote more than a couple of times a minute; a hint inside the remote's fetch interval extends the open ref_moved event rather than opening another. The delivery's own external:webhook:received still lands as the delivery record, so triggers written against event.data.github.* keep working.

How triggers consume it​

Triggers bind with subject external, the external:webhook:received event name and conditions over the event document — event.data.connection eq <code>, event.data.event eq push, event.data.github.ref eq refs/heads/main. Every firing a delivery causes executes — and is authorisation-checked — as the connection's service_account, whoever owns the matching trigger; a connection whose service account no longer exists or is disabled fails the firing by name, telling you to repoint it. The full consumption story — the service-account rule, deferral under maintenance windows, chains — is opschain docs triggers and the hands-on runbook opschain learn 5.2.

Settings and configuration​

One settings-document key tunes this lane: webhooks.default_rate_per_minute (opschain docs settings-reference) is the estate default request budget every connection that leaves rate_per_minute at 0 meters at — unset it is the built-in 60, -1 lifts it for every inheriting connection, and a connection's own rate_per_minute (1–6000, or -1 for unlimited at that connection) always wins over it. The GUI edits the default on Administration → Settings → Global config under Rate limits, and the connection dialogs offer Inherit / Unlimited / Custom with the resolved default shown. The replay window stays a per-connection attribute, and the payload cap, dedup retention, the per-address budget for unknown codes and the secret minimum are built in (server.trusted_proxies decides which address that per-address budget keys on). No config.yaml key tunes this lane.

Governance and audit​

  • Every InboundWebhookService procedure — reads included — sits behind the admin-plane updatable determination (permission_denied on deny).
  • Every write is audited: api:inbound_webhooks:create, update (a rotation is flagged secret_rotated — the value never appears), destroy. Audit attributes carry connection facts only. The provisioning verbs add their own audit events on top (see the integration pages).
  • external:webhook:received and warn:webhooks:refused are ordinary events in the catalogue (opschain docs events), so you can subscribe to — or mandate — visibility of refused deliveries like any other signal.
  • No licence entitlement gates the lane itself; the GitHub and Jira provisioning verbs gate on their own integration entitlements (entitlements).
  • opschain docs cli/admin-inbound-webhooks — the CLI family, verb by verb.
  • opschain docs resources/inbound_webhook_connection — the resource's data dictionary.
  • opschain docs triggers — the engine that consumes the events.
  • integrations-github / integrations-jsm — the provisioners that ride this lane.
  • opschain docs api-v2 — InboundWebhookService on the v2 surface.