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
mediafield ofPOST /messages, either as a publichttpslink (url) or as a file you uploaded first (id). - Optional fields:
filename(the name shown on the document) andcaption(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 andmessage.statuslater reportsfailed. 202 acceptedis 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.
1. Send a PDF from a link
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.
2. Send a PDF that isn't at a public 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, withwindowOpenUntilsaying when it closed. - No record either way (a number that has never written to you): the send goes ahead with a
warningin 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 freeMore guides
Test the WhatsApp API without sending a real message
How to use a SendFromChat test key to check your WhatsApp send code (template, variables, 24-hour window, errors) without messaging a customer. Node test file, what a test key checks, what it cannot prove.
WhatsApp template image header — format, size and how to add one
What a WhatsApp message template needs for a picture header (JPG or PNG, size limits), why Google Drive and Dropbox links fail, how to upload the image from your own code, and what header_image_missing means.
WhatsApp Business API in Europe — a country-by-country guide
Where WhatsApp actually dominates in Europe, what it costs to send in each market, and the rules that differ by country before you launch a campaign.
WhatsApp marketing and the GDPR
What European law actually requires before you message someone on WhatsApp — lawful basis, opt-in, opt-out, retention, transfers and the DPA your customers will ask you for.