Hand-rolled Swift clients, zero third-party dependencies, REST + JSON against https://media.scoreplay.io/v1.
Auth is api_key as a query parameter on every request (ApiKeyAuth, in: query — never an Authorization header); keys are stored per-account in the macOS Keychain. JSON calls run a 25s timeout; the storage PUT gets 180s. Response parsing is deliberately defensive — top-level arrays unwrap from events / data / results / items or a bare array, and ID readers tolerate string-or-number — so payload-shape drift doesn't break shipped clients.
| Endpoint | Method | FrameFlow | IngestFlow | Purpose |
|---|---|---|---|---|
/event/search |
POST | ✓ | ✓ | List / search collections (Events) |
/whoami |
GET | ✓ | — | Key validation, company identity, rights |
/upload/event/{eventID} |
POST | ✓ | ✓ | Upload init → presigned URL + media_id |
/upload/complete/{mediaID} |
POST | ✓ | ✓ | Upload finalize |
/upload/abort |
POST | ✓ | — | Cancel a started-but-incomplete upload |
/media/{id} |
GET | ✓ | — | Tag read-back (audit) |
/tag/search |
GET | ✓ | — | Athlete name → numeric player id |
/media/{id}/tags |
POST / DELETE | ✓ | — | Add / remove player tags |
POST /event/search — body {page, per_page: 200, sub_and_master: true, include_empty: true, query | event_ids}. Those two flags (per ScorePlay engineering's guidance) are what make parent/master folders and empty collections list at all. A purely numeric query is sent as event_ids: [Int]; since search can't return a collection by bare ID, the clients fabricate a placeholder from the pasted ID — upload-into-event only needs the number. Both clients paginate: sequential page fetches (one in flight), deduped by id, looping while full pages return, with a 10-page backstop. Fields consumed: ID, name, external_id, parent_id, full_path, child_count, media_count.
GET /whoami (FrameFlow) — fired the moment a key is entered; consumes user_type, company.name, rights[]. The can_do_media_edit right gates the entire tag-write surface, and drives the "Connected to [company]" confirmation.
Media is created inside the target event — no FTP, no renaming, no post-hoc filing.
POST /upload/event/{eventID}?parts=1 with a CreateMediaPayload. FrameFlow sends the rich form — {name, description, date, raw_tags, player_accessible} where date is unix seconds from camera EXIF (falling back to file mtime, then ingest date) and raw_tags is the curated keyword list only, since raw_tags create tags server-side. IngestFlow sends {name}. Response: {upload_id, key, media_id, urls: [{url: <presigned>}]}.PUT the bytes to the presigned URL (single part), capture the ETag header.POST /upload/complete/{mediaID} with {key, upload_id, parts_number: 1, parts: [{parts_number: 1, etag}]}. The completion response's event.name is the only place the API returns the event's display name, so FrameFlow harvests it there to label collections. The returned media_id is persisted on the image and synced to every editor — it's the handle for all later reads and fixes.POST /upload/abort — {key, upload_id}, best-effort, fired on any failure after init (PUT error, non-2xx, completion failure) so an interrupted send never leaves an orphaned storage object or half-created media record. FrameFlow only; IngestFlow's lean client omits it (known gap).
GET /media/{id} — audit read-back. Player tags parse via player_id + nested player {ID, full_name}; keyword tags via tag_option.label. A 404 (or "media not found") means this key's account doesn't own the media — media belongs to exactly one account — so FrameFlow tries the owning account first (learned and remembered per image), falls through other keys on 404, and marks media gone only after every account 404s, never re-polling it.
GET /tag/search?query= — resolves a signed-off athlete name to players: [{ID, full_name}]; ids ≤ 0 dropped as malformed.
POST / DELETE /media/{id}/tags — identical body both directions: {"player_ids": [Int]}. Error semantics worked out in live testing: 401/403 = key lacks can_do_media_edit, surfaced and never retried; 400/409 whose message contains "already" / "exist" / "not found" = desired state is already true, treated as success. After any successful write, the media is re-read — the client never assumes the write shape.