Gareth Wilson Gareth Wilson

Guide to Reducto Webhooks: Features and Best Practices

Published


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

FeatureSvix modeDirect mode
Enabled byasync.webhook.mode: "svix" on the jobasync.webhook.mode: "direct" and url on the job
EndpointsAdded in the Svix dashboard, filtered by channelsThe URL in the request
Signaturesvix-id, svix-timestamp, svix-signatureNone
SchemeHMAC-SHA256 over {svix-id}.{svix-timestamp}.{raw body}, base64None
Secretwhsec_ signing secret per endpoint, in the Svix dashboardYour own token in metadata
Response window2xx within 15 secondsNot stated
RetriesExponential backoffUp to 3, exponential backoff
Replay"Resend" in the Svix dashboardNone
Deduplication keysvix-idjob_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:

statusMeaning
CompletedThe job finished. Fetch the result by job_id
FailedThe job failed

Here is Reducto's example body:

{
  "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 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:
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.

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:

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

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.

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 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 for free and handle Reducto webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.