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

# Webhooks

> Get a signed event when an async batch finishes, instead of polling.

Register an endpoint once, and we'll POST a signed `batch.update` event to it the moment a batch reaches a terminal state. This is the production alternative to polling `GET /v1/batches/{id}` in a loop.

The signature scheme is **wire-compatible with [Svix](https://www.svix.com/)**, so if you already verify Svix or Reducto webhooks, your verification code works here unchanged — you verify with the standard open-source `svix` library.

<Note>
  Prefer webhooks for production workflows. Polling remains available, but `GET /v1/batches/{id}` is limited to 200 requests per second per organization and may return `429 Too Many Requests` when that limit is exceeded.
</Note>

## Setup

1. In the [dashboard](https://hanji.dev/dashboard/webhooks), add an endpoint (an HTTPS URL). We show you a signing secret (`whsec_…`) once at creation — store it as a secret in your app. You can re-reveal or rotate it anytime from the dashboard.
2. On any batch you want notified, pass `webhook: { "mode": "svix" }`:

```bash theme={null}
curl -X POST https://api.hanji.dev/v1/batches \
  -H "X-API-KEY: $HANJI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "type": "files", "file_ids": ["file_abc"] },
    "metadata": { "prior_auth_id": "PA-1234" },
    "webhook": { "mode": "svix" }
  }'
```

That's it. Webhooks are **opt-in per batch** — a batch with no `webhook` field never fires one, even if you have endpoints registered. When you do opt in, every enabled endpoint on your org receives the event.

## The event

When the batch finishes we POST this body (and only this — no results inline):

```json theme={null}
{
  "type": "batch.update",
  "batch_id": "batch_abc",
  "status": "partially_failed",
  "counts": { "pending": 0, "running": 0, "succeeded": 4, "failed": 1, "cancelled": 0 },
  "total_items": 5,
  "metadata": { "prior_auth_id": "PA-1234" },
  "completed_at": "2026-08-05T12:34:56Z",
  "results_expires_at": "2026-08-08T12:34:56Z"
}
```

The payload is deliberately thin: ids, status, counts, and your echoed `metadata`. To get the actual extraction, call `GET /v1/batches/{batch_id}` and fetch each item's result. `results_expires_at` is the deadline after which those results are deleted (3-day default retention) — fetch before then. Need longer? Email [hello@hanji.dev](mailto:hello@hanji.dev).

<Warning>
  **`status` has four terminal values, not two.** Unlike a single-document webhook, a batch can partially succeed:

  | `status`           | Meaning                                                                                                                                                                                                                                     |
  | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `completed`        | Every item succeeded.                                                                                                                                                                                                                       |
  | `partially_failed` | **Terminal and done** — some items succeeded and some did **not** (failed **or** were cancelled). Check `counts` for the breakdown; `failed` may be `0` if the non-successes were cancellations. Results for the succeeded items are ready. |
  | `failed`           | No item succeeded and there were no cancellations.                                                                                                                                                                                          |
  | `cancelled`        | No item succeeded and at least one item was cancelled (may also include failures).                                                                                                                                                          |

  If you're porting a handler that branches `if status == "Completed"`, it will silently ignore `partially_failed` batches and drop the results of every document that *did* succeed. Treat **both** `completed` and `partially_failed` as "results are ready — inspect `counts` for per-status breakdown."
</Warning>

## Handling the webhook

Verify the signature, branch on `status`, return `2xx` fast, and do the real work after. Headers: `svix-id`, `svix-timestamp`, `svix-signature` (the Standard-Webhooks aliases `webhook-id` / `webhook-timestamp` / `webhook-signature` are also sent).

<CodeGroup>
  ```python python theme={null}
  import os
  from flask import Flask, request, jsonify
  from svix.webhooks import Webhook, WebhookVerificationError

  app = Flask(__name__)
  WEBHOOK_SECRET = os.environ["HANJI_WEBHOOK_SECRET"]  # whsec_… from the dashboard

  @app.post("/webhooks/hanji")
  def handle():
      # Pass the RAW body and the untouched headers object — signature
      # verification is sensitive to any change to the body bytes.
      try:
          payload = Webhook(WEBHOOK_SECRET).verify(request.get_data(), request.headers)
      except WebhookVerificationError:
          return jsonify(error="invalid signature"), 401

      # Ping events are health checks — acknowledge and stop.
      if payload["type"] == "webhook.ping":
          return jsonify(received=True), 200

      svix_id = request.headers["svix-id"]  # stable dedup key across retries
      status = payload["status"]

      # `completed` AND `partially_failed` both mean "results are ready".
      if status in ("completed", "partially_failed"):
          enqueue_fetch(payload["batch_id"], idempotency_key=svix_id)
      elif status in ("failed", "cancelled"):
          # No results to fetch — record the terminal outcome in your system.
          mark_terminal(payload["batch_id"], status=status, idempotency_key=svix_id)

      return jsonify(received=True), 200
  ```

  ```typescript typescript theme={null}
  import express from "express";
  import { Webhook } from "svix";

  const app = express();
  const WEBHOOK_SECRET = process.env.HANJI_WEBHOOK_SECRET!; // whsec_… from the dashboard

  app.post(
    "/webhooks/hanji",
    express.raw({ type: "application/json" }),
    (req, res) => {
      let payload: any;
      try {
        payload = new Webhook(WEBHOOK_SECRET).verify(req.body, {
          "svix-id": req.header("svix-id")!,
          "svix-timestamp": req.header("svix-timestamp")!,
          "svix-signature": req.header("svix-signature")!,
        });
      } catch {
        return res.status(401).json({ error: "invalid signature" });
      }

      if (payload.type === "webhook.ping") {
        return res.status(200).json({ received: true });
      }

      const svixId = req.header("svix-id")!; // stable dedup key across retries

      // `completed` AND `partially_failed` both mean "results are ready".
      if (["completed", "partially_failed"].includes(payload.status)) {
        enqueueFetch(payload.batch_id, svixId);
      } else if (["failed", "cancelled"].includes(payload.status)) {
        // No results to fetch — record the terminal outcome in your system.
        markTerminal(payload.batch_id, payload.status, svixId);
      }

      return res.status(200).json({ received: true });
    },
  );
  ```
</CodeGroup>

### Coming from Reducto?

Your `svix` verification call ports **unchanged**. Two lines in the handler *after* verification change:

* The id field is `batch_id`, not `job_id`.
* Statuses are lowercase (`completed` / `partially_failed` / `failed` / `cancelled`), and you fetch with `GET /v1/batches/{batch_id}`. Remember `partially_failed` — Reducto's binary `Completed`/`Failed` has no equivalent.

## Delivery, retries, idempotency

* **Return `2xx` within 15 seconds.** Do slow work asynchronously; we only read the status code.
* **Retries.** A non-`2xx` (or a timeout) is retried on an escalating schedule — 8 attempts over \~27 hours — so a receiver that's briefly down still gets the event. After the ladder is exhausted the delivery is marked failed and is resendable from the dashboard.
* **Be idempotent.** `svix-id` is stable across every retry of one event — use it as your dedup key. At-least-once delivery means you may occasionally see the same event twice.
* **Redirects are not followed.** Point the endpoint at the final URL.

## Testing

* **Send a test ping** from the dashboard — it delivers a `{"type": "webhook.ping"}` event so you can confirm your endpoint and signature verification work before wiring up a real batch. Short-circuit on the `webhook.ping` type as shown above.
* **Inspect payloads** with a throwaway endpoint from [webhook.site](https://webhook.site) while you build.
* **Watch deliveries** in the dashboard: every attempt, its status code, and a per-row **Resend** (available while the batch's results are still within their 3-day window).

## Direct mode (prototyping)

For quick tests or a dynamic per-request destination, pass the URL **inline** instead of registering an endpoint. Direct deliveries are **unsigned** — best for prototyping or internal integrations. Use signed registered endpoints (above) for production.

```bash theme={null}
curl -X POST https://api.hanji.dev/v1/batches \
  -H "X-API-KEY: $HANJI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "type": "files", "file_ids": ["file_abc"] },
    "metadata": { "secret": "your-shared-token", "prior_auth_id": "PA-1234" },
    "webhook": { "mode": "direct", "url": "https://your-app.com/webhook" }
  }'
```

The URL must be HTTPS and resolve to a public host. The event body is identical to the signed one above — same four terminal statuses (including `partially_failed`).

Since there's no signature, authenticate by round-tripping a secret token through `metadata` (we echo it back verbatim) and checking it in your handler:

```python theme={null}
if request.json.get("metadata", {}).get("secret") != SHARED_SECRET:
    abort(401)
```

Direct deliveries share everything else with signed ones — the same retry ladder, the same 15-second `2xx` deadline, and the same SSRF protections (private, loopback, and cloud-metadata destinations are refused). Don't put PHI in `metadata` unless the receiving endpoint is inside your compliance boundary; it's echoed verbatim to whatever URL the request names.
