Files

The Keel File model field type provides an easy and managed way to create, store and read files in your Keel application. As with any field type, it can be used in models, actions, functions, jobs, subscriber functions and with the generated client.

Take the schema below for example. Here we have defined a Product model which has two properties; its title and an image file field which would, presumably, store an image of the product.

model Product {
    fields {
        title Text
        image File
    }
 
    actions {
        get getProduct(id)
        create createProduct() with (title, image)
    }
}

There are two ways to get a file into an action. A small file can be sent inline as a data URL in the action request itself, which is capped by the request body limit at roughly 4.5MB of file. A larger file is uploaded straight to storage first, using an upload URL minted by the action, and the action is then called with the resulting key. A file field accepts any content type up to 100MB unless you constrain it.

API inputs

When used as an input, a small file can be passed inline as a data URL (opens in a new tab). This data URL must also include a name parameter which stipulates the file name for the file. e.g. data:image/png;name=product.png;base64,iVBORw0KGgoAAAANSUhEUg.....

// POST /api/json/createProduct
{
  "title": "Running shoes",
  "image": "data:image/png;name=product.png;base64,iVBORw0KGgoAAAANSUhEUg....."
}

A data URL has to fit inside the action request, and base64 adds about a third to the size of the file, so this form works up to roughly 4.5MB of actual file. Above that, upload the file first.

Uploading large files

Every create and update action with a top-level File or File[] input also exposes an upload route, one per API the action is exposed in:

POST /{api}/json/{actionName}/upload/{fieldName}

This is a three-step flow. First, ask the action for an upload URL, telling it what you are about to send:

// POST /api/json/createProduct/upload/image
{
  "filename": "product.png",
  "contentType": "image/png",
  "size": 4812331
}

All three fields are required. size is in bytes, and filename must be non-empty and at most 255 bytes. The response gives you somewhere to put the bytes:

{
  "key": "2ZkH1xBenRVdYI8iwao1hYUtt",
  "url": "https://.../uploads/2ZkH1xBenRVdYI8iwao1hYUtt?X-Amz-...",
  "headers": {
    "Content-Type": "image/png",
    "Content-Length": "4812331"
  },
  "expiresAt": "2026-09-09T13:15:00Z",
  "urlExpiresAt": "2026-09-09T11:30:00Z"
}

Second, PUT the bytes to url using exactly the headers that came back. Both of those headers are part of the signature, so storage refuses the upload if the content type or the length differs from what you declared.

Third, call the action with the key where the data URL would otherwise go:

// POST /api/json/createProduct
{
  "title": "Running shoes",
  "image": "2ZkH1xBenRVdYI8iwao1hYUtt"
}

The two timestamps in the response mean different things. urlExpiresAt is when the presigned URL stops accepting the PUT. expiresAt is when the key can no longer be attached to the action at all, which is later, so you have a short window to upload and a longer one to finish calling the action.

A File[] input can mix data URLs and upload keys in the same request. A key can only be attached once, and only through the action and field it was minted for.

Permission to mint an upload comes from the target action's own @permission rules — there is no separate upload permission. A rule that resolves without a record decides outright, a rule that needs a record lets an authenticated caller mint with the full check applied when the upload is attached, and an anonymous caller only mints against actions whose rules provably allow it. A public create action therefore gives you a public upload scope.

The upload route returns 404 for an unknown action or field, or a field that is not a File; 403 for a caller the action's rules deny; 400 when the declared content type or size fails the field's constraints; and 429 with ERR_RATE_LIMITED when a caller has too many uploads outstanding. See Error handling for the error codes.

Two limits on this first version are worth knowing. Flows have their own presigned-URL callback for file inputs and are not affected. Only top-level inputs get a route, so a File nested inside a message input of a function action still has to be sent as a data URL. The GraphQL API and RPC have no equivalent route: a client on those APIs mints through the JSON route above and then passes the key through its own request.

Constraining a file field

A File field accepts any content type, up to a platform default of 100MB. Two optional attributes narrow that:

model Product {
    fields {
        title Text
        image File @accepts(["image/*"]) @maxSize("5MB")
        manuals File[] @accepts(["application/pdf"]) @maxSize("1.5GiB")
    }
 
    actions {
        create createProduct() with (title, image, manuals)
    }
}

@accepts takes a list of MIME patterns. Each one is either an exact type such as application/pdf or a type with a wildcard subtype such as image/*. Matching ignores case and any parameters on the content type, so image/jpeg; charset=binary matches image/jpeg. */* and a bare * are rejected; leaving the attribute off is how you accept anything.

@maxSize takes a size with a unit. B, KB, MB and GB are decimal, so 1KB is 1000 bytes, and KiB, MiB and GiB are binary. Units are case-insensitive, whitespace between the number and the unit is optional, and fractional values such as "1.5GiB" are allowed. TB is not accepted, and the largest size you can declare is 5GB.

Both attributes are checked when an upload is minted, and again when it is attached — against the real content type and length of the object in storage, not the values you declared.

⚠️

@accepts and @maxSize are enforced on the upload path only. A file sent inline as a data URL is stored without either check, so a caller can bypass both with a data URL small enough to fit in the request body. Treat these attributes as constraints on uploads, not as validation of the field.

Response

When an API response of an action or function includes the File type (i.e. it might form part of the model being returned or defined in the custom function message), the format in which it is returned is as follows:

{
  "title": "Running shoes",
  "image": {
    "key": "2k724tpxBenRVdYI8iwao1hYUtt",
    "filename": "product.png",
    "contentType": "image/png",
    "size": 153225,
    "url": "https://env-abcdefghijklmnopqrstuvwxyz.s3.eu-west-2.amazonaws.com/...."
  }
}

The url field provides a short-term presigned URL for the file which expires after 60 minutes. This URL is regenerated each time a file response is returned.

When developing your applications locally with the Keel CLI, the url response field will not contain a URL but rather a Data URL.

The key field is a unique identifier used by Keel to store and locate file data internally and can be ignored for most use cases.

Functions

Keel provides a File TypeScript class in the functions runtime and full Model API support for reading and writing files, which means that they can be used in read and write custom functions, action hooks, jobs and subscriber functions.

The File class allows you to read files in functions. For example, this may be useful for importing bulk data from a CSV file. See the code sample below.

model User {
    fields {
        name Text
    }
 
    actions {
        write importBulk(csv: File) returns (Any)
    }
}

It is also possible to construct a new file from scratch within your function, to either be stored or just returned from the API. The example below does both.

model User {
    fields {
        name Text
    }
 
    actions {
        write exportBulk(Any) returns (ExportedUsersMessage)
    }
}
 
message ExportedUsersMessage {
    csv File
}
 

Files can also be manually stored before writing them to your database using file.store(). This can be useful if you want to multiple models to reference a single stored file (instead of writing the file multiple times).

⚠️

When assigning files to models through the ModelAPI (during create or update operations), only InlineFile instances are automatically stored. If you pass a File object instead, only a reference to the file is saved, not the file itself. To share a single file across multiple models, use File and call File.store() before performing any ModelAPI operations.

Storage

File fields are stored as a jsonb column type in the database as shown in the example below.

{
  "filename": "product.png",
  "contentType": "image/png",
  "size": 153225,
  "key": "2k724tpxBenRVdYI8iwao1hYUtt"
}

The contents of the files on the other hand, are stored securely in a dedicated AWS S3 bucket provisioned just for your environment. Access to this bucket can only be achieved using the Keel platform.

The Keel CLI deploys a local S3-compatible storage solution (Minio (opens in a new tab)).