← Back to blog

Send an Invoice Email with a PDF Attachment (2026) via HTTP API

Send an invoice PDF with Rovela POST /emails attachments[] (base64, filename, content_type), Idempotency-Key = invoice number, and newline-stripped base64—curl and Node fetch from live docs.

Updated November 4, 2026
Send an Invoice Email with a PDF Attachment (2026) via HTTP API

Why invoice PDFs need a deliberate send path

Finance systems generate a PDF, then need that file in the customer's mailbox—usually the same minute the invoice is finalized. The Rovela path is a single HTTP call: POST https://api.rovela.dev/emails with an attachments[] entry (filename, base64 content, content_type), plus an Idempotency-Key keyed to the invoice number so a retried job never double-mails.

This article follows the live recipe in Invoice with an attachment and the attachments field on Send Email. It uses curl and Node fetch—the same patterns the docs show. Rovela does not require an SDK method like rovela.emails.send for this flow.

It is not the overview post Email Attachments Guide (formats, provider caps, link-vs-attach tradeoffs). It is also not Welcome Email Best Practices (onboarding copy). Sibling transactional posts: Next.js magic link, Express OTP, Bun transactional email.

What you own vs what Rovela sends

Your billing / ERP job owns:

  • Rendering or locating the invoice PDF on disk (or in object storage before you base64 it)
  • Invoice number, amount, due date, and the customer billing address
  • Choosing Idempotency-Key (docs use the invoice number—e.g. invoice-1042 / invoice- + number)
  • Encoding bytes as single-line base64 (strip newlines) and setting content_type: "application/pdf"
  • Putting number / amount / due date in the HTML and text bodies so a phone notification is useful without opening the PDF
  • Preferring one POST /emails per invoice rather than /emails/batch for attached PDFs

Rovela owns:

  • Accepting the JSON, decoding attachments, building a proper MIME multipart, DKIM-signing, queueing, retries, and event history
  • Enforcing the documented request size cap (see below)

A 200 with {"object":"email","id":"..."} means accepted and queued, not “opened in Outlook.”

Prerequisites

  1. A verified sending domain in the Rovela dashboard. Sends from an unverified from domain are refused with 400 (Quick Start).
  2. An API key that looks like re_…. Prefer sending_access for production invoice senders (Authentication).
  3. The invoice PDF available to the process that will call the API (local path in the examples below).

Size limit (from the invoice example docs)

The live invoice example states: the whole JSON request is capped at 2 MB. Base64 expands the file by about a third, which leaves roughly 1.5 MB for all attachments together. Over the limit → 413 before anything is sent.

That fits a typical invoice or receipt. For larger packs (scanned statements, multi-megabyte reports), put the file in your own storage and send a link instead—also kinder to corporate gateways that strip big attachments. For a broader formats/security overview (not this invoice recipe), see Email Attachments Guide and Invoice with an attachment.

curl: strip newlines, then POST

GNU base64 wraps at 76 characters by default. The Rovela decoder rejects line breaks inside the base64, so strip them the same way on Linux and macOS:

PDF=$(base64 < invoice-1042.pdf | tr -d '\n')

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-1042" \
  -d @- <<EOF
{
  "from": "Acme Billing <billing@mail.acme.com>",
  "to": "accounts@customer.com",
  "subject": "Invoice INV-1042 from Acme",
  "html": "<p>Hi,</p><p>Your invoice for September is attached. Total due: <strong>\$480.00</strong> by October 15.</p>",
  "text": "Hi,\n\nYour invoice for September is attached. Total due: \$480.00 by October 15.",
  "attachments": [
    {
      "filename": "INV-1042.pdf",
      "content": "$PDF",
      "content_type": "application/pdf"
    }
  ]
}
EOF

Why content_type matters

Without content_type, the part is sent as application/octet-stream. Many clients then show an “unknown file” instead of previewing a PDF. Set "content_type": "application/pdf" every time.

Why Idempotency-Key

Queues, cron retries, and webhook-driven jobs will send twice if the first response times out. With Idempotency-Key: invoice-1042 (up to 256 characters; retries within 24 hours return the original response), the customer gets one invoice email, not two. See Idempotency on Send Email.

Node.js: fetch + invoice number as the key

import { readFile } from "node:fs/promises";

export async function sendInvoice(invoice, pdfPath) {
  const pdf = await readFile(pdfPath);

  const res = await fetch("https://api.rovela.dev/emails", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + process.env.ROVELA_API_KEY,
      "Content-Type": "application/json",
      // The invoice number is the natural key: a retried job never bills twice.
      "Idempotency-Key": "invoice-" + invoice.number,
    },
    body: JSON.stringify({
      from: "Acme Billing <billing@mail.acme.com>",
      to: invoice.billingEmail,
      reply_to: "billing@acme.com",
      subject: "Invoice " + invoice.number + " from Acme",
      html: "<p>Your invoice " + invoice.number + " is attached.</p>",
      text: "Your invoice " + invoice.number + " is attached.",
      attachments: [
        {
          filename: invoice.number + ".pdf",
          content: pdf.toString("base64"),
          content_type: "application/pdf",
        },
      ],
    }),
  });
  if (!res.ok) throw new Error("invoice email failed: " + res.status);
  return (await res.json()).id;
}

Buffer#toString("base64") does not insert newlines, so you avoid the curl wrapping pitfall. Keep ROVELA_API_KEY in the server environment—never in a browser bundle.

reply_to points answers at a mailbox people actually read, while from stays on the verified sending subdomain (docs).

Good habits for invoice mail

  • Essentials in the body — number, amount, due date. Preview panes and lock-screen notifications will not open the PDF.
  • Clean filenames — no spaces or personal data; the name shows in every client and in forwards (INV-1042.pdf, not John Doe invoice final (2).pdf).
  • One request per invoice — do not push attached invoices through /emails/batch: the batch shares the same 2 MB body, and one invalid entry rejects the whole batch. See Batch and scheduled sends in the API reference / the invoice example’s “Good habits” section.
  • Confirm delivery asynchronously — poll GET /emails/:id / events for debugging, or subscribe to webhooks in production (Quick Start).

Errors you will actually hit

From Send Email and the invoice example:

StatusTypical cause
400Missing required field, base64 that does not decode (often leftover newlines), or from domain not verified
401Missing/invalid key
413Whole JSON body over the 2 MB cap
429Rate limit

Surface the API message string in your logs as-is.

Checklist

  1. Domain verified; from uses that domain (or a verified subdomain).
  2. PDF encoded as single-line base64; content_type is application/pdf.
  3. Idempotency-Key stable per invoice number.
  4. HTML and text bodies include number / amount / due date.
  5. Request under the 2 MB JSON cap; otherwise link out.
  6. One POST /emails per invoice with an attachment.

Related