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
| Environment | Key prefix | Base URL (path) | Data |
|---|---|---|---|
| live | pxk_live_… | same host, same paths | your production estimates |
| sandbox | pxk_test_… | same host, same paths | isolated 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_Qm9ndXNTZWNyZXRWYWx1ZUZvckRvY3NPbmx5MTIzA 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:
| Code | Meaning |
|---|---|
| missing_credentials | no Authorization: Bearer header |
| malformed_credentials | not a PaxExact key |
| invalid_credentials | unknown key id, wrong secret, or tampered environment token |
| credential_revoked | the key was revoked or rotated away |
| credential_expired | past 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.
| Scope | Grants |
|---|---|
| pricing:read | GET /contract, GET /pricing/resolve |
| estimates:read | GET /estimates, GET /estimates/{id} |
| estimates:write | POST /estimates, PATCH /estimates/{id}, rooms, line items (create/update/delete) |
| evidence:read, evidence:write | reserved; 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.
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | /me | — | describe the key |
| GET | /contract | pricing:read | closed vocabularies, money precision, floors |
| GET | /pricing/resolve?code=&unit=®ion= | pricing:read | write-free pricing probe |
| GET | /estimates?limit=&offset= | estimates:read | list your estimates (this environment) |
| POST | /estimates | estimates:write | create (optionally with rooms + lines) |
| GET | /estimates/{estimate_id} | estimates:read | full tree with pricing, quantity_state, totals |
| PATCH | /estimates/{estimate_id} | estimates:write | name, notes, pricing_region, overhead_profit_percent, status |
| POST | /estimates/{estimate_id}/rooms | estimates:write | add a room (optionally with lines) |
| POST | /estimates/{estimate_id}/rooms/{room_id}/line-items | estimates:write | add a line |
| PUT | /estimates/{estimate_id}/line-items/{line_item_id} | estimates:write | override price / apply PaxExact price / edit |
| DELETE | /estimates/{estimate_id}/line-items/{line_item_id} | estimates:write | remove 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_DATARules 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,totalsin 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®ion=TXReturns 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": "…", "...": "…"}}| HTTP | code(s) |
|---|---|
| 401 | missing_credentials, malformed_credentials, invalid_credentials, credential_revoked, credential_expired |
| 403 | insufficient_scope, organization_inactive |
| 404 | not_found (estimate/room/line outside your organization or environment, or nonexistent) |
| 409 | http_409 — editing a stale line |
| 422 | validation_error, or a pricing seam refusal (unknown PX- code, incompatible unit, unknown pricing version) — detail is text for seam refusals |
| 429 | rate_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
429rather than401— checkGET /mewith a fresh key, or wait for the window to reset, before concluding the key is merely throttled. X-RateLimit-*headers appear on200and429. A401or403does not carry them; it carriesWWW-Authenticateinstead.- 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_revokedby 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 yourX-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.
Something here not match the live API? The API Center portal and this guide are kept in step by hand — if you find a mismatch, quote the section and we will fix it.