{"openapi":"3.1.1","info":{"title":"pamprr Consumer discovery","version":"1.1.0","description":"GET /api/businesses/search returns business rows by default and one row per visible branch with rows=branches. For branch rows, serviceCount, priceRange and footer.startingPriceInPence describe services offered at that branch through service_locations: active, undeleted services only, excluding add ons. The count and both prices use the same population, across all categories even when a category filter selects the row. An empty branch has serviceCount 0, priceRange { min: null, max: null } and footer.startingPriceInPence null. Business rows retain business-wide figures, including when a nearby branch supplies their address; a legacy row with branch null also uses business figures. Public discovery needs no partner API key. Responses are private, no-store; their catalogue snapshot is shared for 60 seconds with immediate tag expiry after public edits. Branch aggregates and footer calculation add no database reads. The next available slot is omitted because the existing availability summary is live and is absent from both display snapshots. Open state describes cached opening hours evaluated at request time, not capacity. The partner API conventions above do not apply to this consumer endpoint. Rating fields on the row and footer use the same grain. For rows=branches with a non-null branch, only published reviews whose booking belongs to that branch and business qualify. The booking branch is authoritative, not the review's denormalised branch pointer. Business rows and legacy rows with branch null retain all published business reviews, including reviews without an attributable booking. A branch with none has reviewCount 0, row.averageRating 0 for compatibility and footer.averageRating null. Browse renders \"No reviews yet at this branch\". Rating sorts use branch averages and counts, with unrated rows last in both directions."},"servers":[{"url":"https://www.pamprr.me"}],"security":[],"paths":{"/api/businesses/search":{"get":{"operationId":"searchConsumerBusinesses","summary":"Consumer search rows and card footers","description":"GET /api/businesses/search returns business rows by default and one row per visible branch with rows=branches. For branch rows, serviceCount, priceRange and footer.startingPriceInPence describe services offered at that branch through service_locations: active, undeleted services only, excluding add ons. The count and both prices use the same population, across all categories even when a category filter selects the row. An empty branch has serviceCount 0, priceRange { min: null, max: null } and footer.startingPriceInPence null. Business rows retain business-wide figures, including when a nearby branch supplies their address; a legacy row with branch null also uses business figures. Public discovery needs no partner API key. Responses are private, no-store; their catalogue snapshot is shared for 60 seconds with immediate tag expiry after public edits. Branch aggregates and footer calculation add no database reads. The next available slot is omitted because the existing availability summary is live and is absent from both display snapshots. Open state describes cached opening hours evaluated at request time, not capacity. The partner API conventions above do not apply to this consumer endpoint. Rating fields on the row and footer use the same grain. For rows=branches with a non-null branch, only published reviews whose booking belongs to that branch and business qualify. The booking branch is authoritative, not the review's denormalised branch pointer. Business rows and legacy rows with branch null retain all published business reviews, including reviews without an attributable booking. A branch with none has reviewCount 0, row.averageRating 0 for compatibility and footer.averageRating null. Browse renders \"No reviews yet at this branch\". Rating sorts use branch averages and counts, with unrated rows last in both directions.","parameters":[{"name":"q","in":"query","description":"Text search across business and branch names, descriptions and cities.","schema":{"type":"string"}},{"name":"location","in":"query","description":"Postcode to geocode for ranking, or location text. An unresolved location leaves distances null.","schema":{"type":"string"}},{"name":"lat","in":"query","description":"Request latitude. Supply with lng; valid direct coordinates take precedence over location.","schema":{"type":"number"}},{"name":"lng","in":"query","description":"Request longitude. Supply with lat.","schema":{"type":"number"}},{"name":"service","in":"query","description":"Service category slug.","schema":{"type":"string"}},{"name":"rows","in":"query","description":"Use branches for one row per visible branch. Omit for the existing business rows.","schema":{"type":"string"}},{"name":"limit","in":"query","description":"Page size, default 50 and maximum 100.","schema":{"type":"integer"}},{"name":"offset","in":"query","description":"Page offset, default zero.","schema":{"type":"integer"}},{"name":"sort","in":"query","description":"Requires rows=branches. distance, rating_desc, rating_asc or name. distance requires a coordinate signal.","schema":{"type":"string"}},{"name":"radius_miles","in":"query","description":"Requires rows=branches and a coordinate signal. Accepted values: 1, 3, 5, 10.","schema":{"type":"integer"}},{"name":"visited","in":"query","description":"Requires rows=branches and an authenticated consumer session cookie or consumer bearer token. true or false filters businesses by completed bookings. This personal history remains live.","schema":{"type":"string"}}],"responses":{"200":{"description":"Search rows with service figures at the selected row grain and an additive footer. Other row properties remain present. Legacy total is the returned page length; branch total is the full matching branch count.","headers":{"Cache-Control":{"schema":{"type":"string","const":"private, no-store"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerSearchResponse"},"examples":{"position":{"summary":"Footer excerpt with a position; other existing row fields are omitted from this example.","value":{"businesses":[{"id":"fixture-business","name":"Bloom","slug":"bloom","footer":{"averageRating":4.8,"reviewCount":24,"distanceMiles":1.2,"startingPriceInPence":2000,"openNow":true},"serviceCount":3,"priceRange":{"min":500,"max":4000},"averageRating":4.8,"reviewCount":24}],"total":1,"limit":50,"offset":0}},"withoutPosition":{"summary":"Footer excerpt without a position.","value":{"businesses":[{"id":"fixture-business","name":"Bloom","slug":"bloom","footer":{"averageRating":4.8,"reviewCount":24,"distanceMiles":null,"startingPriceInPence":2000,"openNow":true},"serviceCount":3,"priceRange":{"min":500,"max":4000},"averageRating":4.8,"reviewCount":24}],"total":1,"limit":50,"offset":0}},"branchSubsetAndEmpty":{"summary":"rows=branches excerpt: one branch offers a subset and another offers no qualifying services.","value":{"businesses":[{"id":"fixture-business","name":"Bloom","slug":"bloom","branch":{"id":"subset-branch","name":"Subset"},"serviceCount":1,"priceRange":{"min":3500,"max":3500},"footer":{"averageRating":3.5,"reviewCount":2,"distanceMiles":null,"startingPriceInPence":3500,"openNow":true},"averageRating":3.5,"reviewCount":2},{"id":"fixture-business","name":"Bloom","slug":"bloom","branch":{"id":"empty-branch","name":"Empty"},"serviceCount":0,"priceRange":{"min":null,"max":null},"footer":{"averageRating":null,"reviewCount":0,"distanceMiles":null,"startingPriceInPence":null,"openNow":true},"averageRating":0,"reviewCount":0}],"total":2,"limit":50,"offset":0}}}}}},"400":{"description":"Invalid query parameter or unsupported parameter combination.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerSearchError"}}}},"401":{"description":"visited requires an authenticated consumer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerSearchError"}}}},"429":{"description":"Rate limit exceeded. Retry-After reports seconds until retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerSearchError"}}}},"500":{"description":"Search failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumerSearchError"}}}}}}},"/api/businesses/review-summary":{"get":{"operationId":"getConsumerProfileReviewSummary","summary":"Profile review figures by branch","description":"Returns published reviews and their figures from the cached profile snapshot. Omit branchId for all business reviews. Supply branchId for reviews attributed through bookings to that visible branch; an empty branch returns reviews [], totalCount 0 and averageRating null. Unknown, foreign, hidden or deleted branches return 404, never business figures. The snapshot is cached for 60 seconds with immediate expiry after public edits. Branch selection filters that snapshot without another database query or a branch-specific cache key. HTTP responses are private, no-store.","parameters":[{"name":"businessId","in":"query","required":true,"schema":{"type":"string","format":"uuid"},"description":"The public business ID."},{"name":"branchId","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"The selected visible branch of this business. Omit for business figures."}],"responses":{"200":{"description":"The selected review summary.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileReviewSummary"}}}},"400":{"description":"Invalid business or branch ID."},"404":{"description":"Business or visible branch not found."},"429":{"description":"Too many requests."}}}}},"components":{"schemas":{"BusinessCardFooter":{"type":"object","description":"Display data from the catalogue snapshot, added in both search row modes. No next slot is supplied.","required":["averageRating","reviewCount","distanceMiles","startingPriceInPence","openNow"],"properties":{"averageRating":{"type":["number","null"],"description":"Published review mean rounded to one decimal. For rows=branches with a non-null branch, only published reviews whose booking belongs to that branch and business qualify. The booking branch is authoritative, not the review's denormalised branch pointer. Business rows and legacy rows with branch null retain all published business reviews, including reviews without an attributable booking. Null without qualifying published reviews; never falls back to the business figure for an unrated branch.","minimum":0,"maximum":5},"reviewCount":{"type":"integer","description":"Number of published reviews at the row's grain. For rows=branches with a non-null branch, only published reviews whose booking belongs to that branch and business qualify. The booking branch is authoritative, not the review's denormalised branch pointer. Business rows and legacy rows with branch null retain all published business reviews, including reviews without an attributable booking. Zero without qualifying reviews.","minimum":0},"distanceMiles":{"type":["number","null"],"description":"The existing selected row distance in miles rounded to one decimal. Null without a resolved request position or usable business coordinates.","minimum":0},"startingPriceInPence":{"type":["integer","null"],"description":"Minimum current listed service price in GBP pence. For rows=branches with a non-null branch, only services offered at that branch through service_locations qualify. Business rows, and legacy rows with branch null, use the minimum across visible branches of the business. Active, undeleted services only, excluding add ons in both modes. Null without a qualifying service. A listed offer uses its current service price; conditional discounts and vouchers are not applied.","minimum":0},"openNow":{"type":["boolean","null"],"description":"Schedule state at request time in Europe/London. Uses the selected branch hours with business fallback, or the primary overlay without a selected branch. Mobile businesses use mobile hours; fixed and both use fixed hours. Per day hours override legacy working days and opening and closing times. Includes yesterday's overnight interval. Opening is inclusive and closing exclusive; equal times mean closed. Null when incomplete or malformed hours prevent a conclusion. This is not bookable capacity."}},"additionalProperties":false},"ConsumerSearchRow":{"type":"object","description":"Business or branch search row. With rows=branches and a non-null branch, services and ratings describe that branch. Business rows and legacy branch-null rows retain business figures.","required":["id","name","slug","serviceCount","priceRange","footer","averageRating","reviewCount"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"},"footer":{"$ref":"#/components/schemas/BusinessCardFooter"},"serviceCount":{"type":"integer","minimum":0,"description":"For a branch row, the number of active, undeleted services offered at that branch through service_locations, excluding add ons. Zero when none qualify. Business rows retain their existing count across visible branches, including add ons; a service offered at multiple branches counts once at business grain."},"priceRange":{"type":"object","required":["min","max"],"additionalProperties":false,"description":"Minimum and maximum current listed prices in GBP pence from the same population as serviceCount. Branch rows exclude add ons; business rows retain their existing range including add ons. Both endpoints are null without qualifying services. Conditional discounts and vouchers are not applied.","properties":{"min":{"type":["integer","null"],"minimum":0},"max":{"type":["integer","null"],"minimum":0}}},"averageRating":{"type":"number","minimum":0,"maximum":5,"description":"Published review mean rounded to one decimal. For rows=branches with a non-null branch, only published reviews whose booking belongs to that branch and business qualify. The booking branch is authoritative, not the review's denormalised branch pointer. Business rows and legacy rows with branch null retain all published business reviews, including reviews without an attributable booking. Zero without published reviews for compatibility; the footer uses null."},"reviewCount":{"type":"integer","minimum":0,"description":"Number of published reviews at the row's grain. For rows=branches with a non-null branch, only published reviews whose booking belongs to that branch and business qualify. The booking branch is authoritative, not the review's denormalised branch pointer. Business rows and legacy rows with branch null retain all published business reviews, including reviews without an attributable booking. Zero without qualifying reviews."}},"additionalProperties":true},"ConsumerSearchResponse":{"type":"object","required":["businesses","total","limit","offset"],"properties":{"businesses":{"type":"array","items":{"$ref":"#/components/schemas/ConsumerSearchRow"}},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"}}},"ConsumerSearchError":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}},"ProfileReviewSummary":{"type":"object","description":"Returns published reviews and their figures from the cached profile snapshot. Omit branchId for all business reviews. Supply branchId for reviews attributed through bookings to that visible branch; an empty branch returns reviews [], totalCount 0 and averageRating null. Unknown, foreign, hidden or deleted branches return 404, never business figures. The snapshot is cached for 60 seconds with immediate expiry after public edits. Branch selection filters that snapshot without another database query or a branch-specific cache key. HTTP responses are private, no-store.","required":["reviews","totalCount","averageRating"],"additionalProperties":false,"properties":{"averageRating":{"type":["number","null"],"minimum":1,"maximum":5,"description":"One-decimal mean of published reviews for the selected branch, or the whole business when branchId is omitted. Null when there are no reviews."},"totalCount":{"type":"integer","minimum":0,"description":"Published review count for the selected branch, or the whole business when branchId is omitted. Zero when there are no reviews."},"reviews":{"type":"array","description":"Published reviews at the selected grain, newest publication first. Reviewer surnames are reduced to initials.","items":{"$ref":"#/components/schemas/ProfileReview"}}}},"ProfileReview":{"type":"object","additionalProperties":false,"required":["id","rating","reviewText","businessResponse","publishedAt","createdAt","reviewerName"],"properties":{"id":{"type":"string","format":"uuid"},"rating":{"type":"integer","minimum":1,"maximum":5},"reviewText":{"type":["string","null"]},"businessResponse":{"type":["string","null"]},"publishedAt":{"type":["string","null"]},"createdAt":{"type":"string"},"reviewerName":{"type":"string"}}}}}}