Skip to main content

Automatically configure room structure and permissions after room creation

This example demonstrates webhook-based room provisioning in ONLYOFFICE DocSpace.

When a room is created, DocSpace sends a webhook to your backend. The backend script:

  • verifies the webhook signature,
  • checks that the event is a room creation event (event.trigger),
  • extracts the new room ID from payload,
  • creates a default folder structure inside the room,
  • applies room sharing rules (ACL) to automatically grant access to users and groups.

This pattern is useful for standardized workspaces (clients, departments, projects).

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";

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 WEBHOOK_SECRET = process.env.DOCSPACE_WEBHOOK_SECRET; // Optional. If empty, the signature check is skipped (dev only).

const DEFAULT_FOLDERS = ["Documents", "Tasks", "Archive"];

const ROLE_TO_ACCESS = {
Manager: 4,
Editor: 3,
Viewer: 1,
};

const USER_RULES = [
{ userId: "USER_ID_1", role: "Manager" },
{ userId: "USER_ID_2", role: "Editor" },
{ userId: "USER_ID_3", role: "Viewer" },
];

const HR_GROUP_ID = "GROUP_ID_HR";

const ALLOWED_TRIGGERS = new Set(["room.created"]);

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

// Step 1: Validate and accept the webhook request
function verifySignature(rawBody, signatureHeader) {
// 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));
}

async function docspaceRequest(path, method = "GET", body = 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().catch(() => "");
console.error(`[ERROR] API error: ${method} ${url} -> ${res.status} ${text}`);
return null;
}

return await res.json();
} catch (err) {
console.error(`[ERROR] Request failed: ${method} ${url} -> ${err?.message || err}`);
return null;
}
}

function getResponseNode(data) {
if (!data || typeof data !== "object") return null;
return data.response != null ? data.response : data;
}

function extractTriggerAndPayload(body) {
const trigger = String(body?.event?.trigger || "").trim();
const payload = body?.payload && typeof body.payload === "object" ? body.payload : null;
return { trigger, payload };
}

// Step 2: Identify the target room container
function extractRoomId(payload) {
const n = Number(payload?.id ?? payload?.roomId ?? null);
return Number.isFinite(n) ? n : null;
}

// Step 3: Create a default workspace structure
async function createFolder(parentFolderId, title) {
const payload = { title };
const data = await docspaceRequest(`/api/2.0/files/folder/${parentFolderId}`, "POST", payload);
const node = getResponseNode(data);

if (!node || typeof node !== "object") {
console.error(`[ERROR] Failed to create folder "${title}" under parentFolderId=${parentFolderId}.`);
return null;
}

const idRaw = node.id ?? node.folderId ?? null;
const folderId = Number(idRaw);

if (!Number.isFinite(folderId)) {
console.warn(`[WARN] Folder created but id not detected for "${title}".`);
return null;
}

console.log(`[INFO] Folder created: "${title}" (id=${folderId})`);
return folderId;
}

async function createDefaultFolders(roomId) {
for (const name of DEFAULT_FOLDERS) {
const createdId = await createFolder(roomId, name);
if (createdId == null) return false;
}
return true;
}

// Step 4: Build sharing rules for users and groups
function buildAclEntries() {
const entries = [];

for (const rule of USER_RULES) {
const userId = String(rule.userId || "").trim();
const role = String(rule.role || "").trim();
if (!userId) continue;

const access = ROLE_TO_ACCESS[role];
if (access == null) {
console.warn(`[WARN] Unknown role "${role}" for userId=${userId}. Skipping.`);
continue;
}

entries.push({ id: userId, isGroup: false, access });
}

if (HR_GROUP_ID) {
entries.push({ id: String(HR_GROUP_ID), isGroup: true, access: 1 });
}

return entries;
}

// Step 5: Apply sharing rules to the room
async function applyAcl(roomId) {
const entries = buildAclEntries();
if (!entries.length) {
console.warn("[WARN] No ACL entries to apply.");
return false;
}

const payload = { entries };
const data = await docspaceRequest(`/api/2.0/files/rooms/${roomId}/share`, "PUT", payload);

if (!data) {
console.error(`[ERROR] Failed to apply ACL for roomId=${roomId}.`);
return false;
}

console.log(`[INFO] ACL applied for roomId=${roomId}.`);
return true;
}

async function handleRoomCreated(payload) {
const roomId = extractRoomId(payload);
if (!roomId) {
console.warn("[WARN] Missing roomId in payload.");
return;
}

console.log(`[INFO] Room created: id=${roomId}`);

const foldersOk = await createDefaultFolders(roomId);
if (!foldersOk) {
throw new Error(`Failed to create one or more default folders. roomId=${roomId}`);
}

const aclOk = await applyAcl(roomId);
if (!aclOk) {
throw new Error(`Failed to apply ACL. roomId=${roomId}`);
}

console.log("[INFO] Provisioning completed.");
}

const app = express();

app.use(
express.json({
limit: "2mb",
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);

app.head("/docspace/webhook", (_req, res) => res.status(200).send(""));
app.get("/docspace/webhook", (_req, res) => res.status(200).json({ status: "ok" }));

app.post("/docspace/webhook", async (req, res) => {
const signature = req.headers["x-docspace-signature-256"];

if (!req.rawBody) {
console.warn("[WARN] Missing raw body buffer. Cannot verify signature.");
return res.status(400).send("Missing raw body");
}

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

const { trigger, payload } = extractTriggerAndPayload(req.body);

if (!payload) {
console.log("[INFO] No payload object. Skipping.");
return res.status(200).json({ status: "ok" });
}

if (!ALLOWED_TRIGGERS.has(trigger)) {
console.log(`[INFO] Trigger '${trigger}' is not handled. Skipping.`);
return res.status(200).json({ status: "ok" });
}

try {
await handleRoomCreated(payload);
} catch (err) {
console.log(err?.message || err);
return res.status(500).send("Internal error");
}

return res.status(200).json({ status: "ok" });
});

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

Step 1: Validate and accept the webhook request

When a new room is created, DocSpace sends a notification to your webhook URL.

At this step, the script:

  • receives the incoming request from DocSpace,
  • checks that the request really comes from DocSpace (using the secret key you configured),
  • reads the event type from event.trigger,
  • ignores events that are not relevant for this scenario.

If the request does not pass validation, the script stops processing it. If the event type is not supported, the script simply returns 200 OK and does nothing.

function verifySignature(rawBody, signatureHeader) {
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));
}

Step 2: Identify the target room container

The script extracts the room container identifier from payload.id (fallback: payload.roomId). This ID is then used as the target room for all provisioning actions in this example.

function extractRoomId(payload) {
const n = Number(payload?.id ?? payload?.roomId ?? null);
return Number.isFinite(n) ? n : null;
}

Step 3: Create a default workspace structure

To keep all workspaces consistent, the script creates folders defined in DEFAULT_FOLDERS. Each folder is created inside the room container using a folder creation request.

async function createFolder(parentFolderId, title) {
const payload = { title };
const data = await docspaceRequest(`/api/2.0/files/folder/${parentFolderId}`, "POST", payload);
const node = getResponseNode(data);

if (!node || typeof node !== "object") {
console.error(`[ERROR] Failed to create folder "${title}" under parentFolderId=${parentFolderId}.`);
return null;
}

const idRaw = node.id ?? node.folderId ?? null;
const folderId = Number(idRaw);

if (!Number.isFinite(folderId)) {
console.warn(`[WARN] Folder created but id not detected for "${title}".`);
return null;
}

console.log(`[INFO] Folder created: "${title}" (id=${folderId})`);
return folderId;
}

Step 4: Build sharing rules for users and groups

The script converts your internal roles into DocSpace access codes using ROLE_TO_ACCESS, then builds a list of share entries:

  • one entry per user in USER_RULES
  • an optional read-only entry for HR_GROUP_ID
function buildAclEntries() {
const entries = [];

for (const rule of USER_RULES) {
const userId = String(rule.userId || "").trim();
const role = String(rule.role || "").trim();
if (!userId) continue;

const access = ROLE_TO_ACCESS[role];
if (access == null) {
console.warn(`[WARN] Unknown role "${role}" for userId=${userId}. Skipping.`);
continue;
}

entries.push({ id: userId, isGroup: false, access });
}

if (HR_GROUP_ID) {
entries.push({ id: String(HR_GROUP_ID), isGroup: true, access: 1 });
}

return entries;
}

Step 5: Apply sharing rules to the room

All prepared share entries are applied in a single room sharing request.

As a result, right after the room is created:

  • the folder structure is ready
  • required users and groups immediately get access
async function applyAcl(roomId) {
const entries = buildAclEntries();
if (!entries.length) {
console.warn("[WARN] No ACL entries to apply.");
return false;
}

const payload = { entries };
const data = await docspaceRequest(`/api/2.0/files/rooms/${roomId}/share`, "PUT", payload);

if (!data) {
console.error(`[ERROR] Failed to apply ACL for roomId=${roomId}.`);
return false;
}

console.log(`[INFO] ACL applied for roomId=${roomId}.`);
return true;
}