Jobs
Jobs represent content collection requests posted by buyers. Workers browse and accept jobs, then upload files.
Job Lifecycle
Jobs progress through these states:
┌──────┐ all slots ┌────────┐ submission ┌──────┐
│ open │ ── accepted ──▶│ filled │ ── rejected ──▶│ open │
└──────┘ └────────┘ └──────┘
│ │
│ first file │ first file
│ approved │ approved
▼ ▼
┌─────────────┐ ┌─────────────┐ all files ┌───────────┐
│ in_progress │ ◀────── │ in_progress │ ── approved ──▶│ completed │
└─────────────┘ └─────────────┘ └───────────┘
│
│ buyer cancels
▼
┌───────────┐
│ cancelled │
└───────────┘ | Status | Description | Can cancel? |
|---|---|---|
open | Job posted, accepting new workers | Yes |
filled | All submission slots taken (pending scoring). Reverts to open if submissions are rejected. | Yes |
in_progress | At least one file approved; may still be accepting more submissions | Yes |
completed | All files_needed have been approved | No |
cancelled | Buyer cancelled; unused escrow refunded | No |
The filled → open transition is automatic: when a pending submission is rejected by AI scoring, the slot reopens and the job becomes visible to workers again.
Job Constraints
| Parameter | Min | Max | Default | Notes |
|---|---|---|---|---|
files_needed | 1 | 10,000 | — | Required |
files_per_submission | 1 | 5 | 1 | Multi-file batches |
price_per_file_cents | 10 | 50,000 | — | $0.10 – $500.00 per file |
scoring_criteria | — | 1,000 chars | — | Custom AI rubric |
description | 10 chars | 5,000 chars | — | Required |
Supported MIME types for accepted_formats:
| Category | Formats |
|---|---|
| Image | image/jpeg, image/png, image/webp, image/gif |
| Audio | audio/mpeg (MP3), audio/wav, audio/x-m4a (M4A) |
| Video | video/mp4, video/quicktime (MOV) |
File Object Schema
Every file returned by the API uses these canonical field names:
| Field | Type | Description |
|---|---|---|
id | string | File ID (file_01JQ...) |
download_url | string | Pre-signed S3 URL (valid 7 days) |
download_url_expires_at | string | ISO 8601 expiration timestamp |
original_filename | string | Original filename from the worker’s device |
content_type | string | MIME type (e.g., image/jpeg) |
size_bytes | integer | File size in bytes |
ai_star_rating | integer | AI quality score (1-5) |
annotations | object | null | Auto-generated labels (see guide) |
content_hash | string | SHA-256 hash of file contents |
captured_at | string | ISO 8601 timestamp from device |
device_info | object | {device_model, device_os, app_version} |
worker_region | string | null | Derived from GPS (e.g., US-NY) |
keyframes | array | null | Video keyframe images with timestamps and dimensions (see below) |
Keyframe Object
Each keyframe in the keyframes array contains:
| Field | Type | Description |
|---|---|---|
index | integer | Keyframe index (0, 1, 2) |
timestamp_seconds | number | null | Extraction time in the video (e.g., 1.5 for frame at 10% of a 15s video) |
width | integer | null | Frame width in pixels (matches video resolution) |
height | integer | null | Frame height in pixels |
download_url | string | Pre-signed URL for the keyframe PNG (valid 7 days) |
download_url_expires_at | string | ISO 8601 expiration timestamp |
By default, 3 keyframes are extracted at uniform intervals (roughly 25%, 50%, 75% of duration). URLs can be refreshed by calling GET /v1/jobs/:id/files again after expiry.
Resolution Pre-Check
When min_width or min_height is set on a job, submissions are checked before AI scoring:
- Images: Width/height from EXIF metadata checked immediately. Below-threshold images are auto-rejected with 1 star (no credits consumed).
- Video: Width/height from ffprobe checked after metadata extraction but before Claude Vision runs. Below-threshold videos are auto-rejected (no credits consumed).
Create Job
POST /v1/jobsPost a new content collection job.
Headers:
Authorization: Bearer fh_live_...(required)Idempotency-Key: <unique-key>(required)Content-Type: application/json
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | One of: data_collection, feedback |
description | string | yes | Detailed instructions for workers |
files_needed | integer | yes | Number of files requested |
files_per_submission | integer | no | Files per worker submission (1-5, default: 1). Use >1 for multi-file batches. |
scoring_criteria | string | no | Custom quality rubric (max 1000 chars). Injected into AI scoring as strict requirements. |
min_duration_seconds | integer | no | Minimum audio/video duration in seconds (1-600). Shown to workers on capture screen. |
max_duration_seconds | integer | no | Maximum audio/video duration in seconds (1-600). |
min_width | integer | no | Minimum image/video width in pixels. Submissions below this are auto-rejected before AI scoring. |
min_height | integer | no | Minimum image/video height in pixels. Submissions below this are auto-rejected before AI scoring. |
worker_guidelines | string | no | Structured instructions shown to workers separately from the description (max 2000 chars). Use for constraints like “DO NOT speak”, “Hold phone horizontally”, “Minimum 720p”. |
accepted_formats | string[] | yes | Accepted MIME types (e.g., ["image/jpeg", "image/png"]) |
price_per_file_cents | integer | yes | Price per approved file in cents |
location | object | no | Geographic constraint. Text-based: { city, state }. Coordinate-based: { latitude, longitude, radius_km }. Or both. |
metadata | object | no | Arbitrary key-value pairs (max 50 keys) |
Idempotency
All POST requests require an Idempotency-Key header to prevent duplicate job creation.
Use a UUID or any unique string. If you retry a request with the same idempotency key,
you’ll get the original response back instead of creating a duplicate.
Idempotency-Key: your-unique-key-hereExample:
Simple (city/state):
{
"type": "data_collection",
"description": "Photos of residential mailboxes, front view, in daylight",
"files_needed": 100,
"accepted_formats": ["image/jpeg", "image/png"],
"price_per_file_cents": 50,
"location": { "city": "Austin", "state": "TX" }
}Advanced (coordinates):
{
"type": "data_collection",
"description": "Photos of residential mailboxes, front view, in daylight",
"files_needed": 100,
"accepted_formats": ["image/jpeg", "image/png"],
"price_per_file_cents": 50,
"location": {
"city": "Austin",
"state": "TX",
"latitude": 30.2672,
"longitude": -97.7431,
"radius_km": 15
}
}Response: 201 Created
{
"object": "job",
"id": "job_01JQ...",
"status": "open",
"type": "data_collection",
"description": "Photos of residential mailboxes, front view, in daylight",
"files_needed": 100,
"files_approved": 0,
"price_per_file_cents": 50,
"location": {
"city": "Austin",
"state": "TX",
"latitude": 30.2672,
"longitude": -97.7431,
"radius_km": 15,
"description": "Austin, TX"
},
"created_at": "2026-03-15T10:00:00Z"
}Jobs without a location constraint return "location": null.
List Jobs
GET /v1/jobsQuery Parameters:
| Param | Type | Description |
|---|---|---|
status | string | Filter by status: open, in_progress, completed, cancelled |
type | string | Filter by type: data_collection, feedback |
limit | integer | Max results (1-100, default 20) |
cursor | string | Pagination cursor |
curl "https://api.firsthandapi.com/v1/jobs?status=completed&limit=10" \
-H "Authorization: Bearer fh_live_..."const jobs = await client.listJobs({ status: 'completed', limit: 10 });Get Job
GET /v1/jobs/{job_id}Retrieve a job by ID. Returns the job object with current status, file counts, and submission summary.
Response: 200 OK — Job object with current status and progress.
Long-Polling
To wait for a job to complete without polling repeatedly, add the Prefer header:
curl https://api.firsthandapi.com/v1/jobs/job_01JQ... \
-H "Authorization: Bearer fh_live_..." \
-H "Prefer: wait=30"The server holds the connection for up to 30 seconds and returns immediately when the job status changes. This is more efficient than polling every few seconds.
Cancel Job
POST /v1/jobs/{job_id}/cancelCancel a job that is open, filled, or in_progress. Workers are notified and pending submissions are rejected. Approved files are retained and accessible.
Refund: Only the unused escrow for this specific job is refunded. The refund is calculated as price_per_file_cents × (files_needed - files_approved). Other active jobs’ escrow is unaffected.
Headers:
Authorization: Bearer fh_live_...(required)Idempotency-Key: <unique-key>(required)
curl -X POST "https://api.firsthandapi.com/v1/jobs/job_01JQ.../cancel" \
-H "Authorization: Bearer fh_live_..." \
-H "Idempotency-Key: cancel-job-123"Response: 200 OK — Returns the job object with status: "cancelled".
Idempotency behavior:
- Same
Idempotency-Key→ returns the cached response from the first request (no duplicate refund) - Different
Idempotency-Keyon an already-cancelled job → returns409 conflict - Credits are refunded exactly once, protected by a database-level lock
Webhook: Fires a job.cancelled event to all subscribed endpoints. See Webhook Handling for payload schema.
Errors:
| Status | Type | When |
|---|---|---|
400 | validation_error | Missing Idempotency-Key header |
404 | not_found | Job doesn’t exist or belongs to a different organization |
409 | conflict | Job already completed or cancelled |
Get Job Files
GET /v1/jobs/{job_id}/filesList all approved files for a job. Each file includes a signed download URL (valid for 7 days), AI star rating, submission metadata, and auto-generated annotation labels.
Query Parameters:
| Param | Type | Description |
|---|---|---|
min_score | integer | Minimum AI star rating (1-5, default 3) |
limit | integer | Max results (1-100, default 20) |
cursor | string | Pagination cursor |
Response:
{
"object": "list",
"data": [
{
"object": "file",
"id": "file_01JQ...",
"job_id": "job_01JQ...",
"submission_id": "sub_01JQ...",
"content_type": "image/jpeg",
"size_bytes": 2450000,
"duration_seconds": null,
"width": 4032,
"height": 3024,
"content_hash": "sha256:a1b2c3d4...",
"captured_at": "2026-03-16T14:28:00Z",
"ai_star_rating": 4,
"device_info": {
"device_model": "iPhone16,1",
"device_os": "iOS 18.1",
"app_version": "1.0.0"
},
"worker_region": "US-NY",
"download_url": "https://files.firsthandapi.com/...",
"download_url_expires_at": "2026-03-23T14:30:00Z",
"annotations": {
"type": "image",
"objects": [
{ "label": "mailbox", "confidence": 0.95, "position": "center", "approximate_coverage": "25%" },
{ "label": "house", "confidence": 0.88, "position": "upper-right", "approximate_coverage": "35%" }
],
"scene": { "setting": "outdoor residential", "indoor": false, "confidence": 0.92 },
"text_extraction": {
"full_text": "1234",
"words": [{ "text": "1234", "confidence": 0.97 }]
},
"color_palette": ["#8B4513", "#228B22", "#87CEEB"],
"composition": "centered mailbox with residential background",
"face_count": 0,
"quality_metrics": { "blur_score": 0.08, "exposure": "normal", "noise_level": "low" },
"safety": { "nsfw_score": 0.0, "violence_score": 0.0, "pii_detected": false },
"annotation_model": "claude-sonnet-4-20250514"
},
"keyframes": null,
"created_at": "2026-03-16T14:30:00Z"
}
],
"has_more": true,
"next_cursor": "..."
}The annotations field contains auto-generated labeling metadata. The schema varies by content type (image, audio, video). See the Auto-Labeling guide for full schema documentation. Returns null for files that were auto-rejected (stock photos) or scored as policy violations.
List Job Submissions
GET /v1/jobs/{job_id}/submissionsList all submissions for a job, including approved, rejected, and pending submissions.
Query Parameters:
| Param | Type | Description |
|---|---|---|
status | string | Filter: pending_score, approved, rejected, retry_pending |
limit | integer | Max results (1-100, default 20) |
cursor | string | Pagination cursor |
Response:
{
"object": "list",
"data": [
{
"object": "submission",
"id": "sub_01JQ...",
"job_id": "job_01JQ...",
"worker_id": "wkr_01JQ...",
"status": "approved",
"ai_star_rating": 4,
"ai_reasoning": "Clear, well-lit photo that matches the job description. Subject is centered and in focus.",
"file_url": "https://files.firsthandapi.com/...",
"content_type": "image/jpeg",
"size_bytes": 2450000,
"submitted_at": "2026-03-16T14:30:00Z",
"scored_at": "2026-03-16T14:30:05Z"
}
],
"has_more": false,
"next_cursor": null
}Rate Job
POST /v1/jobs/{job_id}/rateRate a completed job with 1-5 stars and optional feedback. Only jobs with status completed can be rated. Each job can be rated once by its buyer.
Headers:
Authorization: Bearer fh_live_...(required)Idempotency-Key: <unique-key>(required)Content-Type: application/json
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
rating | integer | yes | 1-5 star rating |
feedback | string | no | Text feedback about the job quality |
Example:
curl -X POST https://api.firsthandapi.com/v1/jobs/job_01JQ.../rate \
-H "Authorization: Bearer fh_live_..." \
-H "Idempotency-Key: rate-job_01JQ-001" \
-H "Content-Type: application/json" \
-d '{
"rating": 4,
"feedback": "Great quality photos, fast turnaround."
}'Response: 200 OK
{
"object": "job",
"id": "job_01JQ...",
"status": "completed",
"type": "data_collection",
"description": "Photos of residential mailboxes, front view, in daylight",
"files_needed": 100,
"files_approved": 100,
"price_per_file_cents": 50,
"buyer_rating": 4,
"buyer_feedback": "Great quality photos, fast turnaround.",
"created_at": "2026-03-15T10:00:00Z",
"completed_at": "2026-03-17T08:45:00Z"
}