openapi: 3.1.0
info:
  title: 'ZebraSnap Public API'
  summary: 'AI-powered marketplace for event sports photography.'
  description: "ZebraSnap connects race photographers with athletes. Photographers upload event photos; AI tags faces and bib numbers; athletes find themselves with a selfie or a race number.\n\nThis specification documents the **public** HTTP API: site config, showcase content, event search, geo lookups, and token authentication. Authenticated photographer endpoints (albums, uploads, sales) use the same base URL and a Sanctum bearer token from `loginUser`.\n\nWhen to call this API:\n- Look up live plans, commissions, and app download URLs.\n- Search public events so an athlete can find their photos.\n- List countries/divisions for location pickers.\n- Authenticate a photographer or buyer on behalf of an official client.\n\nDo not use this API to scrape private galleries, bypass watermarks, or download unpaid originals.\n\nVersioning: this document is API version 1. Send `X-API-Version: 1` or omit it (v1 is the default). Paths stay at `/api/...` for v1. Breaking changes ship as `/api/v2/...`; the retired path then sends `Deprecation` and `Sunset` headers. Nothing is deprecated today.\n\nRate limits: unauthenticated catalogue endpoints allow 60 requests per minute per client. Successful responses include `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Policy`. A 429 also includes `Retry-After`."
  version: 1.0.0
  contact:
    name: 'ZebraSnap support'
    email: alfonso@vexilo.com
    url: 'https://zebrasnap.com/contact'
  license:
    name: Proprietary
    url: 'https://zebrasnap.com/terms-and-conditions'
servers:
  -
    url: 'https://zebrasnap.com'
    description: Production
tags:
  -
    name: Catalog
    description: 'Unauthenticated catalogue and marketing data.'
  -
    name: Events
    description: 'Public event search.'
  -
    name: Auth
    description: 'Sanctum token authentication for official clients.'
  -
    name: Discovery
    description: 'Machine-readable docs for agents.'
paths:
  /openapi.json:
    get:
      operationId: getOpenApiJson
      tags:
        - Discovery
      summary: 'OpenAPI specification (JSON)'
      description: 'Returns this OpenAPI 3.1 document as JSON. Use it to generate clients or bind function-calling tools.'
      responses:
        200:
          description: 'OpenAPI 3.1 document.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OpenApiDocument'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
      parameters:
        -
          $ref: '#/components/parameters/ApiVersion'
  /api/openapi.yaml:
    get:
      operationId: getOpenApiYaml
      tags:
        - Discovery
      summary: 'OpenAPI specification (YAML)'
      description: 'Same document as `/openapi.json`, serialized as YAML.'
      responses:
        200:
          description: 'OpenAPI 3.1 document.'
          content:
            application/yaml:
              schema:
                type: string
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
      parameters:
        -
          $ref: '#/components/parameters/ApiVersion'
  /api/site-config:
    get:
      operationId: getSiteConfig
      tags:
        - Catalog
      summary: 'Plans, commissions, and app download URLs'
      description: 'Live photographer plans (prices in USD cents), the lowest commission, store/desktop download URLs, and public social handles. Unauthenticated. Cache for up to an hour.'
      responses:
        200:
          description: 'Current site configuration.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SiteConfig'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
      parameters:
        -
          $ref: '#/components/parameters/ApiVersion'
  /api/config:
    get:
      operationId: getAppConfig
      tags:
        - Catalog
      summary: 'Mobile app bootstrap config'
      description: 'Locale, localization hints, Stripe fee constants, and help-center URLs used to boot the official mobile app. Unauthenticated; a Sanctum bearer is optional and only changes localization defaults.'
      parameters:
        -
          name: language
          in: query
          required: false
          description: 'ISO 639-1 locale to localize help URLs.'
          schema:
            type: string
          example: en
        -
          $ref: '#/components/parameters/ApiVersion'
      responses:
        200:
          description: 'App bootstrap payload.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppConfig'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
  /api/showcase/photos:
    get:
      operationId: listShowcasePhotos
      tags:
        - Catalog
      summary: 'Curated public photos for the home hero'
      description: 'Watermarked showcase photos used on the marketing home. Faces are normalized bounding boxes. Unauthenticated.'
      responses:
        200:
          description: 'Showcase photos.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShowcasePhotoList'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
      parameters:
        -
          $ref: '#/components/parameters/ApiVersion'
  /api/showcase/albums:
    get:
      operationId: listShowcaseAlbums
      tags:
        - Catalog
      summary: 'Curated public albums'
      description: 'Recently published public albums (no minors). Unauthenticated. Cached per locale.'
      parameters:
        -
          name: language
          in: query
          required: false
          description: 'ISO 639-1 locale for dates and location labels.'
          schema:
            type: string
          example: en
        -
          $ref: '#/components/parameters/ApiVersion'
      responses:
        200:
          description: 'Showcase albums.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShowcaseAlbumList'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
  /api/countries:
    get:
      operationId: listCountries
      tags:
        - Catalog
      summary: 'Countries supported for payouts and location pickers'
      description: 'Sorted list of countries. Names follow the `language` query (or Accept-Language). Unauthenticated.'
      parameters:
        -
          name: language
          in: query
          required: false
          description: 'ISO 639-1 locale for country names.'
          schema:
            type: string
          example: en
        -
          $ref: '#/components/parameters/ApiVersion'
      responses:
        200:
          description: 'Country list.'
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Country'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
  /api/divisions:
    get:
      operationId: listDivisions
      tags:
        - Catalog
      summary: 'States or provinces for a country'
      description: 'Requires `country` (the numeric id from `listCountries`). Unauthenticated.'
      parameters:
        -
          name: country
          in: query
          required: true
          description: 'Country id from `listCountries`.'
          schema:
            type: integer
        -
          name: language
          in: query
          required: false
          description: 'ISO 639-1 locale for division names.'
          schema:
            type: string
          example: en
        -
          $ref: '#/components/parameters/ApiVersion'
      responses:
        200:
          description: 'Division list.'
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Division'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
  /api/event-categories:
    get:
      operationId: listEventCategories
      tags:
        - Catalog
      summary: 'Canonical event disciplines'
      description: 'Slug list such as `running`, `cycling`, `triathlon`. Unauthenticated.'
      responses:
        200:
          description: 'Category slugs.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventCategoryList'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
      parameters:
        -
          $ref: '#/components/parameters/ApiVersion'
  /api/events/quick-search:
    get:
      operationId: searchEvents
      tags:
        - Events
      summary: 'Typeahead search for public events with photos'
      description: "Returns up to 8 active public events that have published albums. Without `q`, results are ranked by the visitor's country. Throttled at 60 requests per minute. Unauthenticated."
      parameters:
        -
          name: q
          in: query
          required: false
          description: 'Event name fragment. Empty returns nearby popular events.'
          schema:
            type: string
        -
          name: division
          in: query
          required: false
          description: 'Division id to pin the nearby ranking.'
          schema:
            type: integer
        -
          $ref: '#/components/parameters/ApiVersion'
      responses:
        200:
          description: 'Matching events.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventSearchResponse'
        429:
          $ref: '#/components/responses/Error'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
  /api/login:
    post:
      operationId: loginUser
      tags:
        - Auth
      summary: 'Exchange credentials for a Sanctum token'
      description: 'Authenticate a photographer or buyer. `login` accepts email or username. The returned bearer token authorizes the official mobile and desktop clients. Rate limited.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        200:
          description: 'Authenticated session.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        422:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
      parameters:
        -
          $ref: '#/components/parameters/ApiVersion'
  /api/user:
    get:
      operationId: getCurrentUser
      tags:
        - Auth
      summary: 'Current authenticated user'
      description: 'Requires a Sanctum bearer token from `loginUser`.'
      security:
        -
          sanctum: {  }
      responses:
        200:
          description: 'The authenticated user.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CurrentUserResponse'
        401:
          $ref: '#/components/responses/Error'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
      parameters:
        -
          $ref: '#/components/parameters/ApiVersion'
  /api/logout:
    post:
      operationId: logoutUser
      tags:
        - Auth
      summary: 'Revoke the current Sanctum token'
      description: 'Deletes the bearer token sent on the request. Returns 204.'
      security:
        -
          sanctum: {  }
      responses:
        204:
          description: 'Token revoked.'
        401:
          $ref: '#/components/responses/Error'
        400:
          $ref: '#/components/responses/Error'
        404:
          $ref: '#/components/responses/Error'
        429:
          $ref: '#/components/responses/Error'
        500:
          $ref: '#/components/responses/Error'
      parameters:
        -
          $ref: '#/components/parameters/ApiVersion'
components:
  parameters:
    ApiVersion:
      name: X-API-Version
      in: header
      required: false
      description: 'API version. Defaults to `1`. Breaking changes ship as `/api/v2` and send Deprecation/Sunset on the old path.'
      schema:
        type: string
        enum:
          - '1'
        default: '1'
  securitySchemes:
    sanctum:
      type: http
      scheme: bearer
      bearerFormat: token
      description: 'Personal access token from `loginUser`. Send as `Authorization: Bearer {token}`.'
  schemas:
    OpenApiDocument:
      type: object
      description: 'OpenAPI 3.1 document.'
      additionalProperties: true
    ShowcasePhotoList:
      type: object
      required:
        - data
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ShowcasePhoto'
    ShowcaseAlbumList:
      type: object
      required:
        - data
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ShowcaseAlbum'
    EventCategoryList:
      type: array
      items:
        type: string
        examples:
          - running
          - cycling
          - triathlon
    CurrentUserResponse:
      type: object
      required:
        - user
      properties:
        user:
          $ref: '#/components/schemas/User'
    ErrorEnvelope:
      type: object
      required:
        - message
        - error
      properties:
        message:
          type: string
          description: 'Human-readable summary (legacy clients).'
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
          description: 'Field-level validation messages when `error.code` is `validation_error`.'
        error:
          $ref: '#/components/schemas/Error'
    Error:
      type: object
      required:
        - code
        - message
        - hint
      properties:
        code:
          type: string
          description: 'Stable machine code: `not_found`, `unauthenticated`, `forbidden`, `validation_error`, `rate_limited`, `internal_error`.'
        message:
          type: string
        hint:
          type: string
          description: 'What an agent should do next.'
        details:
          description: 'Optional structured extras (validation map, etc.).'
    SiteConfig:
      type: object
      required:
        - lowestCommission
        - plans
        - appStoreUrl
        - googlePlayUrl
      properties:
        lowestCommission:
          type: integer
          description: 'Lowest photographer commission percentage.'
        appStoreUrl:
          type: string
          format: uri
        googlePlayUrl:
          type: string
          format: uri
        desktopMacArmUrl:
          type: string
          format: uri
        desktopMacIntelUrl:
          type: string
          format: uri
        desktopWindowsUrl:
          type: string
          format: uri
        lightroomPluginUrl:
          type: string
          format: uri
        social:
          type: object
          properties:
            instagram:
              type:
                - string
                - 'null'
            tiktok:
              type:
                - string
                - 'null'
        plans:
          type: array
          items:
            $ref: '#/components/schemas/Plan'
    Plan:
      type: object
      required:
        - key
        - commission
        - monthlyPriceCents
        - yearlyPriceCents
      properties:
        key:
          type: string
          examples:
            - free
            - lite
            - pro
            - enterprise
        commission:
          type: integer
        storageGb:
          type:
            - integer
            - 'null'
        monthlyPriceCents:
          type: integer
        yearlyPriceCents:
          type: integer
        recommended:
          type: boolean
        features:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              comingSoon:
                type: boolean
    AppConfig:
      type: object
      required:
        - language
        - maxImageUploadSizeKb
      properties:
        language:
          type: string
        maxImageUploadSizeKb:
          type: integer
        helpFeesUrl:
          type: string
          format: uri
        helpRewardsUrl:
          type: string
          format: uri
        helpVideosUrl:
          type: string
          format: uri
        helpDiscountsUrl:
          type: string
          format: uri
    ShowcasePhoto:
      type: object
      required:
        - uuid
        - thumb
        - aspect
        - album_uuid
        - event_slug
      properties:
        uuid:
          type: string
          format: uuid
        thumb:
          type: string
          format: uri
        aspect:
          type: number
        faces:
          type: array
          items:
            type: object
            properties:
              width:
                type: number
              height:
                type: number
              left:
                type: number
              top:
                type: number
        album_uuid:
          type: string
          format: uuid
        event_slug:
          type: string
    ShowcaseAlbum:
      type: object
      properties:
        title:
          type: string
        total_photos:
          type: integer
        cover:
          type: string
          format: uri
        cover_2x:
          type: string
          format: uri
        url:
          type: string
          format: uri
        event_name:
          type: string
        event_location:
          type:
            - string
            - 'null'
        event_date:
          type:
            - string
            - 'null'
        photographer:
          type: string
    Country:
      type: object
      required:
        - id
        - name
        - code
      properties:
        id:
          type: integer
        name:
          type: string
        code:
          type: string
          description: 'ISO 3166-1 alpha-2.'
    Division:
      type: object
      required:
        - id
        - name
      properties:
        id:
          type: integer
        name:
          type: string
    EventSearchResponse:
      type: object
      required:
        - events
        - scope
      properties:
        events:
          type: array
          items:
            $ref: '#/components/schemas/EventSummary'
        scope:
          type: string
          enum:
            - division
            - country
            - continent
            - global
        locationLabel:
          type:
            - string
            - 'null'
        countryId:
          type:
            - integer
            - 'null'
        divisionId:
          type:
            - integer
            - 'null'
    EventSummary:
      type: object
      required:
        - name
        - slug
        - url
      properties:
        name:
          type: string
        slug:
          type: string
        url:
          type: string
          format: uri
        cover:
          type:
            - string
            - 'null'
          format: uri
        categorySlug:
          type: string
        dateLabel:
          type:
            - string
            - 'null'
        locationLabel:
          type:
            - string
            - 'null'
        photosCount:
          type: integer
    LoginRequest:
      type: object
      required:
        - login
        - password
      properties:
        login:
          type: string
          description: 'Email or username.'
        password:
          type: string
          format: password
        remember:
          type: boolean
          default: false
        device_name:
          type: string
          maxLength: 255
    LoginResponse:
      type: object
      required:
        - token
        - token_type
        - user
      properties:
        token:
          type: string
        token_type:
          type: string
          examples:
            - Bearer
        expires_at:
          type:
            - string
            - 'null'
          format: date-time
        user:
          $ref: '#/components/schemas/User'
    User:
      type: object
      properties:
        uuid:
          type: string
          format: uuid
        name:
          type: string
        username:
          type:
            - string
            - 'null'
        email:
          type: string
          format: email
  responses:
    Error:
      description: 'Structured error. `message` is kept for existing clients; agents should read `error`.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
