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 (sourceapiordashboard)file.updated— metadata changed (e.g. rename)file.deleted— file removedintake.completed— an intake submission was finalized with one or more files (sourceintake; see File Intake)
Payload envelope
Each delivery is a POST with JSON body:
{
"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:
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.
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 →