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
- Authentication
- Conventions
- Health
- Resellers & customers (tenancy)
- Assets / properties
- Geo queries
- Utilities
- Plans
- Rate history
- Usage
- Enrollments
- Exchange transactions
- 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
- All geometry is WGS84 / SRID 4326 (longitude, latitude — in that order, matching GeoJSON and ArcGIS defaults).
- Every spatial endpoint accepts
?format=geojson(default) or?format=esri, switching between an RFC 7946 GeoJSONFeatureCollectionand an Esri JSONFeatureSet. attributeson any resource is a free-form JSON object — put whatever domain-specific fields that record needs there; nothing needs a migration.- Timestamps are ISO 8601 UTC. IDs are UUIDv4, generated server-side on create.
- Tenancy applies everywhere. A reseller-scoped caller only ever sees its own customers, properties, and transactions — including through the spatial
/geo/*endpoints. Reading another reseller's resource returns403; listing simply excludes it.
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}
cost_keyis optional. Omitted, it defaults to the reseller's UUID, so it is never empty and always resolves back to a reseller row. Supply your own billing/settlement code to override.- Uniqueness is on
(customer_id, reseller_id, state)— the same pair may legitimately repeat across states. A duplicate returns409. - Attaching in a state outside the reseller's
operating_statesreturns422.
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:
- The service layer demotes the incumbent, flushes, then promotes — in that order, because a unique index cannot be deferred.
- 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.