Webhooks β‘
Get notified instantly when your report jobs complete. No polling, no waiting, just real-time updates delivered straight to your server.
Why webhooks?β
Without webhooks (polling):
// Blocks your app for minutes, wastes API calls
const report = await mappa.reports.generateFromUrl({
url: "https://example.com/call.mp3",
output: { template: "sales_playbook" },
target: { strategy: "dominant" },
});
With webhooks:
// Returns immediately, webhook notifies you when done
const receipt = await mappa.reports.createJobFromUrl({
url: "https://example.com/call.mp3",
output: { template: "sales_playbook" },
webhook: {
url: "https://yourapp.com/webhooks/mappa",
},
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 { Mappa } from "@mappa-ai/mappa-node";
const app = express();
const mappa = new Mappa({ apiKey: process.env.MAPPA_API_KEY! });
// IMPORTANT: Use text() or raw() middleware to preserve the body for signature verification
app.post(
"/webhooks/mappa",
express.text({ type: "*/*" }),
async (req, res) => {
try {
// 1. Verify the signature
await mappa.webhooks.verifySignature({
payload: req.body,
headers: req.headers as Record<string, string | string[] | undefined>,
secret: process.env.MAPPA_WEBHOOK_SECRET!,
toleranceSec: 300, // Allow 5 min clock skew
});
// 2. Parse the event
const event = mappa.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 mappa.reports.createJobFromUrl({
url: "https://example.com/interview.mp3",
output: { template: "hiring_report" },
webhook: {
url: "https://yourapp.com/webhooks/mappa",
},
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 Mappa, not an attacker.
How it worksβ
Every webhook includes a mappa-signature header:
mappa-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 mappa.webhooks.verifySignature({
payload: req.body, // Raw body string
headers: req.headers, // Request headers
secret: process.env.MAPPA_WEBHOOK_SECRET!,
toleranceSec: 300, // Optional: clock skew tolerance
});
Find your webhook secret in the Mappa Dashboard under Team 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",
data: {
jobId: "cm5job123abc456",
reportId: "cm5report789xyz",
status: "completed"
}
}
Example handler:
if (event.type === "report.completed") {
const report = await mappa.reports.get(event.data.reportId);
await database.reports.save({
reportId: event.data.reportId,
content: report.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",
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 { Mappa } from "@mappa-ai/mappa-node";
import { NextRequest, NextResponse } from "next/server";
const mappa = new Mappa({ apiKey: process.env.MAPPA_API_KEY! });
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const headers = Object.fromEntries(request.headers.entries());
await mappa.webhooks.verifySignature({
payload: body,
headers,
secret: process.env.MAPPA_WEBHOOK_SECRET!,
});
const event = mappa.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 { Mappa } from "@mappa-ai/mappa-node";
const app = Fastify();
const mappa = new Mappa({ apiKey: process.env.MAPPA_API_KEY! });
app.post("/webhooks/mappa", async (request, reply) => {
try {
await mappa.webhooks.verifySignature({
payload: request.body as string,
headers: request.headers as Record<string, string | string[] | undefined>,
secret: process.env.MAPPA_WEBHOOK_SECRET!,
});
const event = mappa.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/mappa", async (req, res) => {
// Verify signature
await mappa.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 = mappa.webhooks.parseEvent(body);
// Do expensive work here
await fetchReport(event.data.reportId);
await updateDatabase();
await sendNotification();
}
2. Handle idempotent retriesβ
Mappa retries failed webhooks. Make your handler idempotent:
const processedEvents = new Set<string>();
app.post("/webhooks/mappa", async (req, res) => {
const event = mappa.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β
If your endpoint returns a non-200 status, Mappa automatically retries:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 5 minutes |
| 5 | 30 minutes |
After 5 failed attempts, the webhook is abandoned.
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 mappa.reports.createJobFromUrl({
url: "https://example.com/call.mp3",
output: { template: "general_report" },
webhook: {
url: "https://abc123.ngrok.io/webhooks/mappa",
},
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/mappa", express.text({ type: "*/*" }), handler);
Webhook not receivedβ
Possible causes:
- Endpoint not accessible from internet
- Firewall blocking Mappa IPs
- SSL certificate issues
- Endpoint returning non-200 status
Test your endpoint:
curl -X POST https://yourapp.com/webhooks/mappa \
-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?
- Error Codes - Understand webhook error codes
- API Reference - Webhook configuration options