Skip to main content
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.
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, 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.
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: 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.
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.