Skip to main content

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:

webhook-handler.ts
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
PartDescription
tUnix timestamp (prevents replay attacks)
v1HMAC-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
});
Get Your Webhook Secret

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​

app/api/webhooks/mappa/route.ts
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:

AttemptDelay
1Immediate
25 seconds
330 seconds
45 minutes
530 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​

  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/mappa", express.text({ type: "*/*" }), handler);

Webhook not received​

Possible causes:

  1. Endpoint not accessible from internet
  2. Firewall blocking Mappa IPs
  3. SSL certificate issues
  4. 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?

Need help?