Skip to main content

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.

Overview

ION stores file attachments — procedure images, run artifacts, inspection photos, custom documents — in cloud object storage. To upload a file via the API you don’t POST it through the GraphQL endpoint. Instead, you use a three-step flow: get a signed upload URL from ION, PUT the file directly to that URL, then register the attachment back to ION with the storage details. The benefits: no proxy bandwidth on ION’s API tier, large files supported, standard signed-URL semantics. The catch: the flow is unusual enough that it’s worth a dedicated page. The two most common stumbling blocks — capturing the ETag from the PUT response and figuring out what entityId to pass — are called out explicitly below.

The flow

Client                      ION GraphQL                Storage
  │                              │                        │
  │ 1. requestFileUploadUrl ─────▶                        │
  │ ◀───── { uploadUrl,           │                        │
  │          s3Key,               │                        │
  │          contentType }        │                        │
  │                              │                        │
  │ 2. PUT (file body, Content-Type) ─────────────────────▶│
  │ ◀───────────────────────────────────── 200 + ETag ─────│
  │                              │                        │
  │ 3. createFileAttachment ─────▶                        │
  │    (s3Key, s3Etag,           │                        │
  │     entityId, filename)       │                        │
  │ ◀───── { fileAttachment }     │                        │
Three round trips: one to ION, one to storage, one to ION. No file bytes ever transit ION’s API tier.

Prerequisites

  • Authenticated GraphQL client (see Authentication)
  • Permission to attach files to the entity you’re targeting
  • The entity ID of the thing you’re attaching to. See Choosing an entityId below.

Step 1 — 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 a few minutes). If you sit on it too long the PUT in step 2 will fail. If that happens, just request a new one — the URL is cheap. The contentType in the response is what you must send in the PUT request’s Content-Type header. ION derives it from the filename extension; if your file is something the extension doesn’t reveal, request the URL with a more specific filename.

Step 2 — Upload the file directly

Upload the bytes 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 is important — you need the response headers, specifically the ETag value:
HTTP/1.1 200 OK
ETag: "9c1185a5c5e9fc54612808977ee8f548"
That ETag value (with the surrounding quotes stripped) is your s3Etag for step 3. It’s how ION verifies the file you uploaded matches the registration you’re about to create.
Capturing the ETag is the single most common file-upload bug. Most HTTP libraries ship with response-header access enabled — make sure you’re not using a wrapper that throws response headers away.

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.

Input fields

FieldTypeNotes
filenameStringDisplay filename. Doesn’t have to match the storage object’s key
s3KeyStringThe s3Key value from step 1 — the storage object key
s3EtagStringThe ETag returned by the storage service in step 2’s response, quotes stripped
entityIdIDThe entity ID of the parent object — see below
validateUploadBooleanWhen true, ION verifies the uploaded object exists and matches the etag before creating the record. Recommended

Choosing an entityId

entityId is not the same as the parent object’s id. ION uses an entities table that lets multiple object types share a polymorphic relationship to file attachments. To attach a file to a run step (the most common case), first fetch the run step’s entity.id:
query GetRunStepEntity($runStepId: Int!) {
  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, Steps, RunStepsFields all expose an entity { id } you can fetch and use.
Attachment targetFetch its entity.id via
Run steprunStep(id) { entity { id } }
Runrun(id) { entity { id } }
Procedureprocedure(id) { entity { id } }
Procedure stepstep(id) { entity { id } }
Partpart(id) { entity { id } }
Run step fieldrunStepField(id) { entity { id } }

End-to-end example (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: Int!) { 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"]
The full Python integration template lives in Python Quickstart.

Common errors

SymptomCauseFix
403 Forbidden on the upload PUTUpload URL expired before the PUT happenedRe-request the URL; don’t sit on a signed URL between issue and use
403 SignatureDoesNotMatch on the upload PUTContent-Type header doesn’t match what was signedSend exactly the contentType returned in step 1
validateUpload mutation failss3Etag doesn’t match what the storage backend actually hasCapture the ETag after a successful 200 response, not the request hash
Attachment created but invisible in UIWrong entityId — passed the parent’s id instead of entity.idRe-fetch the parent’s entity { id } and re-register
Permission denied on createFileAttachmentUser/service account lacks attach permission for the target entityCheck the role’s permissions on the parent resource (e.g. RunStep update)

Verifying the attachment

After step 3, the attachment is visible:
  • In the ION UI — open the parent entity and check the attachments panel.
  • Via API — query the attachment back by ID:
query VerifyUpload($id: Int!) {
  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.

Tips

  • Treat the upload URL as ephemeral. Request, use, discard. If your upload pipeline batches files, request URLs in parallel right before the PUTs.
  • Use validateUpload: true. It costs one extra HEAD request on ION’s side and gives you immediate feedback if the registration doesn’t match the bytes.
  • Set realistic timeouts. Step 2 (the upload PUT) is the long one for big files — don’t share the same 30-second timeout you use for GraphQL with the file PUT.
  • Don’t proxy file bytes through ION. If you find yourself wanting to POST a multipart form to /graphql, you’ve taken a wrong turn — use the three-step flow.