Skip to main content

Webhooks ⚡

Use this page for webhook mechanics: signature verification, handler shape, event payloads, and framework examples.

For the canonical webhook-first architecture, polling tradeoffs, failure recovery, and launch checklist, start with Production workflow.

If you are debugging an active delivery issue such as failed verification, missing deliveries, or duplicate retries, start with Troubleshooting and FAQ.

Why webhooks?

Without webhooks (polling):

// Blocks your app for minutes, wastes API calls
const receipt = await conduit.reports.create({
source: { url: "https://example.com/call.mp3" },
output: { template: "sales_playbook" },
target: { strategy: "dominant" },
});

const report = await receipt.handle?.wait({ timeoutMs: 10 * 60_000 });

With webhooks:

// Returns immediately, webhook notifies you when done
const receipt = await conduit.reports.create({
source: { url: "https://example.com/call.mp3" },
output: { template: "sales_playbook" },
webhook: {
url: "https://yourapp.com/webhooks/conduit",
},
target: { strategy: "dominant" },
});
// ✨ Your server gets notified automatically when complete

Benefits:

  • Real-time - Get notified within seconds of completion
  • Efficient - No wasted API calls polling for status
  • Scalable - Handle thousands of concurrent jobs easily
  • Reliable - Automatic retries if your endpoint is down

Quick start

1. Create a webhook endpoint

Set up an endpoint on your server to receive notifications:

webhook-handler.ts
import express from "express";
import { Conduit } from "@mappa-ai/conduit";

const app = express();
const conduit = new Conduit({ apiKey: process.env.CONDUIT_API_KEY! });

// IMPORTANT: Use text() or raw() middleware to preserve the body for signature verification
app.post(
"/webhooks/conduit",
express.text({ type: "*/*" }),
async (req, res) => {
try {
// 1. Verify the signature
await conduit.webhooks.verifySignature({
payload: req.body,
headers: req.headers as Record<string, string | string[] | undefined>,
secret: process.env.CONDUIT_WEBHOOK_SECRET!,
toleranceSec: 300, // Allow 5 min clock skew
});

// 2. Parse the event
const event = conduit.webhooks.parseEvent(req.body);

// 3. Handle the event
if (event.type === "report.completed") {
console.info(`✅ Report ${event.data.reportId} ready!`);
// Fetch and process the report
}

if (event.type === "report.failed") {
console.error(`❌ Job failed:`, event.data.error);
// Handle the failure
}

// 4. Acknowledge receipt
res.status(200).send("OK");
} catch (err) {
console.error("Webhook verification failed:", err);
res.status(401).send("Invalid signature");
}
}
);

app.listen(3000);

2. Add webhook to job

const receipt = await conduit.reports.create({
source: { url: "https://example.com/interview.mp3" },
output: { template: "general_report" },
webhook: {
url: "https://yourapp.com/webhooks/conduit",
},
target: { strategy: "dominant" },
});

console.info(`Job ${receipt.jobId} created - webhook will notify when done`);

Signature verification (critical!)

Always verify webhook signatures to ensure requests are actually from Conduit, not an attacker.

How it works

Every webhook includes a conduit-signature header:

conduit-signature: t=1705401600,v1=a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1
PartDescription
tUnix timestamp (prevents replay attacks)
v1HMAC-SHA256 signature of the payload

Using the SDK

The SDK handles verification for you:

await conduit.webhooks.verifySignature({
payload: req.body, // Raw body string
headers: req.headers, // Request headers
secret: process.env.CONDUIT_WEBHOOK_SECRET!,
toleranceSec: 300, // Optional: clock skew tolerance
});
Get Your Webhook Secret

Find your webhook secret in the Conduit dashboard under Workspace Settings.

Manual verification (without SDK)

If you can't use the SDK:

import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhook(payload: string, signature: string, secret: string): boolean {
const [tPart, v1Part] = signature.split(",");
const timestamp = tPart.split("=")[1];
const receivedSig = v1Part.split("=")[1];

// Check timestamp is recent (prevent replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number.parseInt(timestamp)) > 300) {
return false; // Older than 5 minutes
}

// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSig = createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");

// Use timing-safe comparison
try {
return timingSafeEqual(
Buffer.from(receivedSig, "hex"),
Buffer.from(expectedSig, "hex")
);
} catch {
return false;
}
}

Webhook events

report.completed

Sent when a job finishes successfully:

{
id: "evt_abc123",
type: "report.completed",
createdAt: "2026-01-17T12:05:00.000Z",
timestamp: "2026-01-17T12:05:00.000Z",
data: {
jobId: "cm5job123abc456",
reportId: "cm5report789xyz",
status: "succeeded"
}
}

Example handler:

if (event.type === "report.completed") {
const report = await conduit.reports.get(event.data.reportId);

await database.reports.save({
reportId: event.data.reportId,
content: report.output.markdown,
});

await emailUser("Your report is ready!");
}

report.failed

Sent when a job fails:

{
id: "evt_def456",
type: "report.failed",
createdAt: "2026-01-17T12:05:00.000Z",
timestamp: "2026-01-17T12:05:00.000Z",
data: {
jobId: "cm5job123abc456",
status: "failed",
error: {
code: "target_not_found",
message: "Could not identify target speaker"
}
}
}

Example handler:

if (event.type === "report.failed") {
console.error(`Job ${event.data.jobId} failed:`, event.data.error);

// Retry with fallback strategy
if (event.data.error.code === "target_not_found") {
await retryWithFallback(event.data.jobId);
}
}

Framework examples

Next.js app router

app/api/webhooks/conduit/route.ts
import { Conduit } from "@mappa-ai/conduit";
import { NextRequest, NextResponse } from "next/server";

const conduit = new Conduit({ apiKey: process.env.CONDUIT_API_KEY! });

export async function POST(request: NextRequest) {
try {
const body = await request.text();
const headers = Object.fromEntries(request.headers.entries());

await conduit.webhooks.verifySignature({
payload: body,
headers,
secret: process.env.CONDUIT_WEBHOOK_SECRET!,
});

const event = conduit.webhooks.parseEvent(body);

// Handle event...

return NextResponse.json({ received: true });
} catch (err) {
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 401 }
);
}
}

Fastify

import Fastify from "fastify";
import { Conduit } from "@mappa-ai/conduit";

const app = Fastify();
const conduit = new Conduit({ apiKey: process.env.CONDUIT_API_KEY! });

app.post("/webhooks/conduit", async (request, reply) => {
try {
await conduit.webhooks.verifySignature({
payload: request.body as string,
headers: request.headers as Record<string, string | string[] | undefined>,
secret: process.env.CONDUIT_WEBHOOK_SECRET!,
});

const event = conduit.webhooks.parseEvent(request.body as string);
// Handle event...

return { received: true };
} catch (err) {
reply.status(401).send({ error: "Invalid signature" });
}
});

Production best practices

1. Respond quickly

Always respond with 200 immediately, then process asynchronously:

app.post("/webhooks/conduit", async (req, res) => {
// Verify signature
await conduit.webhooks.verifySignature({ /* ... */ });

// Acknowledge immediately
res.status(200).send("OK");

// Process asynchronously (don't block the response)
processWebhookAsync(req.body).catch(console.error);
});

async function processWebhookAsync(body: string) {
const event = conduit.webhooks.parseEvent(body);
// Do expensive work here
await fetchReport(event.data.reportId);
await updateDatabase();
await sendNotification();
}

2. Handle idempotent retries

Conduit retries failed webhooks. Make your handler idempotent:

const processedEvents = new Set<string>();

app.post("/webhooks/conduit", async (req, res) => {
const event = conduit.webhooks.parseEvent(req.body);

// Skip if already processed
if (processedEvents.has(event.id)) {
return res.status(200).send("Already processed");
}

await handleEvent(event);
processedEvents.add(event.id);

res.status(200).send("OK");
});

3. Use HTTPS only

const webhookUrl = process.env.WEBHOOK_URL;

if (!webhookUrl.startsWith("https://")) {
throw new Error("Webhook URL must use HTTPS");
}

Webhook retries

Conduit may retry failed webhook deliveries, so your consumer must be idempotent.

Treat duplicate deliveries as normal recovery behavior:

  • dedupe on event.id or an equivalent durable key
  • make downstream writes safe to repeat
  • return success quickly after verification so transient handler work does not cause unnecessary retries

Do not rely on a specific retry count or delay schedule in your application logic.


Testing webhooks locally

Using ngrok

# 1. Start your server
node webhook-server.js
# → http://localhost:3000

# 2. Expose with ngrok
ngrok http 3000
# → https://abc123.ngrok.io

# 3. Use ngrok URL
const receipt = await conduit.reports.create({
source: { url: "https://example.com/call.mp3" },
output: { template: "general_report" },
webhook: {
url: "https://abc123.ngrok.io/webhooks/conduit",
},
target: { strategy: "dominant" },
});

Using webhook.site

  1. Go to https://webhook.site
  2. Copy your unique URL
  3. Use it as your webhook URL to see payloads in real-time

Troubleshooting

Signature verification fails

Cause: Not using raw body for verification

Solution: Use express.text() or express.raw(), not express.json():

// ❌ Wrong
app.use(express.json());

// ✅ Correct
app.post("/webhooks/conduit", express.text({ type: "*/*" }), handler);

Webhook not received

Possible causes:

  1. Endpoint not accessible from internet
  2. Firewall blocking Conduit IPs
  3. SSL certificate issues
  4. Endpoint returning non-200 status

Test your endpoint:

curl -X POST https://yourapp.com/webhooks/conduit \
-H "Content-Type: application/json" \
-d '{"test": true}'

Duplicate deliveries

Cause: Your endpoint returned non-200, triggering a retry

Solution: Implement idempotent handling (see Production Best Practices)


What's next? 🚀

Ready to build more?

Need help?