---
title: "Upload a file for an attachment field, as the builder"
description: "The builder plane's half of `files.upload`."
canonical: "https://grid82.com/docs/builder-api/data/files-upload"
updated: "2026-11-13"
---

# Upload a file for an attachment field, as the builder

`POST https://studio.grid82.app/v1/apps/{appId}/tables/{tableId}/files`

The builder plane's half of `files.upload`.

Takes the BYTES as `multipart/form-data` under `file`, with `recordId` saying which record they will belong to, and answers with the descriptor to write into an attachment field. Attaching it is an ordinary record write, so the file arrives through the same op log and the same undo as any other value.

Requires the `records:write` scope. Operation `apps.files.upload`. Not an MCP tool. A model cannot send bytes: a tool call is JSON, and an upload is a multipart file. Upload with the CLI (`--file ./path`) or from the grid, then attach the returned descriptor with an ordinary record write, which is a tool. CLI: `grid82 apps files upload` (`npx grid82`).

## Path parameters

| Name      | Type   | Required | Description |
| --------- | ------ | -------- | ----------- |
| `appId`   | string | yes      |             |
| `tableId` | string | yes      |             |

## Headers

| Name              | Type   | Required | Description                                                                                                                                                                                                                                                        |
| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Idempotency-Key` | string | no       | Any unique string. Sending the same key with the same request again returns the first response (marked `Idempotency-Replayed: true`) instead of running it twice. Reusing it for a different request is refused with 409. Kept for 24 hours. Up to 255 characters. |

## Request body

Sent as `multipart/form-data`.

| Field      | Type   | Required | Description                                                                        |
| ---------- | ------ | -------- | ---------------------------------------------------------------------------------- |
| `file`     | string | yes      | The bytes.                                                                         |
| `recordId` | string | yes      | The record the file will be attached to, so an upload never attached can be swept. |

**Authorizations**

- `Authorization` (bearer, in header, required)

## Response

**201**: Success

- `data` (`object`, required)
- `data.id` (`string`, required)
- `data.name` (`string`, required)
- `data.size` (`number`, required)
- `data.mime` (`string`, required)
- `data.key` (`string`, required)
- `data.uploadedAt` (`string`, required)
- `data.width` (`number`)
- `data.height` (`number`)

```json
{
  "data": {
    "id": "<string>",
    "name": "<string>",
    "size": 123.45,
    "mime": "<string>",
    "key": "<string>",
    "uploadedAt": "<string>",
    "width": 123.45,
    "height": 123.45
  }
}
```

**4XX**: Refused

- `error` (`object`, required)
- `error.code` (`enum<string>`, required, one of: `not_found, forbidden, unauthenticated, validation_failed, conflict, rate_limited, payment_required, not_implemented, internal`)
- `error.message` (`string`, required)
- `error.details` (`Record<string, unknown>`)

```json
{
  "error": {
    "code": "not_found",
    "message": "<string>",
    "details": {}
  }
}
```

**Example request**

```bash
curl --request POST \
  --url 'https://studio.grid82.app/v1/apps/<appId>/tables/<tableId>/files' \
  --header 'Authorization: Bearer <token>'
```
