Invoice with an attachment

Attach a PDF to a transactional email, and know where the size limit is.

Attachments go in the same POST /emails request, base64-encoded. Each one is a filename, the encoded bytes and a content type; the message is assembled as a proper MIME multipart and signed like any other.

With curl

Encode the file on a single line — the decoder rejects line breaks inside the base64, and GNU base64 wraps at 76 characters by default. Stripping the newlines works the same 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

Without content_type the part is sent as application/octet-stream, which many clients show as an unknown file instead of previewing it. Set it.

From Node.js

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
}

reply_to sends answers to a mailbox people actually read, while the message still comes from the verified sending subdomain.

The size limit

The whole JSON request is capped at 2 MB. Base64 makes a file about a third larger, so that leaves roughly 1.5 MB for all attachments together. A request over the limit is rejected with 413 before anything is sent.

That covers a typical invoice or receipt comfortably. For anything bigger — reports, exports, statements with scanned pages — put the file in your own storage and send a link. That is also kinder to recipients: a large attachment is copied into every mailbox it reaches, and some corporate gateways strip or quarantine them.

Good habits

  • Put the essentials in the body as well — number, amount, due date. A preview pane or a phone notification will not open the PDF.
  • Use a filename without spaces or personal data; it shows up in every client and in forwarded mail.
  • Send invoices one request each rather than through /emails/batch: the batch shares the same 2 MB body, and one invalid entry rejects the whole batch. See Batch and scheduled sends.