---
title: "Run a Decision Bracket round and get a result"
method: POST
path: "/bracket"
---

# Run a Decision Bracket round and get a result

`POST /bracket`

## Request

- Base URL: `https://localselections.com/api/v1`
- URL: `https://localselections.com/api/v1/bracket`
- Auth: API key in header `X-API-Key`

Bracket is a swipe-to-keep/reject flow in the browser (fetch real candidates for one category, decide each one independently, tie-break if more than one is kept) — genuinely multi-step, unlike /spin's single weighted pick, which is why Stage 42 originally left it out of this surface. This endpoint supports two shapes, chosen by whether `decisions` is supplied: omit it for the default "auto" mode, where the server fetches real candidates and runs the keep/reject pass itself (biased toward more-trending/closer/boosted places — or higher-rated for a movie-sourced category — not a coin flip), auto-resolving any tie with the same real data the interactive tie-break uses; or supply `decisions` — an array of `{ id, keep }` referencing candidate ids returned by a *prior* call to this same endpoint with identical categoryMode/optionId/lat/lng — to control the outcome yourself. This endpoint is otherwise stateless (no session is stored server-side): a same-parameters follow-up call typically returns the same candidate set thanks to the shared api_cache layer, but this isn't guaranteed, so a `decisions` id that no longer matches a fetched candidate is simply ignored (treated as not kept). Every response includes the full per-candidate rating/trendingScore/distance/sponsored/kept breakdown, not just a bare winner — Stage 153: place-sourced candidates no longer carry a real Google rating (see /search's description), so `trendingScore` (this app's own free first-party selection/liked-history signal) is what actually drives their ranking and tie-break now. Requires the `bracket` scope. Costs 4 units of the key's rate-limit budget per call — the heaviest of the six v1 endpoints, since it fetches and scores a full candidate list rather than narrowing to one pick.

## Request body

- object
  - `categoryMode` 'cuisine' | 'activity', required
  - `optionId` string, required: e.g. "sushi" for cuisine, or "hike"/"bowling"/"museum"/"movie"/"live-music" for activity — game-night is excluded (no real per-candidate data).
  - `lat` number: Required (with lng) for cuisine and most activity categories — not needed for movie.
  - `lng` number
  - `decisions` object[]: Explicit keep/reject per candidate id, from a prior call's `candidates`. Omitted = automatic resolution. Any candidate id not mentioned is treated as rejected.
    - `id` string, required
    - `keep` boolean, required

## Response `200`

The category, every fetched candidate with its rating/trendingScore/distance/sponsored/kept breakdown, and the resolved outcome.

- object
  - `mechanic` string, required
  - `categoryMode` string, required
  - `category` object, required
    - `id` string, required
    - `label` string, required
  - `candidates` object[], required
    - `id` string, required
    - `name` string, required
    - `rating` number, nullable, required
    - `distanceMiles` number, nullable, required
    - `trendingScore` number, required: Stage 153: real first-party selection/liked-history weight (lib/games/placeTrending.ts) fed into ranking/tie-break in place of Google's rating for a place-sourced candidate — always 0 for movie/event candidates.
    - `sponsored` boolean, required
    - `kept` boolean, required: Whether this candidate was kept (explicitly via `decisions`, or by the automatic resolution pass).
  - `outcome` 'no_candidates_found' | 'single_kept' | 'zero_kept_random_fallback' | 'multiple_kept_tiebreak', required
  - `winner` object, nullable
    - `id` string
    - `label` string
  - `narrowed` object, nullable: A concrete, real result for the winning category — shape depends on `source`.
    - `source` 'place' | 'movie' | 'event' | 'game-night' | 'none', required
    - `place` object
      - `id` string, required
      - `name` string, required
      - `distanceMiles` number, nullable
      - `address` string, nullable
      - `mapsUri` string, uri, required
      - `trending` object, nullable: Null only if the trending lookup itself failed — otherwise always present, with 0s when there's no real history yet.
        - `contributorCount` integer, required: Distinct users who were shown this place as their actual narrowed/won result, all-time.
        - `likedCount` integer, required: Subset of contributorCount who also left a real "liked" outcome vote.
        - `recentContributorCount` integer, required: Subset of contributorCount within the last 7 days.
      - `sponsored` boolean: True when this place has an active, admin-approved vendor boost (Stage 28) — always ranked among places that would organically appear in this search, never inserted separately.
      - `vendorDetails` object, nullable: Stage 185: the claimed vendor's own submitted hours/phone/website/menu highlights (Stage 184), when their listing is approved and at least one field has been submitted. Null otherwise. Takes display precedence over any Overture/Geoapify-sourced equivalent value for the same place.
        - `hours` string, nullable, required
        - `phone` string, nullable, required
        - `website` string, uri, nullable, required
        - `menuHighlights` string, nullable, required
      - `origin` 'overture' | 'geoapify' | 'google_places', required: Stage 191: which real source this place actually came from. Overture (local_selections.places, confidence-floored + deduped) and Geoapify (local_places) are tried first, free of any real API cost; Google Places is the fallback for whatever gap remains.
      - `detailUrl` string, uri, nullable: Stage 196: a real, permanent LocalSelections detail page for this exact place, reusing that page's own indexability rules (never a link to a page that would itself noindex, 301 elsewhere, or not exist). Null for every `google_places`-origin result (no permanent page exists for one) and for a local result that hasn't cleared the stricter indexing confidence floor or has an unresolved cross-source duplicate. Only present on /places (not /search, which predates this field).
    - `title` object
      - `id` string, required
      - `title` string, required
      - `mediaType` 'movie' | 'tv', required
      - `overview` string, nullable
      - `posterUrl` string, uri, nullable
      - `rating` number, nullable
      - `releaseDate` string, nullable
      - `tmdbUrl` string, uri, required: Link to the title at its source provider — TMDB, or IMDb when a fallback served it (see `provider`).
      - `provider` 'tmdb' | 'omdb' | 'tvmaze': Which provider produced this title. Absent means TMDB.
      - `detailUrl` string, uri, nullable: Stage 196: a real, permanent LocalSelections detail page for this exact title, reusing that page's own indexability rule (a TMDB-provider title with enough real overview text). Null for a non-TMDB-provider title (no local page exists) or one without enough overview text to index. Only present on /movies (not /spin or /bracket's narrowed/candidate titles, which predate this field).
    - `event` object
      - `id` string, required
      - `name` string, required
      - `venueName` string, nullable
      - `date` string, nullable
      - `time` string, nullable
      - `distanceMiles` number, nullable
      - `address` string, nullable
      - `imageUrl` string, uri, nullable
      - `ticketUrl` string, uri, required
      - `origin` 'ticketmaster' | 'user_submitted' | 'municipal' | 'web_schema' | 'rss_ical', required: Stage 182/183/186: which source this event came from — never blended indistinguishably. "municipal" is an official city open-data event calendar (currently New York, NY and Los Angeles, CA). "web_schema"/"rss_ical" are found directly on a business's own site (schema.org/JSON-LD markup, or a real structured RSS/iCal feed).
      - `originLabel` string: Stage 196: human-readable form of `origin` (e.g. "Ticketmaster", "the venue's own website"). Only present on /events (not /spin or /bracket's narrowed/candidate events, which predate this field).
      - `detailUrl` string, uri, nullable: Stage 196: a real, permanent LocalSelections detail page for this exact event, reusing that page's own content floor (enough real name/date/location, not ended). Always null for a `ticketmaster`-origin event — resolving it would require a second, separately-billed Ticketmaster API call per event, multiplying real spend against the same shared daily ceiling every other Ticketmaster call already draws from; use `ticketUrl` (the original listing) for those instead. Only present on /events (not /spin or /bracket, which predate this field).
    - `suggestion` object
      - `id` string, required
      - `name` string, required
      - `players` string, required
      - `blurb` string, required
    - `reason` string: Present only when source is "none" — why nothing was narrowed (e.g. missing lat/lng, no results found).
  - `tieBreak` object, nullable: Present only when outcome is "multiple_kept_tiebreak" — the kept subset that was compared, which one won, and the criteria used.
    - `candidates` object[]
      - `id` string, required
      - `name` string, required
      - `rating` number, nullable, required
      - `distanceMiles` number, nullable, required
      - `trendingScore` number, required: Stage 153: real first-party selection/liked-history weight (lib/games/placeTrending.ts) fed into ranking/tie-break in place of Google's rating for a place-sourced candidate — always 0 for movie/event candidates.
      - `sponsored` boolean, required
      - `kept` boolean, required: Whether this candidate was kept (explicitly via `decisions`, or by the automatic resolution pass).
    - `winnerId` string
    - `criteria` string[]
  - `decisionId` string, nullable

## Other responses

- `400`: The request body failed validation.
- `401`: Missing or invalid API key.
- `403`: The API key does not carry the scope this endpoint requires (see the "Requires the ... scope" note on each path, or call GET /introspect to see a key's real granted scopes).
- `429`: Rate limit exceeded for this API key. The budget is 30 units/60s for keys owned by an unsubscribed account, 120 units/60s for a subscribed account's keys (Stage 60) — shared across every endpoint, but each endpoint spends a different number of units per call rather than always 1 (Stage 104): search/groups_host/groups_join/places/movies cost 1, surprise_me/events cost 2, spin costs 3, bracket costs 4. Call GET /introspect to see a key's real remaining budget before it runs out.
- `502`: An upstream data source failed or returned nothing usable.

---

[API](https://skmtc.dev/localselections/apis/localselections-public-api.md) · [All operations](https://skmtc.dev/localselections/apis/localselections-public-api/llms.txt) · [OpenAPI document](https://skmtc.dev/localselections/apis/localselections-public-api/revisions/a24e9598add4?raw)
