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:
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
| Part | Description |
|---|---|
t | Unix timestamp (prevents replay attacks) |
v1 | HMAC-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
});
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
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.idor 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
- Go to https://webhook.site
- Copy your unique URL
- 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:
- Endpoint not accessible from internet
- Firewall blocking Conduit IPs
- SSL certificate issues
- 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?
- Production Guide - Best practices for shipping to production
- Error Handling - Handle failures gracefully
- Examples - More webhook patterns
Need help?
- Troubleshooting and FAQ - Symptom-based recovery path for webhook incidents
- Error Codes - Understand webhook error codes
- API Reference - Webhook configuration options