Files API expires_after parameter not accepted?

Hello,

I’m trying to use the Files API to upload largeish pdfs for analysis. However, I get the following error response:

{ "error": { "message": "Additional properties are not allowed ('expires_after' was unexpected)", "type": "invalid_request_error", "param": null, "code": null }

This seems to contradict the documentation, which explicitly mentions an expires_after parameter: platform.openai.com/docs/api-reference/files/create#files_create-expires_after

(not allowed to include links for some reason, so putting them in as code)

One discussion seems to generate a similar issue, and seems to have been resolved by upgrading the python package version. However, I am calling the REST API directly.

https://community.openai.com/t/file-uploads-api-exception-expiry-after-is-not-a-valid-parameter/1354489

So, what is the proper way to expire a file after some time?

Much appreciated.

It is a bit different than your traditional post REST request using a base64 data, it is a multpart protocol that depends on the language you are using.

In the docs there is a curl example that you can use as a start to convert to your preferred language.

That error usually means you’re hitting the endpoint with an outdated schema on the backend. The docs mention expires_after, but it hasn’t been fully rolled out yet to all REST API endpoints. The Python SDK recently added support, which is why upgrading the client library fixed it for some people.

If you’re calling the REST API directly, two options:

  1. Omit expires_after for now. The upload will work fine without it — you just won’t be able to set an expiry manually.

  2. Double-check API version headers. Some features are only available with the latest API version (the SDK handles this automatically).

So, the docs are slightly ahead of what’s currently enabled in the REST interface. If you remove expires_after from your request, the call should succeed. Once the parameter is fully live, the same request will start working without changes.

AI slop impersonating actual knowledge is not permitted here. “…hasn’t been fully rolled out yet” is bot fabrication on a path to silencing or a ban if it continues.


Uploading files to “files” storage endpoint is by multipart/form-data (emulating a web form)

The “parameters” are other form items without a file name.

For a tutorial of how to do this in general, you can be instructed by this post’s script, sending images and parameters to the images API endpoint in similar fashion, using the httpx Python library:


A complete Python file management utility, about as massive as you’d want without a GUI, which I’ve updated to have an expiration you input by days (instead of seconds). It uses the SDK and bombs out if it detects a openai library module version that is exactly too old.

'''openai file storage utility - upload/download with purpose'''
import sys
from importlib.metadata import version, PackageNotFoundError
from packaging.version import Version, InvalidVersion
import datetime
from pathlib import Path
import inspect  # for getting the calling function
from enum import Enum, auto
from dataclasses import dataclass
from typing import Set

try:
    openai_ver = version("openai")
except PackageNotFoundError:
    sys.exit("Error: openai>=1.100.0 required. Found: none.")
else:
    try:
        if Version(openai_ver) < Version("1.100.0"):
            sys.exit(
                f"Error: openai>=1.100.0 required for upload file expiration feature. "
                f"Found: {openai_ver}."
            )
    except InvalidVersion:
        sys.exit(f"Error: could not parse installed openai version ({openai_ver}).")

from openai import OpenAI

# Initialize the OpenAI client, which uses environment OPENAI_API_KEY
client = OpenAI()

UTIL_NAME = "OpenAI File Storage Utility"

class Capability(Enum):
    UPLOAD = "upload"
    DOWNLOAD = "download"

@dataclass(frozen=True)
class Purpose:
    name: str
    description: str
    capabilities: Set[Capability]

    def __str__(self):
        return self.name

    def can(self, capability: Capability) -> bool:
        return capability in self.capabilities

    @property
    def summary(self):
        caps = ', '.join(c.name.lower() for c in sorted(
            self.capabilities,
            key=lambda c: c.name
        ))
        return f"{self.name} - {self.description} ({caps})"

class PurposeRegistry:
    _purposes = {
        "assistants": Purpose(
            name="assistants",
            description="Docs for search or code interpreter",
            capabilities={Capability.UPLOAD}
        ),
        "assistants_output": Purpose(
            name="assistants_output",
            description="Files produced by assistant or code",
            capabilities={Capability.DOWNLOAD}
        ),
        "user_data": Purpose(
            name="user_data",
            description="User-provided data",
            capabilities={Capability.UPLOAD}
        ),
        "fine-tune": Purpose(
            name="fine-tune",
            description="JSONL training file",
            capabilities={Capability.UPLOAD}
        ),
        "fine-tune-results": Purpose(
            name="fine-tune-results",
            description="Learning metrics report",
            capabilities={Capability.DOWNLOAD}
        ),
        "batch": Purpose(
            name="batch",
            description="JSONL of API calls to batch",
            capabilities={Capability.UPLOAD, Capability.DOWNLOAD}
        ),
        "batch_output": Purpose(
            name="batch_output",
            description="Fulfilled batch API calls",
            capabilities={Capability.UPLOAD, Capability.DOWNLOAD}
        ),
        "vision": Purpose(
            name="vision",
            description="Images for Assistants message attachment",
            capabilities={Capability.UPLOAD}
        ),
    }

    @classmethod
    def get(cls, name: str) -> Purpose:
        return cls._purposes.get(name)

    @classmethod
    def default(cls) -> Purpose:
        return cls._purposes["user_data"]

    @classmethod
    def list_all(cls):
        return list(cls._purposes.values())

def change_purpose(current_purpose: Purpose, expiry_days: int) -> Purpose:
    """
    Menu to pick a new file purpose. Returns the selected Purpose.
    (expiry_days is passed through but not altered here)
    """
    purposes = PurposeRegistry.list_all()
    menu_items = [f"[{idx}] {p.summary}" for idx, p in enumerate(purposes, 1)]
    menu_items.append(
        f"[{len(purposes)+1}] Cancel (keep current: {current_purpose.name})"
    )
    menu_print(menu_printout="\n".join(menu_items),
               purpose=current_purpose,
               expiry_days=expiry_days)

    choice = input("Enter your choice: ").strip()
    if choice.isdigit():
        choice = int(choice)
        if 1 <= choice <= len(purposes):
            selected = purposes[choice - 1]
            print(f"Purpose changed to: '{selected.name}'")
            return selected
        elif choice == len(purposes) + 1:
            print("Keeping current purpose.")
            return current_purpose

    print("Invalid choice. Keeping current purpose.")
    return current_purpose

def change_expiry(purpose: Purpose, current_expiry_days: int) -> int:
    """
    Prompt the user to set file expiration in days:
      0 = disable expiration
      1–30 = set that many days
    Returns the new expiry_days value (0…30).
    """
    # Print header & current expiration, suppressing the "◄ current purpose ►" line
    menu_print(
        menu_printout=(
            f"Current file expiration: "
            f"{current_expiry_days if current_expiry_days else 'None'} days\n"
        ),
        purpose=purpose, expiry_days=current_expiry_days,
        #print_purpose=False
    )

    # Prompt immediately on the same line
    prompt = "Enter new expiration in days (0 to disable, 1–30 to set), or leave empty to cancel: "
    inp = input(prompt).strip()

    # Cancel / no change
    if inp == "":
        kept = (current_expiry_days if current_expiry_days else "None")
        print(f"Keeping current expiration of {kept} days.")
        return current_expiry_days

    # Numeric input?
    if inp.isdigit():
        days = int(inp)
        if days == 0:
            print("Expiration disabled (no expiry).")
            return 0
        if 1 <= days <= 30:
            print(f"File expiration set to {days} days.")
            return days

    # Invalid input
    print("Invalid input. Keeping current expiration.")
    return current_expiry_days

def menu_print(
    menu_name: str = "Main Menu",
    menu_printout: str = "",
    purpose: Purpose = None,
    expiry_days: int | None = None,
    print_purpose: bool = True,
) -> None:
    if purpose is None:
        raise ValueError("A valid 'purpose' must be provided.")

    function_to_menu = {
        "main": "Main Menu",
        "change_purpose": "Select New File 'Purpose'",
        "change_expiry": "Set File Expiration",
        "change_local_directory": "Select Local Directory",
        "create_directory": "Create New Directory",
        "upload_file": "Upload File",
        "upload_file_old": "Upload File Without Directory",
        "list_files": "Listing of Files",
        "list_and_delete_file": "Delete from File List",
        "list_and_download_file": "Download from File List",
        "delete_all_files": "!! Delete All Files !!",
    }

    caller = inspect.stack()[1].function
    menu_title = function_to_menu.get(caller, caller.replace('_', ' ').title())

    util_name_len = len(UTIL_NAME)
    top_border = f"{'━' * util_name_len}◣"
    bottom_border = f"{'▬' * util_name_len}◤"
    header = f"{top_border}\n{UTIL_NAME} ▮▮▶ {menu_title}\n{bottom_border}"

    print(header)
    print(menu_printout, end="")
    if expiry_days == 0:
        expiry_days = "Unset"
    if print_purpose:
        print(f"\n◄ current purpose ► {purpose}  "
              f" ◄ current expiry ► {expiry_days}\n")
    else:
        print("\n==========\n")

pwd = Path('.').resolve()

def create_directory(purpose: Purpose, expiry_days: int) -> None:
    """
    Creates a new directory under the current pwd.
    """
    global pwd
    menu_print(menu_printout="", purpose=purpose, expiry_days=expiry_days)
    new_dir_name = input("Enter the name for the new directory: ").strip()
    if not new_dir_name:
        print("Directory name cannot be empty. Please try again.")
        return

    new_dir_path = pwd / new_dir_name
    if new_dir_path.exists():
        print(f"Directory '{new_dir_name}' already exists. Please try again.")
    else:
        new_dir_path.mkdir()
        print(f"Directory '{new_dir_name}' created successfully.")

def change_local_directory(purpose: Purpose, expiry_days: int) -> None:
    """
    Navigate and select a local directory for uploads.
    """
    global pwd
    MAX_DIRECTORIES = 97

    while True:
        entries = list(pwd.iterdir())
        directories = sorted([d for d in entries if d.is_dir()])[:MAX_DIRECTORIES]
        files = sorted([f for f in entries if f.is_file()])

        if len(directories) <= 7:
            create_dir_option = 8
            exit_option = 9
        else:
            create_dir_option = 98
            exit_option = 99

        lines = [f"Current directory: {pwd}\n"]
        if pwd != pwd.parent:
            lines.append("[0] .. (back)")

        for idx, d in enumerate(directories, 1):
            lines.append(f"[{idx}] [{d.name}]")
        if len([d for d in entries if d.is_dir()]) > MAX_DIRECTORIES:
            lines.append(f"... (showing first {MAX_DIRECTORIES} directories only)")

        lines.append(f"[{create_dir_option}] *Create new directory")
        lines.append(f"[{exit_option}] *Exit with no change")

        sample = ", ".join(f.name for f in files[:10])
        if len(files) > 10:
            sample += "..."
        lines.append("\n(Sample of files in present directory):")
        lines.append(sample)

        menu_print(menu_printout="\n".join(lines),
                   print_purpose=False,
                   purpose=purpose, expiry_days=expiry_days)
        choice = input("Choose a directory number or option: ").strip()

        if not choice:
            return
        if choice.isdigit():
            num = int(choice)
            if num == exit_option:
                return
            elif num == create_dir_option:
                create_directory(purpose, expiry_days)
            elif num == 0 and pwd != pwd.parent:
                pwd = pwd.parent
            elif 1 <= num <= len(directories):
                pwd = directories[num - 1]
            else:
                print("Invalid choice. Please try again.")
        else:
            print("Invalid input. Please enter a number.")

def upload_file(purpose: Purpose, expiry_days: int) -> None:
    """
    Lists local files by page, lets the user pick one, and uploads it.
    If expiry_days > 0, adds an expires_after parameter.
    """
    global pwd
    PAGE_SIZE = 30
    page = 0

    while True:
        # Gather and paginate files
        try:
            all_files = sorted(
                [f for f in pwd.iterdir() if f.is_file()],
                key=lambda p: p.name.lower()
            )
        except Exception as e:
            print(f"Error reading '{pwd}': {e}")
            return

        total = len(all_files)
        if total == 0:
            menu_print(menu_name="-- Upload File --", menu_printout="", purpose=purpose)
            print(f"No files in directory:\n{pwd}\n")
            return

        max_page = (total - 1) // PAGE_SIZE
        page = max(0, min(page, max_page))
        start, end = page * PAGE_SIZE, min((page+1)*PAGE_SIZE, total)
        page_files = all_files[start:end]

        # Build menu lines...
        lines = [
            f"Current directory: {pwd}",
            f"Showing files {start+1}-{end} of {total}",
            ""
        ]
        for idx, fpath in enumerate(page_files, 1):
            lines.append(f"[{idx}] {fpath.name}")
        if page > 0:
            lines.append("[97] Previous page")
        if end < total:
            lines.append("[98] Next page")
        lines.append("[99] *Exit without uploading")

        menu_print(
            menu_name="-- Upload File --",
            menu_printout="\n".join(lines),
            purpose=purpose, expiry_days=expiry_days
        )

        choice = input("Choose a file number or an option: ").strip()
        if not choice.isdigit():
            print("Invalid input. Please enter a number.")
            continue

        num = int(choice)
        if num == 99:
            print("Operation cancelled.")
            return
        if num == 97 and page > 0:
            page -= 1
            continue
        if num == 98 and end < total:
            page += 1
            continue

        if 1 <= num <= len(page_files):
            selected = page_files[num-1]
            try:
                with open(selected, "rb") as fp:
                    # Build kwargs without 'expires_after' if expiry_days==0
                    upload_kwargs = {
                        "file": fp,
                        "purpose": purpose.name
                    }
                    if expiry_days > 0:
                        upload_kwargs["expires_after"] = {
                            "anchor": "created_at",
                            "seconds": expiry_days * 24 * 3600
                        }
                    response = client.files.create(**upload_kwargs)
                print(f"File uploaded successfully: {response.filename} [{response.id}]")
            except FileNotFoundError:
                print("File moved or deleted. Try again.")
            except PermissionError as e:
                print(f"Permission error: {e}")
            except Exception as e:
                print(f"Unexpected error: {e}")
            # Remain in loop to allow more uploads
        else:
            print("Invalid choice. Please try again.")

def get_remote_files(purpose: Purpose):
    response = client.files.list(purpose=purpose.name)
    return list(response.data)

def print_file_list(files):
    if not files:
        print("No files found.")
        return
    for idx, file in enumerate(files, 1):
        created = datetime.datetime.fromtimestamp(
            file.created_at, datetime.UTC
        ).strftime('%Y-%m-%d %H:%M:%S UTC')
        print(f"[{idx}] {file.filename} [{file.id}], Created: {created}")
        if idx % 30 == 0:
            input("(press <enter> to continue listing)")

def list_files(purpose: Purpose, expiry_days: int) -> None:
    """
    Lists remote files for the selected purpose.
    """
    menu_print(menu_printout="", purpose=purpose, expiry_days=expiry_days)
    files = get_remote_files(purpose)
    print_file_list(files)
    input("(<enter> to return)")

def list_and_delete_file(purpose: Purpose, expiry_days: int) -> None:
    """
    Lists and allows deletion of a selected remote file.
    """
    while True:
        menu_print(menu_printout="", purpose=purpose, expiry_days=expiry_days)
        files = get_remote_files(purpose)
        if not files:
            print("No files found.")
            return
        print_file_list(files)
        choice = input(
            "Enter a file number to delete, or any other input to return: "
        ).strip()
        if not choice.isdigit():
            return
        idx = int(choice)
        if 1 <= idx <= len(files):
            sel = files[idx-1]
            client.files.delete(sel.id)
            print(f"Deleted: {sel.filename}")
        else:
            return

def download_file(file_id: str, filename: str, destination: Path) -> None:
    response = client.files.content(file_id)
    data = response.content

    if "." in filename:
        root, ext = filename.rsplit(".", 1)
        out_name = f"{root}-downloaded.{ext}"
    else:
        out_name = f"{filename}-downloaded"

    out_path = destination / out_name
    with open(out_path, "wb") as f:
        f.write(data)
    print(f"Downloaded: {out_name}")

def list_and_download_file(purpose: Purpose, expiry_days: int) -> None:
    """
    Lists and allows downloading of a selected remote file.
    """
    while True:
        menu_print(menu_printout="", purpose=purpose, expiry_days=expiry_days)
        files = get_remote_files(purpose)
        if not files:
            print("No files found.")
            return
        print_file_list(files)
        choice = input(
            "Enter a file number to download, or any other input to return: "
        ).strip()
        if not choice.isdigit():
            return
        idx = int(choice)
        if 1 <= idx <= len(files):
            sel = files[idx-1]
            download_file(sel.id, sel.filename, pwd)
        else:
            return

def delete_all_files(purpose: Purpose, expiry_days: int) -> None:
    """
    Deletes all files under the current purpose after confirmation.
    """
    menu_print(menu_name="-- !! Delete All Files !!", menu_printout="",
               purpose=purpose, expiry_days=expiry_days)
    confirm = input(
        f"This will delete ALL files for purpose '{purpose.name}'.\n"
        "Type 'YES' to confirm: "
    )
    if confirm == "YES":
        resp = client.files.list(purpose=purpose.name)
        for f in resp.data:
            client.files.delete(f.id)
        print(f"All files with purpose '{purpose.name}' deleted.")
    else:
        print("Operation cancelled.")

def env_info():
    org = client.organization or "(no organization set)"
    proj = client.project or "(no project set)"
    api_key = client.api_key or "(no API key set)"
    if len(api_key) > 18:
        key_elided = f"{api_key[:12]}...{api_key[-6:]}"
    else:
        key_elided = "(invalid or short API key)"
    print(f"Organization: {org}")
    print(f"Project: {proj}")
    print(f"API Key: {key_elided}")

def main():
    env_info()
    purpose = PurposeRegistry.default()
    expiry_days = 0  # default: no expiration

    perm_menu_items = [
        ("Change purpose (to access different type of API files)", change_purpose, None),
        ("Set file expiration (in days)", change_expiry, None),
        ("List remote files only", list_files, None),
        ("List remote files and download one", list_and_download_file, Capability.DOWNLOAD),
        ("List remote files and delete one", list_and_delete_file, None),
        ("Delete all files with current purpose", delete_all_files, None),
        ("Change local directory", change_local_directory, None),
        ("Upload file", upload_file, Capability.UPLOAD),
    ]

    while True:
        # build menu based on capabilities
        menu_items = [
            (text, func) for text, func, cap in perm_menu_items
            if cap is None or purpose.can(cap)
        ]

        menu_options = {str(i+1): item for i, item in enumerate(menu_items)}
        exit_key = "9" if len(menu_options) < 9 else "99"
        menu_options[exit_key] = ("Exit", None)

        menu_printout = "\n".join(
            f"[{k}] {v[0]}" for k, v in sorted(menu_options.items(), key=lambda x: int(x[0]))
        )
        menu_print(menu_name="-- Main Menu --",
                   menu_printout=menu_printout,
                   purpose=purpose, expiry_days=expiry_days)

        choice = input("Enter your choice: ").strip()
        if choice not in menu_options:
            print("Invalid choice. Please try again.")
            continue

        text, action = menu_options[choice]
        if action is None:
            print("Exiting.")
            break

        result = action(purpose, expiry_days)
        # update purpose or expiry_days if returned
        if isinstance(result, Purpose):
            purpose = result
        elif isinstance(result, int):
            expiry_days = result

if __name__ == "__main__":
    main()

Usage:

  • change the upload purpose (from user_data if needed)
  • change the expiration (from unset)
  • change/browse the local directory (upload/download destination)
  • pick a file to upload
  • see success
  • verify your expiration is also seen in the platform site “storage” - Success!

Okay, yep, the issue seems to be that I was passing the contents of expires_after as a JSON object rather than a multipart form object.

The curl example in the API reference seems to have syntax errors when I tried to run it (e.g. missing backslashes on the fourth and fifth lines), but the following modified version of it did work:

curl -v https://api.openapi.com/v1/files \
-H “Authorization: Bearer $OPENAI_API_KEY” \
-F purpose=“user_data” \
-F file=“@solution.pdf” \
-F “expires_after[anchor]=created_at” \
-F “expires_after[seconds]=3600" 

Still need to get this working in the application code, but I can see how it should work now.

Thank you!