diff --git a/src/Resources/schema/quote.openapi.json b/src/Resources/schema/quote.openapi.json
index 202b7b97..eedbeb81 100644
--- a/src/Resources/schema/quote.openapi.json
+++ b/src/Resources/schema/quote.openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "com.shopware.quote",
"version": "2026-04-08",
- "description": "Buyer-facing B2B Request-for-Quote capability for Shopware shops. Protocol-neutral: any UCP buyer agent can use it.\n\n## Authentication and authorization\n\nQuote operations act on behalf of a customer. Obtain an OAuth 2.0 access token through the shop's `dev.ucp.common.identity_linking` capability, then send it as `Authorization: Bearer `. The access token subject is the trust boundary: the customer's consent decides whose quotes are touched, and nothing in the request body can select a customer. An unscoped Shopware context token is not accepted. Requests also carry the standard `UCP-Agent` header and a request signature when the shop's signature policy requires it.\n\nWhen `/.well-known/oauth-authorization-server` lists `com.shopware.quote:manage` in `scopes_supported`, the shop enforces scopes: every quote operation requires `com.shopware.quote:manage`, and `acceptQuote` additionally requires `dev.ucp.shopping.order:manage`, because accepting places an order. Request both when linking the account. A token missing a required scope gets a 403 naming the missing scopes; link the account again, requesting them. A shop that does not list the quote scope cannot issue it, so it does not check scopes. Authorization is also by quote ownership, on every shop: the token's subject identifies the customer, and every operation is scoped to that customer's own quotes. A request whose token carries no linked customer fails with a validation error rather than succeeding unscoped.\n\nThe capability is advertised in discovery whenever the shop has a UCP surface and the `dev.ucp.common.identity_linking` capability is enabled for the sales channel; a shop that restricts its capabilities and omits identity linking publishes no quote descriptor at all, because a capability nobody can obtain a token for is a dead end. Being advertised is not being routable: discovery does not probe the commercial backend, so a shop without a licensed Quote Management installation advertises the capability and fails every request as unsupported. Treat a 501 as the real availability answer. The customer must additionally have the Quote Management feature enabled (`customer_specific_features`). Unknown and foreign quote ids both return not found, without confirming existence.\n\n## State machine\n\n`open` and `in_review` are the merchant's turn. `replied` is the buyer's turn: accept, counter, or decline. `change_requested` returns the turn to the merchant. Accepted, declined, expired, withdrawn, and cancelled quotes are terminal for the buyer; a merchant may reopen or extend a quote as documented in `x-state-machine`.\n\n## Expiration\n\nOffers carry `expiration_date` (ISO 8601, always present in responses, and possibly null before the merchant replies). Agents must read it and act before it.\n\n## Price semantics\n\nAll amounts are in the quote's `currency`. `totals.tax_status` states whether `totals.gross` or `totals.net` is authoritative. Line item prices and requested prices are **per unit**.\n\nNot every shop can record a per-line ask: the field and the route behind `requested_unit_price` are absent on released commercial builds. Where they are, sending `line_items` with a `requested_unit_price` fails with a validation error naming the limitation, and `requested_unit_price` is always null on reads. Since a counter line item requires a `requested_unit_price`, on those shops a counter-offer must be sent as `comment` alone, with no `line_items` — the merchant reads the ask from the comment. Probe once with the counter you intended and fall back to a comment on the 422; nothing in discovery reports this.\n\n## Idempotency\n\nEvery mutating request accepts `Idempotency-Key`. Shops configured to require it reject a mutating request without one. A repeated key with the same request fingerprint replays the stored response with `Idempotency-Replay: 1`; a key reused for a different request, or for a response too large to have been stored, is a 409.\n\n## Async negotiation\n\nThe merchant responds out-of-band. Agents poll `GET /ucp/quotes/{id}` for state changes; the recommended interval is 300 seconds. Webhooks are not part of this capability version.",
+ "description": "Buyer-facing B2B Request-for-Quote capability for Shopware shops. Protocol-neutral: any UCP buyer agent can use it.\n\n## Authentication and authorization\n\nQuote operations act on behalf of a customer. Obtain an OAuth 2.0 access token through the shop's `dev.ucp.common.identity_linking` capability, then send it as `Authorization: Bearer `. The access token subject is the trust boundary: the customer's consent decides whose quotes are touched, and nothing in the request body can select a customer. An unscoped Shopware context token is not accepted. Requests also carry the standard `UCP-Agent` header and a request signature when the shop's signature policy requires it.\n\nWhen `/.well-known/oauth-authorization-server` lists `com.shopware.quote:manage` in `scopes_supported`, the shop enforces scopes: every quote operation requires `com.shopware.quote:manage`, and `acceptQuote` additionally requires `dev.ucp.shopping.order:manage`, because accepting places an order. Request both when linking the account. A token missing a required scope gets a 403 naming the missing scopes; link the account again, requesting them. A shop that does not list the quote scope cannot issue it, so it does not check scopes. Authorization is also by quote ownership, on every shop: the token's subject identifies the customer, and every operation is scoped to that customer's own quotes. A request whose token carries no linked customer fails with a validation error rather than succeeding unscoped.\n\nThe capability is advertised in discovery whenever the shop has a UCP surface and the `dev.ucp.common.identity_linking` capability is enabled for the sales channel; a shop that restricts its capabilities and omits identity linking publishes no quote descriptor at all, because a capability nobody can obtain a token for is a dead end. Being advertised is not being usable: discovery does not check the commercial Quote Management backend. A shop without it serves no quote routes, so every request is a 404; a shop that has it but no license fails every request as unsupported (501). Treat either as the real availability answer. A 404 on the `/ucp/quotes` collection can only mean this; on a quote id it can also mean the quote is not the customer's. To request, counter, accept or decline, the customer must additionally have the Quote Management feature enabled (`customer_specific_features`); reads are not checked. Unknown and foreign quote ids both return not found, without confirming existence.\n\n## State machine\n\n`open` and `in_review` are the merchant's turn. `replied` is the buyer's turn: accept, counter, or decline. `change_requested` returns the turn to the merchant. Accepted, declined, expired, withdrawn, and cancelled quotes are terminal for the buyer; a merchant may reopen or extend a quote as documented in `x-state-machine`.\n\n## Expiration\n\nOffers carry `expiration_date` (ISO 8601, always present in responses, and possibly null before the merchant replies). Agents must read it and act before it.\n\n## Price semantics\n\nAll amounts are in the quote's `currency`. `totals.tax_status` states whether `totals.gross` or `totals.net` is authoritative. Line item prices and requested prices are **per unit**.\n\nNot every shop can record a per-line ask: the field and the route behind `requested_unit_price` are absent on released commercial builds. Where they are absent, sending `line_items` with a `requested_unit_price` fails with a validation error naming the limitation, and `requested_unit_price` is always null on reads. Since a counter line item requires a `requested_unit_price`, on those shops a counter-offer must be sent as `comment` alone, with no `line_items` — the merchant reads the ask from the comment. Probe once with the counter you intended and fall back to a comment on the 422; nothing in discovery reports this.\n\n## Idempotency\n\nEvery mutating request accepts `Idempotency-Key`. Shops configured to require it reject a mutating request without one. A repeated key with the same request fingerprint replays the stored response with `Idempotency-Replay: 1`; a key that is still being processed, or reused for a different request, or for a response too large to have been stored, is a 409.\n\n## Async negotiation\n\nThe merchant responds out-of-band. Agents poll `GET /ucp/quotes/{id}` for state changes; the recommended interval is 300 seconds. Webhooks are not part of this capability version.",
"x-capability": "com.shopware.quote"
},
"x-polling-interval-seconds": 300,
@@ -194,6 +194,9 @@
"409": {
"$ref": "#/components/responses/Conflict"
},
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
"501": {
"$ref": "#/components/responses/Unsupported"
}
@@ -213,7 +216,7 @@
"maximum": 50,
"default": 25
},
- "description": "Page size; values above 50 are clamped."
+ "description": "Page size; values above 50 are clamped. A non-integer value is a 400."
},
{
"name": "page",
@@ -223,7 +226,8 @@
"type": "integer",
"minimum": 1,
"default": 1
- }
+ },
+ "description": "A non-integer value is a 400."
}
],
"responses": {
@@ -246,6 +250,9 @@
"422": {
"$ref": "#/components/responses/UnprocessableEntity"
},
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
"501": {
"$ref": "#/components/responses/Unsupported"
}
@@ -379,7 +386,7 @@
}
},
"400": {
- "description": "Not acceptable in current state or expired (code CHECKOUT__QUOTE_CANNOT_PLACE_ORDER)",
+ "description": "Not acceptable in current state or expired. The message `code` is `invalid_request`; `content` carries the reason from the commercial quote-to-order flow.",
"content": {
"application/json": {
"schema": {
@@ -497,7 +504,7 @@
"schema": {
"type": "string"
},
- "description": "Replay guard for a mutating request. Required by shops whose signature policy demands it; a replayed response carries `Idempotency-Replay: 1`."
+ "description": "Replay guard for a mutating request. Required by shops configured to demand it; a replayed response carries `Idempotency-Replay: 1`."
}
},
"responses": {
@@ -512,7 +519,7 @@
}
},
"UnprocessableEntity": {
- "description": "Invalid payload, per-line price asks sent to a shop that cannot record them, the access token does not resolve to a customer, or Quote Management is not enabled for the customer (`customer_specific_features` must contain `{\"QUOTE_MANAGEMENT\": true}`)",
+ "description": "Invalid payload, per-line price asks sent to a shop that cannot record them, the access token does not resolve to a customer, or, on a request, counter, accept or decline, Quote Management is not enabled for the customer (`customer_specific_features` must contain `{\"QUOTE_MANAGEMENT\": true}`)",
"content": {
"application/json": {
"schema": {
@@ -522,7 +529,7 @@
}
},
"NotFound": {
- "description": "Quote unknown or owned by another customer, without confirming which",
+ "description": "Quote unknown or owned by another customer, without confirming which. On any path, also a shop without the commercial Quote Management backend, which serves no quote routes.",
"content": {
"application/json": {
"schema": {
@@ -532,7 +539,7 @@
}
},
"Unsupported": {
- "description": "The quote capability is disabled, not licensed, or the commercial Quote Management backend is unavailable",
+ "description": "The commercial Quote Management backend is installed but not licensed, or a service the capability needs is unavailable",
"content": {
"application/json": {
"schema": {
@@ -542,7 +549,7 @@
}
},
"Conflict": {
- "description": "Idempotency key reused for a different request, or for a response that is no longer replayable (code `idempotency_conflict`)",
+ "description": "Idempotency key still being processed, reused for a different request, or for a response that is no longer replayable (code `idempotency_conflict`)",
"content": {
"application/json": {
"schema": {
@@ -552,7 +559,7 @@
}
},
"InsufficientScope": {
- "description": "insufficient_scope: the access token is valid but lacks a scope this operation requires. Only on shops that list com.shopware.quote:manage in scopes_supported. Link the account again, requesting the scopes the message names.",
+ "description": "The access token is valid but lacks a scope this operation requires. The message `code` is `invalid_request`; its `content` starts with `insufficient_scope:` and names the missing scopes. Only on shops that list com.shopware.quote:manage in scopes_supported. Link the account again, requesting those scopes.",
"content": {
"application/json": {
"schema": {
@@ -787,7 +794,7 @@
},
"order": {
"type": "object",
- "description": "Present only after acceptance",
+ "description": "Present only in the acceptQuote response (and its idempotent replay); later reads of an accepted quote do not carry it.",
"required": [
"id"
],
@@ -805,11 +812,11 @@
},
"a2cn_session_id": {
"type": "string",
- "description": "Present only when the quote was created with an A2CN session stamped onto it. The buyer's negotiation session id for POST /a2cn/sessions/{sessionId}/messages; derivable from the quote id, so its absence is not an error."
+ "description": "Present only in the requestQuote response, when an A2CN session was stamped onto the new quote; later reads do not carry it. The buyer's negotiation session id for POST /a2cn/sessions/{sessionId}/messages; derivable from the quote id, so its absence is not an error."
},
"ucp": {
"type": "object",
- "description": "Protocol envelope added to every successful response; `capabilities` appears once an agent has negotiated.",
+ "description": "Protocol envelope added to every successful response.",
"properties": {
"version": {
"type": "string"
@@ -817,9 +824,6 @@
"status": {
"type": "string",
"const": "success"
- },
- "capabilities": {
- "type": "object"
}
}
}
@@ -861,9 +865,10 @@
"type": "string",
"description": "Machine-readable error code",
"examples": [
- "validation_error",
- "resource_not_found",
- "unsupported_capability"
+ "invalid_request",
+ "not_found",
+ "idempotency_conflict",
+ "capability_unsupported"
]
},
"content": {
diff --git a/src/Resources/schema/quote.spec.html b/src/Resources/schema/quote.spec.html
index 45892ada..2e3b2a38 100644
--- a/src/Resources/schema/quote.spec.html
+++ b/src/Resources/schema/quote.spec.html
@@ -47,28 +47,28 @@ Discovery
The capability appears in /.well-known/ucp when the shop runs a UCP
surface and the merchant has enabled dev.ucp.common.identity_linking for
that sales channel — without it no token can be issued for quoting, so the descriptor is
- suppressed rather than advertised as a dead end. Its descriptor carries the endpoint and
- the schema URL:
+ suppressed rather than advertised as a dead end. Its descriptor carries the capability
+ version and the URLs of this page and of the schema:
"com.shopware.quote": [{
"version": "2026-04-08",
"spec": "https://shop.example/.well-known/ucp/specs/quote.html",
- "schema": "https://shop.example/.well-known/ucp/schemas/quote.openapi.json",
- "config": {
- "endpoint": "https://shop.example/ucp/quotes",
- "schema": "https://shop.example/.well-known/ucp/schemas/quote.openapi.json"
- }
+ "schema": "https://shop.example/.well-known/ucp/schemas/quote.openapi.json"
}]
If the entry is absent, the shop cannot quote: fall back to ordinary checkout, or tell
- the user that quoting is unavailable. Do not hard-code the endpoint; read it from the
- descriptor.
+ the user that quoting is unavailable. The descriptor names no endpoint. The operation
+ paths are the paths of the schema; resolve them against the shop's base
+ URL, which is the schema URL up to /.well-known/.
- Present in discovery is not the same as usable. Discovery does not probe the commercial
- B2B Quote Management backend, so a shop without a licensed installation still advertises
- the capability and answers every call with 501. Treat the first 501 as the real
- availability answer and fall back exactly as you would for a missing entry.
+ Present in discovery is not the same as usable. Discovery does not check the commercial
+ B2B Quote Management backend. A shop without it still advertises the capability but
+ serves no quote routes, so every call is a 404. A shop that has the backend but no
+ license for it answers every call with 501. Treat the first such answer as the real
+ availability answer and fall back exactly as you would for a missing entry. A 404 on
+ POST /ucp/quotes or GET /ucp/quotes can only mean this; a 404
+ on a quote id can also mean the quote is not yours.
Authorization
@@ -169,15 +169,17 @@ Signing GET requests
Requests also carry the usual UCP-Agent header, and an
Idempotency-Key on writes — required where the shop is configured to demand
it, and in every case a replay guard: the same key with the same request replays the
- stored response (Idempotency-Replay: 1), a key reused for a different one
- is a 409. A Shopware
+ stored response (Idempotency-Replay: 1), and a key that is still being
+ processed, or reused for a different request, or for a response too large to have been
+ stored, is a 409. A Shopware
sw-context-token is deliberately not accepted: it carries no OAuth
scopes and could reuse the customer's existing storefront cart.
Beyond authorization, the customer must have the commercial Quote Management feature
- enabled on their account. If not, calls fail with a validation error naming the missing
- feature. That is a merchant-side setting, not something an agent can resolve.
+ enabled on their account. If not, requesting, countering, accepting and declining fail
+ with a validation error naming the missing feature; reads are not checked. That is a
+ merchant-side setting, not something an agent can resolve.
Operations
@@ -200,7 +202,8 @@ State machine
replied | buyer | accept, counter, decline |
change_requested | merchant | poll |
reopen | merchant | poll — the merchant reopened a declined quote; it returns to replied |
- accepted / declined / expired / withdrawn / cancelled | none | terminal for the buyer |
+ expired | merchant | terminal for the buyer; the merchant may extend it back to replied |
+ accepted / declined / withdrawn / cancelled | none | terminal for the buyer |
Only replied is buyer-actionable. Acting from any other state fails, so
@@ -251,16 +254,17 @@
Asynchronous negotiation
Errors
| Situation | Result | Status |
- | Capability disabled, not licensed, or the commercial Quote Management backend is unavailable | unsupported capability — note the descriptor may still be in discovery | 501 |
+ | Commercial Quote Management backend not installed: the shop serves no quote routes | not found, on every path; the descriptor may still be in discovery | 404 |
+ | Commercial Quote Management backend installed but not licensed, or missing a service the capability needs | unsupported capability; the descriptor may still be in discovery | 501 |
| Access token missing, invalid, expired, or revoked | invalid token | 401 |
- Access token lacks com.shopware.quote:manage, or dev.ucp.shopping.order:manage on accept, on a shop that lists the quote scope | insufficient_scope, naming the missing scopes; link the account again | 403 |
- Authorization request whose signature did not verify, or whose client_id is not the signed profile URI | error naming UCP-Agent | 401 |
+ Access token lacks com.shopware.quote:manage, or dev.ucp.shopping.order:manage on accept, on a shop that lists the quote scope | message starting insufficient_scope: and naming the missing scopes; link the account again | 403 |
+ Authorization request whose signature did not verify, or whose client_id is not the signed profile URI | error saying the signature did not verify, or that client_id must be the signed profile URI | 401 |
authorization_endpoint called by an agent without a signed-in storefront customer | identity_required; use the account-linking flow | 400 |
| Credential resolves to no customer | validation error naming the header | 422 |
- Quote Management not enabled for the customer (customer_specific_features) | validation error naming the feature | 422 |
+ Quote Management not enabled for the customer (customer_specific_features), on a request, counter, accept or decline | validation error naming the feature | 422 |
| Malformed request body or line items | validation error | 422 |
| Per-line price ask sent to a shop that cannot record one | validation error telling you to send the ask as a comment | 422 |
- Idempotency-Key missing where required, or reused for a different request | validation error / idempotency_conflict | 422 / 409 |
+ Idempotency-Key missing where required, or still being processed, or reused for a different request | validation error / idempotency_conflict | 422 / 409 |
| Quote id unknown or owned by another customer | not found, without confirming existence | 404 |
| Accepting from a non-actionable or expired state | error from the commercial quote-to-order flow | 400 |
@@ -273,8 +277,12 @@ Errors
Versioning
- The capability version tracks the UCP protocol version in the descriptor. Additive
- fields may appear without a version change; read defensively and ignore unknown fields.
+ The descriptor's version is the revision of this contract, not the UCP
+ protocol version the shop serves (that one is ucp.version in discovery).
+ It changes only when the contract changes incompatibly. UCP negotiation matches a
+ capability by exact version, so if your profile lists com.shopware.quote,
+ list it at the version the descriptor publishes. Additive fields may appear without a
+ version change; read defensively and ignore unknown fields.