Send a PDF (invoice, ticket, brochure) over the WhatsApp API

How to send a PDF with the SendFromChat REST API, from a public link or an uploaded file. Node helper, curl, the 24-hour window, size limits and what a 202 does and does not mean.

November 6, 2026 · 7 min read

Invoices, tickets and brochures are the files customers actually ask for on WhatsApp. Sending one takes a single request, but a few details decide whether it arrives: the link has to be public, the 24-hour window has to be open, and a 202 only means the message was accepted.

SendFromChat is an independent product and is not affiliated with Meta or WhatsApp. What follows comes from our REST API docs and our code.

The helper below was not run against a live account. We ran it only against a local mock server, to check the request shapes, so try it with your own key and your own number first.

The short version

  • A PDF goes in the media field of POST /messages, either as a public https link (url) or as a file you uploaded first (id).
  • Optional fields: filename (the name shown on the document) and caption (up to 1,024 characters).
  • Files follow the same 24-hour window as text. If the window is known to be closed you get 422 window_closed.
  • A link must be public and https, because WhatsApp fetches the file itself. If it can't, the send is accepted and message.status later reports failed.
  • 202 accepted is not delivered. Watch the status webhook.
  • Uploads (POST /media) need a live key. A test key can check a link-based send but cannot upload.

If the PDF is already at a public link, one request is enough:

curl -X POST https://sendfromchat.com/api/v1/messages \
  -H "Authorization: Bearer $SENDFROMCHAT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-4821-pdf" \
  -d '{
    "to": "+4915123456789",
    "media": {
      "url": "https://example.com/invoices/4821.pdf",
      "filename": "Invoice-4821.pdf",
      "caption": "Your invoice is attached."
    }
  }'

The response is 202:

{ "id": "wamid.HBgL…", "status": "accepted", "to": "+4915123456789", "type": "document" }

If you leave out type, it is worked out from the file name in the link, and anything unrecognised goes as a document. Links starting with http:// are rejected with invalid_request. Send either url or id, not both and not neither.

The link must go to the file itself. A page that only shows or previews the file is not a file link.

Invoices often sit behind your login. Upload the file to us first. This needs a live key.

# 1. Ask for an upload link (size is in bytes)
curl -X POST https://sendfromchat.com/api/v1/media \
  -H "Authorization: Bearer $SENDFROMCHAT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "filename": "invoice-4821.pdf", "contentType": "application/pdf", "size": 184320 }'
# 201 → { "id": "…", "uploadUrl": "…" }

# 2. PUT the bytes within two hours, with the same Content-Type
curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/pdf" --data-binary @invoice-4821.pdf

# 3. Send it by id
curl -X POST https://sendfromchat.com/api/v1/messages \
  -H "Authorization: Bearer $SENDFROMCHAT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+4915123456789", "media": { "id": "…", "filename": "Invoice-4821.pdf" } }'

An unknown id returns 404 media_not_found.

Upload limits from our docs: documents (PDF, Word, Excel, PowerPoint, text) up to 50 MB, images (JPEG, PNG) 5 MB, videos (MP4, 3GP) 16 MB. Links you send yourself follow WhatsApp's own limits: 5 MB, 16 MB and 100 MB.

3. One helper for both

This Node helper takes either a public https link or a path on disk. It needs Node 20 or newer (global fetch). Before making any request it refuses http links, anything that isn't a .pdf, and files over 50 MB.

// send-pdf.mjs — send a PDF with SendFromChat. Needs Node 20+.
import { readFile, stat } from "node:fs/promises";
import { basename } from "node:path";

const BASE = process.env.SFC_BASE ?? "https://sendfromchat.com/api/v1";
const KEY = process.env.SENDFROMCHAT_API_KEY ?? "";
const MAX_UPLOAD = 50 * 1024 * 1024; // our upload limit for documents

async function api(path, body, headers = {}) {
  const res = await fetch(`${BASE}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...headers },
    body: JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  return { status: res.status, body: json };
}

// Uploads a local PDF and returns the media id. Needs a live key.
async function upload(path) {
  const filename = basename(path);
  const { size } = await stat(path);
  if (size > MAX_UPLOAD) throw new Error(`${filename} is over 50 MB`);
  const contentType = "application/pdf";
  const created = await api("/media", { filename, contentType, size });
  if (created.status !== 201) throw new Error(`upload not started: ${JSON.stringify(created.body)}`);
  // PUT within two hours, with the same Content-Type
  const put = await fetch(created.body.uploadUrl, {
    method: "PUT",
    headers: { "Content-Type": contentType },
    body: await readFile(path),
  });
  if (!put.ok) throw new Error(`PUT failed: ${put.status}`);
  return created.body.id;
}

/**
 * file: a public https link to the PDF, or a path on disk.
 * Returns { status, body }. 202 means accepted, not delivered.
 */
export async function sendDocument({ to, file, filename, caption, idempotencyKey }) {
  if (!KEY) throw new Error("Set SENDFROMCHAT_API_KEY");
  if (/^http:\/\//i.test(file)) throw new Error("Links must be https");
  if (!/\.pdf(\?|$)/i.test(file)) throw new Error("This helper only sends .pdf files");

  const media = /^https:\/\//i.test(file)
    ? { url: file } // WhatsApp fetches this itself, so it must be public
    : { id: await upload(file) };
  if (filename) media.filename = filename;
  if (caption) media.caption = caption; // up to 1,024 characters

  const headers = idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {};
  return api("/messages", { to, media }, headers);
}

Use it like this, with the window fallback in the same place:

import { sendDocument } from "./send-pdf.mjs";

const r = await sendDocument({
  to: "+4915123456789",
  file: "https://example.com/invoices/4821.pdf",
  filename: "Invoice-4821.pdf",
  caption: "Your invoice is attached.",
  idempotencyKey: "invoice-4821-pdf",
});

if (r.status === 422 && r.body.error?.type === "window_closed") {
  // send an approved template asking them to reply, then send the PDF after their reply
} else if (r.status !== 202) {
  throw new Error(JSON.stringify(r.body)); // fix the request, don't retry blindly
} else if (r.body.warning) {
  // we have no record of a window: WhatsApp may silently drop this. Treat it as unsent.
}

What we ran: the helper against a local mock server, which checked one request for a link, three for a file on disk (create the upload, PUT, send) and none when it refuses. That proves the shape of our requests, nothing more. To check your own send code against our real checks without messaging anyone, use a test key as described in test the WhatsApp API without sending a real message. A test key never fetches your link and cannot upload, and you still need a connected number.

4. The 24-hour window

A file is a free-form message, so it follows the same rule as text. The window opens when the person writes to your number and lasts 24 hours.

  • Window known to be closed: 422 window_closed, with windowOpenUntil saying when it closed.
  • No record either way (a number that has never written to you): the send goes ahead with a warning in the response, and WhatsApp may silently drop it.

If the window is closed, send an approved template that asks them to reply. Their reply opens a fresh window, and then you send the PDF. Our docs say templates take an image header only, so don't plan on attaching the PDF to the template. Details in the 24-hour window guide.

5. Know whether it arrived

202 accepted means we took the message. It does not mean it was delivered or read. Statuses arrive as message.status webhook events (messageId, to, status, error, at), or you can read one with GET /messages/{id}. Register the webhook endpoint as described in the REST API docs.

A failed status after a 202 is where a link WhatsApp couldn't fetch shows up. In your status handler, act on it:

// inside your webhook's handleEvent(event)
if (event.type === "message.status" && event.data.status === "failed") {
  const { messageId, to, error } = event.data;
  console.error("PDF not delivered", { messageId, to, error });
  // first check the link: public, https, and straight to the file
}

Retries: send an Idempotency-Key derived from the event (invoice-4821-pdf). A retry with the same key and body returns the first response with Idempotent-Replayed: true instead of sending a second invoice. Reusing the key with a different body returns idempotency_mismatch.

Data and your customers

An invoice is customer data. SendFromChat stores data in Mumbai, India. Transfers from the EU and UK are covered by the Standard Contractual Clauses and the UK Addendum in our Data Processing Agreement, and the sub-processors are listed publicly. Read it before you decide. This is not legal advice.

Try it

Connect a number, create a key and send the first PDF to your own phone. Field reference: REST API docs.

Connect your number (30-day free trial, no card).

Do this from your AI assistant

SendFromChat connects Claude, ChatGPT, Cursor or any MCP client to the WhatsApp Business API — templates, bulk sends, inbox, chatbots and sequences, just by asking. 30 days free, no card.

Start free