{
  "openapi": "3.1.0",
  "info": {
    "title": "ProductIndex API",
    "version": "1.0.0",
    "description": "Public and agent-facing REST surface for ProductIndex: semantic product search, product/category reads, producer offers, and producer org agent-integration endpoints. This spec covers a deliberately scoped subset of the site's ~140 API routes — the routes meant to be called by external agents/integrators, not the authenticated dashboard's internal account-management surface (member/invite management, analytics, audit-log, billing, admin, cron, auth, OAuth token issuance, and most of api/org/ are intentionally excluded). For MCP tool-call access to the same product data, see /.well-known/mcp.json. For A2A message-based access, see /.well-known/agent-card.json.",
    "contact": { "name": "ProductIndex", "url": "https://productindex.ai/contact" }
  },
  "servers": [
    { "url": "https://productindex.ai", "description": "Production" }
  ],
  "security": [],
  "paths": {
    "/api/search/": {
      "post": {
        "operationId": "search",
        "summary": "Semantic product search",
        "description": "Runs the search-agent Lambda pipeline (pgvector + BM25 RRF + Cohere rerank) and returns ranked product results. If the caller has a verified-email session and either sets `bundle: true` or the query matches a bundle-search pattern (e.g. \"full kitchen set $900\"), a multi-category bundle response is returned instead of a flat result list — see the two response schemas below. Anonymous callers always get the regular (non-bundle) response shape.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/SearchRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Search results — regular or bundle shape depending on caller eligibility and query.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/SearchResponse" },
                    { "$ref": "#/components/schemas/BundleResponse" }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Missing or invalid `query`, or request body was not valid JSON.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "429": {
            "description": "Bundle-search per-user daily limit exceeded (20/day per authenticated, verified-email user). Only reachable when a bundle query is triggered by a verified-email session — bundle search is gated behind email verification, so an anonymous caller through this endpoint can never enter the bundle path or hit this limit (the per-IP hourly limit documented in docs/search.md applies to other, non-REST callers of the same Lambda, not this route).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "502": {
            "description": "The search-agent Lambda returned a function error.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "503": {
            "description": "Search is temporarily unavailable. Retry with backoff.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/p/{category}/{slug}.json": {
      "get": {
        "operationId": "getProductJsonLd",
        "summary": "Product read (JSON-LD)",
        "description": "Returns Schema.org `Product` JSON-LD for a single product. Statically prerendered at build time (one file per product, from static content plus build-time-merged producer enrichments) — this is a read-only, always-200-for-known-slugs endpoint; unknown slugs 404 via normal static-file routing rather than a JSON error body. Response is served with `X-Robots-Tag: noindex`.",
        "security": [],
        "parameters": [
          { "name": "category", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Category slug, e.g. `espresso-machines`." },
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Product slug, e.g. `breville-bambino-plus`." }
        ],
        "responses": {
          "200": {
            "description": "Product JSON-LD.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProductJsonLd" } } }
          },
          "404": { "description": "No product exists at this category/slug (static 404, not a JSON error body)." }
        }
      }
    },
    "/c/{category}.md": {
      "get": {
        "operationId": "getCategoryMarkdown",
        "summary": "Category read (Markdown)",
        "description": "Returns the full category listing plus every product's full markdown profile concatenated into one document — a category-level table (product, price, review count) followed by one `### {title}` section per product. This is the category-read equivalent of the per-product `.md`/`.json` routes; there is no JSON variant of this endpoint (only `[category].astro` for HTML and `[category].md.ts` for markdown exist under `src/pages/c/`). Statically prerendered. Response is served with `X-Robots-Tag: noindex`.",
        "security": [],
        "parameters": [
          { "name": "category", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Category slug, e.g. `espresso-machines`." }
        ],
        "responses": {
          "200": {
            "description": "Category markdown document.",
            "content": { "text/markdown": { "schema": { "type": "string" } } }
          },
          "404": { "description": "No such category (static 404, not a JSON error body)." }
        }
      }
    },
    "/api/offers/create/": {
      "post": {
        "operationId": "createOffer",
        "summary": "Create a producer offer",
        "description": "Creates a new offer in `draft` status for a product the caller's organization has a verified `sells` or `manufactured_by` relationship to.",
        "security": [{ "producerSession": [] }, { "producerBearerToken": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OfferCreateRequest" } } }
        },
        "responses": {
          "201": {
            "description": "Offer created.",
            "content": { "application/json": { "schema": { "type": "object", "required": ["offer_id"], "properties": { "offer_id": { "type": "string" } } } } }
          },
          "400": { "description": "Validation error — missing/invalid `product_slug`, `headline` (required, ≤ 60 chars), `detail` (≤ 200 chars), `destination_url` (must be http/https), `price_usd`/`list_price_usd` (must be ≥ 0), `offer_types` (must be a subset of the valid enum), or `starts_at`/`expires_at` (expires_at must be after starts_at).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "401": { "description": "No valid session or bearer token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "403": { "description": "Caller's org has no verified `sells`/`manufactured_by` relationship to `product_slug` (`no_verified_relationship`), the org is deactivated, or a session caller isn't a producer account.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
        }
      }
    },
    "/api/offers/edit/": {
      "patch": {
        "operationId": "editOffer",
        "summary": "Edit an existing producer offer",
        "description": "Partial update — only fields present in the body are changed. Ownership is enforced by scoping the UPDATE to the caller's `organization_id`; an `offer_id` belonging to a different org returns 404, not 403.",
        "security": [{ "producerSession": [] }, { "producerBearerToken": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OfferEditRequest" } } }
        },
        "responses": {
          "200": {
            "description": "Updated (or no-op if no editable fields were present in the body).",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } }
          },
          "400": { "description": "Validation error, applied only to fields present in the body — headline (non-empty, ≤ 60 chars), detail (≤ 200 chars), destination_url (must be http/https), price_usd/list_price_usd (must be ≥ 0), offer_types (must be a subset of the valid enum). Unlike create, this route does not validate starts_at/expires_at ordering.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "401": { "description": "No valid session or bearer token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "404": { "description": "No offer with this `offer_id` belongs to the caller's organization.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
        }
      }
    },
    "/api/offers/list/": {
      "get": {
        "operationId": "listOffers",
        "summary": "List the caller's offers and offer-eligible products",
        "description": "Returns the caller's organization's offers (newest 100) plus the products it's eligible to create offers for (products with a verified `sells`/`manufactured_by` relationship).",
        "security": [{ "producerSession": [] }, { "producerBearerToken": [] }],
        "responses": {
          "200": {
            "description": "Offers and eligible products.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["offers", "products"],
                  "properties": {
                    "offers": { "type": "array", "items": { "$ref": "#/components/schemas/OfferRow" } },
                    "products": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "product_slug": { "type": "string" },
                          "relationship_type": { "type": "string", "enum": ["sells", "manufactured_by"] }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "description": "No valid session or bearer token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
        }
      }
    },
    "/api/offers/update/": {
      "patch": {
        "operationId": "updateOfferStatus",
        "summary": "Update an offer's status",
        "description": "Status-only transition (`active` / `paused` / `expired`). For any other field, use `PATCH /api/offers/edit/`. Ownership is enforced the same way as edit — a mismatched `offer_id`/org pair returns 404.",
        "security": [{ "producerSession": [] }, { "producerBearerToken": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["offer_id", "status"],
                "properties": {
                  "offer_id": { "type": "string" },
                  "status": { "type": "string", "enum": ["active", "paused", "expired"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Status updated.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } },
          "400": { "description": "Missing `offer_id`, or `status` not one of `active`/`paused`/`expired`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "401": { "description": "No valid session or bearer token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "404": { "description": "No offer with this `offer_id` belongs to the caller's organization.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
        }
      }
    },
    "/api/org/register/": {
      "post": {
        "operationId": "registerOrg",
        "summary": "Create a new producer organization",
        "description": "Creates a new organization and adds the caller as its `owner`. Session-only — there is no bearer-token path for this route (a Shopify plugin token can't create an org, since the org doesn't exist yet). If the org's `domain` matches the caller's verified email domain (and isn't a freemail domain), the org is auto-verified via `email_domain` and `verification_status` is returned as `verified` instead of `pending`.",
        "security": [{ "producerSession": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name", "org_type"],
                "properties": {
                  "name": { "type": "string", "maxLength": 255, "description": "Organization name." },
                  "org_type": { "type": "string", "enum": ["manufacturer", "retailer", "distributor"] },
                  "domain": { "type": "string", "description": "Bare domain, e.g. `example.com` (scheme/path stripped if present)." },
                  "website_url": { "type": "string", "format": "uri" },
                  "shopify_shop": { "type": "string" }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Organization created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["org_id", "verification_status", "skipped_verification"],
                  "properties": {
                    "org_id": { "type": "string" },
                    "verification_status": { "type": "string", "enum": ["pending", "verified"] },
                    "skipped_verification": { "type": "boolean" }
                  }
                }
              }
            }
          },
          "400": { "description": "Missing/invalid `name` (required, ≤ 255 chars), invalid `org_type`, malformed `domain`, or malformed `website_url`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "401": { "description": "No signed-in session.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "403": { "description": "Caller's email is not verified (`EMAIL_UNVERIFIED_CODE`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "500": { "description": "Database error creating the organization or updating the user's account type.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
        }
      }
    },
    "/api/org/register-agent-card/": {
      "post": {
        "operationId": "registerAgentCard",
        "summary": "Register a self-hosted A2A agent card for RFQ automation",
        "description": "Registers (or replaces) the org's self-hosted agent card URL for receiving RFQ/pricing requests. Requires the org to already be domain-verified, the card URL's hostname to match (or be a subdomain of) that verified domain, and the fetched card to declare a `respond-to-rfq` skill. Includes an SSRF guard (`resolvesToPrivateIp`) before ever fetching the card URL. Session callers must be an org `owner` or `admin`; bearer-token (Shopify plugin) callers are exempt from that role gate.",
        "security": [{ "producerSession": [] }, { "producerBearerToken": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["agent_card_url"],
                "properties": {
                  "agent_card_url": { "type": "string", "format": "uri", "description": "Must be https and on (or a subdomain of) the org's verified domain." },
                  "auth_type": { "type": "string", "enum": ["none", "bearer", "hmac"], "default": "none" },
                  "bearer_token": { "type": "string", "minLength": 16, "description": "Required when auth_type is \"bearer\". Stored encrypted." },
                  "hmac_secret": { "type": "string", "minLength": 16, "description": "Required when auth_type is \"hmac\". Stored encrypted." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Agent card registered.",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "validated": { "type": "boolean" } } } } }
          },
          "400": { "description": "Missing `agent_card_url`, invalid URL, invalid `auth_type`, or `bearer_token`/`hmac_secret` shorter than 16 characters.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "401": { "description": "No valid session or bearer token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "403": { "description": "Session caller isn't an org owner/admin, the org's verification_status isn't `verified`, or the card URL's hostname doesn't match the org's verified domain (`domain_mismatch`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "404": { "description": "Organization not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "422": { "description": "Agent card URL resolves to a private IP (SSRF guard), the fetch failed or returned a non-2xx status, or the fetched card doesn't declare a `respond-to-rfq` skill.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
        }
      }
    },
    "/api/org/agent-integration/": {
      "get": {
        "operationId": "getAgentIntegration",
        "summary": "Get the org's RFQ agent-integration configuration",
        "description": "Returns the org's current Managed/self-hosted agent configuration. `reserve_price_usd` is always stripped from `managed_rules` before it's returned, regardless of caller.",
        "security": [{ "producerSession": [] }, { "producerBearerToken": [] }],
        "responses": {
          "200": {
            "description": "Current agent-integration configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "is_managed": { "type": "boolean" },
                    "managed_rules": { "type": ["object", "null"], "description": "Never includes reserve_price_usd." },
                    "agent_card_url": { "type": ["string", "null"] },
                    "auth_type": { "type": "string", "enum": ["none", "bearer", "hmac"] },
                    "status": { "type": "string", "enum": ["pending", "active", "unreachable"] },
                    "last_response_ms": { "type": ["number", "null"] },
                    "last_checked_at": { "type": ["string", "null"], "format": "date-time" }
                  }
                }
              }
            }
          },
          "401": { "description": "No valid session or bearer token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
        }
      },
      "patch": {
        "operationId": "updateAgentIntegration",
        "summary": "Update the org's RFQ agent-integration mode",
        "description": "Toggles `is_managed` (ProductIndex-managed pricing/RFQ responses vs. self-hosted agent card) and updates `managed_rules`. To set or clear `agent_card_url` to a real URL, use `POST /api/org/register-agent-card/` instead — this endpoint only accepts `null` here (to clear it). Body validation runs before the owner/admin role check, so a malformed body from a non-owner/admin still returns 400 rather than 403.",
        "security": [{ "producerSession": [] }, { "producerBearerToken": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["is_managed"],
                "properties": {
                  "is_managed": { "type": "boolean" },
                  "managed_rules": { "type": ["object", "null"], "properties": { "volume_discounts": { "type": "array" } }, "description": "`volume_discounts` is the only org-level pricing rule this endpoint validates (checked for array type). Per-product rules (max_discount_pct, lead_time_days, return_policy_days) belong on the product in the catalog, not here — but note the server does not currently reject them if included in this body; any field other than `reserve_price_usd`/`min_margin_pct` (both always stripped before storage) is passed through and persisted as-is." },
                  "agent_card_url": { "type": "null", "description": "Only `null` is accepted (clears the agent card). Pass a string via POST /api/org/register-agent-card/ instead." }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Updated.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } },
          "400": { "description": "`is_managed` missing/not a boolean, or `managed_rules.volume_discounts` present but not an array.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "401": { "description": "No valid session or bearer token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "403": { "description": "Session caller isn't an org owner/admin.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
        }
      }
    },
    "/api/org/ucp-check/": {
      "post": {
        "operationId": "ucpCheck",
        "summary": "Re-check UCP (Universal Commerce Protocol) support for the org's domain",
        "description": "Probes the org's verified domain (falling back to its Shopify shop subdomain) for UCP discovery support and persists the result. Session-only — there is no bearer-token path. Requires the org's `domain` to already be `verification_status = 'verified'` before it will fetch it, since `domain` is owner-settable with no DNS-ownership check at write time and this endpoint would otherwise be usable as an SSRF proxy against an unverified value. `shopify_shop` is exempt from that check since it's set server-side via OAuth, not owner-editable.",
        "security": [{ "producerSession": [] }],
        "responses": {
          "200": {
            "description": "Check complete (whether or not UCP support was found).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ucp_supported": { "type": "boolean" },
                    "profile_url": { "type": ["string", "null"] },
                    "checkout_endpoint": { "type": ["string", "null"] }
                  }
                }
              }
            }
          },
          "401": { "description": "No signed-in session.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "403": { "description": "Caller isn't a producer account, or the org's `domain` is set but not yet `verified`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "404": { "description": "Org has neither `domain` nor `shopify_shop` configured.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "producerSession": {
        "type": "apiKey",
        "in": "cookie",
        "name": "authjs.session-token",
        "description": "Browser session cookie from signing in at /auth/signin, for a producer account (accountType='producer'). Served as \"__Secure-authjs.session-token\" over HTTPS. Not directly obtainable by a pure API/agent client — this auth mode is for the dashboard's own fetch() calls and the human-driven signup/verification flow, not for third-party integrations. Third-party/agent integrations should use producerBearerToken where the route supports it."
      },
      "producerBearerToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "Shopify-plugin producer link token (issued via the Shopify OAuth/link flow, validated with validateShopifyLinkToken). Only accepted on routes built on resolveProducerAuth — not every producer route supports this; register.ts and ucp-check.ts are session-only."
      }
    },
    "schemas": {
      "ErrorResponse": {
        "type": "object",
        "required": ["error"],
        "properties": { "error": { "type": "string" } },
        "additionalProperties": true,
        "description": "Common error envelope across every route in this spec. Some validation branches add extra fields alongside `error` (e.g. `field`, `max`, `valid`, `code`) — see each operation's per-status description for the exact fields a given failure mode adds."
      },
      "SearchRequest": {
        "type": "object",
        "required": ["query"],
        "properties": {
          "query": { "type": "string", "minLength": 1, "description": "Free-text search query." },
          "session_id": { "type": "string", "description": "Anonymous session UUID for search-analytics correlation; not an auth credential." },
          "bundle": { "type": "boolean", "description": "Explicitly opt into the bundle-search path (skips the keyword-trigger gate, still requires a verified-email session). Ignored for anonymous callers." },
          "filters": {
            "type": "object",
            "properties": {
              "category": { "type": "string", "description": "Category slug. Setting this disables bundle-search consideration entirely." },
              "max_price": { "type": "number" },
              "min_price": { "type": "number" },
              "on_sale": { "type": "boolean" }
            }
          },
          "limit": { "type": "number" }
        }
      },
      "SearchResult": {
        "type": "object",
        "properties": {
          "slug": { "type": "string" },
          "title": { "type": "string" },
          "category": { "type": "string" },
          "verdict": { "type": ["string", "null"] },
          "price_range": { "type": ["string", "null"] },
          "url": { "type": "string", "description": "Relative path, e.g. /p/espresso-machines/breville-bambino-plus" },
          "similarity": { "type": "number" },
          "is_sponsored": { "type": "boolean" }
        }
      },
      "SearchResponse": {
        "type": "object",
        "required": ["query_id", "results"],
        "properties": {
          "query_id": { "type": "string" },
          "results": { "type": "array", "items": { "$ref": "#/components/schemas/SearchResult" } }
        }
      },
      "BundleProduct": {
        "type": "object",
        "properties": {
          "slug": { "type": "string" },
          "url": { "type": "string" },
          "title": { "type": "string" },
          "price_range": { "type": ["string", "null"] },
          "verdict": { "type": ["string", "null"] }
        }
      },
      "BundleCategory": {
        "type": "object",
        "properties": {
          "slug": { "type": "string" },
          "label": { "type": "string" },
          "reasoning": { "type": "string", "description": "Claude's explanation for this category's allocation." },
          "budget_allocated": { "type": ["number", "null"] },
          "over_budget": { "type": "boolean", "description": "True if no candidate was found within the allocated budget and the category was retried unconstrained." },
          "product": { "oneOf": [{ "$ref": "#/components/schemas/BundleProduct" }, { "type": "null" }] }
        }
      },
      "BundleResponse": {
        "type": "object",
        "required": ["type", "query_id", "categories"],
        "properties": {
          "type": { "type": "string", "const": "bundle" },
          "query_id": { "type": "string" },
          "overview": { "type": "string", "description": "Claude's overall bundle summary." },
          "categories": { "type": "array", "items": { "$ref": "#/components/schemas/BundleCategory" } }
        }
      },
      "ProductJsonLd": {
        "type": "object",
        "description": "Schema.org Product JSON-LD, built per-request from static content plus build-time-merged enrichments. Fields beyond @context/@type/name/category/url/offers/dateModified are conditionally present.",
        "properties": {
          "@context": { "type": "string", "const": "https://schema.org" },
          "@type": { "type": "string", "const": "Product" },
          "name": { "type": "string" },
          "model": { "type": "string" },
          "category": { "type": "string" },
          "url": { "type": "string" },
          "brand": { "type": "object", "properties": { "@type": { "type": "string", "const": "Brand" }, "name": { "type": "string" } } },
          "image": { "type": "string", "description": "Present when a non-placeholder image exists." },
          "additionalImages": { "type": "array", "items": { "type": "string" }, "description": "Present when producer-submitted images have been approved." },
          "verificationStatus": { "type": "string", "enum": ["base", "producer_enriched", "manufacturer_enriched", "manufacturer_verified"] },
          "enrichmentEndpoint": { "type": "string" },
          "enrichedBy": { "type": "array", "items": { "type": "object", "properties": { "orgName": { "type": "string" }, "orgType": { "type": "string" }, "verifiedAt": { "type": ["string", "null"] } } }, "description": "Present when at least one verified org contributed an enrichment." },
          "offers": {
            "type": "object",
            "properties": {
              "@type": { "type": "string", "const": "AggregateOffer" },
              "priceCurrency": { "type": "string", "const": "USD" },
              "priceRange": { "type": "string" },
              "url": { "type": "string" }
            }
          },
          "dateModified": { "type": "string" },
          "offersEndpoint": { "type": "string", "description": "Present only when the product has active promotedOffers; points to /offers/{slug}.json." }
        }
      },
      "OfferCreateRequest": {
        "type": "object",
        "required": ["product_slug", "headline", "destination_url"],
        "properties": {
          "product_slug": { "type": "string" },
          "headline": { "type": "string", "maxLength": 60 },
          "detail": { "type": "string", "maxLength": 200 },
          "destination_url": { "type": "string", "format": "uri", "description": "Must be http or https." },
          "price_usd": { "type": "number", "minimum": 0 },
          "list_price_usd": { "type": "number", "minimum": 0 },
          "starts_at": { "type": "string", "format": "date-time" },
          "expires_at": { "type": "string", "format": "date-time", "description": "Must be after starts_at when both are present." },
          "promo_code": { "type": "string" },
          "offer_types": {
            "type": "array",
            "items": { "type": "string", "enum": ["percentage_off", "fixed_amount_off", "free_shipping", "bogo", "bundle", "free_gift"] }
          }
        }
      },
      "OfferEditRequest": {
        "type": "object",
        "required": ["offer_id"],
        "description": "All fields besides offer_id are optional; only fields present in the body are updated.",
        "properties": {
          "offer_id": { "type": "string" },
          "headline": { "type": "string", "maxLength": 60 },
          "detail": { "type": ["string", "null"], "maxLength": 200 },
          "destination_url": { "type": "string", "format": "uri" },
          "price_usd": { "type": ["number", "null"], "minimum": 0 },
          "list_price_usd": { "type": ["number", "null"], "minimum": 0 },
          "starts_at": { "type": ["string", "null"], "format": "date-time" },
          "expires_at": { "type": ["string", "null"], "format": "date-time" },
          "promo_code": { "type": ["string", "null"] },
          "offer_types": {
            "type": "array",
            "items": { "type": "string", "enum": ["percentage_off", "fixed_amount_off", "free_shipping", "bogo", "bundle", "free_gift"] }
          }
        }
      },
      "OfferRow": {
        "type": "object",
        "description": "As stored/returned by GET /api/offers/list/. price_usd/list_price_usd are stored in cents but this reflects the raw DB row shape returned by that route today.",
        "properties": {
          "id": { "type": "string" },
          "product_slug": { "type": "string" },
          "headline": { "type": "string" },
          "detail": { "type": ["string", "null"] },
          "destination_url": { "type": "string" },
          "price_usd": { "type": ["number", "null"] },
          "list_price_usd": { "type": ["number", "null"] },
          "status": { "type": "string", "enum": ["draft", "active", "paused", "expired"] },
          "starts_at": { "type": ["string", "null"], "format": "date-time" },
          "expires_at": { "type": ["string", "null"], "format": "date-time" },
          "created_at": { "type": "string", "format": "date-time" },
          "promo_code": { "type": ["string", "null"] },
          "offer_types": { "type": "array", "items": { "type": "string" } }
        }
      }
    }
  }
}
