Endpoint reference

POST /api/invoice

Generate a complete, professionally laid-out US-Letter invoice PDF from a JSON body — header, FROM/BILL TO blocks, items table, totals, notes. No template upload. Deterministic: the same JSON returns a byte-identical PDF. Prefer a form? The free generator at /tools/invoice calls this endpoint.

This is the first PDFops endpoint that ships its own template: /api/fill-form fills an AcroForm PDF you bring; /api/invoice draws the whole document from data. That makes it a natural fit for a Stripe webhook that turns a payment into an invoice PDF, a monthly billing cron, or freelancer tooling — see the Stripe walkthrough and the invoices use case.

Request

Method: POST. URL: https://pdfops.dev/api/invoice. Body: application/json.

FieldTypeRequiredDescription
fromstring or objectYesSeller, rendered in the FROM block. A plain string ("Acme Studio LLC"), or { name, lines } where lines is up to 6 address lines rendered under the name.
tostring or objectYesBuyer, rendered in the BILL TO block. Same shape as from.
itemsarrayYes1 to 100 line items — schema below.
invoice_numberstringNoPrinted in the invoice header.
datestringNoInvoice date, shown in the header. Defaults to today. Also pins the PDF's metadata dates — pass it explicitly for byte-identical re-renders (see Deterministic output).
duestringNoDue date, shown in the header.
currencystringNoISO 4217 code, default USD. Formatting only, e.g. USD 1,200.00 — no conversion.
tax_ratenumberNoPercent, 0 to 100. Adds a tax line between subtotal and total.
notesstringNoFree-text block rendered under the totals.

Each entry in items:

FieldTypeRequiredDescription
descriptionstringYesWhat the line is for.
quantitynumberNoMust be > 0. Defaults to 1.
unit_pricenumberYesPrice per unit, ≥ 0, in the invoice currency. The line amount is quantity × unit_price.
HeaderRequiredDescription
X-API-KeyNoOptional. Same auth semantics as /api/fill-form and /api/merge: no header → anonymous, 100 requests/IP/month; valid key → your tier's quota; invalid or revoked key → 401 invalid_api_key.

Successful (2xx) calls count 1 request against your quota — the same meter as the other PDF endpoints. Check consumption with GET /api/usage.

Full request example

{
  "from": { "name": "Acme Studio LLC", "lines": ["100 Main St", "Portland, OR 97201"] },
  "to": "Globex Corporation",
  "invoice_number": "INV-0042",
  "date": "2026-07-21",
  "due": "2026-08-20",
  "currency": "USD",
  "tax_rate": 8.5,
  "items": [
    { "description": "Design retainer — July", "quantity": 1, "unit_price": 2400 },
    { "description": "Landing page build", "quantity": 3, "unit_price": 650 }
  ],
  "notes": "Payment by bank transfer within 30 days."
}

Response

On success: 200 OK, Content-Type: application/pdf — a complete US-Letter invoice:

On any failure: 4xx, Content-Type: application/json, body is { "error": "<code>", "details": "<explanation>" }.

Deterministic output

The same JSON body returns a byte-identical PDF — every time. The PDF's internal metadata dates are pinned to the invoice date rather than the render time, so nothing in the file varies between runs. Hash the output today, render the same JSON tomorrow, get the same hash.

Practically, that means re-rendering is idempotent and safe: a Stripe webhook that retries, a monthly cron that re-runs, or a "regenerate" button that fires twice all produce the same file — dedup, caching, content-addressed storage, and audit diffs just work. There is no wall-clock input at all: the PDF's metadata dates are pinned to your date when given, and to a fixed epoch otherwise — never to render time — so a body that omits date is still byte-stable across days (it just has no date line on the document).

Branding

Anonymous calls and Free-tier keys produce PDFs with a small "Generated with pdfops.dev" footer line. Indie and Pro keys produce clean output with no branding — see /pricing.

Error codes

Error codeStatusTrigger
invalid_json400Request body isn't parseable JSON.
invalid_body400Body parsed but isn't a JSON object with the required fields.
invalid_from400from missing or the wrong shape — must be a non-empty string or { name, lines } with at most 6 lines.
invalid_to400to missing or the wrong shape — same rules as from.
invalid_items400items missing, empty, longer than 100, or an item fails validation (missing description, quantity ≤ 0, unit_price < 0).
invalid_currency400currency isn't an ISO 4217 code.
invalid_tax_rate400tax_rate isn't a number between 0 and 100.
invalid_api_key401An X-API-Key header was sent but the key is unknown or revoked. No fallback to the anonymous IP cap — get a fresh key at /pricing.
rate_limited (anonymous)429No X-API-Key header and the per-IP limit (100/month) is exhausted. Retry-After header gives seconds until next calendar month.
rate_limited (keyed)429Your key's tier quota (Free 250 / Indie 4,000 / Pro 25,000 per month) is exhausted. Retry-After header gives seconds until next calendar month.

Example — curl

curl -X POST https://pdfops.dev/api/invoice \
  -H "Content-Type: application/json" \
  -H "X-API-Key: pdfops_live_..." \
  -d '{
    "from": "Acme Studio LLC",
    "to": "Globex Corporation",
    "invoice_number": "INV-0042",
    "date": "2026-07-21",
    "items": [
      { "description": "Design retainer — July", "unit_price": 2400 }
    ]
  }' \
  -o invoice-INV-0042.pdf

The X-API-Key header is optional — drop it to use the anonymous quota (the PDF then carries the footer line).

Example — JavaScript: Stripe webhook → invoice PDF

The marquee pattern from the Stripe walkthrough: a Worker receives an invoice.paid event and renders the PDF. Because output is deterministic, Stripe's webhook retries re-produce the identical file — store it keyed by hash or invoice number and duplicates collapse for free.

export default {
  async fetch(req, env) {
    const event = await req.json(); // Stripe webhook — verify the signature first
    const inv = event.data.object;  // an invoice.paid Invoice object

    const res = await fetch('https://pdfops.dev/api/invoice', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': env.PDFOPS_KEY,
      },
      body: JSON.stringify({
        from: { name: 'Acme Studio LLC', lines: ['100 Main St', 'Portland, OR 97201'] },
        to: inv.customer_name,
        invoice_number: inv.number,
        date: new Date(inv.created * 1000).toISOString().slice(0, 10),
        currency: inv.currency.toUpperCase(),
        items: inv.lines.data.map((line) => ({
          description: line.description,
          quantity: line.quantity,
          unit_price: line.amount / 100 / line.quantity,
        })),
      }),
    });

    const pdf = await res.arrayBuffer();
    await env.BUCKET.put(`invoices/${inv.number}.pdf`, pdf); // R2, email it, etc.
    return new Response('ok');
  },
};

FAQ

Why does deterministic output matter?

Because re-renders become free and safe. The same JSON returns a byte-identical PDF — hash the output today, render the same JSON tomorrow, get the same hash. Webhook retries and cron re-runs produce the same file instead of near-duplicates, so dedup, caching, diffing, and audit trails all work. There is no wall-clock input at all: metadata dates are pinned to the invoice date when you pass one, and to a fixed epoch otherwise — never to render time. Omitting date simply leaves the date line off the document; the output stays byte-stable either way.

How do I remove the "Generated with pdfops.dev" footer line?

Use a paid key. Anonymous calls and Free-tier keys produce PDFs with a small "Generated with pdfops.dev" footer line; Indie and Pro keys produce clean output with no branding. Plans are at /pricing.

Can I customize the invoice layout?

Not yet — there's one clean US-Letter template, and every knob the endpoint exposes (parties, dates, currency, tax, notes) feeds it. If you need a specific layout, logo, or paper size, tell us via the feedback form at POST /api/waitlist — layout requests directly shape what we build next.

How are currencies handled?

currency takes any ISO 4217 code (default USD) and controls formatting only: amounts render as the code plus a grouped two-decimal number, e.g. USD 1,200.00 or EUR 850.00. There's no exchange-rate conversion — the numbers you send are the numbers printed.

Does /api/invoice consume my quota?

Yes — a successful (2xx) call counts as 1 request against the same monthly quota as the other PDF endpoints (anonymous 100/IP/month, or your key's tier). Failed requests (4xx/5xx) don't consume budget.