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

Receive and verify Mastodon admin webhooks on your own server: X-Hub-Signature HMAC-SHA256 validation with source-confirmed facts, the auto-generated signing secret, Docker setup, and RabbitMQ routing, every command verified locally.

By the end of this guide you will have a self-hosted endpoint that receives Mastodon admin webhooks, verifies the HMAC-SHA256 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 and delivery behavior come straight from Mastodon's source code, so nothing here is guessed.

How Mastodon webhooks work

Mastodon instances can notify external systems about moderation and account events through admin webhooks. The supported events, from the official docs: account.approved, account.created, account.updated, report.created, report.updated, status.created, status.updated. The default payload wraps the event name, a timestamp, and the serialized record:

{
  "event": "report.created",
  "created_at": "2026-08-29T21:00:00.000Z",
  "object": {
    "id": "87",
    "category": "spam",
    "comment": "Posting the same link repeatedly",
    "account": {"id": "110", "username": "moderator1"},
    "target_account": {"id": "245", "acct": "[email protected]"}
  }
}

An optional payload template with {{object.username}}-style interpolation can reshape the JSON per webhook.

Authentication: every delivery carries an X-Hub-Signature header, adopted from the WebSub spec. In Mastodon's delivery worker the value is built as exactly "sha256=#{OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), webhook.secret, body)}": HMAC-SHA256 of the raw body, hex-encoded, with a sha256= prefix. The signing secret is generated by Mastodon itself (random hex) when the webhook is created; you copy it out of the admin UI rather than choosing it, and it can be rotated there at any time.

Delivery behavior, also from the source: webhook jobs run through Sidekiq with up to 16 retries on failure (Sidekiq's exponential backoff), and local/private destination addresses are explicitly allowed, so a receiver on the instance's own network works without SSRF workarounds. When a template is set, the signature covers the templated body that is actually sent.

Prerequisites

Step 1: Configure the receiver

Create a working directory with two files.

webhooks.json:

{
    "mastodon_events": {
        "data_type": "json",
        "module": "log",
        "hmac": {
            "secret": "{$MASTODON_WEBHOOK_SECRET}",
            "header": "X-Hub-Signature",
            "algorithm": "sha256"
        }
    }
}

connections.json:

{}

What each parameter does:

  • mastodon_events is the webhook ID; it becomes the URL path (/webhook/mastodon_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-Hub-Signature header in constant time; the sha256= prefix Mastodon sends is handled automatically.
  • {$MASTODON_WEBHOOK_SECRET} pulls the secret from an environment variable. The value comes from Mastodon in Step 4; it never lives in a config file.

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 MASTODON_WEBHOOK_SECRET="paste-the-secret-from-mastodon" \
  spiderhash/webhook:latest

Confirm it is up:

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

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

Step 3: Test it locally

You do not need a Mastodon instance to test; you need a request signed the way Mastodon signs. Save the report.created payload from the top of this article as report_created.json, then compute the signature over the exact bytes of the file and send it with the sha256= prefix:

SIG=$(openssl dgst -sha256 -hmac "paste-the-secret-from-mastodon" -hex report_created.json | awk '{print $NF}')

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

As an instance admin: Preferences > Administration > Webhooks > Add endpoint:

  • Endpoint URL: https://your-domain/webhook/mastodon_events
  • Enabled events: tick what you need; report.created is the classic moderation-pipeline choice
  • Leave the template empty for the standard {event, created_at, object} payload

After saving, Mastodon shows the webhook's signing secret. Copy it, set it as MASTODON_WEBHOOK_SECRET on the gateway, and restart the container. To fire a real event, trigger one of the subscribed actions (for example, file a test report on a throwaway account, or approve a pending sign-up for account.approved); the delivery appears in docker logs webhook-gateway.

Troubleshooting

  • 401 {"detail":"Invalid HMAC signature"}: the env var does not match the webhook's signing secret; re-copy it from the admin UI, and after using "rotate secret" remember to update the gateway. If a payload template is configured, the signature covers the templated body, so this still verifies correctly; a mismatch means the secret, not the template.
  • 401 {"detail":"Missing X-Hub-Signature header"}: something in front of the gateway stripped the header; Mastodon always signs, since the secret is mandatory and auto-generated.
  • Duplicate deliveries: failed deliveries are retried up to 16 times with growing backoff. Respond quickly with 2xx and design the consumer to tolerate replays (the object.id plus event make a good idempotency key).
  • High-traffic instances and status.created: subscribing to status events on a busy instance produces a flood; that is a queue-worthy stream, not a log-worthy one (next section).

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

webhooks.json:

{
    "mastodon_events": {
        "data_type": "json",
        "module": "rabbitmq",
        "connection": "rabbitmq_local",
        "module-config": {
            "queue_name": "mastodon_events"
        },
        "hmac": {
            "secret": "{$MASTODON_WEBHOOK_SECRET}",
            "header": "X-Hub-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 moderation 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; with Mastodon retrying failures 16 times, a receiver that acknowledges in milliseconds keeps the retry queue empty. 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