network/gaiia
2026-07-19 14:42:02 -05:00
..
examples update 2026-07-19 14:42:02 -05:00
src/gaiia update 2026-07-19 14:42:02 -05:00
tests update 2026-07-19 14:42:02 -05:00
.direnv update 2026-07-19 14:42:02 -05:00
.envrc update 2026-07-19 14:42:02 -05:00
.gitignore update 2026-07-19 14:42:02 -05:00
account_status_suspension_workflow.json update 2026-07-19 14:42:02 -05:00
cleanup_stale_byod_workflow.json update 2026-07-19 14:42:02 -05:00
clear_all_ips.py update 2026-07-19 14:42:02 -05:00
clear_ip_assignments_workflow.json update 2026-07-19 14:42:02 -05:00
clear_ips_step1_list.json update 2026-07-19 14:42:02 -05:00
clear_ips_step2_expire_one.json update 2026-07-19 14:42:02 -05:00
compare_pppoe.py update 2026-07-19 14:42:02 -05:00
daily_preseem_sync_workflow.json update 2026-07-19 14:42:02 -05:00
dhcp_opt82_workflow.json update 2026-07-19 14:42:02 -05:00
expire_one_workflow.json update 2026-07-19 14:42:02 -05:00
fuzzy_match.py update 2026-07-19 14:42:02 -05:00
ip_assignments_page1.json update 2026-07-19 14:42:02 -05:00
LICENSE update 2026-07-19 14:42:02 -05:00
list_ip_assignments.py update 2026-07-19 14:42:02 -05:00
match_radius.py update 2026-07-19 14:42:02 -05:00
output.txt update 2026-07-19 14:42:02 -05:00
pppoe_full_sync_workflow.json update 2026-07-19 14:42:02 -05:00
pyproject.toml update 2026-07-19 14:42:02 -05:00
radius_gaiia_fuzzy.csv update 2026-07-19 14:42:02 -05:00
radius_gaiia_matched.csv update 2026-07-19 14:42:02 -05:00
radius_gaiia_unmatched.csv update 2026-07-19 14:42:02 -05:00
radius_usernames.json update 2026-07-19 14:42:02 -05:00
radius_users.txt update 2026-07-19 14:42:02 -05:00
README.md update 2026-07-19 14:42:02 -05:00
sync_all_radius_groups.py update 2026-07-19 14:42:02 -05:00
sync_all_radius_groups_workflow.json update 2026-07-19 14:42:02 -05:00
update_preseem_on_ip_change.json update 2026-07-19 14:42:02 -05:00
uv.lock update 2026-07-19 14:42:02 -05:00
workflows_backup_2026-07-10_125815.json update 2026-07-19 14:42:02 -05:00

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=... (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:

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.