> ## Documentation Index
> Fetch the complete documentation index at: https://docs.firstresonance.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload a file

> Upload files to ION with a three-step flow: request a signed upload URL, send the file directly to ION's file storage, then register the attachment.

ION stores file attachments such as procedure images, run artifacts, and inspection photos. You don't upload a file by posting it through the GraphQL endpoint. Instead, you use a three-step flow: request a signed upload URL from ION, send the file directly to that URL, then register the attachment back to ION with the storage details. Large files upload directly to storage rather than through ION.

The two most common stumbling blocks are capturing the `ETag` from the upload response and choosing the right `entityId`.

## How the flow works

1. Send `requestFileUploadUrl` to ION. ION returns `uploadUrl`, `s3Key`, and `contentType`.
2. Send a PUT request with the file body and `Content-Type` header to `uploadUrl`. Storage returns a `200` response with an `ETag` header.
3. Send `createFileAttachment` to ION with `s3Key`, `s3Etag`, `entityId`, and `filename`. ION returns the `fileAttachment`.

## Prerequisites

* An authenticated GraphQL client. See [Authentication](/api-reference/authentication/overview).
* Permission to attach files to the entity you're targeting.
* The **entity ID** of the object you're attaching to. See [Choose an entityId](#choose-an-entityid).

## Step 1: Request a signed upload URL

Request a signed upload URL:

```graphql theme={null}
query RequestUpload($filename: String!) {
  requestFileUploadUrl(filename: $filename) {
    uploadUrl
    s3Key
    contentType
  }
}
```

**Variables:**

```json theme={null}
{ "filename": "inspection-photo-2026-04-26.jpg" }
```

**Response:**

```json theme={null}
{
  "data": {
    "requestFileUploadUrl": {
      "uploadUrl": "https://ion-files.s3.amazonaws.com/...?X-Amz-Signature=...",
      "s3Key": "uploads/abc123/inspection-photo-2026-04-26.jpg",
      "contentType": "image/jpeg"
    }
  }
}
```

The `uploadUrl` is single-use and time-bound, typically for a few minutes. If you wait too long, the PUT request in step 2 fails. If that happens, request a new URL.

The `contentType` in the response is the value you must send in the PUT request's `Content-Type` header. ION derives it from the filename extension. If the extension doesn't reveal your file's type, request the URL with a more specific filename.

## Step 2: Upload the file

Upload the file directly using the signed URL:

```bash theme={null}
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: $CONTENT_TYPE" \
  --data-binary "@./inspection-photo-2026-04-26.jpg" \
  -i
```

The `-i` flag returns the response headers, including the `ETag` value:

```http theme={null}
HTTP/1.1 200 OK
ETag: "9c1185a5c5e9fc54612808977ee8f548"
```

That `ETag` value, with the surrounding quotes stripped, is your `s3Etag` for step 3. ION uses it to verify that the file you uploaded matches the registration you're about to create.

<Warning>
  Capturing the `ETag` is the most common file-upload bug. Confirm your HTTP
  library exposes response headers and isn't using a wrapper that discards them.
</Warning>

## Step 3: Register the attachment

```graphql theme={null}
mutation RegisterUpload($input: CreateFileAttachmentInput!) {
  createFileAttachment(input: $input) {
    fileAttachment {
      id
      filename
      downloadUrl
      entityId
    }
  }
}
```

**Variables:**

```json theme={null}
{
  "input": {
    "filename": "inspection-photo-2026-04-26.jpg",
    "s3Key": "uploads/abc123/inspection-photo-2026-04-26.jpg",
    "s3Etag": "9c1185a5c5e9fc54612808977ee8f548",
    "entityId": 9876,
    "validateUpload": true
  }
}
```

**Response:**

```json theme={null}
{
  "data": {
    "createFileAttachment": {
      "fileAttachment": {
        "id": 4242,
        "filename": "inspection-photo-2026-04-26.jpg",
        "downloadUrl": "https://ion-files.s3.amazonaws.com/...",
        "entityId": 9876
      }
    }
  }
}
```

After this returns successfully, the file is visible in the ION UI on the entity you attached it to.

### Input fields

| Field            | Type      | Notes                                                                                                                     |
| ---------------- | --------- | ------------------------------------------------------------------------------------------------------------------------- |
| `filename`       | `String`  | Display filename. It doesn't have to match the storage object's key.                                                      |
| `s3Key`          | `String`  | The `s3Key` value from step 1, which is the storage object key.                                                           |
| `s3Etag`         | `String`  | The `ETag` returned by the storage service in step 2's response, with quotes stripped.                                    |
| `entityId`       | `ID`      | The **entity ID** of the parent object. See [Choose an entityId](#choose-an-entityid).                                    |
| `validateUpload` | `Boolean` | When `true`, ION verifies that the uploaded object exists and matches the `ETag` before creating the record. Recommended. |

<Tip>
  Set `validateUpload` to `true`. ION makes one extra HEAD request and gives you
  immediate feedback when the registration doesn't match the uploaded bytes.
</Tip>

## Choose an entityId

The `entityId` is not the same as the parent object's `id`. ION lets multiple object types share a relationship to file attachments, so you pass the parent's entity ID rather than its own `id`. To attach a file to a run step, the most common case, first fetch the run step's `entity.id`:

```graphql theme={null}
query GetRunStepEntity($runStepId: ID!) {
  runStep(id: $runStepId) {
    id
    entity {
      id
    }
  }
}
```

Use the returned `entity.id`, not `runStep.id`, as the `entityId` in step 3.

The same pattern applies to other targets. Parts, runs, procedures, procedure steps, and run step fields all expose an `entity { id }` you can fetch and use.

| Attachment target | Fetch its `entity.id` via            |
| ----------------- | ------------------------------------ |
| Run step          | `runStep(id) { entity { id } }`      |
| Run               | `run(id) { entity { id } }`          |
| Procedure         | `procedure(id) { entity { id } }`    |
| Procedure step    | `step(id) { entity { id } }`         |
| Part              | `part(id) { entity { id } }`         |
| Run step field    | `runStepField(id) { entity { id } }` |

## End-to-end example in Python

```python theme={null}
import httpx
import os

ION_TOKEN = os.environ["ION_TOKEN"]
GQL_URL = "https://api.firstresonance.io/graphql"

def gql(query, variables=None):
    r = httpx.post(
        GQL_URL,
        headers={"Authorization": f"Bearer {ION_TOKEN}"},
        json={"query": query, "variables": variables or {}},
        timeout=30.0,
    )
    r.raise_for_status()
    payload = r.json()
    if "errors" in payload:
        raise RuntimeError(payload["errors"])
    return payload["data"]

def upload_to_run_step(run_step_id: int, file_path: str) -> int:
    filename = os.path.basename(file_path)

    # 1. Get a signed upload URL
    step_1 = gql(
        "query($f: String!) { requestFileUploadUrl(filename: $f) { uploadUrl s3Key contentType } }",
        {"f": filename},
    )["requestFileUploadUrl"]

    # 2. PUT the file to the signed URL, capture the ETag from the response
    with open(file_path, "rb") as f:
        upload_response = httpx.put(
            step_1["uploadUrl"],
            content=f.read(),
            headers={"Content-Type": step_1["contentType"]},
            timeout=120.0,
        )
    upload_response.raise_for_status()
    s3_etag = upload_response.headers["ETag"].strip('"')

    # 3. Resolve the run step's entity.id
    entity_id = gql(
        "query($id: ID!) { runStep(id: $id) { entity { id } } }",
        {"id": run_step_id},
    )["runStep"]["entity"]["id"]

    # 4. Register the attachment
    result = gql(
        """mutation($input: CreateFileAttachmentInput!) {
            createFileAttachment(input: $input) {
                fileAttachment { id filename }
            }
        }""",
        {
            "input": {
                "filename": filename,
                "s3Key": step_1["s3Key"],
                "s3Etag": s3_etag,
                "entityId": entity_id,
                "validateUpload": True,
            }
        },
    )
    return result["createFileAttachment"]["fileAttachment"]["id"]
```

For the full Python integration template, see [Build an API client](/api-reference/guides/python-quickstart).

## Common errors

| Symptom                                       | Cause                                                                      | Fix                                                                           |
| --------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `403 Forbidden` on the upload PUT             | The upload URL expired before the PUT request happened.                    | Re-request the URL and use it right away.                                     |
| `403 SignatureDoesNotMatch` on the upload PUT | The `Content-Type` header doesn't match what was signed.                   | Send exactly the `contentType` returned in step 1.                            |
| `validateUpload` mutation fails               | The `s3Etag` doesn't match what storage has.                               | Capture the `ETag` after a successful `200` response, not the request hash.   |
| Attachment created but not visible in the UI  | The wrong `entityId`, the parent's `id` instead of `entity.id`.            | Re-fetch the parent's `entity { id }` and register again.                     |
| `Permission denied` on `createFileAttachment` | The user or service account lacks attach permission for the target entity. | Check the role's permissions on the parent resource, such as run step update. |

## Verify the attachment

After step 3, you can confirm the attachment in two ways:

* In the ION UI, open the parent entity and check the attachments panel.
* Via the API, query the attachment back by ID:

```graphql theme={null}
query VerifyUpload($id: ID!) {
  fileAttachment(id: $id) {
    id
    filename
    downloadUrl
    entityId
  }
}
```

The `downloadUrl` is a fresh signed download URL, time-bound the same way the upload URL was. Don't cache it long-term.

<Tip>
  Set a longer timeout for the upload PUT in step 2 than for your GraphQL
  requests. For large files, the upload is the slowest part of the flow.
</Tip>

## Related

* [Authentication](/api-reference/authentication/overview)
* [Build an API client](/api-reference/guides/python-quickstart)
* [Example requests](/api-reference/examples)
* [Error codes](/api-reference/error-codes)
