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

This page gives you a working Python integration in under fifteen minutes. The script is structured so each function maps to one ION operation you can lift into your own codebase. By the end you’ll have:
  • A reusable IonClient that handles auth, query/mutation calls, and error parsing
  • Working examples for the four most common starting tasks: list parts, create a run, submit a step result, and upload a file attachment
  • Patterns for fragments, error handling, and retry-with-backoff
Two env vars are all the script needs:
export ION_BASE_URL="https://api.firstresonance.io"
export ION_TOKEN="<your-access-token>"
If you don’t have a token yet, see Authentication → Getting a token.

Setup

python -m venv .venv
source .venv/bin/activate
pip install "httpx>=0.27" "tenacity>=8.0"
That’s it — no GraphQL-specific client required. httpx is enough; tenacity gives us declarative retry. If you prefer the gql library, the patterns below translate directly.

The minimum-viable client

import os
import httpx
from typing import Any
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

class IonError(RuntimeError):
    """Raised when ION returns errors[] in the response body."""
    def __init__(self, errors: list[dict[str, Any]]):
        self.errors = errors
        super().__init__("; ".join(e.get("message", "") for e in errors))


class IonClient:
    def __init__(self, base_url: str | None = None, token: str | None = None):
        self.base_url = (base_url or os.environ["ION_BASE_URL"]).rstrip("/")
        self.token = token or os.environ["ION_TOKEN"]
        self._http = httpx.Client(
            base_url=self.base_url,
            headers={"Authorization": f"Bearer {self.token}"},
            timeout=30.0,
        )

    @retry(
        retry=retry_if_exception_type((httpx.HTTPError,)),
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=1, max=10),
        reraise=True,
    )
    def _post_graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
        r = self._http.post(
            "/graphql",
            json={"query": query, "variables": variables or {}},
        )
        r.raise_for_status()
        payload = r.json()
        if payload.get("errors"):
            raise IonError(payload["errors"])
        return payload["data"]

    def query(self, query: str, **variables) -> dict[str, Any]:
        return self._post_graphql(query, variables)

    def mutate(self, mutation: str, **variables) -> dict[str, Any]:
        return self._post_graphql(mutation, variables)

    def close(self):
        self._http.close()
IonClient.query and IonClient.mutate are the only methods you’ll call. _post_graphql handles transport-level retries (5xx, network errors) and surfaces GraphQL-level errors as IonError.

”Who am I?”

Confirm the token works before doing anything else:
ME = """
query CurrentUser {
  currentUser {
    id
    name
    email
    organization { id domain }
  }
}
"""

with IonClient() as ion:
    me = ion.query(ME)["currentUser"]
    print(f"Authenticated as {me['name']} ({me['email']}) in org {me['organization']['domain']}")
If this fails, see the 401 table in Authentication — the error message will name the fix.

List parts

LIST_PARTS = """
query Parts($filters: PartFilters!, $limit: Int) {
  parts(filters: $filters, limit: $limit) {
    id
    partNumber
    description
    revision
    partType
    status
    partSubtypes { id name }
  }
}
"""

with IonClient() as ion:
    parts = ion.query(LIST_PARTS, filters={}, limit=20)["parts"]
    for p in parts:
        subtypes = ", ".join(s["name"] for s in p["partSubtypes"]) or "—"
        print(f"{p['partNumber']}\t{p['description']}\t{subtypes}")
Notice the explicit field selection — see GraphQL Field Selection for why this matters.

Create a run

CREATE_RUN = """
mutation CreateRun($input: RunCreateInput!) {
  createRun(input: $input) {
    run {
      id
      title
      status
      procedure { id title }
      partInventory { id serialNumber }
    }
  }
}
"""

with IonClient() as ion:
    new_run = ion.mutate(
        CREATE_RUN,
        input={
            "procedureId": 12,
            "partInventoryId": 9876,
            "title": "Build #2026-04-26 Unit 1",
        },
    )["createRun"]["run"]
    print(f"Created run {new_run['id']}: {new_run['title']}")
Replace procedureId and partInventoryId with real IDs from your tenant. See the Common Queries Guide for how to find candidate procedures.

Submit a run step result

SUBMIT_STEP = """
mutation SubmitStep($input: RunStepUpdateInput!) {
  updateRunStep(input: $input) {
    runStep {
      id
      status
      completedAt
      runStepFields { id label value }
    }
  }
}
"""

with IonClient() as ion:
    result = ion.mutate(
        SUBMIT_STEP,
        input={
            "id": 4567,
            "_etag": "abc123",       # required for optimistic concurrency
            "status": "complete",
            "fieldValues": [
                {"runStepFieldId": 88, "value": "PASS"},
                {"runStepFieldId": 89, "value": "12.45"},
            ],
        },
    )["updateRunStep"]["runStep"]
    print(f"Step {result['id']}{result['status']} at {result['completedAt']}")
The _etag field is required on update — see Data Model → Etag-based concurrency. If you get a 409, re-fetch the runStep, take its new etag, and retry.

Upload a file attachment

The full three-step pattern from File Upload:
import os

REQUEST_UPLOAD = """
query Upload($filename: String!) {
  requestFileUploadUrl(filename: $filename) {
    uploadUrl
    s3Key
    contentType
  }
}
"""

GET_RUN_STEP_ENTITY = """
query StepEntity($id: Int!) {
  runStep(id: $id) {
    entity { id }
  }
}
"""

CREATE_FILE_ATTACHMENT = """
mutation CreateAttachment($input: CreateFileAttachmentInput!) {
  createFileAttachment(input: $input) {
    fileAttachment { id filename downloadUrl }
  }
}
"""

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

    # 1. Get a signed upload URL
    upload = ion.query(REQUEST_UPLOAD, filename=filename)["requestFileUploadUrl"]

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

    # 3. Resolve the run step's entity.id (NOT the run step's own id)
    entity_id = ion.query(GET_RUN_STEP_ENTITY, id=run_step_id)["runStep"]["entity"]["id"]

    # 4. Register the attachment
    attachment = ion.mutate(
        CREATE_FILE_ATTACHMENT,
        input={
            "filename": filename,
            "s3Key": upload["s3Key"],
            "s3Etag": s3_etag,
            "entityId": entity_id,
            "validateUpload": True,
        },
    )["createFileAttachment"]["fileAttachment"]
    return attachment["id"]


with IonClient() as ion:
    attachment_id = attach_to_run_step(ion, run_step_id=4567, file_path="./inspection.jpg")
    print(f"Attached file id={attachment_id}")
The two stumbling blocks (capturing the S3 ETag, fetching entity.id not the parent’s id) are documented in File Upload.

Error handling pattern

The IonClient already raises IonError on GraphQL-level errors and uses tenacity for transport retries. Wrap business logic in a try/except that distinguishes:
from httpx import HTTPStatusError, ConnectError

def safely_create_run(ion: IonClient, **inputs) -> int | None:
    try:
        result = ion.mutate(CREATE_RUN, input=inputs)
    except IonError as e:
        # GraphQL errors[] — usually 4xx auth/permission/validation
        # Surface to the user; don't retry blindly
        print(f"ION rejected the request: {e}")
        return None
    except HTTPStatusError as e:
        # 5xx after retries exhausted
        print(f"ION 5xx after retries: {e.response.status_code}")
        return None
    except ConnectError:
        # Network down
        print("Cannot reach ION")
        return None
    return result["createRun"]["run"]["id"]
See Error Codes for the full HTTP status reference and the GraphQL errors[] payload shape.

Reusable fragments

When you have multiple queries that return parts (or runs, or POs), define one fragment per entity and inline it everywhere:
FRAGMENTS = """
fragment PartFields on Part {
  id
  partNumber
  description
  revision
  partType
  status
  partSubtypes { id name }
}

fragment RunFields on Run {
  id
  title
  status
  procedure { id title }
  partInventory { id serialNumber part { ...PartFields } }
}
"""

LIST_RUNS_WITH_PARTS = FRAGMENTS + """
query Runs($filters: RunFilters!) {
  runs(filters: $filters) {
    ...RunFields
  }
}
"""
This keeps field selection consistent across calls and makes “I forgot to ask for etag” mistakes a one-line fix instead of a multi-file refactor.

Tips

  • Reuse one IonClient per process. It maintains a connection pool. Don’t create a new one per call.
  • Keep the token in env vars, not in the script. Rotate tokens on a schedule; don’t bake them in.
  • Test against Sandbox first. Especially for mutations.
  • Use tenacity’s before_sleep_log callback to log retry attempts during development — it makes flaky network behavior visible.
  • Always log GraphQL errors fully including the path field — it tells you which selection in your query failed.

Where to go next

  • Common Queries Guide — 20 use-case queries you can drop into the patterns above
  • Data Model — orientation for the entities your queries will touch
  • Webhooks — push-based alternative to polling-based integrations
  • API Playground — explore the schema interactively before writing Python