Skip to content
Skip to main content
A software engineer pulling a tagged storage box from a metal archive shelf in a bright warehouse office, laptop on a rolling cart, a metaphor for managing uploaded files and expiration in the Claude Files API
8 min readBy Carlos Aragon

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_seconds field 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.

CallWith the headerWithout the header
GET /v1/files?limit=2data, has_more, first_id, last_iddata, next_page (no has_more at all)
?after_id=file_...Works400: after_id: unknown field
File with no expirationexpires_at omitted"expires_at": null
File uploaded with expires_in_seconds=3600expires_at returnedexpires_at returned
Upload with no part Content-TypeRequired (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 the file_ prefix before you send.
  • ids[] can't be mixed with limit or page: 400 ids[]: 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?

  1. Grep for callers. Search for files-api-2025-04-14, beta.files, has_more, after_id and before_id. Include n8n HTTP Request nodes and anything else that sets headers by hand.
  2. 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.
  3. Upgrade the SDK to a release with client.files, then drop beta from the call path.
  4. Make expires_at nullable in your types and filter expired files out of list results.
  5. Add expires_in_seconds to every temporary upload.
  6. Verify by counting. Walk every page with limit=2 against a workspace with a handful of files and check the total matches. If it stops at 2, your loop is still reading has_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