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:
- 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_ALLOWLISTenvironment variable." - 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
- Docker on any machine (your laptop is fine for the test run)
- A public HTTPS URL for production, which also keeps you clear of the SSRF allowlist. Two ways to get one:
- HTTPS for webhooks with nginx and Let's Encrypt if you have a public server
- Receiving webhooks through Cloudflare Tunnel if you do not want to open inbound ports
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_eventsis the webhook ID; it becomes the URL path (/webhook/openproject_events).module: logprints each verified payload to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.hmacwithalgorithm: sha1recomputes the HMAC-SHA1 of the raw body and compares it against theX-OP-Signatureheader in constant time; thesha1=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 andOPENPROJECT_WEBHOOK_SECRETdiffer. 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_ALLOWLISTon 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.