> ## Documentation Index
> Fetch the complete documentation index at: https://docs.contentcare.ai/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](https://docs.contentcare.ai/api-reference/orders/get-order-v3.md) endpoint — your server gets notified the moment a result is ready.

## Setting up webhooks

**1. Register a webhook endpoint**

You can create a webhook endpoint via the API or the [ContentCare Dashboard](https://app.contentcare.ai/webhooks). The example uses a live API key and creates a live endpoint. To create one via the API, use the [Create Webhook](https://docs.contentcare.ai/api-reference/webhooks/create-webhook.md) 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.

**2. 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

**3. Verify signatures**

Every request includes an `X-MoriBiz-Signature` header. See [Signature Verification](#signature-verification) below for implementation in your language.

## Testing webhooks

To receive test-order events, register a separate test endpoint from the Dashboard, a live API key, or a test API key. A test key may omit `isTest` or set it to `true`; `isTest: false` returns `AUTH_FORBIDDEN`:

```bash theme={null}
curl -X POST https://api.bizmori.com/api/v2/orders/webhooks \
  -H "Authorization: Bearer $BIZMORI_TEST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Integration test webhook",
    "url": "https://example.com/test-webhook",
    "isTest": true
  }'
```

* A test API key can create, list, get, update, and delete test endpoints owned by the same owner, list their events, and retry failed events. Live-mode and other-owner resources remain hidden.
* An `isTest: true` endpoint receives test order events only.
* An `isTest: false` endpoint receives live order events only.
* Test webhook payloads use the same event names, HMAC-SHA256 signature, timeout, and retry behavior as live webhooks.
* Anti-AI and Watermark Embed test events use `downloadUrl: null` because test processing does not create a result file.

Test endpoint CRUD, event listing, and retry operations use the same owner and test-mode isolation described below.

## 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. See [Failed event error codes](#failed-event-error-codes) |
| `errorMessage` | string | Fixed guidance message for the error code (Korean). It never contains raw processor output |

### Failed event error codes

`errorCode` is always one of the public codes below. Internal processor codes and raw error output are never included.

| Service | `errorCode` | Meaning |
| - | - | - |
| Anti-AI | `PROCESSING_ERROR` | Processing failed |
| Watermark embed / extract | `WATERMARK_FILE_FORMAT_UNSUPPORTED` | Unsupported file format, including animated images and a file whose content does not match its type. Use a still JPEG, PNG, WEBP, BMP, or TIFF image, or a PDF |
| Watermark embed / extract | `WATERMARK_FILE_CORRUPTED` | The file is corrupted or cannot be read |
| Watermark embed / extract | `WATERMARK_FILE_SIZE_UNSUPPORTED` | The file is empty or exceeds the size limit |
| Watermark embed / extract | `WATERMARK_IMAGE_COLOR_MODE_UNSUPPORTED` | Unsupported color mode, such as palette, CMYK, 16-bit, or grayscale with transparency. Use an RGB, grayscale, or RGBA (PNG, WEBP) image |
| Watermark embed / extract | `WATERMARK_IMAGE_DIMENSIONS_UNSUPPORTED` | Unsupported image resolution or orientation |
| Watermark embed / extract | `WATERMARK_PDF_UNSUPPORTED` | The PDF cannot be processed (for example, it is encrypted, has too many pages, or is damaged) |
| Watermark embed / extract | `WATERMARK_INPUT_UNSUPPORTED` | Any other input the watermark engine cannot process, including a transparent-background image whose opaque area is missing, too small, or too sparse |
| Watermark embed / extract | `WATERMARK_ENGINE_UNAVAILABLE` | Temporary watermark engine error. Try again with the same file |
| Watermark embed / extract | `ORDER_TIMEOUT` | Processing timed out. Try again |
| Watermark embed / extract | `UPLOAD_EXPIRED` | The file was not uploaded before the upload window expired. Create a new order |
| Watermark embed / extract | `WATERMARK_V2_API_SUNSET` | A v2 order that had not started processing when v2 support ended (09:00 KST on October 9, 2026). It was not charged and its credits were restored. Create a new order with v3 |
| Watermark embed / extract | `PROCESSING_ERROR` | Any other processing failure |
| AI detection | `AI_DETECTION_INVALID_IMAGE` | The image cannot be read |
| AI detection | `AI_DETECTION_TIMEOUT` | Detection timed out. Try again |
| AI detection | `AI_DETECTION_SERVICE_ERROR` | Temporary detection service error. Try again |
| AI detection | `AI_DETECTION_FAILED` | Any other detection failure |

Test orders always use `PROCESSING_ERROR`. Handle unknown codes as a generic failure so that new codes do not break your integration.

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

| Field | Type | Description |
| - | - | - |
| `fileCount` | integer | Number of processed files |
| `downloadUrl` | string \| null | Result file download URL (valid for 7 days for live orders; `null` for test orders) |

### 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`. Orders from the new watermark engine can also return `inconclusive` (result could not be confirmed), `unsupported` (the file cannot be checked), or `error` (detection error) |
| `watermarkFound` | boolean | Whether a watermark was detected |
| `watermarkInfo.text` | string | Detected watermark text (only when `watermarkFound` is `true`) |

The payload exposes text only: it never includes MID, `midDecimal`, or `midHex`.

### 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) |

`heatmapUrl` and `overlayUrl` point to WebP images resized so that the longer side is at most 1600 px. If the resized images were not ready when the order completed, they point to the full-size PNG files instead. The webhook payload is fixed when the order completes, so the order details can return the resized images later.

## Signature verification

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

```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();
}
```

## 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](https://docs.contentcare.ai/api-reference/webhooks/list-events.md) 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](https://docs.contentcare.ai/api-reference/webhooks/retry-event.md) endpoint.
* **Bulk resend**: Retry all failed events within a date range (up to 7 days) using the [Retry Failed Webhook Events](https://docs.contentcare.ai/api-reference/webhooks/retry-failed-events.md) 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

## Test API key scope

A test API key can manage only test endpoints owned by the same owner. It can list, get, create, update, and delete those endpoints; list their events; retry one failed event; and retry failed events in a date range of at most seven days. Event responses do not add delivery-log response bodies.

* On create, omitting `isTest` or setting it to `true` creates a test endpoint. Setting `isTest: false` returns `AUTH_FORBIDDEN`.
* On update, omitting `isTest` keeps test mode and `true` is allowed. Setting `isTest: false` returns `AUTH_FORBIDDEN`.
* A live endpoint ID or another owner's endpoint ID returns `WEBHOOK_NOT_FOUND`; this does not disclose whether that endpoint exists.
* An event ID outside an accessible endpoint returns `WEBHOOK_EVENT_NOT_FOUND`.

Live API keys and the Dashboard keep their existing ability to choose `isTest` and manage the owner's live and test endpoints. Secret one-time display, URL validation, HMAC signatures, and the retry policy are unchanged.

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.