How to Receive Webhooks from OpenProject (Self-Hosted, Step by Step)

Receive and verify OpenProject webhooks on your own server: X-OP-Signature HMAC-SHA1 validation with source-confirmed facts, the SSRF allowlist, Docker setup, and RabbitMQ routing, every command verified locally.

By the end of this guide you will have a self-hosted endpoint that receives OpenProject webhooks, verifies the HMAC signature on every delivery, and routes the events to a log, RabbitMQ, or any other destination. Every command below was run and verified against the real Core Webhook Module Docker image, and the signature scheme, SSRF policy, and retry behavior are taken straight from OpenProject's source code, so nothing here is guessed.

How OpenProject webhooks work

OpenProject sends webhooks for events like work package creation and updates, project changes, attachments, and time entries. Deliveries are JSON POSTs whose body wraps the event name and the resource in OpenProject's HAL representation:

{
  "action": "work_package:updated",
  "work_package": {
    "_type": "WorkPackage",
    "id": 42,
    "subject": "Fix rate limiter cleanup",
    "description": {"format": "markdown", "raw": "The cleanup job runs twice."},
    "_embedded": {
      "status": {"_type": "Status", "name": "In progress"},
      "type": {"_type": "Type", "name": "Bug"},
      "project": {"_type": "Project", "id": 3, "name": "API", "identifier": "api"},
      "assignee": {"_type": "User", "name": "John Smith"}
    },
    "updatedAt": "2026-08-29T20:40:00Z"
  }
}

Authentication: you set a secret on the webhook, and OpenProject signs every delivery. In its source the header is built as exactly "sha1=#{OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), secret, request_body)}", sent as X-OP-Signature. That is HMAC-SHA1 of the raw body, hex-encoded, with a sha1= prefix; if no secret is configured, the header is omitted entirely. Your receiver recomputes the digest over the exact bytes it received and compares in constant time.

Two operational facts, also from the source:

  1. SSRF protection: deliveries to private IP addresses are refused. The error log tells you the fix verbatim: "If this is intentional, add the IP to the allowlist via the OPENPROJECT_SSRF_PROTECTION_IP_ALLOWLIST environment variable."
  2. Retries only on timeouts: a timed-out delivery is retried by the background job system; any other failure (connection refused, 4xx/5xx response) is logged once and not retried. Every attempt is recorded in the webhook's delivery log in the admin UI with full request and response, which is your redelivery-debugging surface.

Prerequisites

Step 1: Configure the receiver

Create a working directory with two files.

webhooks.json:

{
    "openproject_events": {
        "data_type": "json",
        "module": "log",
        "hmac": {
            "secret": "{$OPENPROJECT_WEBHOOK_SECRET}",
            "header": "X-OP-Signature",
            "algorithm": "sha1"
        }
    }
}

connections.json:

{}

What each parameter does:

  • openproject_events is the webhook ID; it becomes the URL path (/webhook/openproject_events).
  • module: log prints each verified payload to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.
  • hmac with algorithm: sha1 recomputes the HMAC-SHA1 of the raw body and compares it against the X-OP-Signature header in constant time; the sha1= prefix OpenProject sends is handled automatically.
  • {$OPENPROJECT_WEBHOOK_SECRET} pulls the secret from an environment variable, so it never lives in a config file.

Generate a strong secret (or use our webhook secret generator):

openssl rand -hex 32

Step 2: Run it

docker run -d --name webhook-gateway -p 8000:8000 \
  -v "$PWD/webhooks.json:/app/webhooks.json:ro" \
  -v "$PWD/connections.json:/app/connections.json:ro" \
  -e OPENPROJECT_WEBHOOK_SECRET="your-generated-secret" \
  spiderhash/webhook:latest

Confirm it is up:

docker logs webhook-gateway
# ... INFO: Application startup complete.

The receiver now answers on http://localhost:8000/webhook/openproject_events, with auto-generated API docs at http://localhost:8000/docs.

Step 3: Test it locally

You do not need OpenProject to test; you need a request signed the way OpenProject signs. Save the work package payload from the top of this article as wp_updated.json, then compute the HMAC-SHA1 over the exact bytes of the file and send it with the sha1= prefix:

SIG=$(openssl dgst -sha1 -hmac "your-generated-secret" -hex wp_updated.json | awk '{print $NF}')

curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:8000/webhook/openproject_events \
  -H "Content-Type: application/json" \
  -H "X-OP-Signature: sha1=$SIG" \
  --data-binary @wp_updated.json

Expected result:

{"message":"200 OK"}
HTTP 200

Now prove the verification works. A tampered or wrong signature:

{"detail":"Invalid HMAC signature"}
HTTP 401

And a request with no signature header at all:

{"detail":"Missing X-OP-Signature header"}
HTTP 401

Those are the exact responses the gateway returns. docker logs webhook-gateway shows the accepted payload.

Step 4: Create the webhook in OpenProject

As an admin: Administration > API and webhooks > Webhooks > + Webhook:

  • Name: something like webhook-gateway
  • Payload URL: https://your-domain/webhook/openproject_events
  • Signature secret: the same value you passed as OPENPROJECT_WEBHOOK_SECRET
  • Enabled: checked
  • Events: tick what you need; work package created and updated are the usual core set
  • Projects: all projects, or a selection

Save, then update any work package. The delivery lands within seconds and appears both in docker logs webhook-gateway and in the webhook's delivery log inside OpenProject, which stores every request and response.

Troubleshooting

  • 401 {"detail":"Invalid HMAC signature"}: the Signature secret in OpenProject and OPENPROJECT_WEBHOOK_SECRET differ. They must be byte-identical.
  • 401 {"detail":"Missing X-OP-Signature header"}: the webhook has no Signature secret configured; OpenProject omits the header entirely in that case. Set the secret.
  • Delivery log shows an SSRF error for a private address: add the receiver's IP to OPENPROJECT_SSRF_PROTECTION_IP_ALLOWLIST on the OpenProject container, or give the receiver a public HTTPS URL.
  • A failed delivery never came back: only timeouts are retried. For anything else, fix the receiver and re-trigger the event; the delivery log tells you exactly what OpenProject sent and what it got back.
  • SHA1, not SHA256: OpenProject's scheme is HMAC-SHA1. Within an HMAC construction that remains fine for authentication purposes, but do not reuse this secret anywhere else.

Going to production

Printing to logs is not a pipeline. Swap the module for a real destination; here is the RabbitMQ variant, verified end to end (the signed test delivery above landed as a message in the openproject_events queue):

webhooks.json:

{
    "openproject_events": {
        "data_type": "json",
        "module": "rabbitmq",
        "connection": "rabbitmq_local",
        "module-config": {
            "queue_name": "openproject_events"
        },
        "hmac": {
            "secret": "{$OPENPROJECT_WEBHOOK_SECRET}",
            "header": "X-OP-Signature",
            "algorithm": "sha1"
        }
    }
}

connections.json:

{
    "rabbitmq_local": {
        "type": "rabbitmq",
        "host": "{$RABBITMQ_HOST:localhost}",
        "port": "{$RABBITMQ_PORT:5672}",
        "user": "{$RABBITMQ_USER:guest}",
        "pass": "{$RABBITMQ_PASS:guest}"
    }
}

The same pattern works for 17 other destinations: archive project events to S3, publish to Kafka, or fan out to several at once with webhook chaining. Built-in rate limiting and retry handling cover the operational edges; since OpenProject only retries timeouts, a receiver that acknowledges fast and queues durably is what keeps you from losing events. The full authentication reference, including HMAC validation and 11 other methods, is in the docs.

Subscribe to Free Webhook Tool

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe