Skip to content

API reference

Version 1. Base URL /api/v1. Every POST takes an Idempotency-Key header.

/oauth

POST /oauth/token

OAuth 2.0 client credentials: exchange a client id and secret for a short-lived bearer token

createToken200

Request

TokenRequest {
  grant_type: "client_credentials";
  client_id: string;
  client_secret: string;
  scope?: string;
}

Response

TokenResponse {
  access_token: string;
  token_type: "Bearer";
  expires_in: number;
  scope: string;
}

/markets

GET /markets

Where Ducto takes orders, and for which product lines

listMarketsscope markets:read200

Response

MarketsResponse {
  markets: Array<{
    market: string;
    state: string;
    zips: Array<string>;
    categories: Array<string>;
  }>;
}

/quotes

POST /quotes

Size a home for a product line and price it, with installer availability

createQuotescope quotes:writeIdempotency-Key201

Request

QuoteRequest {
  zip: string;
  category?: "mini_split" | "hpwh" | "ev_charger" | "panel";
  home: Record<string, unknown>;
  tier?: "diy" | "hybrid" | "turnkey";
  grade?: "value" | "mid" | "premium";
}

Response

QuoteResponse {
  quoteId: string;
  mode: "live" | "sandbox" | null;
  category: string;
  market: string;
  tier: "diy" | "hybrid" | "turnkey";
  grade: "value" | "mid" | "premium";
  system: string | null;
  sizingNotes: Array<string>;
  tiers: Array<{
    tier: "diy" | "hybrid" | "turnkey";
    eligible: boolean;
    blockers: Array<string>;
  }>;
  equipment: {
    available: boolean;
    problems: Array<string>;
    units: Array<{
      brand: string;
      series: string;
      name: string;
      warrantyYears: number | null;
    }>;
  };
  permits: {
    lines: Array<{
      kind: "mechanical" | "electrical" | "plumbing" | "building";
      pulledBy: "homeowner" | "contractor";
      feeCents: number;
    }>;
    basis: string;
  };
  installers: Array<{
    name: string;
    rating: number | null;
    reviewCount: number;
    earliestInstallDate: string;
    laborCents?: number;
    totalCents: number;
    chargedAtCheckoutCents?: number;
  }>;
  priceNote?: string;
  url: string | null;
  validDays: number;
}

/compliance

POST /compliance/checks

What a set of products needs at a ZIP: permits and who pulls them, credentials, and one panel check

checkCompliancescope compliance:readIdempotency-Key200

Request

ComplianceCheckRequest {
  zip: string;
  items: Array<{
    category: "mini_split" | "hpwh" | "ev_charger" | "panel";
    home: Record<string, unknown>;
  }>;
  electrical?: HomeElectrical;
}

Response

ComplianceCheckResponse {
  mode: "live" | "sandbox" | null;
  market: {
    name: string;
    state: string;
    feesVerified: boolean;
  };
  items: Array<{
    category: string;
    role: "anchor" | "addon";
    tier: "diy" | "hybrid" | "turnkey";
    eligible: boolean;
    blockers: Array<string>;
    permits: {
      lines: Array<{
        kind: string;
        pulledBy: string;
        feeCents: number;
      }>;
      basis: string;
      office: string | null;
    };
    credentials: Array<Array<string>>;
    notes: Array<string>;
  }>;
  panel: PanelCheck;
}

POST /compliance/plans

The compliance plan for an exact address: the permit office by boundary, permits, who pulls them, fees, inspections, licenses, siting and documents, each line citing its rule and source

createCompliancePlanscope compliance:readIdempotency-Key201

Request

CompliancePlanRequest {
  address: {
    street: string;
    city: string;
    state: string;
    zip: string;
  };
  items: Array<{
    category: "mini_split" | "hpwh" | "ev_charger" | "panel";
    home: Record<string, unknown>;
  }>;
}

Response

CompliancePlanResponse {
  id: string;
  version: number;
  mode: "live" | "sandbox" | null;
  address: AddressResolution;
  office: {
    id: string;
    name: string;
  } | null;
  basis: "boundary" | "zip" | "none";
  plan: ComplianceDecision;
  createdAt: string;
}

GET /compliance/plans/{id}

A compliance plan you requested

getCompliancePlanscope compliance:read200

Response

CompliancePlanResponse {
  id: string;
  version: number;
  mode: "live" | "sandbox" | null;
  address: AddressResolution;
  office: {
    id: string;
    name: string;
  } | null;
  basis: "boundary" | "zip" | "none";
  plan: ComplianceDecision;
  createdAt: string;
}

/homes

GET /homes/{id}

A home's record, where its owner granted you access: limited to the grant's scopes, and every read is shown to the owner

getHomescope homes:read200

Response

HomeRecord {
  id: string;
  generatedAt: string;
  scopes: Array<"home:read" | "systems:read" | "readings:read" | "permits:read" | "warranty:read" | "service:read" | "photos:read">;
  home?: {
    address: {
      street: string;
      city: string;
      state: string;
      zip: string;
    };
    placeGeoid: string | null;
    countyGeoid: string | null;
    permitOffice: string | null;
    climateZone: number | null;
    parcel: {
      id: string;
      source: string | null;
    } | null;
    previousOwners: number;
  };
  systems: Array<HomeSystem>;
}

/projects

POST /projects

Price a project (a heat pump or water heater and its add-ons) for a homeowner to finish on Ducto

createProjectscope projects:writeIdempotency-Key201

Request

ProjectRequest {
  zip: string;
  anchor: {
    category: "mini_split" | "hpwh" | "ev_charger" | "panel";
    home: Record<string, unknown>;
    tier?: "diy" | "hybrid" | "turnkey";
    grade?: "value" | "mid" | "premium";
  };
  addons?: Array<{
    category: "mini_split" | "hpwh" | "ev_charger" | "panel";
    home: Record<string, unknown>;
    tier?: "diy" | "hybrid" | "turnkey";
    grade?: "value" | "mid" | "premium";
  }>;
  electrical?: Record<string, unknown>;
  merchant?: "ducto" | "retailer";
  cart?: Array<CartLine>;
}

Response

ProjectResponse {
  id: string;
  mode: "live" | "sandbox";
  status: "quoted" | "ordered" | "expired";
  market: string;
  items: Array<{
    category: string;
    role: "anchor" | "addon";
    system: string;
    installer: string;
    installDate: string;
    totals: {
      chargeNowCents: number;
      chargeOnConfirmCents: number;
      totalCents: number;
    };
  }>;
  panel: {
    status: "ok" | "review" | "over";
    needsPanelWork: boolean;
    notes: Array<string>;
  };
  totals: {
    chargeNowCents: number;
    chargeOnConfirmCents: number;
    totalCents: number;
  };
  url: string | null;
  expiresAt: string;
  merchant?: "ducto" | "retailer";
  cart?: {
    fits: boolean;
    equipmentCents: number;
    unused: Array<string>;
  };
}

GET /projects/{id}

A project you priced

getProjectscope projects:read200

Response

ProjectResponse {
  id: string;
  mode: "live" | "sandbox";
  status: "quoted" | "ordered" | "expired";
  market: string;
  items: Array<{
    category: string;
    role: "anchor" | "addon";
    system: string;
    installer: string;
    installDate: string;
    totals: {
      chargeNowCents: number;
      chargeOnConfirmCents: number;
      totalCents: number;
    };
  }>;
  panel: {
    status: "ok" | "review" | "over";
    needsPanelWork: boolean;
    notes: Array<string>;
  };
  totals: {
    chargeNowCents: number;
    chargeOnConfirmCents: number;
    totalCents: number;
  };
  url: string | null;
  expiresAt: string;
  merchant?: "ducto" | "retailer";
  cart?: {
    fits: boolean;
    equipmentCents: number;
    unused: Array<string>;
  };
}

POST /projects/{id}/bookings

Book a project priced with merchant "retailer": a link for the homeowner to accept the installation contract

bookProjectscope projects:writeIdempotency-Key201

Request

BookingRequest {
  homeowner: {
    name: string;
    email: string;
    phone?: string;
  };
  address: {
    street: string;
    city: string;
  };
  retailerOrderRef: string;
}

Response

Booking {
  id: string;
  mode: "live" | "sandbox";
  status: "awaiting_homeowner" | "accepted" | "cancelled" | "expired";
  retailerOrderRef: string;
  url: string | null;
  projectRef: string | null;
  orders: Array<{
    ref: string;
    status: string;
    category: string;
  }>;
  cancellation: Array<string>;
  expiresAt: string;
}

/catalog

GET /catalog/products

The products Ducto installs, to map your SKUs to

listProductsscope catalog:read200

Response

ProductList {
  products: Array<{
    sku: string;
    brand: string;
    series: string;
    model: string;
    name: string;
    category: string;
    line: string;
    nominalBtu: number | null;
    ports: number | null;
    headStyle: string | null;
    coldClimate: boolean;
  }>;
}

PUT /catalog/sku-mappings

Map your own SKUs to Ducto products (retailers; used in carts and eligibility)

mapSkusscope projects:write200

Request

SkuMappings {
  mappings: Array<{
    sku: string;
    productSku: string;
  }>;
}

Response

SkuMappingResult {
  mapped: number;
  unknownProducts: Array<string>;
}

PUT /catalog/inventory

Stock and prices from a supplier (applied at once when clean; sandbox: matched, never applied)

updateInventoryscope inventory:write200

Request

InventoryUpdate {
  rows: Array<{
    supplierSku: string;
    costCents: number;
    qty: number;
    leadDays?: number | null;
    mapCents?: number | null;
    brand?: string | null;
    model?: string | null;
    name?: string | null;
    category?: string | null;
    nominalBtu?: number | null;
    imageUrl?: string | null;
    specSheetUrl?: string | null;
  }>;
  fullFeed?: boolean;
  label?: string;
}

Response

InventoryResult {
  importId: string | null;
  mode: "live" | "sandbox" | null;
  applied: boolean;
  summary: {
    rows: number;
    matched: number;
    unmatched: number;
    ignored: number;
    priceChanges: number;
    bigChanges: number;
  };
  unmatchedSkus: Array<string>;
}

/widget-sessions

POST /widget-sessions

A short-lived session for a drop-in widget (sizing, install-and-permit, booking): its token goes to the browser instead of an API key

createWidgetSessionscope quotes:writeIdempotency-Key201

Request

WidgetSessionRequest {
  kind: "sizing" | "install" | "booking";
  zip?: string;
  merchant?: "ducto" | "retailer";
  cart?: Array<CartLine>;
  projectId?: string;
  retailerOrderRef?: string;
  allowedOrigins?: Array<string>;
  theme?: WidgetTheme;
}

Response

WidgetSessionResponse {
  id: string;
  kind: "sizing" | "install" | "booking";
  mode: "live" | "sandbox";
  token: string;
  url: string;
  expiresAt: string;
}

/sandbox

POST /sandbox/bookings/{id}/accept

Sandbox only: the scripted homeowner accepts the booking

sandboxAcceptBookingscope projects:writeIdempotency-Key200

Response

Booking {
  id: string;
  mode: "live" | "sandbox";
  status: "awaiting_homeowner" | "accepted" | "cancelled" | "expired";
  retailerOrderRef: string;
  url: string | null;
  projectRef: string | null;
  orders: Array<{
    ref: string;
    status: string;
    category: string;
  }>;
  cancellation: Array<string>;
  expiresAt: string;
}

POST /sandbox/orders/{ref}/advance

Sandbox only: time travel. The scripted installer and homeowner take the order as far as asked, stopping where it waits on you

advanceSandboxOrderscope orders:writeIdempotency-Key200

Request

AdvanceRequest {
  to?: "confirmed" | "in_progress" | "completed";
}

Response

AdvanceResult {
  ref: string;
  status: string;
  waitingOn: string | null;
  steps: Array<string>;
}

/eligibility

POST /eligibility

Whether your SKUs can be installed at a ZIP, and what the install needs

checkEligibilityscope quotes:writeIdempotency-Key200

Request

EligibilityRequest {
  zip: string;
  items: Array<{
    sku: string;
    qty: number;
    priceCents?: number;
  }>;
}

Response

EligibilityResponse {
  zip: string;
  served: boolean;
  market: string | null;
  items: Array<{
    sku: string;
    product: string | null;
    line: string | null;
    installable: boolean;
    reason: string | null;
  }>;
  installers: number;
  needs: Array<string>;
}

/bookings

GET /bookings/{id}

A booking and its orders

getBookingscope projects:read200

Response

Booking {
  id: string;
  mode: "live" | "sandbox";
  status: "awaiting_homeowner" | "accepted" | "cancelled" | "expired";
  retailerOrderRef: string;
  url: string | null;
  projectRef: string | null;
  orders: Array<{
    ref: string;
    status: string;
    category: string;
  }>;
  cancellation: Array<string>;
  expiresAt: string;
}

POST /bookings/{id}/cancel

Cancel a booking the homeowner hasn't accepted

cancelBookingscope projects:writeIdempotency-Key200

Response

Booking {
  id: string;
  mode: "live" | "sandbox";
  status: "awaiting_homeowner" | "accepted" | "cancelled" | "expired";
  retailerOrderRef: string;
  url: string | null;
  projectRef: string | null;
  orders: Array<{
    ref: string;
    status: string;
    category: string;
  }>;
  cancellation: Array<string>;
  expiresAt: string;
}

/orders

POST /orders/{ref}/shipments

You shipped the equipment for an order you sold: the units' models and serials (checked against the order)

reportShipmentscope orders:writeIdempotency-Key200

Request

ShipmentRequest {
  carrier: string;
  tracking: string;
  units: Array<{
    model: string;
    serial: string;
  }>;
}

Response

ShipmentResult {
  ref: string;
  status: "shipped" | "delivered" | "problem";
  verified: boolean;
  problems: Array<string>;
}

POST /orders/{ref}/delivery

The equipment was delivered (serials checked against the shipment before work starts)

reportDeliveryscope orders:writeIdempotency-Key200

Request

DeliveryRequest {
  serials?: Array<string>;
}

Response

ShipmentResult {
  ref: string;
  status: "shipped" | "delivered" | "problem";
  verified: boolean;
  problems: Array<string>;
}

POST /orders/{ref}/cancel

Cancel an order you booked, by the published cancellation rules

cancelOrderscope orders:writeIdempotency-Key200

Request

CancelRequest {
  reason: string;
}

Response

CancelResult {
  ref: string;
  outcome: "cancelled" | "requested";
  rules: Array<string>;
}

GET /orders/{ref}

The status of an order attributed to you

getOrderscope orders:read200

Response

OrderStatus {
  ref: string;
  project: string | null;
  role: "anchor" | "addon" | null;
  category: string;
  status: string;
  statusLabel: string;
  system: string;
  installDate: string | null;
}

/supply

PUT /supply/inventory

A supplier's stock at one of its locations, by SKU (stock older than 72 hours isn't quoted)

reportStockscope inventory:write200

Request

StockReport {
  location: string;
  items: Array<{
    sku: string;
    qty: number;
  }>;
  reportedAt?: string;
}

Response

StockResult {
  mode: "live" | "sandbox" | null;
  location: string;
  applied: boolean;
  matched: number;
  unmatchedSkus: Array<string>;
}

PUT /supply/prices

A supplier's prices to Ducto by SKU and tier (list, contract, promotional) with their dates

reportPricesscope inventory:write200

Request

PriceList {
  items: Array<{
    sku: string;
    tier: "list" | "contract" | "promo";
    costCents: number;
    effectiveFrom: string;
    effectiveTo?: string | null;
  }>;
}

Response

PriceResult {
  mode: "live" | "sandbox" | null;
  applied: boolean;
  matched: number;
  unmatchedSkus: Array<string>;
}

POST /supply/edi

An X12 document from a supplier: 846 inventory advice (and purchase-order replies)

receiveEdiscope inventory:writeIdempotency-Key200

Request

EdiDocument {
  document: string;
}

Response

EdiResult {
  type: string;
  control: string;
  applied: boolean;
  detail: Record<string, unknown>;
}

GET /supply/purchase-orders

A supplier's open purchase orders, each also as an X12 850 (reply with 855, 856 and 810 to /supply/edi)

listPurchaseOrdersscope purchase_orders:read200

Response

PurchaseOrders {
  orders: Array<{
    po: string;
    status: string;
    billing: string;
    fulfillment: "ship" | "pickup";
    shipTo: {
      name: string;
      street: string;
      city: string;
      state: string;
      zip: string;
    };
    lines: Array<{
      sku: string;
      qty: number;
      unitCents: number;
    }>;
    x12: string;
  }>;
}

/manufacturer

PUT /manufacturer/products

A manufacturer's product data and extended-performance tables, for its verified brands

updateBrandProductsscope brand:write200

Request

ProductData {
  brand?: string;
  items: Array<{
    model: string;
    seer2?: number;
    hspf2?: number;
    heatingBtuAt5F?: number;
    minOutdoorTempF?: number;
    refrigerant?: string;
    warrantyYears?: number;
    performance?: Array<{
      mode: "heating" | "cooling";
      outdoorF: number;
      capacityBtu: number;
      inputWatts: number;
    }>;
  }>;
}

Response

ProductDataResult {
  brand: string;
  updated: number;
  unmatchedModels: Array<string>;
}

PUT /manufacturer/certified-installers

A manufacturer's certified-installer list for a brand (installers' certifications are checked against it)

updateCertifiedInstallersscope brand:write200

Request

CertifiedInstallers {
  brand?: string;
  installers: Array<{
    number: string;
    name: string;
    tier?: string;
    expiresOn?: string;
  }>;
}

Response

CertifiedInstallersResult {
  brand: string;
  listed: number;
  credentialsChecked: number;
}

GET /manufacturer/warranty-registrations

Units of your brands on finished installs, with the owner only where the homeowner asked Ducto to register it

listWarrantyRegistrationsscope brand:read200

Response

WarrantyRegistrations {
  registrations: Array<{
    brand: string;
    model: string;
    serial: string;
    unit: string;
    installedOn: string;
    installer: string;
    installerLicense: string;
    state: string;
    order: string;
    owner: {
      name: string;
      email: string;
      phone: string;
      street: string;
      city: string;
      zip: string;
    } | null;
  }>;
}

GET /manufacturer/report

The brand performance report for your brands (figures for fewer than five jobs are hidden)

getBrandReportscope brand:read200

Response

BrandReports {
  reports: Array<Record<string, unknown>>;
}

Errors on every endpoint are RFC 9457 problems: { type, title, status, detail, code, instance, errors? }, where code is one of invalid_request, unauthorized, forbidden, not_found, conflict, idempotency_key_reused, idempotency_key_required, not_served, category_not_offered, unavailable, rate_limited and internal.

API reference | Ducto