Start free

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.

Updated

What 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.
CheckHowReject when
One faceCount faces with a face detectorNo face, or more than one
Face sizeArea of the face box against the whole imageThe face is under about a tenth of the image
SharpnessVariance of the Laplacian, or the detector's sharpness scoreBelow a threshold you set from real uploads
Sunglasses and coverThe detector's face attributesSunglasses, or eyes, nose or mouth covered
Same personCompare the faces across the photosOne photo doesn't match the others
FileType, bytes and pixel sizeNot PNG, JPEG or WebP, over 4 MB, or under 1,024 pixels on the short side
For face detection, MediaPipe's Face Detector (the @mediapipe/tasks-vision package, Apache 2.0) runs in the browser, so you can turn a photo down before it uploads. On the server, AWS Rekognition's DetectFaces returns a box for each face plus Sunglasses, EyesOpen, FaceOccluded, Pose and a Quality score for brightness and sharpness, and its CompareFaces covers the same-person check. Google Cloud Vision's face detection gives blurredLikelihood, headwearLikelihood and underExposedLikelihood for each face. InsightFace is the usual self-hosted choice, but its pretrained models are licensed for non-commercial research only.Show the reason beside each rejected photo, such as "two faces found" or "too blurry", so people can fix it in one try.

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.
bash
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
Each upload answers with the file:
json
{  "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"}
The API reads the image type from the file's bytes, not from the content-type header. A GIF gets a 400 asking for a PNG, JPEG or WebP image, and a file over 4 MB gets a 413 with the code payload_too_large.

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.
bash
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    }  }'
In the answer, cost_usd is the price of this exact run, model names the version the bare ID resolved to (such as leap/headshot@2), and input shows every option as it will run. A bad input gets the same 400 a generation would: five photos come back with the code invalid_parameter and the param input.photos.Then start the run with the same body. The prefer: wait=60 header holds the request open for up to 60 seconds, so a run that finishes in time comes back complete. The idempotency-key makes a retry after a network error safe: the same key and body return the same generation instead of starting a second one.
bash
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    }  }'
Here is the same flow as one TypeScript script that uploads, quotes, runs and saves the headshots. It has no dependencies.
ts
// 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:
bash
curl -s https://api.tryleap.ai/v1/generations/$GENERATION_ID \  -H "x-api-key: $LEAP_API_KEY" | jq '{status, urls: [.output[].url]}'
A succeeded generation, trimmed:
json
{  "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}
Each output's url is a signed link that expires after 24 hours, so copy the files you keep to your own storage. Reading the generation again signs fresh links. A failed run has an error.message that says why, and it costs nothing.

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:
json
{  "model": "leap/headshot",  "input": {    "photos": ["file_3Kd9Qm2Xr7Lp4Vn8Tc1Wb6Ys", "file_8Vb2Nc5Xm1Qz7Lk4Rt9Wd3Ph", "file_5Tq8Lm2Xv9Kr4Wn7Bc1Yd3Hp"],    "style": "office",    "n": 4  },  "webhook": "https://example.com/api/webhooks/leap"}
The event is signed with your workspace's default webhook secret. Read it once and keep it with your other secrets:
bash
curl -s https://api.tryleap.ai/v1/webhooks/default/secret -H "x-api-key: $LEAP_API_KEY"
Verify every request with a Standard Webhooks library before you trust it. As a Next.js route handler:
ts
// 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 });}
Answer with a 2xx within 15 seconds and do slow work, like copying files, after you answer. Failed deliveries are retried for up to 72 hours, and the same event can arrive twice, so skip any webhook-id you've already handled.

Headshot styles and options

Every option except photos has a default. Leave one out and the quote shows the value it filled in.
FieldValuesWhat it changes
photos1 to 4 file IDsWho is in the headshot. Required.
stylecorporate, executive, bright, office, outdoor, city, creativeThe backdrop and the light, from a grey studio backdrop to a city street
outfitmatch, own, navy-suit, charcoal-blazer, blazer-crewneck, black-turtleneck, knit-sweater, white-shirtmatch (the default) dresses the person for the style; own keeps their clothes from the photos
expressionwarm, soft, confidentA warm smile, a closed-mouth smile or a calm look
glasseskeep, removeKeeps the frames from the photos, or takes them off
aspect_ratio1:1, 4:5, 3:41:1 for LinkedIn and avatars, 4:5 for bios, 3:4 for print and résumés
n1 to 4How many headshots one run makes
GET /v1/models/leap/headshot returns the full input schema with a label for every option, if you'd rather build your form from it.The bare ID leap/headshot runs the newest stable version of the recipe. A new version can change how results look, so pin one in production, such as leap/headshot@2, and move up once you've compared the two. GET /v1/generations?model=leap/headshot lists your runs of every version.

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".
The 2023 fixes were for Stable Diffusion 1.5: square 512 by 512 inputs, a matching square output, and "double torso, totem pole" in the negative prompt. They don't carry over. Many current models take no negative prompt at all (Black Forest Labs says so for FLUX.2), and you choose the output ratio you need.Then check the outputs. Run the same face detector over every headshot before you show it, drop any image without exactly one face or with the face cut off, and run again with n set to the number you dropped.

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.
  1. Find 12 to 20 people who agree to take part, varied in age, skin tone, gender, glasses and facial hair.
  2. From each, take three input photos (a selfie, a three-quarter view, one by someone else) and two held-out ones.
  3. Run every style you offer, once with one photo and once with three, and score each output against the held-out photos.
  4. Ask people who know each subject whether it's them, without saying which images are generated.
Set your pass mark from your own results. This script prints a score for each headshot and rejects any image without exactly one face:
python
# 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}")
It uses InsightFace's buffalo_l model, which is licensed for non-commercial research only. For a check that runs inside your product, license a face model or use a hosted service such as Rekognition's CompareFaces.

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.

Questions people ask