Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.moda.app/llms.txt

Use this file to discover all available pages before exploring further.

Webhook receiver

Problem: Stand up a production-grade endpoint that verifies Moda webhook signatures, rejects replays, deduplicates, acknowledges fast, and hands off event processing to a background worker.

Primitives

  • Raw request body (for HMAC)
  • X-Webhook-Signature + X-Webhook-Timestamp headers
  • Webhook signing secret (stored with the API key — not the API key itself)
  • Event envelope id as the dedupe key
  • Async worker (queue / task runner / Pub-Sub topic) for the actual work

TypeScript — Express

// 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
  }
}

Python — FastAPI

# server/webhooks/moda.py
import hashlib, hmac, json, os, time
from fastapi import FastAPI, Header, HTTPException, Request
from redis.asyncio import Redis

app = FastAPI()
SECRET = os.environ["MODA_WEBHOOK_SECRET"]
redis = Redis.from_url(os.environ["REDIS_URL"])

@app.post("/webhooks/moda")
async def moda_webhook(
    req: Request,
    x_webhook_signature: str = Header(...),
    x_webhook_timestamp: str = Header(...),
):
    body = await req.body()

    # 1. replay protection
    try:
        skew = abs(time.time() - int(x_webhook_timestamp))
    except ValueError:
        raise HTTPException(401, "bad timestamp")
    if skew > 300:
        raise HTTPException(401, "stale timestamp")

    # 2. signature verification
    expected = hmac.new(
        SECRET.encode(),
        f"{x_webhook_timestamp}.{body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    received = x_webhook_signature.removeprefix("v1=")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(401, "bad signature")

    # 3. dedupe on event id
    event = json.loads(body)
    first_time = await redis.set(
        f"moda:evt:{event['id']}", "1", nx=True, ex=60 * 60 * 24 * 7,
    )
    if not first_time:
        return {"ok": True}

    # 4. enqueue async work; return fast
    await enqueue(event)                       # e.g. Celery, Arq, RQ, Pub/Sub
    return {"ok": True}


async def enqueue(event: dict) -> None:
    # Your enqueue implementation. Must return in < a few seconds.
    ...

What to do per event type

task.succeeded  → grab data.result.canvas_url, notify the user / CRM / Slack
task.failed     → check data.error.retryable:
                  - retryable true: transient; task already dead-lettered after N attempts
                  - retryable false: permanent; surface to the user
task.canceled   → quiet path; log only, or notify if cancellation was unexpected
export.succeeded → grab data.result.export_url (future; current exports are sync)
export.failed    → same failure-handling as task.failed
Always log data.error.request_id on failures — it’s the handle support will use to find your request.

Gotchas

  • Use raw body for HMAC. Express / FastAPI default JSON parsing hides the exact bytes. express.raw or await req.body() (not await req.json() before reading body).
  • Return 200 in < 30s, always. Moda retries non-2xx or timeouts at 1s / 5s / 30s.
  • Dedupe on event.id, not event.data.id. Same task can have multiple events (succeeded vs failed, though rare; future: export for the same task). Using event.id dedupes retries of the same event; using task.id would dedupe legitimate different events.
  • TTL your dedupe keys. 7 days is plenty — Moda’s retry window is minutes.
  • Reject stale timestamps. 5 minutes is the conventional bound. Anything older is almost certainly a replay attack.
  • Use constant-time compare (timingSafeEqual / compare_digest). Raw == leaks timing info.
  • HTTPS only. Moda won’t deliver to plain-HTTP callback_url.
  • Don’t do work inside the handler. Enqueue, then 200. If Postgres is down or Slack is slow, you want to 200 anyway and retry the downstream work yourself — not force Moda to retry.
  • Test with the wrong signature in staging to make sure you reject. A handler that silently accepts unsigned bodies is the worst case.

Local testing without public HTTPS

ngrok http 3000           # or `tailscale serve` / `cloudflared tunnel`
Point the task’s callback_url at the ngrok HTTPS URL. Signing still works — the signing secret is per-API-key, not per-URL.

See also