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
- Send
requestFileUploadUrl to ION. ION returns uploadUrl, s3Key, and contentType.
- Send a PUT request with the file body and
Content-Type header to uploadUrl. Storage returns a 200 response with an ETag header.
- Send
createFileAttachment to ION with s3Key, s3Etag, entityId, and filename. ION returns the fileAttachment.
Prerequisites
- An authenticated GraphQL client. See Authentication.
- Permission to attach files to the entity you’re targeting.
- The entity ID of the object you’re attaching to. See Choose an entityId.
Step 1: Request a signed upload URL
Request a signed upload URL:
query RequestUpload($filename: String!) {
requestFileUploadUrl(filename: $filename) {
uploadUrl
s3Key
contentType
}
}
Variables:
{ "filename": "inspection-photo-2026-04-26.jpg" }
Response:
{
"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:
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/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.
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.
Step 3: Register the attachment
mutation RegisterUpload($input: CreateFileAttachmentInput!) {
createFileAttachment(input: $input) {
fileAttachment {
id
filename
downloadUrl
entityId
}
}
}
Variables:
{
"input": {
"filename": "inspection-photo-2026-04-26.jpg",
"s3Key": "uploads/abc123/inspection-photo-2026-04-26.jpg",
"s3Etag": "9c1185a5c5e9fc54612808977ee8f548",
"entityId": 9876,
"validateUpload": true
}
}
Response:
{
"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.
| 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. |
validateUpload | Boolean | When true, ION verifies that the uploaded object exists and matches the ETag before creating the record. Recommended. |
Set validateUpload to true. ION makes one extra HEAD request and gives you
immediate feedback when the registration doesn’t match the uploaded bytes.
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:
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
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.
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:
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.
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.