Inbound webhooks

The sender contract for triggering Nistor from an external system with a signed POST to a webhook endpoint.

An external system triggers Nistor by sending POST /api/webhooks/{slug} with a JSON body. The endpoint (its slug and secret) is created in the Webhooks page or through the API; the secret is shown once.

Signing a delivery#

A generic endpoint authenticates a delivery with an HMAC of the request, sent as X-Webhook-Signature: sha256=<hex digest> (the endpoint's algorithm, sha256 by default). A delivery whose signature does not verify is answered 401.

Name one delivery with a stable id, the same on each retry, so a retry runs once: the first of X-Webhook-Delivery, X-GitHub-Delivery, X-Gitlab-Event-UUID or Idempotency-Key that the sender sets.

Signed timestamp#

A signature over the body alone verifies for ever: whoever captured one delivery (a proxy log, a leak on the sender's side) can send it again later. Add a timestamp to bound that.

Send the time of the delivery, in whole Unix seconds, as X-Webhook-Timestamp, and sign <timestamp>.<body> (the timestamp, a dot, then the exact body bytes) instead of the body:

TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -X POST "https://your-nistor/api/webhooks/my-ci-webhook" \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Timestamp: $TS" \
  -H "X-Webhook-Signature: sha256=$SIG" \
  -d "$BODY"

Nistor answers 401 with stale or missing timestamp when the timestamp is not whole seconds or is more than 300 seconds away from its own clock (either side), so keep the sender's clock synchronised. A delivery with a timestamp header that the signature does not cover is answered 401 Invalid signature.

A delivery without the header is still accepted with a body-only signature unless the operator sets WEBHOOK_REQUIRE_TIMESTAMP=true on the backend, which makes the timestamp mandatory for every generic endpoint (a delivery without it is answered 401). The setting is off by default in 1.x.

One endpoint can require it on its own: set require_timestamp to true when you create or update the endpoint (POST /api/v1/webhooks/ or PUT /api/v1/webhooks/{webhook_id}; it is false by default and is returned when you read the endpoint). A delivery without the header is then answered 401 stale or missing timestamp on that endpoint, whatever the deployment-wide setting says. Nistor refuses an unstamped delivery when either the setting or the endpoint's flag is on, so you can require the timestamp from the senders that already send it while the others move.

What this does not cover#

  • Inside the 300-second window a captured delivery can still be replayed; the delivery id above is what stops a retry from running twice.
  • Typed endpoints (GitLab merge requests with X-Gitlab-Token, GitHub) keep the authentication and the delivery ids of their provider; the timestamp does not apply to them, and neither does WEBHOOK_REQUIRE_TIMESTAMP.
  • The flag and the setting only require the timestamp; neither changes the 300-second window.
  • The delivery-id headers are not signed: a replayer can send a captured delivery under a new id.
  • A delivery replayed inside the 300-second window under a new delivery id runs again.
  • The HMAC input of an unsigned-timestamp body, <timestamp>.<body>, equals that of a stamped delivery of <body>. This is a known limit until the timestamp is mandatory in 2.0; the replayed body is not valid JSON, so the generic endpoint refuses it today.

Upgrading to 2.0#

In 2.0 the timestamp becomes mandatory for generic endpoints: a body-only signature will be refused. Senders should add the timestamp now; turn WEBHOOK_REQUIRE_TIMESTAMP on in a test environment first to find the ones that do not.

Edit this page on GitLab

↑ ↓ to moveEnter to openEsc to close