HubSpot
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 HubSpot account with a Private App (for reading deals) and webhook access. Start with a free CRM while you build this flow.
Use Case 1: Compliant E-Invoice on a Closed Won Deal
Turn a Closed Won HubSpot 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.HubSpot (deal Closed Won) → your handler → fetch deal + line items → PolyDoc API → email / upload
Set up the trigger and a Private App token
HubSpot can notify your handler two ways. Pick whichever your plan allows:
- Workflow "Send a webhook" action (no-code): build a deal-based workflow that enrolls deals reaching Closed Won and add a Send a webhook action pointing at your handler. Requires Operations Hub Professional or higher.
- App webhook subscription (every tier, including free): in a HubSpot app, subscribe to
deal.propertyChangefordealstageand act when it becomes the Closed Won stage id.
Separately, create the token used to read the deal back. In the developer area, go to Keys → Service Keys → Create service key, and under Scopes add the CRM read scopes: crm.objects.deals.read, crm.objects.line_items.read, crm.objects.contacts.read, and crm.objects.companies.read. Copy the token for Step 3. Older accounts may still use a legacy private app, which works the same way.
Verify the webhook signature
Your handler receives the POST. If you used the app-subscription path, verify the X-HubSpot-Signature-v3 header: build an HMAC-SHA256 of method + uri + rawBody + timestamp with your app's client secret, base64-encode it, and compare in constant time. Reject anything whose X-HubSpot-Request-Timestamp is older than five minutes:
import crypto from "node:crypto";
function verifyHubSpotSignature(req, rawBody) {
const ts = req.headers["x-hubspot-request-timestamp"];
if (Date.now() - Number(ts) > 5 * 60 * 1000) return false; // stale
const base = req.method + req.fullUrl + rawBody + ts;
const digest = crypto
.createHmac("sha256", process.env.HUBSPOT_APP_CLIENT_SECRET)
.update(base, "utf8")
.digest("base64");
return crypto.timingSafeEqual(
Buffer.from(digest),
Buffer.from(req.headers["x-hubspot-signature-v3"]),
);
}
if (!verifyHubSpotSignature(request, rawBody)) {
return new Response("invalid signature", { status: 401 });
}
Fetch the deal and its line items
The payload gives you the deal id, so read the rest back with the service-key token from Step 1. It takes two calls: fetch the deal with its associations and properties, then batch-read the associated line items (the associations API returns ids only). Resolve the associated contact or company for the buyer:
# 1. The deal, its line-item / contact / company associations, and its fields
curl "https://api.hubapi.com/crm/v3/objects/deals/$DEAL_ID?associations=line_items,contacts,companies&properties=dealname,amount,closedate,deal_currency_code,invoice_number" \
-H "Authorization: Bearer $HUBSPOT_TOKEN"
# 2. Batch-read the line items by the ids from step 1
curl -X POST "https://api.hubapi.com/crm/v3/objects/line_items/batch/read" \
-H "Authorization: Bearer $HUBSPOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"properties": ["name","description","quantity","price","amount","hs_tax_rate","hs_tax_amount"],
"inputs": [{"id":"<lineItemId1>"},{"id":"<lineItemId2>"}]
}'
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 associated company as the buyer for B2B, falling back to the contact:
PolyDoc eInvoice.invoice | HubSpot source | Transform |
|---|---|---|
number | deal invoice_number, else hs_object_id | deals have no native invoice number |
issueDate | deal closedate (epoch ms) | new Date(Number(closedate)).toISOString().slice(0,10) |
dueDate | closedate + payment term, or a custom field | see the BR-CO-25 note below |
currencyCode | deal deal_currency_code | .toUpperCase() |
buyer.name | company name, else contact firstname + lastname | prefer company |
buyer.address | company / contact address, city, zip, country | zip→postalCode, country→countryCode |
lines[] | batch-read line_items[] | .map() (below) |
lines[].unitPrice | line price | parseFloat (numeric string, no /100) |
lines[].lineTotal | line amount (price x qty - discount) | parseFloat |
lines[].vatRate | line hs_tax_rate if set, else a configured rate | see the tax note below |
totalNet / Tax / Gross | computed from the line items | do not assume deal amount splits cleanly |
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 DEFAULT_VAT_RATE = 19; // your standard rate, used when HubSpot has none
const deal = dealProps; // properties from Step 3's deal fetch
const buyer = company ?? contact; // prefer the company association
const issueDate = new Date(Number(deal.closedate)).toISOString().slice(0, 10);
const lines = lineItems.map((l) => {
const rate = l.hs_tax_rate ? parseFloat(l.hs_tax_rate) : DEFAULT_VAT_RATE;
const quantity = parseFloat(l.quantity);
const unitPrice = parseFloat(l.price); // "150.00" => 150
return {
description: [l.name, l.description].filter(Boolean).join(" / "),
quantity,
unitCode: "C62", // "HUR" for hours, "MON" for months, etc.
unitPrice,
lineTotal: l.amount ? parseFloat(l.amount) : unitPrice * quantity,
vatRate: rate,
vatCategoryCode: "S",
};
});
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: deal.invoice_number ?? deal.hs_object_id,
invoice_date: issueDate,
invoice_total: totalNet + totalTax,
customer_name: buyer.name ?? `${buyer.firstname} ${buyer.lastname}`,
customer_email: contact?.email,
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.hs_object_id),
issueDate,
dueDate: new Date(Number(deal.closedate) + 30 * 864e5)
.toISOString()
.slice(0, 10),
paymentTerms: "Net 30 days", // BT-9/BT-20: see note
currencyCode: (deal.deal_currency_code ?? "EUR").toUpperCase(),
seller: YOUR_BUSINESS, // { name, address, taxId }: your own, set once
buyer: {
name: buyer.name ?? `${buyer.firstname} ${buyer.lastname}`,
email: contact?.email,
address: {
line1: buyer.address,
city: buyer.city,
postalCode: buyer.zip,
countryCode: buyer.country, // ISO 3166-1 alpha-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) Push it back to HubSpot. Upload the PDF to the Files API, then attach it to the deal as a Note (or store the link in a deal property), so the archive lives next to the record:
# Upload to the Files API, then associate a Note carrying the file with the deal
curl -X POST "https://api.hubapi.com/files/v3/files" \
-H "Authorization: Bearer $HUBSPOT_TOKEN" \
-F "file=@invoice.pdf;type=application/pdf" \
-F 'folderPath=/invoices' \
-F 'options={"access":"PRIVATE"}'
Use Case 2: Bulk Historical PDF/A-3 Archive
Back-fill a compliant PDF/A-3 archive for deals you've already closed (useful for the EU's multi-year invoice-retention requirements), writing each archive straight to your own cloud bucket.Search Closed 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 Closed Won deals with the CRM Search API, filtering on the stage and the close date:
curl -X POST "https://api.hubapi.com/crm/v3/objects/deals/search" \
-H "Authorization: Bearer $HUBSPOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"filterGroups": [{"filters": [
{"propertyName":"dealstage","operator":"EQ","value":"closedwon"},
{"propertyName":"closedate","operator":"GTE","value":"1735689600000"}
]}],
"properties": ["dealname","closedate","deal_currency_code","invoice_number"],
"limit": 100
}'
Page through with the paging.next.after cursor from the response until you've collected every deal in range.
Generate and store each archive
For each deal, fetch its line items 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 closedWonDeals) {
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-1042 | s3://invoices/DEAL-1042.pdf | 2026-02-02 | 2036-02-02 |
| DEAL-1043 | s3://invoices/DEAL-1043.pdf | 2026-02-05 | 2036-02-05 |