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), 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 (default 60, range 1–6000): requests beyond it answer 429 before any verification or event is spent, so an unauthenticated flood cannot write events. |
enabled | Whether the endpoint accepts deliveries; a disabled connection refuses by name. |
hook_path | The served endpoint path, /api/hooks/<code>. |
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. The delivery id (
X-GitHub-DeliveryorX-OpsChain-Delivery, else the SHA-256 digest of the body — so an idless replay of the same bytes still dedups) is claimed exactly once per connection; a repeat answers409 replayed_delivery. The dedup ledger retains claims for 30 days — deliberately far beyond the largest replay window, so a delivery id stays refused long after its window closed. - Rate limiting. A per-connection token bucket answers
429before any verification work. - 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 dedup 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…).
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
No config.yaml key or settings-document entry tunes this lane: the
replay window and the rate limit are per-connection attributes, and the
payload cap, dedup retention and secret minimum are built in.
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.