Docs / Partners
Connecting a lead system
Turn every lead your site captures into a Gage quote automatically, and get status pushed back.
Your lead form captures a name, an email, and a home model. A framing quote needs a footprint, stories, wall run, roof, ceiling heights and a delivery ZIP. This page is about the gap between those two, and about keeping your lead record current once a quote exists.
Three pieces do it: register your models once, send each lead with one call, and receive webhooks when anything changes. All three can be set up from the Integration section of your dashboard as well as from the API.
#1. Register your models
A registered model is a slug, a name, and whichever framing parameters you know. Register the Grange Garage once:
curl -X PUT https://buildwithgage.com/api/v1/models/grange-garage-i-long \
-H "Authorization: Bearer $GAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Grange Garage I (Long)",
"params": {
"type": "residential",
"footprint_sqft": 762,
"stories": 1,
"wall_linear_ft": 130,
"ceiling_heights_ft": [10],
"roof": { "type": "gable", "pitch": "6:12" },
"openings": { "windows": 2, "doors": 2 },
"gauge_spec": "18ga"
}
}'
Every parameter is optional. square_footage (total across floors) is accepted as well as footprint_sqft. A lead that names this model, by slug or by a name that slugifies to it, is priced from these values. GET /api/v1/models lists what you have registered; DELETE /api/v1/models/{slug} removes one.
#2. Send each lead
When a lead comes in, send it as it is:
curl -X POST https://buildwithgage.com/api/v1/leads \
-H "Authorization: Bearer $GAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"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"
}'
The response is the same shape as POST /quotes, plus two fields:
assumptionslists every framing value Gage had to fill in, and what it used.zip_assumedis true when the delivery ZIP came from your account default rather than the lead.
A lead made this way carries "stage": "lead": Gage has priced it, but nobody has seen the number. On the dashboard it sits in the Leads tab with no price on the row; the internal estimate is inside the row, marked as not shown to the customer. When you actually present the price to your customer, move it on with PATCH /api/v1/quotes/{quote_id} and body { "stage": "quoted" }, or press Mark quoted on the panel. A quote made directly with POST /quotes starts at quoted, since asking for it means someone wanted the number. Reserving or triggering works from either stage.
external_id is your own id for the lead. Sending it again returns the same quote with a 200 and an Idempotent-Replayed: true header, so a retried request or a double-submitted form never makes two quotes. To find a lead's quote later, GET /api/v1/quotes?external_id=brl_8f3a2c1e.
#What a lead needs before Gage will list it
Every row in your Clients panel is a quote, and a quote needs two things: a size and a place. Size comes from square_footage on the lead, or from the registered model's square_footage or footprint_sqft. Place comes from zip on the lead or the account's default delivery ZIP. A lead with neither source of size is refused with 422 missing_square_footage; one with no ZIP anywhere with 422 missing_zip. Neither is stored on Gage, so if your form does not collect a size, register your models before you connect, or look the size up in your own catalogue and send it with the lead.
Leads stay in the panel after their 14-day quote validity lapses; switch the filter to "all" to see them.
#Where each framing value comes from
Per field, the first of these that exists wins:
framing.<field>on the lead itself- the registered model's
params - the account default, for gauge and ZIP
- the built-in default below
| Field | Built-in default |
|---|---|
type | residential |
stories | 1 |
footprint_sqft | square_footage ÷ stories |
wall_linear_ft | perimeter of a square of the footprint, times 1.15 |
ceiling_heights_ft | 9 ft per story |
roof | gable, 6:12 |
openings | one window per 120 sqft (at least 2), doors = stories + 1 |
gauge_spec | 18ga |
delivery.zip | the account's default_delivery_zip, else the lead is refused with missing_zip |
A quote made from defaults is a real quote against real fabricator rates, but its wall run and openings are estimates. Register the model to remove the estimate, or send framing on the lead when your form collects more.
#3. Receive webhooks
Add an endpoint from the Integration section of your dashboard. You get a signing secret once; store it. Gage then POSTs a JSON body to the endpoint whenever one of these happens:
| Event | When |
|---|---|
quote.created | A quote was priced, from POST /quotes or POST /leads |
quote.reserved | You reserved a fabricator; a project reference now exists |
quote.released | You released the reservation |
job.triggered | You triggered the build; the fabricator is awarded the job |
job.status_changed | The fabricator confirmed, started production, shipped, or delivered |
job.reassigned | The job moved to another fabricator, went back to selection, or ran out of shops |
job.schedule_changed | The job's projected production or ship week, place in the fabricator's queue, or readiness checklist changed |
test.ping | You pressed Send test on the dashboard |
The body:
{
"id": "6d4f...",
"event": "job.status_changed",
"created_at": "2026-09-14T18:40:12.000Z",
"data": {
"ref_number": "GGE-2026-4817",
"quote_id": "qte_ab12cd34ef56gh78",
"external_id": "brl_8f3a2c1e",
"project_name": "Reyes / Cedar Street",
"fabricator": { "id": "fab_op_7c2e91a4", "name": "Mountain West Fabricator", "location": "UT", "confirmed": false },
"status": "shipped",
"portal_status": "shipped",
"deposit_received": true
}
}
Every data object carries quote_id and, when the quote came from a lead, your external_id, so your handler can find its own record without a lookup.
#Verifying the signature
Each request has a Gage-Signature header of the form t=<unix seconds>,v1=<hex>. The hex is an HMAC-SHA256 of "<t>.<raw body>" with your secret. Recompute it over the raw body exactly as received, compare in constant time, and reject a timestamp more than five minutes old.
import crypto from 'node:crypto'
export function verifyGageSignature(secret: string, rawBody: string, header: string | null): boolean {
if (!header) return false
const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')))
const t = Number(parts.t)
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
return expected.length === parts.v1?.length
&& crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex'))
}
Answer with any 2xx within ten seconds. Anything else, or no answer, is retried after 1 minute, 10 minutes, 1 hour, 6 hours and 24 hours, then marked failed. The dashboard shows the last fifteen deliveries per endpoint with their outcome. Deliveries are at-least-once: make your handler safe to run twice for the same id.
Other headers: Gage-Event (the event name) and Gage-Delivery-Id (matches id in the body).
#Email as well as webhooks
The same events can reach a person. Under Lead defaults and notifications on your dashboard, three toggles send mail to the account's contact address: new lead priced (off by default, since your own site usually tells you), reservation, release or build triggered (on), and fabricator progress or reassignment (on). Webhooks fire whatever the toggles say.
#4. What to store on your side
The minimum is quote_id against your lead, written when POST /leads returns. Everything after that arrives by webhook keyed on your external_id. If you also store ref_number from quote.reserved or job.triggered, your staff can find the job in conversation with the fabricator.
#Retrying safely
POST /quotes accepts an Idempotency-Key header. Two requests with the same key on the same API key return the same quote; the second answers 200 with Idempotent-Replayed: true. POST /leads uses the lead's external_id the same way without a header. Keys are kept for as long as the quote exists.
#Setting up, step by step
- On your dashboard, under Lead defaults, set a default delivery ZIP. Without it every lead has to carry its own.
- Under Models, register each thing your leads can name, with at least its square footage.
- Under Webhooks, add your endpoint with no events selected (all events). Copy the secret; it is shown once.
- In your own environment, store the API key and the webhook secret. Never put either in browser code.
- Press Send test on the endpoint. Delivered with HTTP 200 means your signature check works.
- Submit one real lead through your form and watch it appear in the Clients panel.
#Sequence, end to end
- Your form saves the lead and calls
POST /leadswith the lead's id. Store the returnedquote_id. - Your dashboard, or the Clients panel on Gage, shows the quote, the fabricator, and the price at your margin.
- When the customer is real, reserve.
quote.reservedarrives with aref_number. - When money has moved on your side, trigger with the deposit flag.
job.triggeredarrives. job.status_changedarrives as the fabricator confirms, builds, ships and delivers.