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:
POST /generatewith the body for your mode → returns{ id, status: "processing" }- Poll
POST /statuswith{ "generation_id": "<id>" }every 5 s - When
status === "completed", the response contains anoutput_urlyou can fetch or redirect to - Tokens are debited on submit, automatically refunded if status ends in
failed
Personalized LoRA (subject-driven, identity-locked):
POST /lora-trainwith one portrait → returns{ id }(= yourlora_id). One-time 1,500 tokens, ~70–150 min wall-clock.- Poll
GET /lora-status?id=<lora_id>every 60 s untilstatus: "ready". POST /lora-generatewith{ lora_id, prompt, aspect_ratio }per image (72 tokens each) → returns{ id }(a generation_id).- Poll
POST /statuswith{ "generation_id": "<id>" }every 5 s untilstatus: "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_…(notapikey: …, 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 fornano-banana-2but ≥768² is safest. - Source video for
video_swapshows 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_swapis 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_ERROR5xx. Other 5xx responses mean the function executed and recorded state; retrying double-charges. See the retry section below. - You are storing the
idreturned by/generateso 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")
| Field | Required | Notes |
|---|---|---|
mode | yes | "face_swap" |
model | no | "nano-banana-2" (default, 30 tok) or "wan-2.7-pro" (100 tok) |
source_url | yes | Public URL to the target scene image |
reference_image_url | yes | Public URL to the face source image |
aspect_ratio | no | e.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.
| Field | Required | Notes |
|---|---|---|
mode | yes | "text_to_image" |
model | no | "nano-banana-2" (30 tok) or "wan-2.7-pro" (100 tok) |
prompt | yes | Up to 2000 chars |
reference_image_url | yes | Public URL to the subject image |
aspect_ratio | no |
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.
| Field | Required | Notes |
|---|---|---|
mode | yes | "video_swap" |
video_url | yes | Public URL to the source video (mp4) |
reference_image_url | yes | Public URL to the face source image |
frame_url | yes | Public URL to a JPEG/PNG of the first frame of the source video |
duration_seconds | yes | One of 5, 10, 15, 20, 25, 30 |
prompt | no | Optional 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"}'
status | Meaning |
|---|---|
processing | Provider is working. Keep polling. |
completed | output_url is set. Image: PNG. Video: MP4. |
failed | error_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 undernjxndhaavcetpyuzfrop.supabase.co. They will move to the unifiedhttps://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
| Field | Type | Required | Notes |
|---|---|---|---|
image_url | string (URL) | yes | Public HTTPS URL of a portrait image. JPEG/PNG/WebP only. Max 15 MB. We download and store internally. |
name | string | no | Your 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-traincan call/lora-generateagainst thatlora_id.
Errors specific to this endpoint
400 invalid_request_error—image_urlcouldn'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
| Field | Notes |
|---|---|
id | Echo of the lora_id you queried. |
name | The label you provided (or our auto-generated default). |
status | One of augmenting, queued, training, ready, failed. |
ready | Boolean shortcut. true only when status === "ready". |
failed | Boolean shortcut. true only when status === "failed". |
lora_filename | Internal handle. Populated only when ready. You don't need to use this — pass lora_id to /lora-generate. |
trained_at | ISO timestamp set when training finished. |
error | Human-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
| Field | Type | Required | Notes |
|---|---|---|---|
lora_id | string (UUID) | yes | Returned by /lora-train; LoRA must be status: "ready". |
prompt | string | yes | Scene description. Don't include identity descriptors (face, age, ethnicity) — the LoRA handles that. 1–2000 chars. |
aspect_ratio | enum | no | One 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_idnot found or doesn't belong to your account.409 invalid_request_error— LoRA is still training (poll/lora-statusfirst; onlystatus: "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
| Mode | Model | Tokens |
|---|---|---|
face_swap | nano-banana-2 | 30 |
face_swap | wan-2.7-pro | 100 |
text_to_image | nano-banana-2 | 30 |
text_to_image | wan-2.7-pro | 100 |
video_swap (5s) | Kling 2.6 | 110 |
video_swap (10s) | Kling 2.6 | 220 |
video_swap (15s) | Kling 2.6 | 330 |
video_swap (20s) | Kling 2.6 | 440 |
video_swap (25s) | Kling 2.6 | 550 |
video_swap (30s) | Kling 2.6 | 660 |
lora-train | (one-time per LoRA) | 1500 |
lora-generate | per image | 50 |
Failed generations and failed LoRA trainings are auto-refunded.
Errors
{ "error": { "type": "<type>", "message": "<detail>" }, "request_id": "req_..." }
| HTTP | error.type | When | What to do |
|---|---|---|---|
| 400 | invalid_request_error | Missing/invalid field | Read message, fix the body, retry. |
| 401 | authentication_error | Bad bearer key | Check the header; rotate the key in the dashboard if compromised. |
| 402 | payment_required | Balance too low | Top up via dashboard or enable auto-topup. |
| 404 | not_found_error | generation_id not found / not owned | Confirm the id; do not retry. |
| 429 | rate_limit_error | > N req/min on this key | Honor Retry-After; default rate is 60/min, raisable on request. |
| 5xx | api_error / SUPABASE_EDGE_RUNTIME_ERROR | Internal or upstream failure | See retry section. |
Retry semantics
SUPABASE_EDGE_RUNTIME_ERROR5xx: 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
/balanceto see if you were charged, and use the original request's response (if you saved it) to find yourid. If you didn't save theid, the safest path is to wait 30 s, look in the dashboard's Requests log, and resume from there. 200responses withid: 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
200you'll get two charges and two outputs. Build retry only around connection failures andSUPABASE_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... | Cause | Fix |
|---|---|---|
Image dimensions are too small. Minimum area is 589824 pixels² | WAN 2.7 Pro source image below 768×768 | Resize or upscale before sending. Or use model: "nano-banana-2". |
At least one image URL is required | text_to_image sent without reference_image_url | Add reference_image_url to the body. The model is image-edit, not pure prompt. |
No complete upper body detected in the video | Source video doesn't show a clear human upper body | Pick a different video; cartoons / wide landscapes / back-of-head shots are rejected. |
Could not fetch image at URL / 404 from provider | URL is signed/expired/private/IP-restricted | Use a stable public URL. Signed URLs need ≥10 min TTL. |
Content flagged as inappropriate (NSFW) | Provider's safety filter triggered | The job is marked failed with NSFW reason; tokens refunded. Retry with different inputs. |
frame_url is required for video_swap | Missing frame_url | Extract first frame on your side, host it publicly, send the URL. |
duration_seconds must be one of 5, 10, 15, 20, 25, 30 | You sent something else | Round up to the nearest tier. |
Invalid JWT / UNAUTHORIZED_INVALID_JWT_FORMAT | Calling 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 start | Retry 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 dropframe_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.