DEV Community

Cover image for URL or HTML to PDF with one API request: curl, Python, Node.js, Java
Vitalii Nemudryi
Vitalii Nemudryi

Posted on

URL or HTML to PDF with one API request: curl, Python, Node.js, Java

Disclosure first: I'm the founder of PDFik, an API that turns a URL or HTML into a PDF, and this is a tutorial for it. Every command and program below was run in a clean Docker container against the live service on the Free plan before this post went out. Only three things were filled in: the key, a job id and the webhook address.

Most "URL or HTML to PDF" guides end with you looking after a headless browser: a Chromium binary in your image, fonts, a browser that refuses to start in Docker, memory spikes on a heavy page. This one goes the other way. PDFik turns a web page (by its URL) or your own HTML into a PDF file: an invoice, a report, a receipt. Your backend sends one request, and the response is the file. The browser is our problem.

Two ways to follow along

Without an account. Open the live sandbox on the homepage. A sample invoice is already in the box: pass the anti-bot check, press "Render my PDF" and download the file. Then edit the HTML or paste your own. It is the real service, the one paying customers use, not a mock-up; the PDF carries a watermark and you get a few renders a day.

With a free key. Create an account, confirm your email, and generate a key on the API Keys page of the dashboard. The Free plan gives 100 documents a month, asks for no card, and its files carry no watermark. Everything below uses that key: put it where the examples say sk_live_YOUR_KEY (or sk_live_...).

Prefer a client over curl? The same API is importable in one click: there is a
public Postman collection
(test mode on by default, so clicking through it costs nothing from your quota), and an
interactive API reference generated from the same OpenAPI
spec the service serves at api.pdfik.net/openapi.json.

1. URL to PDF: one request, file back

curl --fail -o invoice.pdf https://api.pdfik.net/url-to-pdf \
  -H "X-API-Key: sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "wait": true}'
Enter fullscreen mode Exit fullscreen mode

That is the whole integration. "wait": true holds the request while the page renders — most pages take a few seconds — and the 200 response is the PDF: invoice.pdf is on your disk. The job's id rides along in the X-PDFik-Job-Id response header, and the file also stays downloadable for 24 hours, like any other job's.

Keep --fail. Without it, a rare error answer (quota exceeded, timeout) would be saved into invoice.pdf as JSON. If curl reports an error, rerun the command without --fail to see the JSON and the reason.

What if the page is slow? The request is held for up to 45 seconds. If the render does not finish in time, you get 503 with "error": "SYNC_TIMEOUT" (sync-timeout): the job is cancelled and not charged, the Idempotency-Key (if you sent one) is released, and retrying the same request is safe. If a render is genuinely heavy — a huge page, a long wait_for_selector — don't fight the budget: submit it without wait and let the asynchronous flow from section 4 carry it; that one has no 45-second ceiling. A render that fails (page not found, blocked target) answers 422 with the job's own error code, listed in the error codes.

2. HTML to PDF: send your own HTML

Same shape, different field: send html instead of url. One rule to know before you paste a real template: on this endpoint <script>, <style> and <link> tags are removed, so styling goes into inline style="…" attributes, as in the example below. If your page needs full stylesheets or scripts, host it and send its address to /url-to-pdf, which renders the page as it is.

curl --fail -o invoice.pdf https://api.pdfik.net/html-to-pdf \
  -H "X-API-Key: sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<html><body><h1 style=\"color: #1e3a8a;\">Invoice #90812</h1><p>Thank you for your business!</p></body></html>",
    "options": {
      "format": "A4",
      "margin": {
        "top": "20mm",
        "bottom": "20mm"
      }
    },
    "wait": true
  }'
Enter fullscreen mode Exit fullscreen mode

options is where page format, margins, headers and footers, landscape and the rest live. The full list is in the PDF options reference.

3. The same one call from Python, Node.js and Java

There are official clients for all three. Each *_file / *File method below sends wait: true for you and returns the file bytes — no polling, no download step. The clients wait out the Free plan's rate limit by themselves, but they deliberately never resend a request after a sync timeout: the job was cancelled and not charged, so the retry is your decision, not the SDK's.

Python

Install the client with pip install pdfik (0.5.0 or later):

from pdfik import PdfikClient

client = PdfikClient(api_key="sk_live_...")

# The request is held while the page renders (up to 45 seconds)
# and the response is the PDF itself.
pdf = client.url_to_pdf_file("https://example.com")

with open("output.pdf", "wb") as f:
    f.write(pdf)
print("PDF saved to output.pdf")

client.close()
Enter fullscreen mode Exit fullscreen mode

The same method exists on AsyncPdfikClient as a coroutine, and it takes the same options=, render= and friends as its asynchronous sibling url_to_pdf. A sync timeout raises PdfikError with error_code == "SYNC_TIMEOUT".

Node.js

Install the client with npm install @pdfik/client (0.5.0 or later), save the file as example.mjs and run it with node:

import { writeFileSync } from 'fs';
import { PdfikClient } from '@pdfik/client';

const client = new PdfikClient({
  apiKey: 'sk_live_...' // Get your API key from the dashboard
});

// The response is the PDF itself — no polling, no download step.
const pdf = await client.urlToPdfFile('https://example.com');
writeFileSync('output.pdf', pdf);
console.log('PDF saved to output.pdf');
Enter fullscreen mode Exit fullscreen mode

Java

Add the dependency:

<dependency>
  <groupId>net.pdfik</groupId>
  <artifactId>pdfik-client</artifactId>
  <version>0.4.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode
import net.pdfik.PdfikClient;
import java.nio.file.Files;
import java.nio.file.Path;

public class Main {
    public static void main(String[] args) throws Exception {
        try (PdfikClient client = PdfikClient.builder()
                .apiKey("sk_live_...") // Retrieve from your dashboard
                .build()) {

            // The response is the PDF itself — no polling, no download step.
            byte[] pdf = client.urlToPdfFile("https://example.com");
            Files.write(Path.of("output.pdf"), pdf);
            System.out.println("PDF saved to output.pdf (" + pdf.length + " bytes)");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Every job-creating endpoint has this one-call form in all three clients — html_to_pdf_file, markdown_to_pdf_file, the screenshot twins returning image bytes, einvoice_to_pdf_file (spelled camelCase in Node.js and Java, and Java adds an ...Async twin returning CompletableFuture<byte[]>). The command-line client does the same with a --sync flag, and exits with code 5 when the 45-second budget runs out — cancelled, nothing charged; see the CLI guide.

4. Second act: batches, long renders and webhooks

wait is the right tool for "I need this document now". When you render many documents at once, or a page that may take longer than 45 seconds, drop wait and the API turns asynchronous: the answer is 202 Accepted, it arrives at once, and the page is rendered in the background.

curl -X POST https://api.pdfik.net/url-to-pdf \
  -H "X-API-Key: sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'
Enter fullscreen mode Exit fullscreen mode
{
  "job_id": "9b2f6c1e-4a7d-4f0e-b2c9-1d8e5a3f7b21",
  "status": "queued",
  "detail": "Job successfully sent to processing queue."
}
Enter fullscreen mode Exit fullscreen mode

Ask for the status until it says done, using the job_id you got back. If it says failed instead, stop asking: error_code names the reason (PAGE_NOT_FOUND, PAGE_TIMEOUT and others, listed in the error codes).

curl https://api.pdfik.net/jobs/9b2f6c1e-4a7d-4f0e-b2c9-1d8e5a3f7b21 \
  -H "X-API-Key: sk_live_YOUR_KEY"
Enter fullscreen mode Exit fullscreen mode
{
  "status": "done",
  "created_at": "2026-10-20T15:37:24.368000Z",
  "finished_at": "2026-10-20T15:37:25.357000Z",
  "expires_at": "2026-10-21T15:37:25.357000Z",
  "test": false,
  "pages_count": 1,
  "error_code": null,
  "metrics": {
    "file_size_bytes": 32988,
    "file_size_human": "32.2 KB",
    "page_load_ms": 614,
    "processing_ms": 198,
    "total_duration_ms": 717,
    "page_count": 1
  }
}
Enter fullscreen mode Exit fullscreen mode

Then download the file:

curl -o my-first.pdf \
  https://api.pdfik.net/jobs/9b2f6c1e-4a7d-4f0e-b2c9-1d8e5a3f7b21/download \
  -H "X-API-Key: sk_live_YOUR_KEY"
Enter fullscreen mode Exit fullscreen mode

Two limits to know on day one. A finished file can be downloaded for 24 hours (expires_at is the exact moment it stops), and each file allows 3 download attempts. So fetch it once and keep it in your own storage.

Stop polling: let the API tell you

Polling is fine for a first try. In production you pass a webhook_url and move on; when the PDF is ready, your endpoint gets a POST.

curl -X POST https://api.pdfik.net/url-to-pdf \
  -H "X-API-Key: sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "webhook_url": "https://your-app.com/hooks/pdfik"
  }'
Enter fullscreen mode Exit fullscreen mode

What arrives is a small JSON notification, never the file itself:

{
  "job_id": "9b2f6c1e-4a7d-4f0e-b2c9-1d8e5a3f7b21",
  "status": "done",
  "test": false,
  "file_url": "https://api.pdfik.net/jobs/9b2f6c1e-4a7d-4f0e-b2c9-1d8e5a3f7b21/download",
  "pages_count": 2,
  "finished_at": "2026-08-04T12:00:04Z",
  "expires_at": "2026-08-05T12:00:04Z"
}
Enter fullscreen mode Exit fullscreen mode

A failed job is reported too, with "status": "failed". For a finished one, file_url is the same download address as above: fetch it once, with your X-API-Key header, and keep the file.

If your endpoint is down, delivery is attempted six times over about two minutes. If all six fail, the job is not affected: its status and its file are still there under GET /jobs/{job_id}. Generate a signing secret on the dashboard's Webhooks page (every plan, Free included) and each delivery carries an HMAC-SHA256 signature in X-PDFik-Signature, so your endpoint can refuse anything that did not come from us. The few lines of verification code for Python, Node.js and Java are in the webhooks guide.

By the way, a wait request may carry a webhook_url too — the notification arrives as usual. And the asynchronous flow is in the clients as well: url_to_pdf → wait_for_job → download_pdf in Python, the camelCase twins in Node.js and Java; each client README shows the complete program.

5. Test your integration for free

Add "test": true to any request. The job goes through the whole pipeline — validation, queue, status changes, the webhook — but nothing is rendered and nothing is counted against your quota. Combined with wait, the response is a small sample PDF, at once and for free. That is what you want in CI.

curl --fail -o sample.pdf https://api.pdfik.net/url-to-pdf \
  -H "X-API-Key: sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "wait": true, "test": true}'
Enter fullscreen mode Exit fullscreen mode

Without wait it works exactly like a real asynchronous job — including the webhook, which arrives carrying "test": true — and the download returns the same sample file.

6. Same request, other outputs

Markdown to PDF — for reports and docs you already keep as Markdown:

curl --fail -o report.pdf https://api.pdfik.net/markdown-to-pdf \
  -H "X-API-Key: sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "markdown": "# Monthly Report\n\n| Metric | Value |\n| --- | --- |\n| Renders | 1,204 |\n\nGenerated ~~manually~~ automatically.",
    "options": { "format": "A4" },
    "wait": true
  }'
Enter fullscreen mode Exit fullscreen mode

Screenshots — the same page as a PNG or JPEG instead of a PDF:

curl --fail -o page.png https://api.pdfik.net/url-to-image \
  -H "X-API-Key: sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "options": { "format": "png", "full_page": true },
    "wait": true
  }'
Enter fullscreen mode Exit fullscreen mode

Two more things work the same way and are available on every plan, Free included:

  • E-invoices. POST /einvoice-to-pdf takes invoice XML and returns a Factur-X / ZUGFeRD PDF: an invoice a person can read, with the same data embedded as XML for accounting software. It takes "wait": true like everything else. There is also a no-code invoice builder in the dashboard: add your logo, pick colours and watch a live preview. Details in the e-invoicing guide.
  • Delivery to your own bucket. Give the request a presigned upload URL and the file goes straight to your storage; nothing is kept on our side. The one combination that does not exist is delivery + wait — the file either comes back in the response or goes to your bucket, so sending both is rejected with 400 before anything is charged. See delivery to your own bucket.

And the knobs are there for the day you need them. The examples above are minimal on
purpose; the same request bodies also take page furniture and output controls: header and
footer templates with page numbers, text watermarks, user_password to encrypt the PDF,
compression for size-sensitive files, paper formats and margins — the full list is in the
PDF options reference. Screenshots accept an
exact viewport instead of the 1024×768 default. And on Pro and up the renderer can wait
for a specific element to appear and fetch pages behind a login — basic or bearer
credentials, attached only to same-origin requests; see the
url-to-pdf guide.

7. What it costs

Plan Price per month Documents per month Total file size per month Requests per minute
Free $0 100 0.5 GB 10
Starter $29 5,000 10 GB 100
Pro $99 50,000 50 GB 500
Business $449 500,000 300 GB 2,000

These are the prices in October 2026; annual billing takes 20% off the paid plans. For invoices and ordinary reports the Documents column is the one that matters: the one-page PDFs made in this post came out at 9 to 33 KB each. There are no per-document overage charges: when a monthly limit is reached, new documents are refused until the next period starts or you upgrade. The service is hosted in the EU (Frankfurt).

A word on the Free plan's 10 requests a minute. It is a speed limit, not a price: with wait one document costs exactly one request, and the asynchronous loop takes three to five (create, one to three status checks, download) — plenty for trying things out one document at a time. So run the examples one at a time, and for batches use the webhook from section 4, which needs no status checks at all, or a paid plan. Everything else is on the pricing page.

8. When you should not use an API for this

If you make a handful of PDFs a day inside a service that already runs its own browser, keep doing that; sending them to an outside service gains you nothing. An API earns its place when the load comes in bursts, when the URL or the HTML comes from your users and you would rather not open it on your own network, or when you simply do not want to run and update a browser yourself. If you do run it yourself, I wrote up what breaks first in Chromium in Docker without --no-sandbox.

That is the whole product: one request in, one file out. To see it without an account, open the sandbox on the homepage. To run the commands above, get a free key; it asks for no card. The quickstart repeats the first steps, and the reference pages it links to spell out every option. If something here did not work for you as written, tell me in the comments and I will fix the post.

Top comments (0)