The Ekstra API
Read Ekstra's orders, reservations, leads and quotes, designs, plans and customers, and be told the moment they change. For white-label brands wiring Ekstra into their own tools, for Ekstra's team, and for developers building apps any Ekstra account can connect.
Getting started
Base URL: https://www.ekstrabuild.com/api/v1. Every request and response is JSON. The API is read only: nothing outside Ekstra changes Ekstra's data.
- In the Ekstra admin, open API & webhooks (Ekstra's admins, and a brand's admins for their brand).
- Create an API key with the scopes you need, or an OAuth app if other people will connect your app to their accounts.
- Add a webhook endpoint to be told when things change.
curl https://www.ekstrabuild.com/api/v1/orders?limit=10 \
-H "Authorization: Bearer ek_live_…"
Authentication
Send a credential in the Authorization header: Authorization: Bearer <token>.
| Credential | Looks like | For |
|---|---|---|
| API key | ek_live_… | Your own servers and scripts. Made in the admin for Ekstra or for one brand, with the scopes you choose. Keep it secret; revoke it in the admin. |
| OAuth access token | eka_… | An app acting for the account that connected it. Lasts one hour; refresh it with its refresh token (ekr_…, 60 days, replaced on every use). |
GET /api/v1/me tells you which account a credential acts as, and its scopes.
OAuth 2.0
Authorization code flow with PKCE (S256), refresh tokens, and client credentials. Discovery: /.well-known/oauth-authorization-server.
| Endpoint | |
|---|---|
GET https://www.ekstrabuild.com/oauth/authorize | The consent screen |
POST https://www.ekstrabuild.com/api/v1/oauth/token | Codes and refresh tokens for access tokens |
POST https://www.ekstrabuild.com/api/v1/oauth/revoke | Revoke an access or refresh token |
1. Send the person to the consent screen
https://www.ekstrabuild.com/oauth/authorize
?response_type=code
&client_id=ekc_…
&redirect_uri=https://yourapp.example/callback (registered exactly)
&scope=orders:read leads:read
&state=<random, checked on return>
&code_challenge=<base64url(SHA-256(verifier))>
&code_challenge_method=S256
They sign in to Ekstra, choose which of their accounts to connect (their own customer account, a brand they are an admin of, or Ekstra if they are an Ekstra admin) and approve. They come back to your redirect_uri with ?code=…&state=…, or with ?error=access_denied. A code lasts ten minutes and works once.
2. Trade the code for tokens
curl https://www.ekstrabuild.com/api/v1/oauth/token \
-u "ekc_…:eks_…" \
-d grant_type=authorization_code -d code=ekcode_… \
-d redirect_uri=https://yourapp.example/callback -d code_verifier=<verifier>
{ "access_token": "eka_…", "token_type": "Bearer", "expires_in": 3600,
"refresh_token": "ekr_…", "scope": "orders:read leads:read" }
A confidential app (one with a server) authenticates with its client secret, by HTTP Basic or client_id and client_secret in the body. A public app (browser or mobile) has no secret and must use PKCE.
3. Refresh
curl https://www.ekstrabuild.com/api/v1/oauth/token -u "ekc_…:eks_…" \
-d grant_type=refresh_token -d refresh_token=ekr_…
Client credentials
An organisation's own unlisted app with a secret may get a token for its owner (Ekstra, or its brand) with grant_type=client_credentials, no person involved.
Who can connect an app
An app Ekstra lists as a third-party app can be connected by any Ekstra account. An unlisted app can be connected only by its owner's own accounts: Ekstra's admins for an Ekstra app, the brand's admins for a brand's. Anyone who connected an app can disconnect it; Ekstra's admins and a brand's admins see every connection in the admin.
Business accounts
An app for builders and fabricators connects to their business account: the white-label brand their customers design and order on. A business that does not have one can make it on the way: add signup=business to the authorize link.
https://www.ekstrabuild.com/oauth/authorize?response_type=code&client_id=ekc_…&redirect_uri=…
&scope=brands:read catalog:read orders:read&state=…&code_challenge=…&code_challenge_method=S256
&signup=business
Signed out, they land on the business sign-up (/business) with your app named; signed in with no business account, the consent screen offers to set one up. Either way they come back to the consent screen, choose the new brand and approve. The sign-up links to the design studio, in a new tab, so they can try designing a structure first.
A new business account's brand is made at once with status: "pending" (approved: false): it can be connected and set up straight away, but its site stays closed until Ekstra approves it and moves it to draft or live (a brand.updated event, with approved_at set). GET /api/v1/me on a brand's token includes the brand, with its status and site_url.
Scopes
| Scope | Reads |
|---|---|
orders:read | Orders and reservations: status, stage, payments and the frame's progress |
leads:read | Leads and quotes: who asked, for what, and the prices quoted |
designs:read | Saved designs: the house, its options and its layout |
plans:read | Plan customization requests and plan revisions |
customers:read | Customer accounts: name, email and phone |
brands:read | The brand's own profile and site address (Ekstra: every brand) |
catalog:read | The catalogue of house models the brand offers |
events:read | The event log: every change above, in order |
Accounts and what they see
| A credential for | Sees |
|---|---|
| Ekstra | Every record, including Ekstra's internal fields (internal on orders, leads and customizations). |
| A brand | The records of the customers who signed up under that brand, and nothing else. No internal fields. |
| A customer | Their own records. |
A record another account owns answers 404, exactly as one that does not exist.
Endpoints
| GET | Scope | Filters |
|---|---|---|
/orders, /orders/{id} | orders:read | customer_id |
/reservations, /reservations/{id} | orders:read | customer_id (orders with a reservation) |
/leads, /leads/{id} | leads:read | customer_id |
/designs, /designs/{id} | designs:read | customer_id, expand=configuration |
/customizations, /customizations/{id} | plans:read | customer_id |
/plan-revisions, /plan-revisions/{id} | plans:read | order_id, customization_id, customer_id, expand=configuration |
/customers, /customers/{id} | customers:read | |
/brands, /brands/{id} | brands:read | |
/models, /models/{id} | catalog:read | category (house, garage, spaces), expand=configuration; Ekstra: brand_id |
/events, /events/{id} | events:read | type, resource, resource_id |
/me | any |
Lists and pagination
Lists are newest first. limit is 1 to 100 (default 25). Pass the page's next_cursor as starting_after for the next page. created_since and updated_since take an ISO time: the way to sync only what changed.
GET /api/v1/orders?updated_since=2026-09-01T00:00:00Z&limit=100
{ "object": "list", "data": [ { "object": "order", … } ], "has_more": true, "next_cursor": "MjAyNi0…" }
Objects
Every object has object, id, created_at and updated_at; records that belong to a customer carry customer_id and brand_id (null for Ekstra's own customers). Money is in cents. An order:
{ "object": "order", "id": "…", "customer_id": "…", "brand_id": null, "design_id": "…",
"name": "Woodland Glass", "product": "complete", "size_sf": 864, "stage": 2,
"stage_detail": "Permit submitted", "stalled": null, "total_cents": 21450000,
"reservation": { "status": "paid", "amount_cents": 50000, "paid_at": "2026-09-20T17:02:11Z" },
"gage": { "ekstra_lead_id": "…", "quote_id": "qte_…", "job_id": "job_…", "status": "in_fabrication",
"status_at": "…", "reference": "G-1042" },
"site": { "source": "property", "address_line1": "123 Main St", "address_line2": null, "city": "Sandy",
"state": "UT", "zip": "84070", "country": "US", "lat": 40.57, "lng": -111.86 },
"ordered_on": "2026-09-20", "created_at": "…", "updated_at": "…" }
gage is the steel frame's side in Gage's own ids. Ekstra prices, reserves and accepts the frame through Gage's API, so the Gage job is made by Ekstra: match on quote_id and job_id, and never make a second job from an Ekstra event. ekstra_lead_id is the lead's external_id at Gage (<id>-r<n> after a re-quote).
site is the build site: the property the order is for (source: "property"), or, when none is set yet, only the ZIP the order was priced for (source: "quote"). Designs carry a site the same way. A brand:
{ "object": "brand", "id": "…", "slug": "acme", "name": "Acme Homes", "status": "live", "approved": true, "package": "standard",
"custom_domain": null, "site_url": "https://acme.ekstrabuild.com", "studio_url": "https://acme.ekstrabuild.com/studio",
"logo_url": "…", "color_accent": "#16150F", "website_url": "https://acme.example", … }
A model in the brand's catalogue: Ekstra's designs as the brand has renamed, edited or removed them, and the brand's own (source: "brand"), exactly as its site offers them.
{ "object": "model", "id": "builtin:woodland-glass", "name": "Lune", "program": "one", "category": "house",
"size": "864 SF", "width_ft": 24, "depth_ft": 36, "source": "ekstra", "changed_by_brand": true,
"thumbnail_url": "…", "studio_url": "https://acme.ekstrabuild.com/studio?model=builtin%3Awoodland-glass",
"edit_url": "https://acme.ekstrabuild.com/studio?brandModel=builtin%3Awoodland-glass", "updated_at": "…" }
studio_url opens the brand’s studio on this model, for anyone designing. edit_url opens it for the brand’s admin to edit the model in the brand’s library (they sign in first); saving it there changes the model on the brand’s site and sends model.updated.
Errors
{ "error": { "code": "insufficient_scope", "message": "This needs the leads:read scope." } }
| Status | Code |
|---|---|
| 400 | invalid_param, invalid_cursor |
| 401 | unauthorized: no credential, or one that is revoked or expired |
| 403 | insufficient_scope |
| 404 | not_found |
| 405 | method_not_allowed: the API is read only |
OAuth endpoints answer in OAuth's own form: { "error": "invalid_grant", "error_description": "…" }.
Webhooks
Add an endpoint (an https URL) in the admin and choose its events, or leave them all. Ekstra POSTs each event to it:
POST https://yourapp.example/ekstra/webhooks
Content-Type: application/json
Ekstra-Signature: t=1790000000,v1=5f0c…
Ekstra-Event-Id: 2b1e… (the same on every retry: ignore repeats by it)
Ekstra-Delivery-Id: 9c7d…
{ "id": "2b1e…", "object": "event", "type": "reservation.paid",
"created_at": "2026-09-30T18:04:12Z",
"account": { "type": "brand", "id": "…" },
"data": { "object": { "object": "order", "id": "…", … } } }
data.object is the record as it is when the event is sent, as the endpoint's account sees it. On an *.updated event, data.changed lists the fields that changed. An endpoint for Ekstra gets every event; one for a brand, its customers'; one for an OAuth app, those of the accounts that connected it, within the scopes each gave.
Event types
| Event | When |
|---|---|
customer.created, customer.updated | An account signs up or changes |
order.created, order.updated, order.stage_changed | An order is placed, changes, or moves to another stage |
reservation.paid | A reservation is paid |
lead.created, lead.updated, quote.created | A quote is asked for, changes, or is priced |
design.created, design.updated | A design is saved |
customization.created, customization.updated, customization.paid | A plan customization is requested, changes, or is paid |
plan_revision.created, .updated, .proposed, .approved, .declined | A plan revision is made, sent, or decided |
model.created, model.updated | A model in the catalogue is added, renamed, edited or removed (data.object.removed) |
brand.updated | A brand's profile changes: its name, logo, domain or site address |
Filter an endpoint by exact types or by family (order.*). The admin's Send test sends a ping.
Verifying signatures
Every delivery is signed with the endpoint's secret (whsec_…, shown once when the endpoint is made). Check it before trusting the body: HMAC-SHA256 of <t>.<raw body>, hex, compared in constant time, and reject a t more than five minutes old.
Node
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, header) {
const p = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
const want = createHmac("sha256", secret).update(`${p.t}.${rawBody}`).digest("hex");
return want.length === p.v1.length && timingSafeEqual(Buffer.from(want), Buffer.from(p.v1));
}
Python
import hmac, hashlib, time
def verify(secret, raw_body: bytes, header: str) -> bool:
p = dict(kv.split("=", 1) for kv in header.split(","))
if abs(time.time() - int(p["t"])) > 300:
return False
want = hmac.new(secret.encode(), f"{p['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, p["v1"])
Use the raw request body, exactly as received: a re-serialized JSON object will not match.
Delivery and retries
Answer with any 2xx within 8 seconds; do slow work afterwards. Anything else is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, then marked failed. Fifty failures in a row switch an endpoint off; turn it back on in the admin. Deliveries are at least once and not guaranteed to arrive in order: use Ekstra-Event-Id to ignore repeats, and created_at or a fresh GET for the latest state. Events are kept for 30 days in /events.