
Claude Files API Out of Beta: Migrate Without Breaking
The Claude Files API is out of beta: you can drop the files-api-2025-04-14 header, but the moment you do, list responses lose has_more, after_id starts returning 400, and every file gains an expires_at field. Keeping the header is allowed and changes nothing. Removing it is where code breaks, and one of those breaks doesn't throw. I uploaded, listed, paged and deleted files against the live API this morning with and without the header. Here's what actually changed, the exact errors, and the migration order I'd use.
What changed when the Files API went GA?
Anthropic moved the Files API to general availability in August 2026, alongside Agent Skills and computer use. The Files API docs now treat the header as legacy: requests that still send it get the old beta shapes, requests without it get the new ones. Same endpoints, same file_ids, same Messages content blocks.
The GA release also shipped three things that are only worth having if you migrate:
- File expiration. An
expires_in_secondsfield on upload, so temporary files clean themselves up. - Bulk lookup. Up to 100
ids[]in one list call instead of one metadata request per file. - Higher ceilings. About 500 requests per minute and 1 TB of storage per organization, 500 MB per file.
The practical takeaway: nothing forces you to migrate today, but the new features only exist on the GA shape.If you want expiration, you're migrating.
What breaks when you remove the beta header?
I ran every call below twice on 2 October 2026, once with anthropic-beta: files-api-2025-04-14 and once without. These are the differences I saw, error strings copied from the responses.
| Call | With the header | Without the header |
|---|---|---|
GET /v1/files?limit=2 | data, has_more, first_id, last_id | data, next_page (no has_more at all) |
?after_id=file_... | Works | 400: after_id: unknown field |
| File with no expiration | expires_at omitted | "expires_at": null |
File uploaded with expires_in_seconds=3600 | expires_at returned | expires_at returned |
| Upload with no part Content-Type | Required (per docs) | Optional: a .txt was detected as text/plain |
One row disagrees with the docs. The migration table says expires_atis "not returned" with the header. In my test it was omitted only when it would have been null; a file uploaded with an expiration showed its expires_ateven on the beta shape. Don't write code that relies on the field being absent.
Why can a has_more loop silently stop at page one?
This is the one I'd check first, because it fails quietly. Most hand-written pagination for the beta API looks like this:
let afterId: string | undefined;
let res;
do {
res = await listFiles({ limit: 100, after_id: afterId });
files.push(...res.data);
afterId = res.last_id;
} while (res.has_more);Remove the header and the first call still succeeds on page one. res.has_more is now undefined, which is falsy, so the loop exits after one page. No exception, no 400. A cleanup job built this way would only ever see your 100 newest files and report success every night. The 400 from after_id only shows up if the loop gets that far, and it never does.
The GA version is shorter:
let page: string | null = null;
do {
const qs = new URLSearchParams({ limit: "1000" });
if (page) qs.set("page", page);
const res = await fetch(`https://api.anthropic.com/v1/files?${qs}`, { headers }).then(r => r.json());
files.push(...res.data);
page = res.next_page; // null on the last page
} while (page);limit tops out at 1,000 (limit=1001 returned limit: Input should be less than or equal to 1000), and results come back newest first. The cursor is opaque (mine started with page_eyJ, base64 JSON), so store it as a string and never build one yourself. If you use the SDK auto-pagination helpers, you get this for free, but only on an SDK version that has client.files.
Which SDK versions stop sending the beta header?
This caught me on my own machine. The Python SDK installed globally on my Mac mini is 0.83.0. On that version client.files doesn't exist, and the source of client.beta.files.list appends files-api-2025-04-14 to every request whether you pass betasor not. You can't migrate from that SDK by changing your code; you have to upgrade it.
Per Anthropic's docs, client.beta.files stops sending the header and returns the GA shapes starting at:
- Python 1.2.0, TypeScript 0.122.0, Go 1.68.0
- Java 2.59.0, Ruby 1.67.0, C# 12.44.0
If you're still on Python 0.x, the jump to 1.x has its own breaking changes (the httpx2 swap, sampling params removed). I wrote those up in the Anthropic Python SDK v1 migration guide. Do that upgrade first, then switch client.beta.files to client.files.
Managed Agents users get a compatibility shim: a request carrying managed-agents-2026-04-01 without the Files header still accepts after_id/before_id and returns has_more next to next_page. Later Managed Agents beta versions drop that, so treat it as a grace period, not a fix.
How does expires_in_seconds work?
Add it as a form field on upload. The accepted range is 3,600 to 7,776,000 seconds (1 hour to 90 days), and the API enforces both edges: 3,599 and 7,776,001 each came back with expires_in_seconds: must be between 3600 and 7776000.
curl -X POST https://api.anthropic.com/v1/files \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-F "file=@invoice.txt" \
-F expires_in_seconds=3600
# {"type":"file","id":"file_01Ru...","size_bytes":67,
# "created_at":"2026-10-02T14:10:29.209880Z",
# "expires_at":"2026-10-02T15:10:29.209880Z", ...}Three things the docs mention that are easy to miss:
- It's set once.You can't extend or remove an expiration after upload. Re-upload if you need longer.
- Expired files still show up in lists for up to 30 days, with
expires_atin the past. Filter on it, or a job that picks "the latest invoice file" will pick a dead one. - A Messages request that references an expired file fails before inference.Same as a deleted file: I referenced one I'd just deleted and got a 404
not_found_error, no tokens billed.
My rule: anything uploaded for a single job gets an expiration.The per-run PDFs and screenshots my automations push to Claude don't need to outlive the run, and 1 TB per org is a ceiling you only notice once a cron job has been leaking for a year. One hour is plenty for a synchronous request. For a Batch API job that can take up to 24 hours, give it two days.
When should you use ids[] instead of paging?
When you already know which files you care about. Pass up to 100 ids[] and you get one page back with next_page: null. I asked for two files after deleting one of them, and the response simply had one file in data. Missing IDs are dropped silently, so diff what you asked for against what came back.
Two sharp edges I hit:
- A malformed ID doesn't get dropped. It fails the whole call with 400
ids[]: invalid id `file_01NOPE...`. One bad row in your database takes down the batch lookup, so validate thefile_prefix before you send. ids[]can't be mixed withlimitorpage: 400ids[]: cannot be combined with `limit`.
What didn't change?
Referencing a file in Messages is identical. A document block with source: {type: "file", file_id} worked on claude-haiku-4-5 with no beta header and read a 67-byte test invoice back correctly for 89 input tokens. Images still use image blocks, and anything headed for code execution still uses container_upload.
Uploaded files still aren't downloadable. GET /v1/files/{id}/content on my own upload returned 400 file_not_downloadable; only files produced by code execution or skills come back out. File operations are still free, and file content is billed as input tokens like any other prompt. If the same file goes into many requests, put it behind a cache breakpoint. My prompt caching breakdown has the numbers, and cache diagnostics will tell you when it misses.
And the security model didn't change, which is the part I'd underline for anyone building multi-tenant. Files are scoped to the workspace, not to a user or a session. Any key in that workspace can read any file in it. Never take a file_id from a request body and pass it to Claude. Map users to files in your own database. For hard isolation between client accounts, use one workspace per tenant (you get up to 100 per organization).
In what order should you migrate?
- Grep for callers. Search for
files-api-2025-04-14,beta.files,has_more,after_idandbefore_id. Include n8n HTTP Request nodes and anything else that sets headers by hand. - Rewrite pagination before you touch the header. Loop on
next_page. That code can't run until the header is gone, so ship both together. This is the change that otherwise fails silently. - Upgrade the SDK to a release with
client.files, then dropbetafrom the call path. - Make
expires_atnullable in your types and filter expired files out of list results. - Add
expires_in_secondsto every temporary upload. - Verify by counting. Walk every page with
limit=2against a workspace with a handful of files and check the total matches. If it stops at 2, your loop is still readinghas_more.
Budget an hour per service. The code change is small. Most of the time goes into finding every place that pages through files, which is the same lesson from the Sonnet 4.5 retirement: the call you forgot about is the one that breaks.
Want a second pair of eyes on your Claude stack?
I build and maintain Claude API integrations, n8n automations and voice agents for agencies and operators, and I keep them current when Anthropic ships changes like this one. If you'd rather not find the next silent break in production, tell me what you're running and I'll tell you what I'd check first.
Behavior verified 2 October 2026 with live calls to api.anthropic.com/v1/files and /v1/messages, with and without anthropic-beta: files-api-2025-04-14. SDK version thresholds and limits are from Anthropic's platform release notes and Files API documentation. All test files were deleted afterwards.
Related Posts
AI Agents
ant apply: Manage Claude Agents as Code
ant apply turns Claude agents, skills, environments, memory stores and scheduled deployments into files in your repo, with a Terraform-style plan-and-apply loop. How claude-lock.json detects drift with two hashes, why renaming a file silently duplicates an agent, the adoption gap for Console-made resources, and the five rules for running it in CI.
AI Agents
Claude Agent Memory Stores vs the Memory Tool
A memory store is a versioned folder Anthropic mounts into your agent's sandbox at /mnt/memory. How it differs from the client-side memory tool, the read_write default that enables persistent prompt injection, the 10,000-memory cap that fails silently, and the 15-second sync on self-hosted sandboxes.
AI Agents
Claude Mid-Conversation Tool Changes: Keep the Cache
Editing the tools array invalidates the prompt cache for the whole conversation. tool_addition and tool_removal change what Claude can call without ever touching it — plus the placement rules that 400.