Copying uploads to 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
- Replace
https://yourportal.onlyoffice.comandYOUR_API_KEYwith your actual ONLYOFFICE Apps portal URL and API key. Ensure you have the necessary data and permissions to perform these operations. - 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.
- 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 ONLYOFFICE Apps 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. ONLYOFFICE Apps sends the signature in thex-docspace-signature-256header so you can validate the request against the raw body.
Timeouts and retries
ONLYOFFICE Apps retries failed webhook deliveries. According to the webhook docs:
- Up to 5 attempts are made, with exponential backoff (
2^attemptseconds). - 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
- Node.js
- Python
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) => {
// ONLYOFFICE Apps (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");
});
import os
import hmac
import hashlib
import requests
from flask import Flask, request, jsonify
# Config
API_HOST = os.environ.get("DOCSPACE_API_HOST") # Set DOCSPACE_API_HOST in env (recommended). For quick tests you can temporarily paste your portal URL here.
API_KEY = os.environ.get("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
WEBHOOK_SECRET = os.environ.get("DOCSPACE_WEBHOOK_SECRET") # Optional. If empty, the signature check is skipped (dev only).
SHARED_WITH_CLIENT_ROOM_ID = 539564 # replace
INTERNAL_PROCESSING_FOLDER_ID = 341029 # replace
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
}
app = Flask(__name__)
# Helpers
def verify_signature(secret_key, raw_body, signature_header):
# If WEBHOOK_SECRET is empty, skip verification
if not secret_key:
return True
if not signature_header or not signature_header.startswith("sha256="):
return False
received = signature_header.split("=", 1)[1].strip().lower()
expected = hmac.new(secret_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest().lower()
return hmac.compare_digest(received, expected)
def extract_file_id(payload):
data = payload.get("data") or payload.get("entity") or payload
raw = data.get("fileId") or data.get("id") or payload.get("fileId")
try:
return int(raw)
except Exception:
return None
def extract_source_folder_id(payload):
data = payload.get("data") or payload.get("entity") or payload
raw = data.get("folderId") or data.get("roomId") or data.get("parentId") or payload.get("folderId")
try:
return int(raw)
except Exception:
return None
def docspace_request(method, path, *, json_body = None):
url = f"{API_HOST}{path}"
try:
r = requests.request(method.upper(), url, headers=HEADERS, json=json_body, timeout=30)
except Exception as e:
print(f"[ERROR] Request to {url} failed: {e}")
return None
if not (200 <= r.status_code < 300):
print(f"[ERROR] {method} {url} failed. Status: {r.status_code}, Message: {r.text}")
return None
try:
return r.json()
except Exception:
return None
# Copy helper
def copy_file_to_internal_folder(file_id, dest_folder_id):
payload = {
"fileIds": [file_id],
"destFolderId": dest_folder_id,
"deleteAfter": False, # keep originals (copy)
"content": True,
"toFillOut": False,
}
data = docspace_request("PUT", "/api/2.0/files/fileops/copy", json_body=payload)
return data is not None
# Webhook endpoint
@app.route("/webhook", methods=["POST", "HEAD"])
def webhook():
if request.method == "HEAD":
# ONLYOFFICE Apps (or your reverse proxy) may validate the URL with HEAD
return "", 200
raw_body = request.data
signature_header = request.headers.get("x-docspace-signature-256")
# Step 1: Validate webhook signature (optional)
if not verify_signature(WEBHOOK_SECRET, raw_body, signature_header):
print("[WARN] Invalid webhook signature.")
return "Invalid signature", 401
payload = request.get_json(silent=True)
if not isinstance(payload, dict) or not payload:
return jsonify({"status": "ok"}), 200
# Step 2: Extract fileId (+ optional source folder check)
file_id = extract_file_id(payload)
if file_id is None:
print("[INFO] No fileId in payload. Skipping.")
return jsonify({"status": "ok"}), 200
source_folder_id = extract_source_folder_id(payload)
if source_folder_id is not None and source_folder_id != SHARED_WITH_CLIENT_ROOM_ID:
print(
f"[INFO] Upload folder {source_folder_id} does not match source room {SHARED_WITH_CLIENT_ROOM_ID}. Skipping."
)
return jsonify({"status": "ok"}), 200
# Step 3: Copy file to internal room/folder
print(f"[INFO] Copying fileId={file_id} -> destFolderId={INTERNAL_PROCESSING_FOLDER_ID}")
ok = copy_file_to_internal_folder(file_id, INTERNAL_PROCESSING_FOLDER_ID)
if not ok:
return "Copy failed", 500
print("[INFO] Copy completed.")
return jsonify({"status": "ok"}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=3000)
Step 1: Receive and verify the webhook request
When a file is uploaded, ONLYOFFICE Apps sends a notification to your webhook URL.
At this step, the backend:
- accepts the incoming request from ONLYOFFICE Apps,
- 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.
- Node.js
- Python
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
});
@app.route("/webhook", methods=["POST", "HEAD"])
def webhook():
if request.method == "HEAD":
return "", 200
raw_body = request.data
signature_header = request.headers.get("x-docspace-signature-256")
# Verify webhook signature (optional)
if not verify_signature(WEBHOOK_SECRET, raw_body, signature_header):
print("[WARN] Invalid webhook signature.")
return "Invalid signature", 401
payload = request.get_json(silent=True)
if not isinstance(payload, dict) or not payload:
return jsonify({"status": "ok"}), 200
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.
- Node.js
- Python
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" });
}
file_id = extract_file_id(payload)
if file_id is None:
print("[INFO] No fileId in payload. Skipping.")
return jsonify({"status": "ok"}), 200
source_folder_id = extract_source_folder_id(payload)
if (
source_folder_id is not None and
source_folder_id != SHARED_WITH_CLIENT_ROOM_ID
):
print(
f"[INFO] Upload folder {source_folder_id} does not match source room {SHARED_WITH_CLIENT_ROOM_ID}. Skipping."
)
return jsonify({"status": "ok"}), 200
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_IDdeleteAfter: false (copy, keep original)content: truetoFillOut: false As a result, every client upload is duplicated into the internal processing area automatically.
- Node.js
- Python
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" });
def copy_file_to_internal_folder(file_id, dest_folder_id):
payload = {
"fileIds": [file_id],
"destFolderId": dest_folder_id,
"deleteAfter": False, # keep originals (copy)
"content": True,
"toFillOut": False,
}
data = docspace_request("PUT", "/api/2.0/files/fileops/copy", json_body=payload)
return data is not None
print(
f"[INFO] Copying fileId={file_id} -> destFolderId={INTERNAL_PROCESSING_FOLDER_ID}"
)
ok = copy_file_to_internal_folder(file_id, INTERNAL_PROCESSING_FOLDER_ID)
if not ok:
return "Copy failed", 500
print("[INFO] Copy completed.")
return jsonify({"status": "ok"}), 200