PaperType

Turn a HubSpot deal into a PDF: a deal summary, or an invoice from its line items (Python script)

HubSpot's CRM API returns a deal's properties and the ids of the companies, contacts and line items attached to it; a few more reads turn those ids into names and prices, and https://papertype.dev/v1/pdf turns the result into a PDF. This page gives you a script (papertype-hubspot.py, Python 3.9+, standard library only, MIT) that renders one deal as a summary PDF (properties with their HubSpot labels, companies, contacts, line items with a total, description), or as an invoice through the hosted invoice template, or every deal in a pipeline stage as one PDF each. The script was executed end to end on 2026-10-25 with the HubSpot responses replaced by stubs shaped like the 2026-09 OpenAPI specs on developers.hubspot.com and everything on our side live; it was not run against a HubSpot account, so every HubSpot fact below is quoted from those docs, fetched the same day, and the one place the docs leave open is named.

Setup: a HubSpot token, a PaperType key, one command

Create a free API key at /signup. On the HubSpot side you need a token for one account, not OAuth: HubSpot's authentication overview says OAuth is for apps installed in many accounts and "static auth access tokens are used for installing in a single account at a time". The two ways to get one, both from the docs fetched 2026-10-25:

  • A service key (the page "Make API requests using a service key"): in HubSpot go to Development → Keys → Service keys → Create service key, name it, Add new scope and tick the scopes below, Create; the key is shown under the key's name (Show, Copy) and is sent as a Bearer token. Super admins and users with the Developer-tools permission can create one; HubSpot recommends rotating it every six months and the page shows a one-click Rotate.
  • A private app's access token (the "Legacy private apps" page, still supported): the app's Auth tab, Show token, Copy.

Scopes to grant, read-only (names from HubSpot's scopes reference): crm.objects.deals.read, crm.objects.line_items.read, crm.objects.companies.read, crm.objects.contacts.read, crm.objects.owners.read, crm.schemas.deals.read (property labels). The stage-label lookup reads a deal pipeline; the scopes reference fetched today lists a crm.pipelines.orders.read scope but no deal-pipeline scope I could point to, so the script treats a 403 on that call, on the owner call and on the properties call as "show the raw id or internal name and carry on". Only the deal read itself has to succeed.

export HUBSPOT_TOKEN=pat-na1-...        # service key or private-app token
export PAPERTYPE_API_KEY=dp_...        # from /signup
curl -sO https://papertype.dev/static/hubspot/papertype-hubspot.py

python3 papertype-hubspot.py https://app.hubspot.com/contacts/244488/record/0-3/19485601234
#  Acme-Rollout-phase-2.pdf  2 page(s)

python3 papertype-hubspot.py 19485601234 --theme report --page-size Letter --properties hs_priority,contract_term   # options
python3 papertype-hubspot.py 19485601234 --url     # print a 24-hour download link instead of saving

The deal argument is the id or the record URL from your browser (…/record/0-3/<id>; the older …/deal/<id> form is accepted too). What the script sends to HubSpot, base https://api.hubapi.com, all on the dated 2026-09 version that the current reference documents:

  • GET /crm/properties/2026-09/deals → the property definitions: label, type, fieldType and, for enumerations, options[].value/label. This is how dealtype: existingbusiness prints as Deal Type: Existing Business.
  • GET /crm/objects/2026-09/deals/{dealId}?properties=dealname,amount,…&associations=companies,contacts,line_items → {id, properties: {…}, associations: {<object>: {results: [{id, type}]}}}. The reference describes associations as "a comma separated list of object types to retrieve associated IDs for" and the response map's values as lists of {id, type} (type like deal_to_contact); the map's key spelling for line items is not shown, so the script matches line items and line_items alike. Properties that do not exist in your account are ignored by HubSpot, the reference says, so --properties cannot break a run. The OpenAPI path is written /crm/objects/2026-09/0-3/{dealId} with the deal's object id; HubSpot's own examples use the object name in the same position, which is what the script does.
  • GET /crm/pipelines/2026-09/deals/{pipelineId} → {label, stages: [{id, label, displayOrder, metadata}]}: the dealstage value is a stage id, so this is where Closed Won comes from.
  • GET /crm/owners/2026-09/{ownerId} → {email, firstName, lastName} for hubspot_owner_id.
  • POST /crm/objects/2026-09/{line_items|companies|contacts}/batch/read with {"inputs": [{"id": …}], "properties": […], "propertiesWithHistory": []}; the schema marks all three keys required, the last may be empty. Up to 100 ids per call. Line items ask for name, quantity, price, amount, hs_sku, discount, hs_discount_percentage, description; companies name, domain, city, state, country; contacts firstname, lastname, email, jobtitle, phone.

Then one POST to https://papertype.dev/v1/pdf with the Markdown the script built:

{
  "markdown": "# Acme | Rollout phase 2\n\n**€48,500.00 · Closed Won · Close 2026-10-31**\n\n## Deal\n\n| Property | Value |\n|---|---|\n| Amount | €48,500.00 |\n| Deal Stage | Closed Won |\n| Deal owner | Grace Hopper (grace@acme-sales.example) |\n…\n## Line items\n\n| Item | Qty | Unit price | Amount |\n|---|---:|---:|---:|\n| Onboarding \\| per seat (ONB-1) | 40 | €250.00 | €10,000.00 |\n…",
  "title": "Acme | Rollout phase 2",
  "theme": "clean",
  "page_size": "A4",
  "toc": false,
  "page_numbers": true,
  "filename": "Acme-Rollout-phase-2.pdf",
  "output": "pdf"
}

Live result on 2026-10-25 for the test deal (ten properties, one company, two contacts, two line items, a two-line description): 200, 2 pages with the report theme; the text read back from the PDF had the deal name, the stage label, the owner's name, the company, both line items, the €48,500.00 total and the description.

What goes on the page, and how values are formatted

The summary is: the deal name as title; one bold line with amount, stage and close date; a Deal table of every requested property that has a value (HubSpot's label on the left); Companies, Contacts and Line items tables when the deal has any; the description as its own section; a footer line naming the deal id and the date. Formatting rules, each exercised by the script's test:

  • Money. amount and the line-item prices are printed in the deal's deal_currency_code (€, $, £, CA$, A$, CHF, ¥ without decimals, ₹; other codes as 1,200.00 SEK); a deal with no currency code prints a plain number. The line-items total is the sum of HubSpot's per-line amount (quantity × price when amount is absent).
  • Dates. closedate, createdate and any property whose type is date or datetime print as YYYY-MM-DD, whether HubSpot sends an ISO 8601 string or epoch milliseconds.
  • Enumerations, booleans, numbers. Option values become their labels (multi-value a;b becomes Label A; Label B), true/false become Yes/No, numbers get thousands separators.
  • Table safety. A | inside a value is escaped (the test deal is called "Acme | Rollout phase 2" for that reason) and line breaks inside a cell become spaces; the description is printed as paragraphs, not in a cell.
  • Extra properties. --properties hs_priority,contract_term adds any internal property names, custom ones included, after the default set (dealname, amount, deal_currency_code, closedate, createdate, dealstage, pipeline, hubspot_owner_id, dealtype, description). The internal name is what HubSpot shows under Settings → Properties → the property → internal name; this click path is from memory of the product, not from the docs fetched for this page.
  • Not included. Notes, emails, calls, meetings and tasks on the deal timeline (the engagements APIs) are not read; quotes are not read. If you need them, the script's read_deal is one function to extend, and tell us what you needed.

An invoice from the deal's line items

--invoice renders the hosted invoice template instead of a summary: {"template": "invoice", "data": {…}} in the same POST. The issuing party is yours, so it comes from a small JSON file:

cat > issuer.json <<'EOF'
{"name": "Northwind Consulting", "address_lines": ["12 Harbour St", "Dublin"], "email": "billing@northwind.example", "tax_id": "IE1234567T"}
EOF
python3 papertype-hubspot.py 19485601234 --invoice --issuer issuer.json --tax-rate 23 --notes "Payable within 30 days."
#  INV-19485601234.pdf  1 page(s)

How the deal maps onto the template's fields: number is INV-<deal id> unless --number says otherwise; date is today; due_date is the deal's close date; currency is the deal's currency code (default USD); to is the first associated company's name and location, with the first associated contact's email, falling back to the contact's name and then the deal name; items are the line items as {description, quantity, unit_price} with the line's description appended to its name; discount is the sum of the lines' flat discount values; tax_rate and notes come from the flags. Two honest caveats: the template computes each line as quantity × unit price, so a line-level percentage discount in HubSpot (hs_discount_percentage) is not applied, and a deal with no line items is refused with a message rather than producing an empty invoice. Live result on 2026-10-25: one page; the text read back had the issuer, the client company, both items, the number and the tax line.

Every deal in a stage, and running it on a schedule

--stage <dealstage id> (optionally --pipeline <pipeline id>) renders one PDF per deal, named after the deal, into --outdir:

python3 papertype-hubspot.py --stage closedwon --pipeline default --outdir pdfs
#  2 deal(s) in stage closedwon
#  pdfs/Acme-Rollout-phase-2.pdf  2 page(s)
#  pdfs/Beta-Ltd-renewal.pdf      1 page(s)

It uses POST /crm/objects/2026-09/deals/search with {"filterGroups": [{"filters": [{"propertyName": "dealstage", "operator": "EQ", "value": …}]}], "limit": 200, "sorts": […]} and follows paging.next.after; the reference caps a search page at 200 objects. Stage and pipeline ids are the values HubSpot stores (closedwon, default for the stock pipeline; custom stages have numeric ids), which the pipelines endpoint above lists. The script is two environment variables and one command, so cron, a GitHub Actions job (see the GitHub Actions guide) or any server can run it nightly; --url gives a link per deal that a chat or email step can post. Whether a HubSpot workflow can call an outbound URL to trigger it per deal was not checked for this page; a webhook receiver that runs the script for the deal id it is sent is the shape to build.

Limits that matter

  • HubSpot rate limits (usage-guidelines page, 2026-10-25): privately distributed apps and service keys get 100 requests per 10 seconds per app and 250,000 per day per account on Free and Starter tiers (an API Limit Increase add-on raises these); over the limit every call returns 429 with {"status": "error", "errorType": "RATE_LIMIT", "policyName": "DAILY" | …}. One deal costs at most 7 requests (deal, properties, pipeline, owner, three batch reads), so --stage on a large stage is paced by the 10-second window; the script waits Retry-After (else 10 s) and retries three times.
  • Our quota. Free-plan output carries a small papertype.dev footer mark. Paid plans (from $9/month) do not. The free plan is 50 documents a month and the API returns 429 at the limit; a nightly --stage over 60 deals needs the $9 plan (500 documents). Failed renders are not counted.
  • 2 MB of source per request, 300 pages per document, 2 concurrent renders per account. A deal summary is a few kilobytes; nothing here approaches the 2 MB request limit.
  • Errors. HubSpot errors surface as HubSpot <status> on <method> <path>: <category>: <message>, using the category/message fields of HubSpot's error body (for example OBJECT_NOT_FOUND for an id that is not a deal, MISSING_SCOPES for a token without a scope). Ours surface as PaperType <status>: <detail>; a bad key gives PaperType 401: Missing or invalid API key, as the test checked.
  • What is stored, and where the token goes. With the default output the PDF is the response and nothing is kept on our side. With --url the finished PDF (never the deal data as text) is kept at a public, unguessable link for 24 hours, as the privacy policy describes. The HubSpot token is sent only to api.hubapi.com; what reaches us is the Markdown or the invoice data the script built, over TLS, and the free-plan mark aside it is treated like any other render. Give the token read scopes only; the script never writes.
  • Fonts. Installed fonts: DejaVu, Liberation, Noto, Carlito and Caladea. Embed any other font with an @font-face rule whose src is a data: URI.

Questions

Did you test this against HubSpot?

Not against an account. The script file was executed on 2026-10-25 with its `hubspot()` function replaced by a stub returning the shapes the 2026-09 OpenAPI specs show (properties list, a deal with `companies`, `contacts` and `line items` association maps, a pipeline with stages, an owner, batch reads, a two-page search) and the PaperType half real: the summary render, the invoice-template render, `--url` and `--stage` were posted to the live API, the PDFs saved and read back with pdftotext, the 24-hour link downloaded without a key, and the 401/404/403 paths checked (35 checks). The spelling of the association-map key for line items and the exact scope for reading deal pipelines are the two details the docs did not settle; the script handles both spellings and degrades gracefully on a 403. If a HubSpot detail is wrong, tell support@papertype.dev and the page gets fixed.

Why not HubSpot's own quotes or the deal export?

Use HubSpot quotes when a quote is what you want. This script is for a PDF produced by a job rather than a person: a nightly archive of every deal that closed, a one-page deal brief attached to a hand-off ticket, an invoice drafted from the line items in your own numbering, your own theme or CSS, Letter or A5, or a link posted into a chat from an automation. The CRM export in HubSpot is a spreadsheet, not a document per deal.

Does it work with custom properties and custom pipelines?

Yes. `--properties` takes any internal property names and the labels come from your account's property definitions, options included; the stage label comes from whichever pipeline the deal is in. A property your account does not have is ignored by HubSpot rather than failing the request, per the reference.

Will the invoice match HubSpot's own line-item totals?

When discounts are flat amounts or absent, yes: the template sums quantity × unit price and subtracts the summed flat discounts. A percentage discount on a line (`hs_discount_percentage`) is not applied by the template, and HubSpot-side tax or fee objects are not read, so compare the first few against the deal before trusting the mapping for your account.

Can it run inside a HubSpot workflow?

Not directly: a workflow would have to call a URL that runs the script for one deal id, which means a small webhook receiver on your side (the form-submission guide shows a 30-line Python one you can adapt). Whether HubSpot workflows can make such an outbound call on your tier was not checked for this page.

Related

All guides

Free previews carry a watermark and are deleted after 24 hours. Free API output carries a footer mark. The renderer runs no JavaScript and never fetches external URLs. Privacy · Terms · Refunds