> ## 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.

# Ingesting camera-roll metadata

> Batch photo metadata to FanFeed and get matched events back inline.

This is the bulk of the work. Budget accordingly.

### Permissions

Request **full** photo-library access. iOS "Limited" access technically works, but the user
only sees matches from the handful of photos they picked, which reads as a broken feature.
Detect it and prompt for full access.

### Reading the camera roll

Per asset you need: a stable local identifier, coordinates, capture time, media type, and
optionally filename, duration, and device fields.

**Performance.** Enumerating a large library is the slowest part of this integration; a heavy
user has tens of thousands of assets. On a cross-platform framework the bridge to the photo
library usually dominates, and an off-the-shelf package will not keep up. A **native module**
that reads the library and returns metadata in bulk is markedly faster.

None of that blocks shipping. You can start with an off-the-shelf package and swap the reader
later without any API change. If you do, page the library **by year, newest first**, so the
user sees recent events appear while the scan is still running.

**Filter out assets with no coordinates before sending.** They cannot match, and they cost you
a round trip.

### Send a batch

Metadata only. **No image bytes.**

```http theme={null}
POST /v1/users/8f14e45f-ceea-467a-9a1e-2b4d9c3f0a11/media
X-PARTNER-API-KEY: <your partner key>
Content-Type: application/json

{
  "media": [
    {
      "id": "B84E8479-475C-4727-A4A4-B77AA9980897/L0/001",
      "taken_at": "2026-06-14T02:31:07Z",
      "latitude": 40.750504,
      "longitude": -73.993439,
      "media_type": "photo",
      "filename": "IMG_4821.HEIC",
      "device_make": "Apple",
      "device_model": "iPhone 15 Pro"
    },
    {
      "id": "content://media/external/images/media/1000024891",
      "taken_at": "2026-06-14T03:02:44Z",
      "latitude": 40.750611,
      "longitude": -73.993512,
      "media_type": "photo",
      "device_make": "Google",
      "device_model": "Pixel 9"
    }
  ]
}
```

| Field                                                | Required | Notes                                                                                                             |
| ---------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `id`                                                 | ✔        | Your asset identifier: any string up to 255 characters. See below.                                                |
| `taken_at`                                           | —        | Capture time, UTC. Required for matching; an item without it is returned in `rejected[]` with `missing_taken_at`. |
| `latitude` / `longitude`                             | —        | Required for matching; an item without coordinates is returned in `rejected[]` with `missing_coordinates`.        |
| `media_type`                                         | —        | `photo` (default) or `video`.                                                                                     |
| `filename`, `duration_seconds`, `thumbnail_url`      | —        |                                                                                                                   |
| `device_make` / `device_model` / `device_lens_model` | —        | Diagnostics.                                                                                                      |

**About `id`.** It is 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, so you never have to hold a mapping table.

The only requirement is that it is **stable for that asset across syncs**. It is the
deduplication key: if it changes, the same photo is treated as a new one. Note that iOS local
identifiers are not guaranteed stable across a device restore, so if you support restore,
derive your own id and store it.

**About `thumbnail_url`.** FanFeed never fetches, validates, or hosts it. It is stored
verbatim and echoed back on the event's media in [Events](/guides/events), which is the one
way to show event photos somewhere the device's camera roll is not available. If you do not
host thumbnails, omit it.

**Aim for 50–200 items per request.** 200 is a hard cap; anything larger returns `413`. Smaller batches
are fine 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.

Batches can run concurrently; three in flight is a reasonable starting point. A full library
typically completes in well under a minute.

### The response

**Matches come back inline.** Matching is synchronous. There is no webhook to receive and
nothing to poll; each batch's response is the result for that batch.

```json theme={null}
{
  "accepted": 96,
  "matched": 8,
  "rejected": [
    { "id": "B84E8479-…/L0/002", "reason": "missing_coordinates" }
  ],
  "matches": [
    { "media_id": "B84E8479-…/L0/001", "matched": true, "event_id": 18012359, "venue_id": 4412 },
    { "media_id": "B84E8479-…/L0/003", "matched": false, "event_id": null, "venue_id": null },
    { "media_id": "B84E8479-…/L0/004", "matched": false, "event_id": null, "venue_id": 9310 }
  ],
  "events": [
    {
      "id": 18012359,
      "name": "Zach Bryan",
      "starts_at_local": "2026-06-13T19:30:00",
      "venue": { "id": 4412, "name": "Madison Square Garden", "city": "New York", "region": "NY" },
      "performers": [{ "id": 34421, "name": "Zach Bryan", "category": "Country", "is_headliner": true }],
      "media_count": 3
    }
  ],
  "last_sync_at": "2026-08-25T18:44:12Z"
}
```

Reading it:

* **`accepted`** is how many items in the batch were ingested: everything you sent that was
  not rejected.
* **`matched`** is a **subset of `accepted`**: how many of those landed on an event, meaning
  `matched: true` and an `event_id`. The remainder are perfectly good ingested photos that
  were not taken at a live event.
* **`rejected`** is always present, empty when nothing was rejected. Each entry carries a
  `reason`: `missing_coordinates`, `missing_taken_at`, or `invalid`. Rejections are per-item
  and never fail the request.
* **`matches`** has one entry per accepted item, in no particular order. Resolve them by
  `media_id`, which echoes the `id` you sent.
* **`events`** carries the **fully expanded** distinct events matched in this batch, so you can
  render newly-found events live as the sync progresses without a second call. Drive your
  progress UI from batch completions.

**An unmatched item can still carry a `venue_id`.** That is the licensing case from
[How matching works](/#how-matching-works): the photo was taken at a known venue, but the
event there is not one FanFeed can return. The item has `matched: false`, does not count
toward the `matched` total, and has no entry in `events`. Branch on `matched` or on
`event_id`, never on `venue_id`.

### Telling FanFeed a sync finished

Call this **once at the end of a completed library scan**, not per batch.

```http theme={null}
POST /v1/users/8f14e45f-ceea-467a-9a1e-2b4d9c3f0a11/sync-complete
X-PARTNER-API-KEY: <your partner key>
Content-Type: application/json

{ "photos_processed": 1284 }
```

```json theme={null}
{
  "has_synced": true,
  "last_sync_at": "2026-08-25T18:44:12Z"
}
```

| Field              | Required | Notes                                                                                                                                                                          |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `photos_processed` | —        | Assets your scan examined, whether or not you sent them. Pass `0` when the scan did not complete. Omit it and FanFeed infers the answer from what it received during the scan. |

**Why this call exists.** `has_synced` is what decides whether the next scan is a **full
library scan** or a **narrow incremental one**. FanFeed sets it to `true` only when a
completed scan examined the library, meaning `photos_processed` was greater than zero or was
omitted while FanFeed received media during the scan. It never goes back to `false`.

That asymmetry is deliberate, and it is the one thing to get right here. Say the user denies
photo permission, or the app crashes mid-scan, and the flag gets set anyway: the account is
stuck on the incremental window permanently, and the profile shows only whatever falls in that
narrow window and never recovers. **So pass an honest `photos_processed`.** Passing `0`, or
not calling at all, is always safe; the worst outcome is a redundant full scan.

The count is assets **examined**, even when none were worth sending. A user whose photos
carry no coordinates still completes a real scan; pass the examined count so the account
moves to incremental syncs instead of re-scanning the full library on every launch.

The call is idempotent. Calling it twice advances `last_sync_at` twice and is otherwise a
no-op; `last_sync_at` is a high-water mark and never goes backwards.

### Incremental syncs

At app launch, read `has_synced` and `last_sync_at` from `GET /users/{user_id}/stats`:

* **`has_synced: false`**: run a full library scan, then call `sync-complete`.
* **`has_synced: true`**: scan from shortly before `last_sync_at`, submit as normal, then call
  `sync-complete` again.

`has_synced` never reverts to `false` once set, so this branch only ever moves one way. The
first scan is the only full one.

`last_sync_at` is a server-side timestamp: the last time FanFeed received media or a
`sync-complete` for this user. It advances on every accepted batch and on every
`sync-complete`, and it comes back on both responses and on stats, so there is no timestamp
for you to store. **It never moves backwards.** That matters because batches run concurrently
and can commit out of order: the value on any response is the latest mark, not that batch's
own timestamp, so an out-of-order response can be used as-is.

**Scan by the right date.** The incremental window is on capture time, and a camera roll also
gains photos whose capture time is old: AirDrop, messaging-app saves, shared albums, imports.
On Android, filter by `DATE_ADDED` instead of the capture date and the problem disappears. iOS
does not expose a date-added, so start the window a couple of days before `last_sync_at` and,
if late-added media matters to your product, run an occasional full rescan. Re-sends
deduplicate, so the only cost of overlap is bandwidth.

**On deduplication.** Re-sending a photo that previously matched an event is harmless: it
deduplicates on your `id` and will not create 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 and there is nothing you need to track; overlapping your scan window
slightly is the right call.
