跳到主要内容

Automatically copy uploaded files from a client room to an internal room

This example shows how to automatically copy every newly uploaded file from a client-facing room (for example, “Shared with Client”) to an internal room (for example, “Internal Processing”).

Before you start

  1. Replace https://yourportal.onlyoffice.com and YOUR_API_KEY with your actual DocSpace portal URL and API key. Ensure you have the necessary data and permissions to perform these operations.
  2. Before you can make requests to the API, you need to authenticate. Check out the Personal access tokens page to learn how to obtain and use access tokens.
  3. Expose your webhook endpoint over HTTPS in production (terminate TLS in your app or behind a reverse proxy/load balancer, and reject non-HTTPS requests). For local testing, use a secure tunnel that provides an HTTPS URL.

Webhook configuration

This example relies on DocSpace webhooks. To register and manage webhooks on your portal, see Webhooks and the Help Center instructions: https://helpcenter.onlyoffice.com/administration/docspace-webhooks.aspx.

  • Register a webhook and enable the triggers used in this example.
  • Set the payload URL to your backend endpoint.
  • Generate/set a secret key and store it on the backend as WEBHOOK_SECRET. DocSpace sends the signature in the x-docspace-signature-256 header so you can validate the request against the raw body.

Timeouts and retries

DocSpace retries failed webhook deliveries. According to the webhook docs:

  • Up to 5 attempts are made, with exponential backoff (2^attempt seconds).
  • Any successful status (any 2xx) stops retries.
  • If your endpoint returns 410 Gone, the webhook is removed from the portal.

Keep the handler fast: validate the request, enqueue background work if needed, and return quickly. Make processing idempotent because the same event may be delivered more than once.

Full example
import express from "express";
import crypto from "crypto";

// Config
const API_HOST = process.env.DOCSPACE_API_HOST; // Set DOCSPACE_API_HOST in env (recommended). For quick tests you can temporarily paste your portal URL here.
const API_KEY = process.env.DOCSPACE_API_KEY; // Set DOCSPACE_API_KEY in env (recommended). For quick tests you can temporarily paste token here.

// Optional: if empty, signature validation is skipped
const WEBHOOK_SECRET = process.env.DOCSPACE_WEBHOOK_SECRET; // Optional. If empty, the signature check is skipped (dev only).

const SHARED_WITH_CLIENT_ROOM_ID = 539564; // replace
const INTERNAL_PROCESSING_FOLDER_ID = 341029; // replace

const HEADERS: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
};

// Helpers

async function docspaceRequest(path: string, method: string, jsonBody?: any) {
const url = `${API_HOST}${path}`;

let res: Response;
try {
res = await fetch(url, {
method,
headers: HEADERS,
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
} catch (e: any) {
console.error(`[ERROR] Request failed: ${method} ${url}`);
console.error(`[ERROR] ${e?.message || e}`);
return null;
}

if (!res.ok) {
const text = await res.text().catch(() => "");
console.error(`[ERROR] Request failed: ${method} ${url}`);
console.error(`[ERROR] Status: ${res.status}, Message: ${text}`);
return null;
}

try {
return await res.json();
} catch {
return null;
}
}

function verifySignature(rawBody: Buffer, signatureHeader?: string | null) {
// If WEBHOOK_SECRET is empty, skip verification
if (!WEBHOOK_SECRET) return true;

if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;

const received = signatureHeader.split("=", 2)[1]?.trim()?.toLowerCase();
if (!received) return false;

const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex")
.toLowerCase();

if (received.length !== expected.length) return false;

return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

function extractFileId(payload: any): number | null {
const data = payload?.data ?? payload?.entity ?? payload ?? {};
const raw = data.fileId ?? data.id ?? payload?.fileId ?? null;

const n = Number(raw);
return Number.isFinite(n) ? n : null;
}

function extractSourceFolderId(payload: any): number | null {
const data = payload?.data ?? payload?.entity ?? payload ?? {};
const raw = data.folderId ?? data.roomId ?? data.parentId ?? payload?.folderId ?? null;

const n = Number(raw);
return Number.isFinite(n) ? n : null;
}

// Copy helper

async function copyFileToInternalFolder(fileId: number, destFolderId: number) {
const payload = {
fileIds: [fileId],
destFolderId,
deleteAfter: false, // keep originals (copy)
content: true,
toFillOut: false,
};

return docspaceRequest("/api/2.0/files/fileops/copy", "PUT", payload);
}

// Webhook handler

const app = express();

// Capture raw body for signature validation
app.use(
express.json({
limit: "2mb",
verify: (req: any, _res, buf) => {
req.rawBody = buf;
},
})
);

app.head("/webhook", (_req, res) => {
// DocSpace (or your reverse proxy) may validate the URL with HEAD
return res.status(200).send("");
});

app.post("/webhook", async (req: any, res) => {
const rawBody: Buffer = req.rawBody ?? Buffer.from("");
const signatureHeader = req.header("x-docspace-signature-256");

// Step 1: Validate webhook signature (optional)
if (!verifySignature(rawBody, signatureHeader)) {
console.warn("[WARN] Invalid webhook signature.");
return res.status(401).send("Invalid signature");
}

const payload = req.body;
if (!payload || typeof payload !== "object") {
return res.status(200).json({ status: "ok" });
}

// Step 2: Extract fileId (+ optional source folder check)
const fileId = extractFileId(payload);
if (fileId === null) {
console.log("[INFO] No fileId in payload. Skipping.");
return res.status(200).json({ status: "ok" });
}

const sourceFolderId = extractSourceFolderId(payload);
if (sourceFolderId !== null && sourceFolderId !== SHARED_WITH_CLIENT_ROOM_ID) {
console.log(
`[INFO] Upload folder ${sourceFolderId} does not match source room ${SHARED_WITH_CLIENT_ROOM_ID}. Skipping.`
);
return res.status(200).json({ status: "ok" });
}

// Step 3: Copy file to internal room/folder
try {
console.log(`[INFO] Copying fileId=${fileId} -> destFolderId=${INTERNAL_PROCESSING_FOLDER_ID}`);
const data = await copyFileToInternalFolder(fileId, INTERNAL_PROCESSING_FOLDER_ID);

if (!data) {
console.error("[ERROR] Copy request failed.");
return res.status(500).send("Copy failed");
}

console.log("[INFO] Copy completed.");
return res.status(200).json({ status: "ok" });
} catch (e: any) {
console.error("[ERROR] Handler failed:", e?.message || e);
return res.status(500).send("Internal error");
}
});

app.listen(3000, () => {
console.log("Webhook server listening on http://localhost:3000/webhook");
});

Step 1: Receive and verify the webhook request

When a file is uploaded, DocSpace sends a notification to your webhook URL.

At this step, the backend:

  • accepts the incoming request from DocSpace,
  • checks that the request is authentic using the secret key configured in webhook settings,
  • reads the event data only after successful validation.

If the request cannot be validated, the server responds with 401 and stops processing it.

app.post("/webhook", async (req: any, res) => {
const rawBody: Buffer = req.rawBody ?? Buffer.from("");
const signatureHeader = req.header("x-docspace-signature-256");

// Verify webhook signature (optional)
if (!verifySignature(rawBody, signatureHeader)) {
console.warn("[WARN] Invalid webhook signature.");
return res.status(401).send("Invalid signature");
}

const payload = req.body;
if (!payload || typeof payload !== "object") {
return res.status(200).json({ status: "ok" });
}

// Further processing is described in the next steps
});

Step 2: Extract the uploaded file ID and validate the source room (optional)

From the webhook JSON, the script extracts:

  • fileId — the uploaded file ID
  • optional folderId/roomId — where the file was uploaded

If folderId is present, the script checks it equals SHARED_WITH_CLIENT_ROOM_ID.

const fileId = extractFileId(payload);
if (fileId === null) {
console.log("[INFO] No fileId in payload. Skipping.");
return res.status(200).json({ status: "ok" });
}

const sourceFolderId = extractSourceFolderId(payload);
if (
sourceFolderId !== null &&
sourceFolderId !== SHARED_WITH_CLIENT_ROOM_ID
) {
console.log(
`[INFO] Upload folder ${sourceFolderId} does not match source room ${SHARED_WITH_CLIENT_ROOM_ID}. Skipping.`
);
return res.status(200).json({ status: "ok" });
}

Step 3: Copy the file to the internal room

When fileId is known (and the source check passed), the script copies the file using PUT /api/2.0/files/fileops/copy with JSON body:

  • fileIds: [fileId]
  • destFolderId: INTERNAL_PROCESSING_FOLDER_ID
  • deleteAfter: false (copy, keep original)
  • content: true
  • toFillOut: false As a result, every client upload is duplicated into the internal processing area automatically.
async function copyFileToInternalFolder(fileId: number, destFolderId: number) {
const payload = {
fileIds: [fileId],
destFolderId,
deleteAfter: false, // keep originals (copy)
content: true,
toFillOut: false,
};

return docspaceRequest("/api/2.0/files/fileops/copy", "PUT", payload);
}

console.log(
`[INFO] Copying fileId=${fileId} -> destFolderId=${INTERNAL_PROCESSING_FOLDER_ID}`
);

const result = await copyFileToInternalFolder(
fileId,
INTERNAL_PROCESSING_FOLDER_ID
);

if (!result) {
console.error("[ERROR] Copy request failed.");
return res.status(500).send("Copy failed");
}

console.log("[INFO] Copy completed.");
return res.status(200).json({ status: "ok" });