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
| Attribute | Meaning |
|---|---|
code | The estate address: deliveries POST to /api/hooks/<code>. Immutable after create; the events it lands carry it as data.connection. |
secret | The 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_account | The username (ordinarily a dedicated service account) every trigger firing this connection causes executes — and is authorisation-checked — as. |
replay_window_seconds | The replay window (default 300, range 30–86400): a signed timestamp outside it refuses by name. |
rate_per_minute | The 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. |
enabled | Whether the endpoint accepts deliveries; a disabled connection refuses by name. |
hook_path | The served endpoint path, /api/hooks/<code>. |
git_remote | The 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-nameX-Hub-Signatureheader is honoured when it carries thesha256=shape; asha1=value never authenticates. - Generic —
X-OpsChain-Signature: v1=<hex>, an HMAC-SHA256 over<timestamp>.<body>with the timestamp inX-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 newX-GitHub-Deliveryis 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-DeliveryorX-OpsChain-Delivery, else the SHA-256 digest of the body) names the delivery in the event and the202body — 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
429before 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 itswarn:webhooks:refusedevent). 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 oneserver.trusted_proxiesresolves: 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 answers429rather 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:refusedevent 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'swebhookEvent, 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
InboundWebhookServiceprocedure — reads included — sits behind the admin-plane updatable determination (permission_deniedon deny). - Every write is audited:
api:inbound_webhooks:create,update(a rotation is flaggedsecret_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:receivedandwarn:webhooks:refusedare 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).
Related pages
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—InboundWebhookServiceon the v2 surface.