API

Webhooks

Receive HTTPS callbacks when files change. Configure webhook endpoints in the dashboard — there is no public API to register webhooks programmatically in the current release.

Event types

  • file.uploaded — file finished processing after an upload (source api or dashboard)
  • file.updated — metadata changed (e.g. rename)
  • file.deleted — file removed
  • intake.completed — an intake submission was finalized with one or more files (source intake; see File Intake)

Payload envelope

Each delivery is a POST with JSON body:

webhook payload
{
  "id": "event-uuid",
  "type": "file.uploaded",
  "source": "api",
  "created": "2026-05-20T14:30:00.000Z",
  "data": {
    "fileId": "uuid",
    "projectId": "uuid",
    "path": "projects/uuid/files/uuid/avatar.png",
    "name": "avatar.png",
    "mimeType": "image/png",
    "sizeBytes": 502400,
    "url": "https://cdn.example.com/public/uuid/uuid/avatar.png",
    "urlExpiresAt": null,
    "isPublic": true
  }
}

Public files use a stable CDN URL under /public/... with urlExpiresAt: null. Private files receive a signed CDN download URL (/v1/download/...) and an ISO urlExpiresAt timestamp (5 minutes by default). Use GET /files/:fileId/access when you need a fresh signed URL after expiry. Always verify the signature before trusting the payload.

Signature verification

Reupload sends a Reupload-Signature header:

header
Reupload-Signature: t=1716204600,v1=abc123...

Parse t (Unix seconds) and v1 (hex HMAC). Compute HMAC-SHA256 over {timestamp}.{rawBody} using the signing secret from the dashboard (prefix whsec_, shown once when you create the webhook). Compare with a constant-time equals check. Reject stale timestamps (e.g. older than 5 minutes) to limit replay.

Use the raw request body string before JSON parsing — any whitespace change breaks the signature.

Example: verify signature (Node / Next.js)

This example reads the raw request body, verifies Reupload-Signature, then parses JSON. Reject stale timestamps to prevent replays.

app/api/webhooks/reupload/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
 
const MAX_AGE_SECONDS = 5 * 60;
 
function parseSignatureHeader(header: string | null): { t: number; v1: string } | null {
  if (!header?.trim()) return null;
  let t: number | null = null;
  let v1: string | null = null;
  for (const part of header.split(",")) {
    const [key, value] = part.trim().split("=", 2);
    if (!key || value === undefined) continue;
    if (key === "t") {
      const parsed = Number.parseInt(value, 10);
      if (!Number.isNaN(parsed)) t = parsed;
    }
    if (key === "v1") v1 = value;
  }
  if (t === null || !v1) return null;
  return { t, v1 };
}
 
function safeEqualHex(a: string, b: string): boolean {
  try {
    const bufA = Buffer.from(a, "hex");
    const bufB = Buffer.from(b, "hex");
    if (bufA.length !== bufB.length) return false;
    return timingSafeEqual(bufA, bufB);
  } catch {
    return false;
  }
}
 
function verifyReuploadSignature(secret: string, rawBody: string, header: string | null) {
  const parsed = parseSignatureHeader(header);
  if (!parsed) return { ok: false as const, reason: "missing_or_malformed" };
 
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parsed.t) > MAX_AGE_SECONDS) {
    return { ok: false as const, reason: "stale_timestamp" };
  }
 
  const expected = createHmac("sha256", secret)
    .update(`${parsed.t}.${rawBody}`)
    .digest("hex");
 
  if (!safeEqualHex(expected, parsed.v1)) {
    return { ok: false as const, reason: "invalid_signature" };
  }
 
  return { ok: true as const };
}
 
export async function POST(req: Request) {
  const secret = process.env.REUPLOAD_WEBHOOK_SECRET; // whsec_... (dashboard)
  if (!secret) return Response.json({ error: "missing_secret" }, { status: 500 });
 
  // IMPORTANT: raw body before JSON.parse
  const rawBody = await req.text();
  const header = req.headers.get("Reupload-Signature");
 
  const verified = verifyReuploadSignature(secret, rawBody, header);
  if (!verified.ok) {
    return Response.json({ error: verified.reason }, { status: 401 });
  }
 
  const event = JSON.parse(rawBody);
  // handle event.type / event.data ...
  return Response.json({ received: true });
}

Retries

Failed deliveries are retried with backoff. Inspect delivery logs in the dashboard to debug endpoint issues.

Timing

file.uploaded fires after background processing completes, not immediately when POST /uploads/complete or POST /uploads/direct returns 202. That response means bytes are accepted and stored; the webhook delivers path, url, and confirmed sizeBytes when the file is safe to use. Why direct upload returns processing →