This is the abridged developer documentation for Vyro # Quickstart > Get an access token and query a dealer's stock from the Vyro GraphQL API in five minutes. By the end of this page you will have listed vehicles from a showroom with their state drive-away price and hero image. 1. **Get credentials.** You need a `client_id` and `client_secret`. Dealers create them in the Vyro Dashboard under **Settings > Team > (a team member) > API Clients**. Partners get them from Vyro: email . Start on [staging](/environments). A dealer’s client reads that dealer’s showrooms. A partner reading many dealers uses an app client. See [Authentication](/authentication#two-kinds-of-api-client). 2. **Exchange them for an access token.** Tokens last 60 minutes. Cache one and reuse it until shortly before it expires. ```bash curl -s https://authentication-api.vyrolabs-staging.net/api-client/authenticate \ -H 'Content-Type: application/json' \ -d '{"client_id":"YOUR_CLIENT_ID","client_secret":"YOUR_CLIENT_SECRET"}' ``` ```json { "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 3600 } ``` 3. **Find your showroom.** Every query about stock is scoped to a showroom. Look its `id` up once by code and keep it. ```graphql query Showroom { showrooms(where: { code: { _eq: "your-showroom-code" } }) { id name domain default_pricing_code } } ``` 4. **List vehicles.** Send the query to the GraphQL endpoint with the token in the `Authorization` header. * curl ```bash curl -s https://graphql.vyrolabs-staging.net/v1/graphql \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d @- <<'JSON' { "query": "query Stock($showroomId: uuid!, $state: String!) { stocked_vehicles(where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } }, is_published: { _eq: true }, is_listed: { _eq: true }, is_sold: { _eq: false }, listing_type: { _eq: retail }, pricing_codes: { _contains: [$state] } }, order_by: [{ updated_at: desc }, { id: asc }], limit: 20) { id code production_year make model badge condition odometer price: pricing_config(path: $state) assets(where: { placement: { _eq: \"featured\" } }, limit: 1) { src } } }", "variables": { "showroomId": "YOUR_SHOWROOM_ID", "state": "AU_NSW" } } JSON ``` * JavaScript ```js const query = /* GraphQL */ ` query Stock($showroomId: uuid!, $state: String!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } is_published: { _eq: true } is_listed: { _eq: true } is_sold: { _eq: false } listing_type: { _eq: retail } pricing_codes: { _contains: [$state] } } order_by: [{ updated_at: desc }, { id: asc }] limit: 20 ) { id code production_year make model badge condition odometer price: pricing_config(path: $state) assets(where: { placement: { _eq: "featured" } }, limit: 1) { src } } } `; const res = await fetch('https://graphql.vyrolabs-staging.net/v1/graphql', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ query, variables: { showroomId, state: 'AU_NSW' } }), }); const { data, errors } = await res.json(); if (errors) throw new Error(errors[0].message); ``` * Python ```python import requests query = """ query Stock($showroomId: uuid!, $state: String!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } is_published: { _eq: true } is_listed: { _eq: true } is_sold: { _eq: false } listing_type: { _eq: retail } pricing_codes: { _contains: [$state] } } order_by: [{ updated_at: desc }, { id: asc }] limit: 20 ) { id code production_year make model badge condition odometer price: pricing_config(path: $state) assets(where: { placement: { _eq: "featured" } }, limit: 1) { src } } } """ res = requests.post( "https://graphql.vyrolabs-staging.net/v1/graphql", headers={"Authorization": f"Bearer {access_token}"}, json={"query": query, "variables": {"showroomId": showroom_id, "state": "AU_NSW"}}, timeout=30, ) body = res.json() if "errors" in body: raise RuntimeError(body["errors"][0]["message"]) vehicles = body["data"]["stocked_vehicles"] ``` 5. **Read the result.** Prices are in cents, so `4599000` is $45,990. ```json { "data": { "stocked_vehicles": [ { "id": "0b6c0c1e-…", "code": "2024-byd-atto-3-extended-fs003", "production_year": 2024, "make": "BYD", "model": "Atto 3", "badge": "Extended Range", "condition": "new", "odometer": 0, "price": { "version": "v2", "driveAwayPrice": 4599000, "lineItems": { "…": "…" } }, "assets": [{ "src": "https://ik.imagekit.io/vyro/…/hero.jpg" }] } ] } } ``` ## Next [Section titled “Next”](#next) * [Build a stock feed](/guides/stock-feed): sync, prices, images and links for a marketplace. * [Filtering](/guides/filtering) and [Pagination](/guides/pagination): the two things every integration needs. * [Recipes](/recipes/stocked-vehicles): get-one and list queries for every common entity. # Filtering > Filter Vyro GraphQL queries with where clauses, comparison operators, relationships and JSON operators. Every list query takes a `where` argument. Fields inside it are combined with **AND**. ```graphql query UsedToyotas { stocked_vehicles( where: { make: { _eq: "Toyota" } condition: { _eq: used } odometer: { _lt: 50000 } } limit: 10 ) { id make model odometer } } ``` ## Operators [Section titled “Operators”](#operators) | Operator | Meaning | Example | | ---------------------------- | ----------------------------------------- | -------------------------------------- | | `_eq`, `_neq` | equals, not equals | `make: { _eq: "Kia" }` | | `_gt`, `_gte`, `_lt`, `_lte` | greater or less than | `production_year: { _gte: 2022 }` | | `_in`, `_nin` | in a list, not in a list | `body_type: { _in: ["SUV", "Wagon"] }` | | `_is_null` | is (or isn’t) empty | `available_from: { _is_null: true }` | | `_ilike`, `_nilike` | case-insensitive match, `%` is a wildcard | `model: { _ilike: "%cross%" }` | | `_like` | case-sensitive match | `stock_id: { _like: "NSW-%" }` | | `_iregex` | case-insensitive regular expression | `vin: { _iregex: "^LGX" }` | Text matching with `_eq` is exact and case-sensitive. Use `_ilike` without wildcards for a case-insensitive exact match: `make: { _ilike: "toyota" }`. ### Arrays and JSON [Section titled “Arrays and JSON”](#arrays-and-json) | Operator | On | Meaning | Example | | -------------------------------- | ------------ | ---------------------------- | ------------------------------------------ | | `_contains` | arrays, JSON | contains all of these | `pricing_codes: { _contains: ["AU_VIC"] }` | | `_contained_in` | arrays, JSON | is a subset of | | | `_has_key` | JSON | object has this key | `utm_data: { _has_key: "utm_source" }` | | `_has_keys_any`, `_has_keys_all` | JSON | has any or all of these keys | | ## AND, OR and NOT [Section titled “AND, OR and NOT”](#and-or-and-not) Use `_or` for alternatives. It takes a list of conditions. ```graphql query ElectricOrHybrid { stocked_vehicles( where: { _or: [ { fuel_type: { _ilike: "%electric%" } } { fuel_type: { _ilike: "%hybrid%" } } ] } limit: 10 ) { id fuel_type } } ``` Use `_and` when you need the same field twice, and `_not` to negate a whole condition. ```graphql query MidPriced { stocked_vehicles( where: { _and: [{ min_price: { _gte: 3000000 } }, { min_price: { _lt: 5000000 } }] _not: { make: { _in: ["Ferrari", "Lamborghini"] } } } limit: 10 ) { id min_price } } ``` ## Filter through a relationship [Section titled “Filter through a relationship”](#filter-through-a-relationship) Nest a relationship’s fields to filter on them. This is how you scope stock to a showroom: ```graphql query ShowroomStock($showroomId: uuid!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } } limit: 10 ) { id } } ``` The same works for one-to-one relationships, such as vehicles at a location in Victoria: ```graphql query VictorianStock { stocked_vehicles(where: { location: { address: { _contains: { state: "VIC" } } } }, limit: 10) { id location { name } } } ``` ## Filter nested lists [Section titled “Filter nested lists”](#filter-nested-lists) Relationships that return a list take their own `where`, `order_by` and `limit`. This filters the images, not the vehicles: ```graphql query FeaturedImages { stocked_vehicles(limit: 5) { id assets(where: { placement: { _eq: "featured" } }, limit: 1) { src } } } ``` ## Enums [Section titled “Enums”](#enums) A few fields are enums: `condition` (`new`, `used`, `demo`) and `listing_type` (`retail`, `private`). Written inline, enum values have **no quotes**. Passed as variables, they are ordinary strings. ```graphql # Inline: no quotes stocked_vehicles(where: { condition: { _eq: used } }) { id } ``` ```graphql # Variable: a normal string in the variables JSON, e.g. { "condition": "used" } query ($condition: vehicle_conditions_enum!) { stocked_vehicles(where: { condition: { _eq: $condition } }) { id } } ``` The variable’s type must be the enum type, not `String`: `vehicle_conditions_enum` for `condition`, `listing_types_enum` for `listing_type`. Values are case-sensitive (`used`, not `Used`). Caution `condition: { _eq: "used" }` fails with *expected an enum value*, and so does a `String!` variable. Drop the quotes, or use an enum-typed variable. ## Use variables [Section titled “Use variables”](#use-variables) Put values in `variables` rather than building query strings. It avoids escaping bugs and injection, and you can reuse the query text. ```json { "query": "query ($make: String!) { stocked_vehicles(where: { make: { _ilike: $make } }, limit: 10) { id model } }", "variables": { "make": "toyota" } } ``` Declare each variable with the column’s type: | Columns | Variable type | | ------------------------------------------------------------- | ------------------------------------------------- | | `id`, `showroom_id` and other ids | `uuid!` | | `make`, `model`, `code` and other text | `String!` | | `odometer`, `production_year`, `min_price` and other decimals | `numeric!` | | `limit`, `offset` | `Int!` | | `created_at`, `updated_at` and other timestamps | `timestamptz!` | | `available_from` and other dates | `date!` | | `condition`, `listing_type` | `vehicle_conditions_enum!`, `listing_types_enum!` | Send only the variables a query declares. An extra one fails with `unexpected variables in variableValues`, which is easy to hit if you share one variables object between queries. You can also pass a whole `where` as a variable of type `stocked_vehicles_bool_exp`, which helps when your filters come from a search form. ```graphql query Search($where: stocked_vehicles_bool_exp!) { stocked_vehicles(where: $where, limit: 24) { id } } ``` ## Free-text search [Section titled “Free-text search”](#free-text-search) `search_stocked_vehicles` takes a search phrase and accepts the same `where`, `order_by`, `limit` and `offset` as `stocked_vehicles`. ```graphql query Search($showroomId: uuid!) { search_stocked_vehicles( args: { search: "outlander phev" } where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } } limit: 12 ) { id make model badge } } ``` # Images > Read vehicle images from Vyro, choose the hero image by placement, keep the dealer's order, and resize through the image CDN. A vehicle’s images are in its `assets` list. Each one has a URL, a **placement** and an **order**. ```graphql query VehicleImages($id: uuid!) { stocked_vehicles_by_pk(id: $id) { hero: assets(where: { is_public: { _eq: true }, placement: { _eq: "featured" } }, limit: 1) { src alt } gallery: assets( where: { is_public: { _eq: true }, placement: { _neq: "featured" } } order_by: [{ order: asc }, { id: asc }] ) { src alt placement order } } } ``` | Field | Meaning | | ---------------- | ---------------------------------------------------------------------------------------------------------------------- | | `src` | Absolute image URL. | | `placement` | Where the dealer wants it shown (see below). | | `order` | Position **within its placement**, from 0. | | `alt` | Alt text. Often empty: fall back to the vehicle’s year, make and model. | | `is_public` | Only show public images. Requests without a token only ever see public ones. | | `type` | `image` for photos. Other types (such as documents) can exist. | | `classification` | The shot detected by Vyro: `Front`, `Front34`, `Side`, `Rear34`, `Rear`, `Interior`, `Wheel`, `Other`. Not always set. | ## Placements [Section titled “Placements”](#placements) | Placement | Use it for | | ---------------------- | ----------------------------------------------------------------------------------------------- | | `featured` | The hero image: cards, search results, social previews. One per vehicle. | | `gallery` | The rest of the photos, in `order`. | | `exterior`, `interior` | Configurator-style views on some new vehicles. Show them in the gallery after `gallery` images. | Placement is free text, so you may see others. Treat anything you don’t recognise as a gallery image. **Pick the hero** as the `featured` image, falling back to the first gallery image. **Build the gallery** as the hero followed by the rest in `order`. Caution `order` restarts at 0 for each placement, so sorting all images by `order` alone can put a gallery image before the hero. Fetch the hero separately, as above. ## Sizes [Section titled “Sizes”](#sizes) Images hosted on `ik.imagekit.io` (Vyro’s image CDN) can be resized by adding `?tr=` to the URL. Ask for the size you show, and `f-auto` to get WebP or AVIF where the browser supports it. | You want | Add | | --------------------- | --------------------------------- | | Card, 600 px wide | `?tr=w-600,f-auto` | | Gallery, 1600 px wide | `?tr=w-1600,f-auto` | | Square thumbnail | `?tr=w-300,h-300,fo-auto,f-auto` | | Social preview | `?tr=w-1200,h-630,fo-auto,f-auto` | ```js function imageUrl(src, width) { if (!src.startsWith('https://ik.imagekit.io/')) return src; // other hosts: use as-is const url = new URL(src); url.searchParams.set('tr', `w-${width},f-auto`); return url.toString(); } ``` `URLSearchParams` encodes the comma as `%2C`; ImageKit accepts both forms. Some dealers’ images are served from their own systems (any host other than `ik.imagekit.io`). Use those URLs as they are. ## Caching and hotlinking [Section titled “Caching and hotlinking”](#caching-and-hotlinking) You may display images straight from `src`. Vyro’s image URLs are content-addressed: when a dealer changes a photo, the URL changes, so you can cache them for as long as you like. A vehicle whose `updated_at` changed may have new images, so refresh its list then. # Pagination and sorting > Page through Vyro GraphQL results with limit, offset and a stable order, count totals, and sync changes by updated_at. ## Pages [Section titled “Pages”](#pages) Use `limit` and `offset`, and **always** give an `order_by` that ends in a unique field such as `id`. Without one, rows can move between pages. ```graphql query Page($showroomId: uuid!, $offset: Int!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } } order_by: [{ created_at: desc }, { id: asc }] limit: 50 offset: $offset ) { id created_at } } ``` Page 1 is `offset: 0`, page 2 is `offset: 50`, and so on. Stop when a page returns fewer than `limit` rows. Keep pages at **50 to 200 rows**, fewer if you select large nested lists such as images. There is no hard maximum for API clients, but a smaller page is faster and less likely to time out. Requests without a token are capped at 500 vehicles. ## Totals [Section titled “Totals”](#totals) Every list has an `_aggregate` twin that takes the same `where`. Ask for both in one request. ```graphql query PageWithTotal($showroomId: uuid!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } } order_by: [{ created_at: desc }, { id: asc }] limit: 50 ) { id } stocked_vehicles_aggregate( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } } ) { aggregate { count min { min_price } max { min_price } } } } ``` ## Sorting [Section titled “Sorting”](#sorting) `order_by` takes a field and a direction: `asc`, `desc`, `asc_nulls_first`, `asc_nulls_last`, `desc_nulls_first` or `desc_nulls_last`. Pass a list to sort by several fields in order. | Sort | `order_by` | | ------------------ | ------------------------------------------------------------- | | Newest first | `[{ created_at: desc }, { id: asc }]` | | Recently changed | `[{ updated_at: desc }, { id: asc }]` | | Cheapest first | `[{ min_price: asc_nulls_last }, { id: asc }]` | | Lowest kilometres | `[{ odometer: asc }, { id: asc }]` | | Newest model year | `[{ production_year: desc }, { id: asc }]` | | Dealer’s own order | `[{ featured_order: asc_nulls_last }, { random_order: asc }]` | You can sort by a related object’s field, such as `order_by: { location: { name: asc } }`. ## Syncing by `updated_at` [Section titled “Syncing by updated\_at”](#syncing-by-updated_at) For a sync job, offsets are fragile: a vehicle that changes mid-sync moves position and you can miss one. Page by the last row you saw instead (keyset pagination). ```graphql query ChangedSince($showroomId: uuid!, $since: timestamptz!, $sinceId: uuid!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } _or: [ { updated_at: { _gt: $since } } { updated_at: { _eq: $since }, id: { _gt: $sinceId } } ] } order_by: [{ updated_at: asc }, { id: asc }] limit: 200 ) { id updated_at } } ``` Start with `since` set to a time before your first sync and `sinceId` to `00000000-0000-0000-0000-000000000000`. After each page, set both from the last row. Repeat until a page has fewer than 200 rows. # Prices > How Vyro stores vehicle prices in cents, read drive-away prices from pricing_config, and format them correctly. ## Everything is in cents [Section titled “Everything is in cents”](#everything-is-in-cents) Every money value in the API is an **integer number of cents** (or the smallest unit of the currency). `4599000` is $45,990.00. Divide by 100 to display. ## Read a vehicle’s price [Section titled “Read a vehicle’s price”](#read-a-vehicles-price) A vehicle’s prices live in `pricing_config`, a JSON object with one entry per state or country. Ask for one entry with `path`: ```graphql query VehiclePrice($id: uuid!) { stocked_vehicles_by_pk(id: $id) { id price: pricing_config(path: "AU_NSW") } } ``` ```json { "version": "v2", "driveAwayPrice": 6890000, "isVariable": false, "isExclOnRoads": false, "lineItems": { "dutiable": [{ "code": "vehicle-price", "value": 6890000 }], "onRoad": [], "additional": [] } } ``` | Field | Meaning | | ---------------------- | ----------------------------------------------------------------------------------------------------------- | | `driveAwayPrice` | The total the customer pays, in cents. **Show this.** | | `isExclOnRoads` | `true`: the price excludes government charges. Label it “excl. on-road costs”, not “drive away”. | | `isVariable` | `true`: price on application (for example, built to order). Don’t show a number. | | `lineItems.dutiable` | The vehicle price and options that duty is charged on: `vehicle-price`, `paint`, `accessories`, `discount`… | | `lineItems.onRoad` | Government and on-road charges: `stamp-duty`, `registration`, `ctp`, `luxury-car-tax`… | | `lineItems.additional` | Charges and savings outside duty: `freight`, `discount`, `cashback`, `grant`… | Discounts are line items with a **negative** value. `driveAwayPrice` already includes every line item, so you never need to add them up. Use the line items only if you want to show a breakdown. Caution If `path` names a state the vehicle isn’t priced in, you get `null`. Either the vehicle isn’t for sale there, or you asked for the wrong code. Filter with `pricing_codes: { _contains: ["AU_NSW"] }` so it can’t happen. See [State pricing](/guides/state-pricing). Some older vehicles have a `version: "v1"` entry with `totalAfterOnRoadCosts` instead of `driveAwayPrice`. The Vyro storefront doesn’t show v1 prices, and you can skip them too. ## Format it [Section titled “Format it”](#format-it) Round **up** to whole dollars, which is how dealers advertise. Use the currency for the state (see [State pricing](/guides/state-pricing)). ```js function formatPrice(price, { locale = 'en-AU', currency = 'AUD' } = {}) { if (!price || price.version !== 'v2') return null; if (price.isVariable) return 'Price on application'; const dollars = Math.ceil(price.driveAwayPrice / 100); const amount = new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 0, }).format(dollars); return price.isExclOnRoads ? `${amount} excl. on-road costs` : `${amount} drive away`; } formatPrice({ version: 'v2', driveAwayPrice: 6890000 }); // "$68,900 drive away" ``` ## Filter and sort by price [Section titled “Filter and sort by price”](#filter-and-sort-by-price) `min_price` and `max_price` are the cheapest and dearest drive-away prices across all of a vehicle’s states, in cents. They are good enough for “under $40k” filters: ```graphql query Under40k($showroomId: uuid!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } min_price: { _lt: 4000000 } } order_by: [{ min_price: asc }, { id: asc }] limit: 20 ) { id min_price nsw: pricing_config(path: "AU_NSW") } } ``` For an exact per-state filter or sort, read `pricing_config(path: …).driveAwayPrice` and sort in your code. ## Discounts and was-prices [Section titled “Discounts and was-prices”](#discounts-and-was-prices) * `has_discount` is `true` when the vehicle has a discount in any state. * `pricing_events` is the vehicle’s price history across all states, so it suits a “was” price only for vehicles priced the same everywhere. The highest past price: ```graphql query WasPrice($id: uuid!) { stocked_vehicles_by_pk(id: $id) { pricing_events(order_by: { price: desc }, limit: 1) { price event_at } } } ``` ## Quotes for a specific customer [Section titled “Quotes for a specific customer”](#quotes-for-a-specific-customer) `pricing_config` is the advertised price. To quote a customer exactly (their state, concessions, used-vehicle duty, registration transfer), use the [Pricing API](/pricing-api/overview). # State pricing (Australia) > Australian drive-away prices differ by state. How Vyro pricing codes work, and how to pick and filter by the right state. In Australia, stamp duty, registration and CTP insurance differ by state, so the same car has a different drive-away price in each one. Vyro stores a price per **pricing code**. | Code | State or territory | Currency | | -------- | ---------------------------------- | -------- | | `AU_NSW` | New South Wales | AUD | | `AU_VIC` | Victoria | AUD | | `AU_QLD` | Queensland | AUD | | `AU_SA` | South Australia | AUD | | `AU_WA` | Western Australia | AUD | | `AU_TAS` | Tasmania | AUD | | `AU_ACT` | Australian Capital Territory | AUD | | `AU_NT` | Northern Territory | AUD | | `NZ` | New Zealand (one price nationwide) | NZD | The format is `_` where the country has states, and the country alone where it doesn’t. ## The two fields [Section titled “The two fields”](#the-two-fields) * `pricing_codes` is the list of codes a vehicle is priced in. **Filter on this.** * `pricing_config` holds the price for each code. **Read from this** with `path`. ```graphql query VictorianStock($showroomId: uuid!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } pricing_codes: { _contains: ["AU_VIC"] } } limit: 20 ) { id pricing_codes vic: pricing_config(path: "AU_VIC") } } ``` A vehicle that isn’t in a state’s `pricing_codes` isn’t sold there. Leave it out of that state’s results rather than showing another state’s price. ### Several states at once [Section titled “Several states at once”](#several-states-at-once) Use aliases to fetch more than one state in a single request: ```graphql query AllStates($id: uuid!) { stocked_vehicles_by_pk(id: $id) { nsw: pricing_config(path: "AU_NSW") vic: pricing_config(path: "AU_VIC") qld: pricing_config(path: "AU_QLD") wa: pricing_config(path: "AU_WA") } } ``` Or fetch `pricing_config` without `path` to get every state in one JSON object, keyed by code. ## Which states a dealer sells in [Section titled “Which states a dealer sells in”](#which-states-a-dealer-sells-in) ```graphql query DealerStates($showroomId: uuid!) { showroom_pricing_codes(where: { showroom_id: { _eq: $showroomId } }) { pricing_code { code short_name country region currency } } showrooms_by_pk(id: $showroomId) { default_pricing_code } } ``` `default_pricing_code` is the state the dealer’s own website shows when it doesn’t know where the shopper is. ## Picking the shopper’s state [Section titled “Picking the shopper’s state”](#picking-the-shoppers-state) Pick a state before you show a price, and say which state it’s for (“$45,990 drive away, NSW”). In order of preference: 1. **The shopper chose it**, in a state picker or by entering a postcode. 2. **Their location**, from IP geolocation. Most CDNs give you a region header. 3. **The dealer’s `default_pricing_code`.** To turn a postcode into a state, use your own postcode data, or ask which of the dealer’s locations serve that postcode: ```graphql query ServingLocation($showroomId: uuid!, $postcode: String!) { locations( where: { showroom_id: { _eq: $showroomId } primary_market_area_postcodes: { _contains: [$postcode] } } limit: 1 ) { id name address } } ``` Then turn the location’s `address.state` into a pricing code. It can be the full name or the abbreviation, so map both: ```js const AU_STATES = { NSW: 'NSW', 'NEW SOUTH WALES': 'NSW', VIC: 'VIC', VICTORIA: 'VIC', QLD: 'QLD', QUEENSLAND: 'QLD', SA: 'SA', 'SOUTH AUSTRALIA': 'SA', WA: 'WA', 'WESTERN AUSTRALIA': 'WA', TAS: 'TAS', TASMANIA: 'TAS', ACT: 'ACT', 'AUSTRALIAN CAPITAL TERRITORY': 'ACT', NT: 'NT', 'NORTHERN TERRITORY': 'NT', }; function pricingCodeFor(address) { if (address?.country_code === 'NZ') return 'NZ'; const state = AU_STATES[String(address?.state ?? '').trim().toUpperCase()]; return state ? `AU_${state}` : null; } ``` # Build a stock feed > Show a Vyro dealer's vehicles on your website with the right state price, hero image and link back to the dealer, and keep them in sync. This guide is for marketplaces, comparison sites and dealer-group sites that show a Vyro dealer’s stock. By the end you will have a feed that lists the right vehicles, prices them for a state, shows the hero image, links to the dealer’s page, and stays in sync. ## What “live stock” means [Section titled “What “live stock” means”](#what-live-stock-means) A vehicle should appear on your site when all of these are true. This is the same rule the dealer’s own Vyro website uses. | Field | Value | Why | | --------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------ | | `showroom_stocked_vehicles.showroom_id` | the dealer’s showroom | One vehicle can be shown by several showrooms. | | `is_published` | `true` | The dealer has made it public. | | `is_listed` | `true` | It should appear in lists and search. Unlisted vehicles are reachable only by direct link. | | `is_sold` | `false` | Sold vehicles stay published for a while so links still work. | | `pricing_codes` | contains your state, e.g. `AU_VIC` | The vehicle is priced (and sold) in that state. | | `listing_type` | `retail` | Excludes private (consumer-to-consumer) listings. | ## The query [Section titled “The query”](#the-query) 1. **List vehicles for one state.** Ask for the state’s price with `pricing_config(path: $state)`, the featured image and the fields a card needs. ```graphql query StockFeed($showroomId: uuid!, $state: String!, $limit: Int!, $offset: Int!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } is_published: { _eq: true } is_listed: { _eq: true } is_sold: { _eq: false } listing_type: { _eq: retail } pricing_codes: { _contains: [$state] } } order_by: [{ updated_at: desc }, { id: asc }] limit: $limit offset: $offset ) { id code stock_id vin condition production_year make model badge series body_type fuel_type transmission colour odometer hide_odometer updated_at price: pricing_config(path: $state) location { name address } hero: assets( where: { is_public: { _eq: true }, placement: { _eq: "featured" } } limit: 1 ) { src alt } gallery: assets( where: { is_public: { _eq: true } } order_by: { order: asc } limit: 20 ) { src alt placement } } total: stocked_vehicles_aggregate( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } is_published: { _eq: true } is_listed: { _eq: true } is_sold: { _eq: false } listing_type: { _eq: retail } pricing_codes: { _contains: [$state] } } ) { aggregate { count } } } ``` ```json { "showroomId": "9b1d4e7a-2c3f-4a5b-8d6e-7f8091a2b3c4", "state": "AU_NSW", "limit": 50, "offset": 0 } ``` 2. **Show the price.** `price.driveAwayPrice` is in cents. Read the two flags before you print it. See [Prices](/guides/prices). ```js function priceLabel(price, locale = 'en-AU', currency = 'AUD') { if (!price || price.isVariable) return 'Price on application'; const amount = new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 0, }).format(Math.ceil(price.driveAwayPrice / 100)); return price.isExclOnRoads ? `${amount} excl. on-road costs` : `${amount} drive away`; } ``` 3. **Show the image.** Use `hero[0]`, falling back to `gallery[0]`. See [Images](/guides/images) for resizing. 4. **Link to the dealer.** Send shoppers to the vehicle’s page on the dealer’s site, with the state so they see the same price. See [Vehicle URLs](/guides/vehicle-urls). ```js const url = `https://${showroom.domain}/vehicles/stocked/${vehicle.code}?region_id=${state}`; ``` 5. **Page through the rest.** Repeat with `offset` += `limit` until you have `total.aggregate.count` vehicles. See [Pagination](/guides/pagination). ## Keep it in sync [Section titled “Keep it in sync”](#keep-it-in-sync) Dealers change prices, sell cars and add photos all day. Poll for changes rather than re-downloading everything. **Every 15 minutes, fetch what changed.** Ask for vehicles updated since your last sync, **without** the live-stock filters, so you also see vehicles that were sold, unpublished or removed. Then upsert the live ones and remove the rest. ```graphql query Changes($showroomId: uuid!, $since: timestamptz!, $state: String!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } updated_at: { _gt: $since } } order_by: [{ updated_at: asc }, { id: asc }] limit: 200 ) { id updated_at is_published is_listed is_sold is_trashed pricing_codes price: pricing_config(path: $state) } } ``` * Save the largest `updated_at` you processed as the next `$since`. Timestamps have microseconds, so keep the full string. Don’t round-trip it through JavaScript’s `Date`, which keeps only milliseconds and makes you re-read rows. * If you get exactly `limit` rows, go again from the last `updated_at` before you sleep. [Pagination](/guides/pagination#syncing-by-updated_at) shows how to do this without skipping rows. * A vehicle that is sold, unlisted, unpublished, trashed or no longer priced in your state should come off your site. **Once a day, reconcile.** Fetch the `id` of every live vehicle and remove anything you hold that is not in the list. This catches a vehicle that was removed from the showroom entirely, which a change feed can’t show you. ```graphql query LiveIds($showroomId: uuid!, $state: String!, $offset: Int!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } is_published: { _eq: true } is_listed: { _eq: true } is_sold: { _eq: false } listing_type: { _eq: retail } pricing_codes: { _contains: [$state] } } order_by: { id: asc } limit: 500 offset: $offset ) { id } } ``` ## Checklist [Section titled “Checklist”](#checklist) * [ ] Scoped every query to the showroom, and to one state. * [ ] Prices divided by 100, rounded up, and labelled “drive away” or “excl. on-road costs”. * [ ] “Price on application” when `isVariable` is true. * [ ] Vehicle titles built from year, make, model and badge, not the `title` field. Show `badge` as the dealer wrote it. * [ ] Odometer hidden when `hide_odometer` is true. * [ ] Images requested at the size you display. * [ ] Links go to the dealer’s vehicle page with `?region_id=`. * [ ] Token cached for its 60 minutes, polling no more than every few minutes. # Vehicle URLs > Build the link to a vehicle's page on the dealer's Vyro website, with the state preselected. Every vehicle has a page on its dealer’s Vyro website. Link to it from your listing so shoppers can enquire, book a test drive or order. ```plaintext https://{showroom.domain}/vehicles/stocked/{vehicle.code}?region_id={pricing code} ``` ```graphql query VehicleLink($showroomId: uuid!, $vehicleId: uuid!) { showrooms_by_pk(id: $showroomId) { domain https_enabled } stocked_vehicles_by_pk(id: $vehicleId) { code } } ``` ```js function vehicleUrl(showroom, vehicle, pricingCode) { const scheme = showroom.https_enabled === false ? 'http' : 'https'; const domain = showroom.domain.replace(/^https?:\/\//, '').replace(/\/+$/, ''); const url = new URL(`${scheme}://${domain}/vehicles/stocked/${encodeURIComponent(vehicle.code)}`); if (pricingCode) url.searchParams.set('region_id', pricingCode); return url.toString(); } vehicleUrl( { domain: 'www.exampledealer.com.au', https_enabled: true }, { code: '2024-bmw-ix1-edrive20-xline-32c476f6-e87a-419b-84bb-72e902dca5af' }, 'AU_VIC', ); // https://www.exampledealer.com.au/vehicles/stocked/2024-bmw-ix1-edrive20-xline-32c476f6-e87a-419b-84bb-72e902dca5af?region_id=AU_VIC ``` * **`code` is the vehicle’s permanent URL key.** It is usually a readable slug, but treat it as an opaque string: don’t build it yourself or parse it. * **`region_id`** sets the state on the dealer’s site, so the shopper sees the price you showed them. * Add your own **UTM parameters** (`utm_source`, `utm_medium`, `utm_campaign`) so the dealer can see leads from you. Vyro records them on the enquiry. * A **sold** vehicle’s page keeps working, and says it’s sold. ## External links [Section titled “External links”](#external-links) Some vehicles have `external_cta_url`, a link to the listing on the dealer’s own (non-Vyro) website. When it is set, the dealer prefers you link there. # Assets > GraphQL queries to get and list Vyro assets (images and files) for vehicles, locations and showrooms. `assets` are images and files. Each belongs to one thing: a vehicle, location, showroom, test drive and so on. For vehicle images you normally read them through the vehicle; see [Images](/guides/images). ## Get one [Section titled “Get one”](#get-one) ```graphql query Asset($id: uuid!) { assets_by_pk(id: $id) { id src alt type placement order classification stocked_vehicle_id created_at } } ``` ## List [Section titled “List”](#list) Images for several vehicles at once, for example after a sync: ```graphql query ImagesForVehicles($vehicleIds: [uuid!]!) { assets( where: { stocked_vehicle_id: { _in: $vehicleIds } is_public: { _eq: true } } order_by: [{ stocked_vehicle_id: asc }, { placement: asc }, { order: asc }] limit: 100 ) { stocked_vehicle_id src placement order } } ``` A dealership location’s photos and logo: ```graphql query LocationImages($locationId: uuid!) { assets(where: { location_id: { _eq: $locationId }, is_public: { _eq: true } }) { src alt placement } } ``` ## Fields worth knowing [Section titled “Fields worth knowing”](#fields-worth-knowing) | Field | Notes | | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `src` | Absolute URL. Resize `ik.imagekit.io` URLs with `?tr=w-800,f-auto`. | | `placement` | `featured` (hero), `gallery`, `exterior`, `interior`, `logo`… Free text. | | `order` | Position within its placement, from 0. | | `type` | `image` for photos. | | `classification` | Detected shot: `Front`, `Front34`, `Side`, `Rear34`, `Rear`, `Interior`, `Wheel`, `Other`. | | `is_public` | Safe to show publicly. | | `stocked_vehicle_id`, `location_id`, `showroom_id`, `test_drive_id`… | What it belongs to. Only one is set. | # Enquiries (moments) > GraphQL queries to get and list Vyro enquiries, called moments, with the customer and vehicle they are about. An enquiry is stored as a **moment**: a customer reaching out to a dealer, usually about a vehicle. Moments come from website forms, chat, phone calls and partner leads. ## Get one [Section titled “Get one”](#get-one) ```graphql query Enquiry($id: uuid!) { moments_by_pk(id: $id) { id friendly_id type stage channel priority enquiry_reason created_at customer_full_name customer_email customer_phone is_guest utm_data stocked_vehicle { id code make model stock_id } location { name } owner { full_name email } form_submission { values } } } ``` ## List [Section titled “List”](#list) Today’s enquiries for one showroom, newest first: ```graphql query Enquiries($showroomId: uuid!, $since: timestamptz!) { moments( where: { showroom_id: { _eq: $showroomId }, created_at: { _gte: $since } } order_by: [{ created_at: desc }, { id: asc }] limit: 50 ) { id friendly_id type stage channel created_at customer_full_name customer_email stocked_vehicle { make model } } } ``` ### Useful filters [Section titled “Useful filters”](#useful-filters) | To find | Add to `where` | | ------------------- | --------------------------------------------------------------------- | | Not yet handled | `_or: [{ stage: { _eq: "pending" } }, { stage: { _is_null: true } }]` | | About a vehicle | `stocked_vehicle_id: { _eq: "…" }` | | Unassigned | `owner_id: { _is_null: true }` | | From your campaigns | `utm_data: { _contains: { utm_source: "yourbrand" } }` | | From guests | `is_guest: { _eq: true }` | Count them by stage: ```graphql query EnquiryCounts($showroomId: uuid!) { pending: moments_aggregate(where: { showroom_id: { _eq: $showroomId }, stage: { _eq: "pending" } }) { aggregate { count } } all: moments_aggregate(where: { showroom_id: { _eq: $showroomId } }) { aggregate { count } } } ``` ## Fields worth knowing [Section titled “Fields worth knowing”](#fields-worth-knowing) | Field | Notes | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `friendly_id` | Short reference to show staff and customers. | | `type` | What kind of moment: `enquiry` for a general enquiry, `stocked` for one about a stocked vehicle. | | `stage` | Where it is in the dealer’s process. Starts as `pending` (or empty), then for example `assigned`, `1st_contact`, `engaged`. Dealers can use their own stages, so don’t hard-code the list. | | `channel` | Where it came from: `online`, `phone`, `walk-in`, or a partner such as `carsales-enquiry`. Free text. | | `user_id` / `guest_user_id` | The customer. `is_guest` says which is set. | | `customer_*` | The customer’s name, email and phone, copied at the time of the enquiry. | | `utm_data` | JSON of the UTM parameters on the visit. | | `form_submission` | The form answers, when the enquiry came from a form. | # Form submissions > GraphQL queries to get and list Vyro form submissions and read their answers. A form submission is one set of answers to a [form](/recipes/forms). The answers are in `values`, keyed by each field’s `name`. ## Get one [Section titled “Get one”](#get-one) ```graphql query Submission($id: uuid!) { form_submissions_by_pk(id: $id) { id created_at full_name email phone values utm_data form { name } } } ``` ```json { "values": { "first_name": "Sam", "email": "sam@example.com", "message": "Is the iX1 still available?", "preferred_contact": "phone" } } ``` ## List [Section titled “List”](#list) Submissions for one showroom’s forms, newest first: ```graphql query Submissions($showroomId: uuid!) { form_submissions( where: { form: { showroom_id: { _eq: $showroomId } } } order_by: [{ created_at: desc }, { id: asc }] limit: 50 ) { id created_at full_name email form { name } values } } ``` For one form, filter `form_id: { _eq: "…" }`. To find submissions where an answer has a value, filter `values`: `values: { _contains: { preferred_contact: "phone" } }`. ## Fields worth knowing [Section titled “Fields worth knowing”](#fields-worth-knowing) | Field | Notes | | ----------------------------- | ----------------------------------------------------------------- | | `values` | All answers, as JSON keyed by field `name`. | | `full_name`, `email`, `phone` | Contact details, when the form asked for them. | | `user_id` | The customer, when they were signed in or an account was created. | | `utm_data` | UTM parameters on the visit. | # Forms > GraphQL queries to get and list a Vyro showroom's forms and their fields. `forms` are the dealer’s website forms: enquiry, finance, service, newsletter and so on. Each has `form_fields`. Submissions are in [Form submissions](/recipes/form-submissions). ## Get one [Section titled “Get one”](#get-one) ```graphql query Form($id: uuid!) { forms_by_pk(id: $id) { id name button_text on_success_message create_moment_on_submit form_fields(order_by: { order: asc }) { name label type options rules help_text } } } ``` ## List [Section titled “List”](#list) ```graphql query Forms($showroomId: uuid!) { forms( where: { showroom_id: { _eq: $showroomId }, is_internal: { _eq: false } } order_by: { name: asc } ) { id name created_at } } ``` ## Fields worth knowing [Section titled “Fields worth knowing”](#fields-worth-knowing) | Field | Notes | | ------------------------- | --------------------------------------------------------------- | | `create_moment_on_submit` | Submissions also create an [enquiry](/recipes/enquiries). | | `signup_user_on_submit` | Submissions create a customer account. | | `is_internal` | Used by staff in the Dashboard, not on the website. | | `form_fields.name` | The key the answer is stored under in a submission’s `values`. | | `form_fields.type` | The input type, such as text, email, phone, select or checkbox. | | `form_fields.options` | Choices for select and radio fields. | # Showrooms and locations > GraphQL queries to look up a Vyro showroom by code or domain and list its dealership locations. A **showroom** is a dealer (or dealer group, or brand) on Vyro, with its own website and stock. A showroom has **locations**: dealerships, service centres and delivery points. ## Get a showroom [Section titled “Get a showroom”](#get-a-showroom) By code, domain or id: ```graphql query ShowroomByCode($code: String!) { showrooms(where: { code: { _eq: $code } }, limit: 1) { id code name domain https_enabled phone email timezone default_locale default_pricing_code } } ``` ```graphql query ShowroomByDomain($domain: String!) { showrooms(where: { domain: { _eq: $domain } }, limit: 1) { id name } } ``` ## List locations [Section titled “List locations”](#list-locations) ```graphql query Locations($showroomId: uuid!) { locations( where: { showroom_id: { _eq: $showroomId }, is_dealership: { _eq: true } } order_by: [{ display_order: asc_nulls_last }, { name: asc }] ) { id name code phone email address timezone opening_hours geography is_available_for_test_drives } } ``` `address` is JSON: ```json { "address_line_1": "33 Breakfast Creek Road", "city": "Newstead", "state": "Queensland", "postcode": "4006", "country_code": "AU", "latitude": -27.450758, "longitude": 153.034073, "formatted_address": "33 Breakfast Creek Rd, Newstead QLD 4006, Australia" } ``` `state` can be the full name (`Queensland`) or the abbreviation (`QLD`). Normalise it before you use it; see [State pricing](/guides/state-pricing#picking-the-shoppers-state). ### Nearest locations [Section titled “Nearest locations”](#nearest-locations) `geography` is a GeoJSON point. Find locations within 50 km (distances are in metres; coordinates are longitude, latitude): ```graphql query Nearby($showroomId: uuid!, $point: geography!) { locations( where: { showroom_id: { _eq: $showroomId } geography: { _st_d_within: { distance: 50000, from: $point } } } ) { name address } } ``` ```json { "point": { "type": "Point", "coordinates": [151.2093, -33.8688] } } ``` # Stocked vehicles > GraphQL queries to get one Vyro stocked vehicle or list a showroom's stock, with prices, images and specifications. `stocked_vehicles` are the cars a dealer has for sale: new, demo and used. ## Get one [Section titled “Get one”](#get-one) By `id`: ```graphql query Vehicle($id: uuid!, $state: String!) { stocked_vehicles_by_pk(id: $id) { id code stock_id vin condition production_year make model badge series body_type fuel_type transmission drive_type colour interior odometer hide_odometer description is_sold available_from updated_at price: pricing_config(path: $state) location { name phone address } assets(where: { is_public: { _eq: true } }, order_by: [{ order: asc }, { id: asc }]) { src alt placement order } } } ``` By the `code` in its URL, or by your own `stock_id`: ```graphql query VehicleByCode($showroomId: uuid!, $code: String!) { stocked_vehicles( where: { code: { _eq: $code } showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } } limit: 1 ) { id make model } } ``` ## List [Section titled “List”](#list) ```graphql query Vehicles($showroomId: uuid!, $state: String!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } is_published: { _eq: true } is_listed: { _eq: true } is_sold: { _eq: false } pricing_codes: { _contains: [$state] } } order_by: [{ updated_at: desc }, { id: asc }] limit: 50 offset: 0 ) { id code condition production_year make model badge odometer price: pricing_config(path: $state) assets(where: { placement: { _eq: "featured" } }, limit: 1) { src } } } ``` ### Useful filters [Section titled “Useful filters”](#useful-filters) | To find | Add to `where` | | ------------------------- | --------------------------------------------- | | New cars only | `condition: { _eq: new }` | | Used and demo | `condition: { _in: [used, demo] }` | | One make, any case | `make: { _ilike: "toyota" }` | | Electric | `fuel_type: { _ilike: "%electric%" }` | | Under 50,000 km | `odometer: { _lt: 50000 }` | | 2022 or newer | `production_year: { _gte: 2022 }` | | Under $40,000 (any state) | `min_price: { _lt: 4000000 }` | | Discounted | `has_discount: { _eq: true }` | | At one location | `location_id: { _eq: "…" }` | | Arriving later | `available_from: { _gt: "now()" }` | | Changed since | `updated_at: { _gt: "2026-10-28T00:00:00Z" }` | ### Makes and models in stock [Section titled “Makes and models in stock”](#makes-and-models-in-stock) ```graphql query Makes($showroomId: uuid!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } is_published: { _eq: true } is_listed: { _eq: true } } distinct_on: [make] order_by: [{ make: asc }] ) { make } } ``` `distinct_on` needs the same field first in `order_by`. It scans the whole showroom, so cache the answer rather than running it per page view. ## Fields worth knowing [Section titled “Fields worth knowing”](#fields-worth-knowing) | Field | Type | Notes | | -------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `id` | uuid | Permanent. Use it as your key. | | `code` | string | The vehicle’s URL key. See [Vehicle URLs](/guides/vehicle-urls). | | `stock_id` | string | The dealer’s stock number. Unique within a dealer, not across dealers. | | `vin` | string | Often empty for new stock. Don’t rely on it to match vehicles across sources. | | `source_id`, `source_name` | string | The id and system the vehicle was imported from. | | `condition` | enum | `new`, `used` or `demo`. | | `production_year` | number | Model year. | | `odometer` | number | Kilometres. Hide it when `hide_odometer` is true. | | `range` | number | Electric range in kilometres, when known. | | `useable_battery` | number | Battery capacity in kWh, when known. | | `pricing_config` | JSON | Prices per state, in cents. See [Prices](/guides/prices). | | `pricing_codes` | string\[] | States the vehicle is priced in. | | `min_price`, `max_price` | number | Lowest and highest drive-away price across all states, in cents. For filtering and sorting only: show the state’s own price. | | `is_published` | boolean | Public. | | `is_listed` | boolean | Shown in lists and search. | | `is_sold` | boolean | Sold. Its page stays up. | | `available_from` | date | `null` means available now. | | `estimated_delivery_delay` | number | Days until it can be delivered. | | `body_type`, `fuel_type`, `transmission`, `colour` | string | Free text from the dealer. Normalise before you build filters. | | `vehicle_specification` | relationship | Manufacturer specs when matched. Select its fields, e.g. `vehicle_specification { ancap_rating seat_capacity combined_power_kw }`. | | `updated_at` | timestamp | Changes whenever the vehicle, its price or images change. | Caution Build the vehicle’s title from `production_year`, `make`, `model` and `badge`. The computed `title` field currently returns only the year. # Test drives > GraphQL queries to get and list Vyro test drive bookings with the customer, vehicle and location. `test_drives` are test drive bookings: who, which vehicle, where and when. ## Get one [Section titled “Get one”](#get-one) ```graphql query TestDrive($id: uuid!) { test_drives_by_pk(id: $id) { id stage scheduled_at ends_at notes is_licence_verified customer_full_name customer_email customer_phone stocked_vehicle { id make model stock_id } location { name address timezone } owner { full_name } } } ``` ## List [Section titled “List”](#list) Upcoming test drives for one showroom, soonest first: ```graphql query UpcomingTestDrives($showroomId: uuid!, $from: timestamptz!) { test_drives( where: { showroom_id: { _eq: $showroomId }, scheduled_at: { _gte: $from } } order_by: [{ scheduled_at: asc }, { id: asc }] limit: 50 ) { id stage scheduled_at customer_full_name stocked_vehicle { make model } location { name timezone } } } ``` Filter one location with `location_id`, or one vehicle with `stocked_vehicle_id`. ## Fields worth knowing [Section titled “Fields worth knowing”](#fields-worth-knowing) | Field | Notes | | ------------------------------------ | ---------------------------------------------------------------------- | | `scheduled_at`, `ends_at` | UTC. Show them in the location’s `timezone` (e.g. `Australia/Sydney`). | | `stage` | The booking’s status, e.g. `scheduled` or `completed`. | | `primary_driver`, `secondary_driver` | JSON with the drivers’ details. | | `is_licence_verified` | The dealer has checked the licence. | | `user_id` / `guest_user_id` | The customer. `is_guest` says which is set. | ```js new Date('2026-11-02T23:30:00+00:00').toLocaleString('en-AU', { timeZone: 'Australia/Sydney' }); // "03/11/2026, 10:30:00 am" ``` # Trade-ins > GraphQL queries to get and list Vyro trade-in requests and their valuations. A trade-in is a **trade-in plan** (`trade_in_plans`): the customer’s car and what they want to do with it. Valuers’ offers are **valuations** (`trade_in_valuations`) on the plan. ## Get one [Section titled “Get one”](#get-one) ```graphql query TradeIn($id: uuid!) { trade_in_plans_by_pk(id: $id) { id stage created_at year make model badge odometer condition registration_number state_of_registration postcode customer_full_name customer_email customer_phone customer_comment instant_valuation trade_in_valuations(order_by: { value: desc }) { valuer_name value value_low valuer_comments created_at } } } ``` ## List [Section titled “List”](#list) ```graphql query TradeIns($showroomId: uuid!) { trade_in_plans( where: { showroom_id: { _eq: $showroomId } } order_by: [{ created_at: desc }, { id: asc }] limit: 50 ) { id stage created_at year make model odometer customer_full_name trade_in_valuations_aggregate { aggregate { max { value } } } } } ``` ## Fields worth knowing [Section titled “Fields worth knowing”](#fields-worth-knowing) | Field | Notes | | -------------------------------------------- | ------------------------------------------------------------- | | `year`, `make`, `model`, `badge`, `odometer` | The customer’s car. Odometer in kilometres. | | `stage` | Status in the dealer’s process. New plans start as `pending`. | | `last_step` | How far the customer got in the trade-in form. | | `instant_valuation` | An automated estimate in cents, when one was given. | | `trade_in_valuations.value`, `value_low` | Valuer offers, in cents. | Like every money value in the API, valuations are in cents: `2650000` is $26,500. # Customers and guests > GraphQL queries to get and list a Vyro showroom's customers (users) and guest users, and find all of a customer's activity. A dealer’s customers come in two kinds: * **Users** (`users`) have an account. They signed in, ordered, or were added by the dealer. One user can belong to several showrooms. * **Guests** (`guest_users`) gave their details without an account, for example in an enquiry form. A guest belongs to one showroom. If they later sign in with the same email, `converted_to_user_id` points to their new user. ## Get one [Section titled “Get one”](#get-one) ```graphql query Customer($id: uuid!) { users_by_pk(id: $id) { id email first_name last_name phone stage source created_at last_logged_in } } ``` ```graphql query Guest($id: uuid!) { guest_users_by_pk(id: $id) { id email first_name last_name phone postcode created_at converted_to_user_id } } ``` ## List [Section titled “List”](#list) Customers of one showroom, newest first. Team members are users too, so filter by `role`. ```graphql query Customers($showroomId: uuid!) { users( where: { showroom_users: { showroom_id: { _eq: $showroomId } } role: { _eq: "customer" } } order_by: [{ created_at: desc }, { id: asc }] limit: 50 ) { id email first_name last_name phone created_at } } ``` Guests of one showroom who haven’t created an account: ```graphql query Guests($showroomId: uuid!) { guest_users( where: { showroom_id: { _eq: $showroomId }, converted_to_user_id: { _is_null: true } } order_by: [{ created_at: desc }, { id: asc }] limit: 50 ) { id email first_name last_name phone created_at } } ``` Find someone by email in either table: ```graphql query FindByEmail($email: String!) { users(where: { email: { _ilike: $email } }) { id email } guest_users(where: { email: { _ilike: $email } }) { id email showroom_id } } ``` ## A customer’s activity [Section titled “A customer’s activity”](#a-customers-activity) ```graphql query CustomerActivity($id: uuid!) { users_by_pk(id: $id) { email moments(order_by: { created_at: desc }) { id type stage created_at } test_drives(order_by: { scheduled_at: desc }) { id stage scheduled_at } trade_in_plans(order_by: { created_at: desc }) { id make model stage } wish_lists { stocked_vehicle_id } } } ``` The same lists exist on `guest_users_by_pk`: `moments`, `test_drives` and `trade_in_plans`. # Wishlist > GraphQL queries to get and list vehicles customers saved to their Vyro wishlist. When a shopper saves a vehicle, Vyro adds a row to `wish_list`. It’s a useful buying signal: who is interested in which car. ## Get one [Section titled “Get one”](#get-one) ```graphql query WishlistItem($id: uuid!) { wish_list_by_pk(id: $id) { id created_at user { full_name email } stocked_vehicle { id make model code } } } ``` ## List [Section titled “List”](#list) Saved vehicles for one showroom, newest first: ```graphql query Wishlist($showroomId: uuid!) { wish_list( where: { stocked_vehicle: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } } } order_by: [{ created_at: desc }, { id: asc }] limit: 50 ) { created_at user { full_name email } stocked_vehicle { make model stock_id } } } ``` The most-saved vehicles: ```graphql query MostSaved($showroomId: uuid!) { stocked_vehicles( where: { showroom_stocked_vehicles: { showroom_id: { _eq: $showroomId } } wish_lists: {} } order_by: { wish_lists_aggregate: { count: desc } } limit: 10 ) { make model stock_id wish_lists_aggregate { aggregate { count } } } } ``` The table is `wish_list`. From a vehicle or a customer, the relationship is `wish_lists`. # Authentication > Exchange a Vyro API client ID and secret for a 60-minute bearer token, and choose between user and app clients. Every API call carries a bearer token. You get one by exchanging an **API client** (a `client_id` and `client_secret`) at the authentication API. ```bash curl -s https://authentication-api.vyrolabs-staging.net/api-client/authenticate \ -H 'Content-Type: application/json' \ -d '{"client_id":"YOUR_CLIENT_ID","client_secret":"YOUR_CLIENT_SECRET"}' ``` ```json { "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 3600 } ``` Send it on every request: ```http Authorization: Bearer eyJhbGciOi... ``` * Tokens last **60 minutes** (`expires_in` is in seconds). There is no refresh token: authenticate again. * **Cache the token** and reuse it. Renew it a few minutes before it expires, not per request. * The secret is shown once, when the client is created. Store it like a password. If it leaks, delete the client in the Dashboard and create a new one. Use the authentication endpoint for your [environment](/environments). ## Two kinds of API client [Section titled “Two kinds of API client”](#two-kinds-of-api-client) | | User client | App client | | ------------- | ----------------------------------------------------- | ---------------------------------------------------------------------- | | Who it is for | A dealer automating their own showroom | A partner reading many dealers’ stock (marketplaces, comparison sites) | | Created by | A dealer owner or manager, in the Dashboard | Vyro, on request | | Acts as | That team member, with their role | Your app, with the grants the dealer approved | | Sees | Everything the team member can see in their showrooms | Only showrooms that installed your app, and only what the grants allow | ### User clients [Section titled “User clients”](#user-clients) A user client signs in **as a team member**. It can do exactly what that person can do in the Dashboard, in the showrooms they belong to, including reading enquiries, test drives and customers. Create one in the Dashboard: **Settings > Team**, open the team member, then the **API Clients** tab. Only owners and managers can create clients, for themselves or for team members below them. ### App clients [Section titled “App clients”](#app-clients) An app client belongs to an **app**, not a person. Dealers install the app on their showroom and approve its grants, for example `inventory:read` to read stock. The token only sees showrooms that installed the app. This is the right model for a marketplace or comparison site that shows stock from many dealers. Email to register an app. ## Roles [Section titled “Roles”](#roles) A token’s role decides which tables, columns and rows it can read. Role is in the token’s `x-hasura-default-role` claim. | Role | Typical use | Can read | | ------------------------------------ | ------------------------------ | ---------------------------------------------------------------------- | | `showroom:owner`, `showroom:manager` | Full dealer automation | Everything in their showrooms, and can write | | `showroom:support` | Customer service tools | Customers, enquiries, test drives, trade-ins, stock | | `showroom:reporting` | Reporting, BI, read-only feeds | As support, read-only | | `app` | Partner apps | Stock in installed showrooms (with `inventory:read`), plus public data | | `public` (no token) | Public storefront data | Published stock, showrooms, locations, forms | A field you can’t read is reported as `field '…' not found in type`, not as a permission error. If a query works in the explorer but not in your code, check which token each used. ## Public access [Section titled “Public access”](#public-access) Published stock, showrooms, locations and forms can be read **without a token**, the same way a showroom website does. Without a token, `stocked_vehicles` returns at most 500 rows, `assets` 100 and `showrooms` 1 per request. A larger `limit` is silently cut to the cap, so page with `offset`. A token doesn’t add the public data to what its role can see: it **replaces** it. A team member’s token sees only their own showrooms, including unpublished stock, with no row caps. To read other dealers’ published stock with a token, use an [app client](#app-clients) that those dealers have installed. # Changelog > Changes to the Vyro APIs and these docs that partners should know about. Additive changes (new fields, tables and endpoints) ship continuously and are not listed here. Anything that could break an integration is announced here and by email before it ships. Tell who should get those emails. ## October 2026 [Section titled “October 2026”](#october-2026) * **New developer docs.** Guides for stock feeds, filtering, pagination, prices, state pricing, images and vehicle URLs, and get-one and list recipes for vehicles, customers, enquiries, forms, test drives, trade-ins and wishlists. Every page is available as Markdown, with an [llms.txt](/llms.txt) index. * **Old links redirect.** Pages under `/docs/…` now redirect to their new addresses. # Environments > Vyro API endpoints for staging and the production regions (Asia-Pacific and Europe). Build against **staging**. It has the same schema as production and data you can change freely. Credentials are per environment: a staging client won’t work in production. | Environment | Authentication | GraphQL | | -------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------- | | Staging | `https://authentication-api.vyrolabs-staging.net/api-client/authenticate` | `https://graphql.vyrolabs-staging.net/v1/graphql` | | Production Asia-Pacific (AU, NZ) | `https://authentication-api.vyro.com.au/api-client/authenticate` | `https://graphql.vyro.com.au/v1/graphql` | | Production Europe | `https://authentication-api.vyro-eu.com/api-client/authenticate` | `https://graphql.vyro-eu.com/v1/graphql` | Dealers in Australia and New Zealand are in **Asia-Pacific**. Your region matches your Dashboard address: `dashboard.vyro.com.au` is Asia-Pacific and `dashboard.vyro-eu.com` is Europe. The [Pricing API](/pricing-api/authentication-and-environments) and [Inventory API](/inventory-api) have their own hosts in each environment. ## Staging data [Section titled “Staging data”](#staging-data) Staging is a copy of production’s structure, not its data. Showrooms there are test accounts. Vyro can set you up with a sandbox showroom and sample vehicles: email . # GraphQL explorer > Try Vyro GraphQL queries in the browser with autocomplete and schema docs. The [GraphQL explorer](https://graphiql.vyro.co) runs queries against **staging**, with autocomplete and the full schema in the **Docs** panel. 1. Get an [access token](/authentication). 2. Open [graphiql.vyro.co](https://graphiql.vyro.co). 3. Open the **Headers** tab at the bottom and add: ```json { "Authorization": "Bearer YOUR_ACCESS_TOKEN" } ``` 4. Press the refresh button (bottom left) to reload the schema for your role. The schema shows only the tables and fields your token can read. 5. Paste any query from these docs and press **Run**. Without a token the explorer uses the public role, which can read published stock, showrooms, locations and forms. ## Other clients [Section titled “Other clients”](#other-clients) Any GraphQL client works: Postman, Insomnia, Apollo, urql, `graphql-request`, or plain `fetch`. Point it at the [GraphQL endpoint](/environments) for your environment and send the `Authorization` header. To generate types, run your codegen tool against the endpoint with your token. The schema it sees is the one your role can read. ```bash npx @graphql-codegen/cli init # choose "introspect from URL" and add the Authorization header ``` # Build on Vyro > One GraphQL API for dealer stock, drive-away prices, images and leads. The same API that runs every Vyro showroom. ## Start here [Section titled “Start here”](#start-here) [Quickstart](/quickstart)Get a token and run your first query in five minutes. [Build a stock feed](/guides/stock-feed)Show a dealer's cars on your site: list, price, image, link. [Recipes](/recipes/stocked-vehicles)Copy-paste queries for vehicles, enquiries, test drives, trade-ins and more. [For partners](/partners)Marketplaces and comparison sites: access model, dealer consent and leads. ## Three things to know [Section titled “Three things to know”](#three-things-to-know) Money is in cents Every price is an integer number of cents. `4599000` is $45,990.00. [Prices](/guides/prices) Prices vary by state Australian drive-away prices differ per state. Ask for the one you need with `pricing_config(path: "AU_VIC")`. [State pricing](/guides/state-pricing) Times are UTC Timestamps are ISO 8601 in UTC. Dates without a time are `YYYY-MM-DD`. [Data formats](/reference/data-formats) ## APIs [Section titled “APIs”](#apis) [GraphQL API](/quickstart)Read and write showroom data: stock, customers, enquiries, test drives. [Pricing API](/pricing-api/overview)Australian drive-away quotes with stamp duty, registration, CTP and LCT. [Inventory API](/inventory-api)Push stock into Vyro in batches of up to 100 vehicles. [For AI agents](/reference/ai-agents)llms.txt and plain-Markdown versions of every page. # Inventory API > Push vehicles into Vyro in batches of up to 100, with per-state pricing and images, and track each batch to completion. The Inventory API **writes** stock into Vyro: from a dealer management system, a feed, or your own platform. To **read** stock, use the [GraphQL API](/recipes/stocked-vehicles). | Environment | Base URL | | ----------------------- | -------------------------------------------- | | Staging | `https://inventory-api.vyrolabs-staging.net` | | Production Asia-Pacific | `https://inventory-api.vyro.com.au` | | Production Europe | `https://inventory-api.vyro-eu.com` | The OpenAPI specification is at `/spec` on each host, with no token needed. Point a code generator at it for a typed client. ## Authentication [Section titled “Authentication”](#authentication) Use the same bearer token as the GraphQL API ([Authentication](/authentication)). Each batch has a `source_name` that says who owns the vehicles: | Client | `source_name` | Can write to | | ----------- | --------------------------------------- | --------------------------------- | | User client | `user:`, or leave it out | Showrooms that user belongs to | | App client | `app:` | Showrooms that installed your app | Vehicles are keyed by `source_name` plus your `source_id`. Sending the same `source_id` again updates the vehicle. ## Send a batch [Section titled “Send a batch”](#send-a-batch) 1. **POST up to 100 vehicles** (or 2 MB) to `/batch`. ```bash curl -s https://inventory-api.vyrolabs-staging.net/batch \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d @batch.json ``` ```json { "source_name": "user:3f2a9c1e-5b7d-4e8a-9c0f-1a2b3c4d5e6f", "items": [ { "showroom_ids": ["9b1d4e7a-2c3f-4a5b-8d6e-7f8091a2b3c4"], "vehicle": { "source_id": "STK-1042", "stock_id": "1042", "condition": "used", "production_year": "2022", "make": "Hyundai", "model": "Kona", "badge": "Active", "vin": "KMHK281EMNU000000", "odometer": 38500, "colour": "Ultimate Red", "body_type": "SUV", "fuel_type": "petrol", "transmission": "Automatic", "is_published": true, "is_listed": true, "is_sold": false, "estimated_delivery_delay": 0, "pricing": { "type": "basic", "price": 2299000, "currency": "AUD" } }, "location": { "source_id": "sydney", "name": "Example Motors Sydney" }, "assets": [ { "type": "image", "placement": "featured", "order": 0, "src": "https://example.com/1042/front.jpg" }, { "type": "image", "placement": "gallery", "order": 0, "src": "https://example.com/1042/side.jpg" }, { "type": "image", "placement": "gallery", "order": 1, "src": "https://example.com/1042/interior.jpg" } ] } ] } ``` You get a job id back straight away, with status `201`: ```json { "job_id": "6859392d-d604-4ca6-8842-28494e2bbc1b" } ``` 2. **Poll the job** at `GET /jobs/{job_id}` until `status` is `completed`. Batches usually finish within a minute. ```json { "id": "6859392d-d604-4ca6-8842-28494e2bbc1b", "status": "completed", "errors": [], "image_moderation": [ { "index": 4, "accepted": [{ "src": "https://ik.imagekit.io/vyro/…" }], "rejected": [{ "src": "…", "original_src": "https://example.com/1042/banner.jpg" }] } ] } ``` 3. **Read the result** with GraphQL, by `source_id`, to get each vehicle’s `id` and `code`. ```graphql query Imported($sourceIds: [String!]!) { stocked_vehicles(where: { source_id: { _in: $sourceIds } }) { id code source_id is_published } } ``` ## Required vehicle fields [Section titled “Required vehicle fields”](#required-vehicle-fields) `source_id`, `production_year` (a string such as `"2022"`), `make`, `model`, `condition` (`new`, `used` or `demo`), `is_published`, `is_listed`, `is_sold` and `estimated_delivery_delay` (days until it can be delivered; `0` if it’s on the lot). Everything else is optional, but the more you send, the better the listing. The [specification](https://inventory-api.vyro.com.au/spec) lists every field. * `odometer` is in kilometres. * `fuel_type` is one of `petrol`, `diesel`, `electric`, `hybrid`, `plug-in-hybrid`, `hydrogen`, `lpg`. * Dates are `YYYY-MM-DD`. ## Prices [Section titled “Prices”](#prices) Prices are in cents. Choose one of two shapes. **`pricing`** (basic): one price, used for every state the showroom sells in. ```json "pricing": { "type": "basic", "price": 2299000, "currency": "AUD", "excls_on_roads": false } ``` **`advanced_pricing`**: a full price per state. Use this when drive-away prices differ by state, which in Australia they usually do. ```json "advanced_pricing": { "type": "advanced", "items": [ { "pricing_code": "AU_NSW", "version": "v2", "driveAwayPrice": 3069000, "lineItems": { "dutiable": [{ "code": "vehicle-price", "value": 2850000 }], "onRoad": [{ "code": "stamp-duty", "value": 114000 }, { "code": "registration", "value": 105000 }], "additional": [] } }, { "pricing_code": "AU_VIC", "version": "v2", "driveAwayPrice": 3099000, "lineItems": { "dutiable": [{ "code": "vehicle-price", "value": 2850000 }], "onRoad": [{ "code": "on-road-costs", "value": 249000 }], "additional": [] } } ] } ``` A vehicle is only offered in the states you send. To calculate state drive-away prices, use the [Pricing API](/pricing-api/overview). ## Images [Section titled “Images”](#images) * `src` must be a public HTTPS URL. Vyro downloads each image, checks it, and re-hosts it on its image CDN, so the `src` you read back is different from the one you sent. * `placement` is `featured` for the hero (one per vehicle) and `gallery` for the rest. `order` counts from 0 **within each placement**. * Images are checked against Vyro’s acceptable use policy. Rejected ones (for example, text-only promotional graphics) are listed in the job’s `image_moderation.rejected`, with your `original_src`. Caution A vehicle with no images, or with a price of zero, is saved but not published. ## Mark a vehicle sold or remove it [Section titled “Mark a vehicle sold or remove it”](#mark-a-vehicle-sold-or-remove-it) Send the vehicle again in a batch with the same `source_id` and the new flags: * **Sold:** `is_sold: true`. Its page stays up and says it’s sold. * **Hidden from lists, still reachable by link:** `is_listed: false`. * **Off the site:** `is_published: false`. Send the whole vehicle each time, including its images, because a batch item replaces the vehicle. ## Limits [Section titled “Limits”](#limits) * 100 vehicles or 2 MB per batch. * 10,000 requests per minute. Above that you get `429 Too Many Requests`: back off and retry. # For partners and marketplaces > How marketplaces, comparison sites and other partners show Vyro dealers' stock: the access model, dealer consent, leads and attribution, and how to start. Vyro runs the online showrooms of car dealers, dealer groups and brands. Partners such as marketplaces and comparison sites can show those dealers’ stock, with live state drive-away prices and photos, and send shoppers back to the dealer to enquire, book a test drive or buy. **Dealer**Stock from their DMS, feeds or the Vyro Dashboard → **Vyro**Stock, state prices, images. The dealer installs your app and approves what it can read. → GraphQL **Your site**Listings, search, comparison ← Shopper clicks through to the dealer’s Vyro page with your UTM tags. Enquiries and test drives are recorded with your source. ## Access model [Section titled “Access model”](#access-model) Partners use an **app client**, never a dealer’s own login. * Vyro registers your **app** and gives you an API client for it. * Each dealer **installs** your app on their showroom and approves its **grants**. For a stock feed that is `inventory:read`. * Your token sees **only** showrooms that installed your app, and only what the grants allow. It can’t read customers, enquiries or other dealers. * If a dealer uninstalls your app, access stops on your next request. Your daily [reconcile](/guides/stock-feed#keep-it-in-sync) then removes their vehicles from your site. List the showrooms that have installed your app: ```graphql query InstalledShowrooms { showroom_apps(where: { app: { code: { _eq: "your-app-code" } } }) { created_at showroom { id code name domain default_pricing_code } } } ``` ## What you can show [Section titled “What you can show”](#what-you-can-show) | Data | Where to read it | | ---------------------------------------------------------------- | ------------------------------------------------------------------ | | Live stock: new, demo and used | [Build a stock feed](/guides/stock-feed) | | Drive-away price per state | [State pricing](/guides/state-pricing) | | Photos, hero first, in the dealer’s order | [Images](/guides/images) | | Dealer locations, hours and contact | [Showrooms and locations](/recipes/showrooms-and-locations) | | Manufacturer specifications (with `vehicle_specifications:read`) | [Stocked vehicles](/recipes/stocked-vehicles#fields-worth-knowing) | | A link to buy, enquire or book | [Vehicle URLs](/guides/vehicle-urls) | ## Leads and attribution [Section titled “Leads and attribution”](#leads-and-attribution) Send shoppers to the vehicle’s page on the dealer’s site with your UTM tags: ```plaintext https://{showroom.domain}/vehicles/stocked/{code}?region_id=AU_NSW&utm_source=yourbrand&utm_medium=referral ``` Enquiries, test drives and orders started from that visit record the tags in `utm_data`, so the dealer, and Vyro’s reporting, can see which leads came from you. ## Freshness [Section titled “Freshness”](#freshness) Poll for changes by `updated_at` every few minutes, and reconcile once a day. [Build a stock feed](/guides/stock-feed#keep-it-in-sync) shows both. ## Getting started [Section titled “Getting started”](#getting-started) 1. **Talk to us** at . We’ll set up a sandbox showroom on staging with sample stock, priced in every state, and an API client for it. 2. **Build against staging.** Start with the [Quickstart](/quickstart) and [Build a stock feed](/guides/stock-feed). 3. **Pilot with a few dealers.** We register your app in production and help the first dealers install it. 4. **Roll out.** More dealers install your app; nothing changes in your code. # Authentication and environments > Get a Pricing API token, choose an environment, check connectivity, and understand rate limits. The Pricing API uses the same **OAuth2 client-credentials** flow as the rest of the Vyro platform. Exchange your client ID and secret for an access token, then send it as a Bearer token. *** ## Credentials [Section titled “Credentials”](#credentials) * Vyro issues you an **API client**: a `client_id` and `client_secret`. * The client is bound to **your showroom code(s)**, e.g. `your-showroom-code`. Every quote names the showroom it is for in `showroomCode`. * A request for any showroom your client isn’t bound to is rejected with `403 SHOWROOM_ACCESS_DENIED`. * To get credentials, or credentials for an additional environment, email . **Keep the client secret on your server.** Call the Pricing API from your backend, never from a browser or mobile app. If you show drive-away prices on a website, compute them server-side (or cache them against your stock) and render the result. *** ## Get an access token [Section titled “Get an access token”](#get-an-access-token) ```bash curl -s -X POST "https://authentication-api.vyro.com.au/api-client/authenticate" \ -H 'Content-Type: application/json' \ -d '{ "client_id": "your-client-id", "client_secret": "your-client-secret" }' ``` **200 OK** ```json { "access_token": "your-access-token" } ``` > The access token is valid for up to 1 hour. Cache it and reuse it for every request until shortly before it expires. Don’t request a new token per quote. Send it on every Pricing API request: ```http Authorization: Bearer your-access-token ``` *** ## Environments [Section titled “Environments”](#environments) Use the region your credentials were issued for. Credentials, tokens and showroom codes are **per environment**: a staging client won’t work in production, and vice versa. | Environment | Authenticate endpoint | Pricing API base URL | | ----------------------- | ------------------------------------------------------------------------- | ------------------------------------------ | | Production Asia-Pacific | `https://authentication-api.vyro.com.au/api-client/authenticate` | `https://pricing-api.vyro.com.au` | | Production Europe | `https://authentication-api.vyro-eu.com/api-client/authenticate` | `https://pricing-api.vyro-eu.com` | | Staging | `https://authentication-api.vyrolabs-staging.net/api-client/authenticate` | `https://pricing-api.vyrolabs-staging.net` | | Dev | `https://authentication-api.vyrolabs.net/api-client/authenticate` | `https://pricing-api.vyrolabs.net` | Pricing is for Australian jurisdictions in every region. Most Australian businesses use **Production Asia-Pacific**. If you’re unsure which region you’re in, see [Environments](/environments) or contact support. *** ## Connectivity check [Section titled “Connectivity check”](#connectivity-check) These two routes need no token: ```bash curl -s https://pricing-api.vyro.com.au/healthcheck # OK curl -s https://pricing-api.vyro.com.au/spec # the OpenAPI specification (YAML) ``` *** ## Authentication errors [Section titled “Authentication errors”](#authentication-errors) | Status | `code` | Meaning | | ------ | ------------------------ | ------------------------------------------------------------------------------------------- | | `401` | `not-authenticated` | No token, or the token is invalid or expired. Request a new token. | | `403` | `USER_INACTIVE` | The API client has been revoked. Contact support. | | `403` | `SHOWROOM_ACCESS_DENIED` | The `showroomCode` isn’t one your client is bound to. Check for typos; codes are lowercase. | | `400` | `INVALID_SHOWROOM_CODE` | `showroomCode` is missing or blank. | *** ## Rate limits [Section titled “Rate limits”](#rate-limits) The API is built for high volume. A quote is computed in a few milliseconds. If you plan to price your whole inventory in a burst (for example, a nightly re-price of every listing), use the [batch endpoint](/pricing-api/other-endpoints) (up to 25 quotes per request), and let us know your expected peak volume so we can confirm capacity. # Errors, warnings and changes > Pricing API error codes, warnings, and how statutory rate changes reach you. ## Errors [Section titled “Errors”](#errors) Errors return a JSON body with a machine-readable `code` and a human-readable `message`: ```json { "code": "bad_request", "message": "request/body/vehicleGeneric/condition must be equal to one of the allowed values: new, used", "errors": [ { "path": "/body/vehicleGeneric/condition", "message": "must be equal to one of the allowed values: new, used", "errorCode": "enum.openapi.validation" } ] } ``` | Status | `code` | Meaning | What to do | | ------ | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | `400` | `bad_request` | The request doesn’t match the schema. `errors[].path` points at the field. | Fix the request. | | `400` | `INVALID_SHOWROOM_CODE` | `showroomCode` is missing or blank. | Send your showroom code. | | `400` | `UNSUPPORTED_FINANCIAL_YEAR` | `purchaseDate` is in a financial year we don’t price. | Check the date. | | `400` | `UNSUPPORTED_REGISTRATION_DATE` | `registration.startDate` is beyond the published rates and you sent `taxDatePolicy.outOfRange: "error"`. | See [Tax date policy](/pricing-api/other-endpoints#tax-date-policy). | | `400` | `UNSUPPORTED_REGISTRATION_TYPE` | `registration.type: "transfer"` in a state, or on a date, where its fee isn’t modelled. | Use QLD, WA or VIC within the modelled dates, or `"new"`. | | `400` | `INVALID_VEHICLE_INPUT` | An impossible vehicle date, a transfer of a `"new"` vehicle, or a used car above the LCT threshold missing the dates or `lctPreviouslyPaid` needed to price LCT. The message names the field. | Send the field. See [Used vehicles](/pricing-api/used-vehicles). | | `400` | `INVALID_TARGET` | Target drive-away input is invalid. | See [Target drive-away](/pricing-api/target-drive-away). | | `401` | `not-authenticated` / `UNAUTHENTICATED` | Missing, invalid or expired credentials. | Request a new access token. | | `403` | `SHOWROOM_ACCESS_DENIED` | Your client isn’t bound to that `showroomCode`. | Check the code; contact support to add a showroom. | | `403` | `USER_INACTIVE` | Your API client has been revoked. | Contact support. | | `422` | `TARGET_NOT_ACHIEVABLE` | The target drive-away is below the on-road charges alone. | Raise the target. | | `500` | `error` | Something went wrong on our side. | Retry with exponential backoff, then contact support with the time and request. | `4xx` errors are deterministic: retrying the same request returns the same error. *** ## Warnings [Section titled “Warnings”](#warnings) A `200` response can carry `metadata.warnings`: plain-language notes about **assumptions the engine had to make**. The quote is still valid, but a warning tells you which number is an assumption. For example: | Warning (abridged) | What it means | | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `AU_WA: Missing tareKg – unable to determine licence fee units.` | Registration couldn’t be priced. **The quote is incomplete.** Send `tareKg`. | | `AU_QLD: ICE vehicle without vehicle.cylinders - stamp duty assumes 1-4 cylinders…` | Duty may be too low for a 5+ cylinder vehicle. Send `cylinders`. | | `AU_VIC: Postcode missing for TAC risk zone – using default METRO.` | TAC assumes Melbourne metro. Send `registration.postcode`. | | `AU_QLD: Using the conservative upper-bound Class 1 CTP default…` | CTP is an estimate, not a specific insurer’s premium. | | `…: vehicleGeneric provided; make/model defaulted to "generic"…` | Informational. Make/model-specific rules (e.g. NSW’s lower-taxed EV list) can’t apply. | | `…: PHEV assumed to meet the LCT fuel-efficient test…` | Send `fuelConsumptionLPer100km` to confirm the LCT threshold. | | `…: LCT applied to a ute/cab-chassis without seats, gvmKg and kerbMassKg…` | Send them: most utes are exempt from LCT as commercial vehicles. | **Recommendations:** * **Log every warning** with the quote. * For **contract** figures, decide which warnings you accept. Missing-input warnings (`tareKg`, `cylinders`, `postcode`) mean you should fix the input and re-quote. * Don’t parse warning text in code; it may be reworded. Act on the inputs you send instead. *** ## How pricing changes reach you [Section titled “How pricing changes reach you”](#how-pricing-changes-reach-you) Government charges change, most often on **1 July**, with state budgets and regulator updates in between. * **We monitor official sources** (state revenue offices, transport departments, insurance regulators and the ATO) for every statutory figure the engine uses. Each rule’s sources are listed in [`GET /v1/rules`](/pricing-api/other-endpoints#get-v1rules). * **Changes are effective-dated.** A new rate applies to quotes whose `registration.startDate` is on or after its effective date, so quotes for earlier dates keep reproducing what applied at the time. * **You’re notified ahead of time.** When a change affects your quotes, we email you before the effective date with the components affected (stamp duty, registration, CTP, LCT, fees), the old and new amounts, and the effective date. Changes are released to Staging before Production. * **`ruleId`s carry the financial year** (e.g. `AU_QLD_STAMP_DUTY_FY27`), so you can see which year’s rates priced a stored quote. ### API compatibility [Section titled “API compatibility”](#api-compatibility) * New fields are **added**, never repurposed. New optional request fields never change the result of a request that doesn’t send them. * New `code` values, warning texts and line items can appear. Handle unknown values gracefully. * Breaking changes, if ever needed, would be announced in advance with a migration window. *** ## OpenAPI specification [Section titled “OpenAPI specification”](#openapi-specification) * Every environment serves its live specification at `GET /spec` (no authentication), e.g. `https://pricing-api.vyro.com.au/spec`. * [Download the specification](/pricing-api-openapi.yml) (OpenAPI 3.0) to generate a typed client: ```bash npx openapi-typescript https://pricing-api.vyro.com.au/spec --output pricing-api.ts ``` *** ## Support [Section titled “Support”](#support) * Integration questions, credentials, custom rate cards (insurer CTP, premium plates): . * Production issues: email support with the time (UTC), environment, `pricingCode`, `showroomCode` and, ideally, the request body. Every request is ticketed. # Other endpoints > Batch quotes, concessions, rule metadata and the OpenAPI specification for the Vyro Pricing API. All endpoints below need the same `Authorization: Bearer` token as [`POST /v1/quote`](/pricing-api/quotes). *** ## `POST /v1/quote/batch` [Section titled “POST /v1/quote/batch”](#post-v1quotebatch) Prices up to **25** quotes in one request. The body is a JSON array of quote requests; the response is an array of quote responses **in the same order**. ```bash curl -s -X POST "https://pricing-api.vyro.com.au/v1/quote/batch" \ -H "Authorization: Bearer your-access-token" \ -H 'Content-Type: application/json' \ -d '[ { ...quote request... }, { ...quote request... } ]' ``` The batch is validated as a whole. If any item is invalid, or names a showroom your client isn’t bound to, the whole batch is rejected with that item’s error and no quotes are returned. Use batches for bulk re-pricing (e.g. refreshing every listing’s drive-away nightly) and single quotes for interactive use. *** ## `GET /v1/concessions` [Section titled “GET /v1/concessions”](#get-v1concessions) Lists the concession codes a jurisdiction recognises, with labels for your UI. | Query parameter | Required | Description | | --------------- | -------- | ------------- | | `pricingCode` | Yes | e.g. `AU_QLD` | ```bash curl -s "https://pricing-api.vyro.com.au/v1/concessions?pricingCode=AU_QLD" \ -H "Authorization: Bearer your-access-token" ``` **200 OK** ```json { "pricingCode": "AU_QLD", "concessions": [ { "type": "au_qld_pre_1994_pensioner", "label": "QLD pre-1994 pensioner", "description": "Pre-1994 pensioner concession (MR65) – alternate rego fee schedule, duty unchanged." }, { "type": "au_qld_concession_card_post_1994", "label": "QLD post-1994 concession / seniors card", "description": "Concession card or seniors card issued after 1 Jul 1994 – reduced rego component for private Category 1 vehicles." } ] } ``` Send the chosen `type` as `customer.concession.type` with `hasConcession: true`. Fetch this list rather than hard-coding it; concessions change with state budgets. *** ## `GET /v1/rules` [Section titled “GET /v1/rules”](#get-v1rules) Returns the rules active for a jurisdiction, showroom and date, and the **range of dates we have published rates for**. Use it to bound the dates your UI allows, and to see the official source behind each rule. | Query parameter | Required | Description | | --------------- | -------- | -------------------------------------------------- | | `pricingCode` | Yes | e.g. `AU_QLD` | | `showroomCode` | Yes | Your showroom code. | | `date` | Yes | `YYYY-MM-DD`: which rules are active on this date. | ```bash curl -s "https://pricing-api.vyro.com.au/v1/rules?pricingCode=AU_QLD&showroomCode=your-showroom-code&date=2026-10-01" \ -H "Authorization: Bearer your-access-token" ``` **200 OK** (abridged: one rule shown) ```json { "rules": [ { "id": "AU_QLD_REGISTRATION_TRANSFER_FY27", "name": "Registration transfer fee", "description": "Vehicle registration transfer fee for AU_QLD used-vehicle transfers (registration.type = \"transfer\").", "pricingCode": "AU_QLD", "startDate": "2026-07-01", "endDate": "2027-06-30", "priority": 30, "verification": { "mode": "statutory", "componentType": "registration", "officialSources": [ { "label": "Queensland Government - Registration costs: Other registration related fees (Transfer fees - vehicle or plate, cost as at 1 July 2026)", "url": "https://www.qld.gov.au/transport/registration/fees/cost", "authority": "transport", "kind": "html", "official": true } ] } } ], "supportedTaxRange": { "start": "2025-07-01", "end": "2027-06-30", "financialYears": ["2025-2026", "2026-2027"] } } ``` *** ## Tax date policy [Section titled “Tax date policy”](#tax-date-policy) Statutory rates are published per financial year, and `registration.startDate` selects which year’s rates apply. `GET /v1/rules` → `supportedTaxRange` tells you the dates we have rates for. For a date **beyond** that range (e.g. a forward order dated next financial year), choose the behaviour with `taxDatePolicy.outOfRange`: | Value | Behaviour | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `error` | **Recommended.** Reject with `400 UNSUPPORTED_REGISTRATION_DATE`, so an out-of-range date can never produce a figure. | | `prior_year` | Price with the latest published year’s rates, and report it: `metadata.taxFallbackApplied` is `true`, `metadata.taxEffectiveDate` shows the date used, and a warning is added. Useful for indicative forward quotes. | | `legacy` (default) | Kept for backwards compatibility. **An out-of-range date returns an empty quote (`driveAway: 0`).** Don’t rely on it. | **Send `taxDatePolicy` on every request.** For contract figures use `error`: ```json { "registration": { "termMonths": 12, "startDate": "2026-10-01" }, "taxDatePolicy": { "outOfRange": "error" } } ``` ## `GET /spec` [Section titled “GET /spec”](#get-spec) The OpenAPI 3 specification, served by every environment without authentication. A copy is also [available here](/pricing-api-openapi.yml). Use it to generate a typed client. # Pricing API > Australian drive-away quotes for new and used vehicles: stamp duty, registration, CTP and luxury car tax for every state, from one API call. The Pricing API calculates **drive-away prices** for vehicles in every Australian state and territory. You send the commercial details of a deal. It returns every government charge: stamp duty, registration, CTP or TAC, plates, Luxury Car Tax and transfer fees, each as an itemised line with the rule that produced it. It is the same engine that powers on-road cost calculations across the Vyro platform. It is used by manufacturers, dealer groups and used-vehicle retailers for website advertising and for the figures that go on a customer contract. *** ## What it covers [Section titled “What it covers”](#what-it-covers) * **All 8 jurisdictions**: `AU_NSW`, `AU_VIC`, `AU_QLD`, `AU_WA`, `AU_SA`, `AU_TAS`, `AU_ACT`, `AU_NT`. * **Every fuel type**: battery electric (`BEV`), plug-in hybrid (`PHEV`), hybrid (`HYBRID`) and petrol/diesel (`ICE`). Fuel type changes duty rates, LCT thresholds and registration bands. * **New and used vehicles.** Used vehicles get used-vehicle duty treatment, the ATO two-year LCT rule, and can keep their existing registration (a transfer) instead of a new one. See [Used vehicles](/pricing-api/used-vehicles). * **Private and business buyers**, and state **concessions** (pensioner, seniors, disability, primary producer and more). * **Working back from a drive-away price**: give us the advertised drive-away and we solve the vehicle price. See [Target drive-away](/pricing-api/target-drive-away). * **Auditability.** Every line carries the `ruleId` that produced it, and every response lists the rules applied, so you can store a quote and explain exactly how it was derived. *** ## How pricing works [Section titled “How pricing works”](#how-pricing-works) A quote is built in three stages. You supply the inputs for each stage, and the engine adds the statutory charges: | Stage | You send | The engine adds | | ------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------- | | `dutiable` | The vehicle price, options, accessories, dealer delivery, any pre-contract discount | – | | `onRoad` | Optional dealer-supplied on-road extras (e.g. tinting) | Stamp duty, LCT, registration, CTP/TAC, plates, transfer fees | | `additional` | Post-contract adjustments (e.g. a manufacturer cashback) | – | `driveAway = dutiable + onRoad + additional`. **All money is in cents (AUD).** `3499000` is $34,990.00. Negative values reduce a total. *** ## Quickstart [Section titled “Quickstart”](#quickstart) ### 1. Get credentials [Section titled “1. Get credentials”](#1-get-credentials) Vyro issues you an **API client** (a client ID and client secret) scoped to your showroom code. Email if you don’t have one yet. See [Authentication & environments](/pricing-api/authentication-and-environments). ### 2. Request an access token [Section titled “2. Request an access token”](#2-request-an-access-token) ```bash curl -s -X POST "https://authentication-api.vyro.com.au/api-client/authenticate" \ -H 'Content-Type: application/json' \ -d '{ "client_id": "your-client-id", "client_secret": "your-client-secret" }' ``` ```json { "access_token": "your-access-token" } ``` ### 3. Price a vehicle [Section titled “3. Price a vehicle”](#3-price-a-vehicle) A used, 4-cylinder SUV in Queensland, sold for $34,990 with its existing registration transferring to the buyer: ```bash curl -s -X POST "https://pricing-api.vyro.com.au/v1/quote" \ -H "Authorization: Bearer your-access-token" \ -H 'Content-Type: application/json' \ -d '{ "pricingCode": "AU_QLD", "showroomCode": "your-showroom-code", "purchaseDate": "2026-10-01", "vehicleGeneric": { "bodyType": "suv", "fuelType": "ICE", "cylinders": 4, "tareKg": 1550, "year": 2021, "condition": "used", "manufactureDate": "2021-08-31" }, "pricingInputs": { "dutiable": [{ "description": "Vehicle price", "value": 3499000 }] }, "registration": { "termMonths": 12, "startDate": "2026-10-01", "type": "transfer" }, "customer": { "customerType": "consumer", "usageType": "consumer" } }' ``` **200 OK** ```json { "currency": "AUD", "pricingCode": "AU_QLD", "financialYear": "2026-2027", "categories": { "dutiable": [ { "description": "Vehicle price", "value": 3499000, "amounts": { "beforeTax": 3180909, "taxAmount": 318091, "afterTax": 3499000, "currency": "AUD" }, "tax": { "code": "GST", "jurisdiction": "AU", "rateBps": 1000, "mode": "inclusive", "source": "engine" }, "ruleId": "AU_QLD_DUTIABLE_VALUE_FY27" } ], "onRoad": [ { "description": "Stamp Duty", "value": 105000, "tax": { "code": "GST", "jurisdiction": "AU", "rateBps": 0, "mode": "exempt", "source": "engine" }, "amounts": { "beforeTax": 105000, "taxAmount": 0, "afterTax": 105000, "currency": "AUD" }, "ruleId": "AU_QLD_STAMP_DUTY_FY27" }, { "description": "Vehicle registration transfer fee", "value": 3365, "tax": { "code": "GST", "jurisdiction": "AU", "rateBps": 0, "mode": "exempt", "source": "engine" }, "amounts": { "beforeTax": 3365, "taxAmount": 0, "afterTax": 3365, "currency": "AUD" }, "ruleId": "AU_QLD_REGISTRATION_TRANSFER_FY27" } ], "additional": [] }, "totals": { "dutiable": 3499000, "onRoad": 108365, "additional": 0, "driveAway": 3607365, "tax": { "dutiable": 318091, "onRoad": 0, "additional": 0, "overall": 318091 } }, "metadata": { "rulesVersion": "AU_QLD_FY26_v1", "appliedRules": [ "AU_QLD_DUTIABLE_VALUE_FY27", "AU_QLD_STAMP_DUTY_FY27", "AU_QLD_REGISTRATION_TRANSFER_FY27" ], "appliedConcessions": [], "warnings": [ "AU_QLD: vehicleGeneric provided; make/model defaulted to \"generic\" and \"generic\". Provide make/model for model-specific rules." ], "taxEffectiveDate": "2026-10-01", "taxFinancialYear": "2026-2027", "taxDatePolicy": "legacy", "taxFallbackApplied": false } } ``` The drive-away is **$36,073.65**: $34,990.00 for the vehicle, $1,050.00 stamp duty (Queensland charges $3 per $100 for a 1–4 cylinder vehicle) and the $33.65 transfer fee. No new registration, CTP or plates are charged, because the vehicle’s registration carries over. *** ## Next steps [Section titled “Next steps”](#next-steps) * [Authentication & environments](/pricing-api/authentication-and-environments): tokens, regions, showroom scoping. * [Quote reference](/pricing-api/quotes): every request and response field. * [Used vehicles](/pricing-api/used-vehicles): condition, cylinders, LCT and registration transfers. * [Target drive-away](/pricing-api/target-drive-away): solve the price from an advertised drive-away. * [States & inputs](/pricing-api/states-and-inputs): what each jurisdiction needs, and the assumptions we make. * [Other endpoints](/pricing-api/other-endpoints): batch quotes, concessions and rule metadata. * [Errors, warnings & changes](/pricing-api/errors-warnings-and-changes): error codes, warnings, and how statutory changes reach you. * [OpenAPI specification](/pricing-api-openapi.yml): the full machine-readable contract. # Quote reference > Every request and response field of POST /v1/quote, the Vyro Pricing API drive-away quote. ## `POST /v1/quote` [Section titled “POST /v1/quote”](#post-v1quote) Calculates a drive-away price. Send JSON. All money is in **cents (AUD)**. *** ## Request body [Section titled “Request body”](#request-body) ### Top level [Section titled “Top level”](#top-level) | Field | Type | Required | Description | | ---------------------- | ------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pricingCode` | string | Yes | Jurisdiction: `AU_NSW`, `AU_VIC`, `AU_QLD`, `AU_WA`, `AU_SA`, `AU_TAS`, `AU_ACT` or `AU_NT`. This is the state the vehicle is registered in. | | `showroomCode` | string | Yes | Your showroom code. Must be one your API client is bound to. | | `purchaseDate` | date | Yes | Contract date, `YYYY-MM-DD`. Sets the financial year and is the supply date for the LCT two-year test. | | `vehicle` | object | one of | A vehicle described by make/model/year. See [Vehicle](#vehicle). | | `vehicleGeneric` | object | one of | A vehicle described by body type, when you don’t have or don’t want to send make/model. See [Vehicle](#vehicle). | | `pricingInputs` | object | Yes | Your commercial inputs, split into `dutiable`, `onRoad` and `additional`. See [Pricing inputs](#pricing-inputs). | | `registration` | object | Yes | Registration term, start date, postcode and type. See [Registration](#registration). | | `customer` | object | Yes | Buyer type and any concession. See [Customer](#customer). | | `taxDatePolicy` | object | recommended | What to do when `registration.startDate` is beyond the dates we have published rates for. **Send `{ "outOfRange": "error" }`.** See [Tax date policy](/pricing-api/other-endpoints#tax-date-policy). | | `pricingInputDefaults` | object | | Default `tax` metadata applied to every input line that doesn’t carry its own. | Send exactly one of `vehicle` or `vehicleGeneric`. If you send both, `vehicle` is used and a warning is returned. ### Vehicle [Section titled “Vehicle”](#vehicle) `vehicle` requires `make`, `model`, `year` and `fuelType`. `vehicleGeneric` requires `bodyType` and `fuelType`. Every other field is optional and can be sent on either shape. | Field | Type | Description | | -------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `make`, `model` | string | `vehicle` only. Case-insensitive. | | `year` | integer | Model year. Required on `vehicle`, optional on `vehicleGeneric`. | | `bodyType` | string | `passenger`, `suv`, `ute`, `van`, `truck`, `cab_chassis`, `trailer`, `tractor`. Required on `vehicleGeneric`. `van` means a goods van; send people movers as `passenger`. Victoria and SA tax utes and goods vans differently (see [Used vehicles](/pricing-api/used-vehicles)). | | `fuelType` | string | `BEV`, `PHEV`, `HYBRID` or `ICE`. | | `tareKg` | number | Tare mass in kg. Several states band registration by weight. See [States & inputs](/pricing-api/states-and-inputs#tare-weight). | | `gvmKg` | number | Gross vehicle mass in kg. Vehicles over 4,500 kg are heavy vehicles for duty purposes (e.g. no Queensland $100,000+ surcharge). | | `cylinders` | integer | Engine cylinders for `ICE`, `HYBRID` and `PHEV` (rotary: twice the rotors). Queensland duty and registration depend on it. | | `condition` | string | `new` or `used`. See [Used vehicles](/pricing-api/used-vehicles). | | `firstRegistrationDate` | date | Date first registered in Australia. Used vehicles: proves the car is outside the LCT two-year window. | | `importDate` | date | Date imported (entered for home consumption). Used vehicles: the LCT two-year clock. | | `manufactureDate` | date | Build date. Used vehicles: can show the car is within the LCT window. | | `lctPreviouslyPaid` | integer | LCT already paid on this car, in cents. Required for a used car within the LCT window above the threshold. | | `co2GramsPerKm` | number | Combined CO2 emissions. Victoria’s “green car” duty rate applies at 120 g/km or less; BEVs and PHEVs qualify automatically. | | `fuelConsumptionLPer100km` | number | Combined fuel consumption from the fuel label. LCT fuel-efficient threshold applies at 3.5 L/100km or less. | | `seats` | integer | Seats including the driver. LCT: 10+ seats is not a car; used in the ute commercial-vehicle test. | | `kerbMassKg` | number | Kerb (unladen) mass. With `gvmKg`, decides the LCT load tests for utes and heavy-load vehicles. | | `isWheelchairModified` | boolean | Vehicle modified for wheelchair access (NSW disability concessions). | ### Pricing inputs [Section titled “Pricing inputs”](#pricing-inputs) Each of `dutiable` (required), `onRoad` and `additional` is a list of **lines**: | Field | Type | Required | Description | | ------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | `description` | string | Yes | Your label. It is echoed back in the response. | | `value` | integer | Yes | Amount in cents, **GST-inclusive** by default. Negative values are discounts. | | `tag` | string | | `dealer_delivery` marks a dealer delivery fee. States treat delivery differently for duty and LCT; tag it and we handle that. | | `tax` | object | | Override the tax treatment of this line: `mode` (`inclusive`, `exclusive` or `exempt`), `rateBps` (e.g. `1000` = 10%). | Where each amount goes: * **`dutiable`**: the vehicle price, factory options, accessories fitted before delivery, dealer delivery (tagged), and discounts agreed before the contract. Stamp duty and LCT are calculated from this stage. Never send a trade-in here: it is payment, not a discount. * **`onRoad`**: dealer-supplied extras you want shown with on-road costs (tinting, a dealer admin fee). Don’t send government charges here; the engine calculates those. * **`additional`**: amounts applied after duty, which don’t change the dutiable value, such as a manufacturer cashback paid after sale. For a new vehicle, most states charge duty on the manufacturer’s list price, so a negative `dutiable` discount does not lower the duty. For a used vehicle in QLD and WA it does (see [Used vehicles](/pricing-api/used-vehicles)). ### Registration [Section titled “Registration”](#registration) | Field | Type | Required | Description | | ------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `termMonths` | integer | Yes | `1`, `3`, `6` or `12`. Supported terms vary by state; an unsupported term returns a warning. Queensland adds a short-term surcharge under 12 months. Ignored for a transfer. | | `startDate` | date | Yes | Registration start (or transfer) date. **Selects the statutory rates**, so a quote dated after 1 July uses that financial year’s fees. | | `postcode` | string | | Garaging postcode. **Required for accurate Victorian quotes** (the TAC charge varies by postcode). | | `type` | string | | `new` (default): register the vehicle for `termMonths`. `transfer`: the vehicle is already registered and its registration transfers to the buyer (QLD, WA and VIC). See [Used vehicles](/pricing-api/used-vehicles#registration-transfer). | ### Customer [Section titled “Customer”](#customer) | Field | Type | Required | Description | | -------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `customerType` | string | Yes | `consumer` or `commercial`. | | `usageType` | string | Yes | `consumer` (private use) or `commercial` (business use). | | `concession` | object | | `hasConcession` (boolean) and `type`, a concession code from [`GET /v1/concessions`](/pricing-api/other-endpoints#get-v1concessions). One concession per quote. | **Note:** For no concession, omit `concession` entirely. `"type": null` is rejected. *** ## Response [Section titled “Response”](#response) **200 OK** | Field | Description | | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `currency` | Always `AUD`. | | `pricingCode` | The jurisdiction priced. | | `financialYear` | Financial year of `purchaseDate`, e.g. `2026-2027`. | | `categories.dutiable` | Your dutiable lines, echoed back. | | `categories.onRoad` | Your on-road lines, then every statutory charge the engine added. | | `categories.additional` | Your post-contract lines. | | `totals` | `dutiable`, `onRoad`, `additional`, **`driveAway`**, and `tax` (GST per stage and `overall`). | | `metadata.appliedRules` | The rules that ran, in order. | | `metadata.appliedConcessions` | Concessions that changed the quote. | | `metadata.warnings` | Non-fatal notes about assumptions and defaults. **Log these.** See [Errors, warnings & changes](/pricing-api/errors-warnings-and-changes#warnings). | | `metadata.taxEffectiveDate` | The date actually used to select statutory rates. | | `metadata.taxFinancialYear`, `taxDatePolicy`, `taxFallbackApplied` | How the tax date was resolved. | Each line has: | Field | Description | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `description` | Display label. Statutory lines use consistent labels such as `Stamp Duty`, `Registration Fee`, `CTP Fee`, `TAC Charge`, `Plates` and `Luxury Car Tax (LCT)`. | | `value` | Amount in cents. | | `ruleId` | The rule that produced the line, e.g. `AU_QLD_STAMP_DUTY_FY27`. **Use this, not `description`, to identify a charge in code.** | | `components` | Optional breakdown, e.g. a registration fee’s sub-fees. | | `tax`, `amounts` | GST treatment and the before-tax / tax / after-tax split. Government charges are GST-exempt. | *** ## Example: new vehicle, NSW [Section titled “Example: new vehicle, NSW”](#example-new-vehicle-nsw) A new BEV with dealer delivery, a window tint, and a manufacturer cashback paid after sale. ```bash curl -s -X POST "https://pricing-api.vyro.com.au/v1/quote" \ -H "Authorization: Bearer your-access-token" \ -H 'Content-Type: application/json' \ -d '{ "pricingCode": "AU_NSW", "showroomCode": "your-showroom-code", "purchaseDate": "2026-10-01", "vehicle": { "make": "Hyundai", "model": "IONIQ 5", "year": 2026, "fuelType": "BEV", "tareKg": 1990 }, "pricingInputs": { "dutiable": [ { "description": "Vehicle price", "value": 6499000 }, { "description": "Dealer delivery", "value": 150000, "tag": "dealer_delivery" } ], "onRoad": [{ "description": "Window tint", "value": 49500 }], "additional": [{ "description": "Manufacturer cashback", "value": -200000 }] }, "registration": { "termMonths": 12, "startDate": "2026-10-01" }, "customer": { "customerType": "consumer", "usageType": "consumer" } }' ``` **200 OK** (abridged: the per-line `tax` and `amounts` objects are omitted) ```json { "currency": "AUD", "pricingCode": "AU_NSW", "financialYear": "2026-2027", "categories": { "dutiable": [ { "description": "Vehicle price", "value": 6499000, "ruleId": "AU_NSW_DUTIABLE_VALUE_FY27" }, { "description": "Delivery Fee", "value": 150000, "ruleId": "AU_NSW_DUTIABLE_VALUE_FY27" } ], "onRoad": [ { "description": "Window tint", "value": 49500, "ruleId": "AU_NSW_ON_ROAD_INPUTS_FY27" }, { "description": "Stamp Duty", "value": 242500, "ruleId": "AU_NSW_STAMP_DUTY_FY27" }, { "description": "Registration Fee", "value": 63400, "ruleId": "AU_NSW_REGISTRATION_FY27", "components": [ { "description": "MVT – lower taxed private light vehicle, 1,500 < tare ≤ 2,500 kg (12m)", "value": 55000 }, { "description": "Registration administration fee – all registrable light vehicles, 12-month term (12m)", "value": 8400 } ] }, { "description": "Standard general issue plates – new or replacement for car/light truck", "value": 5800, "ruleId": "AU_NSW_REGISTRATION_FY27" }, { "description": "NSW registration discount (2026-27, 12m)", "value": -10000, "ruleId": "AU_NSW_REGISTRATION_DISCOUNT_2026_27" }, { "description": "CTP Fee", "value": 53600, "ruleId": "AU_NSW_CTP_FY27" } ], "additional": [ { "description": "Manufacturer cashback", "value": -200000, "ruleId": "AU_NSW_POST_SALE_REBATES_FY27" } ] }, "totals": { "dutiable": 6649000, "onRoad": 404800, "additional": -200000, "driveAway": 6853800, "tax": { "dutiable": 604454, "onRoad": 4500, "additional": -18182, "overall": 590772 } }, "metadata": { "rulesVersion": "AU_NSW_FY26_v1", "appliedRules": [ "AU_NSW_DUTIABLE_VALUE_FY27", "AU_NSW_ON_ROAD_INPUTS_FY27", "AU_NSW_STAMP_DUTY_FY27", "AU_NSW_REGISTRATION_FY27", "AU_NSW_REGISTRATION_DISCOUNT_2026_27", "AU_NSW_CTP_FY27", "AU_NSW_POST_SALE_REBATES_FY27" ], "appliedConcessions": [], "warnings": [], "taxEffectiveDate": "2026-10-01", "taxFinancialYear": "2026-2027", "taxDatePolicy": "legacy", "taxFallbackApplied": false } } ``` Reading it: duty is charged on the $66,490 dutiable value (price plus delivery). The cashback in `additional` lowers the drive-away to **$68,538.00** without lowering the duty. *** ## Tips [Section titled “Tips”](#tips) * **Store the whole response** with the deal. `ruleId`s and `appliedRules` let you, and us, explain any figure later. * **Re-quote on the contract date.** Statutory rates change on fixed dates, most often 1 July. A quote is only valid for the `registration.startDate` it was priced for. * **Display `description`, reason on `ruleId`.** Labels are made for customers and may be refined over time; rule IDs are stable within a financial year. # States and inputs > What each Australian state and territory needs to price a vehicle, and the assumptions the Pricing API makes. Every state taxes vehicles differently. This page lists the inputs that change the price in each state, and the assumptions we make when you don’t send them. Every assumption we make is also returned in `metadata.warnings` on the quote it affected. *** ## What matters where [Section titled “What matters where”](#what-matters-where) | State | Inputs that change the price | Notes | | -------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AU_NSW` | `tareKg`, `fuelType`, `make`/`model` (lower-taxed EV list), concession | Registration tax is banded by tare weight. CTP is a default estimate. | | `AU_VIC` | `registration.postcode`, `fuelType` / `co2GramsPerKm`, `bodyType` + `condition`, concession | The TAC charge varies by garaging postcode. “Green” cars (≤ 120 g/km CO2, incl. EVs) get the lowest duty rate. Utes and goods vans are non-passenger; people movers (8 seats or fewer) are passenger cars. | | `AU_QLD` | `cylinders`, `fuelType`, `usageType`, `gvmKg`, concession | Duty and registration are banded by cylinders for combustion engines. Business use has its own registration rate. CTP is a default estimate. | | `AU_WA` | `tareKg`, `condition`, concession | Duty uses a sliding scale between $25,000 and $50,000, rounded down to 5 cents. Registration is $29.76 per 100 kg of tare, capped at $586 a year (2026–27). | | `AU_SA` | `fuelType`, concession | | | `AU_TAS` | `fuelType`, concession | Compulsory insurance is the MAIB charge. | | `AU_ACT` | `tareKg`, `fuelType`, concession | Stamp duty is currently modelled for **battery-electric vehicles only**. For other fuel types the quote omits duty and returns a warning. | | `AU_NT` | `fuelType`, concession | | Used-vehicle treatment (market-value duty, registration transfer) is modelled for **QLD, WA and VIC**. See [Used vehicles](/pricing-api/used-vehicles). *** ## Tare weight [Section titled “Tare weight”](#tare-weight) `tareKg` is needed whenever a **new registration** is priced in NSW, WA or ACT. Without it, the weight-based part of registration can’t be calculated: the quote omits it and returns a warning such as `AU_WA: Missing tareKg – unable to determine licence fee units.` **Treat that warning as an incomplete quote.** You don’t need an exact figure, just one in the right band: * **WA** charges per 100 kg up to a cap that a car reaches at about **1,970 kg**. Above that, every vehicle pays the same, so an approximate tare is fine for heavier SUVs and utes. * A used or imported vehicle being licensed in WA for the first time also pays the **$225.40 inspection**, added automatically (not for a transfer). * **NSW** bands registration tax by tare, e.g. up to 975 kg, 976–1,154 kg, 1,155–1,504 kg and 1,505–2,504 kg. Get the band right and the price is right. If your vehicle data has gaps, fill them from the same model’s other variants, or from the body-type default your data supplier provides. Tell us if you need help choosing defaults for your stock. A **registration transfer** (`registration.type: "transfer"`) doesn’t need tare: no registration is priced. *** ## Cylinders [Section titled “Cylinders”](#cylinders) Queensland duty and registration depend on cylinder count for petrol, diesel, hybrid and plug-in hybrid vehicles: | | 1–3 cyl / EV | 4 cyl | 5–6 cyl | 7–8 cyl | 9–12 cyl | | ---------------------------------------------- | ---------------------------- | ------- | ------- | ------- | --------- | | Duty, petrol/diesel (per $100, up to $100,000) | $3 (1–3 cyl) / EV: see below | $3 | $3.50 | $4 | $4 | | Registration fee, private use, 12 months | $303.10 | $385.45 | $610.30 | $854.70 | $1,002.35 | * **Duty for hybrids and EVs** is $2 per $100 at any cylinder count. * **Above $100,000**, a further $2 per $100 applies to the whole value (except vehicles over 4.5 t GVM). * **Registration for hybrids and plug-in hybrids** is charged on their cylinder count; only battery EVs get the electric rate. * **Traffic improvement fee:** $67.25 on top of every registration fee. * **Rotary engines:** send `cylinders` as twice the number of rotors. * **Without `cylinders`**, duty assumes 1–4 cylinders and registration uses the 1–3 cylinder rate, with a warning for each. *** ## CTP, TAC and insurance [Section titled “CTP, TAC and insurance”](#ctp-tac-and-insurance) * **CTP / TAC / MAIB defaults.** Where insurers set their own premiums (NSW and QLD), we use a published default, flagged as an estimate in `warnings`. If you have a rate agreement with a specific insurer, we can load your rate card as rules for your showroom. * **Victoria** has a single TAC charge set by garaging postcode. Send `registration.postcode`; without it we use the Melbourne metro zone and warn. *** ## Plates [Section titled “Plates”](#plates) Standard number plates are included in every new registration. If you have a commercial arrangement with a premium or personalised plate provider, we can load those rates for your showroom. *** ## Dealer delivery [Section titled “Dealer delivery”](#dealer-delivery) Tag delivery fees with `"tag": "dealer_delivery"` (in `pricingInputs.dutiable`). States differ on whether delivery is dutiable and whether it counts towards LCT, and the tag lets the engine apply each state’s treatment for **new** vehicles. For a **used** vehicle in QLD or WA, delivery is part of the price paid and is dutiable, tagged or not. *** ## Concessions [Section titled “Concessions”](#concessions) Each state has its own concession codes (pensioner, seniors, disability, primary producer and more). Fetch the list with [`GET /v1/concessions`](/pricing-api/other-endpoints#get-v1concessions) and send one in `customer.concession`. Only one concession applies per quote. *** ## Business buyers [Section titled “Business buyers”](#business-buyers) Set `customer.usageType` to `commercial` when the vehicle is registered for business use (and `customerType` to `commercial` for a business buyer). Statutory charges depend on private versus business use, for example CTP classes, NSW registration tax, and **Queensland registration, which has a business-use rate** (e.g. $328.85 + $73.00 traffic improvement fee for an EV, against $303.10 + $67.25 private). Queensland registration concessions don’t apply to business-use vehicles. *** ## Registration terms [Section titled “Registration terms”](#registration-terms) Shorter terms are priced pro rata, plus any surcharge the state charges. Queensland adds a **short-term surcharge** to every term under 12 months ($10.15 for 6 months, $5.05 for 3, $1.75 for 1; lower for concession registrations), and only offers 1- and 3-month terms to vehicles registered with direct debit. *** ## Luxury Car Tax [Section titled “Luxury Car Tax”](#luxury-car-tax) LCT applies above $80,809, or $91,661 for **fuel-efficient** vehicles (2026–27), at 33% of the GST-exclusive excess. * **Fuel-efficient** means combined fuel consumption of **3.5 L/100km or less** (from 1 July 2025). Send `fuelConsumptionLPer100km` from the vehicle’s fuel consumption label. * Without it, battery EVs qualify and plug-in hybrids are assumed to qualify (with a warning). * Conventional hybrids and petrol/diesel vehicles do not qualify. * **Not a luxury car at all:** * vehicles with 10 or more seats (`seats`, including the driver); * vehicles designed to carry 2 tonnes or more (`gvmKg` minus `kerbMassKg`); * trucks and goods vans. * **Utes and cab-chassis** are exempt as commercial vehicles when seats × 68 kg is less than half of (`gvmKg` − `kerbMassKg`), following the ATO’s simplified method. Send all three to apply it. Without them, LCT is charged and a warning asks for them. * SUVs, passenger cars and people movers are always assessed for LCT. *** ## Not included [Section titled “Not included”](#not-included) These costs are not in quotes. Add them as your own `onRoad` lines if they apply: * **Roadworthy and safety certificates**, which are private costs, not government charges. * **WA regional inspection fees.** The WA inspection for used and imported vehicles being licensed is priced at the Perth/non-regional rate ($225.40); regional examination fees are up to $254.80. # Target drive-away > Work back from an advertised drive-away price to the vehicle price that produces it, per state. ## `POST /v1/quote/target-drive-away` [Section titled “POST /v1/quote/target-drive-away”](#post-v1quotetarget-drive-away) Works back from a drive-away price. You advertise a car at **$54,990 drive-away**. This endpoint tells you the vehicle price that produces that figure, with the full statutory breakdown for the contract. It takes a normal [quote request](/pricing-api/quotes) plus a `target`. The engine finds the **highest** value for one dutiable line whose drive-away **does not exceed** the target, and returns the full quote at that value. Every charge is recalculated at the solved price. Duty steps, LCT and registration bands are computed exactly, not estimated. *** ## Request body [Section titled “Request body”](#request-body) Everything from `POST /v1/quote`, plus: | Field | Type | Required | Description | | ------------------------ | ------- | -------- | -------------------------------------------------------------------------------------------------------------------- | | `target.driveAway` | integer | Yes | The drive-away you want, in cents. | | `target.adjustLineIndex` | integer | | Which `pricingInputs.dutiable` line to solve, by position. Default `0` (the first line, normally the vehicle price). | | `target.increment` | integer | | Solve to the nearest multiple of this, in cents. Default `100` (whole dollars). Use `1` to solve to the cent. | The solved line can be a price (solving down or up), or a discount line (solving to a negative value). Every other line is left exactly as you sent it. ```bash curl -s -X POST "https://pricing-api.vyro.com.au/v1/quote/target-drive-away" \ -H "Authorization: Bearer your-access-token" \ -H 'Content-Type: application/json' \ -d '{ "pricingCode": "AU_QLD", "showroomCode": "your-showroom-code", "purchaseDate": "2026-10-01", "vehicleGeneric": { "bodyType": "ute", "fuelType": "ICE", "cylinders": 6, "tareKg": 2150, "year": 2021, "condition": "used", "manufactureDate": "2021-03-31" }, "pricingInputs": { "dutiable": [{ "description": "Vehicle price", "value": 5499000 }] }, "registration": { "termMonths": 12, "startDate": "2026-10-01", "type": "transfer" }, "customer": { "customerType": "consumer", "usageType": "consumer" }, "target": { "driveAway": 5499000 } }' ``` *** ## Response [Section titled “Response”](#response) **200 OK** (`quote.metadata` and the per-line `tax`/`amounts` objects omitted) ```json { "target": { "driveAway": 5499000, "adjustLineIndex": 0, "increment": 100 }, "solution": { "lineDescription": "Vehicle price", "originalValue": 5499000, "solvedValue": 5309700, "adjustment": -189300, "driveAway": 5498915, "shortfall": 85, "exact": false, "iterations": 18 }, "quote": { "categories": { "dutiable": [ { "description": "Vehicle price", "value": 5309700, "ruleId": "AU_QLD_DUTIABLE_VALUE_FY27" } ], "onRoad": [ { "description": "Stamp Duty", "value": 185850, "ruleId": "AU_QLD_STAMP_DUTY_FY27" }, { "description": "Vehicle registration transfer fee", "value": 3365, "ruleId": "AU_QLD_REGISTRATION_TRANSFER_FY27" } ], "additional": [] }, "totals": { "dutiable": 5309700, "onRoad": 189215, "additional": 0, "driveAway": 5498915, "tax": { "dutiable": 482700, "onRoad": 0, "additional": 0, "overall": 482700 } } } } ``` To advertise this ute at $54,990 drive-away, price the vehicle at **$53,097**. Duty is $1,858.50 and the transfer fee $33.65, a total of $54,989.15. | Field | Description | | ---------------------- | ---------------------------------------------------------------------------------------------------------------- | | `solution.solvedValue` | The value for the solved line. | | `solution.adjustment` | `solvedValue - originalValue`. Negative means the price has to come down; this is the discount to apply. | | `solution.driveAway` | The drive-away actually achieved. | | `solution.shortfall` | `target - driveAway`. **Never negative**: the quote never exceeds your target. | | `solution.exact` | `true` when `shortfall` is `0`. | | `solution.iterations` | Engine evaluations used, typically 15–35. | | `quote` | The complete quote at the solved value, identical to calling `POST /v1/quote` with it. Store this with the deal. | ### Why isn’t it always exact? [Section titled “Why isn’t it always exact?”](#why-isnt-it-always-exact) Duty is charged in steps: per $100 or part of $100 in QLD, and per $200 or part of $200 in VIC. So not every drive-away figure is reachable to the cent. The solver returns the closest drive-away **at or below** your target, on your `increment`. Here that leaves $0.85. You can absorb it in the price, add it as a separate line, or leave it. *** ## Errors [Section titled “Errors”](#errors) | Status | `code` | Meaning | | ------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `INVALID_TARGET` | `target.adjustLineIndex` doesn’t match a `dutiable` line, `target` values are invalid (`driveAway` is at most $100m), or the request produces an empty quote (check the date against the supported range). | | `422` | `TARGET_NOT_ACHIEVABLE` | The target is below the drive-away even with the solved line at its lowest value (on-road charges and your other lines already exceed it), or the drive-away doesn’t respond to that line. | | `400` | `INVALID_VEHICLE_INPUT` | The solved price is above the LCT threshold for a used car, and the dates or `lctPreviouslyPaid` needed to price LCT are missing. See [Used vehicles](/pricing-api/used-vehicles). | Plus every error `POST /v1/quote` can return. See [Errors, warnings & changes](/pricing-api/errors-warnings-and-changes). # Used vehicles > Price used vehicles: condition, used-vehicle duty, the LCT two-year rule and registration transfers. Used vehicles are taxed differently from new ones: * A used car usually keeps its registration, so the buyer pays a **transfer fee** instead of a new registration. * In some states its duty is assessed on the **price actually paid**, not the manufacturer’s list price. * Luxury Car Tax applies only while the car is **two years old or less**, counted from when it was imported. Even then, the tax on a resale is reduced by **LCT already paid** on it, which often brings it to zero. Send these optional fields to get used-vehicle treatment. **If you omit them, the vehicle is priced as new.** | Field | Where | What it changes | | ----------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `condition: "used"` | `vehicle` / `vehicleGeneric` | Used-vehicle duty treatment (QLD, WA, VIC) and LCT handling (all states). | | `cylinders` | `vehicle` / `vehicleGeneric` | Queensland duty and registration bands. Send it for every combustion-engine vehicle. | | `firstRegistrationDate` | `vehicle` / `vehicleGeneric` | Date first registered in Australia. Proves a car is outside the LCT two-year window. | | `importDate` | `vehicle` / `vehicleGeneric` | Date imported (entered for home consumption). The LCT two-year clock for an imported car. | | `manufactureDate` | `vehicle` / `vehicleGeneric` | Build date. Can show a car is *within* the LCT window, never that it is outside it. | | `lctPreviouslyPaid` | `vehicle` / `vehicleGeneric` | LCT already paid on this car, in cents. Reduces the LCT on the resale. | | `type: "transfer"` | `registration` | Keep the existing registration: no new registration, CTP or plates; the state transfer fee is added. Implies `condition: "used"`. | All dates must be real calendar dates in `YYYY-MM-DD` format. An impossible date such as `2024-09-31` returns `400 INVALID_VEHICLE_INPUT`. *** ## Registration transfer [Section titled “Registration transfer”](#registration-transfer) Most used cars are sold registered, and the registration carries over to the buyer. Set `registration.type` to `"transfer"`: * Registration, CTP (or TAC in Victoria) and plates are **not** charged. * The state’s **transfer fee** is added as an on-road line. * Stamp duty, LCT and your own lines are priced as normal, with used-vehicle treatment (a transfer is always of a previously registered vehicle). | State | Transfer fee (1 July 2026 – 30 June 2027) | Line description | | -------- | ----------------------------------------- | --------------------------------------------------- | | `AU_QLD` | $33.65 | `Vehicle registration transfer fee` | | `AU_WA` | $20.50 | `Vehicle licence transfer fee` | | `AU_VIC` | $47.50 | `Registration transfer fee (private motor vehicle)` | These return `400` instead of a quote with a fee missing: * a transfer in any other state, or dated outside that window (`UNSUPPORTED_REGISTRATION_TYPE`); * a transfer of a vehicle marked `condition: "new"` (`INVALID_VEHICLE_INPUT`). `termMonths` is still required in the request but is ignored. If the vehicle is **unregistered**, for example a used import being registered in Australia for the first time, or a car whose registration has lapsed, use the default `"new"`. You’ll get a full registration, CTP and plates for `termMonths`. *** ## Luxury Car Tax on used vehicles [Section titled “Luxury Car Tax on used vehicles”](#luxury-car-tax-on-used-vehicles) LCT only matters for cars above the threshold: $80,809, or $91,661 for fuel-efficient vehicles (2026–27). Below it, none of this applies and no extra fields are needed. **1. Is the car within the two-year window?** LCT applies to a sale only if the car was imported, or manufactured in Australia, **two years or less** before the sale. Almost every car is imported, so the clock usually runs from the **import date**, not the build date. A five-year-old car imported last month is inside the window. * Send `importDate` when you know it. * Otherwise send `firstRegistrationDate`. A car is always imported before it is first registered, so a first registration more than two years ago proves the car is outside the window. * A `manufactureDate` within the last two years proves the car is inside the window. An older build date proves nothing on its own. **2. Has LCT already been paid?** If the car is within the window, the LCT on this sale is reduced by LCT already paid on earlier sales or its importation (LCT Act s 5-15(2)). A car first sold new in Australia usually paid more LCT then than its lower resale price attracts now, so its resale LCT is normally **$0**. Full LCT is due when none was paid before, for example on a dealer import where an ABN was quoted. Send `lctPreviouslyPaid`, in cents (`0` if none). | You send (car above the threshold) | Result | | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `importDate` or `firstRegistrationDate` more than two years before `purchaseDate` | No LCT. | | Within the window + `lctPreviouslyPaid` | LCT = the LCT on this sale minus `lctPreviouslyPaid`, never below $0. | | Within the window, no `lctPreviouslyPaid` | `400 INVALID_VEHICLE_INPUT` asking for it. | | No date that proves the car is outside the window | `400 INVALID_VEHICLE_INPUT` asking for `importDate` or `firstRegistrationDate` (or `lctPreviouslyPaid`). | | No `condition` | Priced as new: full LCT above the threshold. | The engine never guesses here. Either way, a guess can be wrong by thousands of dollars on a contract figure. `dutiable` values are always LCT-exclusive, and any LCT payable is added as its own line. The same rules as for new cars decide whether a vehicle is a luxury car at all, and which threshold applies. See [States & inputs](/pricing-api/states-and-inputs#luxury-car-tax): utes and goods vans are often exempt, and hybrids need their fuel consumption. *** ## Stamp duty for used vehicles [Section titled “Stamp duty for used vehicles”](#stamp-duty-for-used-vehicles) | State | New vehicle | Used vehicle (`condition: "used"`) | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `AU_QLD` | Duty on the list price; pre-contract discounts and dealer delivery are excluded. | Duty on the **price paid**: every `dutiable` line counts, including dealer delivery, warranties and accessories, net of genuine price discounts. | | `AU_WA` | As QLD. | As QLD. | | `AU_VIC` | Passenger tiers. **Utes, goods vans, trucks and cab-chassis: $5.40 per $200** at any value. | Passenger cars: same tiers as new. **Utes, goods vans, trucks and cab-chassis: $8.40 per $200 at any value** (no luxury tiers). | | Other states | New-vehicle rules. A used `condition` is accepted and a warning says used-vehicle duty isn’t modelled for that state. LCT handling still applies. | | **Never send a trade-in as a negative `dutiable` line.** A trade-in is part payment, not a price reduction: duty is charged on the full price, and a trade-in line would understate it. If a used car’s market value is higher than its price (a non-arm’s-length sale), duty is assessed on market value; send market value as the price. **Body type.** `van` means a goods (panel) van. A people mover seating 8 or fewer is a passenger car in Victoria; send it as `passenger`. **Queensland duty depends on cylinders** for petrol and diesel vehicles: $3 per $100 for 1–4 cylinders, $3.50 for 5–6 and $4 for 7 or more. Hybrids and EVs pay $2 per $100 at any cylinder count. When the dutiable value is over $100,000, a further $2 per $100 applies **on the whole value**, so EVs pay $4, 1–4 cylinders $5, and so on. This surcharge does not apply to vehicles over 4.5 t GVM: send `gvmKg` for heavy vehicles. Without `cylinders` we assume 1–4 and return a warning. For rotary engines, send `cylinders` as twice the number of rotors. *** ## Examples [Section titled “Examples”](#examples) ### Used 6-cylinder ute, Queensland, registration transferring [Section titled “Used 6-cylinder ute, Queensland, registration transferring”](#used-6-cylinder-ute-queensland-registration-transferring) ```json { "pricingCode": "AU_QLD", "showroomCode": "your-showroom-code", "purchaseDate": "2026-10-01", "vehicleGeneric": { "bodyType": "ute", "fuelType": "ICE", "cylinders": 6, "tareKg": 2150, "year": 2021, "condition": "used", "manufactureDate": "2021-03-31" }, "pricingInputs": { "dutiable": [{ "description": "Vehicle price", "value": 5499000 }] }, "registration": { "termMonths": 12, "startDate": "2026-10-01", "type": "transfer" }, "customer": { "customerType": "consumer", "usageType": "consumer" } } ``` On-road lines and totals from the response: ```json { "onRoad": [ { "description": "Stamp Duty", "value": 192500, "ruleId": "AU_QLD_STAMP_DUTY_FY27" }, { "description": "Vehicle registration transfer fee", "value": 3365, "ruleId": "AU_QLD_REGISTRATION_TRANSFER_FY27" } ], "totals": { "dutiable": 5499000, "onRoad": 195865, "driveAway": 5694865 } } ``` $54,990 at $3.50 per $100 (6 cylinders) is $1,925.00 duty. Add the $33.65 transfer fee for a drive-away of **$56,948.65**. The car is below the LCT threshold, so no LCT dates are needed. ### Used V8 ute, Victoria, registration transferring [Section titled “Used V8 ute, Victoria, registration transferring”](#used-v8-ute-victoria-registration-transferring) The same shape, with `"pricingCode": "AU_VIC"`, `"cylinders": 8`, a `$94,990` price, `"manufactureDate": "2020-11-30"`, `"firstRegistrationDate": "2021-01-15"` and `"postcode": "3000"` in `registration`: ```json { "onRoad": [ { "description": "Stamp Duty", "value": 399000, "ruleId": "AU_VIC_STAMP_DUTY_FY27" }, { "description": "Registration transfer fee (private motor vehicle)", "value": 4750, "ruleId": "AU_VIC_REGISTRATION_TRANSFER_FY27" } ], "totals": { "dutiable": 9499000, "onRoad": 403750, "driveAway": 9902750 } } ``` A used ute pays the flat $8.40 per $200: $3,990.00 duty. It was first registered more than two years ago, so no LCT applies. A passenger car at the same price would pay the $10.40 tier. ### Used import, Western Australia, first Australian registration [Section titled “Used import, Western Australia, first Australian registration”](#used-import-western-australia-first-australian-registration) Built in 2019, imported by the dealer in February 2026 with an ABN quoted (so no LCT was paid at import), and registered in WA for the first time: ```json { "pricingCode": "AU_WA", "showroomCode": "your-showroom-code", "purchaseDate": "2026-10-01", "vehicle": { "make": "Toyota", "model": "LandCruiser", "year": 2019, "fuelType": "ICE", "bodyType": "suv", "cylinders": 8, "tareKg": 2740, "condition": "used", "manufactureDate": "2019-04-30", "importDate": "2026-02-20", "lctPreviouslyPaid": 0 }, "pricingInputs": { "dutiable": [{ "description": "Vehicle price", "value": 11999000 }] }, "registration": { "termMonths": 12, "startDate": "2026-10-01", "postcode": "6000" }, "customer": { "customerType": "consumer", "usageType": "consumer" } } ``` ```json { "onRoad": [ { "description": "Luxury Car Tax (LCT)", "value": 1175430, "ruleId": "AU_WA_LCT_FY27" }, { "description": "Stamp Duty", "value": 856335, "ruleId": "AU_WA_STAMP_DUTY_FY27" }, { "description": "Registration Fee", "value": 60230, "ruleId": "AU_WA_REGISTRATION_FY27" }, { "description": "Plates", "value": 3200, "ruleId": "AU_WA_REGISTRATION_FY27" }, { "description": "Vehicle inspection fee (first licensing)", "value": 22540, "ruleId": "AU_WA_VEHICLE_INSPECTION_FY27" }, { "description": "CTP Fee", "value": 50470, "ruleId": "AU_WA_CTP_FY27" } ], "totals": { "dutiable": 11999000, "onRoad": 2168205, "driveAway": 14167205 } } ``` The car was imported less than two years before the sale and no LCT has been paid on it, so LCT applies in full: $11,754.30 on the amount above the $80,809 threshold. WA duty is 6.5% of the price plus LCT, rounded down to the nearest 5 cents. Because it is being registered for the first time, a full registration, plates and CTP are included, plus the **$225.40 inspection** an imported or used vehicle must pass before it is first licensed in WA. Outside Perth (postcodes 6000–6199) a warning notes that regional inspection fees are slightly higher. Had the car been first sold new in Australia with $12,000 of LCT paid, you would send `"lctPreviouslyPaid": 1200000`. The resale LCT would then be $0, with duty charged on the price alone. *** ## Checklist for used-vehicle integrations [Section titled “Checklist for used-vehicle integrations”](#checklist-for-used-vehicle-integrations) * Send `condition: "used"` on every used vehicle, or `registration.type: "transfer"`, which implies it. * Send `cylinders` on every combustion-engine vehicle (it matters in Queensland). If your vehicle data has gaps, see [States & inputs](/pricing-api/states-and-inputs). * For anything that could be above the LCT threshold, send `firstRegistrationDate` (or `importDate`). If the car is within two years, also send `lctPreviouslyPaid`. * Put every amount the customer pays for the vehicle in `dutiable`, including delivery, accessories and warranties. Never put a trade-in there. * Use `registration.type: "transfer"` when the registration carries over; `"new"` when the vehicle needs registering. * Read `metadata.warnings`: every assumption the engine had to make is listed there. # For AI agents > Machine-readable versions of the Vyro developer docs for LLMs and coding agents, including llms.txt. These docs are published in forms that language models and coding agents can read directly. | File | What it is | | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | An index of every page with a one-line summary, per the [llms.txt](https://llmstxt.org) convention. | | [`/llms-full.txt`](/llms-full.txt) | The whole site as one Markdown file. | | [`/llms-small.txt`](/llms-small.txt) | A condensed version for small context windows. | | [`/pricing-api-openapi.yml`](/pricing-api-openapi.yml) | The Pricing API’s OpenAPI 3 specification. | To give a coding agent context, point it at `https://developers.vyro.co/llms-full.txt`, or add it to your project’s agent instructions: ```md When writing code against the Vyro API, read https://developers.vyro.co/llms-full.txt first. ``` The GraphQL schema itself is available by introspection at the [GraphQL endpoint](/environments), with your token. Most GraphQL codegen tools and agent integrations can load it from there. ## Key facts for agents [Section titled “Key facts for agents”](#key-facts-for-agents) * Money is integer cents. Divide by 100. * Australian prices differ per state: filter `pricing_codes: { _contains: [code] }`, read `pricing_config(path: code)`. * Scope stock to a showroom with `showroom_stocked_vehicles: { showroom_id: { _eq: id } }`. * Live stock is `is_published`, `is_listed` and `not is_sold`. * Enum values are unquoted inline: `condition: { _eq: used }`. * Paginate with `limit`, `offset` and an `order_by` ending in `id`. * Hero image is the asset with `placement: "featured"`. * Vehicle page: `https://{showroom.domain}/vehicles/stocked/{code}?region_id={pricing code}`. # Data formats > How the Vyro API formats money, dates, times, numbers, units, enums, JSON fields and IDs. | Kind | Format | Example | | ------------ | ---------------------------------- | -------------------------------------- | | Money | Integer, in cents | `4599000` = $45,990.00 | | Timestamp | ISO 8601, UTC, microseconds | `2026-10-28T16:46:09.522482+00:00` | | Date | `YYYY-MM-DD` | `2026-11-01` | | ID | UUID v4 string | `9b1d4e7a-2c3f-4a5b-8d6e-7f8091a2b3c4` | | Distance | Kilometres | `odometer: 122011` | | Battery | kWh | `useable_battery: 64` | | Booleans | `true` / `false` | `is_sold: false` | | Pricing code | `_` or `` | `AU_VIC`, `NZ` | | Currency | ISO 4217 | `AUD`, `NZD`, `EUR` | | Time zone | IANA name | `Australia/Sydney` | ## Money [Section titled “Money”](#money) Every money value is an integer in the currency’s smallest unit (cents). That covers vehicle prices, line items, `min_price`, `max_price`, deposits, valuations and order totals. Divide by 100 to display, and round **up** to whole dollars for advertised prices. See [Prices](/guides/prices). The currency comes from the [pricing code](/guides/state-pricing): `AUD` in Australia, `NZD` in New Zealand. ## Dates and times [Section titled “Dates and times”](#dates-and-times) * **Timestamps** (`created_at`, `updated_at`, `scheduled_at`…) are UTC with an offset. Parse them with any ISO 8601 parser. Keep the full string, microseconds included, if you use it as a sync cursor. * **Dates** (`available_from`, `build_date`, `compliance_date`, `registration_expiry_date`) have no time or zone. Don’t convert them through `Date` in JavaScript, which would shift them to UTC midnight. * To display a time, convert it to the **location’s** `timezone`, not your server’s. * In filters you can compare against `"now()"`: `available_from: { _lte: "now()" }`. A timestamp without an offset is read as UTC. ## Numbers [Section titled “Numbers”](#numbers) Decimal columns (`odometer`, `production_year`, `min_price`) are JSON numbers, but can arrive as `122011.0` rather than `122011`. Round or cast them before you show or compare them. In variables their type is `numeric`, not `Int`: `query ($km: numeric!) { … odometer: { _lt: $km } … }`. An unknown or wrong-case pricing code (`au_nsw`) returns `null` from `pricing_config(path: …)`, not an error. Codes are upper case. ## Text fields [Section titled “Text fields”](#text-fields) `make`, `model`, `badge`, `body_type`, `fuel_type`, `transmission` and `colour` are free text as the dealer entered or imported them, so expect `Electric` and `electric`, or `SUV` and `Wagon`. Match with `_ilike`, and normalise before you build filter menus. `vehicle_specification` holds the manufacturer’s normalised specification when a vehicle has been matched to one. ## Enums [Section titled “Enums”](#enums) | Field | Values | | --------------------------------- | --------------------------------------------------------------------------------------------------------- | | `stocked_vehicles.condition` | `new`, `used`, `demo` | | `stocked_vehicles.listing_type` | `retail`, `private` | | `stocked_vehicles.generic_colour` | `black`, `blue`, `brown`, `green`, `grey`, `orange`, `pink`, `purple`, `red`, `silver`, `white`, `yellow` | | `assets.classification` | `Front`, `Front34`, `Side`, `Rear34`, `Rear`, `Interior`, `Wheel`, `Other` | Write enum values without quotes inside a query (`condition: { _eq: used }`), and as plain strings in variables. Statuses such as `moments.stage`, `test_drives.stage` and `assets.placement` are text, not enums. Dealers can add their own, so handle values you don’t recognise. ## JSON fields [Section titled “JSON fields”](#json-fields) Columns such as `pricing_config`, `address`, `utm_data`, `values` and `opening_hours` are returned as JSON objects, not strings. Pick a key with `path` (for example `pricing_config(path: "AU_NSW")`), and filter with `_contains` or `_has_key`. ## Nulls [Section titled “Nulls”](#nulls) A field with no value is `null`, not omitted. Treat `null` and empty strings the same for text such as `alt` and `badge`. # Limits and errors > Vyro GraphQL error format, common errors and their fixes, and fair-use limits. ## Errors [Section titled “Errors”](#errors) GraphQL answers **HTTP 200** even when the query fails. Always check the body for `errors`. ```json { "errors": [ { "message": "field 'cost_price' not found in type: 'stocked_vehicles'", "extensions": { "path": "$.selectionSet.stocked_vehicles.selectionSet.cost_price", "code": "validation-failed" } } ] } ``` | Message or code | Usual cause | Fix | | ------------------------------------------------------------------------ | ------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | `field '…' not found in type` | A typo, or your role can’t read the field. | Check spelling in the [explorer](/explorer) with the same token. | | `expected an enum value … but found a string` | Quotes around an enum value. | `condition: { _eq: used }`, or use a variable. | | `invalid-jwt` with `JWTExpired` | The token is older than 60 minutes. | Authenticate again once, then retry the request. | | `invalid-jwt`, other messages | The token is damaged or from another environment. | Check the endpoint matches the token’s environment. | | `invalid-headers` | The `Authorization` header is malformed. | Send `Authorization: Bearer `. | | `unexpected variables in variableValues` | You sent a variable the query doesn’t declare. | Send only the variables the query declares. | | `variable … is declared as 'Int!', but used where 'numeric' is expected` | Wrong variable type. | Decimal columns such as `odometer` and `production_year` take `numeric`. | | `not-supported` / `unexpected` | Malformed query or variable types. | Check variable types against the schema (for example `uuid!`, not `String!`). | | HTTP `401` `invalid-credentials` from the authentication API | Unknown client, or wrong secret. | Check both; the secret is shown only once. | | HTTP `400` from the authentication API | Missing fields, a malformed `client_id`, or a body that isn’t JSON. | `POST` JSON with `client_id` and `client_secret`. | | `data-exception` | A value that isn’t valid for its type, e.g. a malformed uuid. | Check the value. | | HTTP `403` from the authentication API | The client’s user no longer has the role it was created with. | Create a new client. | | HTTP `5xx` or timeout | A query that is too large, or a temporary fault. | Reduce `limit` and nested lists, then retry with backoff. | An empty list is not an error. If you expected rows, check the `where` and that your token can see that showroom. ## Fair use [Section titled “Fair use”](#fair-use) There is no published rate limit, but please: * **Reuse your token** for its 60 minutes. * **Page** with `limit` of 50 to 200, and only nest the lists you need. * **Poll for changes** with `updated_at` no more often than every few minutes, rather than re-reading everything. * **Cache** slow-changing data: showrooms, locations, makes and models. * **Retry** `5xx` errors and timeouts with exponential backoff, up to a few attempts. Requests without a token are capped per request at 500 vehicles, 100 images and 1 showroom. A larger `limit` is cut to the cap without an error, so page with `offset`. If you expect sustained high volume, email first so we can make sure there is capacity. ## Changes [Section titled “Changes”](#changes) Vyro adds fields and tables regularly; that is not a breaking change, so ignore fields you don’t know. We give notice before removing or renaming anything. Tell who should hear about changes. # Support > How to get help with the Vyro APIs. Email . It helps to include: * the environment (staging, Asia-Pacific or Europe); * your `client_id` (never the secret); * the query and variables, and the full response body; * the time of the request, with time zone. To request an app client, a sandbox showroom on staging, or more capacity, email the same address.