> ## Documentation Index
> Fetch the complete documentation index at: https://docs.closient.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Upsert physical stores in bulk

> Reconcile and apply up to 1,000 store rows for one retailer, returning a per-row result.

Rows are **reconciled, not inserted**. Each row is tied to an existing store by exact store number, then by proximity within `proximity_m`, then by normalised street plus postal code — one-to-one, highest-confidence tier first. A match enriches the existing store in place; only a row that matches nothing creates one. This is why the endpoint is safe to point at a full dataset that overlaps rows already in the database: an insert-only surface would plant a duplicate beside every store whose recorded key predates the retailer's own numbering.

Nothing is ever merged, deactivated or deleted here. An existing store no row claims is counted in `unmatched_existing` and left exactly as it is; a pair the matcher finds contradictory is reported in the row's `conflict` and left unrekeyed.

`dry_run` defaults to **true**: every path executes against real rows inside a transaction that is then rolled back, so the counts and per-row outcomes are the matcher's real verdict rather than an estimate, and a first call writes nothing. Send `dry_run: false` to commit.

`retailer_id` is a `retailers.Retailer`. An organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account, because those rows were previously writable only from a management command. The organization this credential acts for becomes the owning organization of any location record created.

Returns 200 with a per-row error report even on partial success — only request-level failures produce 4xx.



## OpenAPI

````yaml /openapi/openapi-retailers.json post /retailers/api/v1/retailers/{retailer_id}/stores/batch
openapi: 3.1.0
info:
  title: Retailers API
  version: 1.0.0
  description: >
    Manage retailers, in-store offers, and online offers.


    ## Authentication


    All endpoints require an API key passed via the `X-API-Key` HTTP header,
    unless otherwise noted.


    ```

    X-API-Key: csb_<body>_<checksum>

    ```


    Generate API keys in **Settings > API Keys** in your dashboard, or via the
    Account API.

    Session-based (cookie) authentication is also accepted for browser-based
    access.


    ## Rate Limits


    | Tier        | Requests / minute | Requests / day |

    |-------------|-------------------|----------------|

    | Default     | 300               | 10,000         |

    | Custom      | Contact us        | Contact us     |


    Rate-limit headers are included on every response so callers can
    self-throttle without

    hitting our 429s ("informed governor"):


    - `RateLimit-Policy` — every active window, e.g. `300;w=60, 10000;w=86400`

    - `RateLimit-Limit` — quota for the **most-restrictive** currently-active
    window

    - `RateLimit-Remaining` — requests left in that window

    - `RateLimit-Reset` — seconds until that window resets (relative; clock-skew
    safe)


    Legacy `X-RateLimit-*` aliases are also emitted for back-compat.
    `X-RateLimit-Reset`

    keeps the absolute Unix-timestamp shape to avoid breaking existing
    consumers.


    When rate-limited, you receive `429 Too Many Requests` with a
    `retry_after_seconds` field

    in the error envelope and a `Retry-After` header.


    ## Pagination


    List endpoints return paginated results in this envelope:


    ```json

    {
      "data": [...],
      "pagination": {
        "page": 1,
        "page_size": 25,
        "total_count": 342,
        "total_pages": 14,
        "has_next": true,
        "has_previous": false
      }
    }

    ```


    Use `?page=2&page_size=50` query parameters. Maximum page size is 100.


    ## Error Responses


    All errors conform to [RFC 9457 Problem
    Details](https://www.rfc-editor.org/rfc/rfc9457)

    with `Content-Type: application/problem+json`:


    ```json

    {
      "type": "https://closient.com/docs/errors/not_found",
      "title": "Not Found",
      "status": 404,
      "detail": "The requested resource was not found.",
      "error_code": "not_found",
      "retryable": false,
      "timestamp": "2026-03-31T12:00:00+00:00"
    }

    ```


    Common error codes: `unauthorized` (401), `forbidden` (403), `not_found`
    (404),

    `validation_error` (422), `rate_limited` (429), `internal_error` (500).
  termsOfService: https://www.closient.com/terms/
servers:
  - url: https://www.closient.com
security: []
tags:
  - name: Retailers
    description: Manage retailers (canonical and org-private).
  - name: In-store Offers
    description: Per-store physical offers (aisle, on-hand quantity, pickup).
  - name: Online Offers
    description: Per-storefront online offers (URL, delivery, fulfillment).
  - name: In-store Offer Promotions
    description: Time-windowed promotional pricing on in-store offers.
  - name: Online Offer Promotions
    description: Time-windowed promotional pricing on online offers.
  - name: Retailer Stores
    description: >-
      Physical storefront upsert, reconciled against existing stores by store
      number, then proximity, then normalised address — a match enriches in
      place rather than creating a near-duplicate.
  - name: Retailer Listings
    description: >-
      Retailer-scoped product listings (SKU, badges, per-property URLs,
      extensible attributes) and the web properties those URLs attach to.
externalDocs:
  description: Closient Documentation
  url: https://docs.closient.com
paths:
  /retailers/api/v1/retailers/{retailer_id}/stores/batch:
    post:
      tags:
        - Retailer Stores
      summary: Upsert physical stores in bulk
      description: >-
        Reconcile and apply up to 1,000 store rows for one retailer, returning a
        per-row result.


        Rows are **reconciled, not inserted**. Each row is tied to an existing
        store by exact store number, then by proximity within `proximity_m`,
        then by normalised street plus postal code — one-to-one,
        highest-confidence tier first. A match enriches the existing store in
        place; only a row that matches nothing creates one. This is why the
        endpoint is safe to point at a full dataset that overlaps rows already
        in the database: an insert-only surface would plant a duplicate beside
        every store whose recorded key predates the retailer's own numbering.


        Nothing is ever merged, deactivated or deleted here. An existing store
        no row claims is counted in `unmatched_existing` and left exactly as it
        is; a pair the matcher finds contradictory is reported in the row's
        `conflict` and left unrekeyed.


        `dry_run` defaults to **true**: every path executes against real rows
        inside a transaction that is then rolled back, so the counts and per-row
        outcomes are the matcher's real verdict rather than an estimate, and a
        first call writes nothing. Send `dry_run: false` to commit.


        `retailer_id` is a `retailers.Retailer`. An organization-private
        retailer requires OWNER or MANAGER on the organization that owns it; a
        canonical (Closient-curated) retailer requires a staff account, because
        those rows were previously writable only from a management command. The
        organization this credential acts for becomes the owning organization of
        any location record created.


        Returns 200 with a per-row error report even on partial success — only
        request-level failures produce 4xx.
      operationId: apps_retailers_api_stores_upsert_stores_batch
      parameters:
        - in: path
          name: retailer_id
          schema:
            description: Unique identifier of the retailer these stores belong to.
            format: shortuuid
            maxLength: 22
            minLength: 22
            pattern: ^[23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{22}$
            title: Retailer Id
            type: string
          required: true
          description: Unique identifier of the retailer these stores belong to.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StoreUpsertBatchIn'
        required: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StoreUpsertResultOut'
        '400':
          description: Bad Request
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorOut'
        '401':
          description: Unauthorized
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorOut'
        '403':
          description: Forbidden
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorOut'
        '404':
          description: Not Found
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorOut'
        '405':
          description: Method Not Allowed
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorOut'
        '422':
          description: Unprocessable Content
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorOut'
        '429':
          description: Too Many Requests
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ErrorOut'
      security:
        - APIKeyHeaderAuth: []
        - OAuthTokenAuth: []
        - CookieGatedSessionAuth: []
components:
  schemas:
    StoreUpsertBatchIn:
      description: A batch of store rows plus the reconciliation knobs for the call.
      examples:
        - dry_run: true
          phone_region: US
          proximity_m: 100
          rows:
            - address_line_1: 1250 Main St
              city: Springfield
              country: US
              first_party: true
              lat: 39.7817
              lon: -89.6501
              name: Ulta Beauty
              postal_code: '62704'
              region: IL
              source: ulta_site
              store_number: '595'
      properties:
        rows:
          description: >-
            Store rows to reconcile, at most 1,000 per call. Results come back
            in request order, each carrying its `index`. Matching is one-to-one
            *within* a call, so a store claimed by an earlier call is re-matched
            on the store-number tier in the next one — which is what makes
            repeating a batch idempotent rather than duplicative.
          items:
            $ref: '#/components/schemas/StoreUpsertRowIn'
          maxItems: 1000
          minItems: 1
          title: Rows
          type: array
        dry_run:
          default: true
          description: >-
            When true (the default) every path runs against real rows and the
            transaction is rolled back, so the results are the matcher's real
            verdict and nothing is written. Send false to commit.
          title: Dry Run
          type: boolean
        proximity_m:
          default: 100
          description: >-
            Match radius in metres for the proximity tier (default 100). Capped
            at 500 m: pairs beyond the match radius but within it are reported
            as `near_misses` for a human to judge, and widening the radius to
            force a match is exactly what the tiered matcher exists to avoid.
          exclusiveMinimum: 0
          maximum: 500
          title: Proximity M
          type: number
        phone_region:
          default: US
          description: >-
            ISO 3166-1 alpha-2 region used to parse national phone numbers into
            E.164. An unassigned code parses nothing, so every phone in the
            batch would be counted invalid rather than written — silently, since
            a bad parse is a counter and not an error.
          format: iso3166-alpha2
          maxLength: 2
          minLength: 2
          pattern: ^[A-Za-z]{2}$
          title: Phone Region
          type: string
      required:
        - rows
      title: StoreUpsertBatchIn
      type: object
    StoreUpsertResultOut:
      description: >-
        Batch census plus the per-row results.


        ``created + enriched + unchanged + not_created + errors`` equals the

        number of submitted rows for any 200 response. The endpoint returns 200

        even when individual rows fail: a per-row failure is reported here
        rather

        than aborting the batch, so a single malformed row cannot cost the other

        999. Request-level failures (auth, an unwritable retailer, a batch over

        the row cap) are 4xx instead.
      examples:
        - committed: false
          coverage_after:
            stores: 1622
            with_phone: 3
            with_point: 1622
          created: 1
          enriched: 2
          errors: 0
          existing_stores: 1621
          fields_superseded: 4
          legacy_keys_preserved: 2
          matched_address: 0
          matched_proximity: 2
          matched_store_number: 0
          near_misses: 1
          not_created: 0
          phones_written: 3
          rekey_skipped_conflict: 0
          row_errors: []
          rows:
            - conflict: ''
              distance_m: 18.4
              error: ''
              index: 0
              match_method: proximity
              outcome: enriched
              store_number: '595'
          submitted_rows: 3
          unchanged: 0
          unmatched_existing: 1619
          urls_written: 3
      properties:
        committed:
          description: >-
            False when this was a dry run — every path ran and the transaction
            was rolled back.
          title: Committed
          type: boolean
        submitted_rows:
          description: Number of rows in the request.
          minimum: 0
          title: Submitted Rows
          type: integer
        existing_stores:
          description: >-
            Stores this retailer already had, i.e. the size of the candidate
            match set.
          minimum: 0
          title: Existing Stores
          type: integer
        created:
          description: Rows that produced a new store.
          minimum: 0
          title: Created
          type: integer
        enriched:
          description: Rows that matched and changed something.
          minimum: 0
          title: Enriched
          type: integer
        unchanged:
          description: Rows that matched and needed no change.
          minimum: 0
          title: Unchanged
          type: integer
        not_created:
          description: Rows that matched nothing and were not allowed to create a store.
          minimum: 0
          title: Not Created
          type: integer
        errors:
          description: Rows that raised. Equals the length of `row_errors`.
          minimum: 0
          title: Errors
          type: integer
        matched_store_number:
          description: Rows matched on the exact store-number tier.
          minimum: 0
          title: Matched Store Number
          type: integer
        matched_proximity:
          description: Rows matched on the proximity tier.
          minimum: 0
          title: Matched Proximity
          type: integer
        matched_address:
          description: Rows matched on the normalised-address tier.
          minimum: 0
          title: Matched Address
          type: integer
        near_misses:
          description: >-
            Pairs beyond `proximity_m` but within 500 m that were deliberately
            NOT matched. A judgement call for a human, never auto-matched by
            widening the radius.
          minimum: 0
          title: Near Misses
          type: integer
        unmatched_existing:
          description: >-
            Existing stores no submitted row claimed. For a full-dataset run
            these are stores the retailer's own list no longer carries, i.e.
            probably closed — reported, never deactivated or deleted.
          minimum: 0
          title: Unmatched Existing
          type: integer
        phones_written:
          description: Phone numbers written or refreshed.
          minimum: 0
          title: Phones Written
          type: integer
        urls_written:
          description: Store URLs written or refreshed.
          minimum: 0
          title: Urls Written
          type: integer
        fields_superseded:
          description: >-
            Existing non-placeholder values a first-party row overwrote. The
            previous value is preserved under
            `Place.source_extras['superseded']` rather than discarded.
          minimum: 0
          title: Fields Superseded
          type: integer
        legacy_keys_preserved:
          description: >-
            Stores whose pre-existing store number was replaced by this source's
            real one, with the old key kept in
            `Place.source_extras['legacy_store_key']`.
          minimum: 0
          title: Legacy Keys Preserved
          type: integer
        rekey_skipped_conflict:
          description: >-
            Stores left on their existing key because the evidence for re-keying
            was contradictory.
          minimum: 0
          title: Rekey Skipped Conflict
          type: integer
        coverage_after:
          additionalProperties:
            type: integer
          description: >-
            Per-column coverage for this retailer's stores after the call —
            `stores`, `with_point`, `with_real_address`, `with_phone`,
            `with_url`, `with_source_extras`. On a dry run these are the
            post-rollback figures the run *would* have produced, measured inside
            the transaction.
          title: Coverage After
          type: object
        rows:
          description: One result per submitted row, in request order.
          items:
            $ref: '#/components/schemas/StoreRowResultOut'
          title: Rows
          type: array
        row_errors:
          description: >-
            Batch-level restatement of every row that raised, for callers that
            read only the census.
          items:
            type: string
          title: Row Errors
          type: array
      required:
        - committed
        - submitted_rows
        - existing_stores
        - created
        - enriched
        - unchanged
        - not_created
        - errors
        - matched_store_number
        - matched_proximity
        - matched_address
        - near_misses
        - unmatched_existing
        - phones_written
        - urls_written
        - fields_superseded
        - legacy_keys_preserved
        - rekey_skipped_conflict
      title: StoreUpsertResultOut
      type: object
    ErrorOut:
      description: |-
        RFC 9457 Problem Details response.

        All API errors are returned in this format with Content-Type:
        application/problem+json.
      examples:
        - detail: The requested resource was not found.
          error_code: not_found
          retryable: false
          status: 404
          timestamp: '2026-03-31T12:00:00+00:00'
          title: Not Found
          type: https://closient.com/docs/errors/not_found
        - detail: Validation error.
          details:
            - loc:
                - body
                - name
              msg: Field required
              type: missing
          error_code: validation_error
          retryable: false
          status: 422
          timestamp: '2026-03-31T12:00:00+00:00'
          title: Validation Error
          type: https://closient.com/docs/errors/validation_error
        - detail: Rate limit exceeded. Please try again later.
          error_code: rate_limited
          retry_after: 31
          retryable: true
          status: 429
          timestamp: '2026-03-31T12:00:00+00:00'
          title: Rate Limited
          type: https://closient.com/docs/errors/rate_limited
      properties:
        type:
          description: URI reference identifying the error type.
          title: Type
          type: string
        title:
          description: Short human-readable summary of the error.
          title: Title
          type: string
        status:
          description: HTTP status code.
          title: Status
          type: integer
        detail:
          description: Human-readable explanation of this specific occurrence.
          title: Detail
          type: string
        error_code:
          description: Machine-readable error code (e.g. not_found, unauthorized).
          title: Error Code
          type: string
        retryable:
          default: false
          description: Whether retrying the same request can succeed.
          title: Retryable
          type: boolean
        timestamp:
          description: ISO 8601 timestamp of when the error occurred.
          title: Timestamp
          type: string
        retry_after:
          anyOf:
            - type: integer
            - type: 'null'
          description: Seconds to wait before retrying (when applicable).
          title: Retry After
        owner_action_required:
          anyOf:
            - type: boolean
            - type: 'null'
          description: Whether the error requires account owner intervention.
          title: Owner Action Required
        details:
          description: Additional context (validation errors, etc.).
          title: Details
      required:
        - type
        - title
        - status
        - detail
        - error_code
        - timestamp
      title: ErrorOut
      type: object
    StoreUpsertRowIn:
      description: |-
        One store as a retailer's own source describes it.

        Mirrors :class:`~apps.retailers.importers.store_reconcile.SourceRow`
        field for field, ``extras`` included. That is the parity contract: the
        Ulta and Sephora readers produce exactly this shape, so anything this
        schema cannot carry would be data the API drops that the management
        commands keep. Hours, services, raw OSM tags and per-source provenance
        have no typed column on ``Place`` today and travel in ``extras``, which
        lands in ``Place.source_extras`` namespaced under ``source`` — so this
        surface needs no store-model migration to be lossless.
      examples:
        - address_line_1: 1250 Main St
          allow_create: true
          city: Springfield
          country: US
          country_derived: true
          extras:
            branch_code: '595'
            opening_hours_spec:
              - closes: '20:00'
                dayOfWeek: Monday
                opens: '10:00'
            services:
              - Salon
              - Brow Bar
          first_party: true
          lat: 39.7817
          lon: -89.6501
          name: Ulta Beauty
          opening_hours: Mo-Sa 10:00-20:00; Su 11:00-18:00
          phone: '+12175550100'
          postal_code: '62704'
          region: IL
          source: ulta_site
          source_url: https://www.ulta.com/stores/springfield-il-595
          store_number: '595'
          website: https://www.ulta.com/stores/springfield-il-595
      properties:
        store_number:
          description: >-
            The key this source uses for the store — the retailer's own store
            number where it publishes one, otherwise a stable synthesized key
            (e.g. `SYN-…`). This is the highest-confidence match tier, so a
            source that can supply a real store number should: it is what makes
            a repeated call idempotent rather than proximity-matched.
          maxLength: 50
          title: Store Number
          type: string
        name:
          default: ''
          description: Store or branch display name.
          maxLength: 255
          title: Name
          type: string
        address_line_1:
          default: ''
          description: Street address, number and street name.
          maxLength: 255
          title: Address Line 1
          type: string
        city:
          default: ''
          description: City, town or locality.
          maxLength: 255
          title: City
          type: string
        region:
          default: ''
          description: State, province or region.
          maxLength: 255
          title: Region
          type: string
        postal_code:
          default: ''
          description: ZIP, postal code or postcode.
          maxLength: 20
          title: Postal Code
          type: string
        country:
          default: US
          description: >-
            ISO 3166-1 alpha-2 country code for the store. An **assigned** code:
            this value is written to the place's country column, and a store
            filed under a code that names no country is the same class of defect
            C-5969 found 75 instances of. A bare two-letter string would have
            admitted 427 unassigned combinations (C-5975).
          format: iso3166-alpha2
          maxLength: 2
          minLength: 2
          pattern: ^[A-Za-z]{2}$
          title: Country
          type: string
        lat:
          anyOf:
            - maximum: 90
              minimum: -90
              type: number
            - type: 'null'
          description: >-
            WGS84 latitude in decimal degrees. Null is accepted — a store with a
            good address and no coordinates still matches on the address tier
            and is still worth having.
          title: Lat
        lon:
          anyOf:
            - maximum: 180
              minimum: -180
              type: number
            - type: 'null'
          description: WGS84 longitude in decimal degrees. Null is accepted; see `lat`.
          title: Lon
        phone:
          default: ''
          description: Store phone number; normalised to E.164 on write.
          maxLength: 50
          title: Phone
          type: string
        website:
          default: ''
          description: The store's own page on the retailer's site.
          maxLength: 1000
          title: Website
          type: string
        opening_hours:
          default: ''
          description: >-
            Opening hours in OSM `opening_hours` syntax (e.g. `Mo-Sa
            10:00-20:00; Su 11:00-18:00`). There is no hours column on a
            location, so this is stored in `Place.source_extras` under this
            row's `source` — sending it here is equivalent to sending
            `extras.opening_hours`, and an explicit `extras.opening_hours` wins.
            A structured schema.org `openingHoursSpecification` can be sent
            alongside it in `extras`.
          maxLength: 1000
          title: Opening Hours
          type: string
        source:
          description: >-
            Stable name of the dataset this row came from (e.g. `ulta_site`,
            `sephora_csv`, `osm`). Load-bearing, not a label: per-column
            authorship is recorded against it, and a source may later correct a
            value it wrote itself but never one another source wrote. Sending a
            different name on a rerun of the same dataset therefore forfeits
            that source's own right to update the columns it authored.
          maxLength: 100
          minLength: 1
          title: Source
          type: string
        source_url:
          default: ''
          description: Where this specific row was read from.
          maxLength: 1000
          title: Source Url
          type: string
        first_party:
          default: false
          description: >-
            True when the row comes from the retailer's own site or feed. Only a
            first-party row may overwrite an existing non-placeholder value or
            rename a storefront; community mapping data enriches blanks instead.
          title: First Party
          type: boolean
        allow_create:
          default: true
          description: >-
            Whether a row that matches nothing may create a new store. Set false
            for a secondary source: it may enrich a store the retailer confirms
            exists, but a location only a community extract believes in is not
            evidence enough to seed a storefront customers would be sent to.
          title: Allow Create
          type: boolean
        country_derived:
          default: false
          description: >-
            True when `country` came from a coordinate lookup rather than from a
            feed label or address field. This is a **provenance assertion, and
            it grants the row power**: only a derived country may correct a
            country already stored on the place. A country that came from a
            label can fill a blank and nothing more.


            Set it only when you actually resolved the country from `lat`/`lon`.
            The reason it exists is concrete — Sephora's North America feed
            labels 482 of its 483 rows `US`, and trusting that label would have
            filed 75 Canadian stores as American (C-5969). A label is not
            evidence; a coordinate is. Asserting this on a label-derived country
            re-opens exactly that defect, so leave it false when in doubt: the
            cost is a country that stays wrong until a better row arrives, not
            one that gets overwritten with a worse value.
          title: Country Derived
          type: boolean
        extras:
          additionalProperties: true
          description: >-
            Everything this source carries that has no column of its own — raw
            hours specifications, service lists, OSM tags, source-conflict
            flags. Stored in `Place.source_extras` under this row's `source`.
            Nesting is allowed here, unlike the flat-string `metadata` field on
            other resources.
          title: Extras
          type: object
      required:
        - store_number
        - source
      title: StoreUpsertRowIn
      type: object
    StoreRowResultOut:
      description: What happened to one submitted row.
      examples:
        - conflict: ''
          distance_m: 18.4
          error: ''
          index: 0
          match_method: proximity
          outcome: enriched
          store_number: '595'
      properties:
        index:
          description: Zero-based position of this row in the submitted `rows` array.
          minimum: 0
          title: Index
          type: integer
        store_number:
          description: The `store_number` submitted for this row.
          title: Store Number
          type: string
        outcome:
          description: >-
            `created` — a new store and place were written. `enriched` — an
            existing store matched and at least one column, link or provenance
            record changed. `unchanged` — an existing store matched and nothing
            needed changing (the idempotent rerun case). `not_created` — the row
            matched nothing and `allow_create` was false, so no storefront was
            invented. `error` — this row raised; see `error`. The rest of the
            batch still applied.
          title: Outcome
          type: string
        match_method:
          $ref: '#/components/schemas/StoreMatchMethodEnum'
          description: >-
            Which tier tied this row to an existing store: `store_number`
            (exact), `proximity` (within `proximity_m`, resolved globally
            nearest-pair-first), `address` (normalised street plus postal code),
            or `none` for a row that matched nothing.
        distance_m:
          anyOf:
            - type: number
            - type: 'null'
          description: >-
            Distance to the matched store in metres. Set for a `proximity`
            match, else null.
          title: Distance M
        conflict:
          default: ''
          description: >-
            Non-empty when the evidence was contradictory — typically the row's
            store number matches one existing store while a *different* existing
            store sits inside the proximity radius, meaning those two rows
            duplicate each other. The store is still enriched; its store number
            is deliberately **not** re-keyed on a guess, and the pair is left
            for a human to merge.
          title: Conflict
          type: string
        error:
          default: ''
          description: Failure message when `outcome` is `error`. Empty otherwise.
          title: Error
          type: string
      required:
        - index
        - store_number
        - outcome
        - match_method
      title: StoreRowResultOut
      type: object
    StoreMatchMethodEnum:
      description: |-
        How a submitted store row was tied to an existing store (C-5966).

        Mirrors :class:`apps.retailers.importers.store_reconcile.MatchMethod`.
        Listed in descending confidence, which is also the order the matcher
        runs its tiers in — each tier only sees rows the tiers above it did not
        claim, and every existing store can be claimed by at most one row:

        * ``store_number`` — the row's store number equals an existing one.
          Exact, so it wins outright, and it is what makes a repeated import
          idempotent rather than proximity-matched.
        * ``proximity`` — within the request's ``proximity_m`` of an existing
          store's coordinates. Resolved globally nearest-pair-first rather than
          in row order, so two rows competing for one store resolve to the
          closer one.
        * ``address`` — normalised street plus postal code. Catches stores whose
          recorded coordinates are wrong by more than the radius, and stores
          with no coordinates at all.
        * ``none`` — matched nothing, so the row is a genuinely new store
          (or, with ``allow_create`` false, is reported and skipped).
      enum:
        - store_number
        - proximity
        - address
        - none
      title: StoreMatchMethodEnum
      type: string
  securitySchemes:
    APIKeyHeaderAuth:
      type: apiKey
      in: header
      name: X-API-Key
    OAuthTokenAuth:
      type: http
      scheme: bearer
    CookieGatedSessionAuth:
      type: apiKey
      in: cookie
      name: sessionid

````