Public - BevvyTech/BrewskiDocs GitHub Wiki

Public Catalogue

Brewski exposes a read-only surface for marketing/landing pages. These endpoints do not require authentication, but only return data for teams that have enabled their public site.

Method Path Description
GET /public/teams List all teams that have a public presence.
GET /public/:identifier Fetch the public profile for a team (slug or UUID).
GET /public/:identifier/logo Redirect to the team logo asset when it is publicly visible.
GET /public/:identifier/beers/:beerId/gallery Return public gallery images for a specific beer.
GET /public/beers/latest List the most recently active beers with current stock.
GET /public/beers/:beerId Fetch the public details for a single beer.
GET /public/styles Retrieve the BJCP style catalogue (optionally including substyles).
GET /public/upsells List active upsell accessories (optionally filtered by coupler).
GET /public/groups List approved brewery groups with public team summaries.
GET /public/groups/:slug/beers Fetch a group hero + all in-stock beers from member breweries.
GET /public/team-promotion Fetch the currently promoted team, promotion image, and latest beers.
GET /public/hero Return the marketplace hero banners (carousel) with copy, CTA, and images.
GET /public/feature-flags Resolve feature flag states for the marketing and shop front-ends.
GET /public/shop/navigation List storefront navigation toggles grouped by section/item.
GET /public/health/services Aggregate health status for API/Admin/Shop/Show surfaces.
GET /public/game-score/leaderboard Return the top saved scores for minigames such as Keg Kong.
POST /public/analytics/events Ingest storefront analytics events (hashed identifiers only).

GET /public/feature-flags

  • Query Parameters
    • keys[] (required, repeatable) — specific flag keys to evaluate. Up to 25 per request.
    • defaultEnabled (optional true | false) — fallback state used when a flag needs to be auto-created; defaults to false.
  • Response 200
    {
      "flags": [
        { "key": "shop.top-nav.partner-links", "enabled": false },
        { "key": "shop.product.compare-button", "enabled": false }
      ]
    }
  • Notes
    • Unknown keys are created on the fly so they can be managed later in the admin UI; repeated requests with new keys will mint corresponding feature_flags rows.
    • The endpoint never surfaces descriptions or metadata—only the machine-readable code and evaluated state.
    • shop.product.compare-button toggles the compare CTA on the beer detail page of the Indie Brewer storefront.

GET /public/shop/navigation

  • Response 200
    {
      "items": [
        { "section": "menu.top", "item": "home", "enabled": true },
        { "section": "menu.top", "item": "basket", "enabled": true }
      ]
    }
  • Notes
    • The list is sorted alphabetically by section and then item.
    • Missing rows are auto-seeded server-side so the payload always covers the known navigation surface.
    • Clients should treat unknown section/item codes as future expansion and ignore entries they do not understand.

GET /public/health/services

  • Response 200
    {
      "generatedAt": "2025-10-27T11:40:00.000Z",
      "services": {
        "api": {
          "key": "api",
          "name": "Public API",
          "url": "https://api.brewskiapp.com/health",
          "status": "ok",
          "message": "Healthy (32 ms)",
          "latencyMs": 32.1,
          "checkedAt": "2025-10-27T11:39:59.980Z"
        },
        "admin": {
          "key": "admin",
          "name": "Admin UI",
          "url": "https://admin.brewskiapp.com/health.json",
          "status": "ok",
          "message": "Healthy (85 ms)",
          "latencyMs": 84.7,
          "checkedAt": "2025-10-27T11:39:59.732Z"
        },
        "show": {
          "key": "show",
          "name": "Show Site",
          "url": "https://show.brewskiapp.com/health.json",
          "status": "ok",
          "message": "Healthy (64 ms)",
          "latencyMs": 64.2,
          "checkedAt": "2025-10-27T11:39:59.645Z"
        },
        "shop": {
          "key": "shop",
          "name": "Shop Frontend",
          "url": "https://indiebrewer.com/health.json",
          "status": "degraded",
          "message": "Reported status: maintenance",
          "latencyMs": 110.5,
          "checkedAt": "2025-10-27T11:39:59.593Z"
        }
      }
    }
  • Notes
    • The API performs a fast database connectivity probe (SELECT 1) to confirm the core service is operational before returning status: "ok".
    • Admin/Show/Shop status checks proxy their respective health.json endpoints with a 5 second timeout and classify status strings of ok, degraded, or error. Missing or unparsable payloads report as degraded.
    • Responses include per-service latency in milliseconds and the precise timestamp each check completed (checkedAt).
  • Designed for the static health.brewskiapp.com dashboard but available to any public client; sensitive metadata is never exposed.

GET /public/game-score/leaderboard

  • Response 200
    {
      "leaderboard": [
        {
          "id": "8b8510f6-5536-4b39-9c53-3f8d33756f5f",
          "rank": 1,
          "playerName": "Barrel Rollers",
          "score": 1280,
          "game": "kong",
          "recordedAt": "2025-07-21T10:00:00.000Z"
        },
        {
          "id": "0f2e5330-bcda-4a36-9f0f-9d16ca2f1f2b",
          "rank": 2,
          "playerName": "Anonymous Driver",
          "score": 1160,
          "game": "kong",
          "recordedAt": "2025-07-20T09:42:00.000Z"
        }
      ],
      "meta": {
        "totalPlayers": 2
      }
    }
  • Notes
    • Scores are deduplicated per player by selecting their highest saved score, then ranked in descending order (ties favour the earlier timestamp).
    • At most 100 unique players are returned—the list may be shorter while the game is new.
    • Player names fall back to “Anonymous Driver” when no storefront display name is configured.

GET /public/groups

  • Query Parameters
    • region (optional) — all, england, scotland, wales, or ni. When omitted or all, returns every eligible group.
    • page (optional, default 1) — 1-indexed page number.
    • pageSize (optional, default 18, max 50) — number of groups per page.
  • Response 200
    {
      "groups": [
        {
          "id": "6f84c0d5-9a3d-4a67-9fc7-3df1e6d079a4",
          "slug": "northern-alliance",
          "name": "Northern Alliance",
          "description": "Shared distribution across the North.",
          "regionLabel": "North England",
          "imageUrl": "https://cdn.brewskiapp.com/groups/northern-alliance.jpg",
          "teamCount": 3,
          "createdAt": "2025-02-01T09:00:00.000Z",
          "updatedAt": "2025-02-05T09:30:00.000Z",
          "teams": [
            {
              "id": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
              "name": "Lantern Brewery",
              "slug": "lantern-brewery",
              "logoUrl": "https://assets.brewskiapp.com/logos/lantern.png",
              "summary": {
                "title": "Lantern Brewery",
                "subtitle": "Wild-fermented saisons"
              },
              "description": "Wild-fermented saisons brewed in Bristol.",
              "location": { "town": "Bristol", "country": "GB" }
            },
            {
              "id": "f1c8ad79-3289-4c13-97a8-20096bb48fe3",
              "name": "Coastline Brewing",
              "slug": "coastline-brewing",
              "logoUrl": null,
              "summary": {
                "title": "Coastline Brewing",
                "subtitle": "Island-inspired brews"
              },
              "description": null,
              "location": null
            }
          ]
        }
      ],
      "page": 1,
      "pageSize": 18,
      "total": 32,
      "pages": 2
    }
  • Notes
    • Only brewery groups that are active, marked shopVisible, and have approved imagery/content are returned. Results are sorted alphabetically by group name.
    • Group member lists include active teams only. Team metadata is sourced from public_team_settings_v so the payload mirrors the storefront-safe profile (name, public slug, optional logo, summary, and approximate location).
    • Teams without a public logo keep logoUrl: null, and their summary fields may resolve to null when no storefront title/subtitle is configured.

GET /public/groups/:slug/beers

  • Path Parameters
    • slug (required) — Case-insensitive group slug.
  • Response 200
    {
      "group": {
        "id": "6f84c0d5-9a3d-4a67-9fc7-3df1e6d079a4",
        "slug": "northern-alliance",
        "name": "Northern Alliance",
        "description": "Shared distribution across the North.",
        "regionLabel": "North England",
        "imageUrl": "https://cdn.brewskiapp.com/groups/northern-alliance.jpg",
        "createdAt": "2025-02-01T09:00:00.000Z",
        "updatedAt": "2025-02-05T09:30:00.000Z",
        "teamCount": 3,
        "teams": [
          {
            "id": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
            "name": "Lantern Brewery",
            "slug": "lantern-brewery",
            "logoUrl": "https://assets.brewskiapp.com/logos/lantern.png",
            "summary": { "title": "Lantern Brewery", "subtitle": "Wild-fermented saisons" },
            "description": "Wild-fermented saisons brewed in Bristol.",
            "location": { "town": "Bristol", "country": "GB" }
          }
        ]
      },
      "beers": [
        {
          "id": "b9af7396-4970-4e5c-83e2-051752198bdc",
          "breweryId": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
          "name": "Hop Heaven",
          "style": "IPA",
          "description": "Hazy IPA bursting with Citra and Mosaic.",
          "targetAbv": 6.5,
          "colorHex": "#FADB7A",
          "badgeImageUrl": "https://cdn.brewskiapp.com/badges/hop-heaven.png",
          "badgeIconUrl": "https://cdn.brewskiapp.com/badges/hop-heaven-icon.png",
          "totalAvailable": 24,
          "lastBatchDate": "2025-02-04T11:00:00.000Z",
          "containers": [
            {
              "id": "2c9a2a94-58db-46f7-8676-6f379a83661a",
              "name": "30L Keg",
              "type": "keg",
              "volumeMl": 30000,
              "availableUnits": 12,
              "storefrontVariantId": "var_12345",
              "priceMinor": 12500,
              "price": "£125.00",
              "currencySymbol": "£",
              "couplerType": null
            }
          ],
          "team": {
            "id": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
            "name": "Lantern Brewery",
            "slug": "lantern-brewery",
            "logoUrl": "https://assets.brewskiapp.com/logos/lantern.png",
            "publicSite": {
              "title": "Wild-fermented saisons",
              "subtitle": "Small batches, big flavour",
              "showPrices": false
            },
            "contact": {
              "email": "[email protected]",
              "telephone": "+44 20 7946 0992",
              "website": "https://lantern.example/",
              "soldByDisclaimer": "Sold and shipped by Lantern Brewery Ltd.",
              "socialLinks": { "instagram": "https://instagram.com/lanternbrew" }
            },
            "awrsUrn": "XAW12345678901",
            "awrsVerified": true,
            "hasOffLicenceShipping": false,
            "offLicenceApproved": false,
            "location": { "town": "Bristol", "country": "GB" }
          }
        }
      ]
    }
  • Response 404
    { "message": "Group not found" }
  • Notes
    • The slug resolves against brewery_groups.slug and is matched case-insensitively.
    • Groups must be active, marked shopVisible, and have approved content + imagery. Pending/disabled groups return 404.
    • Member teams are restricted to active memberships. Public metadata (name, slug, optional logo, summaries, and coarse location) comes from public_team_settings.
    • Beer inventory aggregates all storefront-visible variants owned by member breweries. price is formatted for display, while priceMinor exposes the raw integer value for commerce integrations.
    • When no active inventory exists, the endpoint still returns the group hero with an empty beers array so storefronts can surface “back soon” messaging.

GET /public/teams

  • Response 200:
    {
      "teams": [
        {
          "id": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
          "name": "Lantern Brewery",
          "slug": "lantern-brewery",
          "locale": "en-GB",
          "currencySymbol": "£",
          "description": "Wild-fermented saisons brewed in Bristol.",
          "logoUrl": "https://assets.brewskiapp.com/logos/d43c046a.png",
          "location": { "town": "Bristol", "country": "GB" },
          "publicSite": {
            "title": "Wild-fermented saisons",
            "subtitle": "Small batches, big flavour",
            "backgroundColor": "#FFFFFF",
            "textColor": "#111827",
            "useTeamLogo": true,
          "logoBackgroundColor": "#FFFFFF",
          "logoCornerRadius": 5,
          "showPrices": false,
          "pricebookId": null
          },
          "contact": {
            "email": "[email protected]",
            "telephone": "+44 20 7946 0992",
            "website": "https://lantern.example/",
            "soldByDisclaimer": "Sold and shipped by Lantern Brewery Ltd.",
            "socialLinks": {
              "instagram": "https://instagram.com/lanternbrew",
              "tiktok": "https://www.tiktok.com/@lanternbrew"
            }
          },
          "awrsUrn": "XAW12345678901",
          "awrsVerified": true,
          "hasOffLicenceShipping": true,
          "offLicenceApproved": true,
          "operatingMode": "shop"
        }
      ]
    }
    • location is included when the team has provided town/country details in Settings.
    • description surfaces the long-form copy from Settings → Shop profile; storefronts can fall back to the subtitle when this is null.
    • contact mirrors the public contact panel (email, optional telephone/website, sold-by disclaimer, and any social links saved with https:// URLs).
    • hasOffLicenceShipping combined with offLicenceApproved tells the storefront whether to render the “Can sell direct” badge.

GET /public/:identifier

  • Path Parameters: identifier is either the team UUID or the public slug (case-insensitive).
  • Response 200:
    {
      "team": {
        "id": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
        "name": "Lantern Brewery",
        "locale": "en-GB",
        "currencySymbol": "£",
        "slug": "lantern-brewery",
        "location": { "town": "Bristol", "country": "GB" },
        "publicSite": {
          "title": "Wild-fermented saisons",
          "subtitle": "Small batches, big flavour",
          "backgroundColor": "#FFFFFF",
          "textColor": "#111827",
          "useTeamLogo": true,
          "logoBackgroundColor": "#FFFFFF",
          "logoCornerRadius": 5,
          "showPrices": false,
          "pricebookId": null
        },
        "logoUrl": "https://assets.brewskiapp.com/logos/d43c046a.png",
        "description": "Wild-fermented saisons brewed in Bristol.",
        "contact": {
          "email": "[email protected]",
          "telephone": "+44 20 7946 0992",
          "website": "https://lantern.example/",
          "soldByDisclaimer": "Sold and shipped by Lantern Brewery Ltd.",
          "socialLinks": {
            "instagram": "https://instagram.com/lanternbrew"
          }
        },
        "awrsUrn": "XAW12345678901",
        "awrsVerified": true,
        "hasOffLicenceShipping": true,
        "offLicenceApproved": true
      }
    }
    • logoUrl is only populated when the team has chosen to expose their logo publicly.
    • contact mirrors the Shop profile fields saved in the Admin; social links are normalised to https:// URLs.
    • hasOffLicenceShipping and offLicenceApproved determine whether storefronts should treat the brewery as able to sell direct.

GET /public/:identifier/logo

  • Behaviour: Issues a 302 redirect to the stored logo asset when one is available and the public site is configured to display it.
  • Errors: 404 if the team has not enabled their public site or no logo is available.

ℹ️ Public endpoints never surface private billing/payment data—only the fields required by the marketing site (name, localisation, logo, and themed copy).

GET /public/:identifier/beers/:beerId/gallery

  • Path Parameters
    • identifier: Team UUID or public slug (case-insensitive).
    • beerId: Beer UUID. Only beers marked as showPublic and active are returned.
  • Response 200
    {
      "team": {
        "id": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
        "name": "Lantern Brewery",
        "slug": "lantern-brewery",
        "currencySymbol": "£",
        "locale": "en-GB"
      },
      "beerId": "9c3b06ff-8e52-4169-b35a-b80fc0e4dab7",
      "count": 2,
      "gallery": [
        {
          "id": "5ddf8e2a-7ad9-47c2-8a62-1a68fa3ada72",
          "imageUrl": "https://assets.brewskiapp.com/public/beer-9c3/gallery/pour.png",
          "thumbnailUrl": "https://assets.brewskiapp.com/public/beer-9c3/gallery/pour-thumb.png",
          "iconUrl": "https://assets.brewskiapp.com/public/beer-9c3/gallery/pour-thumb.png",
          "tag": "keg",
          "caption": "Freshly poured pint",
          "position": 0,
          "createdAt": "2025-03-31T15:07:18.012Z",
          "updatedAt": "2025-03-31T15:07:18.012Z"
        }
      ]
    }
  • Errors
    • 404 when:
      • The team identifier does not resolve to a public team.
      • The beer does not belong to the team, is inactive, or is not marked showPublic.
  • Notes
    • Results are ordered by gallery position and creation time.
    • thumbnailUrl/iconUrl point to a 160×160 rendition suitable for cards or grids; imageUrl remains the full 1024×1024 asset.

GET /public/beers/latest

  • Query Parameters
    • limit (optional, 1–40, default 8) limits how many beers are returned.
    • page (optional, ≥ 1) and page_size (optional, 1–40) enable cursor-less pagination.
    • team_id / team_slug (optional) restrict the listing to a single brewery.
    • packaging_group (optional keg | cask | smallpack) narrows the inventory slice before facets are calculated.
    • packaging (optional keg | cask | smallpack | other) applies an additional filter so the response only includes beers that currently have stock within the selected bucket (other captures containers without a recognised type).
    • style (optional, repeatable) filters the listing to beers whose BJCP style code or mapped label matches the supplied values. Multiple style parameters are supported; the legacy styles comma-separated parameter remains accepted for backwards compatibility.
    • colour (optional pale | gold | amber | brown | black | unknown) filters the listing to beers that fall within the requested palette bucket. The US spelling color is accepted as an alias.
    • abv (optional under-4 | 4-6 | 6-8 | over-8) filters to beers whose ABV falls within the supplied band. Bands are inclusive of the lower bound and exclusive of the upper bound (e.g. 4-6 covers 4.0% up to but not including 6.0%).
    • allergen (optional, repeatable) narrows the listing to beers that match the requested dietary flags. Supported values are gluten (contains gluten), gluten-free, vegan, and vegetarian. Multiple allergen parameters combine with AND semantics.
    • facets (optional, repeatable or comma-separated string) requests aggregate data for one or more supported facet groups. Valid values today are styles, packaging, colour, abv, and allergens.
  • Response 200
    {
      "beers": [
        {
          "id": "9c3b06ff-8e52-4169-b35a-b80fc0e4dab7",
          "breweryId": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
          "name": "Summer Saison",
          "style": "Hazy IPA",
          "description": "Zesty saison fermented warm with Brett.",
          "targetAbv": 5.4,
          "colorHex": "#FADB7A",
          "badgeImageUrl": "https://assets.brewskiapp.com/badges/9c3b06ff.png",
          "badgeIconUrl": "https://assets.brewskiapp.com/badges/9c3b06ff-icon.png",
          "totalAvailable": 120,
          "lastBatchDate": "2025-03-21T12:14:31.000Z",
          "containers": [
            {
              "id": "f3d8f3dc-42df-4d16-8187-4cc099c6abb7",
              "name": "30 L Keg",
              "type": "keg",
              "volumeMl": 30000,
              "availableUnits": 12,
              "storefrontVariantId": "4edd0a8f-7d5a-4a0c-9f5c-5d54f7738d36",
              "priceMinor": 12900,
              "price": "£129.00",
              "currencySymbol": "£",
              "couplerType": "s"
            }
          ],
          "team": {
            "id": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
            "name": "Lantern Brewery",
            "slug": "lantern-brewery",
            "logoUrl": "https://assets.brewskiapp.com/logos/d43c046a.png",
            "description": "Wild-fermented saisons brewed in Bristol.",
            "publicSite": {
              "title": "Wild-fermented saisons",
              "subtitle": "Small batches, big flavour",
              "showPrices": false
            }
          }
        }
      ]
    },
    "facets": {
      "styles": [
        { "id": "21C", "label": "Hazy IPA", "count": 4, "selected": false }
      ],
      "packaging": [
        { "id": "keg", "label": "Keg", "count": 6, "selected": false },
        { "id": "cask", "label": "Cask", "count": 2, "selected": false },
        { "id": "smallpack", "label": "Small Pack", "count": 5, "selected": false },
        { "id": "other", "label": "Other", "count": 1, "selected": false }
      ],
      "abv": [
        { "id": "under-4", "label": "Under 4%", "count": 1, "selected": false },
        { "id": "4-6", "label": "4 – 6%", "count": 5, "selected": false },
        { "id": "6-8", "label": "6 – 8%", "count": 3, "selected": false },
        { "id": "over-8", "label": "Over 8%", "count": 2, "selected": false }
      ],
      "colour": [
        { "id": "gold", "label": "Golden", "count": 3, "selected": false, "meta": { "swatch": "#E1B955" } },
        { "id": "amber", "label": "Amber", "count": 2, "selected": false, "meta": { "swatch": "#C9793A" } }
      ],
      "allergens": [
        { "id": "gluten", "label": "Contains gluten", "count": 7, "selected": false },
        { "id": "gluten-free", "label": "Gluten-free", "count": 2, "selected": false },
        { "id": "vegan", "label": "Vegan friendly", "count": 1, "selected": false },
        { "id": "vegetarian", "label": "Vegetarian friendly", "count": 3, "selected": false }
      ]
    }
  • Notes
  • breweryId echoes the owning team ID to simplify client-side filtering.
  • When a beer stores a BJCP/Brewski style code (for example 21C), the API emits the English category or substyle name (Hazy IPA); custom free-text styles are returned unchanged.
  • When a packaging_group is supplied, only containers that match the requested group are returned; other containers are filtered out.
  • couplerType is populated for keg variants that specify a coupler compatibility in the admin tooling; consumers can use it to surface matching upsells.
  • storefrontVariantId can be null when the brewery has not published an online-orderable variant. In that case the container still appears so the storefront can surface stock; the API populates price/priceMinor from the team’s public pricebook when available, otherwise they remain null.
  • ABV bands prefer the product’s manual override when supplied on the storefront product record; otherwise they fall back to the beer’s target ABV. Dietary flags (gluten-free, vegan, vegetarian) rely on the storefront product’s manual allergen string, with the beer’s allergens field used as a secondary hint.
  • Facet responses include per-option counts and a selected flag that reflects any filters supplied in the query (for example, packaging=smallpack or style=21C). Colour facets also surface a meta.swatch hex value suitable for UI swatches.
  • team contains the lightweight public profile already described in GET /public/teams.
  • team.description surfaces the brewery bio configured in the Admin settings—use it to replace any placeholder copy on brewery detail pages.
  • When the listing is scoped to a single brewery (team_id, team_slug, or the /brewery/:slug catalogue source), the meta block also includes matchedTeamDescription and matchedTeamSubtitle so page builders can hydrate hero copy without re-fetching the profile.

GET /public/styles

  • Query Parameters
    • level (optional, integer, default 1) — limits how deep the catalogue is expanded. 0 returns only BJCP categories; 1 includes the substyles for each category.
  • Response 200
    {
      "level": 1,
      "categories": [
        {
          "id": "21",
          "code": "21",
          "name": "IPA",
          "substyles": [
            { "code": "21A", "name": "American IPA" },
            { "code": "21B", "name": "Specialty IPA" },
            { "code": "21C", "name": "Hazy IPA" }
          ]
        }
      ]
    }
  • Notes
    • Level values above 1 are clamped to 1 until deeper hierarchy levels are introduced.
    • Styles mirror the BJCP taxonomy used throughout the Admin and API; clients can cache this response safely as it only changes when the underlying standard is updated.

GET /public/beers/:beerId

  • Path Parameters
    • beerId: Beer UUID.
  • Response 200
    {
      "beer": {
        "id": "9c3b06ff-8e52-4169-b35a-b80fc0e4dab7",
        "breweryId": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
        "name": "Summer Saison",
        "style": "Hazy IPA",
        "description": "Zesty saison fermented warm with Brett.",
        "tastingNotes": "Tangerine zest, white pepper, bone-dry finish.",
        "ingredients": "Water, barley, wheat, orange peel, coriander, saison yeast",
        "allergens": "Gluten (barley, wheat)",
        "targetAbv": 5.4,
        "colorHex": "#FADB7A",
        "badgeImageUrl": "https://assets.brewskiapp.com/badges/9c3b06ff.png",
        "badgeIconUrl": "https://assets.brewskiapp.com/badges/9c3b06ff-icon.png",
        "totalAvailable": 120,
        "lastBatchDate": "2025-03-21T12:14:31.000Z",
        "containers": [
          {
            "id": "f3d8f3dc-42df-4d16-8187-4cc099c6abb7",
            "name": "30 L Keg",
            "type": "keg",
            "volumeMl": 30000,
            "availableUnits": 12,
            "storefrontVariantId": "4edd0a8f-7d5a-4a0c-9f5c-5d54f7738d36",
            "priceMinor": 12900,
            "price": "£129.00",
            "currencySymbol": "£",
            "couplerType": "s"
          }
        ],
        "team": {
          "id": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
          "name": "Lantern Brewery",
          "slug": "lantern-brewery",
          "logoUrl": "https://assets.brewskiapp.com/logos/d43c046a.png",
          "description": "Wild-fermented saisons brewed in Bristol.",
          "publicSite": {
            "title": "Wild-fermented saisons",
            "subtitle": "Small batches, big flavour",
            "showPrices": false
          }
        }
      }
    }
  • Errors
    • 404 when the beer does not exist, is not public, or the owning team has not enabled their public site.
  • Notes
    • containers includes only container types with stock available; totalAvailable reflects the sum of those containers.
    • Containers without a published storefront variant keep storefrontVariantId null. Pricing is still returned when linked via the public pricebook, but is omitted entirely (null) when the brewery hides prices.
    • Style codes are converted to their en-GB names using the same mapping as GET /public/beers/latest.
    • couplerType exposes the keg coupler enum (when supplied) so storefront clients can highlight compatible hardware.
    • tastingNotes and ingredients surface the brewery-authored copy when present; clients should hide the sections when the values are null.
    • allergens mirrors the public allergens copy; hide the row when the payload provides null.
    • Asset URLs (badgeImageUrl, badgeIconUrl, team.logoUrl) respect the incoming host so local development environments receive liberator.local links instead of localhost.

GET /public/upsells

  • Query Parameters
    • coupler (optional, repeatable) — filter by one or more coupler codes from keg_coupler_type.
    • limit (optional, 1–50, default 20) — maximum number of upsells to return.
  • Response 200
    {
      "upsells": [
        {
          "id": "1a2b3c4d-1111-2222-3333-444455556666",
          "slug": "s-type-coupler",
          "name": "S-Type Keg Coupler",
          "description": "Stainless S-type coupler supplied with check valve.",
          "priceMinor": 12900,
          "currencySymbol": "£",
          "price": "£129.00",
          "couplerType": "s",
          "displayOrder": 10,
          "currentStock": 6,
          "isSoldOut": false,
          "images": [
            {
              "id": "img-01",
              "url": "https://assets.brewskiapp.com/upsells/1a2b3c4d/img-01-main.webp",
              "iconUrl": "https://assets.brewskiapp.com/upsells/1a2b3c4d/img-01-icon.webp",
              "isDefault": true
            }
          ]
        }
      ]
    }
  • Notes
    • Results are ordered by displayOrder then createdAt.
    • price is provided when both priceMinor and currencySymbol are set; otherwise it is null.
    • currentStock reflects the latest operator-entered stock count. isSoldOut is true when currentStock is zero or lower.
    • images contain absolute URLs derived from the current request host; only the default image is flagged with isDefault: true.

GET /public/team-promotion

  • Behaviour: Returns the brewery currently scheduled for the shop homepage. The endpoint automatically resolves the active promotion (or the next upcoming one). If no promotions exist, it falls back to the oldest team in the database so the homepage always has content.
  • Response 200:
    {
      "team": {
        "id": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
        "name": "Lantern Brewery",
        "slug": "lantern-brewery",
        "logoUrl": "https://assets.brewskiapp.com/logos/d43c046a.png",
        "imageUrl": "https://assets.brewskiapp.com/promotions/d43c046a-home.png",
        "imageTitle": "Lantern rooftop pour",
        "imageDescription": "Hazy saison poured at sunset on the taproom roof.",
        "textNeedsBackground": true,
        "textBackground": "dark",
        "textColor": "rainbow",
        "mainTitle": "Featured brewery",
        "mainDescription": "Lantern Brewery leads our spring showcase with farmhouse ales and oak-aged specialties.",
        "startsAt": "2025-04-01T08:00:00.000Z",
        "isFallback": false,
        "beers": [
          {
            "id": "9c3b06ff-8e52-4169-b35a-b80fc0e4dab7",
            "breweryId": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
            "name": "Summer Saison",
            "style": "Hazy IPA",
            "description": "Zesty saison fermented warm with Brett.",
            "badgeImageUrl": "https://assets.brewskiapp.com/badges/9c3b06ff.png",
            "priceMinor": 580,
            "customerRating": 4.6,
            "createdAt": "2025-03-21T12:14:31.000Z",
            "updatedAt": "2025-03-21T12:14:31.000Z"
          }
        ]
      }
    }
    • imageUrl is null when the record is the fallback team (no explicit promotion on file).
    • isFallback=true indicates the response was derived from the oldest team rather than a scheduled promotion.
  • Errors: Always returns 200; the payload carries team: null when no public teams exist.
  • Notes
    • imageTitle/imageDescription stem from the promotional hero card (useful for alt text and captions).
    • mainTitle/mainDescription surface the headline copy configured in the admin UI; consumers can safely fall back to the team name if these are null.
    • breweryId on each beer matches the promoted team’s ID so clients can perform joins without walking back to the parent payload.
    • Beer styles follow the same en-GB mapping noted above, converting stored BJCP codes to descriptive names.
    • textNeedsBackground toggles an overlay block behind the hero copy; when true, pair it with textBackground (dark, darkdeep, light, or lightdeep - 15% vs 85% overlays) and textColor (black/white/rainbow) to style the text appropriately.
    • Each beer includes priceMinor (default price, smallest currency unit or null) and customerRating (0–5 scale, null when unset).

GET /public/hero

  • Behaviour: Returns the hero carousel rendered above the fold on the public shop homepage.
  • Response 200:
    {
      "heroes": [
        {
          "id": "0f6e1fb2-0fa7-4e07-b994-0a28cf6c26d0",
          "category": "Marketplace highlight",
          "title": "Discover independent breweries",
          "message": "Fresh releases, direct from the source. Explore what’s pouring today.",
          "showButton": true,
          "buttonText": "Browse beers",
          "buttonLink": "/shop",
          "theme": "dark",
          "imageUrl": "https://cdn.brewskiapp.com/platform/hero-1738345600.jpg",
          "imageWidth": 1280,
          "imageHeight": 720,
          "displaySeconds": 8,
          "position": 0,
          "createdAt": "2025-06-02T10:05:44.123Z",
          "updatedAt": "2025-06-02T10:05:44.123Z"
        }
      ]
    }
    • The array is empty when no hero slides have been configured.
    • buttonLink may be an absolute URL or a path relative to the storefront origin.
    • displaySeconds indicates how long (in seconds) the slide should remain visible before the carousel advances; storefronts should clamp to 3–120 seconds and fall back to 8 when omitted.
    • imageUrl points to a JPEG already resized to a maximum height of 770 px and aligned to the caller’s host (development requests receive liberator.local, production requests receive the CDN host).
    • Heroes are ordered by position followed by createdAt, matching the admin carousel order.

GET /public/homepage/settings

  • Behaviour: Returns auxiliary homepage content (services strip, footer social links, and featured brewery groups) so the storefront can mirror the admin-configured layout.
  • Response 200:
    {
      "services": [
        {
          "id": "89d5f8d7-97b0-4ea2-9ea9-a0d5f3495f3e",
          "title": "Free UK delivery",
          "description": "Complimentary shipping on orders over £200.",
          "iconKey": "icon-delivery-01",
          "linkUrl": "/shipping",
          "position": 0
        }
      ],
      "socialLinks": [
        {
          "id": "a11bb22c-3344-4555-8666-77889900aa11",
          "label": "Instagram",
          "iconClass": "fab fa-instagram",
          "url": "https://instagram.com/indiebrewer",
          "position": 0
        }
      ],
      "featuredGroups": [
        {
          "id": "f011b2e4-1234-4a6f-9b7e-0c1d2e3f4a5b",
          "position": 1,
          "team": {
            "id": "d43c046a-10a1-4f52-bd0a-9bf16f828ab7",
            "name": "Lantern Brewery",
            "publicSlug": "lantern-brewery",
            "logoUrl": "https://assets.brewskiapp.com/logos/d43c046a.png"
          },
          "group": {
            "id": "8cb5b1e0-4321-4af0-9d3a-0e9f7b24c6aa",
            "name": "Northern Alliance",
            "slug": "northern-alliance",
            "imageUrl": null,
            "description": "Cask-led breweries pooling haulage to reach the Highlands.",
            "regionLabel": "Scotland"
          },
          "groupTeamCount": 3,
          "groupTeams": [
            {
              "id": "7b2b6d8c-98f4-4bf2-93a3-1234567890ab",
              "name": "Lantern Brewery",
              "slug": "lantern-brewery",
              "logoUrl": "https://assets.brewskiapp.com/logos/lantern.png",
              "summary": {
                "title": "Flagship pale ales",
                "subtitle": "Award-winning session beers"
              },
              "description": "Proudly independent Manchester brewery specialising in hop-forward pales.",
              "location": {
                "town": "Manchester",
                "country": "England"
              }
            }
          ]
        }
      ]
    }
  • Notes
    • logoUrl and imageUrl fields are normalised to the caller’s host (development requests receive liberator.local, production requests receive CDN URLs).
    • services and socialLinks are ordered by their stored position.
    • featuredGroups contains zero or more records ordered by position; each entry ships the curated group metadata and the full list of active member breweries (groupTeams).

GET /public/notice-strip

  • Behaviour: Returns the configuration of the announcement strip displayed at the very top of the storefront. Values fall back to sensible defaults when the platform setting does not exist yet.
  • Response 200:
    {
      "enabled": true,
      "text": "Shipping delays expected due to bank holidays.",
      "link": "https://shop.brewskiapp.com/notices/shipping-delays"
    }
  • Notes
    • enabled defaults to false when the toggle is not set in the admin UI.
    • text and link return null when unset; consumers should guard for empty strings before rendering links.
    • All fields are derived from the notice_strip_* platform settings managed via the admin homepage tooling.

GET /public/support/tickets

  • Auth: Bearer token (storefront client access token).
  • Query Parameters
    • status (optional, repeatable) — restrict the listing to a subset of ticket states (pending, open, resolved, cancelled).
    • teamId (optional UUID) — limit results to tickets linked to a specific brewery account that the client belongs to.
    • page / pageSize (optional; defaults 1 / 10, maximum 50).
    • search (optional string ≥ 1 char) — substring match on ticket subject and description.
  • Response 200
    {
      "tickets": [
        {
          "id": "8ca3fd7c-5f87-42d4-a466-6ccb8f4f219d",
          "subject": "Problem downloading invoices",
          "description": "Downloads stall at 25%.",
          "status": "open",
          "priority": "normal",
          "originChannel": "shop",
          "createdAt": "2025-07-20T10:04:00.000Z",
          "updatedAt": "2025-07-20T10:15:27.000Z",
          "lastMessageAt": "2025-07-20T10:15:27.000Z",
          "closedAt": null,
          "firstResponseAt": "2025-07-20T10:12:03.000Z",
          "team": {
            "id": "0a8f6da1-06fe-4bfb-a7f4-4b8c2f7b1fd2",
            "name": "Indie Brewer Support"
          },
          "metadata": {
            "requestedBy": {
              "type": "client",
              "clientId": "a4e57d2a-7dd9-4c1f-8b0d-3d6c88a0d201"
            }
          },
          "creditCost": 1
        }
      ],
      "pagination": {
        "page": 1,
        "pageSize": 10,
        "total": 3,
        "pages": 1
      },
      "counts": {
        "all": 3,
        "pending": 1,
        "open": 2,
        "resolved": 0,
        "cancelled": 0
      }
    }
  • Notes
    • Only tickets tied to the authenticated storefront client are returned; brewery-side tickets raised in the Admin remain hidden.
    • team reflects the brewery currently handling the request (Indie Brewer Support when no specific brewery is selected).
    • originChannel is always shop for tickets raised via this endpoint, allowing the Admin UI to badge the request appropriately.
    • counts summarises ticket totals for quick filtering (including an all aggregate) so clients can render filter badges without issuing additional calls.

POST /public/support/tickets

  • Auth: Bearer token (storefront client access token).
  • Body (application/json)
    • subject (string, 3–200 chars) — short summary of the request.
    • description (string, 1–20 000 chars) — detailed description or markdown body.
    • priority (optional enum low | normal | high | urgent, default normal).
    • teamId (optional UUID) — route straight to a brewery account the client belongs to; omit to contact Indie Brewer’s platform support.
  • Response 201
    {
      "ticket": {
        "id": "cb3e721d-a78c-4d9b-9d0a-0c74b74f64b1",
        "subject": "Help updating delivery address",
        "description": "We need to change the default drop site for next week.",
        "status": "pending",
        "priority": "normal",
        "originChannel": "shop",
        "createdAt": "2025-07-20T09:42:00.000Z",
        "updatedAt": "2025-07-20T09:42:00.000Z",
        "team": {
          "id": "0a8f6da1-06fe-4bfb-a7f4-4b8c2f7b1fd2",
          "name": "Indie Brewer Support"
        },
        "metadata": {
          "requestedBy": {
            "type": "client",
            "clientId": "a4e57d2a-7dd9-4c1f-8b0d-3d6c88a0d201",
            "email": "[email protected]"
          }
        },
        "creditCost": 1
      },
      "credits": {
        "allowance": 20,
        "remaining": 19,
        "used": 1,
        "defaultAllowance": 20,
        "period": {
          "id": "period-2025-07",
          "start": "2025-07-01T00:00:00.000Z",
          "end": "2025-07-31T23:59:59.999Z"
        }
      }
    }
  • Errors
    • 401 unauthenticated.
    • 403 when teamId references a brewery the client does not belong to.
    • 404 when teamId cannot be resolved.
  • Notes
    • Storefront-originated tickets leave requesterId empty and capture the submitter in metadata.requestedBy.
    • Support credits are debited against the chosen brewery; when teamId is omitted the ticket consumes Indie Brewer’s platform allowance.

GET /public/support/tickets/:ticketId/messages

  • Auth: Bearer token (storefront client access token).
  • Query Parameters
    • page / pageSize (optional; defaults 1 / 20, max 100).
  • Response 200
    {
      "ticket": { "id": "cb3e721d-a78c-4d9b-9d0a-0c74b74f64b1", "subject": "Help updating delivery address", "status": "open", "priority": "normal", "originChannel": "shop", "team": { "id": "0a8f6da1-06fe-4bfb-a7f4-4b8c2f7b1fd2", "name": "Indie Brewer Support" }, "createdAt": "2025-07-20T09:42:00.000Z", "updatedAt": "2025-07-20T10:01:00.000Z", "lastMessageAt": "2025-07-20T10:01:00.000Z", "metadata": {} },
      "messages": [
        {
          "id": "msg-01",
          "ticketId": "cb3e721d-a78c-4d9b-9d0a-0c74b74f64b1",
          "body": "Thanks for sending the details — we’re on it.",
          "bodyHtml": null,
          "createdAt": "2025-07-20T10:01:00.000Z",
          "updatedAt": "2025-07-20T10:01:00.000Z",
          "author": {
            "id": "support-user-1",
            "name": "Indie Brewer Support",
            "email": "[email protected]"
          },
          "attachments": []
        }
      ],
      "pagination": {
        "page": 1,
        "pageSize": 20,
        "total": 1,
        "pages": 1
      }
    }
  • Notes
    • Only non-internal messages are returned; internal admin notes remain hidden.
    • Attachments are returned as direct URLs (identical to admin storage URLs) — callers should treat them as ephemeral and avoid hotlinking outside the support flow.
    • author.id resolves to a support user for admin replies and to the storefront client id for customer replies; name / email are populated from the available profile data in each case.

POST /public/support/tickets/:ticketId/messages

  • Auth: Bearer token (storefront client access token).
  • Body (multipart/form-data)
    • body (string, required, 1–20 000 chars) — message body.
    • attachments (optional file[], ≤ 5 MB each) — supporting files; attach the field multiple times for multiple uploads.
  • Response 201
    {
      "ticket": { "id": "cb3e721d-a78c-4d9b-9d0a-0c74b74f64b1", "status": "open", "priority": "normal", "originChannel": "shop", "team": { "id": "0a8f6da1-06fe-4bfb-a7f4-4b8c2f7b1fd2", "name": "Indie Brewer Support" }, "updatedAt": "2025-07-20T10:05:00.000Z", "lastMessageAt": "2025-07-20T10:05:00.000Z" },
      "message": {
        "id": "msg-02",
        "ticketId": "cb3e721d-a78c-4d9b-9d0a-0c74b74f64b1",
        "body": "Here’s the spreadsheet you requested.",
        "createdAt": "2025-07-20T10:05:00.000Z",
        "updatedAt": "2025-07-20T10:05:00.000Z",
        "author": {
          "id": "client-123",
          "name": "Lantern Brewery",
          "email": "[email protected]"
        },
        "attachments": [
          {
            "id": "att-1",
            "fileName": "delivery-notes.pdf",
            "contentType": "application/pdf",
            "fileSizeBytes": 53211,
            "url": "https://assets.brewskiapp.com/support/cb3e721d/delivery-notes.pdf",
            "createdAt": "2025-07-20T10:05:00.000Z"
          }
        ]
      }
    }
  • Errors
    • 401 unauthenticated.
    • 404 ticket not owned by the caller.
    • 409 ticket is closed (resolved/cancelled).
    • 413 attachments too large.
  • Notes
    • Messages are always created as non-internal entries; status changes must be performed by support staff.
    • Attachments reuse the same storage as admin uploads. Do not expose URLs publicly outside authenticated flows.

POST /public/analytics/events

  • Request Body
    {
      "events": [
        {
          "name": "shop.checkout.completed",
          "occurredAt": "2025-11-05T14:21:33.512Z",
          "source": "shop",
          "environment": "production",
          "properties": {
            "mode": "group",
            "groupOrderId": "569be9e1-77d9-49fd-8c2c-8ee7fa5ad375",
            "itemCount": 12,
            "subtotalMinor": 45800
          },
          "identifiers": {
            "sessionId": "cb13901d-9ed5-4a2c-92a0-1e22f687dc42",
            "groupId": "e3d9baa9-6587-4b0b-8ce9-1725518175cf",
            "userId": "72d0adc3-65c9-4af6-9f74-566e3b8e6b3b"
          },
          "hashes": {
            "sessionIdHash": "4f3d6f2c1b...",
            "groupIdHash": "8a563ef0a7...",
            "userIdHash": "20b137ea44..."
          },
          "metadata": {
            "groupSlug": "northern-alliance",
            "orders": ["ORD-1001", "ORD-1002"],
            "pageRoute": "groupCheckout"
          }
        }
      ]
    }
  • Limits & Validation
    • Batches accept up to 25 events. Each object must supply a name and optional identifiers/hashes.
    • Raw identifiers are never persisted directly—UUIDs are normalised and hashed using the configured ANALYTICS_HASH_SALT before storage.
    • Requests exceeding the configured rate limit (60/minute per IP) return 429 with code: "RATE_LIMITED".
    • If analytics ingestion is disabled (ANALYTICS_ENABLED=false) the endpoint responds with 204 No Content and discards the batch.
  • Response 202
    { "accepted": 3 }
  • Notes
    • Unknown events are accepted as-is so new storefront instrumentation can roll out safely; downstream processing enforces schema rules per event name.
    • metadata.orders should contain storefront-visible order references only—never internal database IDs.
    • Missing or weak ANALYTICS_HASH_SALT values cause ingestion to reject the batch with 500. Rotate salts using the documented runbook in Internal/TODO/analytics.md.
⚠️ **GitHub.com Fallback** ⚠️