Docs / API reference
API reference
Every endpoint of the Gage quoting API, with fields, errors and the pricing model behind them.
Version: v1
Base URL: https://buildwithgage.com/api/v1 (production) · http://localhost:3000/api/v1 (local)
The Gage API is the quoting engine behind the website widget, Gage's own tooling, and partner integrations. All clients use the same endpoints.
#Authentication
Every request requires a bearer token in the Authorization header.
Authorization: Bearer gage_live_<key>
Authorization: Bearer gage_test_<key>
- Test keys (
gage_test_...) create real database rows flagged as test-mode. Use them for development and integration testing. - Live keys (
gage_live_...) are for production traffic.
Keys are issued by Gage. Store them securely, they are shown once at generation time and are never retrievable in plain text.
#Fabricator identity
The API never names the shop. Every fabricator block, on a quote, a reservation, an accept, a webhook, the network listing and the rate cards, carries a regional alias in name: "Mountain West Fabricator", "Gulf Coast Fabricator", with a second word ("Mountain Basin Fabricator") when two shops share a region. The alias is fixed per shop, so the same shop reads the same everywhere. id and state are real; location is the state alone, since an alias next to a town would name the shop just as well.
This is how Gage works: your customer, and you, deal with Gage, and the shop is a Gage shop. A quote that named it would be an invitation to go round the marketplace. The same rule has always applied to builders on Gage's own project pages.
The name is revealed in one place. GET /jobs/{job_id} reports fabricator.name as the company name, and location as city and state, once the shop has confirmed the job by paying the Gage fee (fabricator.confirmed: true, the same fact as project.fabricator_confirmed). From then on you know who is building, because you will be paying them. The job.status_changed webhook carries the same block and reveals at the same moment.
Quote pages sent to your customer carry the alias only, whatever you have learned since.
#Building with Gage fabricators
The API is for jobs built by Gage fabricators. Pricing is open: quote as often as you like, with a test key or a live one. What a shop fabricates from is not. The build data (the framed Pascal scene with every member, wall and opening placed, and the panel schedule) is released on the same event that reveals the shop: the job is accepted and the Gage fabricator has confirmed it by paying the fee.
Until then a quote made from drawings gives you what you need to show and sell the job: the price, the steel frame GLB at frame.url to put in front of your customer, the counts and tonnage (frame.summary, frame.cfs), the flags, and the massing model (the building's outer shape). frame.released is false, frame.release says what releases the rest, and frame.scene is null. Once the shop confirms, GET /quotes/{id}/model carries the scene. No field changes type and no URL stops answering. The steel frame is covered by the API Terms like the rest of the build data: it is for delivering the job with the Gage fabricator.
The same holds on the MCP server: its package carries panel totals, and the panel-by-panel schedule goes to the fabricator with the job. The rate card is ranges by region, never one shop's card.
All of this is set out, with what happens to work placed outside Gage, in the API Terms of Service. Every account accepts them before it gets a live key.
#Error format
All errors return a consistent envelope:
{
"error": {
"code": "validation_error",
"message": "Human-readable explanation.",
"param": "delivery.zip"
}
}
param is null for errors not tied to a specific field.
#Error codes
| Code | HTTP | Meaning |
|---|---|---|
missing_api_key | 401 | No Authorization header present |
invalid_api_key | 401 | Key not found or deactivated |
invalid_json | 400 | Request body is not valid JSON |
validation_error | 422 | A request field failed validation; see param |
no_coverage | 422 | Unknown delivery zip, or no fabricator is currently able to price the job. Coverage is nationwide, delivery distance raises the transport price instead of blocking the quote, so this usually means every shop has paused intake or caps job size below this project. The message says which. |
not_found | 404 | Quote or job ID does not exist |
quote_expired | 410 | Quote is past its valid_until date |
quote_not_acceptable | 409 | Quote is in a non-priced status (already accepted, superseded, etc.) |
quote_not_reservable | 409 | /reserve on a quote that is not priced |
already_reserved | 409 | /reserve on a quote that is already reserved |
not_reserved | 409 | /release on a quote that is not reserved, or whose project has moved on |
account_required | 403 | /leads and /models need a key that belongs to a developer account |
live_key_required | 403 | /reserve, /accept or /pricing/rates with a test key. Test keys quote and register; they never commit a fabricator |
missing_square_footage | 422 | /leads: neither the lead nor its registered model gives a size |
missing_zip | 422 | /leads: no zip on the lead and no default_delivery_zip on the account |
unsupported_file_type | 415 | /plans: not a PDF, image, Pascal build, glTF/GLB, IFC or geometry JSON |
file_too_large | 413 | /plans: over 4 MB inline, over 15 MB for a plan set or 50 MB for a model |
invalid_upload, upload_missing | 422 | /plans: storage_path is not this account's, or the upload is gone |
unreadable_design, no_levels, invalid_json, invalid_glb, invalid_ifc | 422 | /plans: the file could not be read as a building |
unsupported_design | 422 | /plans: read, but outside what the engine prices (storeys, floor area) |
no_frame | 404 | /quotes/{id}/frame or /model on a quote made from parameters, not drawings |
rate_limited | 429 | /plans: more than 40 reads on one key in an hour |
internal_error | 500 | Unexpected server error |
#Endpoints
#POST /api/v1/quotes
Create a quote. The engine evaluates all active fabricators, selects the lowest landed cost, and returns pricing valid for 14 days.
Request body
{
"type": "residential",
"footprint_sqft": 1200,
"stories": 1,
"wall_linear_ft": 140,
"ceiling_heights_ft": [9],
"roof": {
"type": "gable",
"pitch": "6:12"
},
"openings": {
"windows": 8,
"doors": 3
},
"gauge_spec": "18ga",
"load_zone": {
"snow_psf": 40,
"wind_mph": 90,
"seismic": "B"
},
"delivery": {
"zip": "80202",
"target_date": "2026-09-01"
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
type | residential | commercial | adu | ✓ | |
footprint_sqft | number | ✓ | 100–50000 |
stories | integer | ✓ | 1–4 |
wall_linear_ft | number | ✓ | Perimeter of exterior walls |
ceiling_heights_ft | number[] | ✓ | One entry per story; each 7–14 ft |
roof.type | flat | shed | gable | hip | gambrel | ✓ | |
roof.pitch | string | ✓ | "N:12" format, e.g. "6:12" or "0:12" for flat |
openings.windows | integer | ✓ | ≥ 0 |
openings.doors | integer | ✓ | ≥ 0 |
gauge_spec | 25ga | 20ga | 18ga | 16ga | ✓ | Steel gauge for the project |
load_zone.snow_psf | number | 1.08× tonnage multiplier if > 50 | |
load_zone.wind_mph | number | 1.05× tonnage multiplier if > 130 | |
load_zone.seismic | A–E | 1.04× tonnage multiplier if D or E | |
delivery.zip | string | ✓ | 5-digit US zip code |
delivery.target_date | YYYY-MM-DD | Informational; not used in pricing | |
lead.label | string | Your name for this client or job, max 120. Shown on your dashboard and, once reserved, to the fabricator | |
lead.model | string | Home model or product name, max 120 | |
lead.external_id | string | Your own record id, max 120 | |
lead.customer | object | name, email, phone, company. Used as the project contact when you reserve without one |
load_zonemultipliers apply toestimated_tonsonly. Since pricing is driven by square footage rather than tonnage, supplying load zone values does not currently change the quoted price.
Response 201
{
"quote_id": "qte_ab12cd34ef56gh78",
"status": "priced",
"fabricator": {
"id": "fab_op_7c2e91a4",
"name": "Mountain West Fabricator",
"location": "UT",
"state": "UT",
"distance_miles": 421,
"lead_time_days": 21
},
"pricing": {
"steel": 24900.00,
"transport": 3036.00,
"engineering": 9400.00,
"subtotal": 37336.00,
"range": { "low": 33602.00, "high": 41070.00 },
"confidence_band_pct": 10,
"valid_until": "2026-07-15T23:04:35.000Z"
},
"wholesale": {
"steel": 24900.00,
"transport": 3036.00,
"engineering": 9400.00,
"subtotal": 37336.00,
"range": { "low": 33602.00, "high": 41070.00 }
},
"markup_pct": 0,
"line_items": [
{ "description": "Steel framing: 4,800 sqft", "qty": 4800, "unit": "sqft", "rate": 5.19, "amount": 24900.00 },
{ "description": "Transport: 4 trucks × ~421 road miles", "qty": 421, "unit": "mile", "rate": 7.21, "amount": 3036.00 },
{ "description": "Modeling, detailing & engineering, medium job (flat)", "qty": 1, "unit": "flat", "rate": 9400, "amount": 9400.00 }
],
"estimated_tons": 5.7,
"truck_count": 4
}
pricing is what you charge your buyer. wholesale is your cost, and markup_pct is the margin configured on your developer account, the two blocks are identical when no margin is set. Job records on the fabricator side always carry the wholesale figures.
fabricator.name is the shop's regional alias, not its name; see Fabricator identity.
range is the low/high spread across the winning fabricator's rate range; subtotal is its midpoint. confidence_band_pct is half that spread as a percentage of the midpoint, clamped to 5–15. A value of 10 means the actual cost is expected to land within ±10% of subtotal.
truck_count is the number of trucks the delivery needs (1 truck per 1,300 sqft of total floor area). estimated_tons is an informational weight estimate, the price itself is driven by square footage, not tonnage.
#POST /api/v1/plans
A quote from drawings. Send a PDF plan set, an image, a Pascal build or GLB, an IFC, a glTF or geometry JSON, with the build site zip; Gage reads it, frames the building in steel, prices it across the network, and returns the POST /quotes body under quote together with design (what was read) and frame (the steel frame GLB, counts, tonnage, flags and the massing preview; the member-level scene follows once a Gage fabricator has confirmed the job, see Building with Gage fabricators). Requires a developer account key. Multipart file up to 4 MB, or JSON with url (public https) or storage_path (from /plans/uploads). Optional type, gauge_spec, min_confidence (default 0.7), model_height_ft, lead.
resolution is quoted (201), review (200, read but below min_confidence; quote is null, the frame's counts are inline without URLs), or reading (202, a large set: send the request again with reading.file_id, mime and filename after retry_after_s). The walkthrough, the file table and a full response are in Quoting from drawings.
#POST /api/v1/plans/uploads
{ "filename": "...", "size": <bytes> } returns { "upload_url", "storage_path", "token" }. PUT the bytes to upload_url, then POST /plans with the storage_path. Plan sets up to 15 MB, models up to 50 MB; read once and deleted.
#GET /api/v1/quotes/{quote_id}/frame
The steel frame of a quote made from drawings, as a GLB (model/gltf-binary), with x-gage-frame-view: frame. ?view=massing returns the approximate building shape (x-gage-frame-view: massing). Both are rebuilt from what the quote keeps, so they are stable and cacheable. 404 no_frame for a quote made from parameters.
#GET /api/v1/quotes/{quote_id}/model
The same frame as JSON: frame (summary, flags, the cfs take-off, the URLs, released, and the Pascal scene once released), design, assumptions and consistency. Poll it after the fabricator confirms to pick up the scene.
#GET /api/v1/quotes/{quote_id}
Retrieve a quote by ID. If the quote is past valid_until and still in priced status, it is lazily transitioned to expired on read.
curl https://buildwithgage.com/api/v1/quotes/qte_ab12cd34ef56gh78 \
-H "Authorization: Bearer $GAGE_API_KEY"
Returns the same shape as the create response, with status reflecting the current state. distance_miles is not included on retrieval.
#POST /api/v1/quotes/{quote_id}/accept
Trigger the build. Works on a priced quote (creates the project) or a reserved one (the reservation becomes the job under the same ref_number). The fabricator is awarded the job and their 48-hour confirmation window opens. Returns 409 if already accepted/superseded, 410 if expired.
A contact is required, the fabricator needs someone to reach about the job.
Request body
{
"contact": {
"name": "Dana Reyes",
"email": "dana@example.com",
"phone": "555-0142",
"company": "Reyes Builders"
},
"project_name": "Cedar Street ADU",
"notify_customer": false,
"deposit": { "received": true, "note": "$5,000 on 2026-09-12" }
}
| Field | Type | Required | Notes |
|---|---|---|---|
contact.name | string | ✓ | |
contact.email | string | ✓ | Must be a valid email address |
contact.phone | string | ||
contact.company | string | ||
project_name | string | Max 120 chars. Defaults to lead.label, then lead.model, then API project {quote_id} | |
notify_customer | boolean | Default true. Set false to keep Gage from emailing your customer a project link when you own that relationship | |
deposit.received | boolean | Recorded on the project and shown to the fabricator. Gage does not collect or hold money | |
deposit.note | string | Max 500 chars |
curl -X POST https://buildwithgage.com/api/v1/quotes/qte_ab12cd34ef56gh78/accept \
-H "Authorization: Bearer $GAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"contact":{"name":"Dana Reyes","email":"dana@example.com"}}'
Response 201
{
"job_id": "job_xy98zw76uv54ts32",
"quote_id": "qte_ab12cd34ef56gh78",
"ref_number": "GGE-2026-4817",
"status": "accepted",
"fabricator": {
"id": "fab_op_7c2e91a4",
"name": "Mountain West Fabricator",
"location": "UT"
},
"total": 37336.00,
"wholesale_total": 37336.00,
"markup_pct": 0
}
ref_number is the Gage project reference your customer and the fabricator both see. total is retail (what your buyer agreed to); wholesale_total is your cost. The shop is still its alias here: its name appears on the job once it has confirmed.
Quotes created before fabricator routing existed cannot be accepted and return 409 quote_not_acceptable, create a new quote instead.
#POST /api/v1/quotes/{quote_id}/reserve
Hold the quoted fabricator for this client without buying yet. A Gage project is created in reserved; the fabricator is emailed the job description and can see it in their portal, with nothing owed by anyone. Trigger it later with /accept (same ref_number) or let it go with /release.
The body is optional. Without a contact, the project carries lead.customer from the quote, or your account name.
{
"contact": { "name": "Dana Reyes", "email": "dana@example.com" },
"project_name": "Cedar Street ADU",
"notes": "Client signing next week."
}
Response 201
{
"quote_id": "qte_ab12cd34ef56gh78",
"ref_number": "GGE-2026-4817",
"status": "reserved",
"fabricator": { "id": "fab_op_7c2e91a4", "name": "Mountain West Fabricator", "location": "UT" }
}
Errors: 409 already_reserved, 409 quote_not_reservable (not priced), 410 quote_expired. A reservation does not stop the quote's valid_until clock; a reserved quote that has lapsed is still accepted, but re-quote if the price matters.
#POST /api/v1/quotes/{quote_id}/send
Send the customer a hosted quote page in your brand. The page is frozen as of now, the customer is emailed the link, and the quote's stage becomes quoted. Live keys only. Body is optional: { "email": "...", "name": "...", "note": "..." }; without email, the lead's customer address is used (422 missing_email if there is none).
Response 201: the document (see below) plus url and emailed. Errors: 409 quote_not_sendable, 410 quote_expired.
#GET /api/v1/quotes/{quote_id}/document
The most recent quote page for the quote: its frozen snapshot (brand, customer, project, retail line items, total, range, validity, fabricator if shown, your note), url, status (sent, viewed, accepted, declined), sent_to, sent_at, first_viewed_at, view_count, accepted_at, declined_at, decline_reason, ref_number and tracking_url once accepted, and expired. 404 if no page has been sent. See Quote pages.
#POST /api/v1/quotes/{quote_id}/release
Release a reservation. The held project is removed, the fabricator is told, and the quote returns to priced (or expired if valid_until has passed). Returns 409 not_reserved if the quote is not reserved or the project has already moved on.
Response 200
{ "quote_id": "qte_ab12cd34ef56gh78", "status": "priced", "released_ref_number": "GGE-2026-4817" }
#GET /api/v1/quotes
Your account's quotes, newest first, across all of its keys. ?status=priced|reserved|accepted|expired filters; ?limit= caps the page (default 50, max 200).
{
"quotes": [
{
"quote_id": "qte_ab12cd34ef56gh78",
"status": "reserved",
"created_at": "2026-09-10T18:02:11.000Z",
"valid_until": "2026-09-24T18:02:11.000Z",
"fabricator_id": "fab_op_7c2e91a4",
"total": 41069.60,
"wholesale_total": 37336.00,
"sqft_total": 1200,
"delivery_zip": "80202",
"lead": { "label": "Reyes / Cedar Street", "model": "Grange Garage I", "customer": { "name": "Dana Reyes", "email": "dana@example.com" } },
"ref_number": "GGE-2026-4817"
}
]
}
?external_id= returns the quotes made for one of your lead ids.
#POST /api/v1/leads
A lead as your lead system has it, turned into a quote. Gage fills in the framing parameters from a registered model or the documented defaults and reports every assumption. Requires a key that belongs to a developer account.
{
"external_id": "brl_8f3a2c1e",
"customer": { "name": "Dana Reyes", "email": "dana@example.com" },
"model": "grange-garage-i-long",
"model_name": "Grange Garage I (Long)",
"square_footage": 762,
"zip": "83702",
"timeframe": "3-6-months",
"budget": "Under $250K",
"source": "/homes/grange-garage-i-long"
}
| Field | Type | Required | Notes |
|---|---|---|---|
external_id | string | ✓ | Your id for the lead, max 120. Idempotent: the same id returns the same quote |
customer | object | name, email, phone, company | |
model | string | Registered model slug, or a name that slugifies to one | |
model_name | string | Display name, kept on the quote | |
label | string | Overrides the generated "customer / model" label | |
square_footage | number | Total across floors. Required unless the model supplies a size | |
stories | integer | ||
zip | string | 5 digits. Falls back to the account's default_delivery_zip, else 422 missing_zip | |
budget, timeframe, source | string | Kept on the quote for your dashboard | |
framing | object | Any POST /quotes framing field, overriding the model and the defaults |
Response 201 (or 200 with Idempotent-Replayed: true): the POST /quotes body plus model ({slug, name} or null), assumptions (each defaulted field and the value used), zip_assumed, and "stage": "lead".
Stage. Every quote carries a stage, separate from status: lead means Gage priced it from a lead and nobody has seen the number; quoted means the price was presented. POST /quotes starts at quoted. Move a lead on with PATCH /api/v1/quotes/{quote_id} and { "stage": "quoted" } (409 unless the quote is priced). GET /quotes and the quote.created webhook include stage.
Defaults, in order of precedence and with their values, are listed in Connecting a lead system.
#Models: GET /api/v1/models, PUT and DELETE /api/v1/models/{slug}
Your catalogue with the framing parameters a quote needs. PUT body: { "name": "...", "params": { ...any POST /quotes framing fields, plus square_footage } }; every parameter is optional. Slugs are lowercase letters, digits and hyphens. GET /models lists them; DELETE returns 204.
#GET /api/v1/jobs/{job_id}
Retrieve a job with its full status history and embedded quote summary.
curl https://buildwithgage.com/api/v1/jobs/job_xy98zw76uv54ts32 \
-H "Authorization: Bearer $GAGE_API_KEY"
Response 200
{
"job_id": "job_xy98zw76uv54ts32",
"status": "in_production",
"status_history": [
{ "status": "accepted", "at": "2026-07-01T23:10:00.000Z", "ref_number": "GGE-2026-4817" }
],
"created_at": "2026-07-01T23:10:00.000Z",
"fabricator": {
"id": "fab_op_7c2e91a4",
"name": "Wasatch Steel Fabrication",
"location": "Salt Lake City, UT",
"confirmed": true
},
"project": {
"ref_number": "GGE-2026-4817",
"name": "Cedar Street ADU",
"portal_status": "in_production",
"fabricator_confirmed": true
},
"quote": {
"quote_id": "qte_ab12cd34ef56gh78",
"status": "accepted",
"subtotal": 37336.00,
"confidence_band_pct": 10,
"valid_until": "2026-07-15T23:04:35.000Z"
},
"schedule": {
"queue_position": 3,
"state": "scheduled",
"production_start_week": "2026-10-19",
"production_finish_week": "2026-10-26",
"ship_week": "2026-11-02",
"pinned": false,
"readiness": { "deposit": true, "drawings": true, "delivery_window": true, "site_access": true },
"readiness_outstanding": []
}
}
status is read live from the fabricator portal once a job is linked to a project, so it can advance without any call from you. Statuses: accepted → in_production → delivered.
status_history records transitions Gage itself made; project.portal_status is the fabricator's own current state. project is null for jobs not linked to a portal project. fabricator_confirmed reflects whether the fabricator has paid the Gage fee and committed to the job.
fabricator.name is the alias until fabricator.confirmed is true, and the shop's company name after: this is the only place the API names a shop. See Fabricator identity.
schedule is the job's place in the fabricator's production queue, null until the shop has confirmed. Weeks are Mondays (YYYY-MM-DD). state is in_production; scheduled (the weeks are projected from the shop's queue and weekly capacity, and move as the queue moves; pinned is true when the shop has promised the start week); waiting_on_readiness (no dates until the checklist is done); or no_capacity_set (the shop has not set a weekly capacity, so it confirms dates with the customer directly). readiness is the pre-production checklist, readiness_outstanding what is left. Every change fires job.schedule_changed.
#POST /api/v1/jobs/{job_id}/readiness
Tick or untick an item on the job's pre-production checklist for your customer. The job gets production and ship weeks once every item is done.
curl -X POST https://buildwithgage.com/api/v1/jobs/job_xy98zw76uv54ts32/readiness \
-H "Authorization: Bearer $GAGE_API_KEY" -H "Content-Type: application/json" \
-d '{ "item": "site_access", "done": true }'
item is one of deposit (the deposit is paid; yours to confirm if you collect it, otherwise the fabricator's), drawings (the customer approved the shop drawings), delivery_window (a delivery week is agreed) and site_access (truck access and a place to set panels down). Returns { job_id, schedule } as it now stands. 409 not_confirmed before the fabricator has confirmed the job.
#GET /api/v1/fabricators
List active fabricators, by alias and state. Does not include rate data, names, cities or ZIP codes.
curl https://buildwithgage.com/api/v1/fabricators \
-H "Authorization: Bearer $GAGE_API_KEY"
Response 200
{
"fabricators": [
{
"id": "fab_op_3d81f0b2",
"name": "Southeast Fabricator",
"city": null,
"state": "FL",
"lead_time_days": 21,
"accepting_jobs": true,
"max_job_sqft": null
},
{
"id": "fab_op_7c2e91a4",
"name": "Mountain West Fabricator",
"city": null,
"state": "UT",
"lead_time_days": 21,
"accepting_jobs": true,
"max_job_sqft": 12000
}
]
}
Fabricator IDs are fab_op_ + the fabricator's account id. Aliases are distinct within the list; see Fabricator identity.
accepting_jobs is false when a fabricator has paused intake from their dashboard, or has no steel rates on file. A paused shop keeps its rates and returns to routing when it resumes. max_job_sqft is the largest total floor area (footprint_sqft × stories) that shop will take; null means no ceiling. Quotes are never routed to a paused shop or to one whose ceiling the job exceeds.
lead_time_days is the fabricator's real lead time for a typical 2,000 sq ft job: the ship date of a new job at the back of its production queue, worked out from its weekly capacity and confirmed work. A shop that has not set a capacity shows the network default of 21. A quote's fabricator.lead_time_days is the same figure for that quote's own floor area. A shop booked out beyond its own limit is not routed new jobs, and when two shops are close on price the one with open weeks is preferred.
#GET /api/v1/pricing/rates
Rate ranges by region across the Gage network, for cost modelling. Each region reports the lowest and highest rate among its shops; a region with a single shop is left out, since its range would be that shop's card. For a figure for your job, quote it.
Live keys only. A test key receives 403 live_key_required; every other endpoint works with either. Quotes, jobs and accepts are scoped to the developer account that created the quote: another account's quote id returns 404 not_found.
curl https://buildwithgage.com/api/v1/pricing/rates \
-H "Authorization: Bearer $GAGE_API_KEY"
Response 200
{
"regions": [
{
"region": "Mountain West",
"fabricators": 3,
"steel_per_sqft": { "low": 4.75, "high": 5.90 },
"transport_per_mile": { "low": 6.40, "high": 8.25 },
"engineering_flat_by_job_size": {
"small": { "low": 4200, "high": 6000 },
"medium": { "low": 8200, "high": 11000 },
"large": { "low": 14000, "high": 19500 }
},
"tiers": { "small_max_sqft": 2500, "medium_max_sqft": 7500 }
}
],
"rates": [
{
"fabricator_id": null,
"name": "Mountain West Fabricators",
"city": null,
"state": null,
"region": "Mountain West",
"fabricators": 3,
"steel_per_sqft": { "low": 4.75, "high": 5.90 },
"transport_per_haul": { "low": 6.40, "high": 8.25 },
"engineering_flat_by_job_size": {
"small": { "max_sqft": 2500, "low": 4200, "high": 6000 },
"medium": { "max_sqft": 7500, "low": 8200, "high": 11000 },
"large": { "max_sqft": null, "low": 14000, "high": 19500 }
}
}
],
"note": "Ranges across the Gage fabricators in each region. A quote prices your job at one shop; POST /quotes for a figure."
}
transport_per_mile is per loaded mile per truck. Job size tiers are each shop's own; tiers averages them, so the engineering ranges are indicative.
rates is the same regions in the row shape this endpoint used when it listed shops, kept so existing clients keep parsing. Rows no longer point at a shop: fabricator_id, city and state are null and name is the region's. New code should read regions.
#Pricing model
Quotes are priced against the live fabricator network, using each fabricator's own posted rates.
Steel = total floor area (footprint_sqft × stories) × the operator's per-sqft rate. This covers material and fabrication together.
Transport = per-loaded-mile rate × road miles × truck count. Road miles are haversine distance × 1.25; trucks are 1 per 1,300 sqft of total floor area. Coverage is nationwide — distance raises the transport price rather than blocking the quote.
Engineering (MDE) — modeling, detailing and engineering — is a flat fee chosen by job-size tier. Each operator sets its own small and medium sqft thresholds; anything above medium is large.
Ranges and the confidence band. Every operator rate is a low/high range. The quote prices the midpoint of each component; range is the sum of the lows and the sum of the highs. confidence_band_pct is half that spread as a percentage of the midpoint, clamped to 5–15.
Routing prices every operator with steel rates on file and selects the lowest subtotal, breaking ties by distance. Operators without rates are recorded as ineligible rather than being priced.
Tonnage (estimated_tons) is reported for planning only and does not enter the price. It is estimated from wall linear footage × ceiling height × gauge weight (lb/ft stud at 24" o.c., +15% for track/blocking), plus floor framing for upper stories and roof framing scaled by type and pitch, with load zone multipliers (snow, wind, seismic) stacking multiplicatively.
Earlier versions of v1 priced from per-ton material rates, a per-sqft fabrication rate chosen by complexity tier, and a separate freight minimum. That model is retired; the
quotestable still carries its column names (material= steel,freight= transport,engineering= MDE,fabricationunused).
#Webhooks
Register an endpoint from the Integration section of the developer dashboard. Gage POSTs a JSON event to it, signed with the endpoint's secret, whenever a quote or job on your account changes. Events: quote.created, quote.sent, quote.viewed, quote.accepted, quote.declined, quote.reserved, quote.released, job.triggered, job.status_changed, job.reassigned, job.schedule_changed, and test.ping from the dashboard. The four quote.sent through quote.declined events are the customer's quote page; quote.accepted is followed by quote.reserved for the same quote.
{
"id": "6d4f2a…",
"event": "job.triggered",
"created_at": "2026-09-14T18:40:12.000Z",
"data": {
"quote_id": "qte_ab12cd34ef56gh78",
"external_id": "brl_8f3a2c1e",
"job_id": "job_xy98zw76uv54ts32",
"ref_number": "GGE-2026-4817",
"status": "accepted",
"fee_due_by": "2026-09-16T18:40:12.000Z",
"fabricator": { "id": "fab_op_7c2e91a4", "name": "Mountain West Fabricator", "location": "UT" },
"total": 41069.60,
"wholesale_total": 37336.00,
"deposit_received": true
}
}
job.status_changed and job.reassigned carry fabricator.confirmed, and name the shop once it is true. job.status_changed and job.schedule_changed carry the job's schedule (as on GET /jobs/{id}); job.schedule_changed fires whenever the projected production or ship week, the place in the queue or the readiness checklist changes, including when other jobs ahead of it move.
Headers: Gage-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">, Gage-Event, Gage-Delivery-Id. Answer 2xx within ten seconds; otherwise Gage retries after 1 minute, 10 minutes, 1 hour, 6 hours and 24 hours. Deliveries are at-least-once. Verification code and the full event list are in Connecting a lead system.
#Rate limits
No hard rate limits are enforced in v1. Excessive usage may result in key deactivation. Request higher throughput SLAs at https://buildwithgage.com/developers.
#MCP server
The quoting engine is also available as an MCP tool at https://buildwithgage.com/mcp, so an AI assistant or agent can hand Gage a floor plan or 3D model and get a quote plus a panel schedule back. It uses the same API keys documented above. Setup for Claude, Cursor, VS Code and OpenAI is on the MCP server page.
#Idempotency
POST /quotes accepts an Idempotency-Key header (up to 200 characters). A second request with the same key on the same API key returns the first quote with 200 and an Idempotent-Replayed: true header instead of pricing again. POST /leads is idempotent on the lead's external_id without a header. Keys live as long as the quote does.
/reserve, /release and /accept are naturally idempotent in effect: a second call answers 409 describing the state the quote is already in.
#Changelog
| Version | Date | Notes |
|---|---|---|
| v1 | 2026-09-27 | Production queue: schedule on GET /jobs/{id} and on job.status_changed; new job.schedule_changed event; POST /jobs/{id}/readiness. lead_time_days is each shop's real lead time from its queue (21 until a shop sets a weekly capacity). |
| v1 | 2026-09-25 | frame.url serves the steel frame GLB for every quote from drawings again, as before 2026-09-24. frame.scene and the panel schedule are still released once a Gage fabricator has confirmed the job. |
| v1 | 2026-09-24 | Building with Gage fabricators: the framed scene and steel frame GLB are released once a Gage fabricator has confirmed the job (frame.released; until then frame.url serves the massing and frame.scene is null); fabricator.location is the state until the reveal; /fabricators city is null; /pricing/rates is ranges by region under regions, with rates kept in its old row shape, one row per region; the MCP package's panel_schedule is empty and it carries panel totals. No field removed or retyped |
| v1 | 2026-09-20 | Fabricators are regional aliases on every response, event, listing and quote page; the name appears on GET /jobs/{id} and job.* events once the shop has confirmed (fabricator.confirmed). /fabricators drops zip; fabricator.state added to quotes |
| v1 | 2026-09-20 | Drawings in: POST /plans and /plans/uploads, GET /quotes/{id}/frame (GLB) and /model (scene and take-off), the widget's read-frame-price pipeline behind an API key |
| v1 | 2026-09-15 | Quote pages: POST /quotes/{id}/send, GET /quotes/{id}/document, events quote.sent, quote.viewed, quote.accepted, quote.declined; stage on quotes with PATCH /quotes/{id}; test keys may no longer reserve or accept |
| v1 | 2026-09-14 | Lead systems: POST /leads, GET/PUT/DELETE /models, Idempotency-Key on POST /quotes, ?external_id= on GET /quotes, signed webhooks with retries |
| v1 | 2026-09-14 | Reservations: POST /quotes/{id}/reserve and /release, GET /quotes, lead on quote creation, notify_customer and deposit on accept, accept of a reserved quote keeps its ref_number. /pricing/rates is live-key only; id lookups are scoped to your account |
| v1 | 2026-09-03 | Docs corrected to match the deployed API: operator-network pricing (steel/transport/engineering + range, replacing material/fabrication/freight), required contact on accept, live job status from the fabricator portal, current /fabricators and /pricing/rates shapes |
| v1 | 2026-07-01 | Initial release: quotes, jobs, fabricators, rates |