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

Receive and verify Discourse forum webhooks on your own server: HMAC-SHA256 signature validation with the sha256= prefix, Docker setup, and routing to RabbitMQ, every command verified locally.

By the end of this guide you will have a self-hosted endpoint that receives Discourse webhooks, verifies the HMAC-SHA256 signature on every delivery, and routes the payloads to a log, RabbitMQ, or any other destination. Every command below was run and verified against the real Core Webhook Module Docker image before publishing; you can copy-paste them in order. The signature details come straight from Discourse's source code, so nothing here is guessed.

How Discourse webhooks work

When an event happens on your forum (a post is created, a user signs up, a topic is destroyed), Discourse sends an HTTP POST to your payload URL with these headers:

  • X-Discourse-Instance: the base URL of the sending forum
  • X-Discourse-Event-Id: a unique ID per event
  • X-Discourse-Event-Type: the event group (post, topic, user, ping, ...)
  • X-Discourse-Event: the specific event (post_created, topic_destroyed, user_approved, ...)
  • X-Discourse-Event-Signature: sent only when a secret is configured

Authentication: you set a secret in the webhook form, and Discourse signs the raw payload body with HMAC-SHA256. The signature header value is sha256= followed by the lowercase hex digest; in Discourse's own code it is literally "sha256=#{OpenSSL::HMAC.hexdigest("sha256", secret, body)}". Your receiver recomputes the digest over the exact bytes it received and compares in constant time. No secret means no signature header at all, so always set one.

Delivery behavior: if the retry web hook events site setting is enabled, a failed delivery is retried up to 4 times with a growing backoff (5^n minutes between attempts). Every delivery is recorded on the webhook's Events page with full request and response details, and you can redeliver individual events or all failed ones from there.

Prerequisites

Step 1: Configure the receiver

Create a working directory with two files.

webhooks.json:

{
    "discourse_events": {
        "data_type": "json",
        "module": "log",
        "hmac": {
            "secret": "{$DISCOURSE_WEBHOOK_SECRET}",
            "header": "X-Discourse-Event-Signature",
            "algorithm": "sha256"
        }
    }
}

connections.json:

{}

What each parameter does:

  • discourse_events is the webhook ID; it becomes the URL path (/webhook/discourse_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 recomputes the HMAC-SHA256 of the raw body and compares it against the X-Discourse-Event-Signature header in constant time. The sha256= prefix Discourse sends is handled automatically, and header lookup is case-insensitive.
  • {$DISCOURSE_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 DISCOURSE_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/discourse_events, with auto-generated API docs at http://localhost:8000/docs.

Step 3: Test it locally

You do not need a forum to test; you need a request signed the way Discourse signs. Save a realistic post_created payload:

cat > post_created.json <<'EOF'
{"post":{"id":42,"name":"John Smith","username":"john","created_at":"2026-08-29T10:15:00.000Z","cooked":"<p>The new release fixes the rate limiter cleanup.</p>","post_number":2,"post_type":1,"topic_id":128,"topic_slug":"release-notes-2-4","topic_title":"Release notes 2.4","category_id":5,"category_slug":"announcements","raw":"The new release fixes the rate limiter cleanup.","user_id":7,"topic_archetype":"regular"}}
EOF

Compute the signature over the exact bytes of the file and send it with the same sha256= prefix Discourse uses:

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

curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:8000/webhook/discourse_events \
  -H "Content-Type: application/json" \
  -H "X-Discourse-Event-Type: post" \
  -H "X-Discourse-Event: post_created" \
  -H "X-Discourse-Event-Signature: sha256=$SIG" \
  --data-binary @post_created.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-Discourse-Event-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 Discourse

In your forum's admin panel, search for webhooks in the sidebar (or open /admin/api/web_hooks), then press + Add Webhook:

  • Payload URL: https://your-domain/webhook/discourse_events
  • Content Type: application/json
  • Secret: the same value you passed as DISCOURSE_WEBHOOK_SECRET
  • Which events should trigger this webhook?: "Select individual events" (for example Post Event) or "Send me everything"
  • Optional filters: limit to specific categories, tags, or groups
  • Check TLS certificate of payload url: leave enabled
  • Active: check it

Save, then press the Ping button on the webhook. Discourse sends a ping event immediately; you should see it in docker logs webhook-gateway, and the webhook's Events page keeps the request and response of every delivery with a redeliver option for debugging.

Troubleshooting

  • 401 {"detail":"Invalid HMAC signature"}: the Secret field in Discourse and DISCOURSE_WEBHOOK_SECRET differ, or something between Discourse and the gateway modified the body. The signature covers the raw bytes; any proxy that re-encodes the payload breaks it.
  • 401 {"detail":"Missing X-Discourse-Event-Signature header"}: Discourse only sends the signature header when a Secret is configured on the webhook. Set the Secret, save, and ping again.
  • Deliveries marked Failed on the Events page: open the failed event to see the exact response body your endpoint returned. With the retry web hook events site setting enabled, Discourse retries up to 4 times with growing backoff; you can also redeliver manually at any time.
  • Using application/x-www-form-urlencoded: the signature then covers the form-encoded body, not the bare JSON. Stick with application/json unless you have a specific reason.

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 discourse_events queue):

webhooks.json:

{
    "discourse_events": {
        "data_type": "json",
        "module": "rabbitmq",
        "connection": "rabbitmq_local",
        "module-config": {
            "queue_name": "discourse_events"
        },
        "hmac": {
            "secret": "{$DISCOURSE_WEBHOOK_SECRET}",
            "header": "X-Discourse-Event-Signature",
            "algorithm": "sha256"
        }
    }
}

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 deliveries 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, and acknowledging fast before handing off to a durable queue keeps your webhook's Events page green. 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