Files

Approved files are the product. Every submission that passes AI scoring becomes a File — an immutable record with the content, a pre-signed S3 download URL, auto-generated annotations, and provenance metadata.

Two endpoints:

MethodPathPurpose
GET/v1/jobs/{job_id}/filesList approved files for a job
POST/v1/files/{file_id}/refresh_urlRefresh an expired download URL for a single file

File Object

FieldTypeDescription
object"file"Resource discriminator
idstringFile ID (file_01JQ...)
job_idstringParent job ID
submission_idstringSubmission that produced this file
content_typestringMIME type (e.g., image/jpeg)
size_bytesintegerFile size in bytes
duration_secondsnumber | nullAudio/video duration via ffprobe
width / heightinteger | nullPixel dimensions (image/video)
content_hashstringSHA-256 of file bytes — for dedup + integrity
captured_atstring | nullDevice EXIF capture timestamp
ai_star_ratinginteger1–5 quality score from the AI ensemble
device_infoobject{device_model, device_os, app_version}
worker_regionstring | nullDerived from GPS, ISO-3166 style (e.g., US-NY)
download_urlstringPre-signed S3 URL, valid 7 days
download_url_expires_atstringISO 8601 expiration
annotationsobject | nullAuto-generated labels — see Auto-Labeling
keyframesarray | nullVideo keyframe images (see below)
created_atstringISO 8601 creation timestamp

List Files for a Job

GET /v1/jobs/{job_id}/files

Returns approved files (ai_star_rating ≥ min_score) for the given job, paginated. This is also documented on the Jobs page — the endpoint is the same; the canonical schema lives here.

Headers:

  • Authorization: Bearer fh_live_... (required; jobs:read scope)

Query Parameters:

ParamTypeDefaultDescription
min_scoreinteger3Minimum AI star rating (1–5)
limitinteger20Max results (1–100)
cursorstringPagination cursor

Response: 200 OK — cursor-paginated list of File objects.

curl "https://api.firsthandapi.com/v1/jobs/job_01JQ.../files?min_score=4&limit=50" \
  -H "Authorization: Bearer fh_live_..."
const page = await client.getJobFiles('job_01JQ...', { min_score: 4, limit: 50 });
for (const file of page.data) {
  console.log(file.id, file.ai_star_rating, file.download_url);
}
page = client.get_job_files('job_01JQ...', min_score=4, limit=50)
for file in page['data']:
    print(file['id'], file['ai_star_rating'], file['download_url'])

See Pagination for cursor semantics.

Refresh a Download URL

POST /v1/files/{file_id}/refresh_url

Pre-signed download URLs expire 7 days after they’re generated. If you need to download a file after that window — or if your downstream pipeline stored a reference and lost the URL — call this endpoint to get a fresh URL without re-listing the whole job.

Headers:

  • Authorization: Bearer fh_live_... (required; jobs:read scope)
  • Idempotency-Key: <unique-key> (recommended — the operation is idempotent on the server; this header prevents client-side double-charges against rate limits)

Response: 200 OK

{
  "id": "file_01JQ...",
  "download_url": "https://files.firsthandapi.com/...",
  "download_url_expires_at": "2026-03-23T14:30:00Z"
}

Errors:

StatusTypeWhen
401authentication_errorMissing or invalid API key
403authorization_errorKey lacks jobs:read scope
404not_foundFile doesn’t exist or belongs to a different organization
curl -X POST https://api.firsthandapi.com/v1/files/file_01JQ.../refresh_url \
  -H "Authorization: Bearer fh_live_..." \
  -H "Idempotency-Key: refresh-file_01JQ-1"
const { download_url, download_url_expires_at } = await client.refreshFileUrl('file_01JQ...');

Video Keyframes

For video files, the API extracts 3 representative keyframe PNGs at roughly 25%, 50%, and 75% of duration. They appear on the file response under keyframes:

"keyframes": [
  {
    "index": 0,
    "timestamp_seconds": 3.75,
    "width": 1920,
    "height": 1080,
    "download_url": "https://files.firsthandapi.com/...",
    "download_url_expires_at": "2026-03-23T14:30:00Z"
  },
  { "index": 1, ... },
  { "index": 2, ... }
]

Use keyframes for video thumbnails, training pairs (video → keyframe for retrieval), or preview UIs without downloading the full video.

Keyframe URLs expire on the same 7-day window. Re-list GET /v1/jobs/:id/files to refresh all URLs at once, or call POST /v1/files/:id/refresh_url to refresh the primary file URL (keyframes are not individually refreshable — fetch them via the parent file relisting).

Pattern 1: Immediate download

For small jobs or one-shot integrations, list and stream files immediately after the job.completed webhook fires:

app.post('/webhooks/firsthand', async (req, res) => {
  const event = req.body;
  if (event.type === 'job.completed') {
    const page = await client.getJobFiles(event.data.job_id, { limit: 100 });
    await Promise.all(page.data.map(f => pipeline(
      got.stream(f.download_url),
      fs.createWriteStream(`./out/${f.id}`)
    )));
  }
  res.status(200).end();
});

Pattern 2: Defer and refresh

For large datasets, store file.id + content_hash in your database and defer the download. When you need the bytes, call POST /v1/files/:id/refresh_url to get a fresh URL.

async function downloadWhenNeeded(fileId: string) {
  const { download_url } = await client.refreshFileUrl(fileId);
  return await got.stream(download_url);
}

This avoids storing URLs that may expire and keeps your DB small.

Pattern 3: Batch + resume

For very large jobs, paginate through getJobFiles with a durable cursor. Persist next_cursor after each batch so you can resume on failure:

let cursor = (await db.get('cursor', jobId)) ?? undefined;
do {
  const page = await client.getJobFiles(jobId, { cursor, limit: 100 });
  await processBatch(page.data);
  cursor = page.next_cursor ?? undefined;
  await db.set('cursor', jobId, cursor);
} while (cursor);

Integrity Verification

Every file response includes content_hash — a SHA-256 of the file bytes. Verify after download:

sha256sum ./downloaded.jpg
# Compare to file.content_hash

Mismatches indicate corruption in transit or storage tampering (extremely rare); retry the download.

Size and Format Limits

CategoryMax SizeFormats
Image25 MBimage/jpeg, image/png, image/webp, image/gif
Audio50 MBaudio/mpeg, audio/wav, audio/x-m4a
Video250 MBvideo/mp4, video/quicktime

Files exceeding these sizes are rejected at upload time and never become File objects.