AXIOM Exchange — API Reference

Base URL (local, via nginx): http://localhost:8080/api/v1 Base URL (direct to a replica, for interactive docs): http://localhost:8000 → Swagger UI at /docs, raw spec at /openapi.json

Every endpoint below was exercised against a live PostgreSQL/PostGIS instance; request/response bodies shown are real captured output, not hand-written examples.

Table of contents

  1. Authentication
  2. Conventions
  3. Health
  4. Resellers & customers (tenancy)
  5. Assets / properties
  6. Geo queries
  7. Utilities
  8. Plans
  9. Rate history
  10. Usage
  11. Enrollments
  12. Exchange transactions
  13. Error format

Authentication

Every route except /healthz and /auth/token requires one of:

Method Header Notes
API key X-API-Key: <key> Static keys configured via AXIOM_API_KEYS. Simplest for service-to-service and local testing.
Bearer JWT Authorization: Bearer <token> From POST /auth/token. Expires after AXIOM_JWT_EXPIRE_MINUTES (default 60).
Gateway identity X-Axiom-Gateway-Identity + X-Axiom-Gateway-Secret Set by a trusted API Gateway that already authenticated the caller. The secret must match AXIOM_JWT_SECRET or the request is rejected.

No credentials:

GET /api/v1/assets

401 Unauthorized
{"detail":"Missing credentials. Provide Authorization: Bearer <jwt> or X-API-Key."}

Get a JWT:

POST /api/v1/auth/token
{"api_key": "axiom-local-dev-key"}
{"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "bearer", "expires_in_minutes": 60}

Get a reseller-scoped JWT — pass reseller_id and every tenant-aware endpoint automatically filters to that reseller:

POST /api/v1/auth/token
{"api_key": "axiom-local-dev-key", "reseller_id": "7fe24e63-ae59-4183-a4a2-d313e483a64d"}

A token with no reseller_id is a platform-level caller and sees across all resellers. API-key and gateway callers carry the same scope via the X-Axiom-Reseller-Id header.


Conventions


Health

GET /healthz

Liveness probe. No auth required. Returns {"status": "ok"} if the process is running.

GET /readyz

Readiness probe. Runs SELECT 1 against the database so a load balancer can pull a bad instance out of rotation.


Resellers & customers (tenancy)

The account model: a reseller is the tenant and the account holder; a customer belongs to many resellers; a property is an asset owned by a customer and serviced by one of those resellers.

The customer↔reseller attachment is a first-class record because the cost key lives on the relationship, not on either side.

POST /resellers

{"code": "LONESTAR-RETAIL", "name": "Lone Star Retail Energy", "operating_states": ["TX", "NM"]}

201 Created. operating_states is enforced: attaching a customer in a state the reseller doesn't operate in is rejected with 422.

GET /resellers

Optional state filter. A reseller-scoped caller sees only itself.

GET /resellers/{id} · PUT /resellers/{id}

GET /resellers/{id}/customers

Every customer attached to this reseller. Optional state.

GET /resellers/{id}/properties

Every property this reseller services, across all states. Optional state.

POST /customers

{"name": "Brazos Property Holdings", "email": "[email protected]", "external_ref": "CUST-4471"}

A customer is deliberately not owned by a single reseller.

GET /customers

Optional reseller_id, state. A reseller-scoped caller sees only customers attached to it.

GET /customers/{id} — the one-to-many view

Returns the customer plus every reseller attached to it:

{
  "customer": {"id": "81b1a684-...", "name": "Brazos Property Holdings", "external_ref": "CUST-4471", "status": "active"},
  "resellers": [
    {"reseller_id": "7fe24e63-...", "cost_key": "7fe24e63-ae59-4183-a4a2-d313e483a64d", "state": "TX", "is_primary": true},
    {"reseller_id": "ed4d52c7-...", "cost_key": "SOONER-COST-882", "state": "OK", "is_primary": false},
    {"reseller_id": "7fe24e63-...", "cost_key": "7fe24e63-ae59-4183-a4a2-d313e483a64d", "state": "NM", "is_primary": false}
  ]
}

A reseller-scoped caller gets the same customer but only its own attachment rows — it never learns which other resellers share the customer.

POST /customers/{id}/resellers — attach (creates the cost key)

{"reseller_id": "ed4d52c7-...", "state": "OK", "cost_key": "SOONER-COST-882", "is_primary": false}

GET /customers/{id}/resellers

The attachment list. Reseller-scoped callers see only their own rows.

POST /customers/{id}/resellers/{link_id}/primary

Promotes one attachment to primary and demotes the incumbent in the same transaction. At most one primary per customer is enforced twice over:

  1. The service layer demotes the incumbent, flushes, then promotes — in that order, because a unique index cannot be deferred.
  2. A partial unique index makes two primaries impossible even under concurrent writes:
CREATE UNIQUE INDEX uq_customer_one_primary_reseller
    ON customer_resellers (customer_id) WHERE is_primary;

Because the index is partial, the many non-primary rows per customer are unconstrained.

DELETE /customers/{id}/resellers/{link_id}

204. Detaching does not delete the customer.

GET /cost-keys — cost attribution rollup

The reverse lookup billing needs: given a cost key, which reseller does it bill to and what is it currently carrying.

[
  {
    "cost_key": "SOONER-COST-882",
    "reseller_id": "ed4d52c7-...", "reseller_code": "SOONER-POWER", "reseller_name": "Sooner Power Partners",
    "customer_count": 1, "property_count": 1, "states": ["OK"]
  },
  {
    "cost_key": "7fe24e63-ae59-4183-a4a2-d313e483a64d",
    "reseller_id": "7fe24e63-...", "reseller_code": "LONESTAR-RETAIL", "reseller_name": "Lone Star Retail Energy",
    "customer_count": 1, "property_count": 2, "states": ["NM", "TX"]
  }
]

Reseller-scoped callers see only their own keys.


Assets / properties

The generic geospatial registry. One table, discriminated by asset_type, backs every kind of spatial thing — polygons, lines, or points.

POST /assets — create any type of property

POST /api/v1/assets
X-API-Key: axiom-local-dev-key

{
  "organization_id": "axiom-demo",
  "asset_type": "commercial_property",
  "name": "Austin Distribution Center",
  "reseller_id": "7fe24e63-...",
  "customer_id": "81b1a684-...",
  "state": "TX",
  "geometry": {"type": "Polygon", "coordinates": [[[-97.77,30.24],[-97.71,30.24],[-97.71,30.30],[-97.77,30.30],[-97.77,30.24]]]},
  "attributes": {"sqft": 240000, "meter_type": "AMI"}
}

201 Created:

{
  "id": "e4c708eb-...", "organization_id": "axiom-demo",
  "reseller_id": "7fe24e63-...", "customer_id": "81b1a684-...",
  "state": "TX", "cost_key": "7fe24e63-ae59-4183-a4a2-d313e483a64d",
  "asset_type": "commercial_property", "name": "Austin Distribution Center",
  "status": "active", "attributes": {"sqft": 240000, "meter_type": "AMI"}
}

Cost keys are derived, not trusted. The API resolves cost_key from the customer's attachment to that reseller for that state, so a property can never be stamped with a key that doesn't correspond to a real relationship. If no attachment covers the property's state, the request fails with 422 naming the states that are covered:

{"detail": "No active attachment covers KS for that customer and reseller (covered: OK). Attach them for this state first."}

A reseller-scoped token supplies reseller_id implicitly.

geometry accepts any standard GeoJSON geometry type — Point, LineString, Polygon, MultiPolygon. Use a Point for a meter/premise, a LineString for a pipeline or feeder segment, a Polygon for a parcel, lease, or territory.

GET /assets — list / filter

Filters: organization_id, reseller_id, customer_id, state, cost_key, asset_type, status, limit (max 1000), offset.

GET /assets/{id}

Single asset, non-spatial fields. 404 if not found, 403 if it belongs to another reseller.

GET /assets/{id}/geometry — map-ready geometry (the ArcGIS-facing endpoint)

Query param: format=geojson (default) | format=esri.

{
  "type": "Feature",
  "id": "e4c708eb-...",
  "geometry": {"type": "Polygon", "coordinates": [[[-97.77,30.24],[-97.71,30.24],[-97.71,30.3],[-97.77,30.3],[-97.77,30.24]]]},
  "properties": {
    "id": "e4c708eb-...",
    "reseller_id": "7fe24e63-...", "customer_id": "81b1a684-...",
    "cost_key": "7fe24e63-ae59-4183-a4a2-d313e483a64d", "state": "TX",
    "asset_type": "commercial_property", "name": "Austin Distribution Center",
    "status": "active", "sqft": 240000, "meter_type": "AMI"
  }
}

Tenancy attribution travels with every feature, so an ArcGIS layer can style or filter by reseller, customer, or cost key with no second round trip. The Esri form carries the same fields under attributes, with geometry as rings.

PUT /assets/{id} · DELETE /assets/{id}

Partial update of name, status, description, geometry, attributes. Delete returns 204.


Geo queries

POST /geo/query — arbitrary polygon intersection

Body: a GeoJSON geometry (typically drawn or exported from ArcGIS), plus optional asset_types, organization_id, limit. Query param format=geojson|esri.

Returns a FeatureCollection of every asset — regardless of its own geometry type — that intersects the supplied polygon. Verified in testing: one polygon query returned both a Polygon property and a Point premise in the same response.

POST /geo/query/radius — proximity search

Body: lon, lat, radius_meters (max 200,000), optional asset_types, limit. Uses a true geodesic distance calculation (geography cast in PostGIS), so it stays accurate at any latitude.

GET /geo/bbox — map viewport load

Query params: min_lon, min_lat, max_lon, max_lat, optional asset_type, organization_id, state, limit, format. The standard "load whatever's currently on screen" call.

POST /geo/customers/search — spatial customer search

Given any GeoJSON area, returns the customers holding properties inside it, each with the properties found, the servicing reseller, and the cost key they bill to. This is the reseller-facing "who do I have in this territory" query.

{
  "property_count": 4,
  "customers": [
    {
      "customer_id": "81b1a684-...", "customer_name": "Brazos Property Holdings",
      "external_ref": "CUST-4471", "states": ["TX"], "property_count": 2,
      "properties": [
        {"id": "e4c708eb-...", "name": "Austin Distribution Center", "asset_type": "commercial_property",
         "state": "TX", "cost_key": "7fe24e63-...", "reseller_code": "LONESTAR-RETAIL"}
      ]
    }
  ]
}

POST /geo/utilities/status — utility status across an area

Every utility territory intersecting the area, with its current service status and how many in-scope properties sit inside it.

{
  "utilities": [
    {"code": "CTX-DEMO", "name": "Central Texas Demo Utility", "state": "TX",
     "service_status": "degraded", "affected_property_count": 3},
    {"code": "ONCOR-DEMO", "name": "Oncor Demo Delivery", "state": "TX",
     "service_status": "operational", "affected_property_count": 1}
  ]
}

Both endpoints are tenant-scoped: a reseller drawing a polygon over three states gets back only its own customers and only its own affected properties.


Utilities

GET /utilities

Optional state filter (2-letter code).

GET /utilities/lookup — point-in-polygon utility lookup

GET /api/v1/utilities/lookup?lon=-97.74&lat=30.27
{
  "utility": {"id": "...", "code": "CTX-DEMO", "name": "Central Texas Demo Utility", "state": "TX",
              "attributes": {"deregulated": true, "regulatory_body": "PUCT", "service_status": "degraded"}},
  "matched_by": "point_in_territory"
}

If no territory contains the point: {"utility": null, "matched_by": "not_found"}. This is the geospatial equivalent of "find the utility for this address" — a true polygon boundary rather than a zip-code table.

GET /utilities/{id}


Plans

GET /plans

Optional utility_id, is_business. Plans may carry a reseller_id: NULL means a platform-wide plan visible to every reseller, set means it is private to that reseller's catalog.

[{"id": "...", "utility_id": "...", "name": "Lone Star Fixed 12", "rate_type": "fixed",
  "rate_cents_per_kwh": 11.4, "term_months": 12, "is_business": true, "effective_date": "2026-08-22"}]

GET /plans/{id}


Rate history

GET /rates/{utility_id}/history

Optional area_code, since (date), limit.

[{"area_code": "78701", "rate_cents_per_kwh": 12.1, "recorded_at": "2026-07-23"}]

Usage

GET /usage/{premise_asset_id}

Period/kWh history for any asset with asset_type='premise', most recent first.

GET /usage/{premise_asset_id}/forecast

Query param periods_ahead (default 3, max 24). Returns a naive trailing-average projection — replace the forecast function with a real model as volume grows; the response contract (period_start, period_end, projected_kwh) is deliberately model-agnostic.


Enrollments

POST /enrollments

{"plan_id": "...", "premise_asset_id": "...", "customer_name": "Jordan Rivera", "customer_email": "[email protected]"}

201 Created, status: "pending". Enrollments also carry reseller_id, customer_id, and cost_key for the same attribution as properties.

GET /enrollments/{id}


Exchange transactions

Nominations, trades, and settlements — optionally tied to a physical asset via asset_id, and to a reseller relationship via reseller_id / cost_key, so volume can be reconciled against both the generation asset and the reseller it bills to.

POST /exchange/transactions

{
  "counterparty_id": "...", "asset_id": "...", "transaction_type": "trade",
  "volume_mwh": 250.5, "price_per_mwh": 34.20,
  "delivery_start": "2026-09-01T00:00:00Z", "delivery_end": "2026-09-02T00:00:00Z"
}

transaction_type must be one of nomination, trade, settlement. 201 Created, status: "pending".

GET /exchange/transactions

Filters: counterparty_id, transaction_type, status, asset_id. Filtering by asset_id returns every transaction reconciled against a given physical asset.

GET /exchange/transactions/{id}


Error format

Validation errors (422) follow FastAPI/Pydantic's standard shape:

{
  "detail": [
    {"type": "uuid_parsing", "loc": ["body", "counterparty_id"],
     "msg": "Input should be a valid UUID, invalid length: expected length 32 for simple format, found 0", "input": ""}
  ]
}

Business-rule failures return a single string detail: 403 for cross-tenant access, 404 not found, 409 duplicate attachment, 422 for an out-of-territory or uncovered-state attachment.