← Blog

·

How to receive and download media customers send through the WhatsApp API

Updated on October 28, 2026

When a customer sends a photo, PDF, audio or video, the WhatsApp Business API webhook does not include the file: it includes a media ID. To download it, call GET /{media-id} to get a temporary URL, which expires after 5 minutes, then GET that URL with your access token in the Authorization header. Since a media ID received via webhook is only valid for 7 days, download right away and keep the file in your own storage.

This guide covers the full flow, the fields for each media type, size limits, how to check file integrity, and examples in cURL, Node.js and Python, including uploading to Amazon S3.

The flow in 4 steps

  1. Webhook: a message arrives with type set to image, document, audio, video or sticker, carrying the media id.
  2. Temporary URL: GET https://graph.facebook.com/v26.0/{media-id} returns url, mime_type, sha256 and file_size.
  3. Download: GET the url with Authorization: Bearer <token>. Without the token, the request fails.
  4. Storage: save it to your storage (S3, GCS, disk) and store the file path in your database, never Meta’s URL.

The time limits come from Meta’s media documentation: media URLs expire after 5 minutes (after that, query the media ID again for a new one), media IDs from webhooks expire after 7 days, and media IDs from uploads expire after 30 days.

What arrives in the webhook

This is the shape of an incoming image, per the image message webhook reference (sample values):

{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "WABA_ID",
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "metadata": {
          "display_phone_number": "15550783881",
          "phone_number_id": "PHONE_NUMBER_ID"
        },
        "contacts": [{ "profile": { "name": "Maria" }, "wa_id": "16505551234" }],
        "messages": [{
          "from": "16505551234",
          "id": "wamid.HBgLMTY1...",
          "timestamp": "1761650000",
          "type": "image",
          "image": {
            "caption": "Receipt",
            "mime_type": "image/jpeg",
            "sha256": "SfInY0gGKTsJlUWbwxC1k+FAD0FZHvzwfpvO0zX0GUI=",
            "id": "1003383421387256",
            "url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=..."
          }
        }]
      }
    }]
  }]
}

The object name changes with the type (image, document, audio, video, sticker), but the structure is similar. Documents also include filename, handy for saving with the original name.

Note the url field. Meta started rolling it out gradually in the webhook itself on November 12, 2025, which lets you skip step 2 and download directly with your token. Because the rollout is gradual, your code should work with and without it: use url when present and, if it is missing or the download fails, query the media ID.

FieldWhat it is for
idMedia ID; use it to generate the download URL
mime_typeFile type (image/jpeg, application/pdf, audio/ogg…)
sha256File hash, to check integrity
captionCaption sent by the customer, if any
filenameOriginal name (documents)
urlDirect download URL, in gradual rollout

Want to try it while you read? Create a Vectalk account and send your first message through the official API. Create free account →

Types and size limits

This table summarizes supported types and maximum sizes from Meta’s media page. Use it to size your storage and download timeouts:

TypeFormatsMax size
AudioAAC, AMR, MP3, M4A, OGG (OPUS codec only, mono)16 MB
DocumentTXT, XLS, XLSX, DOC, DOCX, PPT, PPTX, PDF100 MB
ImageJPEG, PNG (8-bit, RGB or RGBA)5 MB
StickerWebP500 KB animated, 100 KB static
Video3GP, MP4 (H.264 and AAC)16 MB

Voice notes arrive as audio messages. If you pass audio to a transcription service, check the mime_type you received and whether the service accepts that format; if not, convert it first.

cURL example

Step 2, get the URL:

curl "https://graph.facebook.com/v26.0/1003383421387256" \
  -H "Authorization: Bearer $TOKEN"

Response:

{
  "messaging_product": "whatsapp",
  "url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=...",
  "mime_type": "image/jpeg",
  "sha256": "SfInY0gGKTsJlUWbwxC1k+FAD0FZHvzwfpvO0zX0GUI=",
  "file_size": 303833,
  "id": "1003383421387256"
}

Step 3, download:

curl -L "RETURNED_URL" \
  -H "Authorization: Bearer $TOKEN" \
  -o receipt.jpg

Optionally, pass phone_number_id as a query parameter in step 2: Meta then processes the request only if the media belongs to that number, a useful safeguard when one application serves several numbers.

Node.js example

import crypto from 'node:crypto';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const GRAPH = 'https://graph.facebook.com/v26.0';
const TOKEN = process.env.WHATSAPP_TOKEN;
const s3 = new S3Client({ region: process.env.AWS_REGION });

async function getMediaUrl(mediaId) {
  const res = await fetch(`${GRAPH}/${mediaId}`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (!res.ok) throw new Error(`media ${mediaId}: HTTP ${res.status}`);
  return res.json(); // { url, mime_type, sha256, file_size, id }
}

async function download(url) {
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw Object.assign(new Error('download failed'), { status: res.status });
  return Buffer.from(await res.arrayBuffer());
}

export async function saveMedia(media, key) {
  let info = { url: media.url, sha256: media.sha256, mime_type: media.mime_type };
  let file;
  try {
    if (!info.url) throw new Error('no url in webhook');
    file = await download(info.url);
  } catch {
    info = await getMediaUrl(media.id); // fresh URL
    file = await download(info.url);
  }

  const hash = crypto.createHash('sha256').update(file).digest('base64');
  if (info.sha256 && hash !== info.sha256) throw new Error('hash mismatch');

  await s3.send(new PutObjectCommand({
    Bucket: process.env.BUCKET,
    Key: key,
    Body: file,
    ContentType: info.mime_type,
  }));
  return key;
}

In Meta’s examples the sha256 value is base64-encoded, which is why the comparison uses digest('base64'). If you see a different format in your traffic, log both values before turning the check on.

Python example

import base64
import hashlib
import os

import boto3
import httpx

GRAPH = "https://graph.facebook.com/v26.0"
TOKEN = os.environ["WHATSAPP_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
s3 = boto3.client("s3")


def get_media_url(client: httpx.Client, media_id: str) -> dict:
    r = client.get(f"{GRAPH}/{media_id}", headers=HEADERS)
    r.raise_for_status()
    return r.json()


def save_media(media: dict, key: str) -> str:
    with httpx.Client(timeout=60, follow_redirects=True) as client:
        info = get_media_url(client, media["id"])
        r = client.get(info["url"], headers=HEADERS)
        if r.status_code == 404:  # expired URL: get a new one and retry
            info = get_media_url(client, media["id"])
            r = client.get(info["url"], headers=HEADERS)
        r.raise_for_status()

    computed = base64.b64encode(hashlib.sha256(r.content).digest()).decode()
    if info.get("sha256") and computed != info["sha256"]:
        raise ValueError("hash mismatch")

    s3.put_object(
        Bucket=os.environ["BUCKET"],
        Key=key,
        Body=r.content,
        ContentType=info["mime_type"],
    )
    return key

For documents up to 100 MB, holding everything in memory can get heavy. In that case, stream the download to a temporary file and use boto3’s upload_fileobj, or Upload from @aws-sdk/lib-storage in Node, both of which upload in parts.

Where this fits in your architecture

Do not download media inside the webhook request. The endpoint should validate the signature, enqueue the event and return 200 quickly; a separate worker handles download and upload. That way a large video does not stall the webhook or trigger retries from Meta. For the validation part, see how to validate the webhook signature.

Some good practices:

  • Predictable storage keys, such as whatsapp/{phone_number_id}/{wa_id}/{wamid}.{ext}, so you can find the file from the message.
  • Idempotency: before downloading, check whether that message was already processed; webhooks can be delivered more than once.
  • Private buckets and signed links for whoever needs to view the file, since receipts and documents contain personal data. If you serve Brazilian customers, the LGPD (Brazil’s data protection law, Law 13,709/2018) requires care with security and retention of that data; define how long you keep each type. This is not legal advice.
  • Retry within the window: if the download fails, you have the media ID’s 7-day validity to try again.

Common errors

404 on download. Meta’s media page says to request a new media URL and try again; if that does not fix it, renew your access token. In practice, the most common cause is using a URL saved more than 5 minutes earlier.

401 or 403 on download. The GET on the URL was sent without the Authorization header, or with a token that has no access to the account. Use a permanent system user token; see WhatsApp API permanent access token.

Error 131052 in the webhook. Meta’s error table defines it as “unable to download the media sent by the user”. Read the webhook’s error_data.details field and ask the customer to resend the file another way.

Expired media ID. After 7 days there is no way to retrieve the media through the API. That is why the download should happen right after the webhook.

How to do this with Vectalk

With Vectalk, webhooks are Meta’s own, so the media ID arrives in the same field. For media, the Vectalk API offers GET https://apis.vectalk.com.br/api/media/{mediaId}, which returns the temporary URL (valid for about 5 minutes), and GET https://apis.vectalk.com.br/api/media/{mediaId}/download, which returns the binary file with the right Content-Type, removing the second step. Both routes use the instance token. Sign up at apps.vectalk.com.br/register.

For the opposite direction, sending files to customers, see how to send media with the WhatsApp API. If your bot receives lots of files, complete examples are in the Python bot and Node.js bot tutorials. The big picture is in our WhatsApp Cloud API guide.

Frequently asked questions

How long is the media URL valid?

5 minutes. After that, query the media ID again for a new URL. A media ID received via webhook is valid for 7 days.

Can I store Meta’s URL in my database and open it later?

No. The URL expires in 5 minutes and requires the token to download. Store the file in your own storage and save its path in the database.

Do I need the token to download from the URL?

Yes. Meta’s documentation says the request fails if you omit the token. Send Authorization: Bearer <token> both when querying the media ID and when downloading.

What is the maximum size of a received file?

Per Meta’s supported media table, images go up to 5 MB, audio and video up to 16 MB, and documents up to 100 MB. Stickers have smaller limits.

How do I know the downloaded file is intact?

Compute the SHA-256 of the content and compare it with the sha256 from the webhook or the media ID response. In Meta’s examples, the value is base64-encoded.


Official sources consulted on October 28, 2026: Media (Meta), Image message webhook (Meta), Document message webhook (Meta), Error codes (Meta), Media in the Vectalk API, PutObjectCommand (AWS SDK for JavaScript v3), upload_fileobj (boto3), LGPD, Law 13,709/2018 (Portuguese).

Try the official WhatsApp API today

Create your account, generate a token and send your first message with the quick-start guide. Same payloads and webhooks as Meta's Cloud API.