5.5 KiB
gaiia-py
Python client for the gaiia GraphQL API and webhooks. Thin wrapper around the official GraphQL surface — no codegen, no opinionated ORM. You write GraphQL, the client handles auth, pagination, rate limits, GlobalID encoding, and webhook signature verification.
Install
pip install gaiia
Requires Python 3.10+. Depends on httpx.
Quick start
from gaiia import GaiiaClient
with GaiiaClient(api_key="gaiia_sk_live_...", timezone="America/Chicago") as g:
data = g.query(
"query Q($first: Int!) { technicians(first: $first) { nodes { id firstName lastName } } }",
{"first": 10},
)
for t in data["technicians"]["nodes"]:
print(t)
If api_key= is omitted, the client reads GAIIA_KEY from the environment.
Mutations
The mutate() helper unwraps the mutation result and raises
GaiiaMutationError when the response contains validation errors under
data.<mutation>.errors:
res = g.mutate(
"""
mutation A($i: AssignTechnicianToWorkOrderInput!) {
assignTechnicianToWorkOrder(input: $i) {
workOrder { id status startDate }
errors { code message }
}
}
""",
"assignTechnicianToWorkOrder",
{"i": {
"workOrderId": "work_order_...",
"technicianId": "technician_...",
"startDate": "2026-05-12T14:00:00Z",
}},
)
print(res["workOrder"]["id"])
Pagination
iter_nodes() paginates a Relay Connection automatically. Your query must
accept an $after: String variable and pass it to the paginated field:
for tech in g.iter_nodes(
"""
query Q($after: String) {
technicians(first: 50, after: $after) {
nodes { id firstName lastName }
pageInfo { hasNextPage endCursor }
}
}
""",
"technicians",
):
print(tech["firstName"], tech["lastName"])
Default page size is 50, max 250 (per the gaiia docs). Some fields cap lower — check the field's reference page.
For bulk extracts (millions of rows, reporting, analytics), gaiia recommends Snowflake rather than the GraphQL API.
Rate limiting
After every call, client.last_rate_limit is populated from the response
headers (X-Rate-Limit-*). If gaiia returns a RATE_LIMITED error, the
client raises GaiiaRateLimitedError with limit, used, remaining, and
retry_at:
from gaiia import GaiiaClient, GaiiaRateLimitedError
with GaiiaClient(max_retries=2) as g:
try:
g.query("{ ... }")
except GaiiaRateLimitedError as e:
print("retry after", e.retry_at)
Setting max_retries > 0 makes the client sleep until retry_at and retry up
to that many times before re-raising.
GlobalIDs
from gaiia import encode_global_id, decode_global_id
encode_global_id("Account", "3c3b1978-6a68-4a13-bdc2-2d51c8ef7519")
# 'account_8rnXNuR5sKP5uNwoPL41Zp'
decode_global_id("account_8rnXNuR5sKP5uNwoPL41Zp")
# ('account', '3c3b1978-6a68-4a13-bdc2-2d51c8ef7519')
The encoding follows gaiia's short-uuid setup: Flickr Base-58 of the UUID's
128-bit integer, left-padded to 22 chars, prefixed with the snake-cased type.
Webhooks
from gaiia import verify_webhook_signature, WebhookSignatureError
try:
verify_webhook_signature(
header_value=request.headers["X-Gaiia-Webhook-Signature"],
raw_body=request.body_bytes, # the exact bytes gaiia signed
secret=os.environ["GAIIA_WEBHOOK_SECRET"],
tolerance_seconds=300,
)
except WebhookSignatureError:
return 400
The verifier:
- Parses
t=...,v1=...(multiplev1=allowed for key rotation), - Computes
HMAC-SHA256(secret, "${t}.${rawBody}"), - Constant-time compares against each
v1=candidate, - Rejects timestamps outside the tolerance window.
See examples/webhook_flask.py for a complete handler.
Async
AsyncGaiiaClient mirrors GaiiaClient for asyncio code:
import asyncio
from gaiia import AsyncGaiiaClient
async def main():
async with AsyncGaiiaClient(timezone="America/Chicago") as g:
data = await g.query("{ technicians(first: 5) { nodes { id firstName lastName } } }")
print(data)
asyncio.run(main())
async for over iter_nodes() paginates lazily.
Timezones
gaiia stores all timestamps in UTC. Pass timezone="America/Chicago" (or
whatever IANA TZ applies) at the client level — it becomes the x-timezone
header on every request, which gaiia uses for scheduling/availability logic.
Override per-call with g.query(..., timezone="America/Phoenix").
For display, query the work order's location.timezone and format times in
that zone — not the caller's.
Error model
| Class | When it's raised |
|---|---|
GaiiaTransportError |
Network failure, non-JSON response, HTTP 5xx. |
GaiiaAPIError |
Top-level GraphQL errors[] returned (schema/auth/permission). |
GaiiaRateLimitedError |
Subclass of GaiiaAPIError for RATE_LIMITED. |
GaiiaMutationError |
Mutation data.<m>.errors[] is non-empty (validation/business rule). |
All inherit from GaiiaError.
Development
git clone https://github.com/grahammcintire/gaiia-py
cd gaiia-py
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
ruff check .
mypy src/gaiia
License
MIT.