Inventory Read Profile

How a shopping agent asks a dealer what it has, before any transaction exists, without asking the shopper to identify themselves first.

Profile status: This is an optional profile layered on ACP. It defines a read surface only. It produces no envelopes and mutates no state. Publishers may implement it without implementing ACP core, and the reverse.

Why a Read Surface

ACP core defines transaction state. It has no request form, because state is pushed rather than queried. That works once a deal exists. It does nothing for the moment before one does.

A shopping agent starts with a question, not a transaction. What does this dealer have. What is it worth. Is it still there. This profile defines four operations that answer those questions, and stops there.

Operation Answers
read.dealer.info Who this dealer is, where, when open, what they accept
read.inventory.search What matches these filters, paginated
read.inventory.vehicle Everything about one vehicle
read.inventory.facets How to narrow, and by how much

All four are individually optional. A publisher that supports search must also support vehicle detail, because a result that cannot be resolved to detail is not useful.

Read operations are transport-agnostic, exactly as ACP core is. HTTPS is the primary path. Where an agent already speaks agent-to-agent transport, see ACP + A2A.

Anonymous Access

Every operation in this profile must be answerable without identifying the end user. A publisher must not require an email address, phone number, or name to return search results, vehicle detail, facets, or dealer information.

This is a hard requirement, not a recommendation. An agent browsing on behalf of a shopper has no consented identity to give. A read surface that demands one converts browsing into lead capture without consent.

Publishers may rate-limit, may require a credential identifying the consuming agent, and may log requests. None of that identifies the shopper. When the shopper is ready to identify themselves, that is a lead, and it goes through ACP core with a consent record attached.

Freshness

Every response carries as_of and ttl_seconds.

as_of is the timestamp of the underlying inventory state, not the time the response was generated. A publisher serving from a feed refreshed at 06:00 reports 06:00, not the current clock.

The failure mode that matters is an agent quoting a car that sold yesterday. Stale inventory is worse than missing inventory, because the shopper acts on it. A consumer that cannot tell how old a result is has no way to avoid that, so age is mandatory rather than optional.

Dealer Info

Returns publisher identity, one or more locations, and declared capabilities.

dealer.info responseJSON
{
  "type": "org.acpspec.read.dealer.info.response",
  "as_of": "2026-08-05T14:00:00Z",
  "ttl_seconds": 86400,
  "dealer": {
    "dealer_id": "example-motors",
    "name": "Example Motors",
    "franchises": ["Honda", "Acura"],
    "locations": [
      {
        "location_id": "em-main",
        "name": "Example Motors Irvine",
        "address": {
          "street": "1200 Auto Center Dr",
          "city": "Irvine",
          "region": "CA",
          "postal_code": "92618",
          "country": "US"
        },
        "phone": "+19495550142",
        "timezone": "America/Los_Angeles",
        "departments": [
          {
            "department": "SALES",
            "hours": [
              { "day": "MO", "opens": "09:00", "closes": "20:00" },
              { "day": "SU", "closed": true }
            ]
          }
        ]
      }
    ],
    "capabilities": {
      "acp_version": "1.0.0",
      "accepts_leads": true,
      "languages": ["en", "es"]
    }
  }
}

Hours are per department, not per dealership. Sales and service keep different hours at nearly every rooftop, and a single hours block forces the consumer to guess which one it got.

closed: true on a day is distinct from an absent day. Absent means not declared. Closed means declared shut.

inventory.search requestJSON
{
  "type": "org.acpspec.read.inventory.search",
  "filters": {
    "condition": ["USED", "CERTIFIED"],
    "make": ["Honda"],
    "model": ["Accord"],
    "year": { "min": 2021 },
    "price": { "max": 30000 },
    "mileage": { "max": 60000 },
    "availability": ["AVAILABLE", "IN_TRANSIT"]
  },
  "sort": [
    { "field": "price", "direction": "ASC" },
    { "field": "vin", "direction": "ASC" }
  ],
  "limit": 25,
  "cursor": null
}

Array-valued filters are OR within the field and AND across fields. Range filters accept min, max, or both, and both bounds are inclusive.

Sort must be deterministic. A publisher appends a unique tiebreaker, normally vin, to whatever sort the consumer supplies. Without it, pagination over equal values silently drops or repeats records, and nothing in the response reveals that it happened.

inventory.search responseJSON
{
  "type": "org.acpspec.read.inventory.search.response",
  "as_of": "2026-08-05T13:45:00Z",
  "ttl_seconds": 900,
  "total_count": 47,
  "count_is_exact": true,
  "sort_applied": [
    { "field": "price", "direction": "ASC" },
    { "field": "vin", "direction": "ASC" }
  ],
  "next_cursor": "eyJwIjoyODk5NSwidiI6IjFIR0NNIn0",
  "vehicles": []
}

count_is_exact is always present. A publisher that can only estimate at scale sets it false rather than reporting a wrong number as fact.

Cursors are opaque and must not be constructed by the consumer. A cursor may expire, in which case the publisher returns ACP_CURSOR_EXPIRED and the consumer restarts the query.

Vehicle Detail

Request carries exactly one of vin or listing_id. Response carries a single vehicle.

A vehicle that is no longer available still resolves if the publisher retains it, with availability reporting the terminal state. Returning not-found for a sold vehicle leaves the consumer unable to distinguish sold from never existed, and the first is information a shopper needs.

Facets

Request carries an optional filters object with the same shape as search, and a required fields array naming which facets to compute.

inventory.facets responseJSON
{
  "type": "org.acpspec.read.inventory.facets.response",
  "as_of": "2026-08-05T13:45:00Z",
  "ttl_seconds": 900,
  "facets": {
    "make": [
      { "value": "Honda", "count": 31 },
      { "value": "Acura", "count": 12 }
    ],
    "price": { "min": 8995, "max": 54900, "count": 43 },
    "year":  { "min": 2016, "max": 2026, "count": 43 }
  }
}

Facet counts are computed against the same filter set the consumer supplied, so a count of zero means that combination yields nothing. A consumer relies on this to avoid offering a shopper a filter that leads to an empty result.

The Vehicle Object

The same shape appears in search results and vehicle detail. Search results may omit features and carry only a primary image. Everything else is identical, so a consumer never has to reconcile two shapes.

VehicleJSON
{
  "listing_id": "em-main:88213",
  "vin": "1HGCM82633A004352",
  "stock_number": "H88213",
  "condition": "CERTIFIED",
  "availability": "AVAILABLE",
  "location_id": "em-main",
  "year": 2022,
  "make": "Honda",
  "model": "Accord",
  "trim": "EX-L",
  "body_style": "SEDAN",
  "drivetrain": "FWD",
  "transmission": "AUTOMATIC",
  "fuel_type": "GASOLINE",
  "engine": "1.5L I4 Turbo",
  "exterior_color": "Modern Steel Metallic",
  "interior_color": "Black",
  "mileage": { "value": 38412, "unit": "MI" },
  "pricing": {},
  "media": {
    "primary_image": "https://example-motors.com/img/88213-1.jpg",
    "images": [],
    "video_url": null
  },
  "features": ["Sunroof", "Heated Seats", "Apple CarPlay"],
  "url": "https://example-motors.com/inventory/88213",
  "listed_at": "2026-07-02T16:20:00Z",
  "updated_at": "2026-08-05T13:40:00Z"
}

listing_id is required and stable for the life of the listing. It is the identifier a consumer uses to re-read. vin is required for used and certified vehicles and may be absent for new vehicles not yet assigned.

Availability

Availability is an enumerated state, not a boolean.

Value Meaning
AVAILABLE On the lot and sellable
IN_TRANSIT Purchased or allocated, not yet arrived
IN_PRODUCTION Ordered, not yet built
ON_HOLD Physically present, temporarily not sellable
SOLD_PENDING Deal in progress, not yet delivered
SOLD Delivered

A boolean loses the distinction a shopper cares about most. Not available covers both a car arriving Thursday and a car that left the lot in March, and those are different answers to the same question.

Publishers should include in-transit and in-production vehicles in search results, and must make the state visible rather than presenting them as on-lot.

Pricing

pricingJSON
{
  "currency": "USD",
  "advertised_price": 27995,
  "msrp": null,
  "price_excludes": ["TAX", "TITLE", "REGISTRATION", "DOC_FEE"],
  "fees": [
    {
      "name": "Documentation Fee",
      "amount": 85,
      "type": "DOC_FEE",
      "mandatory": true
    }
  ],
  "incentives": [
    {
      "name": "Honda Loyalty Cash",
      "amount": 500,
      "conditional": true,
      "conditions": "Requires current Honda ownership. Not all buyers qualify.",
      "expires_at": "2026-08-31T23:59:59Z"
    }
  ],
  "price_as_of": "2026-08-05T13:40:00Z"
}

advertised_price is the price available to a buyer who qualifies for no conditional incentive. Conditional amounts are never subtracted from it.

Every incentive carries conditional. When true, conditions is required and states in plain language who qualifies.

The reason this is structured rather than a single number: an agent that presents a conditional price as the price will misquote most shoppers, and the shopper finds out at the desk. Separating qualified from unqualified pricing is what lets an agent state a number it can stand behind.

Error Codes

Code Meaning
ACP_OPERATION_UNSUPPORTED Publisher does not implement this operation
ACP_FILTER_INVALID Unrecognized filter field or malformed range
ACP_SORT_INVALID Sort field not supported by this publisher
ACP_CURSOR_EXPIRED Cursor no longer valid, restart the query
ACP_CURSOR_INVALID Cursor malformed or not issued by this publisher
ACP_LIMIT_EXCEEDED Requested limit above 100
ACP_VEHICLE_NOT_FOUND No listing matches the supplied identifier
ACP_FACET_UNSUPPORTED Requested facet field not computable
ACP_RATE_LIMITED Request rejected for rate

A publisher never silently ignores an unrecognized filter. Ignoring it returns a wider result set than the consumer asked for, and the consumer has no way to detect that it happened.

Conformance

  1. Anonymous — It answers every supported operation without requiring personal data.
  2. Dated — It returns as_of and ttl_seconds on every response.
  3. Stateful availability — It reports availability as an enumerated state, not a boolean.
  4. Honest pricing — It reports advertised_price without conditional incentives applied, and marks every conditional incentive with its conditions.
  5. Deterministic — It applies a sort with a unique tiebreaker.
  6. Countable — It returns count_is_exact on every search response.
  7. Loud on bad input — It rejects unrecognized filters rather than ignoring them.
  8. Resolvable — It supports vehicle detail if it supports search.

The first and fourth are the ones that matter. The first keeps a browsing agent from becoming unconsented lead capture. The fourth keeps an agent from quoting a price the shopper cannot get.

Relationship to ACP Core

This profile is a read surface. It produces no envelopes and mutates no state.

When a shopper identifies themselves and a transaction begins, the consumer submits through ACP core with a consent record. The listing_id and vin from a read result carry into the transaction envelope, so the vehicle of interest resolves without a second lookup.

Nothing here requires a publisher to implement ACP core, and nothing in ACP core requires this profile. A publisher may expose inventory reads and accept no leads. A publisher may accept leads and expose no inventory.