WhatsApp Operators DailyThe Blueticks DispatchSaturday, August 1, 2026
Productivity

How to Send Images, PDFs & Documents via the WhatsApp API (Python & Node, 2026)

Attachments are where WhatsApp API integrations quietly break. Here is the real media request contract: caption in `text`, the mediaKind enum, filename rules, two different size ceilings, and the GIF that arrives as a file.

DRBy Daniel Roth · August 1, 2026 · 11 min read
How to Send Images, PDFs & Documents via the WhatsApp API (Python & Node, 2026)

Text sends work on the first try. Attachments are where people lose an afternoon. The caption lands in the wrong field, the invoice arrives named a8f3c2.pdf, and the animated GIF shows up as a downloadable file nobody taps. This is the actual media request contract, verified against the production Blueticks /v1 API on 2026-08-01, with runnable Python and Node for every case.

Can you send an image or PDF over the WhatsApp API on your own number?

Yes. Blueticks sends media from the WhatsApp number you already use, driving WhatsApp Web through a browser session or an always-on hosted gateway. It is not the Meta Cloud API. There is no Business verification, no template pre-approval, and no per-message Meta charge on the send itself.

That distinction matters for attachments specifically. On Meta's WhatsApp Business Platform pricing, business-initiated messages are charged per delivered template message across Marketing, Utility and Authentication categories, and a media-carrying promotional template is a Marketing template every time. On the WhatsApp Web path there is no template object at all, so a PDF is just a message from your number.

The honest part: nothing here makes your number unbannable. WhatsApp's Business Messaging Policy states plainly that it may "limit or remove your access to or use of the WhatsApp Business Services if you receive significant amounts of negative feedback, cause harm to WhatsApp or our users, violate or encourage others to violate our terms or policies." Sending unwanted attachments to strangers is a fast way to trigger exactly that. Every rate limit in this article is a limit on our API, not a promise from WhatsApp.

If you have not made a single API call yet, start with sending a plain text message from the API and come back. This article assumes you already have a bt_live_ key and a linked number.

What a media send actually needs: a URL, base64, or a file upload

Every whatsapp api send media call needs exactly one source of bytes. You pick from three: mediaUrl (https only), mediaBase64 (raw base64 or a data: URL), or a multipart/form-data request carrying a mediaFile part. Everything sits at the top level of the body. There is no nested object and no separate caption field.

Two mental models to kill before you write a line of code, because both are how every other WhatsApp API works:

  • There is no media: { url, caption } object. The body is flat: type, mediaUrl, mediaKind, mediaFilename, text.
  • There is no caption field. The text field carries the caption on a media send, capped at 4,096 characters, the same field a text message uses for its body.

type is required on every send and takes text, media or poll. It is validation-only: it does not change the route, it tells the API which flat fields to enforce. Send "type": "media" with neither a URL, base64, nor an upload and you get a 400 reading media requires 'mediaUrl', 'mediaBase64', or a 'mediaFile' upload.

Precedence is fixed. If you send both mediaUrl and mediaBase64, mediaUrl wins and the base64 is ignored. A mediaFile upload arrives out of band and never competes with the JSON body.

One more thing about mediaUrl: it must be https://, and the API rejects localhost plus the private IPv4 and IPv6 ranges before it fetches anything. A URL on your laptop or an internal 10.x host will fail. The bytes are then re-hosted to Blueticks storage, which is why the mediaUrl echoed back on the response object is not the URL you sent.

Blank photo prints, a memory card and a USB-C cable on slate — the local files behind a media upload

How do you send an image with a caption in Python and Node

POST to https://api.blueticks.co/v1/scheduled-messages/{chatId} with type: "media", a mediaUrl, and your caption in text. The recipient is a path segment in E.164 form, not a body field. Every 2xx comes back wrapped in {"success": true, "data": {...}}.

One structural thing to understand before you read the response, because it changes what you get back: omitting sendAt does not just send sooner, it takes a different code path. With no sendAt the API sends directly and writes no queue row at all. Add a sendAt and it writes a queue row you can later fetch, patch or cancel. The two paths return different handles, and the examples below omit sendAt.

Python, using requests:

import requests

API_KEY = "bt_live_YOUR_KEY_HERE"
TO = "+15551234567"  # recipient is a PATH segment, not a body field

resp = requests.post(
    f"https://api.blueticks.co/v1/scheduled-messages/{TO}",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
        "type": "media",
        "mediaUrl": "https://cdn.example.com/promo/spring-lookbook.jpg",
        "mediaKind": "image",
        "text": "Your spring lookbook is here. Reply STOP to opt out.",
    },
    timeout=30,
)
resp.raise_for_status()
msg = resp.json()["data"]
print(msg["id"], msg["status"], msg["mediaKind"])

Node 18+, no dependencies:

const API_KEY = "bt_live_YOUR_KEY_HERE";
const TO = "+15551234567";

const resp = await fetch(`https://api.blueticks.co/v1/scheduled-messages/${TO}`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    type: "media",
    mediaUrl: "https://cdn.example.com/promo/spring-lookbook.jpg",
    mediaKind: "image",
    text: "Your spring lookbook is here. Reply STOP to opt out.",
  }),
});

if (!resp.ok) throw new Error(`Send failed: ${resp.status}`);
const { data } = await resp.json();
console.log(data.id, data.status, data.mediaKind);

The response object, trimmed to the fields that matter for a whatsapp api image caption send:

{
  "success": true,
  "data": {
    "id": "true_15551234567@c.us_3EB0C767D097F5A3B7A2",
    "waMessageKey": {
      "fromMe": true,
      "remote": "15551234567@c.us",
      "id": "3EB0C767D097F5A3B7A2",
      "_serialized": "true_15551234567@c.us_3EB0C767D097F5A3B7A2"
    },
    "to": "+15551234567",
    "type": "media",
    "text": "Your spring lookbook is here. Reply STOP to opt out.",
    "mediaUrl": "https://api.blueticks.co/v1/utils/asset/PLM7K9PVZB19abis",
    "mediaKind": "image",
    "status": "confirmed",
    "createdAt": "2026-08-01T09:14:22.104Z"
  }
}

Two things here surprise people.

id is not a database id. On a direct send there is no queue row, so id is the serialized WhatsApp message key — the same string as waMessageKey._serialized. It is addressable via GET /v1/messages/{id}, and it will be rejected by GET /v1/scheduled-messages/{id}, which only accepts a 24-character hex queue id. On a scheduled send (one with sendAt) id is that 24-hex queue id instead. Same field, two different handles, decided by whether you sent sendAt.

status is already confirmed, not pending, because the engine returned a key in-line. You get pending back only when it did not.

And notice what is absent: every /v1 success body is deep-stripped of nulls before it goes out, so a field with no value is a missing key rather than "field": null. On this response sendAt, confirmedAt, failedAt and failureReason are simply not there. Falsy-but-meaningful values (false, 0, "") are kept. Parse with .get() and optional chaining, never data["failureReason"].

mediaKind is optional. Leave it out and the API infers the kind from the URL and content type. Set it when you care about the bubble WhatsApp renders, which is most of the time.

There is also a second route, POST /v1/messages/{chatId}, which takes the same flat media body and is explicitly fire-and-forget. It has its own rate-limit bucket, so it does not share a budget with the route above, and it carries withTyping and typingSeconds — both ignored on media and poll sends, text only. Since a sendAt-less call to /v1/scheduled-messages already skips the queue, treat /v1/messages as the route that says so explicitly rather than as the only way to send immediately.

How do you send a PDF so the filename shows correctly

Set mediaKind: "document" and pass mediaFilename. The field is optional and 1 to 255 characters. When you omit it, the API derives a name in a strict order: your explicit mediaFilename, then the last path segment of mediaUrl, then file.<ext> guessed from a data: URI mime type, then the literal string file.

That derivation order is exactly why documents from signed cloud storage URLs arrive looking broken. A presigned S3 or GCS link ends in something like .../7f3a91c2-4b8e?X-Amz-Signature=..., the query string is stripped, and the recipient sees an attachment called 7f3a91c2-4b8e. Always pass mediaFilename for a send document whatsapp api call.

For a local file, skip base64 entirely and use multipart/form-data. Python:

import requests

API_KEY = "bt_live_YOUR_KEY_HERE"
TO = "+15551234567"

with open("invoice-2026-08-001.pdf", "rb") as fh:
    resp = requests.post(
        f"https://api.blueticks.co/v1/scheduled-messages/{TO}",
        headers={"Authorization": f"Bearer {API_KEY}"},
        data={
            "type": "media",
            "mediaKind": "document",
            "mediaFilename": "invoice-2026-08-001.pdf",
            "text": "Your August invoice is attached.",
        },
        files={"mediaFile": ("invoice-2026-08-001.pdf", fh, "application/pdf")},
        timeout=120,
    )
resp.raise_for_status()
print(resp.json()["data"]["id"])

Node, with native FormData:

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

const form = new FormData();
form.set("type", "media");
form.set("mediaKind", "document");
form.set("mediaFilename", "invoice-2026-08-001.pdf");
form.set("text", "Your August invoice is attached.");
form.set(
  "mediaFile",
  new Blob([await readFile("invoice-2026-08-001.pdf")], { type: "application/pdf" }),
  "invoice-2026-08-001.pdf",
);

const resp = await fetch(`https://api.blueticks.co/v1/scheduled-messages/${TO}`, {
  method: "POST",
  headers: { Authorization: `Bearer ${API_KEY}` }, // do NOT set Content-Type
  body: form,
});
if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`);

Two failure modes worth writing on a sticky note. First, do not set Content-Type yourself on a multipart request; fetch and requests generate the boundary token and hand-setting the header breaks parsing. Second, on a multipart upload the API defaults mediaFilename to the uploaded file's original name, so open("tmp.pdf") ships an attachment named tmp.pdf.

Sending your first attachment today? Mint an API key and send an image or PDF from your own number in under ten minutes. No Meta Business verification, no template approval queue, no per-message Meta fees.

Blank sheets beside a flatbed scanner, the source files for a send document whatsapp api workflow

Which media kinds work, and which ones silently break

mediaKind accepts exactly seven values: image, video, audio, document, sticker, voice, gif. Each maps to a different bubble in the chat. Two pairs get confused constantly, and one of them fails silently rather than erroring, which is the worst kind of failure.

mediaKindWhat the recipient sees
imageInline photo, caption below
videoInline player with a caption
gifLooping autoplay bubble, no controls — MP4 bytes, not .gif
audioAttached audio file with a play control
voicePush-to-talk waveform bubble
documentNamed file card, tap to download
stickerBorderless sticker

There is no format allowlist at our request layer, so what actually constrains you is what the WhatsApp client renders. Meta publishes that list for the Cloud API in its supported media types reference: images are JPEG and PNG (WebP is scoped to stickers, not images), audio covers AAC, AMR, MP3, MP4 audio and OGG, and video is 3GPP and MP4. Those are Cloud API figures rather than WhatsApp Web ones, but they are the right shape to design against — WebP in particular is a sticker format, not a general image format.

The GIF trap. WhatsApp never transmits a .gif over the wire. It ships MP4 with a gifPlayback flag. So if you send image/gif bytes with mediaKind: "image", the WhatsApp Web preparation path falls back to document, and your animation arrives as a file card. No error, no failed status, just a dead attachment. To get the autoplay bubble you must transcode to MP4 first and send mediaKind: "gif", which makes the engine set sendVideoAsGif on dispatch.

Voice versus audio. voice renders the push-to-talk waveform that plays inline and can advance a message to a played status. audio renders an attached file. Same bytes, completely different recipient behavior. Pick deliberately.

The two size ceilings, and when to stop using base64

There are two separate caps and they are nowhere near each other. The JSON mediaBase64 field is capped at 15,728,640 characters, and base64 inflates binary by roughly one third, so the real ceiling is about 11 MB of actual file. The multipart mediaFile path runs through a 100 MB upload limit and streams out of band, skipping base64 entirely.

The decision rule is short:

  1. File already on a public https URL? Use mediaUrl. Zero upload cost on your side.
  2. File on disk and comfortably inside the ~11 MB base64 ceiling? mediaBase64 is fine if JSON is simpler for you.
  3. File on disk and anywhere near that ceiling, or you would rather not burn a third of your payload on encoding? Use multipart/form-data with mediaFile and you are working against the 100 MB limit instead.

Our accepting 100 MB does not mean WhatsApp delivers 100 MB. The client sets its own ceilings, and they are far tighter per type. Meta publishes hard numbers for the Cloud API in its supported media types reference: 5 MB for JPEG and PNG images, 16 MB for audio and video, 100 MB for documents, 500 KB for an animated sticker. Those are Cloud API figures rather than WhatsApp Web figures, but they are the right order of magnitude to design against. A 40 MB video will be accepted by our API and then fail or be rejected downstream.

Server rack indicator lights at night, the always-on gateway behind scheduled WhatsApp media sends

How do you send media to a whole audience without getting flagged

Loop your list and issue one media send per recipient. There is no bulk media endpoint, by design. The real constraints are two stacked rate limits, both verified against production on 2026-08-01, plus your own pacing, which should be far slower than either.

  • POST /v1/scheduled-messages/{chatId} is capped at 60 requests per 60 seconds.
  • POST /v1/messages/{chatId} uses a separate counter, also 60 per 60 seconds. Separate buckets, so they do not share a budget.
  • Free accounts additionally get 5 API calls per fixed 6-hour UTC slot. The slots are hard boundaries at 00:00, 06:00, 12:00 and 18:00 UTC and the counter resets at each one, so this is not a rolling window: exhaust it at 05:59 and you have a full five again a minute later. Paid plans with API access skip that layer entirely.
  • On a 429 the response carries Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining. Read the headers instead of guessing.

Media is heavier than text on every axis. Each send fetches or uploads bytes, re-hosts them, and hands a prepared media object to WhatsApp. Do not pace attachments the way you pace text.

import time, requests

for contact in audience:                     # your own opted-in list
    r = requests.post(
        f"https://api.blueticks.co/v1/scheduled-messages/{contact['phone']}",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={
            "type": "media",
            "mediaUrl": contact["asset_url"],
            "mediaKind": "image",
            "text": f"Hi {contact['first_name']}, your order photo is attached.",
        },
        timeout=60,
    )
    if r.status_code == 429:
        time.sleep(int(r.headers.get("Retry-After", "60")))
        continue
    r.raise_for_status()
    time.sleep(6)          # ~10/min: well under the 60/60s route cap

Consent is yours to own, not the API's. WhatsApp's Business Messaging Policy is unambiguous: "You may only contact people on WhatsApp if: (a) they have given you their mobile phone number; and (b) you have received opt-in permission from the recipient confirming that they wish to receive subsequent messages or calls from you." Blueticks will happily send whatever you POST. WhatsApp is the one deciding whether your number stays healthy.

For list mechanics, segmentation and the trade-offs against broadcast lists, see the dedicated guide on sending bulk WhatsApp messages. For timezone-correct sendAt values, idempotency keys and backoff on retries, the Python scheduling article covers all three properly.

How do you confirm a media message actually delivered

Poll GET /v1/scheduled-messages/{id} with the queue id from a scheduled send — one you created with a sendAt. A direct send has no queue row, so its id is a wire key: fetch that one with GET /v1/messages/{id} instead, and note it already came back confirmed. The documented happy path runs pending to confirmed to received to read, with played terminal for voice and audio. confirmed means WhatsApp accepted the message and a key exists. received is the double grey tick. read is double blue.

That ladder is the lifecycle, not the full set of values status can hold. The field is typed against the canonical 15-value status list, and when a message has no lifecycle timestamp yet the API returns its raw stored status instead. In practice the two extra values you will actually see are sent and sent_pending_ack — the latter meaning WhatsApp took the message but no acknowledgement has come back yet. Branch on a terminal set rather than on the five happy-path states, and keep the rarer schema values in it defensively:

TERMINAL = {"received", "read", "played", "failed", "error", "expired", "cancelled"}

The identity field is waMessageKey, not key. It is an object with fromMe, remote, id and _serialized, and it is absent from the response until the engine has actually dispatched. A queued message with no waMessageKey key at all has not reached WhatsApp yet, no matter what its status says. Test for absence, not for null.

import time, requests

for _ in range(10):
    r = requests.get(
        f"https://api.blueticks.co/v1/scheduled-messages/{message_id}",
        headers={"Authorization": f"Bearer {API_KEY}"}, timeout=30,
    )
    m = r.json()["data"]
    # absent-not-null: use .get(), these keys are stripped when empty
    print(m["status"], m.get("waMessageKey"), m.get("failureReason"))
    if m["status"] in TERMINAL:
        break
    time.sleep(15)

Media fails in ways text does not, and pretending otherwise wastes your debugging time. Upload stages stall, mimetypes get misread, and until recently a large share of those surfaced as a bare unknown error. Blueticks shipped media-specific failure diagnostics on 2026-07-31 that tag the failing stage on every non-OK return path, so a failed media send now records where it broke rather than just that it broke. Read failureReason on the message object first.

One limitation to plan around: posting media into a community parent group or an announce-only group is a known open failure on the WhatsApp Web path, and the API classifies "cannot send to this group" as a permanent failure rather than something worth retrying. Send to the subgroup, not the announcement channel.

If polling feels wrong for your architecture, register a webhook instead and let delivery events come to you. That is covered end to end in the webhooks and auto-reply guide.

Phone face down beside a handwritten checklist while a scheduled media send delivers

What a laptop-based media send cannot do, and where Blueticks fits

A script driving WhatsApp Web from your own browser works until the tab closes. Then the queue stops, nothing retries, and there is no record of what went out. That is fine for a one-off invoice and unworkable for anything scheduled at 6am.

The hosted gateway is what changes the equation. It keeps your number connected 24/7 without a browser on your desk, so a sendAt two weeks out actually fires, POST /v1/scheduled-messages/{id}/cancel can pull a queued attachment back before it goes, and every send carries a status row you can query later. Scheduled media is only meaningful if something is awake to send it.

The conceptual model, including what "your own number" means legally and operationally, is covered in the piece on the WhatsApp REST API on your own number.

FAQ

Can I send a PDF via the WhatsApp API?

Yes — to send pdf via whatsapp api, POST type: "media" with mediaKind: "document" and either a mediaUrl, a base64 payload, or a multipart mediaFile upload. Always pass mediaFilename so the recipient sees a real name instead of a URL fragment. The endpoint is POST /v1/scheduled-messages/{chatId} with the recipient in the path.

What image formats does the whatsapp api send image path accept?

There is no format allowlist enforced at the request layer, so the constraint is what the WhatsApp client itself renders. Meta's Cloud API reference lists images as JPEG and PNG only, both capped at 5 MB — WebP is a sticker format there, not a general image format. Those caps are a sensible design target even on the WhatsApp Web path.

Why does my GIF arrive as a downloadable file?

Because WhatsApp never transmits raw .gif. It ships MP4 with a gif-playback flag. Sending image/gif bytes with mediaKind: "image" silently falls back to a document attachment. Transcode to MP4 and send mediaKind: "gif" to get the looping autoplay bubble.

How big can an attachment be on a whatsapp api send file call?

Two ceilings. The JSON mediaBase64 field caps at 15,728,640 characters, which is roughly 11 MB of real file after base64 inflation. The multipart mediaFile path allows up to 100 MB and streams out of band. WhatsApp's own per-type limits are much lower and apply after ours.

Do I need Meta Business verification to send media this way?

No. Blueticks drives your existing WhatsApp number through WhatsApp Web or a hosted gateway, so there is no Meta Business account, no template approval, and no per-message Meta charge. You are still bound by WhatsApp's Business Messaging Policy, and WhatsApp can restrict any number for policy violations.

Can I schedule an image or document for later?

Yes. Add an ISO 8601 sendAt with an offset to the same body. It must be at least 10 seconds in the future and no more than 365 days out. Scheduled sends return a queue id you can later fetch, edit with PATCH, or pull back with the cancel endpoint.

Why did my media send to a community fail?

Posting media into a community parent group or an announce-only group where you cannot post is a known limitation on the WhatsApp Web path. The API treats "cannot send to this group" as a permanent failure, not a transient one, so retries will not help. Send to the individual subgroup instead.

Email

The Dispatch, every week.

One sharp WhatsApp growth tactic in your inbox each week. Joined by 238,000+ founders, marketers and support leads.

Free forever. No spam, unsubscribe in one click.