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

# Ingest a batch of camera-roll metadata

> Send photo **metadata only**, never image bytes. FanFeed matches each item against
venue and event data using its coordinates and capture time, and returns the matches
**inline in the response for that batch**.

There is no webhook and nothing to poll: each batch's response is the result for that
batch. Drive your progress UI from batch completions.

Send **50–200 items per request**. Items without coordinates or a capture time cannot
be matched and are returned in `rejected`; filter them out client-side to save the
round trip.

Re-sending a batch is safe. Photos that matched upsert on your asset id and never
produce a second entry; photos that matched nothing are not retained, so re-sending one
costs a re-evaluation rather than a duplicate. Either way the result is the same, so
overlapping your scan window slightly is the right call.

FanFeed records the sync high-water mark per user; `last_sync_at` comes back on every
response and is also available on the stats endpoint.




## OpenAPI

````yaml /api-reference/openapi.yaml post /users/{user_id}/media
openapi: 3.1.0
info:
  title: FanFeed Partner API
  version: 1.0.0
  description: >
    FanFeed turns a user's camera roll into an attended-event history. Your app
    sends photo

    *metadata*; FanFeed matches it against live-event data and returns the
    events those photos

    were taken at, along with the stats derived from that history.


    Five endpoints cover the whole integration:


    | Endpoint | Purpose |

    |---|---|

    | `POST /users` | Create a FanFeed user, once, when your user opts in |

    | `POST /users/{user_id}/media` | Send photo metadata in batches; get
    matched events back |

    | `POST /users/{user_id}/sync-complete` | Tell FanFeed a library scan
    finished |

    | `GET /users/{user_id}/events` | The user's matched event history |

    | `GET /users/{user_id}/stats` | Profile statistics |
  contact:
    name: FanFeed Engineering
    email: engineering@fanfeed.ai
servers:
  - url: https://api.fanfeed.ai/v1
    description: Production
  - url: https://api-dev.fanfeed.ai/v1
    description: Development
security:
  - PartnerApiKey: []
tags:
  - name: Users
    description: Creating a FanFeed user for one of your users.
  - name: Media
    description: Batch camera-roll metadata ingest and event matching.
  - name: Sync
    description: Recording that a library scan finished.
  - name: Events
    description: A user's matched event history.
  - name: Stats
    description: Profile statistics derived from the event history.
paths:
  /users/{user_id}/media:
    post:
      tags:
        - Media
      summary: Ingest a batch of camera-roll metadata
      description: >
        Send photo **metadata only**, never image bytes. FanFeed matches each
        item against

        venue and event data using its coordinates and capture time, and returns
        the matches

        **inline in the response for that batch**.


        There is no webhook and nothing to poll: each batch's response is the
        result for that

        batch. Drive your progress UI from batch completions.


        Send **50–200 items per request**. Items without coordinates or a
        capture time cannot

        be matched and are returned in `rejected`; filter them out client-side
        to save the

        round trip.


        Re-sending a batch is safe. Photos that matched upsert on your asset id
        and never

        produce a second entry; photos that matched nothing are not retained, so
        re-sending one

        costs a re-evaluation rather than a duplicate. Either way the result is
        the same, so

        overlapping your scan window slightly is the right call.


        FanFeed records the sync high-water mark per user; `last_sync_at` comes
        back on every

        response and is also available on the stats endpoint.
      operationId: ingestMedia
      parameters:
        - $ref: '#/components/parameters/UserId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IngestMediaRequest'
      responses:
        '200':
          description: Batch processed. Matches are included.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IngestMediaResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          description: Batch too large. Send at most 200 items per request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
components:
  parameters:
    UserId:
      name: user_id
      in: path
      required: true
      description: The FanFeed user id returned by `POST /users`.
      schema:
        type: string
        format: uuid
        examples:
          - 8f14e45f-ceea-467a-9a1e-2b4d9c3f0a11
  schemas:
    IngestMediaRequest:
      properties:
        media:
          description: >
            Aim for 50–200 items per request. 200 is a hard cap; anything larger
            returns `413`.


            Smaller batches are valid and expected: the last batch of a scan is
            normally

            short, and a user with only a handful of geotagged photos may never
            fill one.
          items:
            $ref: '#/components/schemas/MediaMetadata'
          maxItems: 200
          minItems: 1
          type: array
      required:
        - media
      type: object
    IngestMediaResponse:
      properties:
        accepted:
          description: >
            Items in this batch accepted for matching: everything you sent that
            was not

            rejected.
          examples:
            - 96
          type: integer
        matched:
          description: >
            A **subset of `accepted`**: how many of those landed on an event
            (`matched: true`

            with an `event_id`). The remainder are perfectly good ingested
            photos that were

            not taken at a live event.
          examples:
            - 8
          type: integer
        rejected:
          description: >
            Always present. Empty when every item in the batch was accepted.
            Rejections are

            per-item and never fail the request.
          items:
            $ref: '#/components/schemas/RejectedMedia'
          type: array
        matches:
          description: >
            One entry per accepted item, in no particular order. Resolve by
            `media_id`.
          items:
            $ref: '#/components/schemas/MediaMatch'
          type: array
        events:
          description: >
            The distinct events matched in **this batch**, fully expanded, so
            you can render

            newly-found events as the sync progresses without a second call.
          items:
            $ref: '#/components/schemas/Event'
          type: array
        last_sync_at:
          description: >
            Server-side timestamp of the last time FanFeed received media or a
            `sync-complete`

            for this user, advanced by this batch. It never moves backwards, so
            the value on

            any response is the latest mark even when concurrent batches commit
            out of order.
          examples:
            - '2026-08-25T18:44:12Z'
          format: date-time
          type: string
      required:
        - accepted
        - events
        - last_sync_at
        - matched
        - matches
        - rejected
      type: object
    Error:
      description: The published error envelope.
      properties:
        error:
          description: The ``error`` member of the envelope.
          properties:
            code:
              $ref: '#/components/schemas/ErrorCode'
              description: Stable, machine-readable error code.
            message:
              description: Human-readable explanation. Not intended for end users.
              examples:
                - Send at most 200 media items per request.
              type: string
            details:
              description: >
                Optional per-field or per-item context. Present only where it
                adds something;

                its shape varies by code.
              anyOf:
                - additionalProperties: true
                  type: object
                - type: 'null'
          required:
            - code
            - message
          type: object
      required:
        - error
      type: object
    MediaMetadata:
      description: >-
        One camera-roll asset. Capture time and coordinates are optional at the
        schema level

        because unusable items are returned in `rejected[]` without failing the
        rest of the batch.
      properties:
        id:
          description: >
            Your asset identifier. Opaque to FanFeed: an iOS local identifier,
            an Android

            MediaStore URI, or your own surrogate key all work, and the two
            platforms do not

            need to agree on a format. FanFeed derives its internal identifier
            from yours and

            echoes **your** id back on every response.


            The only requirement is that it is **stable for that asset across
            syncs**: it is

            the deduplication key, so if it changes the same photo is treated as
            a new one.

            iOS local identifiers are not guaranteed stable across a device
            restore, so if

            you support restore, derive your own id and store it.
          examples:
            - B84E8479-475C-4727-A4A4-B77AA9980897/L0/001
            - content://media/external/images/media/1000024891
          maxLength: 255
          type: string
        taken_at:
          description: >-
            Capture time. Required for matching; an item without it is rejected
            with `missing_taken_at`.
          examples:
            - '2026-06-14T02:31:07Z'
          anyOf:
            - format: date-time
              type: string
            - type: 'null'
        latitude:
          examples:
            - 40.750504
          anyOf:
            - maximum: 90
              minimum: -90
              type: number
            - type: 'null'
          description: >-
            Required for matching. An item without coordinates is rejected with
            `missing_coordinates`.
          format: double
        longitude:
          examples:
            - -73.993439
          anyOf:
            - maximum: 180
              minimum: -180
              type: number
            - type: 'null'
          format: double
        media_type:
          default: photo
          enum:
            - photo
            - video
          type: string
        filename:
          examples:
            - IMG_4821.HEIC
          anyOf:
            - type: string
            - type: 'null'
        thumbnail_url:
          description: >
            Optional. A thumbnail you host. FanFeed never fetches, validates, or
            serves it; it

            is stored verbatim and echoed back on event media. Omit it if you do
            not host

            thumbnails.
          anyOf:
            - type: string
            - type: 'null'
          format: uri
        duration_seconds:
          description: Videos only.
          anyOf:
            - type: number
            - type: 'null'
          format: float
        device_make:
          examples:
            - Apple
          anyOf:
            - type: string
            - type: 'null'
        device_model:
          examples:
            - iPhone 15 Pro
          anyOf:
            - type: string
            - type: 'null'
        device_lens_model:
          anyOf:
            - type: string
            - type: 'null'
      required:
        - id
      type: object
    RejectedMedia:
      properties:
        id:
          examples:
            - B84E8479-475C-4727-A4A4-B77AA9980897/L0/002
          type: string
        reason:
          examples:
            - missing_coordinates
          enum:
            - missing_coordinates
            - missing_taken_at
            - invalid
          type: string
      required:
        - id
        - reason
      type: object
    MediaMatch:
      properties:
        media_id:
          description: Echoes the `id` you sent.
          examples:
            - B84E8479-475C-4727-A4A4-B77AA9980897/L0/001
          type: string
        matched:
          description: >
            `true` only when the item matched an event, i.e. `event_id` is set.
            `false` for

            most photos; a camera roll is mostly not taken at live events, and
            this is

            expected. Venue-only items (see `event_id`) are `false`.
          examples:
            - true
          type: boolean
        event_id:
          description: >
            Set exactly when `matched` is `true`. `null` with a non-null
            `venue_id` means the

            photo was taken at a known venue whose event is not licensed for
            redistribution;

            the item is unmatched, does not count toward the batch's `matched`
            total, and has

            no entry in `events`. Branch on `matched` or `event_id`, never on
            `venue_id`.
          examples:
            - 18012359
          anyOf:
            - type: integer
            - type: 'null'
        venue_id:
          examples:
            - 4412
          anyOf:
            - type: integer
            - type: 'null'
      required:
        - matched
        - media_id
      type: object
    Event:
      properties:
        id:
          examples:
            - 18012359
          type: integer
        name:
          examples:
            - Zach Bryan
          type: string
        starts_at_local:
          description: >
            Local date and time at the venue, without a timezone offset. Do not
            convert it;

            it is already the time the user experienced.
          examples:
            - '2026-06-13T19:30:00'
          type: string
        venue:
          anyOf:
            - $ref: '#/components/schemas/Venue'
            - type: 'null'
        performers:
          anyOf:
            - items:
                $ref: '#/components/schemas/Performer'
              type: array
            - type: 'null'
        media_count:
          examples:
            - 12
          anyOf:
            - type: integer
            - type: 'null'
        media:
          description: >-
            Present unless `include_media=false`. Complete for the event; not
            capped.
          anyOf:
            - items:
                $ref: '#/components/schemas/EventMedia'
              type: array
            - type: 'null'
      required:
        - id
        - name
        - starts_at_local
      type: object
    ErrorCode:
      description: >
        Stable, machine-readable error code. Branch on this, not on `message`.


        Note that per-item rejections inside a media batch are *not* errors: the
        request

        still returns `200` and the items appear in `rejected[]` with a
        `reason`. An

        `error.code` means the whole request failed.


        The first nine codes are published application errors. The last four are

        framework-level codes that malformed or unsupported requests can trigger
        (for

        example, a non-JSON body returns `unsupported_media_type` and an unknown
        path

        returns `not_found`). Treat any unrecognized code as a retry or fix-once
        failure

        according to the HTTP status.
      examples:
        - invalid_batch_size
      enum:
        - invalid_api_key
        - user_not_found
        - invalid_email
        - empty_batch
        - invalid_batch_size
        - invalid_media_item
        - invalid_cursor
        - rate_limited
        - internal_error
        - unsupported_media_type
        - invalid_request
        - not_found
        - method_not_allowed
      type: string
    Venue:
      properties:
        id:
          examples:
            - 4412
          type: integer
        name:
          examples:
            - Madison Square Garden
          type: string
        city:
          examples:
            - New York
          anyOf:
            - type: string
            - type: 'null'
        region:
          examples:
            - NY
          anyOf:
            - type: string
            - type: 'null'
        country:
          examples:
            - US
          anyOf:
            - type: string
            - type: 'null'
        latitude:
          examples:
            - 40.750504
          anyOf:
            - type: number
            - type: 'null'
          format: double
        longitude:
          examples:
            - -73.993439
          anyOf:
            - type: number
            - type: 'null'
          format: double
      required:
        - id
        - name
      type: object
    Performer:
      properties:
        id:
          examples:
            - 34421
          type: integer
        name:
          examples:
            - Zach Bryan
          type: string
        category:
          description: Genre for music, league for sport.
          examples:
            - Country
          anyOf:
            - type: string
            - type: 'null'
        image_url:
          anyOf:
            - type: string
            - type: 'null'
          format: uri
        is_headliner:
          examples:
            - true
          anyOf:
            - type: boolean
            - type: 'null'
      required:
        - id
        - name
      type: object
    EventMedia:
      properties:
        media_id:
          description: Your `id` for the asset, as originally submitted.
          examples:
            - B84E8479-475C-4727-A4A4-B77AA9980897/L0/001
          type: string
        taken_at:
          anyOf:
            - format: date-time
              type: string
            - type: 'null'
        media_type:
          anyOf:
            - enum:
                - photo
                - video
              type: string
            - type: 'null'
        thumbnail_url:
          description: >
            The `thumbnail_url` you submitted for this asset. Absent when you
            did not send

            one; FanFeed never generates or hosts thumbnails itself.
          anyOf:
            - format: uri
              type: string
            - type: 'null'
      required:
        - media_id
      type: object
  responses:
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: No FanFeed user with that id.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    UnsupportedMediaType:
      description: Request body must be application/json.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    TooManyRequests:
      description: >
        Rate limited. Nothing is throttled today, but the limiter exists, so
        build for this

        from day one: honor `Retry-After` and back off.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InternalServerError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    PartnerApiKey:
      type: apiKey
      in: header
      name: X-PARTNER-API-KEY
      description: >
        Your FanFeed partner API key, issued to your organization.


        FanFeed trusts your authentication: the key identifies you, and the
        `user_id` in the

        path identifies which of your users the request is for. FanFeed performs
        no end-user

        authentication of its own.


        Treat the key as a server-side secret. See the guide for what that
        implies for the

        camera-roll ingest path, which is the one call that naturally originates
        on-device.

````