Download OpenAPI specification:
The versioned, resource-oriented public API that customers and partners (starting with Zapier) use to import and export their Yeti data.
This is the contract, and it is the source of truth: CI fails if a route
exists in the service without appearing here. It replaces the legacy
public_access surface, whose as-is reference and redesign proposal live in
docs/legacy-endpoints/ and docs/redesign-proposal.md in this repository.
Phase 0 (YET-104). The cross-cutting contract below — authentication, scopes, errors, pagination, rate limiting — is settled and implemented. The resource endpoints (sites, catalog, parties, history, reports) arrive in Phase 1.
Send a per-company API key as Authorization: Bearer <key>. Keys are issued
by a company admin in the Yeti office UI, shown once, and stored only as a
hash. Each key carries scopes of the form {resource}:{ability} — see
GET /v1/reference/scopes.
application/problem+json documents. Switch on the
code member, which is stable; type is a URI to documentation.{ data, meta, links } and page by cursor
(?cursor=…&limit=…). There is no total: counting every match on every
page is what makes large exports slow, and meta.page.has_more answers the
question a paging client actually has.{ "amount": "12.50", "currency": "USD" } — a decimal string
so no client loses precision, and always an explicit ISO currency.RateLimit-Limit,
RateLimit-Remaining and RateLimit-Reset on every response.Liveness/readiness for the service and its hard dependencies. Requires no credential — a load balancer has none — and therefore discloses nothing beyond whether each dependency answers.
{- "status": "ok",
- "checks": {
- "property1": "ok",
- "property2": "ok"
}
}What this key is, which company it is bound to, and which scopes it holds. The first call to make when setting up an integration: it turns "some endpoint returns 403" into "this key is missing that scope".
Never returns the key itself — only prefix…last_four.
{- "data": {
- "api_key": {
- "id": "string",
- "name": "string",
- "identifier": "yeti_pk_a1b2c3…9f4e",
- "scopes": [
- "string"
], - "expires_at": "2019-08-24T14:15:22Z",
- "revokes_at": "2019-08-24T14:15:22Z"
}, - "company": {
- "id": "string",
- "name": "string",
- "currency": "string"
}
}
}Which image is serving this request — the commit, branch, tag and when it was deployed. Stamped into the image at build time, so it describes the running code rather than whatever the deployment configuration claims.
Unauthenticated, like /health, so anyone debugging a deployment can answer
"what is actually out there?" without first being issued a key — which is the
whole point of the endpoint. Nothing returned is a secret: a commit SHA is not
a credential and is inert without the (private) repository.
Every field is nullable: an image built outside the deploy script carries no stamp, and the null is the answer — "this was not built by the deploy script" — rather than an error.
{- "commit_hash": "4b1ee3bd2c9f7a05e18d6c3b2a91f4e7d0c85a63",
- "branch": "main",
- "tag": "untagged",
- "deployed_at": "Fri August 15, 2026 4:12pm MDT"
}The full scope vocabulary, as data rather than prose, so a client or an admin UI can enumerate it without hardcoding.
Unpaginated: the list is bounded by the resource catalogue.
{- "data": [
- {
- "scope": "sites:read",
- "resource": "sites",
- "ability": "read",
- "domain": "Sites"
}
], - "meta": {
- "count": 0
}
}Serviced locations. The resource most integrations are about, and the first
collection endpoint — so it is also where the shared collection conventions
(cursor paging, filter, sort, include, fields) are documented.
The calling company's sites, newest ids last, paged by cursor.
Request the first page, then follow links.next until it is null. Do
not rebuild the query string yourself — next already carries your
filters, and the cursor is only valid alongside them.
The default order is id ascending. That is deliberate for exports: an
immutable unique key is a total order, so a row edited while you page
cannot move and be seen twice.
filter[updated_since] is the incremental sync: store the time your last
successful run started, and pass it next time.
GET /v1/sites?filter[updated_since]=2026-08-01T00:00:00Z
Requires sites:read.
| cursor | string An opaque cursor from |
| limit | integer [ 1 .. 200 ] Default: 50 Rows per page. Clamped to the maximum rather than rejected. |
| sort | string Example: sort=-updated_at,name Comma-separated fields, The primary key is always appended, so the order is total and paging cannot skip or repeat a row when values tie. |
| include | string Example: include=contacts Comma-separated related resources to embed, at most 5. A relation that is not included is absent from the response rather than null, so "not requested" is distinguishable from "requested, and empty". |
object Example: fields[sites]=name,address
| |
object Examples:
Filterable: Two named filters: |
{- "data": [
- {
- "id": "string",
- "name": "string",
- "name_for_operators": "string",
- "site_reference": "string",
- "client_reference": "string",
- "client_company_id": "string",
- "notes": "string",
- "area": 0,
- "address": {
- "line1": "string",
- "line2": "string",
- "city": "string",
- "region": "string",
- "postal_code": "string",
- "country": "string"
}, - "coordinates": {
- "latitude": 51.0447,
- "longitude": -114.0719,
- "accuracy": "rooftop",
- "geocoded_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "contacts": [
- {
- "id": "string",
- "name": "string",
- "role": "string",
- "email": "user@example.com",
- "phone": "string",
- "phone_type": "string"
}
]
}
], - "meta": {
- "page": {
- "limit": 0,
- "has_more": true
}
},
}A single site of the calling company.
include and fields behave exactly as they do on the collection.
A site belonging to another company returns 404, not 403: saying "forbidden" would confirm the id exists.
Requires sites:read.
| siteId required | string The site's |
| include | string Example: include=contacts Comma-separated related resources to embed, at most 5. A relation that is not included is absent from the response rather than null, so "not requested" is distinguishable from "requested, and empty". |
object Example: fields[sites]=name,address
|
{- "data": {
- "id": "string",
- "name": "string",
- "name_for_operators": "string",
- "site_reference": "string",
- "client_reference": "string",
- "client_company_id": "string",
- "notes": "string",
- "area": 0,
- "address": {
- "line1": "string",
- "line2": "string",
- "city": "string",
- "region": "string",
- "postal_code": "string",
- "country": "string"
}, - "coordinates": {
- "latitude": 51.0447,
- "longitude": -114.0719,
- "accuracy": "rooftop",
- "geocoded_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "contacts": [
- {
- "id": "string",
- "name": "string",
- "role": "string",
- "email": "user@example.com",
- "phone": "string",
- "phone_type": "string"
}
]
}
}What a company prices work from: services, equipment, equipment categories and consumables. Each family carries its own scope.
The services this company sells. client is what it charges; subcontractor is what it pays.
Paged by cursor, id ascending — follow links.next until it is null.
filter[updated_since] is the incremental export.
Requires services:read.
| cursor | string An opaque cursor from |
| limit | integer [ 1 .. 200 ] Default: 50 Rows per page. Clamped to the maximum rather than rejected. |
| sort | string Example: sort=-updated_at,name Comma-separated fields, The primary key is always appended, so the order is total and paging cannot skip or repeat a row when values tie. |
object Example: fields[sites]=name,address
| |
object Filterable: Rates are not filterable — see the |
{- "data": [
- {
- "id": "string",
- "name": "string",
- "name_for_operators": "string",
- "client_description": "string",
- "color": "#0a31a8",
- "client": {
- "charge_type": "string",
- "rate": {
- "amount": "12.50",
- "currency": "USD"
}, - "minimum_minutes": 0
}, - "subcontractor": {
- "charge_type": "string",
- "rate": {
- "amount": "12.50",
- "currency": "USD"
}, - "minimum_minutes": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "page": {
- "limit": 0,
- "has_more": true
}
},
}A single record of the calling company.
Another company's record returns 404, not 403: saying "forbidden" would confirm the id exists.
Requires services:read.
| serviceId required | string The service's |
object Example: fields[sites]=name,address
|
{- "data": {
- "id": "string",
- "name": "string",
- "name_for_operators": "string",
- "client_description": "string",
- "color": "#0a31a8",
- "client": {
- "charge_type": "string",
- "rate": {
- "amount": "12.50",
- "currency": "USD"
}, - "minimum_minutes": 0
}, - "subcontractor": {
- "charge_type": "string",
- "rate": {
- "amount": "12.50",
- "currency": "USD"
}, - "minimum_minutes": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Equipment this company operates. It carries no rate of its own — pricing is the category's, so include=category is usually what you want.
Paged by cursor, id ascending — follow links.next until it is null.
filter[updated_since] is the incremental export.
Includable: category.
Requires equipment:read.
| cursor | string An opaque cursor from |
| limit | integer [ 1 .. 200 ] Default: 50 Rows per page. Clamped to the maximum rather than rejected. |
| sort | string Example: sort=-updated_at,name Comma-separated fields, The primary key is always appended, so the order is total and paging cannot skip or repeat a row when values tie. |
| include | string Example: include=contacts Comma-separated related resources to embed, at most 5. A relation that is not included is absent from the response rather than null, so "not requested" is distinguishable from "requested, and empty". |
object Example: fields[sites]=name,address
| |
object Filterable: |
{- "data": [
- {
- "id": "string",
- "name": "string",
- "name_for_operators": "string",
- "client_description": "string",
- "category_id": "string",
- "category": {
- "id": "string",
- "name": "string",
- "group": "string",
- "default_rates": {
- "client": {
- "amount": "12.50",
- "currency": "USD"
}, - "subcontractor": {
- "amount": "12.50",
- "currency": "USD"
}
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "page": {
- "limit": 0,
- "has_more": true
}
},
}A single record of the calling company.
Another company's record returns 404, not 403: saying "forbidden" would confirm the id exists.
Requires equipment:read.
| equipmentId required | string The equipment's |
| include | string Example: include=contacts Comma-separated related resources to embed, at most 5. A relation that is not included is absent from the response rather than null, so "not requested" is distinguishable from "requested, and empty". |
object Example: fields[sites]=name,address
|
{- "data": {
- "id": "string",
- "name": "string",
- "name_for_operators": "string",
- "client_description": "string",
- "category_id": "string",
- "category": {
- "id": "string",
- "name": "string",
- "group": "string",
- "default_rates": {
- "client": {
- "amount": "12.50",
- "currency": "USD"
}, - "subcontractor": {
- "amount": "12.50",
- "currency": "USD"
}
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Equipment categories, and where equipment pricing lives.
In the legacy API categories were global with a separate per-company rate join; in v1 a category is the company's and carries its own defaults.
Paged by cursor, id ascending — follow links.next until it is null.
filter[updated_since] is the incremental export.
Requires equipment-categories:read.
| cursor | string An opaque cursor from |
| limit | integer [ 1 .. 200 ] Default: 50 Rows per page. Clamped to the maximum rather than rejected. |
| sort | string Example: sort=-updated_at,name Comma-separated fields, The primary key is always appended, so the order is total and paging cannot skip or repeat a row when values tie. |
object Example: fields[sites]=name,address
| |
object Filterable: |
{- "data": [
- {
- "id": "string",
- "name": "string",
- "group": "string",
- "default_rates": {
- "client": {
- "amount": "12.50",
- "currency": "USD"
}, - "subcontractor": {
- "amount": "12.50",
- "currency": "USD"
}
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "page": {
- "limit": 0,
- "has_more": true
}
},
}A single record of the calling company.
Another company's record returns 404, not 403: saying "forbidden" would confirm the id exists.
Requires equipment-categories:read.
| categoryId required | string The category's |
object Example: fields[sites]=name,address
|
{- "data": {
- "id": "string",
- "name": "string",
- "group": "string",
- "default_rates": {
- "client": {
- "amount": "12.50",
- "currency": "USD"
}, - "subcontractor": {
- "amount": "12.50",
- "currency": "USD"
}
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Consumables — salt, sand, brine. Unlike equipment these carry their own default rates.
Paged by cursor, id ascending — follow links.next until it is null.
filter[updated_since] is the incremental export.
Requires consumables:read.
| cursor | string An opaque cursor from |
| limit | integer [ 1 .. 200 ] Default: 50 Rows per page. Clamped to the maximum rather than rejected. |
| sort | string Example: sort=-updated_at,name Comma-separated fields, The primary key is always appended, so the order is total and paging cannot skip or repeat a row when values tie. |
object Example: fields[sites]=name,address
| |
object Filterable: |
{- "data": [
- {
- "id": "string",
- "name": "string",
- "name_for_operators": "string",
- "client_description": "string",
- "group": "string",
- "default_rates": {
- "client": {
- "amount": "12.50",
- "currency": "USD"
}, - "subcontractor": {
- "amount": "12.50",
- "currency": "USD"
}
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "page": {
- "limit": 0,
- "has_more": true
}
},
}A single record of the calling company.
Another company's record returns 404, not 403: saying "forbidden" would confirm the id exists.
Requires consumables:read.
| consumableId required | string The consumable's |
object Example: fields[sites]=name,address
|
{- "data": {
- "id": "string",
- "name": "string",
- "name_for_operators": "string",
- "client_description": "string",
- "group": "string",
- "default_rates": {
- "client": {
- "amount": "12.50",
- "currency": "USD"
}, - "subcontractor": {
- "amount": "12.50",
- "currency": "USD"
}
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}