Contents Capture integration
Contents Capture is the field-capture platform (an iOS app backed by a Cloudflare Workers API, D1 metadata database, and a private R2 media bucket). ContentsVision integrates with it as a pull-based media source: users browse a connected Capture org like a folder tree and import selected media into claims.
Architecture
ContentsVision PULLS media. All Capture traffic is server-to-server from the backend; the browser never talks to the Capture API and never sees the org token.
ContentsVision UI
| /api/integrations/... (team-scoped)
ContentsVision backend --Bearer cci_ token--> Contents Capture API
| |
| <-- short-lived presigned R2 GET URLs ------+
+--> downloads bytes, creates claims/rooms/jobs
Contents Capture API --signed webhook POST--> ContentsVision backend
(submission.created) /api/integrations/contents-capture/webhook
-> auto-imports the submitted media into a claim
Key properties:
- Org-level access. A Contents Capture org admin mints an org integration
token (
cci_...) in the Capture admin console. A ContentsVision team admin pastes it under Settings, then Integrations. The token is read-only on the Capture side (its principal is an org-wide observer) and is validated againstGET /integrations/whoamibefore it is stored. - The Capture API is the source of truth. Only media with
syncState == "synced"has its original durably stored; browse and import filter on it. ContentsVision holds no R2 credentials: bytes are fetched through presigned GET URLs the Capture API signs per media id (1 hour TTL, signed in batches immediately before download). - Entity mapping. Capture assignment -> claim (the assignment
referencebecomes the claim number), location -> room (matched by name, created when missing), group -> staged photogroup_index(grouping arrives pre-done), photo ->StagedPhotoin one photo job per room, video/audio -> one job per file. - Billing is never bypassed. Imported photo jobs park at
awaiting_groupingand video/audio jobs atawaiting_confirmation; the user starts them through the normal quote/process step.
Data model
Three tables, declared in backend/app/models/integration.py and registered in
database.py:init_db() (whole new tables, so no ALTER lines):
| Table | Purpose |
|---|---|
storage_connections | One configured provider per team (kind = contents_capture). Holds the Capture org id (external_account_id) and the org token (api_token, following the Team.team_token precedent; secret_ref is the upgrade path to a secret manager). |
uploads | One piece of source media with provenance: (connection_id, external_media_id) is the idempotency key, sha256/size_bytes come from the Capture variant record, meta snapshots the capture tree (assignment/location/group ids). Lifecycle: `registered -> importing -> ready |
import_batches | One user-initiated import run: the selection manifest verbatim, progress counters (total/imported/skipped/failed), the claim it landed in, and the job ids it created. The UI polls this row. Also used by the claim-level contents-list PDF import, which has no connection and leaves connection_id blank, so both imports share one progress endpoint. |
Provider layer
backend/app/services/integrations/ holds the adapter layer:
base.pydefines theStorageProviderinterface (browse a virtual tree, stat one item, stream its bytes) plusProviderCapabilities, so future providers (S3, Dropbox, ...) plug in without touching callers.registry.pymapsStorageConnection.kindto a provider class via@register_provider/build_provider.contents_capture.pyimplements the Capture provider:CaptureClientis a thin async httpx client over the Capture read API (same house style asadjust_square.py), andContentsCaptureProvideris registered undercontents_capture.importer.pyis the import engine (see below).
API endpoints
All under /api/integrations, team-scoped via the standard Actor dependency
(backend/app/api/routes/integrations.py):
| Endpoint | Purpose |
|---|---|
GET /integrations/connections | The team's connections plus contents_capture_available (false when the server has no base URL configured, which hides the integration in the UI). |
POST /integrations/contents-capture/connect | Validate a pasted cci_ token via whoami and store/replace the team's connection. |
DELETE /integrations/connections/{id} | Disconnect. |
GET .../connections/{id}/teams, .../teams/{tid}/assignments, .../assignments/{aid}/tree, .../assignments/{aid}/media | Browse proxy over the Capture read API. The media page passes Capture's presigned thumbnail URLs through to the grid. |
POST .../connections/{id}/import | Save a selection manifest on an ImportBatch and enqueue run_capture_import. |
GET /integrations/imports/{batch_id} | Poll import progress. |
POST /integrations/contents-capture/webhook | Public endpoint for Capture webhook deliveries (no Actor; the per-delivery HMAC signature authenticates it). A submission.created event auto-imports the submitted media; everything else is accepted and ignored. |
Automatic import on submission (webhooks)
Manual import is the pull path. The push path removes the button entirely: when the field team submits an assignment in Capture, the media imports itself and a claim appears in ContentsVision.
Setup (one time, per Capture org):
- Set
CONTENTS_CAPTURE_WEBHOOK_SECRETon the backend to a long random string and restart. Empty disables the endpoint: it answers 503 and manual import keeps working. - In Contents Capture, register the endpoint (org admin with
webhook.manage):POST /orgs/:id/webhook-endpointswithurl = https://<contentsvision-host>/api/integrations/contents-capture/webhook,subscriptions = ["submission.created"], andsecretset to the same value. Capture signs every delivery with it (X-CC-Signature: sha256=<hex HMAC-SHA256 of the raw body>).
What happens on each submission:
- Capture emits
submission.created(data carriesassignmentIdand the exactmediaIdssubmitted) and its queue consumer POSTs the signed event to the endpoint, at-least-once with retries on non-2xx. - The endpoint verifies the signature (401 on mismatch, which Capture retries and then logs as a terminal delivery failure), resolves the team's connection by the event's org id, and ignores deliveries for orgs with no active connection (202, so stale endpoints drop instead of retrying).
- A
submission.createdevent becomes anImportBatchwhose manifest selects exactly the submitted media (submitted_onlyas a belt-and-braces filter) and is run by the samerun_capture_importengine as a manual import. The batch'smanifest.autorecords the event/submission/delivery ids. - Targeting: the first submission of an assignment creates a new claim
(numbered from the assignment reference, as always). If an earlier import
for the same assignment and connection already landed in a claim, manual or
automatic, follow-up submissions append into that claim
(
existing_claimmode), so supplemental submissions extend the original claim instead of minting duplicates. - Replay safety: a redelivered event id, or a new event id for the same submission id, returns the batch the first delivery created. Media-level idempotency in the import engine covers everything below that.
The provider layer advertises this as has_events=True on
ContentsCaptureProvider; handle_webhook verifies the signature, binds the
event's org to the connection, and normalizes the payload into an IngestEvent.
The selection manifest
The import request is selector-based, never a flat list of media ids, so selecting "everything" stays a tiny request and the server expands it:
{
"assignment_id": "...",
"target": {"mode": "new_claim" | "existing_claim" | "room", "claim_id": "...", "room_id": "..."},
"select": [{"scope": "assignment"} , {"scope": "location", "id": "..."},
{"scope": "group", "id": "..."}, {"scope": "media", "ids": ["..."]}],
"exclude": [ ...same shapes... ],
"filters": {"types": ["photo"], "submitted_only": false}
}
The import job
run_capture_import runs on the ARQ worker in production (REDIS_URL set) or
as a BackgroundTask locally, registered in worker.py and core/queue.py. Per batch it:
- expands the manifest against the Capture API (synced media only, cursor-paginated), applies excludes and filters, deduplicates;
- skips media already imported on this connection (idempotency key), counting them as skipped;
- resolves the target claim and rooms, creating them as needed;
- signs original URLs in batches of 200 immediately before downloading
(bounded concurrency,
CONTENTS_CAPTURE_IMPORT_CONCURRENCY), normalizes photos to JPEG with the EXIF orientation baked into the pixels (Capture keeps its originals untouched, tag included, so a portrait phone photo arrives as landscape pixels plus a "rotate me" tag;upright_rgbinservices/image_encode.pyturns them before the re-encode drops the tag), falling back to the Capture proxy variant when the original is not Pillow-readable (e.g. HEIC), seedsstaged_photoswith group indices carried over from Capture groups; - updates the batch counters as it goes and finishes
complete,partial(some files failed; re-running the import retries just those), orfailed.
Configuration
| Setting | Meaning |
|---|---|
CONTENTS_CAPTURE_API_BASE_URL | The Capture API base URL. Empty (default) disables the integration entirely. Dev/stage: https://contents-capture-api.dev-908.workers.dev. |
CONTENTS_CAPTURE_WEBHOOK_SECRET | HMAC secret verifying Capture webhook deliveries at the auto-import endpoint. Empty disables the endpoint (503); manual import works regardless. See "Automatic import on submission". |
CONTENTS_CAPTURE_IMPORT_CONCURRENCY | Parallel downloads per import run (default 4). |
Per-team org tokens live on storage_connections.api_token, not in the
environment.
Frontend
- Integrations page (
frontend/src/app/integrations/page.tsx, card infrontend/src/components/IntegrationsCard.tsx): connect (token paste + validate), connected state, disconnect. It sat under Settings until the Integrations page was added; the card itself is unchanged. - Import browser (
frontend/src/components/CaptureImportModal.tsx): a folder-style explorer (assignment folders on the left, media grid on the right) with tri-state folder checkboxes, shift-click range select, type and submitted-only filters, selector-based selection, and a polling progress view. Opened from the Dashboard ("new claim" imports) and the claim page ("into this claim" imports); the buttons render only when the team has an active connection.
Production cutover
The integration currently points at the Capture dev/stage stack. Moving to
production means: provision the Capture production environment (Worker, D1,
R2, Clerk live), set CONTENTS_CAPTURE_API_BASE_URL to the production host,
and have org admins mint production tokens. No code changes.