# Developer API documentation

> Self-serve server-to-server API. Paid Modal processing uses one existing credit for each successfully stored image. Free, referral and subscription allowances are excluded. Bulk submission is orchestration, not a promise of tensor batching or discounted GPU cost. Verified, photo-confirmed accounts create keys without admin approval or a credit balance. New uploads and generation require credits and operational availability.

- **URL**: https://swapshoot.com/developers
- **Last Updated**: 2 November 2026

---

Read the [OpenAPI 3.1.2 specification](https://swapshoot.com/api/openapi.json) for version 1.0.0-pilot. Open the [developer console](https://swapshoot.com/app/developers) for access status and show-once key management. Verified, photo-confirmed accounts create server keys without admin approval or a credit balance. New uploads and generation require credits, operational availability and an account that is not suspended for new work. Existing held jobs, owned reads and accepted replays can continue when the available balance reaches zero.

## Authentication

Verify your account and confirm photo permissions, then create a scoped key in /app/developers without admin approval or a credit balance. Choose Never expires, 30 days or 90 days; Never expires is preselected. Edit an active key's expiry in the console without changing its secret or scopes. A dated choice starts from when you save; existing keys keep their expiry until edited. Expired or revoked keys cannot be restored. Keep it in a server secret store; never expose it in a browser, mobile binary, URL or repository. Pausing new API work blocks uploads, submissions and unattempted dispatch. Existing valid keys with read scopes can still read account usage, job/batch status and results. Revoke keys or close the account to remove read access; attempted jobs continue settlement.

Send Authorization: Bearer <server-key>. Keep the secret in your server environment, never a browser bundle, mobile binary, URL or repository. The complete key is shown once; only its digest and safe management metadata are retained. Choose Never expires, 30 days or 90 days; Never expires is preselected. You can edit an active key's expiry without changing its secret or scopes. A dated choice starts from when you save. Existing keys keep their expiry until you edit them; expired or revoked keys cannot be restored. Another key does not create another account quota.

## Endpoints

Base URL: https://swapshoot.com/api/v1

### GET /usage

Read credit balance and account limits

Requires account:read. Reports spendable credits, API holds and usage counters; another key does not create another quota.

### POST /swaps

Queue one image swap

Requires swaps:write. Validates both photos, then atomically holds one existing credit and admits a durable job. A matching idempotent retry returns the original job; changed bytes or options conflict. No client-controlled image URLs or worker callbacks are accepted.

### GET /swaps/{id}

Read a swap state

Requires swaps:read. Returns only the authenticated account generation's job. Missing and foreign jobs have the same 404. Poll every 5 seconds initially, backing off to 15 seconds.

### GET /swaps/{id}/result

Download a completed JPEG

Requires swaps:read. Private authenticated JPEG, at most 2 MiB. Access expires 18 hours after successful settlement. Download from a trusted server and keep your own copy before expiry.

### POST /uploads

Stage one private batch image

Requires swaps:write. Upload one image per request; batch manifests reference opaque owned upload IDs. Unclaimed uploads expire after 30 minutes and are single-use across admissions. No image retrieval URL is exposed. A new upload requires a positive credit balance, but uploading does not hold or charge a credit. Key creation, existing work reads and matching accepted replays remain available with zero credits. Retrying after a lost response may create another temporary upload.

### POST /batches

Queue up to 20 targets with one shared head

Requires swaps:write. Uses complete, owned, unexpired, unclaimed uploads from the same account generation. All child credits and capacity are admitted atomically or none are. Each child settles independently; partial failures refund only failed children. Ordered items have unique printable client_item_id values, distinct target IDs, and no head-as-target. Request JSON is at most 32 KiB.

### GET /batches/{id}

Read batch counts and individual results

Requires swaps:read. Read one batch instead of polling every child. completed includes mixed successes and failures. Download successful children individually; there is no ZIP endpoint.

## Quickstart: curl

```sh
# Requires curl, jq and a UUID saved for this operation.
# Set SWAPSHOOT_API_KEY in your server secret environment.
# Generate SWAPSHOOT_IDEMPOTENCY_KEY once; reuse it for retries.
set -eu
: "${SWAPSHOOT_API_KEY:?Set your server API key}"
: "${SWAPSHOOT_IDEMPOTENCY_KEY:?Set a saved operation UUID}"
BASE="https://swapshoot.com/api/v1"

JOB=$(curl --fail-with-body --silent --show-error \
  "$BASE/swaps" \
  -H "Authorization: Bearer $SWAPSHOOT_API_KEY" \
  -H "Idempotency-Key: $SWAPSHOOT_IDEMPOTENCY_KEY" \
  -F "target=@target.jpg" -F "head=@head.jpg" -F "consent=yes")
ID=$(printf '%s' "$JOB" | jq -er '.id')
DEADLINE=$(( $(date +%s) + 2100 ))
DELAY=5

while [ "$(date +%s)" -lt "$DEADLINE" ]; do
  JOB=$(curl --fail-with-body --silent --show-error \
    "$BASE/swaps/$ID" -H "Authorization: Bearer $SWAPSHOOT_API_KEY")
  STATE=$(printf '%s' "$JOB" | jq -r '.status')
  case "$STATE" in
    succeeded) curl --fail-with-body --silent --show-error \
      "$BASE/swaps/$ID/result" \
      -H "Authorization: Bearer $SWAPSHOOT_API_KEY" -o result.jpg
      printf 'Saved result.jpg\n'; exit 0 ;;
    failed|expired) printf '%s\n' "$JOB" >&2; exit 1 ;;
  esac
  sleep "$DELAY"
  [ "$DELAY" -ge 15 ] || DELAY=$((DELAY + 5))
done
printf 'Polling stopped. Resume GET for the same job ID; do not submit a new operation.\n' >&2
exit 1
```

## Quickstart: Python

```python
# Python 3.10+; pip install requests
# Set both values in your server environment. Save the operation UUID
# before first submission and keep it unchanged for retries.
import os
import time
from pathlib import Path
import requests

BASE = "https://swapshoot.com/api/v1"
headers = {"Authorization": "Bearer " + os.environ["SWAPSHOOT_API_KEY"]}
operation_key = os.environ["SWAPSHOOT_IDEMPOTENCY_KEY"]

# A retry reopens the same files and retains the original operation key.
for attempt in range(3):
    try:
        with open("target.jpg", "rb") as target, open("head.jpg", "rb") as head:
            response = requests.post(
                BASE + "/swaps",
                headers={**headers, "Idempotency-Key": operation_key},
                data={"consent": "yes"},
                files={"target": ("target.jpg", target, "image/jpeg"),
                       "head": ("head.jpg", head, "image/jpeg")},
                timeout=60, allow_redirects=False,
            )
    except requests.RequestException:
        if attempt == 2:
            raise  # Resume with the same operation UUID and original files.
        time.sleep(5 * (attempt + 1))
        continue
    if response.status_code in (429, 503) and attempt < 2:
        raw_wait = response.headers.get("Retry-After", "5")
        wait = max(5, int(raw_wait)) if raw_wait.isdigit() else 5
        if wait > 60:
            raise RuntimeError("Resume after Retry-After with the same operation UUID")
        time.sleep(wait)
        continue
    response.raise_for_status()
    break
job = response.json()
job_id = job["id"]  # Persist this in your application.
deadline = time.monotonic() + 35 * 60
delay = 5

while time.monotonic() < deadline:
    response = requests.get(BASE + "/swaps/" + job_id,
                            headers=headers, timeout=30, allow_redirects=False)
    if response.status_code in (429, 503):
        wait = response.headers.get("Retry-After", str(delay))
        seconds = max(delay, int(wait)) if wait.isdigit() else delay
        if time.monotonic() + seconds >= deadline:
            raise TimeoutError("Resume the saved job after Retry-After")
        time.sleep(seconds)
        delay = min(15, delay + 5)
        continue
    response.raise_for_status()
    job = response.json()
    if job["status"] == "succeeded":
        image = requests.get(BASE + "/swaps/" + job_id + "/result",
                             headers=headers, timeout=30, allow_redirects=False)
        image.raise_for_status()
        if image.headers.get("Content-Type", "").split(";")[0] != "image/jpeg":
            raise RuntimeError("Unexpected result format")
        Path("result.jpg").write_bytes(image.content)
        break
    if job["status"] in ("failed", "expired"):
        raise RuntimeError(job.get("error") or job["status"])
    time.sleep(delay)
    delay = min(15, delay + 5)
else:
    raise TimeoutError("Resume polling the saved job ID; do not create a new job")
```

## Quickstart: Node.js

```javascript
// Node.js 24+. Run on your server, never in a browser.
// Set the key and a saved operation UUID through your environment.
import { readFile, writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";

const BASE = "https://swapshoot.com/api/v1";
const key = process.env.SWAPSHOOT_API_KEY;
const operationKey = process.env.SWAPSHOOT_IDEMPOTENCY_KEY;
if (!key || !operationKey) throw new Error("Set the server key and saved operation UUID");
const headers = { Authorization: `Bearer ${key}` };
const form = new FormData();
form.set("target", new Blob([await readFile("target.jpg")], { type: "image/jpeg" }), "target.jpg");
form.set("head", new Blob([await readFile("head.jpg")], { type: "image/jpeg" }), "head.jpg");
form.set("consent", "yes");

let submitted;
for (let attempt = 0; attempt < 3; attempt++) {
  try {
    submitted = await fetch(BASE + "/swaps", {
      method: "POST", headers: { ...headers, "Idempotency-Key": operationKey },
      body: form, redirect: "error", signal: AbortSignal.timeout(60_000),
    });
  } catch (error) {
    if (attempt === 2) throw error; // Keep the same operation key for recovery.
    await sleep(5_000 * (attempt + 1));
    continue;
  }
  if ([429, 503].includes(submitted.status) && attempt < 2) {
    const retry = Number(submitted.headers.get("Retry-After"));
    const wait = Math.max(5_000, Number.isFinite(retry) ? retry * 1000 : 5_000);
    if (wait > 60_000) throw new Error("Resume after Retry-After with the same operation key");
    await sleep(wait);
    continue;
  }
  if (!submitted.ok) throw new Error(`Submit HTTP ${submitted.status}; keep the same operation key`);
  break;
}
if (!submitted?.ok) throw new Error("Retain the operation key and original files for recovery");
let job = await submitted.json();
const jobId = job.id; // Persist in your application.
const deadline = Date.now() + 35 * 60_000;
let delay = 5_000;
let completed = false;

while (Date.now() < deadline) {
  const response = await fetch(BASE + "/swaps/" + jobId, {
    headers, redirect: "error", signal: AbortSignal.timeout(30_000),
  });
  if (response.status === 429 || response.status === 503) {
    const retry = Number(response.headers.get("Retry-After"));
    const wait = Math.max(delay, Number.isFinite(retry) ? retry * 1000 : delay);
    if (Date.now() + wait >= deadline) throw new Error("Resume the saved job after Retry-After");
    await sleep(wait);
    delay = Math.min(15_000, delay + 5_000);
    continue;
  }
  if (!response.ok) throw new Error(`Status HTTP ${response.status}`);
  job = await response.json();
  if (job.status === "succeeded") {
    const image = await fetch(BASE + "/swaps/" + jobId + "/result", {
      headers, redirect: "error", signal: AbortSignal.timeout(30_000),
    });
    if (!image.ok || !image.headers.get("Content-Type")?.startsWith("image/jpeg")) {
      throw new Error(`Result unavailable: HTTP ${image.status}`);
    }
    await writeFile("result.jpg", Buffer.from(await image.arrayBuffer()));
    completed = true;
    break;
  }
  if (["failed", "expired"].includes(job.status)) throw new Error(job.error?.message || job.status);
  await sleep(delay);
  delay = Math.min(15_000, delay + 5_000);
}
if (!completed) throw new Error("Resume polling the saved job ID; do not create a new job");
```

## Batch submission

Upload each private image individually, then reference one shared head and up to 20 distinct targets. A batch reserves all credits and admission capacity atomically; each child has its own result and credit outcome. Bulk submission does not establish true GPU inference batching or discounted GPU cost.

```sh
# Python 3.10+; pip install requests
# Upload one head and each target separately, then submit a small JSON manifest.
import json
import os
import time
import uuid
from pathlib import Path
import requests

BASE = "https://swapshoot.com/api/v1"
headers = {"Authorization": "Bearer " + os.environ["SWAPSHOOT_API_KEY"]}
request_file = Path("swapshoot-batch-request.json")

def upload(filename):
    with open(filename, "rb") as image:
        response = requests.post(BASE + "/uploads", headers=headers,
            files={"image": (Path(filename).name, image, "image/jpeg")},
            timeout=60, allow_redirects=False)
    response.raise_for_status()
    return response.json()["id"]

# Keep this file to retry exactly the same operation. Use a different file
# for a genuinely new batch. It contains request metadata, not your API key.
if request_file.exists():
    saved = json.loads(request_file.read_text())
else:
    targets = ["target-1.jpg", "target-2.jpg"]  # 1-20 targets
    saved = {"key": str(uuid.uuid4()), "body": {
        "head_upload_id": upload("head.jpg"),
        "items": [{"client_item_id": str(i + 1), "target_upload_id": upload(name)}
                  for i, name in enumerate(targets)],
        "consent": True,
    }}
    request_file.write_text(json.dumps(saved))

response = requests.post(BASE + "/batches", headers={**headers,
    "Idempotency-Key": saved["key"]}, json=saved["body"],
    timeout=60, allow_redirects=False)
response.raise_for_status()
batch_id = response.json()["id"]
deadline = time.monotonic() + 35 * 60
delay = 5

while time.monotonic() < deadline:
    response = requests.get(BASE + "/batches/" + batch_id,
                            headers=headers, timeout=30, allow_redirects=False)
    if response.status_code in (429, 503):
        wait = response.headers.get("Retry-After", str(delay))
        seconds = max(delay, int(wait)) if wait.isdigit() else delay
        if time.monotonic() + seconds >= deadline:
            raise TimeoutError("Resume the saved batch after Retry-After")
        time.sleep(seconds)
        delay = min(15, delay + 5)
        continue
    response.raise_for_status()
    batch = response.json()
    if batch["state"] == "completed":
        for index, job in enumerate(batch["jobs"]):
            if job["status"] != "succeeded":
                print(job["client_item_id"], job["status"], job["billing"]["status"])
                continue
            image = requests.get(BASE + "/swaps/" + job["id"] + "/result",
                                 headers=headers, timeout=30, allow_redirects=False)
            image.raise_for_status()
            Path("result-" + str(index + 1) + ".jpg").write_bytes(image.content)
        break
    time.sleep(delay)
    delay = min(15, delay + 5)
else:
    raise TimeoutError("Resume polling the same batch ID; do not create a new batch")
```

## Credits and states

One existing credit is held for every admitted image. It is consumed only after the result has been stored for retrieval; failed items release or restore their credit through reconciliation. Free swaps, referral rewards, subscription allowances and unlimited website access do not cover API jobs.

Statuses are queued, processing, succeeded, failed and expired. Billing is held, charged or refunded. A 200 status response can contain a failed job. A pending job has a 30-minute delivery deadline; settlement occurs on subsequent reconciliation and can be delayed by outages. An expired successful download remains charged.

## Images and retention

JPEG, PNG and WebP images are limited to 2 MiB per file; combined multipart requests are limited to 4,400,000 bytes. Images are decoded with bounded pixels and normalized to JPEG, fitting within 2,400 pixels without enlargement. See the [Privacy Policy](https://swapshoot.com/privacy) and [API pilot terms](https://swapshoot.com/terms).

Unclaimed uploads expire after 30 minutes. Accepted inputs remain private until dispatch/terminal cleanup and any bounded read/send lease ends. Successful result access expires 18 hours after settlement. Physical deletion is asynchronous and retried; access expiry is not a guaranteed physical-erasure deadline. Jobs and idempotency receipts are ordinarily kept 30 days; per-account operational aggregates remain while active and 30 days after inactivity. Necessary deletion locators and unresolved provider-reconciliation records may remain longer.

## Idempotency, polling and limits

Save a fresh 16-128-character printable non-space ASCII Idempotency-Key before submitting a genuinely new operation. Reuse it and the identical body after an uncertain response. Matching accepted receipts are shared across account keys for 30 days; changing the request under the same key returns 409. Do not blindly submit a new operation after a timeout.

Public success responses are bare objects and carry X-Request-Id. New admissions return 202 with Location; pending responses use Retry-After: 5. Terminal receipt replays return 200. Poll every 5 seconds and back off to 15 seconds; honor Retry-After on 429. Limits apply per account across keys, with shared capacity possibly stricter than the account limit.

## Errors and support

Errors normally use {error:{code,message},request_id}. Check HTTP status and terminal job status; platform errors may not use that envelope. The downloadable specification lists request schemas, response schemas and HTTP errors. Include the method/path, time, X-Request-Id and public job/batch ID when contacting support; never include a key secret or private image.

Contact [team@swapshoot.com](mailto:team@swapshoot.com). The pilot has no completion-time or availability SLA. Read the [API overview](https://swapshoot.com/head-swap-api) for access and integration context.
