Notify manager when a client uploads an invoice
This example shows how to use ONLYOFFICE DocSpace webhooks to react when a client uploads an invoice to their room.
When DocSpace sends a webhook to your backend, the receiver:
- verifies the webhook signature,
- reads the event trigger (
event.trigger) and file data (payload), - accepts only upload-like triggers (
file.uploaded/file.created), - checks that the file belongs to the target client room (by
payload.rootId), - checks that the file looks like an invoice (by file name),
- moves the file to the
Incomingfolder, - notifies the account manager (placeholder),
- optionally triggers an external processing pipeline (placeholder).
Before you start
- Replace
https://yourportal.onlyoffice.comandYOUR_API_KEYwith your actual DocSpace 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 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 thex-docspace-signature-256header 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^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.
const WEBHOOK_SECRET = process.env.DOCSPACE_WEBHOOK_SECRET; // Optional. If empty, the signature check is skipped (dev only).
const CLIENT_ROOT_ID = 86193;
const INCOMING_FOLDER_ID = 748239;
const MANAGER_EMAIL = 'manager@example.com';
const ALLOWED_TRIGGERS = new Set(['file.uploaded', 'file.created']);
const HEADERS = {
Accept: 'application/json',
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
};
// Step 1: Verify webhook signature
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));
}
// Step 2: Check upload trigger and extract file data
function extractUploadEvent(body) {
const trigger = String(body?.event?.trigger || '').trim();
const payload = body?.payload && typeof body.payload === 'object' ? body.payload : null;
if (!payload) return { ok: false, reason: 'No payload object.' };
if (!ALLOWED_TRIGGERS.has(trigger)) return { ok: false, reason: `Trigger '${trigger}' is not handled.` };
const fileId = Number(payload?.id ?? null);
if (!Number.isFinite(fileId)) return { ok: false, reason: 'No valid file ID in payload.id.' };
const rootIdRaw = payload?.rootId ?? null;
const rootId = rootIdRaw === null ? null : Number(rootIdRaw);
if (rootIdRaw !== null && !Number.isFinite(rootId)) return { ok: false, reason: 'Invalid payload.rootId.' };
return { ok: true, trigger, payload, fileId, rootId };
}
// Step 3: Check that the file belongs to the target client room
function isTargetClientRoom(rootId) {
if (rootId == null) return false;
return String(rootId) === String(CLIENT_ROOT_ID);
}
// Step 4: Check that the file looks like an invoice
function isInvoiceTitle(title) {
return String(title || '').trim().toLowerCase().startsWith('invoice');
}
async function getFileTitle(fileId) {
const res = await fetch(`${API_HOST}/api/2.0/files/file/${fileId}`, {
method: 'GET',
headers: HEADERS,
});
if (!res.ok) {
const text = await res.text();
console.log(`Failed to load file info: ${res.status} - ${text}`);
return null;
}
const data = await res.json();
const file = data?.response ?? {};
const title = String(file?.title || '').trim();
return title || null;
}
// Step 5: Move invoice to Incoming and notify manager
async function moveFileToIncoming(fileId) {
const payload = {
fileIds: [fileId],
destFolderId: INCOMING_FOLDER_ID,
deleteAfter: true,
content: true,
toFillOut: false,
};
const res = await fetch(`${API_HOST}/api/2.0/files/fileops/move`, {
method: 'PUT',
headers: HEADERS,
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text();
console.log(`Failed to move file: ${res.status} - ${text}`);
return false;
}
return true;
}
function notifyManager(fileId, title) {
console.log('[MANAGER NOTIFICATION]');
console.log(`To: ${MANAGER_EMAIL}`);
console.log(`Invoice uploaded: ${title} (fileId=${fileId})`);
}
// Webhook endpoint
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 (!verifySignature(req.rawBody, signature)) {
console.log('Invalid webhook signature.');
return res.status(401).send('Unauthorized');
}
const evt = extractUploadEvent(req.body);
if (!evt.ok) {
console.log(evt.reason);
return res.status(200).json({ status: 'ok' });
}
if (!isTargetClientRoom(evt.rootId)) {
console.log('File is not in the target client room/root. Skipping.');
return res.status(200).json({ status: 'ok' });
}
const titleFromApi = await getFileTitle(evt.fileId);
const title = titleFromApi || String(evt.payload?.title || 'Untitled');
if (!isInvoiceTitle(title)) {
console.log('File title does not look like an invoice. Skipping.');
return res.status(200).json({ status: 'ok' });
}
const moved = await moveFileToIncoming(evt.fileId);
if (!moved) {
return res.status(200).json({ status: 'ok' });
}
notifyManager(evt.fileId, title);
return res.status(200).json({ status: 'ok' });
});
app.listen(3000, () => {
console.log('DocSpace webhook listener: http://localhost:3000/docspace/webhook');
});
from flask import Flask, request, jsonify
import hmac
import hashlib
import requests
import os
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.
WEBHOOK_SECRET = os.environ.get("DOCSPACE_WEBHOOK_SECRET") # Optional. If empty, the signature check is skipped (dev only).
CLIENT_ROOT_ID = 86193
INCOMING_FOLDER_ID = 748239
MANAGER_EMAIL = 'manager@example.com'
ALLOWED_TRIGGERS = {'file.uploaded', 'file.created'}
HEADERS = {
'Authorization': f'Bearer {API_KEY}',
'Accept': 'application/json',
'Content-Type': 'application/json',
}
app = Flask(__name__)
# Step 1: Verify webhook signature
def verify_signature(raw_body, signature_header):
# If WEBHOOK_SECRET is empty, skip verification
if not WEBHOOK_SECRET:
return True
if not signature_header or not signature_header.startswith('sha256='):
return False
parts = signature_header.split('=', 1)
if len(parts) < 2 or not parts[1]:
return False
received = parts[1].strip().lower()
expected = hmac.new(WEBHOOK_SECRET.encode('utf-8'), raw_body, hashlib.sha256).hexdigest().lower()
return hmac.compare_digest(received, expected)
# Step 2: Check upload trigger and extract file data
def extract_upload_event(body):
trigger = str(((body.get('event') or {}).get('trigger') or '')).strip()
payload = body.get('payload') if isinstance(body.get('payload'), dict) else None
if not payload:
return False, 'No payload object.', None, None, None
if trigger not in ALLOWED_TRIGGERS:
return False, f"Trigger '{trigger}' is not handled.", payload, None, None
file_id_raw = payload.get('id')
try:
file_id = int(file_id_raw) if file_id_raw is not None else None
except Exception:
file_id = None
if file_id is None:
return False, 'No valid file ID in payload.id.', payload, None, None
root_id_raw = payload.get('rootId')
root_id = None
if root_id_raw is not None:
try:
root_id = int(root_id_raw)
except Exception:
return False, 'Invalid payload.rootId.', payload, file_id, None
return True, trigger, payload, file_id, root_id
# Step 3: Check that the file belongs to the target client room
def is_target_client_room(root_id):
if root_id is None:
return False
return str(root_id) == str(CLIENT_ROOT_ID)
# Step 4: Check that the file looks like an invoice
def is_invoice_title(title):
return str(title or '').strip().lower().startswith('invoice')
def get_file_title(file_id):
r = requests.get(f'{API_HOST}/api/2.0/files/file/{file_id}', headers=HEADERS, timeout=30)
if not (200 <= r.status_code < 300):
print(f'Failed to load file info. Status code: {r.status_code}, Message: {r.text}')
return None
data = r.json() or {}
file = data.get('response') or {}
title = str(file.get('title') or '').strip()
return title if title else None
# Step 5: Move invoice to Incoming and notify manager
def move_file_to_incoming(file_id):
payload = {
'fileIds': [file_id],
'destFolderId': INCOMING_FOLDER_ID,
'deleteAfter': True,
'content': True,
'toFillOut': False,
}
r = requests.put(f'{API_HOST}/api/2.0/files/fileops/move', headers=HEADERS, json=payload, timeout=30)
if not (200 <= r.status_code < 300):
print(f'Failed to move file. Status code: {r.status_code}, Message: {r.text}')
return False
return True
def notify_manager(file_id, title):
print('[MANAGER NOTIFICATION]')
print(f'To: {MANAGER_EMAIL}')
print(f'Invoice uploaded: {title} (fileId={file_id})')
@app.route('/docspace/webhook', methods=['POST', 'HEAD', 'GET'])
def docspace_webhook():
if request.method == 'HEAD':
return '', 200
if request.method == 'GET':
return jsonify({'status': 'ok'}), 200
raw_body = request.data
signature = request.headers.get('x-docspace-signature-256')
if not verify_signature(raw_body, signature):
print('Invalid webhook signature.')
return 'Unauthorized', 401
body = request.get_json(silent=True) or {}
if not isinstance(body, dict) or not body:
return jsonify({'status': 'ok'}), 200
ok, _trigger, payload, file_id, root_id = extract_upload_event(body)
if not ok or payload is None or file_id is None:
return jsonify({'status': 'ok'}), 200
if not is_target_client_room(root_id):
print('File is not in the target client room/root. Skipping.')
return jsonify({'status': 'ok'}), 200
title_from_api = get_file_title(file_id)
title = title_from_api or str(payload.get('title') or 'Untitled')
if not is_invoice_title(title):
print('File title does not look like an invoice. Skipping.')
return jsonify({'status': 'ok'}), 200
if not move_file_to_incoming(file_id):
return jsonify({'status': 'ok'}), 200
notify_manager(file_id, title)
return jsonify({'status': 'ok'}), 200
if __name__ == '__main__':
port = int(os.environ.get('PORT', 3000))
print(f'DocSpace webhook listener: http://localhost:{port}/docspace/webhook')
app.run(host='0.0.0.0', port=port)
Step 1: Receive an event from DocSpace
When a client uploads a file, DocSpace calls your webhook URL (/docspace/webhook) and sends a JSON payload.
The handler:
- receives the request,
- makes sure the request is really coming from DocSpace (basic security check),
- then reads the event data from JSON.
If the request does not look valid, the handler returns 401.
DocSpace may also send a HEAD request to check that your URL is reachable, so the example answers 200 for HEAD.
- Node.js
- Python
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));
}
def verify_signature(raw_body, signature_header):
# If WEBHOOK_SECRET is empty, skip verification
if not WEBHOOK_SECRET:
return True
if not signature_header or not signature_header.startswith('sha256='):
return False
parts = signature_header.split('=', 1)
if len(parts) < 2 or not parts[1]:
return False
received = parts[1].strip().lower()
expected = hmac.new(WEBHOOK_SECRET.encode('utf-8'), raw_body, hashlib.sha256).hexdigest().lower()
return hmac.compare_digest(received, expected)
Step 2: Detect an "invoice upload" event
The script reads:
event.trigger— what happened (file.uploaded,file.created, etc.)payload— file data sent by DocSpace
It processes only upload-like triggers:
file.uploadedfile.created
All other triggers are ignored (server still returns 200 OK).
- Node.js
- Python
function extractUploadEvent(body) {
const trigger = String(body?.event?.trigger || '').trim();
const payload = body?.payload && typeof body.payload === 'object' ? body.payload : null;
if (!payload) return { ok: false, reason: 'No payload object.' };
if (!ALLOWED_TRIGGERS.has(trigger)) return { ok: false, reason: `Trigger '${trigger}' is not handled.` };
const fileId = Number(payload?.id ?? null);
if (!Number.isFinite(fileId)) return { ok: false, reason: 'No valid file ID in payload.id.' };
const rootIdRaw = payload?.rootId ?? null;
const rootId = rootIdRaw === null ? null : Number(rootIdRaw);
if (rootIdRaw !== null && !Number.isFinite(rootId)) return { ok: false, reason: 'Invalid payload.rootId.' };
return { ok: true, trigger, payload, fileId, rootId };
}
def extract_upload_event(body):
trigger = str(((body.get('event') or {}).get('trigger') or '')).strip()
payload = body.get('payload') if isinstance(body.get('payload'), dict) else None
if not payload:
return False, 'No payload object.', None, None, None
if trigger not in ALLOWED_TRIGGERS:
return False, f"Trigger '{trigger}' is not handled.", payload, None, None
file_id_raw = payload.get('id')
try:
file_id = int(file_id_raw) if file_id_raw is not None else None
except Exception:
file_id = None
if file_id is None:
return False, 'No valid file ID in payload.id.', payload, None, None
root_id_raw = payload.get('rootId')
root_id = None
if root_id_raw is not None:
try:
root_id = int(root_id_raw)
except Exception:
return False, 'Invalid payload.rootId.', payload, file_id, None
return True, trigger, payload, file_id, root_id
Step 3: Make sure the upload belongs to the target client room
To avoid reacting to uploads from other rooms, the script compares:
payload.rootId(the room/root container from the webhook) withCLIENT_ROOT_ID(the client room you want to monitor)
If rootId does not match your client room, the script skips the event.
- Node.js
- Python
function isTargetClientRoom(rootId) {
if (rootId == null) return false;
return String(rootId) === String(CLIENT_ROOT_ID);
}
def is_target_client_room(root_id):
if root_id is None:
return False
return str(root_id) == str(CLIENT_ROOT_ID)
Step 4: Check if the file looks like an invoice
The script verifies the file name the title must start with invoice (case-insensitive)
To be safer, it also loads the file title from DocSpace GET /api/2.0/files/file/:fileId
If the file does not look like an invoice, the script skips it.
- Node.js
- Python
function isInvoiceTitle(title) {
return String(title || '').trim().toLowerCase().startsWith('invoice');
}
def is_invoice_title(title):
return str(title or '').strip().lower().startswith('invoice')
Step 5: Move the invoice and notify the manager
If all checks pass, the script:
- Moves the file into the Incoming folder:
- PUT /api/2.0/files/fileops/move}
- with
destFolderId = INCOMING_FOLDER_ID
- Sends a notification to the account manager (placeholder)
- Optionally triggers an external processing pipeline (placeholder)
- Node.js
- Python
async function moveFileToIncoming(fileId) {
const payload = {
fileIds: [fileId],
destFolderId: INCOMING_FOLDER_ID,
deleteAfter: true,
content: true,
toFillOut: false,
};
const res = await fetch(`${API_HOST}/api/2.0/files/fileops/move`, {
method: 'PUT',
headers: HEADERS,
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text();
console.log(`Failed to move file: ${res.status} - ${text}`);
return false;
}
return true;
}
def move_file_to_incoming(file_id):
payload = {
'fileIds': [file_id],
'destFolderId': INCOMING_FOLDER_ID,
'deleteAfter': True,
'content': True,
'toFillOut': False,
}
r = requests.put(f'{API_HOST}/api/2.0/files/fileops/move', headers=HEADERS, json=payload, timeout=30)
if not (200 <= r.status_code < 300):
print(f'Failed to move file. Status code: {r.status_code}, Message: {r.text}')
return False
return True