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