How to Send Images, Videos, and Documents via WhatsApp Business API
Complete technical guide to sending media via WhatsApp Cloud API: sending by public URL, pre-upload with media_id, supported formats, size limits, Node.js and Python examples, and error handling.
Sending text via WhatsApp is straightforward. Sending media — images, videos, audio, documents — requires understanding two distinct flows that the WhatsApp Cloud API provides, each with its own advantages and limitations.
This guide covers everything: formats and limits per media type, when to use a direct URL versus pre-upload, required headers, production-ready code examples in Node.js and Python, and how to handle the most common errors. At the end, you'll have a reusable helper function that detects the media type and routes to the correct endpoint.
The Two Media Sending Flows
The Cloud API supports two ways to reference media in a message:
1. By public URL — You pass a publicly accessible URL in the message payload. Meta fetches the file from that URL at the time of sending.
2. By media_id (pre-upload) — You first upload the file to Meta's servers via POST /media, receive a media_id, and use that ID when sending the message.
When to use each flow
| Situation | Recommended flow | |---|---| | File already hosted on CDN/S3 | Public URL | | Dynamically generated file (invoice PDF, etc.) | Pre-upload | | Same file sent to many users | Pre-upload (reuse media_id) | | Sensitive file that can't be public | Pre-upload | | Prototyping and quick tests | Public URL |
The media_id is valid for 30 days from the time of upload. After that, it needs to be re-uploaded.
Supported Formats and Size Limits
Before writing any code, it's essential to know the API limits. Sending an unsupported format or a file that exceeds the limit results in an immediate error.
Images
| Format | Size limit | |---|---| | JPEG | 5 MB | | PNG | 5 MB | | WebP | 100 KB (animated stickers only) |
Note: The image limit applies to both URL-based sending and pre-upload. Resize or compress images before sending to avoid rejections.
Videos
| Format | Video codec | Audio codec | Limit | |---|---|---|---| | MP4 | H.264 | AAC | 16 MB | | 3GP | H.264 | AAC | 16 MB |
Videos with codecs other than H.264 (such as VP9 or AV1) are rejected even if the container is .mp4. Verify the codec before sending.
Documents
| Format | Limit | |---|---| | PDF | 100 MB | | DOC / DOCX | 100 MB | | XLS / XLSX | 100 MB | | PPT / PPTX | 100 MB | | TXT | 100 MB |
Audio
| Format | Limit | |---|---| | AAC | 16 MB | | MP4 (audio only) | 16 MB | | MPEG | 16 MB | | AMR | 16 MB | | OGG (Opus only) | 16 MB |
Note: OGG audio with Opus codec is played as a voice message inside WhatsApp. Other audio formats appear as audio file attachments.
Message Payload Structure for Media
Regardless of the media type or flow used, the base payload structure is always the same:
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "15551234567",
"type": "image",
"image": {
"link": "https://yoursite.com/image.jpg",
"caption": "Optional image caption"
}
}
To use media_id instead of a URL:
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "15551234567",
"type": "image",
"image": {
"id": "1234567890123456",
"caption": "Optional image caption"
}
}
The field that changes is the type (image, video, document, audio) and the corresponding object with either link or id.
Sending endpoint
POST https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/messages
Authentication via Authorization: Bearer {TOKEN} header.
Sending Media by Public URL
Node.js
const axios = require('axios')
const PHONE_NUMBER_ID = process.env.WHATSAPP_PHONE_NUMBER_ID
const TOKEN = process.env.WHATSAPP_TOKEN
const BASE_URL = `https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/messages`
async function sendImage(to, imageUrl, caption = '') {
const response = await axios.post(
BASE_URL,
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'image',
image: {
link: imageUrl,
...(caption && { caption }),
},
},
{
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
}
)
return response.data
}
async function sendVideo(to, videoUrl, caption = '') {
const response = await axios.post(
BASE_URL,
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'video',
video: {
link: videoUrl,
...(caption && { caption }),
},
},
{
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
}
)
return response.data
}
async function sendDocument(to, documentUrl, filename, caption = '') {
const response = await axios.post(
BASE_URL,
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'document',
document: {
link: documentUrl,
filename, // Name shown in the recipient's WhatsApp
...(caption && { caption }),
},
},
{
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
}
)
return response.data
}
// Usage examples
sendImage('15551234567', 'https://yoursite.com/product.jpg', 'New product available!')
sendVideo('15551234567', 'https://yoursite.com/demo.mp4', 'See how it works')
sendDocument('15551234567', 'https://yoursite.com/invoice.pdf', 'invoice-january-2026.pdf', 'Your January invoice')
Python
import os
import requests
PHONE_NUMBER_ID = os.environ['WHATSAPP_PHONE_NUMBER_ID']
TOKEN = os.environ['WHATSAPP_TOKEN']
BASE_URL = f'https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/messages'
HEADERS = {
'Authorization': f'Bearer {TOKEN}',
'Content-Type': 'application/json',
}
def send_image(to: str, image_url: str, caption: str = '') -> dict:
payload = {
'messaging_product': 'whatsapp',
'recipient_type': 'individual',
'to': to,
'type': 'image',
'image': {'link': image_url},
}
if caption:
payload['image']['caption'] = caption
response = requests.post(BASE_URL, json=payload, headers=HEADERS)
response.raise_for_status()
return response.json()
def send_video(to: str, video_url: str, caption: str = '') -> dict:
payload = {
'messaging_product': 'whatsapp',
'recipient_type': 'individual',
'to': to,
'type': 'video',
'video': {'link': video_url},
}
if caption:
payload['video']['caption'] = caption
response = requests.post(BASE_URL, json=payload, headers=HEADERS)
response.raise_for_status()
return response.json()
def send_document(to: str, doc_url: str, filename: str, caption: str = '') -> dict:
payload = {
'messaging_product': 'whatsapp',
'recipient_type': 'individual',
'to': to,
'type': 'document',
'document': {
'link': doc_url,
'filename': filename,
},
}
if caption:
payload['document']['caption'] = caption
response = requests.post(BASE_URL, json=payload, headers=HEADERS)
response.raise_for_status()
return response.json()
# Usage examples
send_image('15551234567', 'https://yoursite.com/product.jpg', 'New product available!')
send_video('15551234567', 'https://yoursite.com/demo.mp4', 'See how it works')
send_document('15551234567', 'https://yoursite.com/invoice.pdf', 'invoice-january-2026.pdf', 'Your January invoice')
Pre-Upload via POST /media
When you don't have a public URL or want to reuse a file across multiple sends, the correct flow is to upload the file first and get a media_id.
Upload endpoint
POST https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/media
Required headers
The upload uses multipart/form-data, not JSON. Required fields:
messaging_product: always"whatsapp"type: MIME type of the file (e.g.,image/jpeg,video/mp4,application/pdf)file: the file itself
Important: The HTTP
Content-Typeheader must bemultipart/form-datawith the correct boundary. Libraries likeaxiosandrequestshandle this automatically when you useFormData.
Node.js — File upload
const axios = require('axios')
const FormData = require('form-data')
const fs = require('fs')
const path = require('path')
const PHONE_NUMBER_ID = process.env.WHATSAPP_PHONE_NUMBER_ID
const TOKEN = process.env.WHATSAPP_TOKEN
const MIME_TYPES = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.mp4': 'video/mp4',
'.3gp': 'video/3gpp',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.aac': 'audio/aac',
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
}
async function uploadMedia(filePath) {
const ext = path.extname(filePath).toLowerCase()
const mimeType = MIME_TYPES[ext]
if (!mimeType) {
throw new Error(`Unsupported format: ${ext}`)
}
const form = new FormData()
form.append('messaging_product', 'whatsapp')
form.append('type', mimeType)
form.append('file', fs.createReadStream(filePath), {
filename: path.basename(filePath),
contentType: mimeType,
})
const response = await axios.post(
`https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/media`,
form,
{
headers: {
Authorization: `Bearer ${TOKEN}`,
...form.getHeaders(),
},
}
)
return response.data.id // Returns the media_id
}
async function sendMediaById(to, mediaId, mediaType, options = {}) {
const typeMap = {
'image/jpeg': 'image',
'image/png': 'image',
'video/mp4': 'video',
'application/pdf': 'document',
'audio/ogg': 'audio',
}
const whatsappType = typeMap[mediaType] || 'document'
const payload = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: whatsappType,
[whatsappType]: {
id: mediaId,
...options,
},
}
const response = await axios.post(
`https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/messages`,
payload,
{
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
}
)
return response.data
}
// Example: upload then send
async function uploadAndSend(to, filePath, caption = '') {
const mediaId = await uploadMedia(filePath)
console.log(`Media uploaded: ${mediaId}`)
const ext = path.extname(filePath).toLowerCase()
const mimeType = MIME_TYPES[ext]
return sendMediaById(to, mediaId, mimeType, {
...(caption && { caption }),
...(mimeType === 'application/pdf' && { filename: path.basename(filePath) }),
})
}
// Usage
uploadAndSend('15551234567', './invoice-jan-2026.pdf', 'Your January invoice')
Python — File upload
import os
import requests
from pathlib import Path
PHONE_NUMBER_ID = os.environ['WHATSAPP_PHONE_NUMBER_ID']
TOKEN = os.environ['WHATSAPP_TOKEN']
MIME_MAP = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.mp4': 'video/mp4',
'.3gp': 'video/3gpp',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.aac': 'audio/aac',
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
}
WHATSAPP_TYPE_MAP = {
'image/jpeg': 'image',
'image/png': 'image',
'image/webp': 'image',
'video/mp4': 'video',
'video/3gpp': 'video',
'application/pdf': 'document',
'audio/aac': 'audio',
'audio/mpeg': 'audio',
'audio/ogg': 'audio',
}
def upload_media(file_path: str) -> str:
"""Uploads a file to the API and returns the media_id."""
file_path = Path(file_path)
ext = file_path.suffix.lower()
mime_type = MIME_MAP.get(ext)
if not mime_type:
raise ValueError(f'Unsupported format: {ext}')
upload_url = f'https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/media'
with open(file_path, 'rb') as f:
files = {
'file': (file_path.name, f, mime_type),
}
data = {
'messaging_product': 'whatsapp',
'type': mime_type,
}
response = requests.post(
upload_url,
headers={'Authorization': f'Bearer {TOKEN}'},
files=files,
data=data,
)
response.raise_for_status()
return response.json()['id']
def send_media_by_id(
to: str,
media_id: str,
mime_type: str,
caption: str = '',
filename: str = '',
) -> dict:
"""Sends a message using an already obtained media_id."""
whatsapp_type = WHATSAPP_TYPE_MAP.get(mime_type, 'document')
media_object = {'id': media_id}
if caption:
media_object['caption'] = caption
if filename and whatsapp_type == 'document':
media_object['filename'] = filename
payload = {
'messaging_product': 'whatsapp',
'recipient_type': 'individual',
'to': to,
'type': whatsapp_type,
whatsapp_type: media_object,
}
messages_url = f'https://graph.facebook.com/v21.0/{PHONE_NUMBER_ID}/messages'
response = requests.post(
messages_url,
json=payload,
headers={
'Authorization': f'Bearer {TOKEN}',
'Content-Type': 'application/json',
},
)
response.raise_for_status()
return response.json()
def upload_and_send(to: str, file_path: str, caption: str = '') -> dict:
"""Uploads and sends in a single call."""
file_path = Path(file_path)
ext = file_path.suffix.lower()
mime_type = MIME_MAP.get(ext, 'application/octet-stream')
media_id = upload_media(str(file_path))
print(f'Upload complete. media_id: {media_id}')
return send_media_by_id(
to=to,
media_id=media_id,
mime_type=mime_type,
caption=caption,
filename=file_path.name,
)
# Usage
upload_and_send('15551234567', './invoice-jan-2026.pdf', 'Your January invoice')
Reusable Helper Function
For a unified interface that automatically detects the media type and chooses the correct flow, here's a complete helper in Node.js:
const axios = require('axios')
const FormData = require('form-data')
const fs = require('fs')
const path = require('path')
const PHONE_NUMBER_ID = process.env.WHATSAPP_PHONE_NUMBER_ID
const TOKEN = process.env.WHATSAPP_TOKEN
const MEDIA_CONFIG = {
'image/jpeg': { type: 'image', maxMB: 5, ext: ['.jpg', '.jpeg'] },
'image/png': { type: 'image', maxMB: 5, ext: ['.png'] },
'image/webp': { type: 'image', maxMB: 0.1, ext: ['.webp'] },
'video/mp4': { type: 'video', maxMB: 16, ext: ['.mp4'] },
'video/3gpp': { type: 'video', maxMB: 16, ext: ['.3gp'] },
'application/pdf': { type: 'document', maxMB: 100, ext: ['.pdf'] },
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
{ type: 'document', maxMB: 100, ext: ['.docx'] },
'audio/aac': { type: 'audio', maxMB: 16, ext: ['.aac'] },
'audio/mpeg': { type: 'audio', maxMB: 16, ext: ['.mp3'] },
'audio/ogg': { type: 'audio', maxMB: 16, ext: ['.ogg'] },
}
const EXT_TO_MIME = Object.entries(MEDIA_CONFIG).reduce((acc, [mime, cfg]) => {
cfg.ext.forEach((e) => (acc[e] = mime))
return acc
}, {})
/**
* Sends media via WhatsApp Cloud API.
*
* @param {Object} options
* @param {string} options.to - Recipient number (with country code, without +)
* @param {string} [options.url] - Public URL of the file
* @param {string} [options.filePath] - Local file path (for pre-upload)
* @param {string} [options.caption] - Media caption (optional)
* @param {string} [options.filename] - File name for documents (optional)
*/
async function sendMedia({ to, url, filePath, caption, filename }) {
if (!url && !filePath) {
throw new Error('Provide either "url" or "filePath".')
}
// --- URL flow ---
if (url) {
const ext = path.extname(new URL(url).pathname).toLowerCase()
const mime = EXT_TO_MIME[ext]
if (!mime) throw new Error(`Unsupported extension: ${ext}`)
const { type } = MEDIA_CONFIG[mime]
const mediaObject = { link: url }
if (caption) mediaObject.caption = caption
if (filename && type === 'document') mediaObject.filename = filename
const res = await axios.post(
`https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/messages`,
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type,
[type]: mediaObject,
},
{ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' } }
)
return res.data
}
// --- Pre-upload flow ---
const ext = path.extname(filePath).toLowerCase()
const mime = EXT_TO_MIME[ext]
if (!mime) throw new Error(`Unsupported extension: ${ext}`)
const { type, maxMB } = MEDIA_CONFIG[mime]
const stats = fs.statSync(filePath)
const fileMB = stats.size / (1024 * 1024)
if (fileMB > maxMB) {
throw new Error(`File exceeds the ${maxMB}MB limit for ${type} (${fileMB.toFixed(2)}MB)`)
}
const form = new FormData()
form.append('messaging_product', 'whatsapp')
form.append('type', mime)
form.append('file', fs.createReadStream(filePath), {
filename: path.basename(filePath),
contentType: mime,
})
const uploadRes = await axios.post(
`https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/media`,
form,
{ headers: { Authorization: `Bearer ${TOKEN}`, ...form.getHeaders() } }
)
const mediaId = uploadRes.data.id
const mediaObject = { id: mediaId }
if (caption) mediaObject.caption = caption
mediaObject.filename = filename || path.basename(filePath)
const sendRes = await axios.post(
`https://graph.facebook.com/v21.0/${PHONE_NUMBER_ID}/messages`,
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type,
[type]: mediaObject,
},
{ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' } }
)
return { mediaId, message: sendRes.data }
}
module.exports = { sendMedia }
// --- Examples ---
// Send by URL:
// sendMedia({ to: '15551234567', url: 'https://cdn.yoursite.com/photo.jpg', caption: 'Check this out!' })
// Send by upload:
// sendMedia({ to: '15551234567', filePath: './report.pdf', filename: 'report-march-2026.pdf' })
Handling Common Errors
The API returns errors with 4xx or 5xx HTTP codes and an error object in the body. The most frequent media errors:
Error 131053 — Invalid media format
{
"error": {
"code": 131053,
"message": "Media type not supported.",
"error_data": { "details": "The media format is not supported." }
}
}
Cause: File with correct extension but incompatible codec (e.g., video with VP9 codec), or corrupted file.
Solution: Convert the file before sending. For video:
ffmpeg -i input.mov -vcodec h264 -acodec aac output.mp4
Error 131052 — Inaccessible URL
{
"error": {
"code": 131052,
"message": "Media URL is not accessible.",
"error_data": { "details": "Failed to download the media." }
}
}
Cause: The URL returned 401, 403, 404, or timed out. Meta attempts to fetch the file at the time of sending.
Solution: Make sure the URL is public, requires no authentication, and is externally accessible. Test with curl -I <URL> before sending.
Error 131051 — File size too large
{
"error": {
"code": 131051,
"message": "Media file size too large.",
"error_data": { "details": "Media file size exceeds the limit." }
}
}
Cause: The file exceeds the limits described earlier.
Solution: Compress or resize before sending. For images, use sharp (Node.js) or Pillow (Python). For PDFs, use ghostscript.
Centralized error handling
async function sendMediaSafe(options) {
try {
return await sendMedia(options)
} catch (error) {
if (error.response) {
const { code, message } = error.response.data?.error || {}
switch (code) {
case 131051:
throw new Error(`File too large. Compress before sending. (${message})`)
case 131052:
throw new Error(`URL inaccessible by Meta. Ensure it is public. (${message})`)
case 131053:
throw new Error(`Unsupported format. Convert the file. (${message})`)
default:
throw new Error(`API error (${code}): ${message}`)
}
}
throw error
}
}
def send_media_safe(**kwargs):
"""Wrapper with structured error handling."""
error_messages = {
131051: 'File too large. Compress before sending.',
131052: 'URL inaccessible by Meta. Ensure it is public.',
131053: 'Unsupported format. Convert the file.',
}
try:
return upload_and_send(**kwargs)
except requests.exceptions.HTTPError as e:
error_data = e.response.json().get('error', {})
code = error_data.get('code')
api_msg = error_data.get('message', '')
friendly = error_messages.get(code, f'API error ({code})')
raise RuntimeError(f'{friendly} — {api_msg}') from e
Managing Media IDs
If you reuse the same file for multiple users (e.g., a catalog PDF sent to your entire contact list), it makes sense to upload once and cache the media_id.
// Simple in-memory media_id cache
// In production, use Redis or a database
const mediaCache = new Map()
async function getOrUploadMedia(filePath) {
if (mediaCache.has(filePath)) {
return mediaCache.get(filePath)
}
const mediaId = await uploadMedia(filePath)
// media_id is valid for 30 days — in production, store with expiration timestamp
mediaCache.set(filePath, mediaId)
return mediaId
}
// Batch send to multiple recipients
async function broadcastDocument(recipients, filePath, caption) {
const mediaId = await getOrUploadMedia(filePath)
const filename = path.basename(filePath)
const results = await Promise.allSettled(
recipients.map((to) =>
sendMediaById(to, mediaId, 'application/pdf', caption, filename)
)
)
const successes = results.filter((r) => r.status === 'fulfilled').length
const failures = results.filter((r) => r.status === 'rejected').length
console.log(`Sent: ${successes} ✅ | Failed: ${failures} ❌`)
return results
}
Best Practices
Validate before sending. Check file size and format on your server before calling the API. This avoids unnecessary round-trips and improves the error experience for the end user.
For URLs, use CDN with HTTPS. Meta rejects HTTP URLs (no SSL). Use a CDN (Cloudflare, AWS CloudFront, etc.) to ensure availability and download speed.
Prefer pre-upload for large-scale sends. If you're sending the same file to hundreds or thousands of users, a single upload and media_id reuse is far more efficient than having Meta fetch the file on each send.
Captions are optional but useful. For images and videos, the caption appears as text below the media. For documents, use the filename field to give a friendly name — the user will see that name before downloading.
Monitor 131052 errors actively. URLs that work today may stop working tomorrow (deleted file, CDN outage, signed URL expiration). Implement logs and alerts for this error.
Summary
Sending media via the WhatsApp Cloud API is straightforward once you understand the two flows and the limits per file type.
The key points:
- Public URL for already-hosted files; pre-upload for local, sensitive, or reused files
- Images: JPEG/PNG up to 5 MB; Videos: MP4 with H.264 up to 16 MB; Documents: PDF and Office up to 100 MB
- The upload uses
multipart/form-datawith themessaging_product,type, andfilefields - The
media_idis valid for 30 days — worth caching for batch sends - Errors 131051, 131052, and 131053 are the most common — handle them explicitly in your code
With the helper function presented above, you have a unified interface that validates size, detects the media type, and routes to the correct flow — ready to integrate into any Node.js application.