Skip to main content
Version: v5.6.1

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​

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 (default 60, range 1–6000): requests beyond it answer 429 before any verification or event is spent, so an unauthenticated flood cannot write events.
enabledWhether the endpoint accepts deliveries; a disabled connection refuses by name.
hook_pathThe 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-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. The delivery id (X-GitHub-Delivery or X-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 answers 409 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 429 before 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: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 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's webhookEvent, 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 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.