monday.com
Prerequisites
- A PolyDoc account and API key. Sign up for free, no credit card required; the free plan includes 150 PDF conversions per month.
- A PolyDoc invoice template. Note its short ID for the
sourcefield (e.g.[template:jlE-whg]). - A monday.com account with an API token (Developers, My Access Tokens) and a board you can add automations to. Start a free trial while you build this flow.
Use Case 1: Compliant E-Invoice on a Won Item
Turn a won monday.com item (a deal or invoice record) into a compliant e-invoice: a single PDF/A-3 that carries the human-readable invoice and the embedded Factur-X / ZUGFeRD XML, then deliver it to your customer.monday (item status changes) → your handler → re-fetch item + subitems → PolyDoc API → email / upload
Set up the trigger and an API token
Create the trigger on your invoicing board. Open the board and add the automation "When a column changes, send a webhook" (or "When status changes to something, send a webhook"), configure it for the status column on your "Won" / "Ready to invoice" value, and point it at your handler URL. monday verifies the URL with a challenge handshake right after you save the recipe: Step 2 echoes the challenge back so the webhook activates.
Verify the webhook
First handle the one-time URL challenge: when monday posts a body that contains a challenge field, echo it back. After that, every event arrives with a JSON Web Token in the Authorization header, signed with your app's Signing Secret. Verify that signature (or, if you used a no-code automation without an app, secure the endpoint with a long secret in the URL or a custom header):
import jwt from "jsonwebtoken";
const event = JSON.parse(rawBody);
// 1. Echo the URL-verification challenge once, on setup
if (event.challenge) {
return new Response(JSON.stringify({ challenge: event.challenge }), {
headers: { "Content-Type": "application/json" },
});
}
// 2. Verify the JWT monday signs every webhook with
const token = (request.headers["authorization"] ?? "").replace(/^Bearer\s+/i, "");
try {
jwt.verify(token, process.env.MONDAY_SIGNING_SECRET);
} catch {
return new Response("invalid signature", { status: 401 });
}
const { boardId, pulseId, columnId, value } = event.event ?? {};
// Optional: only act when the status reaches your "Won" value index
Re-fetch the item and its subitems
The payload carries the item id (event.pulseId) and the column that changed, not the full record. Re-fetch the item, its column values, and its subitems with one GraphQL call:
curl -sS "https://api.monday.com/v2" \
-H "Authorization: $MONDAY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "query($id:[ID!]){ items(ids:$id){ id name created_at column_values{ id text value } subitems{ id name column_values{ id text value } } } }",
"variables": { "id": ["$PULSE_ID"] }
}'
Map the item and call PolyDoc
Pick the column ids on your invoicing board, then map them to PolyDoc's eInvoice block. The example layout below is a starting point; replace each id with the one from your board:
PolyDoc eInvoice.invoice | monday source (by column id) | Transform |
|---|---|---|
number | an "Invoice number" text column, else item id | direct; no native invoice number |
issueDate | a date column, else item created_at | .slice(0,10) |
dueDate | a due-date column, else issueDate + payment term | see BR-CO-25 note |
currencyCode | a currency text column, else a constant | .toUpperCase() |
buyer.name | a Customer / Company text column | by column id |
buyer.email | an email column | read text (the address is in text) |
buyer.address | address text columns (street / city / zip / country) | map; country name to ISO-2 |
lines[] | subitems | .map() (below) |
lines[].quantity | subitem number column | Number(...) (numeric string) |
lines[].unitPrice | subitem number column | Number(...) (no /100) |
lines[].vatRate | a VAT% column, else a configured rate | direct (already a percent) |
totalNet / Tax / Gross | computed from the subitems | compute, don't trust a single total column |
Then POST to PolyDoc. source renders the visual invoice from your template; eInvoice generates and embeds the CII XML; verify: true runs VeraPDF and fails the request if the output isn't PDF/A-3b compliant:
const item = monday.data.items[0];
const cols = Object.fromEntries(item.column_values.map((c) => [c.id, c.text]));
// Replace these with the column ids from your own board:
const COL = {
invoiceNumber: "text_invoice_number",
invoiceDate: "date_invoice_date",
buyerName: "text_customer",
buyerEmail: "email_customer",
buyerLine1: "text_address",
buyerCity: "text_city",
buyerPostal: "text_postal",
buyerCountry: "country_buyer", // a country name or ISO-2 text
currency: "text_currency",
};
const SUB = { quantity: "numbers_qty", unitPrice: "numbers_price", vatRate: "numbers_vat" };
const DEFAULT_VAT_RATE = 19;
const issueDate = (cols[COL.invoiceDate] ?? item.created_at).slice(0, 10);
const dueDate = new Date(new Date(issueDate).getTime() + 30 * 864e5)
.toISOString()
.slice(0, 10);
const lines = item.subitems.map((s) => {
const sc = Object.fromEntries(s.column_values.map((c) => [c.id, c.text]));
const quantity = Number(sc[SUB.quantity] ?? 0);
const unitPrice = Number(sc[SUB.unitPrice] ?? 0);
const rate = sc[SUB.vatRate] ? Number(sc[SUB.vatRate]) : DEFAULT_VAT_RATE;
return {
description: s.name,
quantity,
unitCode: "C62", // "HUR" for hours, "DAY" for days, etc.
unitPrice,
lineTotal: quantity * unitPrice,
vatRate: rate,
vatCategoryCode: rate > 0 ? "S" : "Z",
};
});
const totalNet = lines.reduce((s, l) => s + l.lineTotal, 0);
const totalTax = lines.reduce((s, l) => s + l.lineTotal * (l.vatRate / 100), 0);
const body = {
source: "[template:YOUR_TEMPLATE_ID]",
templateData: {
invoice_number: cols[COL.invoiceNumber] || String(item.id),
invoice_date: issueDate,
invoice_total: totalNet + totalTax,
customer_name: cols[COL.buyerName],
customer_email: cols[COL.buyerEmail],
items: lines.map((l) => ({
quantity: l.quantity,
name: l.description,
description: l.description,
price: l.unitPrice,
})),
},
eInvoice: {
standard: "facturx", // or "zugferd"
profile: "en16931",
verify: true,
invoice: {
number: cols[COL.invoiceNumber] || String(item.id),
issueDate,
dueDate,
paymentTerms: "Net 30 days", // BT-9/BT-20: see note
currencyCode: (cols[COL.currency] || "EUR").toUpperCase(),
seller: YOUR_BUSINESS, // { name, address, taxId }: your own, set once
buyer: {
name: cols[COL.buyerName],
email: cols[COL.buyerEmail],
address: {
line1: cols[COL.buyerLine1],
city: cols[COL.buyerCity],
postalCode: cols[COL.buyerPostal],
countryCode: toIso2(cols[COL.buyerCountry]), // name to ISO-2 if needed
},
},
lines,
totalNetAmount: totalNet,
totalTaxAmount: totalTax,
totalGrossAmount: totalNet + totalTax,
},
},
};
const res = await fetch("https://api.polydoc.tech/pdf/convert", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
"X-Sandbox": "true", // drop in production
},
body: JSON.stringify(body),
});
const pdf = Buffer.from(await res.arrayBuffer()); // the PDF/A-3 hybrid
Deliver the hybrid PDF
Deliver the hybrid PDF. Two common options:
a) Email it to the customer. Attach the PDF buffer from Step 4 to your transactional email (SES, Resend, Postmark, …). The single file is both the readable invoice and the compliant e-invoice, so one attachment covers both.
b) Write it back to monday. Upload the PDF to a File column on the item with the add_file_to_column mutation, or set a Link column with the archive URL so the document lives next to the record:
curl -sS "https://api.monday.com/v2/file" \
-H "Authorization: $MONDAY_API_TOKEN" \
-F 'query=mutation ($file: File!) { add_file_to_column(item_id: $ITEM_ID, column_id: "files_invoice", file: $file) { id } }' \
-F 'variables[file]=@invoice.pdf;type=application/pdf' \
-F 'map={"variables[file]": "variables.file"}'
Use Case 2: Bulk Historical PDF/A-3 Archive
Back-fill a compliant PDF/A-3 archive for items you've already marked Won (useful for the EU's multi-year invoice-retention requirements), writing each archive straight to your own cloud bucket.Page won items → PolyDoc API (+ cloudStorage) → PDF/A-3b in your bucket
Page the items to archive
Decide the window to archive (e.g. the last fiscal year) and page the board's items, filtering on the status column. GraphQL items_page takes a query_params.rules filter:
curl -sS "https://api.monday.com/v2" \
-H "Authorization: $MONDAY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "query($b:[ID!]){ boards(ids:$b){ items_page(limit:100, query_params:{rules:[{column_id:\"status\", compare_value:[1]}]}){ cursor items{ id name created_at column_values{id text value} subitems{id name column_values{id text value}} } } } }",
"variables": { "b": ["$BOARD_ID"] }
}'
Page through with the cursor returned in each response until you've collected every item. Adjust compare_value to match the index of your Won status.
Generate and store each archive
For each item, map it exactly as in Use Case 1, then POST to PolyDoc with a cloudStorage.presignedUrl: a pre-signed PUT URL you generate for your S3 / R2 / GCS / Azure bucket. PolyDoc renders, builds the hybrid, and PUTs it there; the response is just a status:
for (const item of wonItems) {
const body = mapToPolydoc(item); // Step 4 mapping
body.cloudStorage = {
presignedUrl: await presignPut(`invoices/${body.eInvoice.invoice.number}.pdf`),
};
await fetch("https://api.polydoc.tech/pdf/convert", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
See the cloud-storage guides for generating presigned URLs per provider.
Index the archive
Keep a small index so you can find any archived invoice later. Map the invoice number to its archive location and retention deadline:
| invoice_number | archive_url | issued | retain_until |
|---|---|---|---|
| INV-3120 | s3://invoices/INV-3120.pdf | 2026-02-18 | 2036-02-18 |
| INV-3121 | s3://invoices/INV-3121.pdf | 2026-02-22 | 2036-02-22 |