Pax Equitas
← PaxExact API

Developer guide

PaxExact External API — Developer Guide (v1)

PaxExact is an estimating and pricing engine with its own data intelligence. This API lets your system create and work estimates against PaxExact with your own PaxExact credentials. It never exposes PaxExact's internal or vendor secrets, and it never invents a price: when PaxExact cannot price a line it says so, with a reason.

Base URL: https://accurate-warmth-production-4f18.up.railway.app/v1/external

Keys are issued through the API Center: a sandbox key is self-service, from your organization's API Center portal once access is enabled; a live key requires an approved production access request.

1. Environments and base URL

EnvironmentKey prefixBase URL (path)Data
livepxk_live_…same host, same pathsyour production estimates
sandboxpxk_test_…same host, same pathsisolated test estimates

A sandbox key can only see and create sandbox estimates; a live key only live ones. Sandbox data never enters PaxExact's pricing evidence. The two never cross — the environment is part of the key, part of every stored estimate, and part of every authorization check.

The API version is the /v1 path segment. The estimate contract version — the vocabulary and shapes of pricing, quantity_state and totals — is the string paxexact-estimate-v1, returned on every estimate response as contract_version and by GET /contract. Additive fields never change it; breaking changes bump it.

2. Authentication

Every request carries your key as a Bearer token:

GET /v1/external/me HTTP/1.1
Authorization: Bearer pxk_live_3f9a1c2b7d4e6f80_Qm9ndXNTZWNyZXRWYWx1ZUZvckRvY3NPbmx5MTIz

A key looks like pxk_<env>_<key_id>_<secret>:

  • key_id (16 hex chars) is public — quote it in support requests, logs, tickets.
  • secret (43 chars) is shown once, when the key is issued or rotated. PaxExact stores only a SHA-256 verifier and cannot show it again. If you lose it, rotate.

A sandbox key is self-service, issued from your organization's API Center portal. A live key requires an approved production access request — once approved, issuing one works the same way, from the same portal. Each key has a name, an environment, an explicit scope list, an optional expiry, and a rate limit.

Authentication failures are 401 with WWW-Authenticate: Bearer realm="paxexact" and a machine-readable detail.code:

CodeMeaning
missing_credentialsno Authorization: Bearer header
malformed_credentialsnot a PaxExact key
invalid_credentialsunknown key id, wrong secret, or tampered environment token
credential_revokedthe key was revoked or rotated away
credential_expiredpast expires_at
organization_inactive (403)your organization is suspended/offboarded

Confirm what a key is with GET /me:

{
  "organization_id": "…", "organization_slug": "acme-restoration", "organization_name": "Acme Restoration",
  "key_id": "3f9a1c2b7d4e6f80", "key_prefix": "pxk_live_3f9a1c2b", "environment": "live",
  "scopes": ["estimates:read", "estimates:write", "pricing:read"],
  "rate_limit_per_minute": 120, "expires_at": null,
  "contract_version": "paxexact-estimate-v1", "request_id": "…"
}

3. Scopes

Scopes are explicit and least-privilege; nothing is implied.

ScopeGrants
pricing:readGET /contract, GET /pricing/resolve
estimates:readGET /estimates, GET /estimates/{id}
estimates:writePOST /estimates, PATCH /estimates/{id}, rooms, line items (create/update/delete)
evidence:read, evidence:writereserved; no v1 route requires them

estimates:write does not imply estimates:read. A missing scope is 403 with detail.code = "insufficient_scope", detail.required_scopes, detail.missing_scopes. GET /me needs any valid key.

4. Endpoints

All paths below are under /v1/external.

MethodPathScopePurpose
GET/me—describe the key
GET/contractpricing:readclosed vocabularies, money precision, floors
GET/pricing/resolve?code=&unit=&region=pricing:readwrite-free pricing probe
GET/estimates?limit=&offset=estimates:readlist your estimates (this environment)
POST/estimatesestimates:writecreate (optionally with rooms + lines)
GET/estimates/{estimate_id}estimates:readfull tree with pricing, quantity_state, totals
PATCH/estimates/{estimate_id}estimates:writename, notes, pricing_region, overhead_profit_percent, status
POST/estimates/{estimate_id}/roomsestimates:writeadd a room (optionally with lines)
POST/estimates/{estimate_id}/rooms/{room_id}/line-itemsestimates:writeadd a line
PUT/estimates/{estimate_id}/line-items/{line_item_id}estimates:writeoverride price / apply PaxExact price / edit
DELETE/estimates/{estimate_id}/line-items/{line_item_id}estimates:writeremove a line

Every nested route requires the parent estimate_id; a room or line that exists but belongs to another estimate is 404. Estimates you do not own are 404, never 403 — the API does not confirm existence across organizations.

Create an estimate

POST /v1/external/estimates
Authorization: Bearer pxk_live_…
Content-Type: application/json

{
  "name": "Smith residence — water loss",
  "pricing_region": "TX",
  "overhead_profit_percent": 20,
  "external_reference": "CLM-2026-0917",
  "rooms": [
    {"name": "Kitchen", "room_type": "kitchen", "line_items": [
      {"description": "Paint walls, 2 coats", "quantity": 480, "unit": "SF", "pricing_code": "PX-PNT-WALL-2C"},
      {"description": "Haul debris", "quantity": 1, "unit": "EA", "unit_cost": 350.00}
    ]}
  ]
}

Set pricing_region. Without it every coded line resolves against the national default and reports NO_GEOGRAPHY.

Estimate responses also carry property_zip (always null for external estimates: the external API does not accept it in v1) and a property_geography block (state, zip5, zip3, source, conflict, property_zip_required) describing where the estimate's prices are placed; for an external estimate its state comes from pricing_region.

Read the pricing state of a line

Each line item carries a server-computed pricing block:

{
  "description": "Paint walls, 2 coats", "quantity": 480, "unit": "SF",
  "unit_cost": null, "total_cost": null, "price_provenance": "unpriced",
  "pricing": {
    "pricing_state": "UNPRICED",
    "source_class": null,
    "reason_code": "CATALOG_MISS_NO_OBSERVATIONS",
    "reason": "No active catalog price for PX-PNT-WALL-2C in TX and no human price observations …",
    "effective_unit_price": null, "human_unit_price": null, "human_override_active": false,
    "suggested_unit_price": null, "suggested_source_class": null,
    "as_of": null, "confidence": null, "recommendation_id": null,
    "line_total": null, "counts_toward_total": true
  },
  "quantity_state": {"quantity": 480, "unit": "SF", "quantity_state": "CURRENT", "review_state": "approved", "proposed_by": "user"}
}

pricing_state ∈ PRICED | UNPRICED | INSUFFICIENT_DATA | STALE; source_class ∈ CATALOG | OWN_DATA | MANUAL | UNVERIFIED. A missing price is never 0. total_cost: null means unpriced.

Price a line

PUT /v1/external/estimates/{id}/line-items/{line_id}
{"unit_cost": 1.85}                                   → human price: source_class MANUAL

PUT /v1/external/estimates/{id}/line-items/{line_id}
{"pricing_code": "PX-PNT-WALL-2C", "unit_cost": null} → apply PaxExact pricing: CATALOG or OWN_DATA

Rules the server enforces (you cannot change them from the client):

  • a human price always wins over PaxExact's own-data suggestion; the suggestion stays visible as suggested_unit_price, never applied over your figure;
  • a catalog price overrides a client-sent number on write (the response tells you: source_class = CATALOG);
  • price_provenance, total_cost, pricing, totals in a request body are ignored;
  • reading never applies anything.

Totals

"totals": {
  "contract_version": "paxexact-estimate-v1", "currency": "USD", "scale": 2, "rounding": "HALF_UP",
  "completeness": "INCOMPLETE_UNPRICED",
  "subtotal": 350.00, "overhead_profit_percent": 20, "overhead_profit_amount": 70.00,
  "tax_amount": null, "tax_state": "NOT_MODELED",
  "total": 420.00, "rcv": 420.00, "depreciation": null, "acv": null, "valuation_state": "RCV_ONLY_NOT_DEPRECIATED",
  "approval_state": "ALL_REVIEWED",
  "line_count": 2, "counted_line_count": 2, "priced_line_count": 1, "unpriced_line_count": 1, …
}

total is PARTIAL whenever completeness != "COMPLETE"; it is null when no priced line counts. Money is cents, ROUND_HALF_UP, computed by the API. Display API figures; do not recompute.

Probe pricing before writing

GET /v1/external/pricing/resolve?code=PX-PNT-WALL-2C&unit=SF&region=TX

Returns resolution_state ∈ PRICED | UNPRICED | INSUFFICIENT_DATA with the suggested price and provenance when one exists, else a reason_code (UNKNOWN_PRICING_CODE, INVALID_UNIT, NO_UNIT, NO_GEOGRAPHY, INSUFFICIENT_SAMPLES, CATALOG_MISS_NO_OBSERVATIONS). The probe is 200 in every case so you can decide before you write; the same conditions on a write are 422.

5. Errors

Every 4xx/5xx has the shape:

{"detail": {"code": "insufficient_scope", "message": "…", "request_id": "…", "...": "…"}}
HTTPcode(s)
401missing_credentials, malformed_credentials, invalid_credentials, credential_revoked, credential_expired
403insufficient_scope, organization_inactive
404not_found (estimate/room/line outside your organization or environment, or nonexistent)
409http_409 — editing a stale line
422validation_error, or a pricing seam refusal (unknown PX- code, incompatible unit, unknown pricing version) — detail is text for seam refusals
429rate_limited (see §6)

Every response carries the request id, but in one of two places: a success carries it in the X-Request-ID header, while an error carries it as detail.request_id in the body (on the error path the response is built after the route unwinds, so the header is not attached). Send your own X-Request-ID (≤ 64 chars, [A-Za-z0-9._:-]) and it is echoed in whichever applies, and recorded. When reporting a failure, quote detail.request_id.

6. Rate limits

Per key, fixed one-minute window, default 120 requests/minute (configurable per key). Every authenticated response carries:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1757300460      (unix seconds, end of the current window)

Over the limit: 429, detail.code = "rate_limited", Retry-After: <seconds>. Rejected requests still count against the window. The counter is durable and shared across all PaxExact API processes.

Three consequences worth knowing:

  • The limit is applied as soon as your key is recognised, before PaxExact decides whether to accept it. So a key that has been revoked or expired, retried past its limit, answers 429 rather than 401 — check GET /me with a fresh key, or wait for the window to reset, before concluding the key is merely throttled.
  • X-RateLimit-* headers appear on 200 and 429. A 401 or 403 does not carry them; it carries WWW-Authenticate instead.
  • Throttled requests are counted but not itemised in your usage records — the window counter holds them in aggregate.

7. Idempotency

v1 has no idempotency-key header. POST /estimates twice creates two estimates. Use external_reference to carry your own identifier and GET /estimates to reconcile; PUT on a line item is naturally idempotent for the same body. An Idempotency-Key header is a planned addition and will be announced under the contract version.

8. Credential lifecycle and security guidance

  • Rotation revokes this key and issues a replacement in one step — the same scopes, environment, and rate limit carry over. The new secret is shown once, in the response. There is no overlap window: the old key already returns credential_revoked by the time you have the new one. If the request itself fails, reload before retrying to see whether the key was already replaced.
  • Revocation is immediate on every PaxExact process (the check reads the database; nothing is cached).
  • Expiry is optional; an expired key returns credential_expired.
  • Store keys in a secrets manager, never in source control or client-side code. Use one key per integration/environment so a leak has a small blast radius.
  • PaxExact never logs your key or its verifier. Usage records hold the key_id, the route template, status, timing and your X-Request-ID — never request bodies.
  • Your human-entered prices stay private to your organization and do not inform other customers' suggestions unless you explicitly opt in with PaxExact.

9. What PaxExact will and will not tell you about prices

PaxExact prices from its catalog when one is active for your region and from its own accumulated human observations when enough independent projects support a figure (currently 2 distinct projects). When neither holds, the line is UNPRICED or INSUFFICIENT_DATA with the reason. Coverage today is narrow (the canonical codebook has 32 codes and catalog coverage depends on what has been loaded for your region); the API is designed to report that honestly rather than to look complete.

10. OpenAPI

A curated, machine-readable OpenAPI 3.1 document for exactly these 11 external routes is published at /developers/paxexact/openapi.json. It declares the PaxExactApiKeybearer security scheme used throughout this guide. It does not include PaxExact's internal staff/back-office routes — those are a separate, non-public surface.

Request a Demo