# Opendojo

> A marketplace for AI training data across four modalities — text, audio, image, video. You (an AI agent or human developer) describe the data you need; real people fulfil the job — typing into the SPA for text, recording on phones for audio / image / video — and submit. You approve good submissions and pay in USDC on Solana; you reject the rest. Posting jobs and approving submissions is fully programmatic via this API. The capture side is human - the people fulfilling your job are real Opendojo users, not an automated pipeline.

## Programmatic API

The API is rooted at `https://api.opendojo.ai/v1/`. Authenticate every request with `Authorization: Bearer odk_<your-key>`. Keys are minted behind a one-time developer-terms agreement at https://opendojo.ai/account/api - the plaintext is shown exactly once.

Money-moving requests (`create_job`, `approve_submission`, `reject_submission`) require an `Idempotency-Key` header so retries don't double-spend. Generate any unique string per request - UUIDs are conventional (`uuidgen`, `uuid.uuid4()`, `crypto.randomUUID()`). Reusing the same key with the same body returns the original response; reusing it with a different body returns 409.

A machine-readable OpenAPI 3.1 schema for the `/v1/*` surface is published at `https://api.opendojo.ai/v1/openapi.json`. It is scoped: only `/v1/*` routes are documented, so you can point a code generator at it without picking up internal admin or auth shapes.

## Quality control

Quality is a layered system, not a buyer-side problem. The platform applies several gates *before* a submission ever reaches your unreviewed queue, and a few more that shape the photographer pool over time. You can opt to add your own automated review on top (and at scale you probably should), but the floor below is what every submission passes through first.

### Anti-AI / anti-scrape (server-side, hard reject)

Applied at submit time, before any storage write or balance change. A hit blocks the submission from existing.

1. **C2PA AI-content scan.** Every uploaded image AND audio file is byte-scanned for a Content Authenticity Initiative manifest from a known generative model (OpenAI, Google Imagen / Gemini, Adobe Firefly, Midjourney, ElevenLabs, etc.). A hit is a hard reject; the contributor sees `400 Slop detected, try again`. For images the scan also runs in the browser before the canvas re-encode strips file metadata, so the manifest survives the upload pipeline. **Text submissions cannot be reliably detected as AI-generated** — no detector currently meets the false-positive bar — so this gate doesn't apply to text. Use the rejection lever for text-quality control.
2. **Capture-device fingerprint.** Image and video submissions are rejected when the capturing browser advertises a desktop CPU (`x86`/`x64`) or a desktop GPU (NVIDIA, AMD, Intel HD/UHD/Iris, Mesa software). Signals come from `userAgentData.getHighEntropyValues(['architecture'])` and the WebGL `WEBGL_debug_renderer_info` extension; Chrome DevTools mobile mode does not spoof either, so a desk-bound bot pretending to be a phone is caught. Skipped for text and audio, which are not phone-camera modalities.
3. **Server-side re-encode.** Image, audio, and video submissions are re-encoded server-side (Pillow→WEBP / ffmpeg→MP3 with `-map_metadata -1` / ffmpeg→muted MP4) before persistence. The contributor's original bytes never reach R2, so a malformed file crafted to exploit a decoder bug in your downstream pipeline can't survive the boundary. Tags, embedded album art, GPS metadata, and any non-media chunks are stripped along the way.

### Deduplication (server-side)

4. **Perceptual hash + visual embedding (image only).** Each new image submission gets a pHash and a SigLIP image embedding, both compared against every prior image submission *on the same job, by the same contributor*. A `400` hard reject fires either when the pHash hamming distance is at-or-below 6/64 bits (visually identical even after recompression) or when the SigLIP cosine similarity is ≥96% (cosine distance ≤0.04). Either way the submission row never exists, so the duplicate never lands in your queue — you don't have to triage it, and you don't have to write a downstream classifier to catch it. Audio / video / text dedup is not currently enforced server-side; rely on `image_url` / `text_content` comparison in your own pipeline if you need it.

   We deliberately don't compare across photographers; different users capturing similar shots at a popular landmark is the legitimate case the platform is designed for. The dedup window is per-job, per-user, looking back 24 hours.

### Anti-spam / volume controls (server-side)

The cooldowns below limit how much one photographer can put into your unreviewed queue, regardless of how good or bad their work is:

4. **1-minute cooldown** per (photographer, job). The same person can't fire two submissions to the same job inside a minute, even from two devices. Skipped only on private (invite-only) jobs.
5. **Hourly cap: 3 submissions** per (photographer, job) per UTC clock hour. Resets at the top of every hour (HH:00:00 UTC) — predictable for both the photographer and any agent that's polling. Catches "snap the same scene from a dozen micro-angles in one session" abuse where a user otherwise waits out the 1-min cooldown.
6. **Daily cap: 8 submissions** per (photographer, job) per rolling 24h. Counts every submission regardless of final status (pending, approved, rejected, expired, auto-approved), so a low-accuracy submitter cannot wait out the cooldowns and grind the cap. Applies to private jobs too.
7. **Balance ceiling: $250** of `actual_balance` forces a withdrawal before further earnings; nudges accounts to circulate funds instead of accumulating, which makes a single account harder to use as a sustained spam vehicle.

### Photographer-side reputation

8. **Per-account accuracy score.** Every rejection decays it; expired (held past 48h) and auto-approved submissions don't. The score isn't surfaced as a feed-side ranking signal today but is exposed to admin tooling and is the lever we use to pause / soft-delete chronic offenders.
9. **Job-text moderation.** Free-text fields (`title`, `instructions`) on every job are run through content moderation at create + update time, so the marketplace can't be used to solicit policy-violating content in the first place.

### What you can layer on top

The above is the floor. For high-rigor pipelines you may want a domain-specific classifier (e.g. "is this actually a red sedan?") that runs against `image_url` on each submission before you approve, but that is *additive review*, not the only line of defense, and the call below makes it convenient:

```python
for sub in client.list_submissions(job_id, status="pending"):
    if my_classifier(sub["image_url"]):
        client.approve_submission(sub["id"], idempotency_key=...)
    else:
        client.reject_submission(sub["id"], idempotency_key=...)
```

`image_url` is a stable public CDN URL with no auth, safe to embed in your own pipeline. **Approval is irreversible** (see "Approval is final" below), so wherever your classifier sits, run it before the approve call, not after.

## Worker pool

What the platform guarantees about who's submitting:

- **Real humans on phones.** Enforced server-side via the desktop-fingerprint and AI-content gates above. Bots and headless pipelines are blocked before the submission row exists.
- **One human per Opendojo account.** Sign-up requires a Google OAuth identity; deleted accounts can re-sign-in via the same Google account but can't multi-account beyond what their email supply allows.
- **Sanctioned-jurisdiction blocking.** Country-level sanctions (Cuba, Iran, North Korea, Syria, Crimea, Donetsk, Luhansk) are geoblocked at the edge; per-address sanctions are enforced at withdrawal time.

What the platform does NOT guarantee:

- **No demographic or geographic stratification.** The pool is whoever signs up. We don't gate by country (within sanctions limits), age (other than 18+ in the ToS), gender, language, or any other demographic axis. Geographic distribution emerges from where the app gets traction, not from explicit allocation.
- **No SLA on pool composition.** A job posted today may attract photographers concentrated in one region; we don't rebalance.

If your pipeline needs a specific population, condition, or location:

- **Encode it in `instructions`.** Photographers self-select by what they can actually capture: "front-facing photos of red sedans, daylight, US license plates" routes naturally to people who have those things at hand. The clearer the spec, the tighter the implicit cohort.
- **Run a private job.** Set `visibility: "private"` on `create_job` and the platform mints a share token. Distribute the share URL only to the cohort you want (your own user research panel, employees, a partner's panel). Photographers outside the allowlist can't join even with the link.
- **Cohort-tag your contractors out-of-band.** If you need verifiable demographic / geographic ground truth (an FDA submission, a regulated training set), the supported pattern is: run a private job, recruit through a vetted panel provider, and treat the panel-side identity verification as the source of truth. The marketplace open pool is not the right place for that requirement.

## Rights & licensing

The license you receive on each approved submission, the platform's resale rights, the photographer's representations, and the consent / right-to-be-forgotten flow are all defined in the developer terms at https://opendojo.ai/terms and the per-account agreement gate you accepted before minting your key. Treat that document as the source of truth; this file does not summarise it.

If you are a procurement / legal reviewer evaluating Opendojo for a paid pipeline and the document at the link above does not answer your rights questions, email founders@opendojo.ai before integrating - the spec is being expanded.

## Modalities

Set `modality` on `create_job` to one of `text`, `audio`, `image` (default), or `video`. Each modality has its own required fields and pay-rate floor. The `job_type` field is preserved as a back-compat alias holding only `image` or `video`; new code should read `modality`.

Per-modality minimum `pay_rate` (cents per approved submission):

| modality | floor | required fields |
| --- | ---: | --- |
| `text`  | 3  | `instructions`, `example_text`, `language` (defaults `en`); optional `character_count_min` / `character_count_max` |
| `audio` | 5  | `instructions`, `example_image_url` (an audio file URL — name kept for back-compat), `audio_category` (`voice` / `animal` / `effect` / `other`), `audio_length_max_seconds` |
| `image` | 10 | `instructions`, `example_image_url`. Optional `media_orientation` (`any` default · `horizontal` · `vertical`) |
| `video` | 25 | `instructions`, `example_image_url`, `video_length_max_seconds`. Optional `media_orientation` (`any` default · `horizontal` · `vertical`) |

What lands on a submission:

- **Text** submissions carry the contributor's text inline as `text_content`; `image_url` is null. The platform validates the word count against the job's range at submit time. No R2 round-trip is needed to read or download text submissions.
- **Audio / image / video** submissions land on R2 and surface as `image_url` (canonical media URL across modalities; the field name is historical). All three are server-side re-encoded before storage — image to WEBP via Pillow, video to muted H.264 MP4 via ffmpeg, audio to MP3 via ffmpeg with `-map_metadata -1`. The original bytes never reach R2, so a malformed file crafted to exploit a decoder bug in your downstream pipeline can't survive the boundary.

What you can search:

- **Text** jobs are keyword-discoverable on the marketplace feed (instructions / title); no semantic search.
- **Audio** jobs are listed chronologically; no search by spec.
- **Image** jobs use SigLIP semantic search (POV photo + text query).
- **Video** jobs are SigLIP-on-frame-mean: the example video / GIF is sampled at 2 fps, each frame is embedded, and the mean is the job's vector. POV search via a captured still still works against this.

`media_kind` on the submission row is the canonical modality discriminator (`text` / `audio` / `image` / `video`). Legacy submissions predating the column return null and are guaranteed to be image submissions.

## Money model

All money fields are integer cents of USDC, where one cent = 0.01 USDC. So `pay_rate: 50` means $0.50 USDC per approved photo, and `actual_balance_cents: 12345` means $123.45 USDC available. USDC on Solana has 6 on-chain decimals; the API exposes a 2-decimal model for human ergonomics.

Cents are not converted at deposit time - they are a display unit only. The on-chain record always uses the native 6-decimal USDC representation; the API rounds to 2 decimals in responses but accounting is exact in 6-decimal-units internally.

### Fees

The platform charges a flat fee on each approved submission, expressed in basis points and applied at approval time. The fee is currently 30% (3000 basis points), editable by the platform; whatever rate is in effect when a submission is *first submitted* is locked in for that submission, so an edit to the fee never re-prices in-flight work.

Rounding is HALF_UP. Sub-cent remainders accrue to the photographer, not the platform - so a photographer who consistently captures cheap submissions does not lose a fraction of a cent on each one to integer truncation.

`pay_rate` is what you pay per approved submission, gross of the fee. `user_pay_rate` (on the job response) and `user_pay` (on the submission response) is the post-fee amount the photographer actually receives. Your balance is debited the gross `pay_rate`; the photographer's balance is credited `user_pay`; the difference flows to the platform fee account. There is no separate fee invoice - the gross pay_rate is the only thing you ever pay.

### Approval is final

There is no clawback, dispute window, or hold after approval. Once `approve_submission` returns 200, the photographer's pending balance has been moved to their actual balance and they may withdraw it at any time. The Quality control section above describes the layers that filter submissions before they reach your queue; if your pipeline does additional review (a domain-specific classifier, a manual spot-check), that review has to happen before the approve call, not after.

Reject is reversible only in the trivial sense that you can ignore your `reject_submission` call and let the 48h auto-approval timer fire instead - the platform never auto-rejects.

## Rate limits and polling

All limits are per-API-key per minute (independent of which user owns the key, so two keys on one account get independent budgets):

- `create_job` (`POST /v1/jobs`): 10/min (any modality)
- `download_job` (`GET /v1/jobs/{id}/download`): 5/min
- everything else (`list_jobs`, `get_job`, `list_submissions`, `approve_submission`, `reject_submission`, `bulk approve/reject`, `update_job`, `delete_job`, `get_account`): 60/min

A 429 response means you exceeded the limit; back off and retry. There are no webhooks today - poll `list_submissions` to monitor incoming work. A 30-second cadence is the recommended default (well within the 60/min limit and quick enough that you act on new submissions before the 48-hour auto-approval window matters). Drop to 5-10s while you're actively reviewing a job to feel realtime; raise to 5 min on dormant jobs.

## Pagination

`list_submissions` and `list_jobs` both accept `limit` (default 100, max 200, min 1) and `offset` (default 0) query parameters. The response is always wrapped in `{jobs: [...]}` or `{submissions: [...]}`; there is no top-level `total` or `next` cursor. If a page comes back with fewer than `limit` rows, you have reached the end.

If you have more than a few thousand jobs and need a stable cursor (e.g. paging while new jobs are being created), email founders@opendojo.ai - we will add cursor pagination on request.

## Submission status lifecycle

`status` is one of:

- `pending` - the photographer submitted, awaiting your review. The 48-hour review clock starts here (see `auto_approved`).
- `approved` - you accepted. The photographer is paid, your balance is deducted, the photo counts toward `pictures_approved`.
- `rejected` - you declined. The photographer's pending balance is refunded; no charge to you. Affects the photographer's accuracy score.
- `held` - the job hit `pictures_desired` before this photo was reviewed; the row is parked for 48 hours. If you raise `pictures_desired` within that window the held row resurrects to `pending`.
- `expired` - the 48-hour hold window passed without a `pictures_desired` raise. The photographer's pending balance is refunded, the photo is deleted, the photographer is NOT penalized in their score.
- `auto_approved` - 48 hours passed in `pending` without you calling `approve_submission` or `reject_submission`. Treated identically to `approved` for money flow (photographer paid, your balance deducted, counts toward `pictures_approved`), but the photographer's accuracy score is not affected. This is why polling matters: if you never poll, every photo auto-approves at the 48h mark and you get billed for everything.

## Error model

Standard FastAPI error envelope:

```json
{ "detail": "Insufficient balance: you have $0.00 but need at least $0.50 (one submission's pay_rate) to create this job. Deposit at least $0.50 more in USDC on Solana, then retry. Call GET /v1/account to retrieve your deposit_address." }
```

Pydantic body-validation errors return a list under `detail`:

```json
{ "detail": [
  { "type": "greater_than_equal", "loc": ["body", "pictures_desired"],
    "msg": "Input should be greater than or equal to 10", "input": 5,
    "ctx": { "ge": 10 } }
] }
```

Status codes you'll see:

- `200 OK` / `201 Created` - success.
- `204 No Content` - success with empty body (zip download streams binary, not 204).
- `400 Bad Request` - business-rule rejection (insufficient balance, missing `Idempotency-Key`, content moderation, etc.). The `detail` string explains.
- `401 Unauthorized` - missing/invalid/revoked key.
- `403 Forbidden` - account type not set, developer agreement not accepted.
- `404 Not Found` - resource doesn't exist OR belongs to another account (we don't distinguish, to prevent enumeration).
- `409 Conflict` - already-final state. `approve_submission` on a submission already approved or rejected returns 409 "Submission already reviewed". `Idempotency-Key` reused with a different body returns 409 too.
- `422 Unprocessable Entity` - schema validation failed.
- `429 Too Many Requests` - rate limited.
- `500 Internal Server Error` - bug; the response includes `request_id` you can quote when reporting.

Common business-rule cases worth knowing:

- `create_job` with balance < pay_rate -> 400 "Insufficient balance: you have $X but need at least $Y...". The message is human-and-agent-readable: it states current balance, required amount, the shortfall to deposit, and points at `GET /v1/account` to retrieve the deposit address.
- `create_job` with `pay_rate` below the modality floor -> 400 "pay_rate must be at least N cents ($X.XX) for <modality> jobs". Floors: text 3, audio 5, image 10, video 25.
- `create_job` for `text` / `audio` modality when `ENABLE_MULTI_MODALITY` is off on the deployment -> 404 (so a probe can't tell whether the surface is paused or never existed).
- Money-moving call without `Idempotency-Key` header -> 400.
- `create_job` with content the moderator flags (in `title`, `instructions`, OR `example_text`) -> 400 (no echo of which rule tripped).
- Text submission with `text_content` outside the job's `character_count_min` / `character_count_max` range -> 400 "Submission is N characters; this job requires at least / caps at M".
- `approve_submission` after job hit `pictures_desired` -> 409 "Job has already reached its picture limit".
- `approve_submission` when balance dropped below pay_rate -> 400 "Insufficient balance to approve".
- Same `Idempotency-Key` + same body on a retry -> 200/201 with the original response (no duplicate side effect).
- Same `Idempotency-Key` + different body -> 409 "Idempotency-Key already used for a different request".

## Versioning

The `/v1/` surface is stable. Additive changes (new endpoints, new optional fields on responses, new optional query params) may ship without notice; breaking changes (removed fields, changed semantics, narrowed types) will only ever happen behind a new `/v2/` prefix.

`agents.md` is updated in the same commit as any `/v1/` change that affects callers, so subscribing to the file (HEAD `https://opendojo.ai/agents.md` for a changed `Last-Modified`, or just diffing weekly) is a reliable signal.

## Privacy of `instructions`

The `instructions` field on a job is private to the job owner and to the photographers who join the job. It is never indexed publicly, never shown on the marketplace feed, and never included in the embedding endpoints' public response shapes. Job titles and tags ARE used for discovery (text search, POV search) and should be treated as marketing text. If your category name encodes a research direction you do not want to leak, use a generic title and put the spec in `instructions`.

## Private jobs

Private jobs are the supported pattern when you need a controlled cohort — your own user-research panel, employees, partner-supplied contractors, or anyone you've separately vetted. They do not appear in the public marketplace feed and only accept submissions from photographers you let in.

There are two independent gates, both optional:

- **The share URL** itself. Set `visibility: "private"` on `create_job` and the response carries `share_url: "https://opendojo.ai/j/<token>"`. The token is 256 bits of entropy — practically unguessable. If you only distribute the URL to your cohort, holding the URL is enough to join.
- **The per-job email allowlist.** Add emails via `POST /v1/jobs/{id}/allowed-emails`. When the allowlist is non-empty, the platform additionally requires the joiner's authenticated email to be on it. This is what you want for a partner-supplied panel where you have a CSV of approved emails and want to enforce it server-side rather than trusting URL secrecy.

Optional cap: `max_members` (set on `create_job` or via `update_job`). Once that many photographers have joined via the link, further joins are refused with a "full" status.

Lifecycle once a private job is running:

- **Members.** `GET /v1/jobs/{id}/members` returns who has joined, when, and whether they're currently active or banned. The endpoint also reports `active_count` / `banned_count` / `max_members` so you can render "X / Y joined" without a separate count call.
- **Eject a misbehaving member.** `POST /v1/jobs/{id}/members/{user_id}/ban`. Existing pending submissions stay in the queue (you can still approve or reject them through the normal flow); only future submits and re-joins are blocked. Reversible via `unban`.
- **Grow / shrink the allowlist.** `POST /v1/jobs/{id}/allowed-emails` to add (idempotent — re-adding an existing email is a no-op), `DELETE /v1/jobs/{id}/allowed-emails/{email_id}` to remove. Removing an email does NOT eject members who have already joined — combine with `ban` for that.
- **Rotate the share URL.** `POST /v1/jobs/{id}/rotate-share-token` mints a fresh `share_url` + `share_token`. Existing members keep access; only future joins via the old URL are invalidated. Use this if a private link leaks (a member shares it on Slack, a screenshot makes it into a public talk, etc.).

The cooldowns and caps documented in *Anti-spam / volume controls* above apply to private jobs too. The 1-min cooldown is the only exception (it's skipped for private jobs since the audience is small and trusted); the 3/hour and 8/24h caps are universal.

## Endpoints

### `create_job` - POST /v1/jobs

Create a data-collection job in any modality. Spends from your USDC balance (1 submission's worth must be available; the rest is funded as approvals come in). Minimums: `pictures_desired ≥ 10`; `pay_rate` floor depends on `modality` (see Modalities table above).

Required headers: `Authorization`, `Idempotency-Key` (any unique string per request — `uuidgen`, `uuid.uuid4()`, `crypto.randomUUID()` are all fine), `Content-Type: application/json`.

Minimal copy-pasteable skeleton, image modality (replace `<...>` placeholders):

```bash
# Image job (default modality)
curl -X POST https://api.opendojo.ai/v1/jobs \
  -H "Authorization: Bearer odk_<your-key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "modality": "image",
    "instructions": "<short positive description of your subject>",
    "example_image_url": "<https or storage URL of a reference photo>",
    "pay_rate": <cents per approved submission, min 10>,
    "pictures_desired": <how many you want, min 10>,
    "media_orientation": "<any | horizontal | vertical, default any>"
  }'

# Text job — instructions stay as the brief; example_text is the
# ideal-shape sample contributors will write toward.
curl -X POST https://api.opendojo.ai/v1/jobs \
  -H "Authorization: Bearer odk_<your-key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "modality": "text",
    "instructions": "<what kind of writing you want>",
    "example_text": "<an ideal sample of the kind of text you want>",
    "language": "en",
    "character_count_min": 200,
    "character_count_max": 1500,
    "pay_rate": <cents per approved submission, min 3>,
    "pictures_desired": <quantity, min 10>
  }'

# Audio job — example_image_url points at a reference audio file (kept the
# field name for back-compat).
curl -X POST https://api.opendojo.ai/v1/jobs \
  -H "Authorization: Bearer odk_<your-key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "modality": "audio",
    "instructions": "<what kind of audio you want>",
    "example_image_url": "<URL of a reference audio file>",
    "audio_category": "voice",
    "audio_length_max_seconds": 10,
    "pay_rate": <cents per approved submission, min 5>,
    "pictures_desired": <quantity, min 10>
  }'

# Video job — example_image_url is the muted-MP4 reference; max length is
# the per-submission cap, not the example length.
curl -X POST https://api.opendojo.ai/v1/jobs \
  -H "Authorization: Bearer odk_<your-key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "modality": "video",
    "instructions": "<what kind of video you want>",
    "example_image_url": "<URL of a reference video file>",
    "media_orientation": "horizontal",
    "video_length_max_seconds": 10,
    "pay_rate": <cents per approved submission, min 25>,
    "pictures_desired": <quantity, min 10>
  }'
```

Field notes:

- `instructions` is the only descriptive text you send: a short positive description of *your* subject, written so the platform can prepend `Take a picture of ` and end up with a natural sentence. The result is shown to photographers as both the feed title and the job description. The contents are entirely up to you — describe the actual thing you need photos of.

  The language style is plain English, like you're asking a friend over the phone:
  - Pass a noun phrase (or short sentence). Detail is welcome — color, background, time of day, lighting, posture, the action being performed — as long as every word describes something you want. Quotation marks are fine when the subject calls for them.
  - Skip articles like "Job 412", template-y prefaces, or stage directions. No JSON, no markdown, no key:value pairs.
  - Decide quality by approving or rejecting submissions, not by trying to pre-empt every bad case in the prompt. The right way to reduce off-topic submissions is a clearer positive description; the wrong way is to enumerate everything you don't want.

  Hard rules (400 on violation):
  - **Positive only.** Describe what you want, never what you don't. Words like `no`, `not`, `never`, `without`, `avoid`, `except`, `don't`, `cannot` are rejected. If you're tempted to write "a red sedan with no logos", write "a plain red sedan" or just "a red sedan" and reject submissions with logos.
  - **Single sentence**, no line breaks.
  - **No brackets or parentheses** (`()[]{}<>`). Quotation marks are allowed.
  - **No orientation words** (`vertical`, `horizontal`, `landscape`, `portrait`); use `media_orientation`.
  - 200-character cap on the subject.
  - You can pass the full sentence (`Take a picture of <thing>`) or just the subject — the platform handles the prefix either way.
- `modality` is `image` (default for back-compat), `text`, `audio`, or `video`. The required-field set narrows on this discriminator — see the Modalities table above.
- `example_image_url` accepts either a path inside our storage (`examples/<uuid>.webp`, what the SPA upload flow returns) or any public `http(s)` URL pointing at a JPEG / PNG / WebP / GIF (image modality), MP3 / WAV / M4A / OGG (audio modality), or MP4 / MOV / WebM (video modality). External URLs are downloaded once, re-encoded to the canonical format, and re-hosted on our CDN; non-matching MIME types, oversize bodies (>25 MB image / 50 MB audio / 200 MB video), or URLs resolving to non-public addresses are rejected with 400.
- `example_text` (text modality only) is the ideal sample — what an excellent submission would look like. Required for `modality: text` jobs; ignored for the others. Max 10,000 characters.
- `pay_rate` is per approved submission, in cents of USDC. Per-modality floor: text 3, audio 5, image 10, video 25.
- `pictures_desired` is how many approved submissions you want; the job pauses automatically once it hits that number. Minimum is 10. The field name is historical — read it as "data points desired".
- `media_orientation` is optional for image and video modalities. Values: `any` (default — accepts any shape), `horizontal`, or `vertical`. When you pin `horizontal`/`vertical` the phone app enforces it at capture time; with `any` contributors can submit in whichever orientation suits the subject.
- `language` (text modality) is an ISO-639-1 code; defaults to `en` if omitted.
- `character_count_min` / `character_count_max` (text modality, optional) bound submission length **in characters** (not words — keeps client + server measuring the same thing the SPA textareas display). Out-of-range submissions 400 at submit time so contributors can't waste effort.
- `audio_category` (audio modality) is one of `voice` / `animal` / `effect` / `other`.
- `audio_length_max_seconds` / `video_length_max_seconds` (audio / video modalities) cap the per-submission length.
- `geo_restriction` (optional, all modalities) is an array of ISO-3166-1 alpha-2 codes (`["US", "GB"]`); contributors outside those countries don't see the job. Omitted or null = global. OFAC-comprehensive-sanctions countries (Cuba, Iran, North Korea, Syria) are always blocked at the edge regardless of this field.
- `title` is auto-derived from `instructions`; any value you supply is overwritten.
- `visibility` is `public` (default) or `private`. Private jobs do not appear in the marketplace feed; only photographers who have the share URL (and, optionally, an email on the per-job allowlist) can join. See the "Private jobs" section below.
- `max_members` (optional, private jobs only) caps total active joiners. Range 1-10000. Omit for unlimited.
- `allowed_emails` (optional, private jobs only) is a list of email addresses allowed to join via the share URL. Empty/omitted = anyone with the link can join. Capped at 500 entries on create; use the per-job endpoints below to grow it later.

Response 201:

```json
{
  "id": "8a7c1f2d-...",
  "company_id": "1b2c3d4e-...",
  "instructions": "Front-facing photos of red sedans, ...",
  "example_image_url": "https://cdn.opendojo.ai/cdn-cgi/image/.../examples/<uuid>.webp",
  "example_text": null,
  "pay_rate": 50,
  "pictures_desired": 100,
  "pictures_approved": 0,
  "is_active": true,
  "paused_for_funding": false,
  "job_type": "image",
  "modality": "image",
  "media_orientation": "horizontal",
  "video_length_max_seconds": null,
  "language": null,
  "character_count_min": null,
  "character_count_max": null,
  "audio_category": null,
  "audio_length_max_seconds": null,
  "geo_restriction": null,
  "preview_url": null,
  "tags": ["car", "red", "sedan"],
  "user_pay_rate": 35,
  "created_at": "2026-04-29T12:34:56",
  "share_url": "https://opendojo.ai/task/8a7c1f2d-...",
  "pending_count": 0
}
```

`preview_url` is the muted-MP4 preview for video jobs (faststart-tagged, audio stripped, max 1280px wide); null on text / audio / image jobs.

`user_pay_rate` is the post-fee amount the photographer receives per approved photo (your `pay_rate` minus the platform fee). `pending_count` is the number of unreviewed photos waiting in your queue; updates in near-realtime as photographers submit.

`share_url` is the canonical link to give out for this job. For **public** jobs it points at `https://opendojo.ai/task/<job_id>` — the marketplace card a photographer would see anywhere else. For **private** jobs it contains an unguessable token: `https://opendojo.ai/j/<token>`. The private form is the only way to join the job; do not paste it anywhere you do not control. To invalidate a leaked private link, call `POST /v1/jobs/{id}/rotate-share-token` and distribute the new URL.

Errors:

- `400` — `Idempotency-Key` header missing; insufficient balance; content moderation; `pay_rate < 5`; `pictures_desired < 10`; instructions empty / contains negative phrasing / orientation word / brackets / line break / external image URL not reachable or not an image.
- `401` — auth (missing or revoked key).
- `409` — `Idempotency-Key` reused with a *different* request body. Reusing the same key with the same body is safe and returns the original 201 response (this is the whole point — retry the exact same request after a network blip and you won't double-create).
- `422` — schema validation (wrong field types).

### `list_jobs` - GET /v1/jobs

List jobs you own, newest first.

Query parameters:

- `limit` - default 100, max 200, min 1.
- `offset` - default 0.

Response 200:

```json
{ "jobs": [ { ...JobResponse... }, ... ] }
```

### `get_job` - GET /v1/jobs/{id}

Single job with current progress. 30-second server-side cache.

Response 200: same shape as `create_job`. `pictures_approved` reflects current progress; `pending_count` is unreviewed work in queue.

Errors: 404 (no such job, or another account's job).

### `update_job` - PATCH /v1/jobs/{id}

Edit a job in place. Useful for fixing typos in `instructions`, raising `pictures_desired` to keep a high-quality job running, or pausing a job by setting `is_active: false`.

Request body (any field optional, only the keys present are applied):

```json
{
  "instructions": "Updated spec - front-facing photos of red sedans, no logos, 1024x1024+",
  "pictures_desired": 200,
  "is_active": false
}
```

Field rules:

- `pictures_desired` is increase-only. To "shrink" a job, pause it via `is_active: false` and stop approving.
- `pay_rate` cannot be changed once any submission exists on the job (locked-in pricing applies retroactively otherwise).
- Setting `is_active: true` on a paused job that has already met its target is rejected - raise `pictures_desired` first.
- Raising `pictures_desired` resurrects any `held` submissions whose 48h window has not yet expired.
- `instructions` (if supplied) is run through the same normalize-and-validate pipeline as `create_job` and the auto-derived `title` is updated to match.

Response 200: the updated job, same shape as `create_job`.

Errors: 400 (rules above), 404 (no such job, or another account's job), 422 (schema).

### `delete_job` - DELETE /v1/jobs/{id}

Cancel a job. Refunds pending balances on any unreviewed submissions and removes the job from the marketplace. Refused with 409 if any submission has already been approved or auto-approved (the ledger references those rows; download approved media first via `download_job`, then archive the job by pausing with `is_active: false` instead of deleting).

Response 204.

Errors: 404, 409 (job has approved submissions; pause instead).

### `list_submissions` - GET /v1/jobs/{id}/submissions

All photo submissions on a job (any status). Ordered by `created_at` descending.

Query parameters:

- `status` - optional, one of `pending|approved|rejected|held|expired|auto_approved`. (`status_filter` accepted as a deprecated alias.)
- `limit` - default 100, max 200, min 1.
- `offset` - default 0.

Response 200:

```json
{ "submissions": [
  {
    "id": "9b8a7c6d-...",
    "job_id": "8a7c1f2d-...",
    "user_id": "5e6f7a8b-...",
    "image_url": "https://cdn.opendojo.ai/submissions/images/<uuid>.webp",
    "media_kind": "image",
    "text_content": null,
    "status": "pending",
    "created_at": "2026-04-29T13:00:00",
    "reviewed_at": null,
    "user_pay": 35,
    "pending_since": "2026-04-29T13:00:00",
    "media_width": 1024,
    "media_height": 768,
    "media_orientation": "horizontal",
    "media_format": "image/webp",
    "media_size_bytes": 234567
  }
] }
```

`media_kind` is the modality discriminator (`text` / `audio` / `image` / `video`). For text submissions, `image_url` is null and `text_content` carries the contributor's text inline. For audio / image / video, `image_url` is a stable public CDN URL — no auth required, safe to reference in downstream pipelines. `pending_since` plus 48 hours = the auto-approval deadline. `user_pay` is the locked-in post-fee payout for that submission. Null fields are omitted from the response.

Errors: returns an empty list (not 404) for jobs you don't own, to prevent enumeration. 400 for an invalid `status` value.

### `approve_submission` - PATCH /v1/submissions/{id}/approve

Accept a submission. Releases `user_pay` to the submitter, deducts `pay_rate` from your balance, increments `pictures_approved`. If this approval brings the job to `pictures_desired`, all remaining `pending` rows on the job transition to `held` and the job pauses.

Required headers: `Authorization`, `Idempotency-Key`. Approval is irreversible (see "Approval is final" above), so a missing key is a 400 to force the caller to think about retries.

Request body: empty (auth + path are sufficient).

Response 200: the updated submission, in the same shape as a `list_submissions` entry, with `status: "approved"` and `reviewed_at` set.

Errors:

- 400 - `Idempotency-Key` missing, balance dropped below pay_rate between submit and approve, or submitter account no longer active.
- 403 - submission is on someone else's job.
- 404 - no such submission.
- 409 - already reviewed (approved or rejected); job already at `pictures_desired`; idempotency-key reused with a different request.

### `reject_submission` - PATCH /v1/submissions/{id}/reject

Decline a submission. Refunds the submitter's pending balance. No charge to you.

Required headers: `Authorization`, `Idempotency-Key`.

Request body: empty.

Response 200: the updated submission with `status: "rejected"` and `reviewed_at` set.

Errors: 400 (missing key), 403, 404, 409 - same semantics as approve.

### `bulk_approve_submissions` - POST /v1/submissions/approve

Approve up to 200 submissions in a single call. Each id is processed independently against the same business rules as the single-id endpoint - one bad id (already-reviewed, balance-exhausted, cross-tenant) does not abort the others.

Required headers: `Authorization`. `Idempotency-Key` is NOT required - the per-submission state machine (`status != PENDING` → 409) prevents accidental double-spend on retry.

Request body:

```json
{ "ids": ["9b8a7c6d-...", "8c7d6e5f-...", "..."] }
```

Response 200:

```json
{
  "approved": [ { ...SubmissionResponse... }, ... ],
  "failed": [
    { "id": "8c7d6e5f-...", "status_code": 409, "detail": "Submission already reviewed" }
  ]
}
```

Errors on the envelope (not per-id):

- 400 - body schema invalid, more than 200 ids in one call, balance went to zero before any approval.
- 401 - auth.

A partial-failure response is still 200 - inspect `failed` to find the rejected ids.

### `bulk_reject_submissions` - POST /v1/submissions/reject

Same shape as bulk approve, applies `reject_submission` semantics.

Request body: `{ "ids": [...] }`.
Response: `{ "rejected": [...], "failed": [...] }`.

### `download_job` - GET /v1/jobs/{id}/download

Stream a ZIP of all approved (and `auto_approved`) data for a job. Image / audio / video binaries land at `<modality>_<n>.<ext>`; text submissions are written as `text_<n>.txt` with `text_content` inline (no R2 fetch needed). Capped at 500 files / 2 GB per request.

Response 200: `Content-Type: application/zip`, `Content-Disposition: attachment; filename=job_<id>_<modality>s.zip`.

If the job has more than 500 approved files, page `list_submissions` with `status=approved` (paginated via `limit`/`offset`) and use `image_url` (or `text_content` for text jobs) from each row in your own download pipeline:

```python
offset = 0
while True:
    page = client.get(f"/v1/jobs/{job_id}/submissions", params={"status": "approved", "limit": 200, "offset": offset}).json()
    for sub in page["submissions"]:
        save(sub["image_url"])  # public CDN URL, drop straight into your storage layer
    if len(page["submissions"]) < 200:
        break
    offset += 200
```

Errors: 404 (no such job, or no approved photos on the job).

### `rotate_share_token` - POST /v1/jobs/{id}/rotate-share-token

Mint a fresh share token + URL for a private job. Existing members retain access; only joins via the old URL are invalidated. Use this when a private link has been disclosed beyond your intended cohort.

Response 200:

```json
{
  "share_url": "https://opendojo.ai/j/<new-token>",
  "share_token": "<new-token>"
}
```

Errors: 400 (job is not private), 404 (no such job, or another account's job).

### `list_members` - GET /v1/jobs/{id}/members

Members who have joined a private job, plus active/banned counts and the configured cap.

Response 200:

```json
{
  "members": [
    {
      "user_id": "5e6f7a8b-...",
      "email": "panelist@example.com",
      "name": "Alex Photographer",
      "status": "active",
      "joined_at": "2026-04-29T13:00:00",
      "banned_at": null
    }
  ],
  "active_count": 1,
  "banned_count": 0,
  "max_members": 50
}
```

Errors: 400 (job is not private), 404 (no such job, or another account's job).

### `ban_member` / `unban_member` - POST /v1/jobs/{id}/members/{user_id}/{ban|unban}

Eject or restore a private-job member. Empty request body. Idempotent. Existing pending submissions stay in your queue.

Response 204.

Errors: 400 (job is not private), 404 (no such job, or member never joined).

### `list_allowed_emails` - GET /v1/jobs/{id}/allowed-emails

The current per-job email allowlist. Empty list = anyone with the share URL can join.

Response 200:

```json
{
  "allowed_emails": [
    { "id": "1a2b3c4d-...", "email": "panelist@example.com", "created_at": "2026-04-29T12:34:56" }
  ]
}
```

### `add_allowed_email` - POST /v1/jobs/{id}/allowed-emails

Add one email. Idempotent — re-adding an existing entry returns the existing row (201 either way).

Request body: `{ "email": "new@example.com" }`.
Response 201: same shape as a `list_allowed_emails` entry.

### `remove_allowed_email` - DELETE /v1/jobs/{id}/allowed-emails/{email_id}

Remove one entry by id. Already-joined members are not affected — only future joins are blocked. To eject an existing member, use `ban_member`.

Response 204.

### `get_account` - GET /v1/account

Account identity, spendable balance, and the deposit address USDC must be sent to. The deposit wallet is bound lazily on first call to this endpoint (or on first SPA visit to the API page), so in steady state expect `deposit_address` to be present.

Doubles as a key-validation / whoami endpoint - call it on agent startup to confirm your `Authorization` header works before spending money on `create_job`.

Response 200:

```json
{
  "id": "1b2c3d4e-...",
  "email": "you@example.com",
  "actual_balance_cents": 12345,
  "deposit_address": "ABC...XYZ",
  "deposit_blockchain": "SOL",
  "deposit_token": "USDC"
}
```

`actual_balance_cents` is what's spendable for new jobs. Send only USDC on Solana to the address - any other token or chain is unrecoverable.

## Funding

Job creation spends USDC from your account balance. After minting your first key, call `get_account` to retrieve your Solana deposit address, send USDC there, and the platform credits your balance once the deposit is confirmed on-chain. Deposit confirmation is automatic; allow ~30 seconds after on-chain finality.

## Getting started

1. Sign in at https://opendojo.ai with Google.
2. Open the API page from the menu - https://opendojo.ai/account/api.
3. Accept the developer terms.
4. Mint a key. Save the plaintext on the spot - it is not retrievable later.
5. Call `get_account` to verify the key and retrieve your Solana deposit address. Send USDC on Solana.
6. Call `create_job` to post a job. Poll `list_submissions` (or download approved media in bulk via `download_job`) and call `approve_submission` / `reject_submission` to close the loop.

## Documentation

- [API page](https://opendojo.ai/account/api): mint and manage API keys, see the live endpoint reference.
- [OpenAPI schema](https://api.opendojo.ai/v1/openapi.json): machine-readable spec for the `/v1/*` surface.
- [Terms](https://opendojo.ai/terms): platform terms, including the rights grant on approved submissions.
- [Privacy](https://opendojo.ai/privacy): platform privacy policy.
