跳到主要内容

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 Incoming folder,
  • notifies the account manager (placeholder),
  • optionally triggers an external processing pipeline (placeholder).

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

// 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');
});

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.

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: 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.uploaded
  • file.created

All other triggers are ignored (server still returns 200 OK).

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: 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) with
  • CLIENT_ROOT_ID (the client room you want to monitor)

If rootId does not match your client room, the script skips the event.

function isTargetClientRoom(rootId) {
if (rootId == null) return false;
return String(rootId) === String(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.

function isInvoiceTitle(title) {
return String(title || '').trim().toLowerCase().startsWith('invoice');
}

Step 5: Move the invoice and notify the manager

If all checks pass, the script:

  1. Moves the file into the Incoming folder:
  1. Sends a notification to the account manager (placeholder)
  2. Optionally triggers an external processing pipeline (placeholder)
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;
}