Pipedrive
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 Pipedrive account with an API token (Settings, Personal preferences, API) and webhook access. Start a free trial while you build this flow.
Use Case 1: Compliant E-Invoice on a Won Deal
Turn a won Pipedrive deal 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.Pipedrive (deal Won) → your handler → fetch products + buyer → PolyDoc API → email / upload
Set up the webhook trigger and an API token
Create the trigger. In Pipedrive, go to Settings → Tools and apps → Webhooks → Create new webhook, set Event action to Change and Event object to Deal (the v2 equivalent of the old updated.deal; act when the deal's status becomes won), and point it at your handler URL. Set an HTTP Basic Auth username and password on the webhook. Pipedrive will send those on every delivery so your handler can authenticate the request (see Step 2).
Verify the webhook
Your handler receives the POST. Unlike most webhook providers, Pipedrive does not HMAC-sign the body. Instead it sends the HTTP Basic Auth credentials you set in Step 1 in the Authorization header. Compare them in constant time and serve the endpoint only over HTTPS:
import crypto from "node:crypto";
function verifyPipedriveWebhook(request) {
const got = request.headers["authorization"] ?? "";
const expected =
"Basic " +
Buffer.from(
`${process.env.PIPEDRIVE_WEBHOOK_USER}:${process.env.PIPEDRIVE_WEBHOOK_PASSWORD}`,
).toString("base64");
return (
got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))
);
}
if (!verifyPipedriveWebhook(request)) {
return new Response("unauthorized", { status: 401 });
}
const event = JSON.parse(rawBody);
if (event.data?.status !== "won") return; // only invoice won deals
Fetch the deal's products and buyer
The webhook gives you the deal, but the line items and the buyer come from separate calls. Read the deal's products, plus the linked person and organization, with the API token from Step 1:
# Line items attached to the deal
curl "https://your-company.pipedrive.com/api/v2/deals/$DEAL_ID/products" \
-H "x-api-token: $PIPEDRIVE_API_TOKEN"
# Buyer: the linked person (B2C) and organization (B2B)
curl "https://your-company.pipedrive.com/api/v2/persons/$PERSON_ID" \
-H "x-api-token: $PIPEDRIVE_API_TOKEN"
curl "https://your-company.pipedrive.com/api/v2/organizations/$ORG_ID" \
-H "x-api-token: $PIPEDRIVE_API_TOKEN"
Map the deal and call PolyDoc
Map the deal to PolyDoc's eInvoice block. The seller is your own business (set it once). Prefer the linked organization as the buyer for B2B, falling back to the person:
PolyDoc eInvoice.invoice | Pipedrive source | Transform |
|---|---|---|
number | deal custom field, else deal id | deals have no native invoice number |
issueDate | deal won_time | .slice(0,10) (datetime string) |
dueDate | won_time + payment term, or a custom field | see the BR-CO-25 note below |
currencyCode | deal currency | .toUpperCase() |
buyer.name | organization name, else person name | prefer organization |
buyer.address | organization address_* parts | address_postal_code→postalCode; map address_country (a name) to ISO-2 |
lines[] | /deals/{id}/products | .map() (below) |
lines[].quantity | product quantity | Number(...) (numeric, no /100) |
lines[].unitPrice | product item_price + tax_method | inclusive divides out the tax; else direct |
lines[].vatRate | product tax | direct (already a percent) |
totalNet / Tax / Gross | computed from the lines | do not assume deal value is net or gross |
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 deal = event.data; // the deal from the webhook payload
const buyer = organization ?? person; // prefer the organization
const issueDate = (deal.won_time ?? deal.add_time).slice(0, 10);
const contactEmail = person?.emails?.find((e) => e.primary)?.value;
const lines = dealProducts.map((p) => {
const rate = Number(p.tax ?? 0); // percent, e.g. 19
const listed = Number(p.item_price);
const unitPrice =
p.tax_method === "inclusive" ? listed / (1 + rate / 100) : listed;
const quantity = Number(p.quantity);
return {
description: p.name,
quantity,
unitCode: "C62", // "HUR" for hours, "DAY" for days, etc.
unitPrice,
lineTotal: unitPrice * quantity,
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 dueDate = new Date(new Date(issueDate).getTime() + 30 * 864e5)
.toISOString()
.slice(0, 10);
const body = {
source: "[template:YOUR_TEMPLATE_ID]",
templateData: {
invoice_number: deal.invoice_number ?? String(deal.id),
invoice_date: issueDate,
invoice_total: totalNet + totalTax,
customer_name: buyer.name,
customer_email: contactEmail,
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: String(deal.invoice_number ?? deal.id),
issueDate,
dueDate,
paymentTerms: "Net 30 days", // BT-9/BT-20: see note
currencyCode: (deal.currency ?? "EUR").toUpperCase(),
seller: YOUR_BUSINESS, // { name, address, taxId }: your own, set once
buyer: {
name: buyer.name,
email: contactEmail,
address: {
line1: organization?.address,
city: organization?.address_locality,
postalCode: organization?.address_postal_code,
countryCode: toIso2(organization?.address_country), // name -> ISO-2
},
},
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) Attach it back to the deal. Upload the PDF to Pipedrive's Files API and link it to the deal, so the archive lives next to the record:
curl -X POST "https://your-company.pipedrive.com/api/v1/files" \
-H "x-api-token: $PIPEDRIVE_API_TOKEN" \
-F "file=@invoice.pdf;type=application/pdf" \
-F "deal_id=$DEAL_ID"
Use Case 2: Bulk Historical PDF/A-3 Archive
Back-fill a compliant PDF/A-3 archive for deals you've already won (useful for the EU's multi-year invoice-retention requirements), writing each archive straight to your own cloud bucket.List won deals → PolyDoc API (+ cloudStorage) → PDF/A-3b in your bucket
List the deals to archive
Decide the window to archive (e.g. the last fiscal year) and list the won deals with the API token:
curl -G "https://your-company.pipedrive.com/api/v2/deals" \
-H "x-api-token: $PIPEDRIVE_API_TOKEN" \
-d "status=won" \
-d "limit=100"
Page through with the additional_data.next_cursor value from the response until you've collected every deal in range.
Generate and store each archive
For each deal, fetch its products and 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 deal of wonDeals) {
const body = await mapToPolydoc(deal); // Step 3 fetch + 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 |
|---|---|---|---|
| DEAL-2087 | s3://invoices/DEAL-2087.pdf | 2026-02-10 | 2036-02-10 |
| DEAL-2091 | s3://invoices/DEAL-2091.pdf | 2026-02-14 | 2036-02-14 |