Face Generator API

Everything the buttons do, from a script.

Back to Face Generator

Face Generator is a SkillSafe app, so everything the page does is a plain HTTPS call you can make yourself. Base URL:

https://api.skillsafe.ai/v1/app-api

Every response is an envelope. Success is {"ok": true, "data": {...}}; failure is {"ok": false, "error": {"code": "...", "message": "...", "details": {...}}}. Read data, never the top level.

Error codes

HTTPcodeWhat it means here
401unauthorizedNo token, or it expired. Take a new one from tokens.html.
403forbiddenA guest token tried to run. Runs need a signed-in account unless the publisher sponsors guests.
402payment_requiredBalance below the hold. Image runs hold per image — a six-face batch is six holds.
400validation_errorThe input shape is wrong. For a portrait run the usual cause is a missing $model.
429rate_limitedBack off. Collection queries allow 120/min, similarity 30/min.
503unavailableThe upstream model is down. Retry with the same idempotency key — it will not double-bill.

The two run shapes

Face Generator has one system prompt and two input shapes. Which one you send decides which lane runs — there is no task router on the portrait side, because an image run and a text run are already different calls.

LaneSendGet back
portrait (image) instruction — the compiled brief
$modelgpt-image
output.images[0].b64 and output.images[0].content_type. output.output is empty.
cast (text) task: "cast", project, count, tone, cast_instructions output.output — one JSON object, schema in the instruction set.

The $model override is the whole image lane. Without it a run goes to the app's own text model and you get prose describing a portrait instead of a portrait. With it, the run is priced per image rather than per token.

Keep the portrait input to those two fields. Anything else you add is concatenated into the text the image model sees and tends to get painted into the picture as literal words. That is also why this app's system prompt is four lines long.

One run is one image. A batch of six is six runs. Send them a couple at a time rather than all at once.

1. Get a token

Open tokens.html in the browser, sign in, and copy the shell export. Every call below sends it as Authorization: Bearer <token>. The token is scoped to this app and can spend your credits — treat it as a password.

2. Check the session and balance

/me returns exactly three fields: subject_type, subject_id and credits. There is no email or name, so “signed in?” is subject_type === "user". Credits are hundredths of a cent: 10,000 credits is $1.00.

3. Estimate a portrait render

Free, and it starts no job. On an image model the hold comes back per image and does not vary with the length of the brief, so one probe covers every portrait you will ever send to that renderer. Multiply by your batch size yourself.

Measured on this app: gpt-image holds 2,652 credits (about $0.27) per image and a real render settled at 96 credits (about $0.01). The hold is deliberately far above the settled cost, so quote the hold as reserved and never as the price. Read the live numbers rather than trusting these.

flux-klein also appears in GET /v1/models with available: true, and /estimate quotes it at 30 credits an image. Every actual run against it on this deployment fails with error_code: "internal", so this app does not offer it. A clean estimate is not evidence a model runs.

4. Render one portrait

Returns {"job_id": "..."} immediately. Pass an Idempotency-Key header (or idempotency_key in the body) so a network retry cannot double-bill — but vary it per logical attempt, because an idempotent replay returns the original job even when that job failed.

Twenty to forty seconds per image on gpt-image. The brief below is exactly what the app's compiler produces. Note the opening clause: every brief this app sends declares the subject fictional, and the app will not send one that asks for the likeness of a real person.

5. Poll the job and take the bytes

Poll until status is succeeded or failed. Twenty to forty seconds is typical. A failed run reports charged_credits: null — you are not billed for it.

Then read data.output.images[0].b64 — base64 image bytes, with the MIME type in data.output.images[0].content_type. Do not look in data.output.output; it is empty for image runs, and reading it is the first mistake every text-lane habit produces here. data.charged_credits is what you actually paid, usually below the hold.

6. Write a cast sheet (streaming)

The cast lane is an ordinary text run on the app's model. Send cast_instructions — fetch it from /cast-prompt.js, which builds it from the same controlled vocabulary the portrait form uses, so the attributes it returns are always renderable.

Use /run-stream for Server-Sent Events (delta, job, done), or /run plus polling as above. The reply is one JSON object; each character carries an attributes block you can feed straight back into a portrait run.

Turning a cast character into a portrait

Each character's attributes uses the app's controlled vocabulary. To render one, compile a brief from those values — the app does it in brief.js, and the fixed opening and closing clauses are exported as FaceBrief.FICTIONAL_LEAD and FaceBrief.NEGATIVES. If you build the text yourself, keep both: the first is what makes the subject fictional, the second is what keeps captions and watermarks out of the frame.

Keep briefs inside the renderer's input cap — about 12,000 characters for GPT Image 2. Past the cap the tail is dropped silently, and the tail is where the negative constraints live.

What the API will not do

The likeness guard runs in the browser, not on the server, so a script can send a brief the app itself would refuse. Do not. Face Generator exists to make faces of people who do not exist; generating a synthetic likeness of a real individual is outside what this app is for, and the guard's rules are readable in /guard.js if you want to apply them on your side.