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.acceptedis how many items in the batch were ingested: everything you sent that was not rejected.matchedis a subset ofaccepted: how many of those landed on an event, meaningmatched: trueand anevent_id. The remainder are perfectly good ingested photos that were not taken at a live event.rejectedis always present, empty when nothing was rejected. Each entry carries areason:missing_coordinates,missing_taken_at, orinvalid. Rejections are per-item and never fail the request.matcheshas one entry per accepted item, in no particular order. Resolve them bymedia_id, which echoes theidyou sent.eventscarries 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.
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, readhas_synced and last_sync_at from GET /users/{user_id}/stats:
has_synced: false: run a full library scan, then callsync-complete.has_synced: true: scan from shortly beforelast_sync_at, submit as normal, then callsync-completeagain.
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.