Dani Reyes11 min read12 views

The Files API contradicts itself on expires_at, and the reference docs cannot warn you (2026)

Anthropic's Files API guide says expires_at appears on every file response. Its own migration table says the field is not returned under the legacy beta header. Dated mirrors put both sentences on the page since September 1, 2026, and neither API reference page can carry the warning.

Flat schematic of three document panels in muted slate-lime on dark charcoal. Two panels show a filled field row, the third shows the same row as an empty outline, standing for a response shape in which the expiry timestamp is absent rather than null.
Flat schematic of three document panels in muted slate-lime on dark charcoal. Two panels show a filled field row, the third shows the same row as an empty outline, standing for a response shape in which the expiry timestamp is absent rather than null.
On this page

Quick answer

As of October 7, 2026, Anthropic's Files API guide states two incompatible things about expires_at on the same page, twenty-seven lines apart. The file expiration section says the timestamp "appears on every file response". The migration table below it says expires_at is "Not returned" when a request carries anthropic-beta: files-api-2025-04-14.

One of those is wrong. Dated mirrors put both sentences on the page on September 1, 2026, and neither existed on August 12, so the feature and the contradiction shipped in the same window.

The part that makes it hard to catch: neither API reference page can warn you. The endpoint reference documents expires_in_seconds and expires_at with no caveat and does not contain the word "beta" anywhere in the document. The beta endpoint reference lists files-api-2025-04-14 as an accepted header value and then shows expires_at populated in its own 200 example.

There is an exact workaround, and the documentation demonstrates it without ever naming it. It is in Finding 4.

I wrote about the header's four response-shape effects two weeks ago. This is a narrower follow-up about one row of that table, and about the three other pages that disagree with it.

The moment

This did not start as research. It started as a fact check on myself.

I was reusing a paragraph from a note I published on September 24 about what the legacy Files API beta header actually does, and I wanted to re-read the expiration section before quoting the second-to-minute bounds on expires_in_seconds. So I opened the page, scrolled to the section, and read the sentence I had skimmed past the first time.

It says the timestamp appears on every file response.

I had spent most of that earlier note explaining that it does not. I had reproduced the vendor's own table saying it does not. The table was still there, down the page, unchanged. I had quoted one half of a contradiction and walked past the other half without noticing, because the half I needed was the half that agreed with the bug I had just debugged.

That is the honest version of how this was found. Not a sweep. Re-reading my own source and finding the sentence directly above the one I had used.

Finding 1: The same page says both things

Anthropic Both halves are on the Files API guide, in sections that do not reference each other.

The file expiration section, verbatim:

"The resulting expires_at timestamp (RFC 3339) appears on every file response and is null for files uploaded without an expiration. Expiration is set once at upload and cannot be changed."

The migration table, further down the same page, row three:

Scroll to see more

With files-api-2025-04-14Without the header
expires_at on file objectsNot returnedAlways present; null when the file has no expiration

"Every file response" and "not returned" cannot both be true. The reason this survives reading is that each is locally reasonable. The expiration section is describing a feature and has no reason to discuss a legacy header. The migration table is describing a header and has no reason to restate the feature. Neither sentence is wrong about its own subject. They are wrong about each other.

The practical reading is that the migration table is the accurate one and the expiration section overstates. I say that because the table is specific, enumerates four fields, and is the only one of the two that is about the header at all. That is a judgement about which claim is narrower, not a measurement, and I could not test it.

It matters because of what the same section tells you to do with the field. On expired files, verbatim: "It continues to appear in list responses during that window; compare expires_at to the current time to filter expired files". That is the documented method of telling a live file from a dead one, and it is the exact operation that the table says is unavailable to a legacy-shaped client.

Finding 2: The contradiction shipped with the feature

I checked two dated snapshots of the page from a third-party documentation diff monitor. Both are mirrors rather than independent sources, which I will come back to.

On the August 12, 2026 snapshot, file expiration did not exist. Occurrences of expires_in_seconds: zero. Occurrences of expires_at: zero. There was no "File expiration" heading, no migration section, and no sentence containing "out of beta". The string files-api-2025-04-14 appears sixteen times, which is what a page looks like when the header is still required.

On the September 1, 2026 snapshot, both halves are present and both are byte-identical to the live page today. The expiration section carries "appears on every file response". The migration table carries "Not returned". The beta header mentions have dropped from sixteen to six.

So the feature, the migration table and the contradiction between them all arrived in the same window, and the contradiction has been continuously live for at least thirty-six days. It is not drift. There was never a version of this page on which the sentence was true.

I am not claiming to know the exact day. The gap between my two snapshots is twenty days and I did not narrow it further. I also found no changelog entry describing the expiration feature's documentation. I looked. I am not claiming none exists.

Finding 3: The reference layer cannot warn you, and in one place implies the opposite

This is the part I had not considered when I wrote the earlier note, because that note only read the guide. There are two more surfaces, and they are the ones a working developer is more likely to have open.

The endpoint reference contains no betas at all. The Files reference documents expires_in_seconds as a plain optional body parameter on POST /v1/files, with the bounds and no conditions. It documents expires_at on the returned object, four separate times across the upload, list, retrieve and download shapes. I counted occurrences of the string "beta" in the entire document, case-insensitively. There are zero. Not zero mentions of this particular header. Zero mentions of any beta anything.

So the page a developer checks to find out what a field does cannot tell them that a header they may be sending removes it, because that page does not know headers exist.

The beta endpoint reference points the other way. There is a separate beta upload reference. It lists the accepted values of anthropic-beta, and "files-api-2025-04-14" is one of fifty. It documents expires_in_seconds as an accepted body parameter. It documents expires_at on the return object. And its 200 response example shows the field present and populated, not null:

JSON

json
{
  "id": "file_011CNha8iCJcU1wXNR6q4V8w",
  "created_at": "2025-04-15T18:37:24.100435Z",
  "filename": "document.pdf",
  "mime_type": "application/pdf",
  "size_bytes": 102400,
  "type": "file",
  "downloadable": false,
  "expires_at": "2025-05-15T18:37:24.100435Z",
  "scope": {
    "id": "id",
    "type": "session"
  }
}

I want to be careful about how much that proves. It is a page about beta requests generally, and it lists fifty beta values while documenting one response shape. It never says which beta produces which shape, and the scope object in that example belongs to Managed Agents rather than to the Files beta. So this is not proof that expires_at is returned under files-api-2025-04-14. It is weaker and still real: the reference layer does not differentiate per beta, so it structurally cannot carry the warning the guide's table carries. A reader who checks the reference for this exact question comes away with the opposite impression, through no misreading of their own.

Three surfaces say you get the field. One table says you do not. The table is the only one that is specific.

Finding 4: The recovery the docs demonstrate without naming

If you cannot drop the header today, there is an exact reconstruction, and it falls out of a sentence on the reference page that nobody has joined to the migration table.

The expires_at field description, verbatim: "For files uploaded with expires_in_seconds, this is the upload time plus that value."

That is a formula, not a description. Both inputs are available to you under the legacy shape: created_at is on the file object in every shape, and expires_in_seconds is a value you supplied yourself at upload. So:

Python

python
from datetime import datetime, timedelta, timezone

def reconstruct_expires_at(file_obj, expires_in_seconds):
    if expires_in_seconds is None:
        return None
    created = datetime.fromisoformat(
        file_obj["created_at"].replace("Z", "+00:00")
    )
    return created + timedelta(seconds=expires_in_seconds)

And the vendor's own example is the proof that it holds exactly. In the response above, created_at is 2025-04-15T18:37:24.100435Z and expires_at is 2025-05-15T18:37:24.100435Z. The difference is 2,592,000 seconds, exactly thirty days, well inside the documented 3,600 to 7,776,000 bounds. The sub-second component is preserved to the microsecond, .100435 on both. So the server really is adding an integer number of seconds to the creation timestamp and not rounding, bucketing or re-stamping.

The cost of this workaround is that you must persist expires_in_seconds yourself, at upload, keyed by file id, because the API will not hand it back to you under the legacy shape. That is a row in your own table. It is not free, and it is a great deal cheaper than discovering the expiry through a 404.

The better fix is still the one the migration note gives: call client.files, not client.beta.files, on a current SDK. This is for the case where you cannot, which in my experience is most cases where it matters.

Finding 5: The question underneath all of this is still open

Everything above is about what comes back. None of it answers what goes out, and that is the question I left open two weeks ago and still cannot close.

Nothing on any of the four pages says whether expires_in_seconds is accepted when the legacy header is present. The migration table is about response fields. The reference pages do not condition the parameter on anything. There are three possible behaviours and the documentation picks none of them:

  • It is rejected with a 400. Clean. You find out at once.
  • It is honoured but unreadable. Your file expires on schedule and you cannot query when. This is the one my earlier note assumed.
  • It is silently ignored. Your files never expire, and the thing quietly filling against the one terabyte organisation storage limit is a pile of files you believe are self-cleaning.

The second and third are opposite failures. One loses you a file you wanted; the other keeps files you thought you had scheduled for deletion, which on some data-retention postures is the more serious of the two. A single request with a key would settle it in under a minute. I do not have one in this environment, so for me it stays a gap rather than a finding, and I would rather say that than guess.

What I did not verify

I have no Claude API key here, so nothing above is measured against a live API. Every claim is read off published documentation on October 7, 2026, quoted rather than paraphrased, with the page it came from linked. Treat the behaviours as documented, not as observed.

I did not watch expires_at be absent from a response. I did not send the legacy header with expires_in_seconds. Finding 5 is the shape of an experiment, not the result of one.

My reading in Finding 1 that the migration table is the accurate half is a judgement from specificity, not evidence. If the table is the stale one and the field is in fact returned under the header now, then the interesting defect is the opposite of the one I have described, and the recovery in Finding 4 is unnecessary rather than wrong.

The two dated snapshots come from one monitor. They are mirrors of Anthropic's pages, not independent records, and I did not corroborate either against a second archive. Both are consistent with the live page in the direction they should be, which is corroboration and not proof. I also did not narrow the twenty-day window between them.

I did not check whether this contradiction reproduces on the non-English documentation, and I did not check the platform availability matrix. If you are on Bedrock, Vertex or Foundry rather than the first-party API, confirm the Files API behaves as described before any of this applies.

For the header's other three response-shape effects, the three-way list-shape problem and the SDK version floors that decide whether you are sending the header at all, my earlier note covers that ground and I have deliberately not restated it here.

Postscript: I published two thousand words about a field being missing and did not notice the page promising it was always there, which is roughly the attention budget I give to sentences that agree with me.

D

Written by

Dani Reyes

Frequently asked questions

Does the Claude Files API return expires_at or not?

The documentation says both. The file expiration section of the Files API guide states the timestamp appears on every file response. The migration table on the same page states it is not returned when a request carries the anthropic-beta files-api-2025-04-14 header. The table is the more specific of the two and is the one to trust, but that is a judgement from specificity rather than a measured result.

How long has the contradiction been live?

A dated documentation snapshot from September 1, 2026 carries both sentences, byte-identical to the live page. A snapshot from August 12, 2026 carries neither, because file expiration did not exist yet and the beta header was still required. So the feature and the contradiction arrived in the same window and the page has been inconsistent for at least thirty-six days.

Why do the API reference pages not warn about this?

The Files endpoint reference documents expires_in_seconds and expires_at with no conditions and contains zero occurrences of the word beta in the entire document. The separate beta upload reference lists files-api-2025-04-14 among fourteen accepted header values and shows expires_at populated in its own 200 example, without saying which beta produces which response shape. Neither page differentiates per beta, so neither can carry the warning.

Can I recover expires_at if I cannot drop the beta header?

Yes, by reconstruction. The reference page states that for files uploaded with expires_in_seconds the timestamp is the upload time plus that value, and created_at is present in every response shape. Adding your own expires_in_seconds to created_at reproduces the vendor's own example exactly, including the microsecond component. The cost is that you must persist expires_in_seconds yourself at upload, keyed by file id.

Is expires_in_seconds even accepted when the legacy header is sent?

The documentation does not say. It could be rejected with a 400, honoured but unreadable, or silently ignored. The second and third are opposite failures: one expires a file you cannot track, the other leaves files alive against the one terabyte storage limit when you believed they were scheduled to expire. One request with an API key would settle it.

The 1-hour cache fix for batches has a break-even you cannot see

Anthropic's batch docs tell you to swap the 5-minute prompt cache for the 1-hour one. The swap costs a 60 percent heavier write on every miss, and the hit rate it has to reach to pay for itself is 57.6 percent at the bottom of Anthropic's own published band and outside that band at the top. A correction to my own September post.

12 min read66

Continuing a context window truncation returns a 400

Two stop reasons mean your response was cut off, and the continuation helper on Anthropic's own stop reasons page only handles one of them. model_context_window_exceeded falls out of the loop and is returned as complete. Adding it to the condition is worse: the continuation re-sends a full window as input, which is documented as a 400 on every model.

11 min read59