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

# Webhooks

> Receive real-time push notifications when orders complete or fail

Webhooks deliver HTTP `POST` callbacks to your server when order processing completes or fails. Use webhooks instead of polling the [Get Order](/api-reference/orders/get-order) endpoint — your server gets notified the moment a result is ready.

## Setting up webhooks

<Steps>
  <Step title="Register a webhook endpoint">
    You can create a webhook endpoint via the API or the [BIZ MORI Dashboard](https://app.bizmori.com/webhooks). To create one via the API, use the [Create Webhook](/api-reference/webhooks/create-webhook) endpoint:

    ```bash theme={null}
    curl -X POST https://api.bizmori.com/api/v2/orders/webhooks \
      -H "Authorization: Bearer YOUR_API_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "My Webhook",
        "url": "https://your-server.com/webhook"
      }'
    ```

    **Response:**

    ```json theme={null}
    {
      "data": {
        "id": 1,
        "name": "My Webhook",
        "secret": "whsec_xxxxxxxxxxxxxxxxxx"
      }
    }
    ```

    <Warning>
      Save the `secret` immediately — **it is shown only once** and cannot be retrieved again. You need it to verify every incoming webhook signature.
    </Warning>
  </Step>

  <Step title="Implement your endpoint">
    Your webhook handler must:

    * Accept `POST` requests with a JSON body
    * Respond with a `2xx` status code within **5 seconds**
    * Verify the `X-MoriBiz-Signature` header before processing
  </Step>

  <Step title="Verify signatures">
    Every request includes an `X-MoriBiz-Signature` header. See [Signature Verification](#signature-verification) below for implementation in your language.
  </Step>
</Steps>

## Event types

| Event Type                         | Description                                 |
| ---------------------------------- | ------------------------------------------- |
| `order.antiAi.completed`           | Anti-AI processing completed successfully   |
| `order.antiAi.failed`              | Anti-AI processing failed                   |
| `order.watermarkEmbed.completed`   | Watermark embedding completed successfully  |
| `order.watermarkEmbed.failed`      | Watermark embedding failed                  |
| `order.watermarkExtract.completed` | Watermark extraction completed successfully |
| `order.watermarkExtract.failed`    | Watermark extraction failed                 |
| `order.aiDetection.completed`      | AI Detection completed successfully         |
| `order.aiDetection.failed`         | AI Detection failed                         |

## Webhook payload

All webhook payloads follow this structure:

```json theme={null}
{
  "eventId": "550e8400-e29b-41d4-a716-446655440000",
  "eventType": "order.antiAi.completed",
  "occurredAt": "2026-02-19T12:00:00.000Z",
  "data": {
    "orderId": "123456789",
    "orderName": "anti_ai_2026-02-19",
    "createdAt": "2026-02-19T11:50:00.000Z",
    "updatedAt": "2026-02-19T12:00:00.000Z",
    "completedAt": "2026-02-19T12:00:00.000Z",
    "status": "complete"
    // ...service-specific fields
  }
}
```

### Completed event — common fields

| Field         | Type   | Description                           |
| ------------- | ------ | ------------------------------------- |
| `orderId`     | string | Order ID                              |
| `orderName`   | string | Order name                            |
| `createdAt`   | string | Order creation time (ISO 8601)        |
| `updatedAt`   | string | Order last updated time (ISO 8601)    |
| `completedAt` | string | Processing completion time (ISO 8601) |
| `status`      | string | Always `complete`                     |

### Failed event — common fields

| Field          | Type   | Description                        |
| -------------- | ------ | ---------------------------------- |
| `orderId`      | string | Order ID                           |
| `orderName`    | string | Order name                         |
| `createdAt`    | string | Order creation time (ISO 8601)     |
| `updatedAt`    | string | Order last updated time (ISO 8601) |
| `failedAt`     | string | Processing failure time (ISO 8601) |
| `status`       | string | Always `failed`                    |
| `errorCode`    | string | Error code                         |
| `errorMessage` | string | Error message                      |

### Anti-AI / Watermark Embed — additional fields (completed)

| Field         | Type    | Description                                 |
| ------------- | ------- | ------------------------------------------- |
| `fileCount`   | integer | Number of processed files                   |
| `downloadUrl` | string  | Result file download URL (valid for 1 hour) |

### Watermark Extract — additional fields (completed)

The `status` field is always `complete`. Use `statusCode` to determine the detection result.

| Field                | Type    | Description                                                    |
| -------------------- | ------- | -------------------------------------------------------------- |
| `statusCode`         | string  | `detected` or `undetected`                                     |
| `watermarkFound`     | boolean | Whether a watermark was detected                               |
| `watermarkInfo.text` | string  | Detected watermark text (only when `watermarkFound` is `true`) |

### AI Detection — additional fields (completed)

| Field         | Type           | Description                                                                                  |
| ------------- | -------------- | -------------------------------------------------------------------------------------------- |
| `probability` | number         | Probability of being AI-generated (0.0–1.0)                                                  |
| `heatmapUrl`  | string \| null | AI detection heatmap image download URL (only when `generateHeatmap` option was used)        |
| `overlayUrl`  | string \| null | Heatmap overlay on original image download URL (only when `generateOverlay` option was used) |

## Signature verification

Verify webhook authenticity by checking the `X-MoriBiz-Signature` header. Always verify **before** processing the event.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verifyWebhookSignature(rawBody, signature, secret) {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(rawBody)          // use the raw request body string
      .digest('hex');

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }

  // Express handler
  app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const signature = req.headers['x-moribiz-signature'];

    if (!verifyWebhookSignature(req.body.toString(), signature, process.env.WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    const { eventType, data } = JSON.parse(req.body);
    console.log(`Received ${eventType} for order ${data.orderId}`);
    res.sendStatus(200);
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_webhook_signature(raw_body: str, signature: str, secret: str) -> bool:
      expected = hmac.new(
          secret.encode(),
          raw_body.encode(),
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(signature, expected)

  # Flask handler
  from flask import Flask, request, abort
  import json

  app = Flask(__name__)

  @app.route('/webhook', methods=['POST'])
  def webhook():
      signature = request.headers.get('X-MoriBiz-Signature')
      raw_body  = request.get_data(as_text=True)

      if not verify_webhook_signature(raw_body, signature, os.environ['WEBHOOK_SECRET']):
          abort(401)

      event = json.loads(raw_body)
      return '', 200
  ```

  ```php PHP theme={null}
  <?php
  function verifyWebhookSignature(string $rawBody, string $signature, string $secret): bool {
      $expected = hash_hmac('sha256', $rawBody, $secret);
      return hash_equals($expected, $signature);
  }

  $signature = $_SERVER['HTTP_X_MORIBIZ_SIGNATURE'] ?? '';
  $rawBody   = file_get_contents('php://input');

  if (!verifyWebhookSignature($rawBody, $signature, getenv('WEBHOOK_SECRET'))) {
      http_response_code(401);
      exit('Unauthorized');
  }

  $event = json_decode($rawBody, true);
  http_response_code(200);
  ```

  ```ruby Ruby theme={null}
  require 'openssl'
  require 'json'

  def verify_webhook_signature(raw_body, signature, secret)
    expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
    Rack::Utils.secure_compare(expected, signature)
  end

  # Sinatra / Rails
  post '/webhook' do
    raw_body  = request.body.read
    signature = request.env['HTTP_X_MORIBIZ_SIGNATURE']

    halt 401, 'Unauthorized' unless
      verify_webhook_signature(raw_body, signature, ENV['WEBHOOK_SECRET'])

    event = JSON.parse(raw_body)
    status 200
  end
  ```

  ```go Go theme={null}
  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"net/http"
  	"os"
  )

  func verifySignature(rawBody []byte, signature, secret string) bool {
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))
  	return hmac.Equal([]byte(expected), []byte(signature))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	body, _ := io.ReadAll(r.Body)
  	signature := r.Header.Get("X-MoriBiz-Signature")

  	if !verifySignature(body, signature, os.Getenv("WEBHOOK_SECRET")) {
  		http.Error(w, "Unauthorized", http.StatusUnauthorized)
  		return
  	}
  	w.WriteHeader(http.StatusOK)
  }
  ```

  ```java Java theme={null}
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.security.MessageDigest;
  import java.util.HexFormat;

  public static boolean verifySignature(String rawBody, String signature, String secret)
          throws Exception {
      Mac mac = Mac.getInstance("HmacSHA256");
      mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
      String expected = HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes()));
      return MessageDigest.isEqual(expected.getBytes(), signature.getBytes());
  }

  // Spring Boot handler
  @PostMapping("/webhook")
  public ResponseEntity<Void> handleWebhook(
          @RequestBody String rawBody,
          @RequestHeader("X-MoriBiz-Signature") String signature) throws Exception {

      if (!verifySignature(rawBody, signature, webhookSecret)) {
          return ResponseEntity.status(401).build();
      }
      // process event...
      return ResponseEntity.ok().build();
  }
  ```
</CodeGroup>

## Retry policy

BIZ MORI uses a multi-layered retry system to ensure reliable webhook delivery.

### Initial retry

If your endpoint doesn't return a `2xx` status code, BIZ MORI immediately retries:

| Attempt   | Delay     |
| --------- | --------- |
| 1st retry | 1 second  |
| 2nd retry | 2 seconds |
| 3rd retry | 4 seconds |

### Scheduler-based retry

If all initial retries fail, the event enters a scheduler-based retry queue with increasing intervals:

| Attempt   | Delay      |
| --------- | ---------- |
| 4th retry | 30 minutes |
| 5th retry | 2 hours    |
| 6th retry | 4 hours    |

After all scheduler-based retries are exhausted, the event is marked as `FAILED`. You can view failed events using the [List Webhook Events](/api-reference/webhooks/list-events) endpoint.

### Manual resend API

Regardless of the automatic retry process, you can manually resend webhooks at any time using the resend APIs:

* **Single event resend**: Retry a specific failed event using the [Retry Webhook Event](/api-reference/webhooks/retry-event) endpoint.
* **Bulk resend**: Retry all failed events within a date range (up to 7 days) using the [Retry Failed Webhook Events](/api-reference/webhooks/retry-failed-events) endpoint.

```bash theme={null}
# Retry a single event
curl -X POST https://api.bizmori.com/api/v2/orders/webhooks/{webhookId}/events/{eventId}/retry \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# Retry all failed events in a date range
curl -X POST https://api.bizmori.com/api/v2/orders/webhooks/{webhookId}/events/retry-failed \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fromDate": "2026-01-01",
    "toDate": "2026-01-07"
  }'
```

### Failure notification email

When initial webhook delivery fails, BIZ MORI sends a failure notification email to the account owner. This email is sent at most **once per day** to avoid excessive notifications.

## Managing webhooks

| Action               | Endpoint                                                                               |
| -------------------- | -------------------------------------------------------------------------------------- |
| List all webhooks    | [GET /webhooks](/api-reference/webhooks/list-webhooks)                                 |
| Create a webhook     | [POST /webhooks](/api-reference/webhooks/create-webhook)                               |
| Get webhook details  | [GET /webhooks/{id}](/api-reference/webhooks/get-webhook)                              |
| Update a webhook     | [PUT /webhooks/{id}](/api-reference/webhooks/update-webhook)                           |
| Delete a webhook     | [DELETE /webhooks/{id}](/api-reference/webhooks/delete-webhook)                        |
| View delivery events | [GET /webhooks/{id}/events](/api-reference/webhooks/list-events)                       |
| Retry a single event | [POST /webhooks/{id}/events/{eventId}/retry](/api-reference/webhooks/retry-event)      |
| Retry failed events  | [POST /webhooks/{id}/events/retry-failed](/api-reference/webhooks/retry-failed-events) |
