Buffer doesn't accept file uploads
This is the part that surprises people, and it's stated plainly in Buffer's own documentation: the Buffer API does not accept file uploads. You host the media yourself and pass a publicly reachable URL in the assets array. From Buffer's create-image-post example, an image post is just a text post plus an ordered list of assets:
assets: [{ image: { url: "https://…" } }]
There's no multipart endpoint, no pre-signed upload handshake, no media_id to exchange. The URL is the attachment. Which means the durability of your post is exactly the durability of whatever host you pointed at.
The requirement that actually breaks things
Buffer's Hosting Media guide spells out four requirements, and the second one is the one that bites. Paraphrasing closely:
- Publicly reachable — no authentication. Google Drive and Dropbox share links don't work.
- Reachable until the post publishes — "not just when you create it." Buffer fetches the media when the post goes out, which for a queued or scheduled post can be hours or days later.
- Not expiring or signed. Buffer explicitly names S3 pre-signed links and Cloudinary signed-delivery URLs, warning they "often work the moment you call
createPostbut expire before the post publishes." - A direct link to the file — not a redirect or a preview page.
Read those together and the failure mode is obvious in hindsight. There are two moments in a Buffer post's life: when you create it, and when it publishes. Every test you ran happened in the first moment. Every real publish happens in the second. A URL that's alive for one hour passes every check you can think to write and then dies in the queue — and because the fetch happens inside Buffer's infrastructure, long after your script exited, there's nothing in your logs to look at.
If that shape is familiar, it's because it's the same bug as Notion's one-hour signed URLs, Discord's expiring attachment links, and GPT Image 2's temporary result URLs. A short-lived credential doing a job that needs a permanent address.
Carousels multiply it by ten
A single-image post gives you one URL to get right. A carousel gives you up to ten, and they have to all still be alive at publish time — one dead slide is a failed post, not a nine-slide carousel.
The platform ceilings are worth knowing before you build the pipeline. Buffer's help centre puts Instagram carousels at up to 10 images — Instagram's native app allows more, but its API caps third-party tools lower — and TikTok photo posts at up to 10 as well.
Then there's the format gate underneath Buffer. Meta's IG User Media reference is strict about what a carousel image container will accept: JPEG, 8 MB maximum, an aspect ratio between 4:5 and 1.91:1, sRGB (other colour spaces get converted), and a width between 320px and 1440px — narrower is scaled up, wider is scaled down. Hand Buffer a tall 9:16 PNG and the failure surfaces two hops away from your code, in Instagram's container step, with an error you never see.
Buffer and Meta requirements on this page last re-read against their docs on 4 October 2026.
What Buffer recommends — and where we fit
Credit where it's due: Buffer's hosting guide doesn't leave you guessing. It recommends two hosts by name, Cloudinary and Cloudflare R2, and tells you to verify a URL by opening it in an incognito window. That's good advice and it works. If you already run Cloudinary for your media, use it — just use an unsigned delivery URL, which is the thing the guide is warning you about.
PixelVault is the second of those two recommendations with the setup removed. It runs on Cloudflare R2 behind Cloudflare's CDN, so you get the same storage Buffer points you at — but as one authenticated API call instead of a bucket, a public-access policy, a custom domain and a CORS config. Uploads come back on an immutable URL, and egress is never billed — though each plan does include a bandwidth allowance, so it isn't unmetered.
To be precise about where that helps, since it's easy to oversell: Buffer fetches each slide roughly once, and Instagram then re-hosts the media on its own CDN. So a carousel going viral drives almost no traffic to us, and bandwidth is not really the argument here. The argument is reuse — the same ten URLs feeding a cross-post to TikTok, a re-publish next quarter, and a landing page, without re-uploading anything.
Where we're the wrong answer: if your slides are rendered by a design tool that already publishes public URLs, you don't need another host in the chain. And if you want to generate the slides programmatically with templates and overlays, that's Cloudinary's and ImageKit's home turf, not ours — we compared the three in Cloudinary vs ImageKit vs S3.
The fix: host first, then post
Host every slide, collect the URLs in slide order, and pass them to createPost. The URL you get back doesn't change and doesn't expire, so it's safe to store alongside your content and reuse across posts and channels.
const PV = process.env.PIXELVAULT_API_KEY;
const BUFFER = process.env.BUFFER_API_KEY;
// PixelVault fetches each source server-side — the bytes never
// pass through your process. Returns an immutable CDN URL.
async function host(sourceUrl) {
const res = await fetch("https://api.pixelvault.dev/v1/images", {
method: "POST",
headers: {
Authorization: `Bearer ${PV}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ url: sourceUrl, folder: "carousel" }),
});
if (!res.ok) throw new Error(`Host failed: ${res.status}`);
return (await res.json()).data.url;
}
// Slide order = assets[] order = the order people swipe.
const sources = [
"./slide-01.jpg", "./slide-02.jpg", "./slide-03.jpg",
]; // local paths, or URLs for the JSON form
// Serial on purpose — not for ordering (Promise.all preserves
// input order), but to stay gentle on the rate limit and to
// make a failure point at one identifiable slide.
const slides = [];
for (const src of sources) {
const url = await host(src);
// size=square -> exactly 1080x1080 JPEG. A preset skips
// the dimension snapping, so the ratio is guaranteed.
// Use size=portrait for Instagram's 4:5 (1080x1350).
slides.push({ image: { url: `${url}?size=square&fmt=jpg` } });
}
const mutation = `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
... on PostActionSuccess { post { id } }
... on MutationError { message }
}
}`;
const bufferRes = await fetch("https://api.buffer.com", {
method: "POST",
headers: {
Authorization: `Bearer ${BUFFER}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: mutation,
variables: {
input: {
text: "Six things we learned shipping on the edge 👇",
channelId: process.env.BUFFER_CHANNEL_ID,
schedulingType: "automatic",
mode: "addToQueue",
assets: slides,
},
},
}),
});
// Buffer: "GraphQL always returns HTTP 200. Check the response
// body to determine success or failure." So res.ok proves nothing.
const json = await bufferRes.json();
if (json.errors?.length) {
// System-level: auth, rate limit, not found.
throw new Error(json.errors.map((e) => e.message).join("; "));
}
// Typed mutation errors arrive in data, not errors. MutationError
// is the catch-all — Buffer advises selecting it on every mutation,
// so new members (LimitReachedError, InvalidInputError) still surface.
const result = json.data?.createPost;
if (!result?.post?.id) throw new Error(result?.message ?? "createPost failed");
console.log("queued", result.post.id);
Each URL carries ?size=square&fmt=jpg, which crops and converts at the edge — an exact 1080×1080 JPEG, with no ImageMagick step and no second copy to manage. Swap in size=portrait for 4:5.
One caveat we have to be straight about, because it undercuts the recipe if you don't know it: our transforms are fail-open by design. If a transform can't be produced — an unsupported source, or a free project that has crossed its monthly transform budget — the original object is served instead, and invalid params are ignored rather than erroring. SVG is never transformed at all; it's served as-is. So if you upload a PNG and lean on ?fmt=jpg, the one scenario where the transform is skipped is the scenario where Buffer fetches your PNG and Instagram rejects the container — the same invisible failure this post exists to prevent. Upload JPEG slides and treat fmt=jpg as a belt-and-braces second line, not the gate itself. It also saves minting a billed variant per slide.
One sizing gotcha worth knowing
Pick those numbers carefully, because our transform dimensions snap up to a discrete whitelist — …, 800, 1024, 1200, 1600, 2000, … That's a deliberate cost guard: it bounds how many distinct variants can exist, which keeps transforms inside Cloudflare's free tier and stops a URL being used as a cache-buster. It also means w and h snap independently, so a ratio you asked for isn't necessarily the ratio you get.
The trap is specific, and it's one we walked into while writing this post. Asking for Instagram's classic 4:5 portrait as ?w=1080&h=1350 snaps to 1200×1600 — a 3:4 ratio of 0.75, which falls below Instagram's 4:5 (0.8) floor and gets the container rejected. The request looks exactly right and the result is out of spec.
Writing this post is what made us fix it properly. Rather than ask you to reverse-engineer a grid, there are now named presets for exactly these ratios, and a preset is a single fixed variant that skips the snapping entirely:
- Portrait 4:5 —
?size=portrait&fmt=jpg→ 1080×1350, Instagram's canonical carousel size. - Square 1:1 —
?size=square&fmt=jpg→ 1080×1080. The safest default, valid on Instagram and TikTok. - Landscape —
?size=social&fmt=jpg→ 1200×630. It exists as an OG-card preset, and at 1.905:1 it lands just inside Instagram's 1.91:1 ceiling.
All three sit inside the container window on both ratio and width, so there's nothing to compute. They also work on the free playground project, which only accepts presets.
If you'd rather drive w/h yourself, the rule is: pick values that are both already on the list, and the ratio you compute is the ratio you get. Pick anything else and two independent snaps decide it for you — ?w=1600&h=2000, for instance, is on-grid and exactly 0.8.
If your slides are already on a URL somewhere — a render service, an AI image model, a temporary bucket — use the JSON form and pass url. PixelVault fetches the bytes server-side, so you never download and re-upload them yourself. That's also the move for rescuing a source that's about to expire, as long as you do it while it's still valid.
Three things that will bite you
Being straight about our own edges, because two of them are failure modes you'd otherwise discover in the queue:
- Use an API key, not a keyless upload. PixelVault's anonymous uploads expire after 30 days by design. A queued post would publish fine, but the carousel is live on Instagram forever and the images would 404 a month later. Keyed uploads are permanent — this is the one setting that matters.
- Our upload cap is 5 MB, tighter than Instagram's 8 MB container limit. For carousel slides that's rarely a constraint — a 1080×1350 JPEG lands far under it — but a raw export straight off a design tool can exceed it, so compress before upload.
- Don't reach for private signed URLs here. We offer them, and they're exactly what Buffer's guide tells you not to use. Signed-link expiry is the right tool for CI screenshots; it's the wrong tool for a post that publishes next Thursday. Keep carousel slides public.
If an agent is doing the posting
This is increasingly the actual workflow: you ask an assistant to draft and queue a carousel, and it drives Buffer's MCP server at mcp.buffer.com/mcp to do it. The media constraint doesn't change — create_post attaches hosted media by URL, same as the GraphQL path, because it's the same API underneath.
What changes is that the agent is holding image bytes it has no way to turn into a URL. That's the gap PixelVault's MCP server closes — and it's worth knowing which one you've connected, because they differ exactly here. The remote server takes source_url or base64 data: a URL costs the agent nothing, but base64 means the bytes travel through its context window. The local stdio server (npx -y @pixelvault-dev/local-mcp) adds path, so an agent can host a file off disk without reading it into context at all. For slides rendered locally, that's the one you want. Either way the returned URL goes straight into Buffer's create_post — two MCP servers, one chain, no copy-pasting between windows.
It's worth being explicit with an agent that the slides must be hosted before the post is created, and in order. Left to improvise, an agent will cheerfully hand Buffer a data: URI or a localhost path, get a successful createPost back, and report the job done — because from its side, it was. If the agent is generating the slides too, hosting AI-generated images covers that half.
A URL Buffer can still fetch on Thursday
createPost?size=portrait&fmt=jpgFree to start
PixelVault's free tier includes 200 MB storage, 500 uploads/month, and 1 GB bandwidth — no credit card, zero egress. That's roughly fifty ten-slide carousels before you pay anything, and paid plans start at $9/month. Start from the API quickstart →.