EkstraAPI v1 · Developers

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 startedAuthenticationOAuth 2.0ScopesBusiness accountsAccounts and what they seeEndpointsLists and paginationObjectsErrorsWebhooksEvent typesVerifying signaturesDelivery and retries

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.

  1. In the Ekstra admin, open API & webhooks (Ekstra's admins, and a brand's admins for their brand).
  2. Create an API key with the scopes you need, or an OAuth app if other people will connect your app to their accounts.
  3. 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>.

CredentialLooks likeFor
API keyek_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 tokeneka_…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/authorizeThe consent screen
POST https://www.ekstrabuild.com/api/v1/oauth/tokenCodes and refresh tokens for access tokens
POST https://www.ekstrabuild.com/api/v1/oauth/revokeRevoke 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

ScopeReads
orders:readOrders and reservations: status, stage, payments and the frame's progress
leads:readLeads and quotes: who asked, for what, and the prices quoted
designs:readSaved designs: the house, its options and its layout
plans:readPlan customization requests and plan revisions
customers:readCustomer accounts: name, email and phone
brands:readThe brand's own profile and site address (Ekstra: every brand)
catalog:readThe catalogue of house models the brand offers
events:readThe event log: every change above, in order

Accounts and what they see

A credential forSees
EkstraEvery record, including Ekstra's internal fields (internal on orders, leads and customizations).
A brandThe records of the customers who signed up under that brand, and nothing else. No internal fields.
A customerTheir own records.

A record another account owns answers 404, exactly as one that does not exist.

Endpoints

GETScopeFilters
/orders, /orders/{id}orders:readcustomer_id
/reservations, /reservations/{id}orders:readcustomer_id (orders with a reservation)
/leads, /leads/{id}leads:readcustomer_id
/designs, /designs/{id}designs:readcustomer_id, expand=configuration
/customizations, /customizations/{id}plans:readcustomer_id
/plan-revisions, /plan-revisions/{id}plans:readorder_id, customization_id, customer_id, expand=configuration
/customers, /customers/{id}customers:read
/brands, /brands/{id}brands:read
/models, /models/{id}catalog:readcategory (house, garage, spaces), expand=configuration; Ekstra: brand_id
/events, /events/{id}events:readtype, resource, resource_id
/meany

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." } }
StatusCode
400invalid_param, invalid_cursor
401unauthorized: no credential, or one that is revoked or expired
403insufficient_scope
404not_found
405method_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

EventWhen
customer.created, customer.updatedAn account signs up or changes
order.created, order.updated, order.stage_changedAn order is placed, changes, or moves to another stage
reservation.paidA reservation is paid
lead.created, lead.updated, quote.createdA quote is asked for, changes, or is priced
design.created, design.updatedA design is saved
customization.created, customization.updated, customization.paidA plan customization is requested, changes, or is paid
plan_revision.created, .updated, .proposed, .approved, .declinedA plan revision is made, sent, or decided
model.created, model.updatedA model in the catalogue is added, renamed, edited or removed (data.object.removed)
brand.updatedA 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.