Desktop Pricing Docs Blog About GitHub View as Markdown (Agent) Get started
Human Agent
← Back to blog

Hosting images for Twilio MMS and WhatsApp Cloud API

Neither API takes your file. Both take a URL, and both open it from their own servers — Twilio when the message sends, Meta as it processes each send. Your code is long gone by then. If that URL was signed, temporary, or behind anything, the send fails somewhere you aren't looking.

A message attachment with an expiring link on the left, re-hosted through PixelVault's vault into a permanent URL that still resolves when the message is delivered on the right
This hero is hosted on PixelVault itself — a stable img.pixelvault.dev URL, the same kind this post shows you how to get for message media.

Twilio fetches your URL — and HEADs it first

Twilio's media documentation is direct about the requirement: "Static media urls should resolve to publicly hosted media files." For an out-of-session content template, it tells you to "provide the URL of the publicly hosted file" in the template's Media field. There's no upload endpoint in that path — the URL is the attachment.

The detail worth internalising is how Twilio opens it. Per Twilio's accepted-MIME-types guide, Twilio issues GET and HEAD requests to the MediaUrl to validate the file's Content-Type header. So your host has to answer a HEAD correctly, not just a GET — a surprising number of ad-hoc file servers and signed-URL schemes don't.

And a content template makes the gap enormous. With a one-off message the fetch happens seconds after your call. With a template, the media URL is stored in the template and fetched every time that template is sent — which could be next month, or for the next year. A URL that "worked when I set it up" is not a useful property there.

WhatsApp: when a hosted link is required

Here's where being honest beats being promotional. For a plain Cloud API image message you have two options, id or link, and Meta's own docs say the link is "URL of the media asset hosted on your public server" and that "for better performance, we recommend using id." If you're firing a single session message and you don't mind an upload round-trip, use id — that's Meta's recommendation and it's good advice.

Hosting wins in three concrete cases:

  • Template headers. Meta's documented shape for a media header parameter is image: { "link": "https://…" }, pointing at a direct download. (Reports differ on whether an uploaded id is also accepted there; every first-party example we found uses link, and template creation with a media header uses a Resumable Upload handle, which is a third thing again. If you need an id here, test it before you rely on it.)
  • Media IDs expire. An uploaded media ID is good for 30 days, after which you re-upload to get a new one. For an asset you send for a year — a logo, a receipt header, a product shot — that's a recurring job you don't need.
  • IDs are tied to a phone number. Uploads go to POST /PHONE_NUMBER_ID/media. Run several numbers, or several channels, and one hosted URL replaces an upload per number.

Format gates are tight: JPEG or PNG only, 5 MB maximum, 8-bit RGB or RGBA. No WebP, no AVIF — which matters if your pipeline defaults to modern formats for the web.

The fix: host first, then send

Host the asset once, keep the URL, and reuse it. The Twilio path uploads a file and sends; the WhatsApp path hosts a header image and references it from a template. Both verify the URL before handing it over — because the cheapest moment to catch a bad URL is before an API queues it.

// Host once, then send. The URL is what Twilio fetches —
// at send time, not now.
import twilio from "twilio";
import { readFile } from "node:fs/promises";
import { basename } from "node:path";

const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);

// Upload the file already in its final format. Don't rely on
// ?fmt= here — see the extension/MIME note below.
async function host(path) {
  const form = new FormData();
  form.append("file", new Blob([await readFile(path)]), basename(path));
  form.append("folder", "mms");

  const res = await fetch("https://api.pixelvault.dev/v1/images", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.PIXELVAULT_API_KEY}` },
    body: form,
  });
  if (!res.ok) throw new Error(`Host failed: ${res.status}`);
  return (await res.json()).data.url;
}

const mediaUrl = await host("./receipt.jpg");

// Check it the way Twilio will — HEAD — and make it a gate,
// not a log line. Fail here, not in the carrier's queue.
const probe = await fetch(mediaUrl, { method: "HEAD" });
const ct = probe.headers.get("content-type");
if (!probe.ok || ct !== "image/jpeg") {
  throw new Error(`Not sendable: ${probe.status} ${ct}`);
}

await client.messages.create({
  from: process.env.TWILIO_FROM,
  to: "+15551234567",
  body: "Here's your receipt.",
  mediaUrl: [mediaUrl], // up to 10, all must be alive at send time
});

The HEAD probe in the first tab isn't decoration — it's the same request Twilio makes to validate your Content-Type, so running it yourself turns a send-time failure into a deploy-time one. Which brings us to the thing most likely to break.

The trap: file extension vs Content-Type

This is the part that cost us a round of testing, and it's the reason the Twilio snippet above uploads a JPEG rather than converting one on the fly.

Here's what Twilio's guide actually establishes, stated no more strongly than that: it validates the Content-Type against the real media and rejects a body that doesn't match the declared MIME type, and it documents filename and suffix expectations via Content-Disposition. We did not find official wording that a send is rejected solely because the URL's extension disagrees with a correct Content-Type — so treat that as a thing to avoid, not a documented rejection rule. Our transform params make the disagreement easy to create:

  • …/img_abc.png → Content-Type: image/png. Extension and body agree. ✅
  • …/img_abc.png?fmt=jpg → Content-Type: image/jpeg from a URL ending .png. Header and body agree with each other, but not with the extension. ⚠️

Worth knowing why the first case is safe, because it's a stronger guarantee than "we set the right header": on a plain URL the extension can't lie. We detect the type from the file's magic bytes, not from the filename you uploaded, and both the stored extension and the served Content-Type derive from that one detected value. Upload a PNG named header.jpg and you get a .png URL serving image/png. The only way to manufacture a mismatch is to add a ?fmt= param.

Both are verified against a live upload. The second is fine for a browser and fine for Buffer, which reads the header — but it's precisely the kind of disagreement Twilio says it checks. So for MMS and WhatsApp, upload the asset in its final format and let the extension tell the truth. Save ?fmt= for web and social surfaces, where it's doing real work.

There's a second reason to upload the final format rather than convert on the fly, and it's about our own design: transforms are fail-open. If a transform can't be produced — an unsupported source, or a free project past its monthly transform budget — the original object is served instead, and invalid params are ignored rather than raising an error. SVG is never transformed at all. For a web page that's the right behaviour; for a message payload it means the failure case of ?fmt=jpg is "Twilio receives your PNG", which is exactly what you were trying to avoid. Upload the JPEG and there is no transform to fail.

fmt=auto deserves its own warning, and the honest version is more interesting than "it returns AVIF". By design it negotiates on the Accept header: a browser advertising image/avif gets AVIF, and a client that advertises nothing modern is supposed to get the source format back. A messaging provider is the second kind of client, so in principle you're fine.

In practice we measured something less comfortable. On a cold URL, a bare Accept: */* came back as image/jpeg — not the PNG source, so "keeps the source format" isn't quite what happens either. And on a URL a browser had already requested, the same bare-Accept request came back as image/avif: the cached variant gets replayed to a client that never asked for it. Order of arrival decides your Content-Type.

That's our bug to fix, not yours to work around — it's filed as issue #234, with the reproduction. But it makes the rule simple: a message payload should never depend on content negotiation. WhatsApp accepts JPEG and PNG only, so "whatever format the last visitor warmed the cache with" is not a risk worth taking. Pin an explicit format — or better, upload the final one and skip the question.

How you find out it broke

Both of the problems above have names in Twilio's error catalogue, and both are worth wiring an alert to:

  • 12300 — Invalid Content-Type. "Twilio is unable to process the Content-Type of the provided URL." Its first suggested fix is to confirm your server returns the correct Content-Type header. Note what that describes: a Content-Type Twilio can't process — missing, wrong, or unsupported. It's the error to expect when the header is the problem; we haven't isolated which code an extension-only disagreement produces, if any.
  • 11751 — Media exceeds messaging provider size limit. "The combined size of your outbound message and media exceeds the maximum allowed for the channel" — the combined-budget point, confirmed in Twilio's own words.

The thing to plan for is where you'll see them. Your create call returns a message resource that starts out queued, so a problem with media Twilio hasn't fetched yet won't necessarily come back as an exception in the code that sent it. Configure a status callback and check the message's error code, or these failures live only in the Twilio console. That's the same lesson as the Buffer post's "GraphQL always returns HTTP 200" — an accepted request is not a delivered message.

Size limits, and where ours sits

Three different numbers are in play, which is a common source of confusion:

Channel
Limit
Note
MMS (Twilio)
5 MB
Combined message and media
RCS (Twilio)
16 MB
Falls back to MMS's 5 MB
WhatsApp (Twilio)
20 MB
Per Twilio's channel table
Image (Cloud API)
5 MB
JPEG/PNG, 8-bit RGB/RGBA
PixelVault upload
5 MB
Same ceiling as MMS and Cloud API images

Twilio and Meta limits on this page last re-read against their docs on 4 October 2026.

Our 5 MB upload cap is usually the thing we have to caveat, and here it only half-applies — so let's split it properly:

  • WhatsApp Cloud API images: genuine parity. 5 MB is the media limit on both sides, so anything we accept is within what Meta will send.
  • MMS: the parity is illusory, and in our own disfavour. MMS's 5 MB covers the message and all media combined, so an upload at our 5 MB maximum is already over the MMS budget before you add one character of body text. Budget roughly 4 MB of media for a single-attachment MMS, and a lot less across ten. Carriers transcode aggressively too, so a 4.9 MB image is a bad idea for reasons that have nothing to do with either limit.

One inconsistency to know about: Twilio's accepted-media guide lists WhatsApp at 20 MB while its error-11751 page still says 16 MB. Either way the Cloud API image limit is 5 MB, which is the number that binds for images.

Three things to get right

  • Use an API key. Keyless uploads expire after 30 days by design. A template that sends for a year needs a permanent URL, and that's what a keyed upload gives you.
  • Keep it public. We offer private signed URLs; this is the wrong place for them. Twilio states plainly that media must be publicly hosted, and a signed link is the classic way to make a template start failing weeks later. Signed URLs are the right tool for CI screenshots, not for message media.
  • Verify with HEAD, not GET. Twilio validates via both. A quick curl -sI in your deploy checks catches a host that only answers GET — ours returns the correct Content-Type on HEAD, including on transformed URLs.

Why not just serve it from your own app?

You can, and for a small volume you probably should — a static file behind your CDN is a perfectly good MediaUrl. The reasons teams end up moving it out: a message blast means a fetch per recipient against your app's origin, media URLs in a long-lived template outlive the route that served them, and app deploys change paths in ways a stored template doesn't follow. Immutable URLs on separate infrastructure decouple "the image exists" from "the app is up" — which is the same argument as hosting images for transactional email, where the asset has to outlive the send by years.

If an agent is sending the messages, the same shape applies as with Buffer carousels: connect PixelVault's MCP server and it can host the asset, then pass the returned URL straight into the Twilio or Meta call instead of inventing a data: URI that both APIs reject. Which server matters: the remote one's upload_image takes source_url or base64 data (base64 means the bytes cross the agent's context window), while the local stdio package @pixelvault-dev/local-mcp adds path for hosting a file off disk without reading it into context. For a receipt or a render sitting on local disk, that's the one you want.

Free to start

PixelVault's free tier includes 200 MB storage, 500 uploads/month, and 1 GB bandwidth — no credit card, zero egress, which matters when a campaign means one fetch per recipient. Paid plans start at $9/month. Start from the API quickstart →.