How to Receive Webhooks from Paperless-ngx (Self-Hosted, Step by Step)
Receive paperless-ngx workflow webhooks on your own server: Jinja placeholders, secret header authentication, the PAPERLESS_URL tip, Docker setup, and RabbitMQ routing, verified against real document consumptions.
By the end of this guide you will have a self-hosted endpoint that receives webhooks from paperless-ngx workflows, authenticates every delivery with a secret header, and routes the document events to a log, RabbitMQ, or any other destination. Everything below was verified end to end against a real paperless-ngx instance and the real Core Webhook Module Docker image: actual document consumptions fired the deliveries quoted here.
How paperless-ngx sends webhooks
Paperless-ngx sends outbound webhooks through workflows: a trigger (Consumption Started, Document Added, Document Updated, or Scheduled) plus actions, one of which is Webhook. Per the official docs, the action takes the URL, the request body "as text or as key-value pairs, which can include placeholders", the body encoding ("either JSON or form data"), and request headers as key-value pairs. Body values are rendered with Jinja templates, so {{doc_title}} and {{doc_url}} become the real document title and URL at send time.
A real Document Added delivery from this guide's verification run:
{
"event": "document_added",
"title": "Invoice 2026-081",
"url": "https://paperless.example.com/documents/1/"
}
The request arrives with Content-Type: application/json and a python-httpx user agent. There is no payload signing; authentication is the headers you configure, and the receiver compares the secret in constant time.
Security posture, straight from the docs: webhook destinations can be restricted with PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES (default http,https), PAPERLESS_WEBHOOKS_ALLOWED_PORTS (default: all ports), and PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS (default true, so private-network receivers work out of the box; set it to false if non-admins can create workflows).
Prerequisites
- Docker on any machine (your laptop is fine for the test run)
- A public HTTPS URL for production, unless paperless-ngx and your receiver share a network. 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:
{
"paperless_events": {
"data_type": "json",
"module": "log",
"header_auth": {
"header_name": "X-Webhook-Token",
"api_key": "{$PAPERLESS_WEBHOOK_TOKEN}",
"case_sensitive": true
}
}
}
connections.json:
{}
What each parameter does:
paperless_eventsis the webhook ID; it becomes the URL path (/webhook/paperless_events).module: logprints each authenticated event to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.header_authrequires theX-Webhook-Tokenheader on every request and compares it in constant time.{$PAPERLESS_WEBHOOK_TOKEN}pulls the token from an environment variable, so it never lives in a config file.
Generate a strong token (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 PAPERLESS_WEBHOOK_TOKEN="your-generated-token" \
spiderhash/webhook:latest
Confirm it is up:
docker logs webhook-gateway
# ... INFO: Application startup complete.
The receiver now answers on http://localhost:8000/webhook/paperless_events, with auto-generated API docs at http://localhost:8000/docs.
Step 3: Test it locally
Simulate exactly what the workflow sends:
cat > doc_event.json <<'EOF'
{"event": "document_added", "title": "Invoice 2026-081", "url": "https://paperless.example.com/documents/1/"}
EOF
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:8000/webhook/paperless_events \
-H "Content-Type: application/json" \
-H "X-Webhook-Token: your-generated-token" \
--data-binary @doc_event.json
Expected result:
{"message":"200 OK"}
HTTP 200
Now prove the authentication works. A wrong token:
{"detail":"Invalid API key in header: X-Webhook-Token"}
HTTP 401
And a request with no token header at all:
{"detail":"Missing required header: X-Webhook-Token"}
HTTP 401
Those are the exact responses the gateway returns. docker logs webhook-gateway shows the accepted payload.
Step 4: Create the workflow in paperless-ngx
In paperless-ngx: Settings > Workflows > Add Workflow:
- Name it (for example
notify-gateway) and add a trigger: Document Added. - Add an action of type Webhook: - Webhook url:
https://your-domain/webhook/paperless_events- Use parameters: on, with key-value pairs such asevent=document_added,title={{doc_title}},url={{doc_url}}- Send as JSON: on (otherwise the pairs go out form-encoded) - Headers: addX-Webhook-Tokenwith your token as the value - Save and make sure the workflow is enabled.
The same workflow can be created via the API (POST /api/workflows/ with a trigger of type: 2 and an action of type: 4 carrying the webhook object), which is exactly how this guide's verification run did it.
Now add a document (drag one in, or POST /api/documents/post_document/). The webhook fires when consumption finishes; docker logs webhook-gateway shows the rendered payload.
One tip from the verification run: {{doc_url}} is built from the PAPERLESS_URL setting. If that is unset, the rendered URL starts with None/; set PAPERLESS_URL=https://your-paperless-domain on the paperless container to get correct links.
Troubleshooting
401 {"detail":"Invalid API key in header: X-Webhook-Token"}: the header value in the action andPAPERLESS_WEBHOOK_TOKENdiffer. They must be byte-identical.- Nothing fires on upload: the webhook fires after consumption completes, which takes a few seconds (longer with OCR on scanned files). Check Documents > the document's history, and confirm the workflow is enabled and its trigger is Document Added rather than Scheduled.
- The delivery is form-encoded instead of JSON: "Send as JSON" is off. The action defaults to form data for parameter pairs; turn the toggle on for
application/json. - Webhook blocked by policy: if your instance runs with
PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=falseor a restrictedPAPERLESS_WEBHOOKS_ALLOWED_PORTS, a private or nonstandard-port receiver is refused. Adjust those settings or give the receiver a public HTTPS URL on 443.
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 (a real document consumption landed as a message in the paperless_events queue):
webhooks.json:
{
"paperless_events": {
"data_type": "json",
"module": "rabbitmq",
"connection": "rabbitmq_local",
"module-config": {
"queue_name": "paperless_events"
},
"header_auth": {
"header_name": "X-Webhook-Token",
"api_key": "{$PAPERLESS_WEBHOOK_TOKEN}",
"case_sensitive": true
}
}
}
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 intake 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; a bulk import of a scanned archive is exactly when acknowledging fast and queueing durably pays off. The full authentication reference, including header auth and 11 other methods, is in the docs.