// server/webhooks/moda.ts
import express from "express";
import crypto from "node:crypto";
import { createClient } from "redis"; // use your dedupe store of choice
const app = express();
const SECRET = process.env.MODA_WEBHOOK_SECRET!;
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
// IMPORTANT: use express.raw here, NOT express.json — we need the exact bytes for HMAC.
app.post(
"/webhooks/moda",
express.raw({ type: "application/json", limit: "1mb" }),
async (req, res) => {
const tsHeader = req.header("x-webhook-timestamp");
const sigHeader = req.header("x-webhook-signature");
if (!tsHeader || !sigHeader) return res.status(400).end();
// 1. replay protection
const skew = Math.abs(Date.now() / 1000 - Number(tsHeader));
if (!Number.isFinite(skew) || skew > 300) return res.status(401).end();
// 2. signature verification
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${tsHeader}.${req.body.toString("utf8")}`)
.digest("hex");
const received = sigHeader.replace(/^v1=/, "");
const sigOk =
expected.length === received.length &&
crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex"),
);
if (!sigOk) return res.status(401).end();
// 3. parse + dedupe on event id
const event = JSON.parse(req.body.toString("utf8"));
const dedupeKey = `moda:evt:${event.id}`;
const firstTime = await redis.set(dedupeKey, "1", { NX: true, EX: 60 * 60 * 24 * 7 });
if (!firstTime) return res.status(200).json({ ok: true }); // already handled
// 4. ack fast; process in the background
res.status(200).json({ ok: true });
void handleAsync(event);
},
);
async function handleAsync(event: any) {
try {
switch (event.type) {
case "task.succeeded": {
const { canvas_url } = event.data.result;
await notifySuccess(event.data.id, canvas_url);
break;
}
case "task.failed": {
const retryable = event.data.error?.retryable ?? false;
await notifyFailure(event.data.id, event.data.error?.message, retryable);
break;
}
case "task.canceled":
case "export.succeeded":
case "export.failed":
// handle as needed
break;
default:
// unexpected event type → log for ops, not for the user
console.warn("Unknown Moda event type", event.type, event.id);
}
} catch (e) {
console.error("Handler threw", e, "event=", event.id);
// do not re-throw — webhook was already acked
}
}