> ## Documentation Index
> Fetch the complete documentation index at: https://docs.moderationapi.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive HTTP callbacks when moderation events happen — queue items resolved, authors blocked, custom actions performed, and more.

Webhooks let you react to moderation events in real time. Whenever an event you subscribe to fires, we'll `POST` a JSON payload to your URL. A single webhook can subscribe to **any combination of events** — you don't need a separate URL per event type.

Configure webhooks under [**Configure → Webhooks**](https://dash.moderationapi.com/project/latest/configure/webhooks) in the dashboard.

## How it works

1. Add a webhook in the dashboard, set its URL, and tick the events you care about.
2. Whenever any of those events fire, we POST the event to your URL.
3. Your endpoint returns a `2xx` within 5 seconds to acknowledge.
4. Anything else (timeout, `4xx`, `5xx`) triggers retries with exponential backoff — up to **5 attempts** total.

## Request headers

Every delivery includes:

| Header             | Description                                                                                         |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| `webhook-version`  | Payload envelope version. Currently always `v2`.                                                    |
| `webhook-event-id` | Stable event ID (matches `id` in the body). Use this to dedupe retries.                             |
| `modapi-signature` | HMAC-SHA256 signature of the raw request body. See [Verifying signatures](#verifying-signatures).   |
| `User-Agent`       | `ModAPI/1.0`. Useful for allow-listing in security tools — see [Troubleshooting](#troubleshooting). |
| `Content-Type`     | `application/json`.                                                                                 |

## Payload envelope

Every event shares the same outer envelope. The event-specific data lives in `data.object`.

<ResponseField name="id" type="string" required>
  Stable event ID, prefixed with `evt_`. Identical across retries — use it to
  dedupe.
</ResponseField>

<ResponseField name="type" type="string" required>
  The event type, e.g. `queue_item.rejected` or `author.blocked`. See the [event
  catalog](#event-catalog).
</ResponseField>

<ResponseField name="api_version" type="string" required>
  Always `v2`.
</ResponseField>

<ResponseField name="created" type="string" required>
  ISO 8601 timestamp of when the event was emitted.
</ResponseField>

<ResponseField name="data" type="object" required>
  Wraps the event-specific payload.

  <Expandable title="properties">
    <ResponseField name="object" type="object" required>
      The resource that triggered the event. The shape depends on `type` — see
      each event below.
    </ResponseField>
  </Expandable>
</ResponseField>

```json Envelope shape theme={"theme":"nord"}
{
  "id": "evt_clxxx...",
  "type": "queue_item.rejected",
  "api_version": "v2",
  "created": "2026-05-08T12:34:56.789Z",
  "data": {
    "object": {
      /* event-specific resource */
    }
  }
}
```

<Tip>
  Need typed payloads? The full OpenAPI schema for every event lives under
  `components.schemas` in our [OpenAPI
  spec](https://app.stainless.com/api/spec/documented/moderation-api/openapi.documented.yml)
  — look for `WebhookEvent` (a discriminated union over `type`) and the
  per-event schemas like `QueueItemRejectedEvent`, `AuthorBlockedEvent`, etc.
</Tip>

## Event catalog

| Event                                                                              | Fires when                                                                                                         | `data.object`                                                        |
| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| [`queue_item.resolved`](/api-reference/webhooks/queue-item-resolved)               | A moderator marks a queue item as resolved (checked off the queue).                                                | The item, plus optional `author` and `queue` references.             |
| [`queue_item.action`](/api-reference/webhooks/queue-item-action)                   | A **custom** moderation action runs on a queue item.                                                               | The action that ran, with the related `item`, `author`, and `queue`. |
| [`queue_item.rejected`](/api-reference/webhooks/queue-item-rejected)               | The built-in **reject** action runs on a queue item.                                                               | Same shape as `queue_item.action`.                                   |
| [`queue_item.allowed`](/api-reference/webhooks/queue-item-allowed)                 | The built-in **allow** action runs on a queue item.                                                                | Same shape as `queue_item.action`.                                   |
| [`author.blocked`](/api-reference/webhooks/author-blocked)                         | An author transitions to the `blocked` status.                                                                     | The action that drove the transition, with the affected `author`.    |
| [`author.unblocked`](/api-reference/webhooks/author-unblocked)                     | An author transitions back to `enabled`. May fire automatically when a temporary suspension expires.               | Same shape as `author.blocked`.                                      |
| [`author.suspended`](/api-reference/webhooks/author-suspended)                     | An author is suspended for a finite period. The nested `author.block.until` indicates when the suspension lifts.   | Same shape as `author.blocked`.                                      |
| [`author.updated`](/api-reference/webhooks/author-updated)                         | Public author fields (name, email, profile picture, metadata, etc.) change. Status transitions don't trigger this. | The updated `author`.                                                |
| [`author.trust_level_changed`](/api-reference/webhooks/author-trust-level-changed) | An author's resolved trust level transitions to a new value. Doesn't fire for no-op recomputes.                    | The updated `author`.                                                |
| [`author.action`](/api-reference/webhooks/author-action)                           | A custom action runs against an author.                                                                            | Same shape as `author.blocked`.                                      |

<Note>
  **Reject and allow are first-class events.** If you want to capture every
  moderation action firing on a queue item, subscribe to `queue_item.action`
  **and** `queue_item.rejected` **and** `queue_item.allowed`. Built-in actions
  don't fire under `queue_item.action`.
</Note>

## Routing by content type

Queue-item events carry the item's `meta_type` — a high-level classifier you set when submitting content (or inherit from the channel). Use it to fan a single webhook out to the right handler per entity:

| `meta_type` | Typical use     |
| ----------- | --------------- |
| `profile`   | User profiles   |
| `message`   | DMs / chat      |
| `post`      | Long-form posts |
| `comment`   | Replies         |
| `event`     | Event listings  |
| `product`   | Marketplace     |
| `review`    | Ratings/reviews |
| `other`     | Anything else   |

```ts Webhook router theme={"theme":"nord"}
import type { WebhookEvent } from "@moderation-api/sdk";

const route = (event: WebhookEvent) => {
  switch (event.type) {
    case "queue_item.rejected":
      switch (event.data.object.item?.meta_type) {
        case "event":
          return rejectEvent(event);
        case "profile":
          return rejectProfile(event);
        case "review":
          return rejectReview(event);
      }
      break;

    case "queue_item.allowed":
      switch (event.data.object.item?.meta_type) {
        case "event":
          return allowEvent(event);
        case "profile":
          return allowProfile(event);
        case "review":
          return allowReview(event);
      }
      break;
  }
  return null;
};
```

<Tip>
  Set `meta_type` per submission via the `type` field on the [moderation
  endpoint](/api-reference/moderate/submit-content), or configure it as the
  default for a channel.
</Tip>

## Verifying signatures

Each delivery is signed with HMAC-SHA256 using your project's webhook secret. Find the secret under [API Keys in the dashboard](https://dash.moderationapi.com/project/latest/configure/api-keys).

To verify, compute `HMAC_SHA256(rawRequestBody, webhookSecret)` as a hex digest and compare it to the `modapi-signature` header. Always use a constant-time comparison.

<CodeGroup>
  ```js Node.js (SDK) theme={"theme":"nord"}
  import ModerationAPI from "@moderation-api/sdk";

  // Reads MODAPI_SECRET_KEY by default
  const client = new ModerationAPI();

  export async function POST(request) {
    const rawBody = await request.text();
    const signatureHeader = request.headers.get("modapi-signature") ?? "";

    // Verifies the signature with MODAPI_WEBHOOK_SECRET (or pass it explicitly
    // as a third arg). Throws if the signature is invalid.
    const event = client.webhooks.constructEvent(
      Buffer.from(rawBody),
      signatureHeader,
    );

    // `event` is typed as the WebhookEvent discriminated union — switching on
    // `event.type` narrows `event.data.object` to the right resource.
    switch (event.type) {
      case "queue_item.rejected":
        // event.data.object is the action_performed record
        break;
      case "author.blocked":
        // event.data.object.author is the blocked author
        break;
      // ...
    }

    return Response.json({ received: true });
  }
  ```

  ```js Node.js (manual) theme={"theme":"nord"}
  import crypto from "crypto";
  import { buffer } from "micro";

  export const config = { api: { bodyParser: false } };

  export default async function handler(req, res) {
    const rawBody = (await buffer(req)).toString("utf8");
    const signatureHeader = req.headers["modapi-signature"];

    if (!signatureHeader) {
      return res.status(400).json({ error: "Missing modapi-signature" });
    }

    const expected = crypto
      .createHmac("sha256", process.env.MODAPI_WEBHOOK_SECRET)
      .update(rawBody)
      .digest("hex");

    const sig = Buffer.from(signatureHeader, "utf8");
    const dig = Buffer.from(expected, "utf8");

    if (sig.length !== dig.length || !crypto.timingSafeEqual(sig, dig)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(rawBody);

    switch (event.type) {
      case "queue_item.rejected":
        // handle reject
        break;
      case "author.blocked":
        // handle block
        break;
      // ...
    }

    return res.json({ received: true });
  }
  ```

  ```python Python (Flask) theme={"theme":"nord"}
  import hmac
  import hashlib
  import os
  from flask import Flask, request, abort, jsonify

  app = Flask(__name__)
  SECRET = os.environ["MODAPI_WEBHOOK_SECRET"].encode()

  @app.post("/webhooks/moderation")
  def moderation_webhook():
      raw = request.get_data()
      signature = request.headers.get("modapi-signature", "")

      expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(signature, expected):
          abort(401)

      event = request.get_json()

      if event["type"] == "queue_item.rejected":
          ...  # handle reject
      elif event["type"] == "author.blocked":
          ...  # handle block

      return jsonify(received=True)
  ```
</CodeGroup>

### Preventing replay attacks

The envelope `created` timestamp lets you reject events older than your tolerance window (e.g. 5 minutes). Combine that with the stable `webhook-event-id` header to dedupe retries — store recently-seen IDs and ignore repeats.

## Retries and delivery

* **Success:** any `2xx` response within 5 seconds closes the delivery.
* **Failure:** any non-`2xx`, timeout, or network error triggers a retry.
* **Limits:** up to **5 attempts** total, with exponential backoff between tries.
* **Permanent failure:** after the final attempt fails, we'll email the project's admin.

You can inspect every delivery attempt — request headers, payload, response status, and response body — under [**Events log**](https://dash.moderationapi.com/moderation/events) in the dashboard.

## Troubleshooting

### Check the events log

If webhooks aren't behaving as expected, the [events log](https://dash.moderationapi.com/moderation/events) is the first place to look. It shows every delivery attempt with full request and response details.

### Allow Moderation API through your firewall

Some hosting providers and security services block webhook traffic by default. Cloudflare's Bot Fight Mode, for example, will challenge the request instead of letting it through.

<Tabs>
  <Tab title="Cloudflare">
    ### Common blocking scenarios

    * Bot Fight Mode / Super Bot Fight Mode is enabled
    * WAF Managed Rules active
    * Custom security rules

    ### Choose your solution based on your plan

    <Tabs>
      <Tab title="Super Bot Fight Mode (Pro+)">
        **✅ Use Security Rules (Recommended)**

        1. Go to your Cloudflare dashboard
        2. Select your domain
        3. Navigate to **Security → Security rules**
        4. Create a custom rule:
           * **Name**: "Moderation API Webhooks"
           * **Field**: User Agent
           * **Operator**: starts with
           * **Value**: `ModAPI/`
           * **Action**: Skip `All Super Bot Fight Mode Rules` and other rules that might interfere.
        5. Make sure to place this rule before other rules
        6. Deploy the rule
      </Tab>

      <Tab title="Bot Fight Mode (Free)">
        **❌ Security Rules won't work**

        Bot Fight Mode cannot be bypassed using Skip actions in WAF custom rules or Page Rules. Skip, Bypass, and Allow actions only apply to Ruleset Engine rules.

        **✅ Solutions:**

        * **Option 1**: Upgrade to a Pro plan to use Super Bot Fight Mode (which can be bypassed)
        * **Option 2**: Create a rule to allow Moderation API's IP address — see [Cloudflare's guide here](https://developers.cloudflare.com/waf/tools/ip-access-rules/create/), and reach out to support at [support@moderationapi.com](mailto:support@moderationapi.com) to get the IP address used for your account.
      </Tab>
    </Tabs>
  </Tab>

  <Tab title="Other providers">
    **Common providers with security blocking:**

    * SiteDistrict
    * DigitalOcean App Platform
    * Vercel
    * Netlify
    * AWS CloudFront
    * Azure Front Door

    **General configuration steps:**

    1. Access your provider's security/firewall settings
    2. Look for "allow rules", "exceptions", or "bypass rules"
    3. Add a User-Agent rule for `ModAPI/` (preferred)
    4. Or contact support to get IP addresses allowlisted for your account
    5. Save and deploy the configuration

    **Where to find settings:**

    * **Vercel**: Project settings → Security
    * **Netlify**: Site settings → Build & deploy → Post processing
    * **DigitalOcean**: App settings → Security section
    * **AWS CloudFront**: WAF & Shield → Web ACLs
    * **Azure Front Door**: Rules engine or WAF policies

    <Warning>
      Each provider uses different terminology. Look for "firewall rules", "security exceptions", or "allow rules" in your provider's documentation.
    </Warning>
  </Tab>
</Tabs>

### Still having issues?

1. Check your hosting provider's security logs for blocked `ModAPI/` requests.
2. Verify your endpoint returns a `2xx` status code within 5 seconds.
3. Test with a minimal endpoint (just respond `200 OK`) to isolate the issue.
4. Make sure firewall allow rules don't conflict with deny rules earlier in the chain.

Need a hand? Email [support@moderationapi.com](mailto:support@moderationapi.com) and we'll help you configure or allowlist for your account.
