Overview
PrysM Tools generates finished, print-ready PDFs from JSON. One endpoint covers all four business documents, the type field picks the template. Everything else about the payload is shared, so adding a second document type to your integration is a one-word change.
invoiceInvoice
Priced lines, tax, totals, payment details.
quoteQuote
Same as an invoice, with a validity date.
credit_noteCredit note
A refund. Amounts represent money going back.
delivery_noteDelivery note
Goods received. No prices, no payment.
https://prysm.tools/api/v1/documents/generateThe PDF comes back as base64 by default, or stored in your PrysM Cloud with a signed link. Text is vector, searchable and selectable, not a picture of a document.
Stored documents are then yours to work with from the API too: find them, read them, download them, email them from your own mailbox, get them paid, turn a quote into its invoice, delete and restore them. See Documents in the cloud.
A free trial on every account
Creating an account is enough to start calling this endpoint, no card, and no clock counting down from the day you sign up. The trial allowance is granted once and does not renew, so it is there for building and testing the integration rather than for running it. A plan adds a monthly quota on top; it never consumes what is left of the trial. Your remaining balance is always on the API dashboard, and in the usage block of every response. Documents rendered on the trial hold up to 25 line items and 10 pages; on a plan, a document may run to 50 pages. Every document, trial or plan, built-in theme or custom template, must render within 15 seconds.
Authentication
Every request carries a secret key as a bearer token. Keys are created in your API dashboard and shown once; only a SHA-256 digest is stored, so a lost key is replaced rather than recovered.
Authorization: Bearer prysm_sk_live_8f3a2b1c94ed07bb215af6390c4d81e7
Content-Type: application/jsonTreat a key like a password
Anyone holding it can generate documents billed to your account. Keep it server-side, never in a browser bundle, a mobile app, or a public repository. Revoke a leaked key from the dashboard; it stops working on the next request.
Quickstart
A complete, working invoice. Replace the key and run it, the response contains a base64 PDF you can write straight to disk.
curl -X POST https://prysm.tools/api/v1/documents/generate \
-H "Authorization: Bearer prysm_sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"type": "invoice",
"document": {
"number": "INV-2026-0042",
"issued_on": "2026-09-19",
"due_on": "2026-10-19",
"currency": "EUR",
"language": "en",
"tax_label": "VAT"
},
"sender": {
"name": "Meridian Studio Ltd",
"address": "18 Fontaine Street",
"postal_code": "75011",
"city": "Paris",
"country": "France",
"tax_id": "FR62902471338",
"legal_form": "SASU au capital de 1 000 EUR"
},
"recipient": {
"name": "Northwind Labs Inc.",
"address": "4 Opera Square",
"postal_code": "69001",
"city": "Lyon",
"country": "France"
},
"line_items": [
{
"name": "Brand identity redesign",
"description": "Logotype, palette and usage guidelines.",
"quantity": 1,
"unit_price": 4800,
"tax_rate": 20
},
{
"name": "Product interface design",
"quantity": 12,
"unit_price": 620,
"tax_rate": 20
}
],
"payment": {
"iban": "FR76 3000 4008 2800 0123 4567 890",
"bic": "BNPAFRPPXXX"
},
"notes": {
"terms": "Payment due within 30 days - bank transfer."
}
}'Request body
The payload mirrors the seven blocks of the editor, so a field on screen and a field in JSON are the same field. Unknown keys are rejected rather than ignored, a typo produces a 422 naming it, not a silently incomplete document.
typerequiredWhich template to render.typeenumrequiredinvoice,quote,credit_noteordelivery_note.
documentrequiredNumbering, dates, currency, presentation.numberstringrequired- Your own reference, e.g.
INV-2026-0042. issued_ondaterequiredYYYY-MM-DD. Real calendar dates only, 31 February is refused.due_ondate- Required when
payment_timingisdue_by. Cannot precedeissued_on. valid_untildate- How long a quote's price holds.
payment_timingenumdefault due_bypaidprints “Paid on”,due_byprints “To be paid by” with the date,dueprints “To be paid” with no date.statusenumdefault pendingpaid,pendingoroverdue.currencyenumdefault EUREUR,USD,GBP,CHF,RON,PLN,HUF,NOK,SEK,DKK. Symbol placement follows the document language.languageenumdefault enen,en-US,fr,es,de,it. Translates the printed sheet, labels, dates, number formatting.en-USis US English: American dates and numbers, the ZIP code after the city, and US Letter paper.paper_sizeenumdefault per languagea4orletter. Defaults toletterforen-USanda4for every other language.themeenumdefault minimalistminimalist,modern,bold, orcustom, your own HTML/CSS template, named bytemplate_id. See Custom templates.template_iduuid- Required with
theme: "custom", refused otherwise. The template's ID, from the dashboard's Custom templates page. Cloud Pro and above. title_overridestring- Replaces the printed title. Australian invoices must read “Tax Invoice”.
tax_labelstring- Renames the tax column:
VAT,TVA,GST,Sales Tax. logostring- A PNG, JPEG, WebP or GIF image, as a
data:image/…URL or anhttps://one. SVG is not accepted. Nothing else is accepted. referencestring- Purchase order or internal reference.
correctsobject- Credit notes:
{ number, date }of the invoice this one corrects. Printed on the document and carried in its Factur-X data; EU law wants a credit note to name the invoice it amends. shipping_addressstring- Printed as a separate block when present.
watermarkbooleandefault false- Off by default on paid plans. Calls spent from the free trial allowance of a Free account always carry the watermark, whatever this says, the response reports it in
document.watermarked. An invoice or credit note without the watermark, from an issuer in the EU, carries its data as Factur-X (EN 16931) inside the PDF, reported indocument.e_invoiceas"factur-x", ornullwhen a figure the standard needs is missing (the seller's VAT or tax number, for one). When the workspace's compliance country has a file of its own, Italy's FatturaPA, Romania's e-Factura, Belgium's Peppol UBL, the response also carries it, base64, ine_invoice_file: { format, filename, content }, or what it lacks in{ format, missing: [...] }. A workspace in Germany gets the XRechnung there (format"xrechnung") whenever the invoice can be one; what it lacks is reported only for a public buyer, a recipient whosee_invoice_addressis a Leitweg-ID, which requires it. A workspace in Austria gets its ebInterface 6.1 the same way (format"ebinterface"), one in Spain its Facturae 3.2.2, unsigned (format"facturae"), and one in the Netherlands, Luxembourg, Sweden, Denmark or Norway its Peppol BIS file (format"peppol"), for an invoice to a business; Norway, outside the EU, has no Factur-X. Never in webhook events. An invoice still to pay, in CHF or EUR, to a Swiss or Liechtenstein IBAN, ends with a page carrying the Swiss QR-bill. depositobject- Invoices only. A deposit already received, printed under the total and deducted from the amount due:
{ "mode": "percent", "value": 30 }or{ "mode": "fixed", "value": 1500, "label": "Deposit | order 1042" }. A percentage is of what the customer pays. The response then addsdepositandbalance_dueto the totals. withholdingnumberdefault 0- Tax withheld at source, a percentage of the total excluding tax, Spain's IRPF retention (
15, or7in the first years of activity), Italy's ritenuta d'acconto (20). Printed after the total and deducted from the amount due; the response addswithholdingandbalance_dueto the totals. EN 16931 has no place for it, so such an invoice carries no Factur-X: the Spanish Facturae and the Italian FatturaPA (DatiRitenuta) state it.
sender · recipientrequiredBoth sides. Same shape; only name is required.namestringrequired- Trading name.
legal_namestring- Registered name, when it differs.
address · postal_code · city · countrystring- Printed as the party block.
email · phonestring- Contact details.
tax_idstring- VAT or sales-tax number, e.g.
FR62902471338. registration_idstring- SIRET, company number, EIN.
legal_formstring- Legal form and share capital, e.g.
SASU au capital de 1 000 €. registration_citystring- Registry and city, e.g.
RCS Paris 902 471 338. e_invoice_addressstring- Where e-invoices reach this party, written
scheme:identifier: France's directory (0225:315143296), a Peppol ID (0208:0123456749), or an Italian SdI recipient code (7 characters) or PEC address. Carried in the e-invoice data (Factur-X BT-34 / BT-49, Peppol endpoint, SdI) and printed under the party's identifiers.
line_itemsrequired1 to 200 entries. The priced body of the document.namestringrequired- What is being charged for.
descriptionstring- A second line under the name.
quantitynumberdefault 1unit_pricenumberdefault 0- In the document's currency, as a decimal:
1499.90, not minor units. tax_ratenumberdefault 0- A percentage.
20means 20%. discountnumberdefault 0- Per-line discount, percentage.
unitstring- What the quantity counts, printed after it. One of
unit,hour,day,week,month,year,flat,kg,m,m2,km,l, translated into the document's language (dayprints “12 jours” on a French invoice), or any text up to 24 characters, printed as written. photostring- Thumbnail beside the line. Same rules as
logo.
adjustmentsUp to 20. Document-level discounts and surcharges.labelstringrequired- Printed in the totals, e.g.
Shipping. kindenumrequireddiscountorsurcharge.modeenumrequiredpercentorfixed.valuenumberrequired- A percentage or an amount, per
mode.
paymentBank details. Not allowed on a delivery note.iban · bic · bank_namestringpayment_linkstring- An
https://URL to a hosted payment page. instructionsstring- Free text under the bank block.
notesTerms and statutory mentions.termsstring- The short line under the totals, e.g.
Payment due within 30 days. legalstring- The free-text legal footer: statutory mentions, an Impressum, the French €40 recovery-fee clause. Printed above the watermark.
delivery_instructionsstring- Delivery notes only. Handling instructions, never payment language.
outputHow you want the result back.formatenumdefault base64base64returns the bytes inline.urlstores the PDF in your PrysM Cloud and returns a signed link.filenamestring- Without the extension;
.pdfis appended. folder_iduuidurlonly. The cloud folder to file it under.
Business rules
A few constraints are enforced because breaking them produces a document that is wrong rather than malformed. These return 422 with the offending field named.
A delivery note is not a payment request
Sending payment with type: "delivery_note" is refused, and so is any payment_timing other than due. Printing an IBAN and a deadline on a proof of receipt invites a customer to pay against a document that is not an invoice, a real accounting problem, not a cosmetic one. Put handling notes in notes.delivery_instructions.
due_onis required whenpayment_timingisdue_by, and cannot precedeissued_on.valid_untilcannot precedeissued_on.folder_idonly applies whenoutput.formatisurl.template_idis required whenthemeiscustom, and refused with any other theme.- Images must be PNG, JPEG, WebP or GIF, as
data:image/…orhttps://, SVG is refused. Plainhttp,file:and protocol-relative URLs are refused, and remote fetches are blocked for private and loopback addresses. - The whole request body is capped at 4 MB. Reference large images by URL rather than inlining them.
Custom templates
On Cloud Pro, Business and Scale, a workspace can design its own invoices, quotes, credit notes and delivery notes in HTML and CSS, CSS grid, flexbox, web fonts, in the dashboard's Custom templates page, with the real PDF redrawn beside the code as you type. Tick Ready for API, save, and print with it by naming it in the request. Everything else in the request stays the same; the built-in themes keep working exactly as before.
{
"type": "invoice",
"document": {
"number": "INV-2026-0042",
"issued_on": "2026-10-06",
"due_on": "2026-11-05",
"theme": "custom",
"template_id": "3f1c2a9e-5b7d-4e21-9a0c-1d2e3f4a5b6c"
},
"sender": { "name": "Northwind Labs" },
"recipient": { "name": "Acme Corp" },
"line_items": [{ "name": "Design sprint", "quantity": 1, "unit_price": 4800, "tax_rate": 20 }]
}The template language
Templates use Liquid. The variables are named like the request body, document, sender, recipient, line_items, adjustments, payment, notes , plus totals, computed exactly as in the response, and labels, the printed words already translated into document.language. Amounts and dates arrive formatted for that language (totals.total → 1 234,56 €); add _amount for the number itself (totals.total_amount). Every printed value is HTML-escaped.
<header class="top">
{% if document.logo != "" %}<img src="{{ document.logo }}" alt="">{% endif %}
<h1>{{ document.title }} {{ document.number }}</h1>
<p>{{ labels.issued_on }} {{ document.issued_on }}</p>
</header>
<table>
{% for item in line_items %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.quantity }} {{ item.unit }}</td>
<td>{{ item.total }}</td>
</tr>
{% endfor %}
</table>
<p class="total">{{ labels.total_incl_tax }} {{ totals.total }}</p>What a template can and cannot do
- Stylesheets and fonts may be imported from Google Fonts, jsDelivr, unpkg and cdnjs. They are downloaded and embedded when the template is saved, so rendering never waits on another server. Anything else is dropped, with a warning in the dashboard.
@pagesets the paper and margins (by default the document's paper, A4, or US Letter, with no margin;document.paper_sizesays which). Page breaks followbreak-before,break-insideand friends.- No scripts, no forms, no frames, and no network while rendering: images are the logo and photos you send (inlined, like with the built-in themes) or
data:URLs in the template. The Liquid tagsinclude,renderandlayoutare not available. - Up to 200 KB of HTML and 200 KB of CSS per template, 20 templates per workspace. The render limits, time and 50 pages per document, are the same as for the built-in themes.
Refusals
Checked before anything is charged: a plan without custom templates answers 402 payment_required; a template_id that is not one of the workspace's templates, one made for another document type, or one not marked Ready for API answers 422 invalid_request. A template that cannot be filled with the request's data, a loop past its limits, say, answers 422 too, and is charged like a render that runs out of time.
Responses
200 The document was generated. Totals are included so you never have to re-derive VAT to reconcile against your own ledger.
Inline | output.format: base64
{
"type": "invoice",
"created_at": "2026-09-19T10:24:31.882Z",
"document": {
"number": "INV-2026-0042",
"currency": "EUR",
"page_count": 1,
"byte_size": 148203,
"watermarked": false,
"e_invoice": "factur-x",
"totals": {
"subtotal": 12240.00,
"total_excl_tax": 12240.00,
"tax": 2448.00,
"total": 14688.00,
"tax_breakdown": [
{ "rate": 20, "base": 12240.00, "tax": 2448.00 }
]
}
},
"output": {
"filename": "INV-2026-0042.pdf",
"format": "base64",
"content": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBl…"
},
"usage": {
"used": 7413,
"quota": 10000,
"remaining": 2587,
"allowance": "plan",
"resets_at": 1790812800
}
}Stored | output.format: url
The PDF is written to your PrysM Cloud, consuming one storage slot, and comes back as a link on prysm.tools valid for one hour. The document also appears in your cloud and can be reopened in the editor.
{
"type": "invoice",
"document": { "number": "INV-2026-0042", "page_count": 1 },
"output": {
"filename": "INV-2026-0042.pdf",
"format": "url",
"url": "https://prysm.tools/api/v1/files/8f3a2b1c-94ed-07bb-215a-f6390c4d81e7?token=1790816431.pKQ2u…",
"expires_at": "2026-09-19T11:24:31.882Z",
"document_id": "8f3a2b1c-94ed-07bb-215a-f6390c4d81e7"
},
"usage": { "used": 7414, "quota": 10000, "remaining": 2586, "allowance": "plan" }
}The link needs no credentials, the signed token in it is the authorisation, which is what lets you pass the URL straight to a browser, an email or a download manager. Keep the document_id rather than the URL: once the hour is up, generating the same document again returns a fresh link, and the id is the only part that does not expire. The file is always served as a download, and a link answers at most 20 times an hour per address and 200 times a day, store the PDF on your side if you need to serve it more often.
Storage is a plan feature. On a plan without it the request is refused with 402 rather than silently falling back to base64, a document you asked to be stored and were not told was dropped is worse than an error.
Documents in the cloud
The documents of your PrysM Cloud: those the API stored with output.format: "url", those saved from the editor, those your apps filed (Stripe, PayPal, Mollie, Shopify, WooCommerce). It is the same cloud as the Cloud page, not a copy: a document deleted here waits in its History, a document marked paid here reads as paid there, a quote converted here sits beside its invoice there.
| Request | What it does, and the permission it needs |
|---|---|
| GET/api/v1/documents | Find documents: type, status, number, customer, dates, folder, History.documents.read |
| GET/api/v1/documents/{id} | One document, its pay link and its last email; with include=content, the document itself.documents.read |
| GET/api/v1/documents/{id}/pdf | The PDF, or a link to it signed for an hour.documents.download |
| POST/api/v1/documents/{id}/send | Email it to the customer from Gmail, Outlook or your SMTP server.documents.download, apps.operate |
| POST/api/v1/documents/{id}/pay-link | A pay link for an invoice: Stripe, PayPal, Mollie.documents.download, apps.paylinks |
| POST/api/v1/documents/{id}/mark-paid | Record a payment made outside PrysM.documents.update |
| POST/api/v1/documents/{id}/convert | Turn a quote into its invoice, numbered, printed and filed.documents.download, documents.create |
| DELETE/api/v1/documents/{id} | Move it to History, restorable for 24 hours.documents.delete |
| POST/api/v1/documents/{id}/restore | Bring it back out of History.documents.update |
| GET/api/v1/folders | The cloud's folders, for folder_id.folders.read |
The same key as for generating. Each request needs, in the key's workspace, the permission shown: your role grants it (Owner, Admin and Member hold them all; a Viewer reads and downloads), and a key made with scopes holds only those. Each costs one request of the monthly allowance, taken only once the request is served; a refusal is never charged. Sending an email costs the email itself (one request), converting a quote costs its render and its filing, as generate does with a stored link.
Find documents | GET /api/v1/documents
Newest first, 25 a page (up to 100 with limit). Every filter is optional, and they combine.
queryall optionaltypestring- invoice, quote, credit_note, delivery_note (or several, comma separated).
statusenum- paid, due, overdue (invoices:
overdueis unpaid past its due date); open, accepted, declined, converted (quotes: the answer is marked on the Cloud page,convertedonce invoiced). numberstring- The document's number, exactly.
qstring- Contained in the number, the customer's name or email, or the file name.
customer_emailstring- The customer's email, exactly (any case).
folder_iduuid- A folder (GET /api/v1/folders), or root for documents outside any.
issued_from · issued_todate- Issue dates, inclusive, as YYYY-MM-DD.
deletedbooleandefault false- true lists History: documents deleted in the last 24 hours.
limitintegerdefault 25- 1 to 100.
cursorstring- The previous page's next_cursor, as it came.
curl "https://prysm.tools/api/v1/documents?type=invoice&status=overdue&limit=50" \
-H "Authorization: Bearer prysm_sk_live_…"{
"data": [
{
"id": "6f1c2a9e-4b7d-4e0a-9c3f-2d8b7e1a5c40",
"type": "invoice",
"number": "INV-2026-0042",
"status": "overdue",
"file_name": "INV-2026-0042.pdf",
"folder_id": null,
"customer": { "name": "Northwind Labs Inc.", "email": "accounts@northwindlabs.io" },
"issued_on": "2026-10-01",
"due_on": "2026-10-15",
"currency": "EUR",
"total": 1440,
"amount_due": 1440,
"paid": null,
"converted_from": "0b7e2d41-9a3c-4f58-8e16-5c2a7d9b3e04",
"converted_to": null,
"answered_at": null,
"byte_size": 48213,
"editable": true,
"created_at": "2026-10-01T09:12:44.512+00:00",
"updated_at": "2026-10-01T09:12:44.512+00:00"
}
],
"has_more": true,
"next_cursor": "MjAyNi0xMC0wMVQwOToxMjo0NC41MTIrMDA6MDA"
}Documents saved before PrysM kept their type and amounts beside them are read the first time you search: until every one is, indexing: true says a search may miss a few, for a moment. A document saved without its content keeps a null type; opening it in the editor and saving it again fixes that.
One document | GET /api/v1/documents/{id}
The same fields, with the invoice's pay link and the last email it went out with. Add include=content for the document itself, in the very shape generate takes, so a stored document can be read, changed and generated again (images, the logo and line photos, are left out). That is reading the document: it needs documents.download too.
{
"id": "6f1c2a9e-4b7d-4e0a-9c3f-2d8b7e1a5c40",
"type": "invoice",
"number": "INV-2026-0042",
"status": "overdue",
"…": "the fields above",
"pay_link": {
"id": "c4a1e9b2-7d3f-4e60-8a15-9b2c7e4d1f08",
"url": "https://prysm.tools/pay/Vb3kQ9xR2mT7pL4wN8cZ1yH6",
"status": "open",
"amount": 1440,
"currency": "EUR",
"paid_at": null,
"paid_with": null,
"created_at": "2026-10-01T09:13:02.118+00:00"
},
"email": { "status": "sent", "via": "gmail", "to": "accounts@northwindlabs.io", "sent_at": "2026-10-01T09:13:05.940+00:00", "error": null }
}The PDF | GET /api/v1/documents/{id}/pdf
The file itself, always as a download. With format=url, a link to it on prysm.tools signed for one hour instead, for a step that wants a URL rather than bytes. Downloads count toward the workspace's daily download allowance, like the Cloud page's.
Email it | POST /api/v1/documents/{id}/send
From the workspace's own mailbox, to its customer, the PDF attached (with the country's e-invoice file for an invoice or a credit note): Gmail, Outlook or the SMTP server connected in the App Store, once it is allowed to write to customers. Invoices, credit notes, quotes and delivery notes; from Cloud Pro.
bodyall optionalviaenum- gmail, outlook or smtp. Left out: the mailbox sending automatically, else the only one allowed to write to customers.
toemail- The recipient. Left out: the document's customer.
subjectstring- Your words for this email (200 characters). Left out: the mailbox's own (its app page), else PrysM's, in the document's language. Fields: {document} {number} {customer} {issuer} {amount} {due_date} {pay_link}.
messagestring- Plain text, a blank line starts a paragraph (5,000 characters). The same fields.
resendbooleandefault false- A number is emailed once: sent before, the answer is 409 unless this is true.
include_pay_linkbooleandefault true- An invoice with an open pay link offers it in the message, unless your words already place {pay_link}.
// POST /api/v1/documents/6f1c2a9e-…/send { "via": "smtp", "resend": true }
{
"id": "6f1c2a9e-4b7d-4e0a-9c3f-2d8b7e1a5c40",
"number": "INV-2026-0042",
"status": "sent",
"via": "smtp",
"to": "accounts@northwindlabs.io",
"sent_at": "2026-10-27T08:30:12.402Z",
"resent": true,
"pay_link": "https://prysm.tools/pay/Vb3kQ9xR2mT7pL4wN8cZ1yH6"
}A mailbox that refuses the message answers 502 upstream_error, in its own words; the sending is listed on the mailbox's app page, where it can be sent again.
Get it paid | POST …/pay-link and …/mark-paid
pay-link makes the page the customer pays an invoice on, by card with Stripe, with PayPal or with Mollie: whichever the workspace connected, or those named in providers. It is the editor's own link: its amount is what the invoice still asks for, computed from its lines; asked again for the same amount, the same link comes back (reused: true). Once paid, the invoice reads as paid and invoice.paid goes out.
// POST /api/v1/documents/6f1c2a9e-…/pay-link { "providers": ["stripe", "paypal"] }
{
"id": "6f1c2a9e-4b7d-4e0a-9c3f-2d8b7e1a5c40",
"number": "INV-2026-0042",
"pay_link": {
"url": "https://prysm.tools/pay/Vb3kQ9xR2mT7pL4wN8cZ1yH6",
"amount": 1440,
"currency": "EUR",
"providers": ["stripe", "paypal"],
"status": "open",
"reused": false
}
}mark-paid records money that came in another way: a transfer, cash, a cheque. Its body is optional: paid_on (YYYY-MM-DD, today by default), amount (what is due by default), method and reference (free text). The invoice then reads as paid, its open pay link stops taking payments, and invoice.paid goes out with payment.via "manual". The PDF itself stays as it was issued.
Quote to invoice | POST /api/v1/documents/{id}/convert
The invoice takes the quote's customer, lines, discounts, payment details and notes, names the quote as its reference, and is filed beside it. Then it goes where every new invoice goes: the tax channel, the customer's email when automatic sending is on, accounting, Slack, webhooks (invoice.issued). The quote reads as converted, and each points at the other. A quote is converted once.
bodyall optionalnumberstring- The invoice's number. Left out: the next of the workspace's invoice series, taken once, so two conversions never share one (after INV-2026-0041 comes INV-2026-0042).
issued_ondatedefault todaydue_ondatedefault issued_on + 30 dayspayment_timingenumdefault due_by- due_by, due or paid, as in generate.
folder_iduuiddefault the quote's- Where the invoice is filed.
template_iduuid- A custom template made for invoices, ready for the API.
filenamestringdefault the number- Without .pdf.
curl -X POST https://prysm.tools/api/v1/documents/0b7e2d41-…/convert \
-H "Authorization: Bearer prysm_sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "due_on": "2026-11-26" }'
# 201: the invoice, as generate answers with a stored link, and where it came from
# { "type": "invoice", "converted_from": { "id": "0b7e2d41-…", "number": "QUO-2026-0017" },
# "document": { "id": "6f1c2a9e-…", "number": "INV-2026-0042", "totals": { … } },
# "output": { "format": "url", "url": "https://prysm.tools/api/v1/files/6f1c2a9e-…?token=…", … } }History | DELETE /api/v1/documents/{id} and POST …/restore
Deleting moves the document to History, exactly as the Cloud page does: restorable for 24 hours (POST …/restore, while the cloud has room for it), then gone for good, its PDF and its content with it. document.deleted goes out with restorable_until.
Built for no-code tools
Every one of these is one HTTP step in Zapier, Make or n8n: find the overdue invoices every morning and email them again, mark an invoice paid when your bank says so, turn a quote into its invoice when the deal is won in your CRM, file the PDF in your drive.
Idempotency
Send an Idempotency-Key header on every request that costs money. Use a fresh, unique value per logical operation, a UUID is ideal.
Idempotency-Key: 3f1a9c2e-7b4d-4f10-9a52-0c8e1d6b7a34Why this matters
A network timeout does not tell you whether the work happened. Without a key, your retry renders a second document and consumes a second request, the customer is billed twice for one intended operation. With a key, the retry returns the original response, byte for byte, and nothing is charged again.
- A repeat of a completed request replays the stored response with
Idempotent-Replay: true. - A repeat while the first is still running returns 409 , wait and retry.
- The same key with a different body returns 422. That is a bug on your side, and replaying the first response would hide it.
- A failed request releases its key, so you can retry with the same one and actually get a document.
- Keys are remembered for 24 hours.
Errors
Every failure returns the same envelope. code is the contract and is safe to branch on; message is for humans and may be reworded.
{
"error": {
"code": "invalid_api_key",
"message": "This API key is unknown, revoked, or no longer valid."
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON. |
| 401 | unauthorized | No credentials, or an expired access token. |
| 401 | invalid_api_key | The key is unknown, revoked or no longer valid. |
| 402 | payment_required | The free allowance is spent and the plan includes no requests. Upgrade to Cloud Pro or above. |
| 403 | forbidden | The key is valid, but its holder no longer has API access in this organization. |
| 404 | not_found | No such document in the key's workspace, or a stored file link points at one that no longer exists. |
| 409 | conflict | A request with this Idempotency-Key is still running; or the document was already emailed, converted or marked paid. |
| 413 | payload_too_large | The body exceeds 4 MB. |
| 415 | unsupported_media_type | Content-Type is not application/json. |
| 422 | invalid_request | The payload failed validation. See `details`. |
| 429 | quota_exceeded | Monthly allowance spent, or the rate limit hit. See `Retry-After`. |
| 500 | internal_error | Generation failed. The request was not charged. |
| 502 | upstream_error | A service the workspace connected (its mailbox) refused or could not be reached, in its own words. |
| 503 | not_configured | Limits are temporarily unreadable. Retry shortly. |
Validation errors name the field
A 422 carries a details array with one entry per problem, all of them, so you fix your payload in one pass rather than discovering the next error on the next call.
{
"error": {
"code": "invalid_request",
"message": "The request payload is not valid. See `details`.",
"details": [
{
"field": "document.due_on",
"message": "`due_on` is required when `payment_timing` is \"due_by\"."
},
{
"field": "line_items.2.tax_rate",
"message": "Too big: expected number to be <=100"
},
{
"field": "line_item",
"message": "Unknown field: line_item. Check the spelling, or remove it."
}
]
}
}Rate limits & quotas
Two separate ceilings. The monthly quota is how many documents your plan includes. The rate limit is how fast you may spend it, and exists to protect the service.
Before either applies there is the free trial, on every account, granted once. It is spent only while your plan includes no requests of its own, so it is not consumed by subscribing and not topped up by it either. Every response carries the balance, so the integration never has to guess:
"usage": { "used": 3, "quota": 20, "remaining": 17, "allowance": "free_trial", "resets_at": null }resets_at is null on the free allowance because there is nothing to wait for. Once it is spent the endpoint answers 402 , not 429, which would send a well-behaved client into a retry loop that can never succeed.
| Plan | Requests / month | Cloud storage | Webhooks |
|---|---|---|---|
| Free · Premium | Free trial | — | — |
| Cloud Pro | 10,000 / month | 10,000 PDFs | 1 endpoint |
| Business API | 50,000 / month | 50,000 PDFs | 3 endpoints |
| Scale | 200,000 / month | 250,000 PDFs | 10 endpoints · custom |
The rate limit is 30 requests every 10 seconds per account. Exceeding it returns 429 with a Retry-After header in seconds.
What counts
A call that is served costs one request of the monthly allowance; a refusal costs nothing. A few calls do more than one thing, and each thing counts once:
| Call | Requests |
|---|---|
| POST /v1/documents/generate | 1, and 1 more with output.format "url": the filing in your cloud |
| POST /v1/documents/{id}/convert | 2: the invoice's render and its filing |
| POST /v1/documents/{id}/send | 1: the email itself. One that does not leave costs nothing |
| Every other call | 1 |
| What your apps do next | 1 each: an email sent automatically or a reminder, a sending to a tax network, a sync to Odoo or Pennylane, a webhook or a Slack message |
| What the Cloud does on its own | A recurring invoice: 2 (its render and its filing), then its apps. A quote signed online: 2 (the signed copy printed and filed), then its email and webhooks |
Each plan also includes rendering time, concurrent renders and downloads within fair-use limits, sized well above what normal use of the plan needs (see the Acceptable Use Policy). A request past them answers 429 and is not charged.
Every response carries your remaining monthly allowance, so you can back off before you hit the wall rather than after:
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 2588
X-RateLimit-Reset: 1790812800What fails on our side is never charged
The request is reserved before the work and settled after it. When it fails on our side, a storage or network fault, the reservation is released; a document that was not filed, or an email that did not leave, gives its request back. A document refused for what it is (too many pages, too heavy, too slow to print, a template that cannot be filled) used the renderer, and is charged, as its error says.
Webhooks
Instead of asking PrysM whether something happened, let PrysM tell you. When a document is generated, stored or deleted, when a key is revoked or your quota nears its end, PrysM sends an HTTPS POST to the endpoints you registered, signed so you can prove it came from us.
Endpoints are managed in the API dashboard, under Webhooks: a URL, the events it should receive, and a signing secret shown once. Cloud Pro includes one endpoint, Business API three, and Scale ten, with custom webhooks.
Events
| Type | Sent when |
|---|---|
document.generated | A document was generated: through the API, or by the Stripe app for a payment or a refund. Carries the document's type, number and totals, the output (with a signed link when it was stored), the API key and your Idempotency-Key. From the Stripe app, `document.source` is "stripe" and a `stripe` object adds the payment or refund id, the payer's name and email, the amount and the day it was paid, what it takes to send the invoice on. |
document.stored | A PDF was stored in PrysM Cloud, by the API with output.format "url", saved from the editor, or filed by the Stripe app (with the same `stripe` object). |
document.updated | A stored document was replaced from the editor. |
document.deleted | A stored document was moved to History, from the Cloud page or the API. restorable_until says how long it can still be brought back. |
usage.threshold_reached | The plan's monthly quota reached 80 % or 100 %. Once per threshold per month. |
api_key.created | A new API key was created in the organization. |
api_key.revoked | An API key was revoked. Useful to alert on, as a security event. |
invoice.issued | A new invoice was issued: exported in the editor, made by the API, by a Stripe, PayPal or Mollie payment, or a paid Shopify / WooCommerce order. Carries `document` (type, number, dates, currency, totals, amount due, status, customer name, email, tax id, country, and `lines`: name, description, quantity, unit, unit price, discount, tax rate, total), `pdf` (`url`, a link to the PDF signed for 7 days, `filename`, `expires_at`; null if the file could not be kept) and `source`. |
credit_note.issued | A new credit note was issued, in the same shape as invoice.issued. |
invoice.paid | Money came in for an invoice: its pay link was paid, or a payment (Stripe, PayPal, Mollie) or a shop's order made it already paid. Or it was marked paid through the API (`via`: manual, with `method` and `reference`). Carries `document` and `pdf` as invoice.issued, and `payment` (`via`, `pay_link`, `amount`, `currency`, `paid_at`). |
einvoice.accepted | A tax channel accepted an e-invoice (accredited platform, KSeF, SdI, ANAF, NAV, Peppol): `document`, `channel`, and the official number when it gave one. |
einvoice.rejected | A tax channel refused an e-invoice, or never received it: `document`, `channel` and `error`, in the channel's own words. |
quote.accepted | A customer accepted a quote on its acceptance page, by signing it: `document` (id, number, customer, total), `answered_at` and `signer.name`. The signed quote then replaces the original in the cloud (`document.updated`). |
quote.declined | A customer declined a quote on its acceptance page: `document`, `answered_at` and the `reason` they gave, if any. |
webhook.test | Sent from the dashboard's Send test button, to one endpoint. |
The request
A JSON envelope, the same for every event. data holds the event itself. A base64 PDF is never put in an event, when the document was stored, data.output.url is a signed link to it.
POST /webhooks/prysm HTTP/1.1
Content-Type: application/json
User-Agent: PrysM-Webhooks/1.0 (+https://prysm.tools/docs#webhooks)
webhook-id: evt_7c1f0a9e5b3d4e2f8a6b1c0d9e8f7a6b
webhook-timestamp: 1790934067
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4={
"id": "evt_7c1f0a9e5b3d4e2f8a6b1c0d9e8f7a6b",
"type": "document.generated",
"created_at": "2026-10-02T09:41:07.312Z",
"organization_id": "5f0c2a4e-8b1d-4c3e-9f2a-7d6e5c4b3a21",
"data": {
"document": {
"type": "invoice",
"number": "INV-2026-0042",
"currency": "EUR",
"page_count": 1,
"byte_size": 48213,
"totals": {
"subtotal": 4800,
"total_excl_tax": 4800,
"tax": 960,
"total": 5760
}
},
"output": {
"format": "url",
"filename": "INV-2026-0042.pdf",
"document_id": "b2d4f6a8-1c3e-4a5b-8d7f-9e0a1b2c3d4e",
"url": "https://prysm.tools/api/v1/files/b2d4f6a8-1c3e-4a5b-8d7f-9e0a1b2c3d4e?token=…",
"expires_at": "2026-10-02T10:41:07.000Z"
},
"api_key": {
"id": "0e9d8c7b-6a5f-4e3d-2c1b-0a9f8e7d6c5b",
"prefix": "prysm_sk_live_8f3a2b1c"
},
"idempotency_key": "order-1042-invoice"
}
}Verifying the signature
Signatures follow the open Standard Webhooks specification, so you can use its library in your language or ten lines of your own. The signed content is webhook-id.webhook-timestamp.body, the key is the base64 part of your whsec_… secret, and the algorithm is HMAC-SHA256.
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.PRYSM_WEBHOOK_SECRET; // whsec_…
export function verifyPrysmWebhook(rawBody, headers) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"] ?? "";
// A captured request cannot be replayed later.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(SECRET.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
// Several signatures can be present, separated by spaces.
return signatures.split(" ").some((part) => {
const [version, value] = part.split(",");
const given = Buffer.from(value ?? "", "base64");
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
}Verify the raw body, before parsing it
The signature covers the exact bytes PrysM sent. Parse the JSON first and re-serialise it, and a single space of difference breaks the comparison. Reject a timestamp more than five minutes away from your clock: a captured request then cannot be replayed later.
A complete receiver
// app/api/webhooks/prysm/route.js: Next.js; any framework works the same way.
import { verifyPrysmWebhook } from "@/lib/prysm";
export async function POST(request) {
const raw = await request.text(); // the raw body, before any JSON parsing
if (!verifyPrysmWebhook(raw, Object.fromEntries(request.headers))) {
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(raw);
// At least once: a retry can arrive after you processed the first attempt.
if (await alreadyHandled(event.id)) return new Response(null, { status: 200 });
switch (event.type) {
case "document.generated":
await queue.add("attach-invoice", event.data); // slow work goes to a queue
break;
case "usage.threshold_reached":
await alertTeam(`PrysM quota at ${event.data.threshold}%`);
break;
}
await markHandled(event.id);
return new Response(null, { status: 200 }); // within 10 seconds
}Responding, retries and ordering
Answer 2xx within 10 seconds.
Anything else, an error, a timeout, a redirect, is a failed delivery. Acknowledge first and do slow work in the background.
Failures are retried.
After 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours: eight attempts over about two days.
At least once, not exactly once.
A retry can arrive after your server did process the first attempt. webhook-id is the event id and stays the same across retries, store the ids you have handled and skip repeats.
No ordering guarantee.
Deliveries run in parallel and retries overtake. Use created_at, not arrival order, when order matters.
A dead endpoint is disabled.
When five events in a row fail every attempt and nothing has been delivered for three days, the endpoint is switched off. Re-enable it from the dashboard once it is fixed; failed deliveries can be resent individually.
What an endpoint must be
HTTPS, on port 443 or 8443, on an address reachable from the internet. Private, loopback, link-local and reserved addresses are refused, when you save the endpoint and again at every delivery, after DNS resolution, so a name cannot be pointed somewhere private later. Redirects are not followed. Never put credentials in the URL: verify the signature, or on Scale add your own header.
The Send test button in the dashboard delivers a webhook.test event immediately and shows your server’s answer, the quickest way to check a new receiver.
Custom webhooks
A webhook sends PrysM’s standard, signed event, you adapt your server to it. A custom webhook adapts to your server: you decide the headers, the shape of the body, and which events reach which endpoint. It is how an event goes straight into Slack, Microsoft Teams, a no-code tool or an internal gateway without writing a relay. Custom webhooks are part of Scale, set per endpoint in the dashboard.
| Webhook | Custom webhook · Scale | |
|---|---|---|
| Body | The standard envelope | Your own JSON template |
| Headers | PrysM's, signed | PrysM's + up to 5 of yours |
| Events | By type | By type, document type and API key |
| Signature, retries, logs | Yes | Yes | computed over your body |
Custom headers
Up to five, sent with every delivery | typically the Authorization header your gateway expects. Values are encrypted at rest and never shown again. They cannot replace content-type, user-agent or the webhook-* headers: the signature stays verifiable.
Payload template
A JSON document whose string values can name fields of the event. It is data, not code, there are no expressions and nothing runs. A value that is only a placeholder keeps its type; a placeholder inside a longer string becomes text. Paths start at the envelope: id, type, created_at, organization_id, data.…. A path that does not exist becomes null, or an empty string inside text.
{
"text": "New {{data.document.type}} {{data.document.number}} | {{data.document.totals.total}} {{data.document.currency}}",
"invoice": {
"number": "{{data.document.number}}",
"total": "{{data.document.totals.total}}",
"pdf": "{{data.output.url}}"
},
"source": "prysm"
}The signature headers are still sent, computed over the body your template produced, so a receiver that can verify does.
Filters
Narrow an endpoint to some document types (only invoices to the accounting system) or some API keys (events caused by the production key to the production endpoint, staging to staging). Events without that field, a key revoked, the quota reached, are not filtered.
Downgrading keeps you safe
Plans are applied at every delivery. Move below Scale and custom endpoints fall back to the standard event; move to a plan with fewer endpoints and the oldest ones keep receiving, up to the new limit. Nothing has to be edited, and nothing is sent that the plan does not include.