# gaiia-py Python client for the [gaiia](https://app.gaiia.com/docs/overview) 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 ```bash pip install gaiia ``` Requires Python 3.10+. Depends on `httpx`. ## Quick start ```python 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..errors`: ```python 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: ```python 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](https://app.gaiia.com/docs/rate-limits) 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`: ```python 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 ```python 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 ```python 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=...` (multiple `v1=` 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: ```python 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..errors[]` is non-empty (validation/business rule). | All inherit from `GaiiaError`. ## Development ```bash 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.