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 │                                                
  └───────────┘                                                
StatusDescriptionCan cancel?
openJob posted, accepting new workersYes
filledAll submission slots taken (pending scoring). Reverts to open if submissions are rejected.Yes
in_progressAt least one file approved; may still be accepting more submissionsYes
completedAll files_needed have been approvedNo
cancelledBuyer cancelled; unused escrow refundedNo

The filledopen 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

ParameterMinMaxDefaultNotes
files_needed110,000Required
files_per_submission151Multi-file batches
price_per_file_cents1050,000$0.10 – $500.00 per file
scoring_criteria1,000 charsCustom AI rubric
description10 chars5,000 charsRequired

Supported MIME types for accepted_formats:

CategoryFormats
Imageimage/jpeg, image/png, image/webp, image/gif
Audioaudio/mpeg (MP3), audio/wav, audio/x-m4a (M4A)
Videovideo/mp4, video/quicktime (MOV)

File Object Schema

Every file returned by the API uses these canonical field names:

FieldTypeDescription
idstringFile ID (file_01JQ...)
download_urlstringPre-signed S3 URL (valid 7 days)
download_url_expires_atstringISO 8601 expiration timestamp
original_filenamestringOriginal filename from the worker’s device
content_typestringMIME type (e.g., image/jpeg)
size_bytesintegerFile size in bytes
ai_star_ratingintegerAI quality score (1-5)
annotationsobject | nullAuto-generated labels (see guide)
content_hashstringSHA-256 hash of file contents
captured_atstringISO 8601 timestamp from device
device_infoobject{device_model, device_os, app_version}
worker_regionstring | nullDerived from GPS (e.g., US-NY)
keyframesarray | nullVideo keyframe images with timestamps and dimensions (see below)

Keyframe Object

Each keyframe in the keyframes array contains:

FieldTypeDescription
indexintegerKeyframe index (0, 1, 2)
timestamp_secondsnumber | nullExtraction time in the video (e.g., 1.5 for frame at 10% of a 15s video)
widthinteger | nullFrame width in pixels (matches video resolution)
heightinteger | nullFrame height in pixels
download_urlstringPre-signed URL for the keyframe PNG (valid 7 days)
download_url_expires_atstringISO 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/jobs

Post a new content collection job.

Headers:

  • Authorization: Bearer fh_live_... (required)
  • Idempotency-Key: <unique-key> (required)
  • Content-Type: application/json

Request Body:

FieldTypeRequiredDescription
typestringyesOne of: data_collection, feedback
descriptionstringyesDetailed instructions for workers
files_neededintegeryesNumber of files requested
files_per_submissionintegernoFiles per worker submission (1-5, default: 1). Use >1 for multi-file batches.
scoring_criteriastringnoCustom quality rubric (max 1000 chars). Injected into AI scoring as strict requirements.
min_duration_secondsintegernoMinimum audio/video duration in seconds (1-600). Shown to workers on capture screen.
max_duration_secondsintegernoMaximum audio/video duration in seconds (1-600).
min_widthintegernoMinimum image/video width in pixels. Submissions below this are auto-rejected before AI scoring.
min_heightintegernoMinimum image/video height in pixels. Submissions below this are auto-rejected before AI scoring.
worker_guidelinesstringnoStructured instructions shown to workers separately from the description (max 2000 chars). Use for constraints like “DO NOT speak”, “Hold phone horizontally”, “Minimum 720p”.
accepted_formatsstring[]yesAccepted MIME types (e.g., ["image/jpeg", "image/png"])
price_per_file_centsintegeryesPrice per approved file in cents
locationobjectnoGeographic constraint. Text-based: { city, state }. Coordinate-based: { latitude, longitude, radius_km }. Or both.
metadataobjectnoArbitrary 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-here

Example:

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/jobs

Query Parameters:

ParamTypeDescription
statusstringFilter by status: open, in_progress, completed, cancelled
typestringFilter by type: data_collection, feedback
limitintegerMax results (1-100, default 20)
cursorstringPagination 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}/cancel

Cancel 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-Key on an already-cancelled job → returns 409 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:

StatusTypeWhen
400validation_errorMissing Idempotency-Key header
404not_foundJob doesn’t exist or belongs to a different organization
409conflictJob already completed or cancelled

Get Job Files

GET /v1/jobs/{job_id}/files

List 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:

ParamTypeDescription
min_scoreintegerMinimum AI star rating (1-5, default 3)
limitintegerMax results (1-100, default 20)
cursorstringPagination 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}/submissions

List all submissions for a job, including approved, rejected, and pending submissions.

Query Parameters:

ParamTypeDescription
statusstringFilter: pending_score, approved, rejected, retry_pending
limitintegerMax results (1-100, default 20)
cursorstringPagination 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}/rate

Rate 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:

FieldTypeRequiredDescription
ratingintegeryes1-5 star rating
feedbackstringnoText 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"
}