# Guide to Reducto Webhooks: Features and Best Practices

Reducto webhooks tell you when an async document job has finished. Instead of polling for the result of a large parse, extract, split or pipeline job, you submit it with a webhook configuration and Reducto notifies your endpoint when the job completes or fails. The webhook carries the job ID and status; you fetch the result from the API.

This guide covers Reducto's two delivery modes, the payload, how to verify Svix signatures, and the practices that keep a Reducto receiver reliable in production.

## What are Reducto webhooks?

Reducto webhooks are set per job. When you call an async endpoint such as `/parse_async`, you add an `async.webhook` object that says how to notify you. There are two modes:

* **Svix mode** (`"mode": "svix"`). Reducto sends the notification to Svix, which delivers it to the endpoints you have added in a Svix dashboard. Deliveries are signed, retried, and logged. Reducto recommends this mode for production.
* **Direct mode** (`"mode": "direct"`, with a `url`). Reducto posts straight to the URL in the job request. Deliveries are unsigned and retried up to three times. Reducto positions this mode for prototyping and simple internal integrations.

Both modes send the same small JSON body. Svix names the event `async.update` in its dashboard, but no event type appears in the body or the headers.

## Reducto webhook features

| Feature | Svix mode | Direct mode |
| --- | --- | --- |
| Enabled by | `async.webhook.mode: "svix"` on the job | `async.webhook.mode: "direct"` and `url` on the job |
| Endpoints | Added in the Svix dashboard, filtered by `channels` | The URL in the request |
| Signature | `svix-id`, `svix-timestamp`, `svix-signature` | None |
| Scheme | HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{raw body}`, base64 | None |
| Secret | `whsec_` signing secret per endpoint, in the Svix dashboard | Your own token in `metadata` |
| Response window | 2xx within 15 seconds | Not stated |
| Retries | Exponential backoff | Up to 3, exponential backoff |
| Replay | "Resend" in the Svix dashboard | None |
| Deduplication key | `svix-id` | `job_id` |

Webhooks work with `/parse_async`, `/extract_async`, `/split_async` and `/pipeline_async`.

## Common events

There is one notification, sent when an async job finishes. Its outcome is in `status`:

| `status` | Meaning |
| --- | --- |
| `Completed` | The job finished. Fetch the result by `job_id` |
| `Failed` | The job failed |

Here is Reducto's example body:

```json
{
  "status": "Completed",
  "job_id": "a1b9090e-c9ae-420b-9726-f658afbbe338",
  "metadata": {
    "user_id": "123",
    "document_type": "invoice"
  }
}

```

`metadata` is whatever you passed as `async.metadata` when you submitted the job, so you can use it to link the notification back to your own records. Reducto does not show a `Failed` example, so do not assume a failed body carries an error reason; fetch the job to find out what went wrong.

The body never includes the job result. Reducto says results are generally available for about 12 hours, so fetch them soon after the notification arrives.

## Setting up Reducto webhooks

For Svix mode:

1. Open the Svix dashboard, either from **Webhooks** in the [Reducto Studio](https://studio.reducto.ai) sidebar or from the URL that `POST https://platform.reducto.ai/configure_webhook` returns.
2. Click **+ Add Endpoint** and enter your HTTPS URL.
3. Open the endpoint and copy its signing secret, which starts with `whsec_`.
4. Optionally assign the endpoint to channels, such as `production` and `development`.
5. Submit jobs with the webhook configuration:

```bash
curl -X POST https://platform.reducto.ai/parse_async \
  -H "Authorization: Bearer $REDUCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "https://example.com/document.pdf",
    "async": {
      "webhook": {"mode": "svix", "channels": ["production"]},
      "metadata": {"user_id": "123"}
    }
  }'

```

An empty `channels` list sends the notification to every endpoint.

For direct mode, set `"webhook": {"mode": "direct", "url": "https://your-app.com/webhook"}` on each job instead. There is nothing to configure in a dashboard.

## Securing Reducto webhooks

In Svix mode, verify every delivery. Build `{svix-id}.{svix-timestamp}.{raw body}`, compute an HMAC-SHA256 with the base64-decoded part of the secret after `whsec_`, base64-encode it, and accept the request if any `v1,` entry in `svix-signature` matches. Reducto's own examples use the Svix SDK's `Webhook.verify`, which does the same, plus a timestamp check.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyReductoSignature(rawBody, headers, secret) {
  const id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatureHeader = headers['svix-signature'];
  if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) {
    return false;
  }

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest('base64');
  const computed = Buffer.from(expected, 'utf8');

  // Optional: reject timestamps too far from now, as the Svix SDK does.
  // if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'utf8');
    return received.length === computed.length && crypto.timingSafeEqual(received, computed);
  });
}

const app = express();

app.post('/webhooks/reducto', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyReductoSignature(req.body, req.headers, process.env.REDUCTO_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const notification = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on the svix-id header, then fetch the job by notification.job_id asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_reducto_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
        return False
    key = base64.b64decode(secret[len("whsec_"):])
    signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_reducto_signature(raw_body, request.headers, os.environ["REDUCTO_WEBHOOK_SECRET"])

```

To test your code, use the secret `whsec_cmVkdWN0by10ZXN0LXNpZ25pbmcta2V5LTMyYnl0ZSE=`, the `svix-id` `msg_2xKq7RbS9mT4vN1cH8dJ3pLw`, the `svix-timestamp` `1760000000` and this exact body:

```
{"status":"Completed","job_id":"a1b9090e-c9ae-420b-9726-f658afbbe338","metadata":{"user_id":"123"}}

```

The `svix-signature` header should contain:

```
v1,ijIkf6b3/9quapXtX8Pccl2BPdNyPS2wEBVslRm064A=

```

Leave any timestamp check off while you run this test.

In direct mode there is no signature. Reducto's suggestion is to put a secret token in `async.metadata` and compare it when the notification arrives. Its async overview also warns against putting secrets in `metadata` when metadata keys are republished through the usage API, so check how your account uses metadata before relying on this.

In Hookdeck, paste the Svix signing secret into the source with its `whsec_` prefix. Hookdeck checks `svix-signature` and does not apply a timestamp window, so retried and replayed events keep passing verification. If you use direct mode, leave verification off, because direct deliveries carry no `svix-*` headers.

> **Make Reducto webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `svix-signature`, answers well inside the 15-second window, and holds each job notification until your service has fetched the result.

## Reducto webhook limitations and pain points

### The result is not in the webhook

**The Problem:** Every notification needs an API call, and the result does not wait for ever.

**Why It Happens:** The body has only `status`, `job_id` and `metadata`. Results are generally available for about 12 hours, so a notification processed late can point at a result that has gone.

**Workarounds:**

* Fetch the result as soon as you have acknowledged the webhook, and store it.
* Alert when notifications sit unprocessed for more than a few hours.

**How Hookdeck Can Help:** Hookdeck delivers the notification to your service as soon as it is up and retries on a schedule you control, so jobs are not left waiting until their results have expired.

### Direct mode is unsigned and thinly retried

**The Problem:** Direct-mode notifications can be forged and are easy to lose.

**Why It Happens:** Direct mode has no signature, three retries, and no dashboard or replay.

**Workarounds:**

* Use Svix mode in production.
* If you must use direct mode, check a secret token from `metadata` and poll for jobs that never report back.

**How Hookdeck Can Help:** Pointing direct mode at Hookdeck gives you a stored copy of every notification that arrives, with retries and replay against your service, although it cannot add a signature Reducto does not send.

### No event type on the wire

**The Problem:** You cannot tell Reducto deliveries apart by type in shared infrastructure.

**Why It Happens:** Svix shows the event as `async.update` in its dashboard, but Svix does not put the event type in the payload, and the body has no type field.

**Workarounds:**

* Give Reducto its own endpoint and treat every delivery there as a job notification.
* Branch on `status`, and correlate on `job_id` and `metadata`.

**How Hookdeck Can Help:** A dedicated Hookdeck source for Reducto identifies the traffic by where it arrives, and filters on `status` can send failures to a different destination.

### Retries are Svix's, not documented by Reducto

**The Problem:** You cannot plan for how long Reducto will keep trying.

**Why It Happens:** Reducto says Svix mode retries "with exponential backoff" without giving the schedule. Svix's published default is immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, but Reducto does not say its Svix application uses that default unchanged.

**Workarounds:**

* Check the Message Attempts list in the Svix dashboard after a failure to see the real timings.
* Use **Resend** in the dashboard for anything that ran out of attempts.

**How Hookdeck Can Help:** Hookdeck takes the first delivery and then follows its own retry rules against your service, so the vendor's schedule matters less.

## Best practices

### Verify against the raw body

Reducto's troubleshooting section tells you to pass "the raw request body (not parsed JSON) to verification". Read it with `express.raw()`, `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, or `await request.body()` in FastAPI.

### Deduplicate on `svix-id`

Reducto recommends `svix-id` as the idempotency key, since it stays the same when Svix retries a message. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use channels to separate environments

Assign production and development endpoints to different channels and set `channels` on each job, so test jobs never reach production.

### Put correlation data in `metadata`

Include your own IDs, such as a document or user ID, so the handler can find the matching record without a lookup by `job_id`. Keep credentials out of it unless you have checked how your account uses metadata.

### Acknowledge fast and process asynchronously

Return `2xx` within 15 seconds and fetch the result in a background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Use Svix mode for anything that matters. It gives you signed deliveries, a dashboard and replay, and verification follows the usual Svix pattern of `{svix-id}.{svix-timestamp}.{raw body}` with a `whsec_` secret. Because the webhook only tells you a job has finished, the reliability work is in fetching the result promptly, deduplicating on `svix-id`, and noticing jobs that never report back.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Svix signature, and holds each job notification until your service has fetched the result, so a deploy or an outage does not leave results to expire.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Reducto webhooks reliably in minutes.