Skip to main content

Monitor changes in critical folders and notify the department owner

This example shows how to monitor a critical folder (for example, Compliance) in ONLYOFFICE DocSpace and notify the department owner whenever a file in that folder is changed.

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 http from "http";
import crypto from "crypto";

// DocSpace configuration
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.

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

// Folder (or room root folder) that must be monitored
const COMPLIANCE_FOLDER_ID = 539564;

// Department owner (recipient)
const DEPARTMENT_OWNER_EMAIL = "compliance.owner@company.com";

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

function verifySignature(secretKey: string, rawBody: Buffer, signatureHeader?: string): boolean {
// If WEBHOOK_SECRET is empty, the signature check is skipped.
if (!secretKey) 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", secretKey).update(rawBody).digest("hex").toLowerCase();
if (received.length !== expected.length) return false;

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

async function docspaceRequest(path: string, method: string = "GET", jsonBody: any = null) {
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] DocSpace request error: ${e?.message || e}`);
return null;
}

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

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

function getResponseNode(data: any) {
if (data && typeof data === "object" && "response" in data) return data.response;
return data;
}

// Step 1: Extract fileId and userId from the webhook payload
function extractFileAndUser(payload: any): { fileId: number | null; userId: number | null } {
const entity = payload?.entity || payload?.data || {};
const trigger = payload?.trigger || {};

// Keep extraction tolerant: different integrations can format payload differently
const entityType = String(trigger?.entity || entity?.entityType || entity?.type || "").toLowerCase();
const action = String(trigger?.action || entity?.action || entity?.event || "").toLowerCase();

// Allow several “file changed” verbs
const allowedEntity = entityType === "file" || entityType === "document" || entityType === "";
const allowedAction = ["update", "change", "save", "edit", "modify"].some((x) => action.includes(x)) || action === "";

if (!allowedEntity || !allowedAction) return { fileId: null, userId: null };

const fileIdRaw = entity?.fileId ?? entity?.id ?? payload?.fileId;
const userIdRaw = entity?.userId ?? trigger?.userId ?? payload?.userId ?? null;

const fileId = fileIdRaw != null ? Number(fileIdRaw) : null;
const userId = userIdRaw != null ? Number(userIdRaw) : null;

return {
fileId: Number.isFinite(fileId as number) ? (fileId as number) : null,
userId: Number.isFinite(userId as number) ? (userId as number) : null,
};
}

// Step 2: Load file info and check if it belongs to COMPLIANCE_FOLDER_ID
async function getFileInfo(fileId: number): Promise<{
title: string;
folderId: number | null;
webUrl: string | null;
} | null> {
const data = await docspaceRequest(`/api/2.0/files/file/${fileId}`, "GET");
const file = getResponseNode(data);

if (!file || typeof file !== "object") return null;

const title = String(file.title || "file");
const folderId =
typeof file.folderId === "number"
? file.folderId
: typeof file.parentId === "number"
? file.parentId
: null;

// Prefer URL fields returned by API (if present)
const webUrl = (file.webUrl || file.shortWebUrl || file.viewUrl || null) as string | null;

return { title, folderId, webUrl };
}

async function getUserInfo(userId: number): Promise<{ name: string; email: string } | null> {
const data = await docspaceRequest(`/api/2.0/people/${userId}`, "GET");
const user = getResponseNode(data);

if (!user || typeof user !== "object") return null;

const first = String(user.firstName || user.firstname || "");
const last = String(user.lastName || user.lastname || "");
const email = String(user.email || "");

const name = `${first} ${last}`.trim() || String(user.displayName || email || "User");
return { name, email };
}

// Step 3: Send a notification (stub)
async function sendNotification(params: {
to: string;
fileId: number;
fileTitle: string;
fileUrl?: string | null;
changedBy?: string | null;
}) {
const { to, fileId, fileTitle, fileUrl, changedBy } = params;

console.log("--- COMPLIANCE NOTIFICATION ---");
console.log(`To: ${to}`);
console.log("Subject: File changed in Compliance folder");

const lines: string[] = [];
lines.push(`File: "${fileTitle}" (id=${fileId})`);
if (fileUrl) lines.push(`Link: ${fileUrl}`);
if (changedBy) lines.push(`Changed by: ${changedBy}`);
lines.push("Action: Please review the recent changes.");

console.log("Body:\n" + lines.join("\n"));

// Replace with SMTP / mail provider / Slack / Teams integration
}

// Step 4: Orchestrate webhook handling
async function handleWebhookPayload(payload: any) {
const { fileId, userId } = extractFileAndUser(payload);

if (fileId === null) {
console.log("[INFO] Webhook is not a file-change event, skipping.");
return;
}

const fileInfo = await getFileInfo(fileId);
if (!fileInfo) {
console.warn(`[WARN] Could not load file metadata for fileId=${fileId}`);
return;
}

if (fileInfo.folderId !== COMPLIANCE_FOLDER_ID) {
console.log(
`[INFO] File ${fileId} is not in Compliance folder (folderId=${fileInfo.folderId}), skipping.`
);
return;
}

let changedBy: string | null = null;
if (userId !== null) {
const user = await getUserInfo(userId);
if (user) {
changedBy = user.email ? `${user.name} <${user.email}>` : user.name;
}
}

await sendNotification({
to: DEPARTMENT_OWNER_EMAIL,
fileId,
fileTitle: fileInfo.title,
fileUrl: fileInfo.webUrl,
changedBy,
});
}

// Minimal HTTP webhook server
const PORT = 3000;

const server = http.createServer((req, res) => {
// DocSpace may send HEAD requests to validate the webhook URL
if (req.method === "HEAD" && req.url === "/docspace/webhook") {
res.statusCode = 200;
res.end();
return;
}

if (req.method !== "POST" || req.url !== "/docspace/webhook") {
res.statusCode = 404;
res.end("Not found");
return;
}

const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", async () => {
const rawBody = Buffer.concat(chunks);
const signatureHeader = req.headers["x-docspace-signature-256"] as string | undefined;

if (!verifySignature(WEBHOOK_SECRET, rawBody, signatureHeader)) {
console.warn("[WARN] Invalid webhook signature.");
res.statusCode = 401;
res.end("Invalid signature");
return;
}

let payload: any;
try {
payload = JSON.parse(rawBody.toString("utf8"));
} catch {
res.statusCode = 400;
res.end("Bad JSON");
return;
}

try {
await handleWebhookPayload(payload);
res.statusCode = 200;
res.end("OK");
} catch (e: any) {
console.error("[ERROR] Webhook handler error:", e?.message || e);
res.statusCode = 500;
res.end("Internal error");
}
});
});

server.listen(PORT, () => {
console.log(`Webhook listener: http://localhost:${PORT}/docspace/webhook`);
});

Step 1: Receive and verify the webhook request

When a file is changed, 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.

DocSpace may also send HEAD requests to check that the webhook URL is reachable, so the endpoint should return 200 OK for such requests.

function extractFileAndUser(payload: any): { fileId: number | null; userId: number | null } {
const entity = payload?.entity || payload?.data || {};
const trigger = payload?.trigger || {};

// Keep extraction tolerant: different integrations can format payload differently
const entityType = String(trigger?.entity || entity?.entityType || entity?.type || "").toLowerCase();
const action = String(trigger?.action || entity?.action || entity?.event || "").toLowerCase();

// Allow several “file changed” verbs
const allowedEntity = entityType === "file" || entityType === "document" || entityType === "";
const allowedAction = ["update", "change", "save", "edit", "modify"].some((x) => action.includes(x)) || action === "";

if (!allowedEntity || !allowedAction) return { fileId: null, userId: null };

const fileIdRaw = entity?.fileId ?? entity?.id ?? payload?.fileId;
const userIdRaw = entity?.userId ?? trigger?.userId ?? payload?.userId ?? null;

const fileId = fileIdRaw != null ? Number(fileIdRaw) : null;
const userId = userIdRaw != null ? Number(userIdRaw) : null;

return {
fileId: Number.isFinite(fileId as number) ? (fileId as number) : null,
userId: Number.isFinite(userId as number) ? (userId as number) : null,
};
}

Step 2: Load file metadata and check the monitored folder

For each accepted event, the script loads file metadata using GET /api/2.0/files/file/:fileId

It reads:

  • title: file name,
  • folderId: where the file is stored.

If folderId equals COMPLIANCE_FOLDER_ID, the change is considered relevant. Otherwise, the event is ignored. Once the template is selected, the integration starts room creation using POST In this example the minimal payload is { "templateId": <templateId>, "title": "<projectRoomTitle>" }

async function getFileInfo(fileId: number): Promise<{
title: string;
folderId: number | null;
webUrl: string | null;
} | null> {
const data = await docspaceRequest(`/api/2.0/files/file/${fileId}`, "GET");
const file = getResponseNode(data);

if (!file || typeof file !== "object") return null;

const title = String(file.title || "file");
const folderId =
typeof file.folderId === "number"
? file.folderId
: typeof file.parentId === "number"
? file.parentId
: null;

// Prefer URL fields returned by API (if present)
const webUrl = (file.webUrl || file.shortWebUrl || file.viewUrl || null) as string | null;

return { title, folderId, webUrl };
}

Step 3: Resolve the user who changed the file (optional)

If the payload contains userId, the script calls GET /api/2.0/people/:userId and resolves a friendly name/email to include in the notification.

async function sendNotification(params: {
to: string;
fileId: number;
fileTitle: string;
fileUrl?: string | null;
changedBy?: string | null;
}) {
const { to, fileId, fileTitle, fileUrl, changedBy } = params;

console.log("--- COMPLIANCE NOTIFICATION ---");
console.log(`To: ${to}`);
console.log("Subject: File changed in Compliance folder");

const lines: string[] = [];
lines.push(`File: "${fileTitle}" (id=${fileId})`);
if (fileUrl) lines.push(`Link: ${fileUrl}`);
if (changedBy) lines.push(`Changed by: ${changedBy}`);
lines.push("Action: Please review the recent changes.");

console.log("Body:\n" + lines.join("\n"));

// Replace with SMTP / mail provider / Slack / Teams integration
}

Step 4: Notify the department owner

Finally, the script sends a notification to DEPARTMENT_OWNER_EMAIL containing:

  • file title and file ID,
  • a web URL (if the API returns a usable webUrl/shortWebUrl),
  • changed-by user (if available). In the example, the notification is printed to the console as a stub. Replace it with SMTP/mail provider/chat notifications.
async function handleWebhookPayload(payload: any) {
const { fileId, userId } = extractFileAndUser(payload);

if (fileId === null) {
console.log("[INFO] Webhook is not a file-change event, skipping.");
return;
}

const fileInfo = await getFileInfo(fileId);
if (!fileInfo) {
console.warn(`[WARN] Could not load file metadata for fileId=${fileId}`);
return;
}

if (fileInfo.folderId !== COMPLIANCE_FOLDER_ID) {
console.log(
`[INFO] File ${fileId} is not in Compliance folder (folderId=${fileInfo.folderId}), skipping.`
);
return;
}

let changedBy: string | null = null;
if (userId !== null) {
const user = await getUserInfo(userId);
if (user) {
changedBy = user.email ? `${user.name} <${user.email}>` : user.name;
}
}

await sendNotification({
to: DEPARTMENT_OWNER_EMAIL,
fileId,
fileTitle: fileInfo.title,
fileUrl: fileInfo.webUrl,
changedBy,
});
}