Skip to main content

Archive all client files when a CRM deal is closed

This example shows how to automatically archive all documents for a client when their CRM deal is marked as Closed.

When your CRM sets the deal status to Closed, it calls your backend (webhook handler) with:

  • CLIENT_ROOM_ID — the client room ID in DocSpace (it is also a folder ID in the Files API),
  • ARCHIVE_ROOT_FOLDER_ID — the ID of the /Archive folder (parent for /Archive/<ClientName>),
  • CLIENT_NAME — client name to use as the archive folder title,
  • optional GUEST_USER_ID — a DocSpace guest user ID to delete (to revoke access).

The script then:

  • collects all files inside the client room (including subfolders),
  • creates (or reuses) /Archive/<ClientName> under /Archive,
  • moves all collected files into /Archive/<ClientName>,
  • optionally deletes the guest user.

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.

Concurrency and locking

CRMs often retry webhooks and may deliver the same "deal closed" event more than once. If your backend handles those requests in parallel, two archiving runs can overlap and cause race conditions (for example: moving the same files twice, creating the same archive folder concurrently, or deleting the guest user too early).

In production, use a distributed lock (for example, Redis/DB) keyed by CLIENT_ROOM_ID (or by deal ID) and make the workflow idempotent. The code below includes an in-process lock to illustrate the pattern, but it only protects a single backend process.

Full example
// 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.

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

// Simple in-process lock keyed by clientRoomId.
// For multi-instance deployments, replace it with a distributed lock (Redis/DB).
const ROOM_LOCKS = new Map<number, number>(); // roomId -> expiresAtMs

function acquireRoomLock(roomId: number, ttlMs: number = 10 * 60 * 1000) {
const now = Date.now();
const expiresAt = ROOM_LOCKS.get(roomId);
if (expiresAt != null && expiresAt > now) return false;
ROOM_LOCKS.set(roomId, now + ttlMs);
return true;
}

function releaseRoomLock(roomId: number) {
ROOM_LOCKS.delete(roomId);
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// Basic DocSpace request helper
async function docspaceRequest(path: string, method: string = "GET", body: any = null) {
const url = `${API_HOST}${path}`;

try {
const res = await fetch(url, {
method,
headers: HEADERS,
body: body ? JSON.stringify(body) : undefined,
});

if (!res.ok) {
const text = await res.text();
console.log(`DocSpace request failed: ${method} ${url}`);
console.log(`Status: ${res.status}, Message: ${text}`);
return null;
}

return res.json();
} catch (err: any) {
console.log(`DocSpace request error: ${err?.message || err}`);
return null;
}
}

// Step 1: Collect all file IDs in a room (recursive)
async function listFolderContents(folderId: number) {
const data = await docspaceRequest(`/api/2.0/files/${folderId}`, "GET");
const resp = data && typeof data === "object" ? (data.response ?? null) : null;

if (!resp) return { files: [], folders: [] };

// Common response format: { files: [...], folders: [...] }
if (resp && typeof resp === "object" && !Array.isArray(resp)) {
const files = Array.isArray((resp as any).files) ? (resp as any).files : [];
const folders = Array.isArray((resp as any).folders) ? (resp as any).folders : [];
return { files, folders };
}

// Fallback: sometimes it can be a flat array of items
if (Array.isArray(resp)) {
const files = resp.filter((it: any) => it && typeof it === "object" && !(it.isFolder || it.folder));
const folders = resp.filter((it: any) => it && typeof it === "object" && (it.isFolder || it.folder));
return { files, folders };
}

return { files: [], folders: [] };
}

async function collectAllFileIds(startFolderId: number) {
const result: number[] = [];

async function walk(folderId: number) {
const { files, folders } = await listFolderContents(folderId);

for (const f of files) {
const id = Number((f as any).id);
if (Number.isFinite(id)) result.push(id);
}

for (const sub of folders) {
const id = Number((sub as any).id);
if (!Number.isFinite(id)) continue;
await walk(id);
}
}

await walk(startFolderId);
return result;
}

// Step 2: Ensure /Archive/ClientName exists (reuse if already created)
async function findChildFolderByTitle(parentFolderId: number, title: string) {
const { folders } = await listFolderContents(parentFolderId);
const target = String(title || "").trim();

for (const f of folders) {
const t = String((f as any).title || "");
if (t === target) {
const id = Number((f as any).id);
return Number.isFinite(id) ? id : null;
}
}

return null;
}

async function createFolder(parentFolderId: number, title: string) {
const payload = { title };
const data = await docspaceRequest(`/api/2.0/files/folder/${parentFolderId}`, "POST", payload);
const node = data && typeof data === "object" ? (data.response ?? data) : null;
const idRaw = node?.id ?? node?.folderId ?? null;
const id = Number(idRaw);
return Number.isFinite(id) ? id : null;
}

async function ensureArchiveFolder(archiveRootFolderId: number, clientName: string) {
const existing = await findChildFolderByTitle(archiveRootFolderId, clientName);
if (existing != null) return existing;

const created = await createFolder(archiveRootFolderId, clientName);
return created;
}

// Step 3: Move files in chunks into /Archive/ClientName
async function moveFilesChunk(fileIds: number[], destFolderId: number) {
const payload = {
fileIds,
destFolderId,
deleteAfter: true,
content: true,
toFillOut: false,
};

const data = await docspaceRequest("/api/2.0/files/fileops/move", "PUT", payload);
return Boolean(data);
}

function chunkArray<T>(arr: T[], chunkSize: number) {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += chunkSize) out.push(arr.slice(i, i + chunkSize));
return out;
}

async function moveAllFilesToArchive(fileIds: number[], archiveFolderId: number) {
// Keep chunks small to avoid request size limits.
const chunks = chunkArray(fileIds, 100);

const CHUNK_DELAY_MS = 500;
const CHUNK_DELAY_JITTER_MS = 250;

for (const c of chunks) {
const ok = await moveFilesChunk(c, archiveFolderId);
if (!ok) {
console.log(`Failed to move a chunk of ${c.length} file(s).`);
return false;
}
// Small delay to be gentle on rate limits and reduce API contention.
await sleep(CHUNK_DELAY_MS + Math.floor(Math.random() * CHUNK_DELAY_JITTER_MS));
}

return true;
}

// Step 4 (optional): Delete guest user to revoke access
async function deleteGuestUser(guestUserId: number) {
const payload = {
userIds: [guestUserId],
resendAll: false,
};

// Some setups accept DELETE with JSON body.
const data = await docspaceRequest("/api/2.0/people/guests", "DELETE", payload);
return Boolean(data);
}

// Orchestrator called from CRM webhook handler
export async function archiveClientRoomOnDealClosed(args: {
clientRoomId: number;
archiveRootFolderId: number;
clientName: string;
guestUserId?: number | null;
}) {
const { clientRoomId, archiveRootFolderId, clientName, guestUserId } = args;

if (!acquireRoomLock(clientRoomId)) {
console.warn(`[WARN] Archive job is already running for roomId=${clientRoomId}. Skipping duplicate request.`);
return;
}

try {
console.log(`Deal closed. Archiving room ${clientRoomId} for client "${clientName}".`);

// Step 1: Collect all file IDs in the client room (recursive)
const fileIds = await collectAllFileIds(clientRoomId);
console.log(`Found ${fileIds.length} file(s) in client room (including subfolders).`);

if (!fileIds.length) {
console.log("Nothing to move. Skipping archive move.");
}

// Step 2: Ensure the archive folder exists
const archiveFolderId = await ensureArchiveFolder(archiveRootFolderId, clientName);
if (archiveFolderId == null) {
console.log("Failed to create or find the archive folder. Aborting.");
return;
}
console.log(`Archive folder ready: /Archive/${clientName} (id=${archiveFolderId}).`);

// Step 3: Move files into the archive folder
if (fileIds.length) {
const moved = await moveAllFilesToArchive(fileIds, archiveFolderId);
if (!moved) {
console.log("Move failed. Aborting before optional guest deletion.");
return;
}
console.log("All client files were moved to the archive folder.");
}

// Step 4 (optional)
if (guestUserId != null) {
const ok = await deleteGuestUser(Number(guestUserId));
if (ok) {
console.log(`Guest user deleted: userId=${guestUserId}`);
} else {
console.log(`Failed to delete guest user: userId=${guestUserId}`);
}
}
} finally {
releaseRoomLock(clientRoomId);
}
}

// Example local run (replace values with your CRM payload)
(async () => {
await archiveClientRoomOnDealClosed({
clientRoomId: 539564,
archiveRootFolderId: 748239,
clientName: "Contoso Ltd",
guestUserId: 412497, // optional
});
})();

Step 1: Collect all client files from the room

Your CRM provides CLIENT_ROOM_ID. The script treats it as a folder ID and recursively walks through the room:

  • It reads the room contents using GET /api/2.0/files/:folderId.
  • It repeats the same request for every subfolder.
  • It builds a flat list of file IDs found across the entire room tree.
async function listFolderContents(folderId: number) {
const data = await docspaceRequest(`/api/2.0/files/${folderId}`, "GET");
const resp = data && typeof data === "object" ? (data.response ?? null) : null;

if (!resp) return { files: [], folders: [] };

// Common response format: { files: [...], folders: [...] }
if (resp && typeof resp === "object" && !Array.isArray(resp)) {
const files = Array.isArray((resp as any).files) ? (resp as any).files : [];
const folders = Array.isArray((resp as any).folders) ? (resp as any).folders : [];
return { files, folders };
}

// Fallback: sometimes it can be a flat array of items
if (Array.isArray(resp)) {
const files = resp.filter((it: any) => it && typeof it === "object" && !(it.isFolder || it.folder));
const folders = resp.filter((it: any) => it && typeof it === "object" && (it.isFolder || it.folder));
return { files, folders };
}

return { files: [], folders: [] };
}

Step 2: Ensure /Archive/<ClientName> exists

Under your archive root folder (for example, /Archive with ID ARCHIVE_ROOT_FOLDER_ID), the script:

async function findChildFolderByTitle(parentFolderId: number, title: string) {
const { folders } = await listFolderContents(parentFolderId);
const target = String(title || "").trim();

for (const f of folders) {
const t = String((f as any).title || "");
if (t === target) {
const id = Number((f as any).id);
return Number.isFinite(id) ? id : null;
}
}

return null;
}

Step 3: Move files to the archive folder

It sends one or more PUT /api/2.0/files/fileops/move requests (in chunks).

Each request uses body:

  • fileIds: a chunk of file IDs,
  • destFolderId: the ID of /Archive/<ClientName>,
  • deleteAfter: true to remove files from the original room,
  • content: true, toFillOut: false.

As a result, all documents from the client room are relocated into the archive folder.

async function moveFilesChunk(fileIds: number[], destFolderId: number) {
const payload = {
fileIds,
destFolderId,
deleteAfter: true,
content: true,
toFillOut: false,
};

const data = await docspaceRequest("/api/2.0/files/fileops/move", "PUT", payload);
return Boolean(data);
}

Step 4: Disable client access (optional)

If the client is a guest user and you want to revoke access after deal closure, the script:

  • receives GUEST_USER_ID,
  • deletes the guest user using DELETE /api/2.0/people/guests with body { "userIds": [<guestUserId>], "resendAll": false }.

After that, the guest can no longer access the portal or shared rooms.

async function deleteGuestUser(guestUserId: number) {
const payload = {
userIds: [guestUserId],
resendAll: false,
};

// Some setups accept DELETE with JSON body.
const data = await docspaceRequest("/api/2.0/people/guests", "DELETE", payload);
return Boolean(data);
}