PixelPig Partner API — v1

A prepaid, token-based REST API for face-swap (image + video) and subject-driven image generation.

  • Base URL: https://www.pixelpig.app/api/v1
  • Auth: Authorization: Bearer pp_live_<your-key>
  • Format: all bodies are JSON. All errors return { "error": { "type", "message" }, "request_id" }.

TL;DR — 60-second integration

Face-swap / text-to-image / video-swap:

  1. POST /generate with the body for your mode → returns { id, status: "processing" }
  2. Poll POST /status with { "generation_id": "<id>" } every 5 s
  3. When status === "completed", the response contains an output_url you can fetch or redirect to
  4. Tokens are debited on submit, automatically refunded if status ends in failed

Personalized LoRA (subject-driven, identity-locked):

  1. POST /lora-train with one portrait → returns { id } (= your lora_id). One-time 1,500 tokens, ~70–150 min wall-clock.
  2. Poll GET /lora-status?id=<lora_id> every 60 s until status: "ready".
  3. POST /lora-generate with { lora_id, prompt, aspect_ratio } per image (72 tokens each) → returns { id } (a generation_id).
  4. Poll POST /status with { "generation_id": "<id>" } every 5 s until status: "completed". Same polling endpoint as regular generations.
KEY="pp_live_..."
RES=$(curl -sS -X POST https://www.pixelpig.app/api/v1/generate \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "mode":"face_swap","model":"nano-banana-2",
    "source_url":"https://your-cdn/scene.jpg",
    "reference_image_url":"https://your-cdn/face.jpg"
  }')
ID=$(echo "$RES" | jq -r .id)

while true; do
  STATUS=$(curl -sS -X POST https://www.pixelpig.app/api/v1/status \
    -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
    -d "{\"generation_id\":\"$ID\"}")
  case $(echo "$STATUS" | jq -r .status) in
    completed) echo "$STATUS" | jq .output_url -r; break ;;
    failed)    echo "$STATUS" | jq .error_message -r; exit 1 ;;
    *)         sleep 5 ;;
  esac
done

Preflight checklist

Run through this once before you start integrating. Every line saves a back-and-forth.

  • Bearer key sent as Authorization: Bearer pp_live_… (not apikey: …, not ?key=…)
  • All image / video / frame URLs are publicly fetchable (no signed URLs that expire in <1 minute, no localhost, no IP-restricted CDNs). The provider — not your server — fetches them.
  • Source images are at least 768×768 for wan-2.7-pro (590k px² minimum). Smaller works for nano-banana-2 but ≥768² is safest.
  • Source video for video_swap shows a clear human upper body at the start. Cartoon, full-body wide shots, or back-of-head shots will be rejected by Kling.
  • First frame for video_swap is extracted on your side as a JPEG/PNG and hosted publicly. (Server-side extraction is on the v2 roadmap.)
  • Aspect ratio strings look like "16:9", "9:16", "1:1", "4:3", "3:2" — not numbers, not floats.
  • Polling interval ≥ 5 s. Faster polling won't make jobs finish faster and may trip the rate limiter.
  • Retry only on SUPABASE_EDGE_RUNTIME_ERROR 5xx. Other 5xx responses mean the function executed and recorded state; retrying double-charges. See the retry section below.
  • You are storing the id returned by /generate so you can poll status. Without it the generation runs but is unreachable.

Endpoints

POST /generate

Submit a generation. Returns immediately with a media id and a token-debit summary.

Image — face swap (mode: "face_swap")

FieldRequiredNotes
modeyes"face_swap"
modelno"nano-banana-2" (default, 30 tok) or "wan-2.7-pro" (100 tok)
source_urlyesPublic URL to the target scene image
reference_image_urlyesPublic URL to the face source image
aspect_rationoe.g. "16:9", "9:16", "1:1"

Image — text-to-image (mode: "text_to_image")

The underlying model is image-edit, so a subject image is required. The prompt describes the scene around the subject.

FieldRequiredNotes
modeyes"text_to_image"
modelno"nano-banana-2" (30 tok) or "wan-2.7-pro" (100 tok)
promptyesUp to 2000 chars
reference_image_urlyesPublic URL to the subject image
aspect_rationo

Video — face swap (mode: "video_swap")

Two-step pipeline: (1) face-swap on the supplied first frame, (2) Kling 2.6 motion-control over the source video.

FieldRequiredNotes
modeyes"video_swap"
video_urlyesPublic URL to the source video (mp4)
reference_image_urlyesPublic URL to the face source image
frame_urlyesPublic URL to a JPEG/PNG of the first frame of the source video
duration_secondsyesOne of 5, 10, 15, 20, 25, 30
promptnoOptional motion guidance

Success response

{
  "id": "aaa222cb-f78d-449d-add9-8af352314442",
  "status": "processing",
  "tokens_charged": 30,
  "balance_remaining": 114860,
  "request_id": "req_828ba8cd4d8644aa84cf"
}

POST /status

Returns current state of a generation. Polled, not pushed.

curl -X POST https://www.pixelpig.app/api/v1/status \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"generation_id":"aaa222cb-f78d-449d-add9-8af352314442"}'
statusMeaning
processingProvider is working. Keep polling.
completedoutput_url is set. Image: PNG. Video: MP4.
failederror_message is set. Tokens are already refunded.

Polling cadence: 5 s. Image gens usually finish in 15–40 s; video in 60–180 s depending on duration_seconds.


POST /lora-train

Early access. The three LoRA endpoints (/lora-train, /lora-status, /lora-generate) are currently served from our staging environment under njxndhaavcetpyuzfrop.supabase.co. They will move to the unified https://www.pixelpig.app/api/v1/... base URL on production rollout. Same auth, same request/response shapes — only the host changes.

Train a personalized LoRA model from a single portrait. Returns immediately with a lora_id; training runs ~70-150 min in the background. Poll /lora-status until status: "ready", then call /lora-generate to produce images.

curl -X POST https://njxndhaavcetpyuzfrop.supabase.co/functions/v1/api-v1-lora-train \
  -H "Authorization: Bearer pp_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "image_url": "https://your-cdn.example/portrait.jpg",
    "name": "Sara — campaign A"
  }'

Inputs

FieldTypeRequiredNotes
image_urlstring (URL)yesPublic HTTPS URL of a portrait image. JPEG/PNG/WebP only. Max 15 MB. We download and store internally.
namestringnoYour label for this LoRA — surfaces in our admin tooling for support requests. 1–100 chars.

Success response

{
  "id": "9e0d8d2b-...",
  "status": "training",
  "tokens_charged": 1500,
  "balance_remaining": 28000,
  "request_id": "req_..."
}

The id is your lora_id — store it for /lora-status and /lora-generate calls.

Notes

  • Training is irreversible-after-deduction: you're charged the full 1500 tokens at submission. If training fails (rare), tokens are auto-refunded.
  • Behind the scenes we generate ~150 augmented training images via Nano Banana Pro, then run a 1500-step LoRA fine-tune on a high-end GPU (ai-toolkit / ostris). Total wall-clock ~70–150 min.
  • The image you submit must show a clear, single-subject portrait. Group photos / occluded faces / cropped heads will produce poor LoRAs.
  • Each LoRA is private to your account — only API keys belonging to the same account that called /lora-train can call /lora-generate against that lora_id.

Errors specific to this endpoint

  • 400 invalid_request_error — image_url couldn't be fetched, MIME isn't JPEG/PNG/WebP, or file exceeds 15 MB.
  • 402 payment_required — balance below 1,500 tokens. Top up or enable auto-topup.
  • 502 api_error — augmentation pipeline could not be reached. Tokens are auto-refunded; safe to retry.
  • 503 api_error — augmentation service not configured (transient platform issue). Tokens are auto-refunded.

GET /lora-status

Polls training status by lora_id.

curl "https://njxndhaavcetpyuzfrop.supabase.co/functions/v1/api-v1-lora-status?id=9e0d8d2b-..." \
  -H "Authorization: Bearer pp_live_..."

Success response (training in progress)

{
  "id": "9e0d8d2b-...",
  "name": "Sara — campaign A",
  "status": "training",
  "ready": false,
  "failed": false,
  "lora_filename": null,
  "trained_at": null,
  "error": null,
  "augmentation": {
    "image_count": 153,
    "started_at": "2026-05-07T16:57:12Z",
    "completed_at": "2026-05-07T17:01:46Z"
  },
  "request_id": "req_..."
}

Response fields

FieldNotes
idEcho of the lora_id you queried.
nameThe label you provided (or our auto-generated default).
statusOne of augmenting, queued, training, ready, failed.
readyBoolean shortcut. true only when status === "ready".
failedBoolean shortcut. true only when status === "failed".
lora_filenameInternal handle. Populated only when ready. You don't need to use this — pass lora_id to /lora-generate.
trained_atISO timestamp set when training finished.
errorHuman-readable failure reason; null unless failed.
augmentation{ image_count, started_at, completed_at } — visibility into the dataset-prep stage. image_count settles around 153 once augmentation finishes.

Status values: augmenting → queued → training → ready (or failed).

Polling cadence: every 60 seconds is plenty. Training takes 70–150 min; faster polling won't speed it up and may trip the rate limiter.


POST /lora-generate

Generate one image using a trained LoRA. Returns immediately with id (a generation_id); poll /status (the existing endpoint) until completed.

curl -X POST https://njxndhaavcetpyuzfrop.supabase.co/functions/v1/api-v1-lora-generate \
  -H "Authorization: Bearer pp_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "lora_id": "9e0d8d2b-...",
    "prompt": "in a Tokyo cafe at golden hour, denim jacket, candid photo",
    "aspect_ratio": "3:4"
  }'

Inputs

FieldTypeRequiredNotes
lora_idstring (UUID)yesReturned by /lora-train; LoRA must be status: "ready".
promptstringyesScene description. Don't include identity descriptors (face, age, ethnicity) — the LoRA handles that. 1–2000 chars.
aspect_ratioenumnoOne of the 9 SDXL trained-bucket ratios: 3:4 (default), 1:1, 9:16, 4:3, 16:9, 2:3, 3:2, 9:21, 21:9. Output quality drops outside these.

Success response

{
  "id": "f4cd1f8e-...",
  "status": "processing",
  "tokens_charged": 72,
  "balance_remaining": 24428,
  "request_id": "req_..."
}

Poll the existing POST /status endpoint with { "generation_id": "<id>" } every 5 seconds until status: "completed" and output_url is populated. Same polling endpoint, response shape, and refund semantics as regular /generate calls — there is no separate lora-poll endpoint.

curl -X POST https://www.pixelpig.app/api/v1/status \
  -H "Authorization: Bearer pp_live_..." -H "Content-Type: application/json" \
  -d '{"generation_id":"f4cd1f8e-..."}'

Prompt guidance

  • Don't include identity descriptors (face, age, hair colour, ethnicity) — the LoRA already encodes those. Describe the scene: setting, wardrobe, mood, lighting, camera, action.
  • The trigger word is injected automatically by the API. You don't need to add it.
  • Prompts are run through a safety guard before generation. Explicit minor / non-consensual / extreme-violence content is rejected with 422.

Errors specific to this endpoint

  • 404 invalid_request_error — lora_id not found or doesn't belong to your account.
  • 409 invalid_request_error — LoRA is still training (poll /lora-status first; only status: "ready" LoRAs can generate).
  • 422 invalid_request_error — prompt was rejected by the safety filter. Adjust the prompt and retry; no tokens are charged.
  • 402 payment_required — balance below 72 tokens.

GET /balance

curl https://www.pixelpig.app/api/v1/balance -H "Authorization: Bearer $KEY"
{
  "balance": 114730,
  "api_key_name": "Partner CRM test",
  "auto_topup": {
    "enabled": false,
    "threshold": 100,
    "topup_tokens": 1000,
    "last_error": null
  },
  "request_id": "..."
}

Pricing

ModeModelTokens
face_swapnano-banana-230
face_swapwan-2.7-pro100
text_to_imagenano-banana-230
text_to_imagewan-2.7-pro100
video_swap (5s)Kling 2.6110
video_swap (10s)Kling 2.6220
video_swap (15s)Kling 2.6330
video_swap (20s)Kling 2.6440
video_swap (25s)Kling 2.6550
video_swap (30s)Kling 2.6660
lora-train(one-time per LoRA)1500
lora-generateper image50

Failed generations and failed LoRA trainings are auto-refunded.


Errors

{ "error": { "type": "<type>", "message": "<detail>" }, "request_id": "req_..." }
HTTPerror.typeWhenWhat to do
400invalid_request_errorMissing/invalid fieldRead message, fix the body, retry.
401authentication_errorBad bearer keyCheck the header; rotate the key in the dashboard if compromised.
402payment_requiredBalance too lowTop up via dashboard or enable auto-topup.
404not_found_errorgeneration_id not found / not ownedConfirm the id; do not retry.
429rate_limit_error> N req/min on this keyHonor Retry-After; default rate is 60/min, raisable on request.
5xxapi_error / SUPABASE_EDGE_RUNTIME_ERRORInternal or upstream failureSee retry section.

Retry semantics

  • SUPABASE_EDGE_RUNTIME_ERROR 5xx: the function never executed. Safe to retry with the same body. Recommended back-off: 5 s, 10 s, 20 s.
  • Other 5xx returning structured JSON: the function executed, debited tokens, and may have submitted to the provider. Do not retry blind — instead, query /balance to see if you were charged, and use the original request's response (if you saved it) to find your id. If you didn't save the id, the safest path is to wait 30 s, look in the dashboard's Requests log, and resume from there.
  • 200 responses with id: never retry. The job is in flight regardless of network conditions on your side.

The API is not yet idempotency-key aware. If your client retries a 200 you'll get two charges and two outputs. Build retry only around connection failures and SUPABASE_EDGE_RUNTIME_ERROR.


Troubleshooting matrix

The provider's reason ends up in status.error_message verbatim. Here are the ones you'll see most often.

Error message contains...CauseFix
Image dimensions are too small. Minimum area is 589824 pixels²WAN 2.7 Pro source image below 768×768Resize or upscale before sending. Or use model: "nano-banana-2".
At least one image URL is requiredtext_to_image sent without reference_image_urlAdd reference_image_url to the body. The model is image-edit, not pure prompt.
No complete upper body detected in the videoSource video doesn't show a clear human upper bodyPick a different video; cartoons / wide landscapes / back-of-head shots are rejected.
Could not fetch image at URL / 404 from providerURL is signed/expired/private/IP-restrictedUse a stable public URL. Signed URLs need ≥10 min TTL.
Content flagged as inappropriate (NSFW)Provider's safety filter triggeredThe job is marked failed with NSFW reason; tokens refunded. Retry with different inputs.
frame_url is required for video_swapMissing frame_urlExtract first frame on your side, host it publicly, send the URL.
duration_seconds must be one of 5, 10, 15, 20, 25, 30You sent something elseRound up to the nearest tier.
Invalid JWT / UNAUTHORIZED_INVALID_JWT_FORMATCalling the raw Supabase URL without verify_jwt=false (only happens if you bypass www.pixelpig.app/api/v1/...)Use the www.pixelpig.app/api/v1/... URL or hit the gateway with no apikey header.
Service is temporarily unavailable (5xx)Edge runtime cold startRetry once after 5 s.

Recommended client patterns

Polling helper (Node)

async function generate(body, key) {
  let res;
  for (let i = 0; i < 3; i++) {
    res = await fetch("https://www.pixelpig.app/api/v1/generate", {
      method: "POST",
      headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    const data = await res.json();
    if (res.status >= 500 && data.code === "SUPABASE_EDGE_RUNTIME_ERROR") {
      await new Promise((r) => setTimeout(r, 5000 * (i + 1)));
      continue;
    }
    if (!res.ok) throw new Error(`${data.error?.type}: ${data.error?.message}`);
    return data; // { id, status: "processing", ... }
  }
  throw new Error("Edge runtime unreachable after 3 attempts");
}

async function waitFor(id, key, { timeoutMs = 300000, intervalMs = 5000 } = {}) {
  const t0 = Date.now();
  while (Date.now() - t0 < timeoutMs) {
    const r = await fetch("https://www.pixelpig.app/api/v1/status", {
      method: "POST",
      headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
      body: JSON.stringify({ generation_id: id }),
    });
    if (!r.ok) { await new Promise((x) => setTimeout(x, intervalMs)); continue; }
    const d = await r.json();
    if (d.status === "completed") return d;
    if (d.status === "failed")    throw new Error(d.error_message || "generation failed");
    await new Promise((x) => setTimeout(x, intervalMs));
  }
  throw new Error("timeout");
}

Webhooks

Not yet supported. Roadmap. For now, poll.

CORS

The API serves Access-Control-Allow-Origin: *. You can call it directly from the browser, but doing so leaks your pp_live_ key to clients — always call from a server-side environment.


Test the API in 30 seconds

KEY="<your key>"
curl https://www.pixelpig.app/api/v1/balance -H "Authorization: Bearer $KEY"

If you see a balance number, your key works. If you see authentication_error, recheck the header.


Roadmap

  • Server-side first-frame extraction for video_swap (you'll be able to drop frame_url)
  • Idempotency-key support
  • Webhooks on completion (post to your URL with the result)
  • More models: Sora 2 Pro, Veo 3.1, Seedream v4.5, GPT Image 2

Support

support@pixelpig.app — feature requests, integration help, custom rate-limit increases. Include your request_id from any failing call; it makes diagnosis 10× faster on our side.