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:

  • assumptions lists every framing value Gage had to fill in, and what it used.
  • zip_assumed is 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:

  1. framing.<field> on the lead itself
  2. the registered model's params
  3. the account default, for gauge and ZIP
  4. the built-in default below
FieldBuilt-in default
typeresidential
stories1
footprint_sqftsquare_footage ÷ stories
wall_linear_ftperimeter of a square of the footprint, times 1.15
ceiling_heights_ft9 ft per story
roofgable, 6:12
openingsone window per 120 sqft (at least 2), doors = stories + 1
gauge_spec18ga
delivery.zipthe 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:

EventWhen
quote.createdA quote was priced, from POST /quotes or POST /leads
quote.reservedYou reserved a fabricator; a project reference now exists
quote.releasedYou released the reservation
job.triggeredYou triggered the build; the fabricator is awarded the job
job.status_changedThe fabricator confirmed, started production, shipped, or delivered
job.reassignedThe job moved to another fabricator, went back to selection, or ran out of shops
job.schedule_changedThe job's projected production or ship week, place in the fabricator's queue, or readiness checklist changed
test.pingYou 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

  1. On your dashboard, under Lead defaults, set a default delivery ZIP. Without it every lead has to carry its own.
  2. Under Models, register each thing your leads can name, with at least its square footage.
  3. Under Webhooks, add your endpoint with no events selected (all events). Copy the secret; it is shown once.
  4. In your own environment, store the API key and the webhook secret. Never put either in browser code.
  5. Press Send test on the endpoint. Delivered with HTTP 200 means your signature check works.
  6. Submit one real lead through your form and watch it appear in the Clients panel.

#Sequence, end to end

  1. Your form saves the lead and calls POST /leads with the lead's id. Store the returned quote_id.
  2. Your dashboard, or the Clients panel on Gage, shows the quote, the fabricator, and the price at your margin.
  3. When the customer is real, reserve. quote.reserved arrives with a ref_number.
  4. When money has moved on your side, trigger with the deposit flag. job.triggered arrives.
  5. job.status_changed arrives as the fabricator confirms, builds, ships and delivers.