{
  "openapi": "3.1.0",
  "info": {
    "title": "Salt of the Earth storefront API",
    "version": "1.0.0",
    "summary": "Read-only product search for the Salt of the Earth natural deodorant storefront.",
    "description": "One anonymous, read-only endpoint: search the Salt of the Earth catalogue by phrase and get back the matching products with links to their pages. No API key, account or token is needed.\n\nIt does not return prices or stock. Every product page publishes schema.org Product/Offer JSON-LD with the current price and availability, and every price is in one table at https://www.saltoftheearthnatural.com/pricing.md (markdown, GBP) or https://www.saltoftheearthnatural.com/en/pricing (HTML, GBP; EUR at https://www.saltoftheearthnatural.com/nl/pricing). Any storefront page returns markdown when fetched with `Accept: text/markdown`.\n\nBuying is not an HTTP API on this host. Agents that purchase on a user's behalf use the store's Universal Commerce Protocol MCP endpoint, run by Shopify on the checkout host: discovery at https://checkout.saltoftheearthnatural.com/.well-known/ucp, MCP at `POST https://checkout.saltoftheearthnatural.com/api/ucp/mcp`. Instructions: https://www.saltoftheearthnatural.com/agents.md. Payment always needs the buyer's explicit approval.\n\nThere are no per-key quotas or quota headers. The hosting edge rate-limits abusive traffic with HTTP 429; back off and retry. Cache responses for as long as their `Cache-Control` allows.",
    "contact": {
      "name": "Salt of the Earth developer guide",
      "url": "https://www.saltoftheearthnatural.com/en/developers"
    }
  },
  "externalDocs": {
    "description": "Developer guide: the two hosts, search, prices, markdown pages and buying through UCP.",
    "url": "https://www.saltoftheearthnatural.com/en/developers"
  },
  "servers": [
    {
      "url": "https://www.saltoftheearthnatural.com",
      "description": "Production storefront"
    }
  ],
  "tags": [
    {
      "name": "search",
      "description": "Read-only catalogue search."
    }
  ],
  "security": [],
  "paths": {
    "/api/search": {
      "get": {
        "operationId": "searchProducts",
        "tags": [
          "search"
        ],
        "summary": "Search the product catalogue",
        "description": "Returns the products that best match a phrase, combining keyword and semantic matching over names, fragrances, formats and notes. Best matches come first.\n\nThe query is trimmed and cut to 200 characters; fewer than 2 remaining characters is a `400 invalid_query`. `limit` is clamped to 1..50 rather than rejected. There is no cursor or page parameter: the response is the top `limit` matches.\n\nWhen the primary index cannot answer, the Shopify catalogue is searched instead and the response says so in `source` and `X-Search-Source`; such results carry only name, handle and URL. If both providers fail the answer is `503 search_unavailable` with `Retry-After`.\n\nSuccessful responses are shared-cacheable (up to 30 minutes for non-empty results, briefly for empty ones); degraded responses are `no-store`. The query is never logged.",
        "security": [],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Search phrase. Trimmed, then truncated to 200 characters. Must be at least 2 characters after trimming.",
            "schema": {
              "type": "string",
              "minLength": 2,
              "maxLength": 200
            },
            "example": "lavender refill"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Maximum number of results. Default 20; values below 1 or above 50 are clamped, and a non-numeric value falls back to the default.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 20
            },
            "example": 5
          }
        ],
        "responses": {
          "200": {
            "description": "Matching products, best first. `count` may be 0.",
            "headers": {
              "Cache-Control": {
                "description": "How long the response may be shared-cached. `no-store` marks a degraded response.",
                "schema": {
                  "type": "string"
                }
              },
              "X-Search-Source": {
                "$ref": "#/components/headers/X-Search-Source"
              },
              "X-Search-Degraded": {
                "$ref": "#/components/headers/X-Search-Degraded"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SearchResponse"
                },
                "example": {
                  "query": "refill",
                  "count": 1,
                  "source": "supabase",
                  "results": [
                    {
                      "product_code_uk": "CRYS300LV-C",
                      "shopify_handle": "lavender-vanilla-spray-refill-pouch",
                      "url": "https://www.saltoftheearthnatural.com/en/product/lavender-vanilla-spray-refill-pouch",
                      "product_name": "Lavender & Vanilla Spray Refill Deodorant",
                      "format": "spray",
                      "is_refill": true,
                      "is_refillable": false,
                      "parent_product_code": "CRYS38-C",
                      "fragrance_name": "Lavender & Vanilla",
                      "top_notes": "Cardamom & Lemon",
                      "awards": null,
                      "award_count": 0,
                      "review_summary": null,
                      "average_rating": 5,
                      "fts_rank": 0,
                      "semantic_rank": 0.48,
                      "combined_score": 0.0246
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`invalid_query`: `q` is missing or shorter than 2 characters after trimming.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "405": {
            "description": "`method_not_allowed`: only GET and HEAD are supported.",
            "headers": {
              "Allow": {
                "description": "The accepted methods.",
                "schema": {
                  "type": "string",
                  "const": "GET, HEAD"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited at the hosting edge under abusive traffic. This is not an application quota: there are no keys, no quota headers and the body is the edge's own. Back off and retry later."
          },
          "503": {
            "description": "`search_unavailable`: both search providers failed. Honour `Retry-After` and retry; the response is never cached.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer",
                  "const": 30
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "SearchResponse": {
        "type": "object",
        "required": [
          "query",
          "count",
          "results",
          "source"
        ],
        "properties": {
          "query": {
            "type": "string",
            "description": "The query as searched: trimmed and cut to 200 characters."
          },
          "count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 50,
            "description": "Number of items in `results`. Zero is a valid answer."
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SearchResult"
            },
            "description": "Best matches first. There is no pagination beyond `limit`."
          },
          "source": {
            "type": "string",
            "enum": [
              "supabase",
              "shopify"
            ],
            "description": "`supabase`: the primary hybrid index answered. `shopify`: the primary index was unavailable or empty and the Shopify catalogue answered instead; results then carry only name and handle, with descriptive fields null."
          }
        }
      },
      "SearchResult": {
        "type": "object",
        "description": "One product matching the query. Descriptive fields come from the product index and may be null; the page at `url` is the source of truth for price, stock and full ingredients.",
        "required": [
          "product_code_uk",
          "shopify_handle",
          "url",
          "product_name",
          "format",
          "is_refill",
          "is_refillable",
          "parent_product_code",
          "fragrance_name",
          "top_notes",
          "awards",
          "award_count",
          "review_summary",
          "average_rating",
          "fts_rank",
          "semantic_rank",
          "combined_score"
        ],
        "properties": {
          "product_code_uk": {
            "type": "string",
            "description": "Merchant product code, e.g. `CRYS300LV-C`."
          },
          "shopify_handle": {
            "type": "string",
            "description": "URL handle of the product. The English product page is `/en/product/{shopify_handle}`, the Dutch one `/nl/product/{shopify_handle}`."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Absolute URL of the English product page. Fetch it with `Accept: text/markdown` for a text version; its HTML carries schema.org Product/Offer JSON-LD with the current GBP price and availability."
          },
          "product_name": {
            "type": "string"
          },
          "format": {
            "type": [
              "string",
              "null"
            ],
            "description": "Product format such as `spray`, `roll-on`, `stick`, `balm` or `crystal`."
          },
          "is_refill": {
            "type": "boolean",
            "description": "True when the product is a refill pouch or refill bottle."
          },
          "is_refillable": {
            "type": "boolean",
            "description": "True when the product is a container that takes a refill."
          },
          "parent_product_code": {
            "type": [
              "string",
              "null"
            ],
            "description": "For a refill, the product code of the container it fits."
          },
          "fragrance_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "top_notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "awards": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Award names recorded against the product, if any."
          },
          "award_count": {
            "type": "integer",
            "minimum": 0
          },
          "review_summary": {
            "type": [
              "string",
              "null"
            ],
            "description": "A generated summary of customer reviews, when one exists."
          },
          "average_rating": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 5
          },
          "fts_rank": {
            "type": "number",
            "description": "Keyword-match signal. Relative to this response only; not stable between requests and not comparable across queries."
          },
          "semantic_rank": {
            "type": "number",
            "description": "Semantic-match signal. Relative to this response only; not stable between requests and not comparable across queries."
          },
          "combined_score": {
            "type": "number",
            "description": "The score results are ordered by. Relative to this response only."
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "required": [
          "error",
          "code",
          "hint",
          "results"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable message."
          },
          "code": {
            "type": "string",
            "enum": [
              "invalid_query",
              "search_unavailable",
              "method_not_allowed"
            ],
            "description": "Stable machine-readable code; key recovery on this."
          },
          "hint": {
            "type": "string",
            "description": "What to change or do next."
          },
          "results": {
            "type": "array",
            "maxItems": 0,
            "items": {},
            "description": "Always empty on an error; kept for older clients."
          }
        }
      }
    },
    "headers": {
      "X-Search-Source": {
        "description": "Which provider answered; mirrors the body's `source`.",
        "schema": {
          "type": "string",
          "enum": [
            "supabase",
            "shopify"
          ]
        }
      },
      "X-Search-Degraded": {
        "description": "Present, with value `1`, when one provider failed and the other answered.",
        "schema": {
          "type": "string",
          "const": "1"
        }
      }
    }
  }
}