Where this code lives: extracted from the PDFik platform monorepo (last sync 2026-11-11). Releases to npm are cut from the monorepo; issues and PRs are welcome here.
Official JavaScript/TypeScript SDK for PDFik — the URL/HTML-to-PDF API.
One call returns the file: pass a public URL or raw HTML and get the PDF (or screenshot) bytes back in the same response — the server answers within about 45 seconds. For batches, webhook-driven pipelines and renders longer than that, the same endpoints run asynchronously: submit, get a job id back, and receive an HMAC-signed webhook (or poll) when the file is ready. Rendering runs on sandboxed headless Chromium, so modern CSS, web fonts and JavaScript-heavy pages come out the way they look in the browser.
- TypeScript Native: Full auto-complete and type safety for all endpoints.
- Dual Build: ES Modules (ESM) and CommonJS (CJS) support.
- Zero Runtime Dependencies: Uses native
fetch(Node 24+, browsers, Edge). - One Call, One File:
urlToPdfFileand the other*Filemethods send the job withwait: trueand return the output bytes directly — no polling, no download step. - Auto Retry: The asynchronous methods retry
5xxerrors and network failures with exponential backoff (3 attempts). A request that gets no HTTP answer at all — DNS failure, refused or reset connection, TLS error — or whose connection breaks while the body is being read is reported as aPdfikErrorwitherrorCode: 'NETWORK_ERROR'andstatusCode: 503. A rate-limit or concurrency-limit429is waited out for the time the API states on every call — create, status, download and the synchronous*Filemethods — and so is a busy download (download-busy); quota429s are thrown at once, without a retry. The*Filemethods never resend after a5xxor a network failure, and in particular not after a503 SYNC_TIMEOUT: the job was cancelled and not charged, and the retry is yours to decide. Every create call carries anIdempotency-Keyheader — theidempotencyKeyyou pass, otherwise one generated per call and reused on every resend — so a retried create returns the original job instead of creating a second one. - DX Affordances: Built-in polling logic (
waitForJob) and binary download helpers. - E-invoicing: Factur-X / ZUGFeRD hybrid PDF/A-3 output — generated from your CII XML alone (
einvoiceToPdf) or attached to your own render (theeinvoiceoption).
npm install @pdfik/client
# or
yarn add @pdfik/client
# or
pnpm add @pdfik/clientimport { writeFileSync } from 'fs';
import { PdfikClient } from '@pdfik/client';
const client = new PdfikClient({
apiKey: 'sk_live_YOUR_KEY' // Get your API key from the dashboard
});
const pdf = await client.urlToPdfFile('https://example.com');
writeFileSync('page.pdf', pdf);urlToPdfFile — and its siblings htmlToPdfFile, markdownToPdfFile, urlToImageFile, htmlToImageFile and einvoiceToPdfFile — sends the job with wait: true: the server holds the request and answers with the file itself, within about 45 seconds (the SDK sets no timers of its own). Each takes the same options as its asynchronous sibling below, minus delivery — with wait the response is the file, so there is nothing left to deliver to your own bucket and the server rejects the combination.
Three answers are possible:
- The file (
Uint8Array) — the common case; most pages render in a few seconds. The file also stays downloadable for the normal 24 hours. - A
PdfikErrorwitherrorCode: 'SYNC_TIMEOUT'(HTTP503) — the render did not finish in time. The job was cancelled and not charged, and itsIdempotency-Keywas released, so retrying is safe — but the SDK never resends it for you: the retry is yours to decide. (Rate-limit429s are still waited out automatically, as on every other call.) For renders this slow, submit withoutwaitand use a webhook or polling. - A
PdfikErrorwith the render's own error code (HTTP422) — e.g.PAGE_TIMEOUTorPAGE_NOT_FOUND: the same codes a failed job status would show.
import { writeFileSync } from 'fs';
import { PdfikError } from '@pdfik/client';
try {
const png = await client.urlToImageFile('https://example.com', {
options: { format: 'png', fullPage: true },
});
writeFileSync('page.png', png);
} catch (e) {
if (e instanceof PdfikError && e.errorCode === 'SYNC_TIMEOUT') {
// Cancelled and not charged. Retry if you choose to — the SDK never
// retries a sync timeout for you — or submit without wait and poll.
} else {
throw e;
}
}A server from before synchronous mode ignores wait and answers 202 with a job id; the *File methods detect that and throw (the message names the job id so you can poll it) instead of handing the JSON back as file bytes.
For batches, webhook-driven pipelines and renders longer than 45 seconds, submit the job and fetch the result when it is ready:
import { writeFileSync } from 'fs';
import { PdfikClient } from '@pdfik/client';
// Initialize the client
const client = new PdfikClient({
apiKey: 'sk_live_YOUR_KEY' // Get your API key from the dashboard
});
async function generatePdf() {
try {
// 1. Submit the URL to be rendered
const job = await client.urlToPdf('https://example.com', {
options: {
format: 'A4',
landscape: false,
printBackground: true,
margin: { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' }
}
});
console.log(`Job created: ${job.jobId}. Waiting for rendering...`);
// 2. Poll until the job completes
const result = await client.waitForJob(job.jobId);
console.log(`Job finished! Pages: ${result.pagesCount}`);
// 3. Download the PDF bytes
const pdfBytes = await client.downloadPdf(job.jobId);
// Save to file (Node.js example)
writeFileSync('output.pdf', pdfBytes);
console.log('PDF saved to output.pdf');
} catch (error) {
console.error('Failed to generate PDF:', error);
}
}
generatePdf();const job = await client.htmlToPdf('<h1>Hello World</h1><p>Sent from PDFik SDK</p>', {
options: {
format: 'Letter',
displayHeaderFooter: true,
headerTemplate: "<span style='font-size: 10px;'>My Invoice Header</span>",
footerTemplate: "<span style='font-size: 10px;'>Page <span class='pageNumber'></span></span>"
}
});Get the direct API download URL for the generated PDF (requires the "X-API-Key" header to download):
const { downloadUrl } = await client.getFileUrl(job.jobId);
console.log(`Direct Download URL: ${downloadUrl}`);Pass test: true to run a job through the full pipeline (statuses, webhook, download) without real rendering — the download returns a small sample PDF. Test jobs are free (no PDF or byte quota is debited) and rate-limited instead: 60 test calls per minute and 2,000 per day per account. Job status and webhook payloads always carry test: true|false, so you can safely exercise your integration end to end:
const job = await client.urlToPdf('https://example.com', { test: true });
const result = await client.waitForJob(job.jobId);
console.log(result.test); // true
console.log(result.expiresAt); // e.g. "2026-08-05T12:00:00Z"
const pdfBytes = await client.downloadPdf(job.jobId); // sample PDFmarkdownToPdf converts Markdown (CommonMark + GFM tables and strikethrough) with a built-in print stylesheet. Raw HTML inside the Markdown is escaped, not rendered — use htmlToPdf for full HTML control. The same options (paper format, margins, header/footer, watermark, ...) and job flow apply. The Markdown may be up to 100,000 characters (longer input is rejected with 422); Markdown whose converted document is too large for the processing queue is rejected with 413 (payload-too-large-for-queue) and is not charged. Example:
const job = await client.markdownToPdf('# Report\n\n| Item | Price |\n| --- | --- |\n| Render | $0.01 |', {
options: { format: 'A4' },
});
await client.waitForJob(job.jobId);
const pdfBytes = await client.downloadPdf(job.jobId);urlToImage / htmlToImage capture a page as a PNG (default) or JPEG instead of a PDF. Options: format ('png' | 'jpeg'), fullPage (default false - the visible area only; with true the height follows the real page and is clipped at 8,192 px, which is a ceiling and not a target, so a 2,000 px page still gives a 2,000 px image, and horizontal overflow beyond the viewport width is never captured), quality (1-100, JPEG only) and viewport (width/height; you pick the window size and we capture exactly that - nothing is scaled or fitted - width 320-1920, height 320-8192, defaults to 1024x768). Polling is identical to the PDF endpoints, and downloadPdf returns the raw image bytes (image/png or image/jpeg, filename {jobId}.png/.jpg):
const job = await client.urlToImage('https://example.com', {
options: {
format: 'jpeg',
quality: 80,
fullPage: true,
viewport: { width: 1280, height: 720 },
},
});
await client.waitForJob(job.jobId);
const imageBytes = await client.downloadPdf(job.jobId); // JPEG bytes
const htmlShot = await client.htmlToImage('<h1>Hello</h1>'); // 1024x768 PNGurlToImage also accepts the auth option (basic/bearer), exactly as urlToPdf. Passing quality together with PNG is rejected with 422.
Pass delivery (every plan, on every job-creating method) and the output is uploaded straight to your own bucket via a presigned PUT URL; nothing is stored on PDFik's side. The URL must be https on the standard port 443 (any other port is rejected with 422); presign it for at least 15 minutes and without a Content-Type condition:
const job = await client.urlToPdf('https://example.com', {
delivery: { url: presignedPutUrl }, // mode: 'presigned_put' is the only mode and may be omitted
});
await client.waitForJob(job.jobId); // 'done' means the PUT to your bucket succeededOnce a delivered job is done, the job.finished webhook reports the outcome only: it carries neither file_url nor expires_at, because PDFik keeps no copy and never records where the file went (the presigned URL is a credential, so it is used once and forgotten). You already know the destination — you signed it. downloadPdf answers 404 (output-delivered-externally) — the file only exists in your bucket. delivery cannot be combined with test: true (400).
PDFik can produce hybrid e-invoices: a normal, human-readable PDF that also embeds your UN/CEFACT Cross-Industry-Invoice (CII) XML as factur-x.xml, normalized to PDF/A-3. Outputs are validated with veraPDF and Mustangproject. Available on every plan;
Schema-valid does not mean tax-compliant — the invoice content is the caller's responsibility. The
minimumandbasicwlprofiles carry accompanying data only and are NOT a legally sufficient e-invoice in DE/FR.
Send just the XML; PDFik builds the human-readable invoice from a block template (design one on the dashboard E-Invoice page, or pass an inline definition):
import { readFileSync } from 'fs';
const xml = readFileSync('invoice.xml', 'utf8'); // UN/CEFACT CII XML, UTF-8, up to 1 MB
const job = await client.einvoiceToPdf(xml, {
profile: 'en16931', // 'minimum' | 'basicwl' | 'basic' | 'en16931' | 'extended' (default 'en16931')
templateId: '550e8400-...', // optional saved template; omit to use your account default
webhookUrl: 'https://yourserver.com/webhooks/pdf',
});
await client.waitForJob(job.jobId);
const pdfBytes = await client.downloadPdf(job.jobId);templateId and template (an inline block-template definition — the same JSON the dashboard editor produces) are mutually exclusive; omit both to use your account's default template. The XML is validated against the official XSD of the declared profile before any quota is spent, and the XML's guideline parameter must match the profile. Invoices (TypeCode 380) and credit notes (381) are supported.
If you already render the visual invoice yourself, pass einvoice to urlToPdf / htmlToPdf — the rendered page becomes the visual half and the output is normalized to PDF/A-3 with the XML embedded:
const job = await client.htmlToPdf(invoiceHtml, {
einvoice: {
format: 'factur-x', // only value in v1 (may be omitted)
profile: 'en16931',
xml,
},
});The einvoice option is mutually exclusive with options.userPassword (PDF/A forbids encryption) and options.compression (re-saving breaks the PDF/A attributes).
422einvoice-xml-invalid — the XML failed XSD validation or does not match the declared profile.422einvoice-options-conflict —einvoicecombined withuserPassword/compression, ortemplateIdcombined withtemplate.404einvoice-template-not-found — unknowntemplateIdfor this account.413payload-too-large-for-queue — the XML does not fit the job queue even compressed.- A job that fails during rendering reports
errorCode: 'EINVOICE_FAILED'in the job status.
- Generated volume per month (byte quota, counts rendered output only — downloads are free): Free 0.5 GB, Starter 10 GB, Pro 50 GB, Business 300 GB. Business plans can purchase additional +1 GB blocks from the dashboard. Exceeding the quota returns
429(quota-bytes-exceeded). - File size: up to 150 MB per PDF on every plan — contact support if you need more. A larger render fails the job with
FILE_TOO_LARGE(file-too-large). - Retention: each file is available for download for 24 hours after the job finishes; the exact deadline is the
expiresAtfield in the job status (expires_atin the webhook payload). After that the download returns410(file-expired). - Downloads: at most 3 download attempts per file (counted when the stream starts); after that the API returns
429(download-attempts-exhausted) — re-render the file or contact support. Only 1 download stream may be active at a time; a concurrent request returns429(download-busy), which does not burn an attempt —downloadPdfwaits about 10 seconds and asks again by itself.
If you specify webhookUrl in the options of urlToPdf or htmlToPdf, PDFik will send an HTTP POST request to your server when the rendering job is finished.
You should verify the authenticity of the webhook by validating the signature. We provide a static helper verifyWebhookSignature in PdfikClient for this purpose.
Deliveries are signed once you generate a signing secret on the dashboard's Webhooks
page (every plan, Free included); until then — or after you delete the secret — they
arrive without the X-PDFik-Signature and X-PDFik-Timestamp headers, and
verifyWebhookSignature returns false for them. While a secret is set, every delivery
carries X-PDFik-Signature: t=<unix seconds>,v1=<signature> — the
hex HMAC-SHA256 of <t>.<raw body> under your webhook secret — and X-PDFik-Timestamp;
a t more than 300 seconds away from now is rejected. When you rotate the secret with
an overlap window (picked in the dashboard: 1 hour to 5 days, or none at all), every
delivery inside the window is signed with both secrets, current first:
t=<unix seconds>,v1=<signature with the new secret>,v1=<signature with the previous secret>.
verifyWebhookSignature accepts a delivery if any v1 matches the secret you pass, so
a receiver still holding the previous secret keeps working — switch each receiver to the
new secret before the window closes.
Custom delivery headers (e.g. a Cloudflare Access service token) are configured once on the dashboard Webhooks page and attached to every delivery — nothing to pass per request.
import express from 'express';
import { PdfikClient } from '@pdfik/client';
const app = express();
// IMPORTANT: Use raw body parser to get the exact raw bytes for verification
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const webhookSecret = process.env.PDFIK_WEBHOOK_SECRET || 'whsec_...';
const rawBody = req.body.toString('utf8');
// Pass req.headers directly (contains X-PDFik-Signature and X-PDFik-Timestamp)
const isValid = PdfikClient.verifyWebhookSignature(rawBody, req.headers as any, webhookSecret);
if (!isValid) {
console.error('Invalid webhook signature!');
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(rawBody);
console.log(`Webhook received for job: ${event.job_id}, status: ${event.status}`);
// The payload has no event type: a finished job carries status 'done' (or 'failed').
// file_url is absent for jobs delivered to your own bucket (the delivery option).
if (event.status === 'done' && event.file_url) {
console.log(`PDF rendered! Download URL: ${event.file_url}`);
}
res.status(200).send('OK');
});
app.listen(3000, () => console.log('Webhook server running on port 3000'));constructor(config: { apiKey: string; baseUrl?: string })The constructor throws a TypeError when baseUrl is not an absolute http(s) URL, or when apiKey cannot be sent as an HTTP header (a line break, or a character above U+00FF such as a pasted smart quote).
urlToPdf(url, opts): Submits a job to convert a public URL to a PDF. Returns aJobCreatedResponse.htmlToPdf(html, opts): Submits a job to convert raw HTML markup to a PDF. Returns aJobCreatedResponse.markdownToPdf(markdown, opts): Submits a job to convert Markdown (CommonMark + GFM tables) to a PDF. Returns aJobCreatedResponse.urlToImage(url, opts): Submits a job to capture a public URL as a PNG/JPEG screenshot. Returns aJobCreatedResponse.htmlToImage(html, opts): Submits a job to capture raw HTML markup as a PNG/JPEG screenshot. Returns aJobCreatedResponse.einvoiceToPdf(xml, opts): Submits a Factur-X e-invoice job — builds the human-readable invoice from your CII XML with a block template, renders to PDF/A-3 and embeds the XML asfactur-x.xml. Returns aJobCreatedResponse.urlToPdfFile(url, opts)/htmlToPdfFile(html, opts)/markdownToPdfFile(markdown, opts)/urlToImageFile(url, opts)/htmlToImageFile(html, opts)/einvoiceToPdfFile(xml, opts): Synchronous variants of the six methods above — send the same request withwait: trueand return the output file itself as aUint8Array(the PDF, or PNG/JPEG bytes for the image methods); the server holds the request for up to ~45 s. Sameoptsas the corresponding method, minusdelivery. On timeout they throw aPdfikErrorwitherrorCode: 'SYNC_TIMEOUT'(HTTP503— the job was cancelled and not charged; never resent by the SDK), and a failed render throws the job's own error code (HTTP422).getJob(jobId): Fetches the status of a specific job. Returns aJobStatusResponse.waitForJob(jobId, opts): Helper that pollsgetJobuntil the job isdoneorfailed. Throws aPdfikErrorif the job fails or times out.opts.timeoutMs(default:120000ms)opts.pollIntervalMs(default:2000ms)
getFileUrl(jobId): Returns the direct download URL for the PDF (requires the "X-API-Key" header).downloadPdf(jobId): Downloads and returns the output file as aUint8Array— the PDF, or for image jobs the raw PNG/JPEG bytes.verifyWebhookSignature(rawBody, headers, webhookSecret): (Static) Verifies the webhook HMAC-SHA256 signature using the raw request body and request headers (header names in any case):truewhen anyv1inX-PDFik-SignaturematcheswebhookSecretand itstis within ±300 s of now. Returnsboolean.
MIT License.