Developer guide
Build an AI headshot generator with the Leap API
In 2023 this tutorial fine-tuned Stable Diffusion on 3 to 15 selfies per person. Today you send one to four photos to a headshot recipe and get finished headshots back, with no training step. This guide builds the whole flow in curl and TypeScript: photo checks, upload, price, run, results and a likeness test.
UpdatedWhat you'll build
A small service where someone uploads a few photos of themselves, picks a style and gets up to four professional headshots back. It takes four calls to the Leap API: upload each photo, quote the run, start it and read the result. Your code checks photos before you spend money on them, stores the results and sets what your users pay.The model is leap/headshot, a Leap recipe: an image model paired with a portrait photographer's prompt and a few typed options. You call it like any other model in the catalog.You need:- A Leap API key from app.tryleap.ai/go/api. Keep it on your server, because it spends your workspace's balance.
- curl and jq for the shell examples, or Node 22.18 or later (or Bun) for the TypeScript one.
- Somewhere to keep the results, such as S3, R2 or Vercel Blob. Leap's links to an output expire after 24 hours.
Do you still need to fine-tune Stable Diffusion?
For headshots, no. The 2023 version of this guide trained a DreamBooth model for each person: upload 3 to 15 cropped 512 by 512 selfies, wait for training, then prompt the new model. Most headshot apps of 2023 worked that way.Image models that take reference photos changed that. Google's Nano Banana Pro, OpenAI's GPT Image 2 and ByteDance's Seedream keep a person's identity from a few photos without a training run. Headshot companies followed: in March 2026 HeadshotPro went from 15 selfies to between 1 and 3, and Dreamwave and Higgsfield's headshot app start from one. There's no per-person model to train, store or delete, and a new style needs no retraining.A per-person LoRA still earns its keep when you need the same face across hundreds of images (a brand character, an avatar product), when you want a strongly stylized look that reference models drift away from, or when you self-host open weights and the identity adapters don't hold your users' faces well enough. Otherwise, start with reference photos.Step 1: Gather your image samples
Garbage in, garbage out still holds. Most bad headshots start as bad inputs, so check photos before you charge anyone for them.Ask for one to four photos and suggest three: one looking straight at the camera, one turned about three-quarters, and one taken by someone else from a few steps away. A selfie at arm's length has wide-angle distortion (a bigger nose, a wider face), and a model given only selfies tends to keep it.A good input photo has one person in it and nobody in the background, the whole face in view (eyes open, no sunglasses or hat), even light, sharp eyes and the person's current hair and glasses. Aim for at least 1,024 pixels on the short side. The upload takes PNG, JPEG or WebP under 4 MB, so convert iPhone HEIC photos and resize large JPEGs first.| Check | How | Reject when |
|---|---|---|
| One face | Count faces with a face detector | No face, or more than one |
| Face size | Area of the face box against the whole image | The face is under about a tenth of the image |
| Sharpness | Variance of the Laplacian, or the detector's sharpness score | Below a threshold you set from real uploads |
| Sunglasses and cover | The detector's face attributes | Sunglasses, or eyes, nose or mouth covered |
| Same person | Compare the faces across the photos | One photo doesn't match the others |
| File | Type, bytes and pixel size | Not PNG, JPEG or WebP, over 4 MB, or under 1,024 pixels on the short side |
Step 2: Upload the photos
Send each photo as the raw request body of POST /v1/files, with no multipart encoding. The filename query parameter is optional. The answer is a file, and its id is what the recipe takes.export LEAP_API_KEY="leap_..." for photo in front.jpg three-quarter.jpg by-a-friend.jpg; do curl -s "https://api.tryleap.ai/v1/files?filename=$photo" \ -H "x-api-key: $LEAP_API_KEY" \ -H "content-type: image/jpeg" \ --data-binary @"$photo" | jq -r .iddone{ "id": "file_3Kd9Qm2Xr7Lp4Vn8Tc1Wb6Ys", "object": "file", "content_type": "image/jpeg", "bytes": 117249, "width": 960, "height": 1280, "duration": null, "filename": "front.jpg", "url": "https://api.tryleap.ai/files/eyJrIjoibWVkaWEv...", "created_at": "2026-10-04T18:20:03.441Z"}Step 3: Get a quote, then generate
POST /v1/quotes takes the same body as a generation. It checks the input, fills in the defaults and returns the exact price, without running anything or holding credit. Call it to show a price before someone pays, and as a free check of the input.curl -s https://api.tryleap.ai/v1/quotes \ -H "x-api-key: $LEAP_API_KEY" \ -H "content-type: application/json" \ -d '{ "model": "leap/headshot", "input": { "photos": ["file_3Kd9Qm2Xr7Lp4Vn8Tc1Wb6Ys", "file_8Vb2Nc5Xm1Qz7Lk4Rt9Wd3Ph", "file_5Tq8Lm2Xv9Kr4Wn7Bc1Yd3Hp"], "style": "corporate", "outfit": "match", "expression": "warm", "glasses": "keep", "aspect_ratio": "1:1", "n": 4 } }'curl -s https://api.tryleap.ai/v1/generations \ -H "x-api-key: $LEAP_API_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: $(uuidgen)" \ -H "prefer: wait=60" \ -d '{ "model": "leap/headshot", "input": { "photos": ["file_3Kd9Qm2Xr7Lp4Vn8Tc1Wb6Ys", "file_8Vb2Nc5Xm1Qz7Lk4Rt9Wd3Ph", "file_5Tq8Lm2Xv9Kr4Wn7Bc1Yd3Hp"], "style": "corporate", "outfit": "match", "expression": "warm", "glasses": "keep", "aspect_ratio": "1:1", "n": 4 } }'// headshots.ts. Run it with: node headshots.ts front.jpg three-quarter.jpgimport { readFile, writeFile } from "node:fs/promises";import { basename } from "node:path"; const API = "https://api.tryleap.ai/v1"; type Generation = { id: string; status: "queued" | "running" | "succeeded" | "failed" | "canceled"; output: { url: string; content_type: string }[]; error: { message: string } | null;}; type Init = { method?: string; headers?: Record<string, string>; body?: RequestInit["body"] }; async function leap<T>(path: string, init: Init = {}): Promise<T> { const response = await fetch(`${API}${path}`, { ...init, headers: { "x-api-key": process.env.LEAP_API_KEY!, ...init.headers }, }); const body = (await response.json()) as T & { error?: { code: string; message: string } }; if (!response.ok) throw new Error(`${body.error?.code}: ${body.error?.message}`); return body;} // Step 2: upload each photo and keep its file ID.async function upload(path: string): Promise<string> { const file = await leap<{ id: string }>(`/files?filename=${encodeURIComponent(basename(path))}`, { method: "POST", headers: { "content-type": "image/jpeg" }, // the API reads the real type from the bytes body: await readFile(path), }); return file.id;} const photos = await Promise.all(process.argv.slice(2).map(upload)); const request = JSON.stringify({ model: "leap/headshot", input: { photos, style: "corporate", outfit: "match", expression: "warm", glasses: "keep", aspect_ratio: "1:1", n: 4, },}); // Step 3: price the run. A quote checks the input and charges nothing.const quote = await leap<{ cost_usd: string }>("/quotes", { method: "POST", headers: { "content-type": "application/json" }, body: request,});console.log(`Four headshots cost $${Number(quote.cost_usd).toFixed(2)}`); // Start it, and wait up to 60 seconds in the request for the result.let generation = await leap<Generation>("/generations", { method: "POST", headers: { "content-type": "application/json", "idempotency-key": crypto.randomUUID(), prefer: "wait=60", }, body: request,}); // Step 4: if it's still running, poll every 5 seconds.while (generation.status === "queued" || generation.status === "running") { await new Promise((resolve) => setTimeout(resolve, 5_000)); generation = await leap<Generation>(`/generations/${generation.id}`);} if (generation.status !== "succeeded") { throw new Error(`${generation.id} ${generation.status}: ${generation.error?.message}`);} // Links expire after 24 hours, so save the files you keep.for (const [index, image] of generation.output.entries()) { const file = await fetch(image.url); const extension = image.content_type.split("/")[1]; await writeFile(`headshot-${index + 1}.${extension}`, Buffer.from(await file.arrayBuffer()));}Step 4: Get the results
A generation's status goes from queued to running and ends as succeeded, failed or canceled. If the wait ran out first, read GET /v1/generations/{id} every few seconds until the status is final:curl -s https://api.tryleap.ai/v1/generations/$GENERATION_ID \ -H "x-api-key: $LEAP_API_KEY" | jq '{status, urls: [.output[].url]}'{ "id": "gen_7Hq2mVx9Lr4Kp8Tn3Wc6Yb1D", "object": "generation", "model": "leap/headshot@2", "status": "succeeded", "output": [ { "type": "image", "url": "https://api.tryleap.ai/files/eyJrIjoibWVkaWEv...", "expires_at": "2026-10-05T18:22:47.120Z", "content_type": "image/jpeg" } ], "error": null}Use a webhook instead of polling
In a web app, let Leap tell you when the run ends. Add a webhook URL to the request body:{ "model": "leap/headshot", "input": { "photos": ["file_3Kd9Qm2Xr7Lp4Vn8Tc1Wb6Ys", "file_8Vb2Nc5Xm1Qz7Lk4Rt9Wd3Ph", "file_5Tq8Lm2Xv9Kr4Wn7Bc1Yd3Hp"], "style": "office", "n": 4 }, "webhook": "https://example.com/api/webhooks/leap"}curl -s https://api.tryleap.ai/v1/webhooks/default/secret -H "x-api-key: $LEAP_API_KEY"// app/api/webhooks/leap/route.ts (Next.js). npm install standardwebhooksimport { Webhook } from "standardwebhooks"; const webhook = new Webhook(process.env.LEAP_WEBHOOK_SECRET!); type LeapEvent = { type: "generation.succeeded" | "generation.failed" | "generation.canceled"; data: { object: { id: string; output: { url: string }[] } };}; export async function POST(request: Request) { const body = await request.text(); let event: LeapEvent; try { event = webhook.verify(body, Object.fromEntries(request.headers)) as LeapEvent; } catch { return new Response("Invalid signature", { status: 400 }); } if (event.type === "generation.succeeded") { // Find the order by event.data.object.id, then copy each output URL to // your own storage: the links expire after 24 hours. } return new Response(null, { status: 204 });}Headshot styles and options
Every option except photos has a default. Leave one out and the quote shows the value it filled in.| Field | Values | What it changes |
|---|---|---|
| photos | 1 to 4 file IDs | Who is in the headshot. Required. |
| style | corporate, executive, bright, office, outdoor, city, creative | The backdrop and the light, from a grey studio backdrop to a city street |
| outfit | match, own, navy-suit, charcoal-blazer, blazer-crewneck, black-turtleneck, knit-sweater, white-shirt | match (the default) dresses the person for the style; own keeps their clothes from the photos |
| expression | warm, soft, confident | A warm smile, a closed-mouth smile or a calm look |
| glasses | keep, remove | Keeps the frames from the photos, or takes them off |
| aspect_ratio | 1:1, 4:5, 3:4 | 1:1 for LinkedIn and avatars, 4:5 for bios, 3:4 for print and résumés |
| n | 1 to 4 | How many headshots one run makes |
How to avoid multiple faces in results
Two heads, a second person behind the first, or two people merged into one face were common with 2023's fine-tuned models. Reference models do it less, and when they do, the cause is nearly always one of these:- A second face in an input: a group photo, a friend cropped badly, a face on a poster or a screen, the reflection in a mirror selfie. Reject any photo where the detector finds more than one face, small ones included.
- Photos of different people in one set, such as a partner's photo added by mistake. The model blends the two or puts both in the frame. Run the same-person check from step 1 across every upload.
- A face cut off at the edge, or shot very close. The model invents what's missing and sometimes invents too much. Ask for head and shoulders with space around them.
- A prompt that describes more than one subject. leap/headshot's prompt asks for one person. If you write your own prompt for a raw model, describe a single subject and say "one person only".
Check likeness before you launch
"Doesn't look like me" is the most common complaint in low-star reviews of headshot apps, so measure likeness before you launch and whenever you move to a new version. The usual tool is a face recognition model such as ArcFace. It turns a face into an embedding, and the cosine similarity of two embeddings says how alike two faces are.Score each headshot against held-out photos: real photos of the same person that the model never saw. Don't score against the photos you sent. A model that pastes the selfie's face, angle and expression onto new clothes scores very high against that selfie, and the result looks pasted.- Find 12 to 20 people who agree to take part, varied in age, skin tone, gender, glasses and facial hair.
- From each, take three input photos (a selfie, a three-quarter view, one by someone else) and two held-out ones.
- Run every style you offer, once with one photo and once with three, and score each output against the held-out photos.
- Ask people who know each subject whether it's them, without saying which images are generated.
# likeness.py: uv run likeness.py held-out-photos/ headshots/# /// script# requires-python = ">=3.10,<3.13"# dependencies = ["insightface==0.7.3", "onnxruntime", "opencv-python-headless", "numpy<2.3"]# ///import sysfrom pathlib import Path import cv2import numpy as npfrom insightface.app import FaceAnalysis # buffalo_l is licensed for non-commercial research only. For a check that# runs in your product, license a model or use a hosted face API.app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"])app.prepare(ctx_id=-1, det_size=(640, 640)) def embedding(path): faces = app.get(cv2.imread(str(path))) if len(faces) != 1: return None # no face, or more than one: reject the image return faces[0].normed_embedding held_out = [e for p in sorted(Path(sys.argv[1]).glob("*.jpg")) if (e := embedding(p)) is not None] for path in sorted(Path(sys.argv[2]).glob("*.jpg")): e = embedding(path) if e is None: print(f"{path.name}: rejected, not exactly one face") continue score = np.mean([float(np.dot(e, h)) for h in held_out]) print(f"{path.name}: {score:.3f}")What an AI headshot costs to make
You pay per headshot, from your workspace's balance, at the price the quote returns. There's no plan or seat fee, and the quote reflects every option that changes the price, such as n.When a run starts, its price is held from your balance. You're charged only if it succeeds. If it fails, is refused or is canceled, the hold is released. If your balance can't cover the hold, the request gets a 402 with the code insufficient_credit and nothing starts.To price your own product, start from the quote and add what your checks throw away, since a regenerated headshot costs the same as the first. Turn on auto top-up so a busy day doesn't stop at a zero balance.Ship it as a product
- Run the checks as people upload, show why a photo failed, and get a better one before you take payment.
- Give a choice: several headshots per run and an easy way to try another style, and let people delete the ones they don't want.
- Set expectations: a good AI headshot looks like the person on a good day, and LinkedIn's rules say a profile photo must reflect your likeness.
- Accept photos only of the user or of someone who agreed, adults only, and no public figures. Many models refuse public figures; don't route around a refusal.
- Treat faces as sensitive data. Face scans and embeddings count as biometric data under laws such as Illinois' BIPA and the EU's GDPR, so get consent before any face check, say how long you keep photos, and delete them on request and on a schedule.
- Keep provenance marks. Google's image models put a SynthID watermark in the pixels, and some models attach C2PA Content Credentials, which LinkedIn can show as a label. Don't strip them, and tell users their headshots are AI-generated.