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:
| Method | Path | Purpose |
|---|---|---|
GET | /v1/jobs/{job_id}/files | List approved files for a job |
POST | /v1/files/{file_id}/refresh_url | Refresh an expired download URL for a single file |
File Object
| Field | Type | Description |
|---|---|---|
object | "file" | Resource discriminator |
id | string | File ID (file_01JQ...) |
job_id | string | Parent job ID |
submission_id | string | Submission that produced this file |
content_type | string | MIME type (e.g., image/jpeg) |
size_bytes | integer | File size in bytes |
duration_seconds | number | null | Audio/video duration via ffprobe |
width / height | integer | null | Pixel dimensions (image/video) |
content_hash | string | SHA-256 of file bytes — for dedup + integrity |
captured_at | string | null | Device EXIF capture timestamp |
ai_star_rating | integer | 1–5 quality score from the AI ensemble |
device_info | object | {device_model, device_os, app_version} |
worker_region | string | null | Derived from GPS, ISO-3166 style (e.g., US-NY) |
download_url | string | Pre-signed S3 URL, valid 7 days |
download_url_expires_at | string | ISO 8601 expiration |
annotations | object | null | Auto-generated labels — see Auto-Labeling |
keyframes | array | null | Video keyframe images (see below) |
created_at | string | ISO 8601 creation timestamp |
List Files for a Job
GET /v1/jobs/{job_id}/filesReturns 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:readscope)
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
min_score | integer | 3 | Minimum AI star rating (1–5) |
limit | integer | 20 | Max results (1–100) |
cursor | string | — | Pagination 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_urlPre-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:readscope)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:
| Status | Type | When |
|---|---|---|
401 | authentication_error | Missing or invalid API key |
403 | authorization_error | Key lacks jobs:read scope |
404 | not_found | File 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).
Recommended Download Patterns
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_hashMismatches indicate corruption in transit or storage tampering (extremely rare); retry the download.
Size and Format Limits
| Category | Max Size | Formats |
|---|---|---|
| Image | 25 MB | image/jpeg, image/png, image/webp, image/gif |
| Audio | 50 MB | audio/mpeg, audio/wav, audio/x-m4a |
| Video | 250 MB | video/mp4, video/quicktime |
Files exceeding these sizes are rejected at upload time and never become File objects.