Turn a Jira Cloud issue into a PDF, or a JQL query into release notes with a contents page (Python script)
Jira's REST API v3 returns an issue's fields and, with expand=renderedFields, its description already rendered as HTML; the comments endpoint does the same. This page gives you a script (papertype-jira.py, Python 3.9+, standard library only, MIT) that assembles that into a document and posts it to https://papertype.dev/v1/pdf: one issue as a PDF with its fields, description, attachments and comments; or every issue a JQL query matches as one document with an issue table whose page numbers are real, or as one file per issue. The script was executed end to end on 2026-10-25 with the Jira responses replaced by stubs shaped like the Jira Cloud platform OpenAPI document and everything on our side live (the PDFs were saved and read back); it was not run against a Jira site, so every Jira fact below is quoted from Atlassian's reference pages fetched the same day, and the two places where the docs leave a detail open are named.
Setup: an API token, a PaperType key, one command
Create a free API key at /signup. On the Jira side you need an Atlassian API token (the page "Manage API tokens for your Atlassian account", support.atlassian.com, fetched 2026-10-25): log in at id.atlassian.com/manage-profile/security/api-tokens, Create API token, name it, pick an expiry ("1 to 365 days"; new tokens expire after a year by default), Create, Copy to clipboard; "you can't recover the API token after you're done with this step". The token is used with HTTP basic authentication, your account email as the user name (Atlassian's own example: curl https://mysite.atlassian.net --user me@example.com:my-api-token). Atlassian recommends the newer API token with scopes; those must be sent to https://api.atlassian.com/ex/jira/{cloudId} instead of your site URL, so set JIRA_SITE to that address if you use one. The basic-auth reference adds that Jira does not send an authentication challenge, so a client has to send the header on the first request; the script always does.
export JIRA_SITE=https://your-domain.atlassian.net
export JIRA_EMAIL=me@example.com
export JIRA_API_TOKEN=... # id.atlassian.com → Security → API tokens
export PAPERTYPE_API_KEY=dp_... # from /signup
curl -sO https://papertype.dev/static/jira/papertype-jira.py
python3 papertype-jira.py PROJ-123
# PROJ-123-Checkout-fails-with-Apple-Pay.pdf 2 page(s)
python3 papertype-jira.py https://your-domain.atlassian.net/browse/PROJ-123 issue.pdf --theme report --page-size Letter
python3 papertype-jira.py PROJ-123 --fields customfield_10020,customfield_10016 --footer "Confidential" # add Sprint, Story points
python3 papertype-jira.py PROJ-123 --url # print a 24-hour download link instead of savingThe argument is an issue key or any Jira URL that contains one (the browse URL, a board URL with selectedIssue=); from a URL the site is taken too, so JIRA_SITE is optional then. What the script sends to Jira, all GET, each quoted from the REST API v3 reference fetched 2026-10-25:
/rest/api/3/issue/{key}?expand=renderedFields,names&fields=summary,issuetype,status,…,description,environment. The reference:renderedFields"returns field values rendered in HTML format" andnames"returns the display name of each field", which is where the table's labels come from (Fix versions, Story point estimate, your custom field's name). Needs the Browse projects permission; classic scoperead:jira-work./rest/api/3/issue/{key}/comment?expand=renderedBody&maxResults=100&startAt=…&orderBy=created→{comments: [{author, body, renderedBody, created, updated}], startAt, maxResults, total};renderedBodyis "the rendered version of the comment". The script pages withstartAtuntiltotal./rest/api/3/attachment/content/{id}?redirect=falsefor images: without the flag Jira answers303to the file store; with it, "returned if the request is successful when redirect is set to false" is a200with the bytes. Needs Browse projects on the issue./rest/api/3/search/jql?jql=…&fields=…&expand=renderedFields,names&maxResults=100&nextPageToken=…for--jql, see below.
Then one POST to https://papertype.dev/v1/pdf with the HTML the script built, the html field rather than markdown because the description arrives as HTML:
{
"html": "<h1>PROJ-123: Checkout fails with Apple Pay</h1><p class='lead'><strong>Bug · In Progress · High · Assignee Mia Krystof</strong></p><table class='meta-table'><tr><th>Status</th><td>In Progress</td></tr>…</table><h2>Description</h2><p>Main order flow broken.</p><p><img src=\"data:image/png;base64,…\" alt=\"picture.png\"></p>…<h2>Comments (2)</h2>…",
"css": "table.issues td, table.issues th { font-size: 9pt; } a.pg::after { content: target-counter(attr(href), page); } section.issue { page-break-before: always; } …",
"title": "PROJ-123: Checkout fails with Apple Pay",
"theme": "clean",
"page_size": "A4",
"page_numbers": true,
"filename": "PROJ-123-Checkout-fails-with-Apple-Pay.pdf",
"output": "pdf"
}Live result on 2026-10-25 for the test issue (fifteen fields, a description with an inline image, a sub-task, a linked issue, two attachments, two comments): 200, 2 pages with the report theme; the text read back from the PDF had the key and summary, the status, the assignee, the sprint name, the sub-task, the linked issue, the attachment's file name, both comments and the footer.
What goes on the page
Title KEY: summary; one bold line with type, status, priority and assignee; a table of every field that has a value, labelled with the names your Jira uses (type, status, priority, resolution, assignee, reporter, created, updated, resolved, due date, labels, components, fix versions, affects versions, parent, plus anything in --fields); Description; Environment when set; Sub-tasks and Linked issues as lists with each one's status ("is depended by PR-2 Payment SDK upgrade (Open)"); an Attachments table (file, size, who, when) with image attachments shown below it; Comments (n) in creation order with author, date and an edited marker; a footer line with the key, the site and the date. Rules, each exercised by the script's test:
- Rendered HTML first, ADF second. The description and comments use Jira's own HTML rendering when
renderedFields/renderedBodycarry one. When the rendered value isnull(the test's second issue), the script converts the Atlassian Document Format itself: headings, paragraphs, bullet and numbered lists, task lists (☑ / ☐), code blocks, quotes, panels, tables, rules, links, mentions, dates, status lozenges, emoji as their:short_name:(the renderer has no colour-emoji font), and a[attachment name]placeholder for media, whose ADF ids are not attachment ids. A custom field holding ADF prints as plain text in the table. - Values. Users print as their display name; options, statuses, versions and components as their name; lists comma-joined; the parent and linked issues as
KEY summary;2026-10-06T07:32:47.000+0000as2026-10-06 07:32 +0000(the zone Jira sent, unchanged); plain dates stay; numbers drop trailing zeros; booleans are Yes/No. Fields with no value are not rows. - Safety. Everything from a field is HTML-escaped; from the rendered HTML the script removes
<script>,<style>andon*=attributes, and makes site-relative links absolute so/browse/EX-10in a comment is clickable in the PDF. - Extra fields.
--fields customfield_10020,customfield_10016adds any field by its REST id, custom fields included; the label is whatever Jira'snamesmap says. The id is visible in the issue's JSON (…/rest/api/3/issue/KEYin a browser) or in Jira's custom-field admin screen; which screen shows it was not checked for this page. - Not included. Worklogs, the changelog (history), issue properties, votes and watchers are not read;
--fieldscannot add them because they are expansions, not fields. If you need one,read_issueis the function to extend, and tell us what you needed.
Release notes, sprint reports, audits: --jql
--jql renders every issue a JQL query matches. The default is one document: a title, the count and the query, an issue table (key, summary, type, status, priority, assignee, resolved, plus --fields) whose last column is the page each issue starts on, then a section per issue on a new page with the same content as a single-issue PDF. The page numbers are not guessed: the table's links point at each section's anchor and the CSS the script sends uses target-counter(attr(href), page), which the renderer resolves at layout time; the test read the first page back and checked that the three numbers ascend and start at 2.
python3 papertype-jira.py --jql 'fixVersion = 2.4.0 ORDER BY key' --title "Release 2.4.0" release.pdf
# release.pdf 5 page(s)
python3 papertype-jira.py --jql 'sprint in openSprints() AND project = PROJ' --table-only sprint.pdf # the table alone
python3 papertype-jira.py --jql 'project = PROJ AND resolved >= -7d' --comments weekly.pdf # sections with comments
python3 papertype-jira.py --jql 'project = PROJ AND resolved >= -7d' --separate --outdir pdfs # one file per issue
# 3 issue(s) match
# pdfs/PROJ-121-Checkout-fails-with-Apple-Pay.pdf 2 page(s)
# …What the reference says about the search endpoint, and what the script does with it: the query goes to GET /rest/api/3/search/jql (the older /rest/api/3/search is marked "currently being removed"); the JQL must be bounded, "a query with a search restriction" (assignee = currentUser() ORDER BY key yes, ORDER BY key alone no); fields defaults to id only, so the script names every field it needs; pages come back with nextPageToken and isLast, the token "will expire in 7 days" and is absent on the last page; maxResults is "max 5000" but "API may return fewer items per page where a large number of fields or properties are requested", so the script asks for 100 and follows the token until isLast. --separate searches for keys only and then reads each issue in full (comments included); --comments on the combined document fetches each issue's comments, one request per issue. A 400 from Jira for an invalid or unbounded query surfaces as Jira 400 on GET /rest/api/3/search/jql: <Jira's errorMessages>.
Images and attachments
The renderer never fetches from the web, so every image has to travel inside the request. The script inlines images that live on your Jira site as data: URIs: an <img> in the rendered description or a comment whose src is site-relative or on the site's host is fetched with your token (attachment URLs get ?redirect=false so the bytes come back directly), and image attachments from the issue's attachment list are fetched the same way and shown under the table. The budget is 1.2 MB of image bytes per document, which is about 1.6 MB as base64 and leaves room under the 2 MB request limit; past it, and for any image on another host (for example api.media.atlassian.com, which needs its own token), the image becomes a link labelled [image: name] and the script prints how many it linked. --no-images links all of them and makes no attachment requests at all. Non-image attachments are listed, never fetched.
What was not observed first-hand: the exact HTML Jira's renderer emits around an attached image (the OpenAPI document gives the attachment object, not the rendered markup; the script handles a plain <img src> with a site-relative or site-absolute URL, wrapped in anything) and whether renderedFields is populated for a custom ADF field (the ADF fallback covers the case it is not). If either differs on your site, the symptom is an image shown as a link or a field printed as plain text, not a failed render; tell support@papertype.dev and the page gets fixed.
Limits that matter
- Jira rate limits (rate-limiting page, 2026-10-25): a points-based model where "each request starts with a base cost of 1 point" and heavier operations cost more; over the limit "Jira returns an HTTP 429 Too Many Requests response" and asks clients to respect
Retry-After. The script waits forRetry-After(else 10 s) and retries three times. One issue costs 2 requests plus one per 100 comments plus one per inlined image; a--jqldocument costs one search page per 100 issues plus the images, or plus one comments request per issue with--comments. - Our quota. Free-plan output carries a small
papertype.devfooter mark. Paid plans (from $9/month) do not. The free plan is 50 documents a month and the API returns429at the limit; a nightly--separateover 60 issues needs the $9 plan (500 documents). A combined--jqldocument is one document however many issues it holds. Failed renders are not counted. - 2 MB of source per request, 300 pages per document, 2 concurrent renders per account. Images are the only thing that approaches the 2 MB request limit, hence the 1.2 MB image budget; a long issue's text is kilobytes.
- Errors. Jira errors surface as
Jira <status> on GET <path>: <errorMessages>using theerrorMessagesanderrorsof Jira's error body (an unknown key gives "Issue does not exist or you do not have permission to see it.", as the test checked); a401with an empty body says to checkJIRA_EMAILand the token. Ours surface asPaperType <status>: <detail>; a bad key givesPaperType 401: Missing or invalid API key. - 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
--urlthe finished PDF (never the issue data as text) is kept at a public, unguessable link for 24 hours, as the privacy policy describes. The Atlassian token is sent only to your Jira site (orapi.atlassian.comfor a scoped token); what reaches us is the HTML the script built, over TLS, inlined images included. The script never writes to Jira, so a read-only scope is enough. - Fonts. Installed fonts: DejaVu, Liberation, Noto, Carlito and Caladea. Embed any other font with an
@font-facerule whosesrcis adata:URI.
Questions
Did you test this against Jira?
Not against a site. The script file was executed on 2026-10-25 with its `jira()` and `fetch_bytes()` functions replaced by stubs returning the shapes the Jira Cloud platform OpenAPI document gives (an IssueBean with `fields`, `renderedFields` and `names`; a PageOfComments with `renderedBody`, paged; SearchAndReconcileResults with `nextPageToken` and `isLast`; an attachment answered with PNG bytes) and the PaperType half real: the single-issue render, the combined `--jql` document, `--table-only`, `--separate` and `--url` were posted to the live API, the PDFs saved and read back with pdftotext, the contents-table page numbers checked, the 24-hour link downloaded without a key, and the 401/404 paths checked (47 checks). The two details the docs do not show, the rendered markup around images and `renderedFields` for custom ADF fields, are handled both ways. If a Jira detail is wrong, tell support@papertype.dev and the page gets fixed.
Why not a Marketplace PDF exporter?
If you want a button in the issue view and a template designer, use one: on 2026-10-25 the Atlassian Marketplace listed Better PDF Exporter for Jira with 4,191 installs, Xporter with 3,741 and BigTemplate with 3,578, so the need is real and those apps are mature. They are installed into your site by an admin and priced in per-user tiers (two of the three are free up to 10 users, then priced per user). This script is for the other shape of the problem: a PDF produced by a job rather than a click, with nothing installed in Jira, authenticated with one person's read-only token, priced per document rather than per Jira user: nightly release notes, an audit copy of every resolved issue, an incident report posted to a chat from an automation, or a document for someone who is not a Jira user.
Does it work with Jira Data Center or Server?
It was written against the Cloud REST API v3 as documented on developer.atlassian.com (ADF descriptions, `renderedFields`, `search/jql` with `nextPageToken`, Atlassian API tokens). Data Center's API is v2 with wiki-markup descriptions and a different search endpoint, so no, not as it is; the request functions are small and the HTML assembly would carry over.
Can a Jira Automation rule trigger it?
Not directly: the rule would have to call a URL that runs the script for the issue key it is sent, which means a small webhook receiver on your side (the form-submission guide shows a 30-line Python one you can adapt; Jira Automation's web-request action and what it can send were not checked for this page). On a schedule, cron or a GitHub Actions job with the four environment variables as secrets is the simplest shape; the GitHub Actions guide has the workflow skeleton.
Custom fields, custom issue types, next-gen (team-managed) projects?
Custom fields: `--fields customfield_NNNNN`, labelled from Jira's `names` map; anything the API returns as a user, option, list, number, date or ADF document prints sensibly, and an unknown object shape prints its JSON truncated so nothing is silently dropped. Issue types and statuses are printed by name, so custom ones need nothing. Team-managed projects use the same issue endpoint and were not treated specially; their fields come back with the same shapes per the reference.
Related
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