Developers
Your software, acting as you
The Luv API lets your own code do what you do in the Studio: create personas, give them looks, order shots and hear when one is delivered. It follows the same rules and charges the same prices, to the same Luv balance.
Who it is for
Members who want to automate their own studio: a script that shoots a daily set, a bot, a tool, a pipeline of your own. Every call is made as you, with a key you create and can revoke at any time.
https://api.luv.club/api/v1curl https://api.luv.club/api/v1/me \
-H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/me", {
headers: {
Authorization: `Bearer ${process.env.LUV_API_KEY}`,
},
});
const data = await response.json();Keys with scopes
Give each key only the access it needs, and revoke it at once from Settings.
Same rules, same prices
There is no second price list: a call costs what the same action costs in the app.
Signed webhooks
Hear when a shot is delivered instead of polling, and verify every delivery.
On this page
Quickstart
From a key to a delivered shot
Five steps, about five minutes. The examples run as they are, with curl or with Node 18 or later.
Get a key
Open Settings, then API access, and create a key. For this quickstart tick
personas:read,personas:write,shots:readandshots:write: the default key can read and order shots but cannot create personas. Copy the key when it is shown, because you will not see it again, and keep it in the environment.export LUV_API_KEY="luv_live_EXAMPLE_0123456789abcdef"// Put the key in the environment, never in your code: // export LUV_API_KEY="luv_live_EXAMPLE_0123456789abcdef" const BASE = "https://api.luv.club/api/v1"; async function luv(method, path, { body, headers } = {}) { const response = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, ...(body ? { "Content-Type": "application/json" } : {}), ...headers, }, body: body ? JSON.stringify(body) : undefined, }); const data = await response.json(); if (!response.ok) { // Every error is { code, error }: branch on the code. throw Object.assign(new Error(data.error), { code: data.code, status: response.status }); } return data; }Check who you are
GET /meworks with any key. It returns the account you act for, your Luv balance, your limits, how much of them you have used and the price of a photo.curl https://api.luv.club/api/v1/me \ -H "Authorization: Bearer $LUV_API_KEY"const me = await luv("GET", "/me"); console.log(me.member.name, me.luvs.balance, me.prices.photo);Create a persona and choose a face
Describe her. Her faces are drawn in a couple of minutes: ask for the draft every 3 seconds until it is
ready(or listen forpersona.draft.ready), then choose one. A new persona can be shot at once.# 1. Describe her. The answer is a draft; her faces are being drawn. curl https://api.luv.club/api/v1/personas/drafts \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Mila","age":27,"description":"Warm brown eyes, long dark hair, an easy smile"}' # 2. Ask every few seconds until "state" is "ready", then pick one of "candidates". curl https://api.luv.club/api/v1/personas/drafts/5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30 \ -H "Authorization: Bearer $LUV_API_KEY" # 3. Choose the face. The answer is the persona. curl https://api.luv.club/api/v1/personas/drafts/5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30/choose \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"candidateId":"c1d8f3a2-5b7e-4a90-8e21-6f0c9b3d7a14"}'// Uses luv() from step 1. let { draft } = await luv("POST", "/personas/drafts", { body: { name: "Mila", age: 27, description: "Warm brown eyes, long dark hair, an easy smile" }, }); while (draft.state === "drawing") { await new Promise((resolve) => setTimeout(resolve, 3000)); ({ draft } = await luv("GET", `/personas/drafts/${draft.id}`)); } if (draft.state === "failed") { throw new Error(draft.error); } const { persona } = await luv("POST", `/personas/drafts/${draft.id}/choose`, { body: { candidateId: draft.candidates[0].id }, }); console.log(persona.id, persona.ready);Order a shot
Name the persona, a scene, a format and a content level. The answer comes at once, with the shot queued. The
Idempotency-Keyheader is required: repeating a request with the same key returns the same shots and never charges twice.curl https://api.luv.club/api/v1/shots \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-0001-mila-cafe" \ -d '{"personaId":"3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47","sceneId":"cafe-window","aspectRatio":"3:4","level":1,"count":1}'import { randomUUID } from "node:crypto"; // Uses luv() from step 1. Repeating a request with the same key returns the same shots // and charges nothing twice, so it is safe to retry after a timeout. const { shots } = await luv("POST", "/shots", { headers: { "Idempotency-Key": randomUUID() }, body: { personaId: persona.id, sceneId: "cafe-window", aspectRatio: "3:4", level: 1, count: 1, }, }); console.log(shots[0].id, shots[0].status); // "queued"Get the picture
Ask for the shot every 4 seconds while it is
queuedorrunning, or have Luv tell you: add a webhook endpoint forshot.delivered. A delivered photo costs 5 Luvs; a shot that fails costs nothing.Poll
curl https://api.luv.club/api/v1/shots/a7e1b5c3-92d4-4f06-8b3a-1c6e9d2f4a58 \ -H "Authorization: Bearer $LUV_API_KEY"// Uses luv() from step 1. let { shot } = await luv("GET", `/shots/${shots[0].id}`); while (shot.status === "queued" || shot.status === "running") { await new Promise((resolve) => setTimeout(resolve, 4000)); ({ shot } = await luv("GET", `/shots/${shot.id}`)); } if (shot.status === "done") { console.log(shot.imageUrl); } else { console.log(shot.status, shot.failure); }Or receive a webhook
curl https://api.luv.club/api/v1/webhooks \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/luv/webhooks","events":["shot.delivered","shot.failed"]}'// Needs the webhooks:write scope. Uses luv() from step 1. const { webhook, secret } = await luv("POST", "/webhooks", { body: { url: "https://example.com/luv/webhooks", events: ["shot.delivered", "shot.failed"], }, }); // The secret is shown once. Keep it: it verifies every delivery (see Webhooks below). console.log(webhook.id, secret);
Authentication
Keys, scopes and rate limits
Send your key as a bearer token on every request. A key belongs to one member: it can never read or change another member's personas, looks or shots.
Keys
Authorization: Bearer luv_live_EXAMPLE_0123456789abcdefA key is a long string that starts with luv_live_. You can hold 5 active keys (revoked and expired ones do not count); each has a name, scopes and an optional expiry of up to 365 days. Settings offers 30, 90 or 365. Create and revoke keys there.
The whole key is shown once, when you create it. Luv keeps only a hash of it, so a lost key cannot be recovered: revoke it and make another.
A missing, malformed, unknown, revoked or expired key is answered 401 invalid_api_key. A key without the scope a route needs is answered 403 insufficient_scope, and the keys of a banned account 403 account_banned.
Scopes
A route needs one scope. GET /me and GET /scenes work with any key, and GET /openapi.json needs none. There is no read scope for webhooks: listing endpoints needs webhooks:write too.
| Scope | Allows |
|---|---|
personas:read | List and read your personas and persona drafts.
|
personas:write | Create, edit and delete personas and drafts.
|
looks:read | List your looks.
|
looks:write | Create, edit and delete looks.
|
shots:read | List and read your shots.
|
shots:write | Order shots, which spends Luvs, and cancel or delete them.
|
feed:read | Read the community feed and the Editor's picks.
|
webhooks:write | Add and remove webhook endpoints through the API.
|
Rate limits
120 requests per minute per key. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds since the epoch). Over the limit the answer is 429 rate_limited with a Retry-After header in seconds. Failed attempts to authenticate are limited per address instead of banned, because servers share addresses.
Conventions
How the API behaves
Base URL and format
Everything lives under https://api.luv.club/api/v1. Requests and answers are JSON. A body may hold only the fields a route lists: an unknown field is refused, and a body is at most 16 KB.
Errors
A refusal carries the HTTP status and a JSON body with a code for your program and an error for people, plus the fields the Studio already returns with it (limit, field, balance, issues). Branch on the code, not on the text. An invalid body lists what is wrong in issues.
Every code
- 401
invalid_api_keyThe key is missing, malformed, unknown, revoked or expired.
- 403
insufficient_scopeThe key does not have the scope this route needs; requiredScope names it.
- 403
account_bannedThe account that owns the key is banned.
- 403
tenant_inactiveThis service is not active for the account.
- 429
rate_limitedToo many requests. Wait for Retry-After seconds.
- 400
invalid_inputThe request is not valid; issues says where. A body over 16 KB is answered 413 with this code.
- 422
idempotency_key_reusedThe Idempotency-Key was used before with a different body.
- 409
request_in_progressA request with this Idempotency-Key is still running.
- 402
INSUFFICIENT_LUVSNot enough Luvs. The answer carries balance and requiredLuvs.
- 403
adult-content-level-requiredThe order, look or scene needs a higher content level than the account has.
- 404
not_foundNo such draft, or no such route.
- 404
persona_not_foundNo such persona among yours.
- 404
look_not_foundNo such look among yours.
- 404
scene_not_foundNo such scene at your level.
- 404
shot_not_foundNo such shot: not yours, or not public when remixing.
- 404
webhook_not_foundNo such webhook endpoint among yours.
- 409
conflictThe draft is still drawing, its faces are not ready or expired, or another face was already chosen.
- 409
persona_limitYou have the most personas an account may have, drafts included.
- 409
too_many_draftsTwo drafts are open already. Choose or discard one.
- 409
too_many_in_progressYou have the most shots in progress. The answer carries inProgress and limit.
- 409
persona_not_readyHer face is not pinned and approved yet.
- 409
look_not_for_personaThat look belongs to another persona.
- 409
webhook_limitYou have the most webhook endpoints there can be. Delete one first.
- 422
unsafe_promptA text was refused by the safety rules. field names which one.
- 429
daily_limitThe shots-per-24-hours ceiling is reached. The answer carries limit and orderedToday.
- 503
engine_unavailableThe picture engine is not available right now. Nothing was charged: repeat the request later.
- 503
economy_unavailableLuvs cannot be charged right now. Repeat the request later.
Pagination
Lists are newest first. Ask for a page with limit (1 to 48, 24 by default) and pass the nextCursor of an answer back as cursor; null means the end. A cursor belongs to the list that issued it: a mangled one is answered 400 invalid_input.
Idempotency
POST /shots requires an Idempotency-Key header: 8 to 100 characters from A-Z a-z 0-9 _ . : -, new for every order. Repeating the request with the same key returns the same shots and never charges twice, even after they were cancelled or deleted. POST /personas/drafts and POST /looks honour the header too: the first answer is kept for 24 hours and a repeat carries Idempotent-Replayed: true. The same key with another body is 422 idempotency_key_reused; a repeat while the first request is still running is 409 request_in_progress.
Content levels
Every shot has a level: 1 Romance, 2 Spicy, 3 Explicit. A key acts with its account's level and can never raise it: the level, and the confirmation that you are 18 or older, are set by a person in Settings. An order above the account's level is refused with adult-content-level-required before anything is queued or charged, and scenes and the feed only show what the level allows. The Studio's safety rules apply to every word you send: a refused text is answered 422 unsafe_prompt with the field.
Limits and prices
What it costs and how much you can do
A delivered photo costs 5 Luvs, charged when it arrives from the same balance as in the app. Nothing is charged for a shot that fails, is refused or is cancelled. Your balance must cover the shots already in progress plus the new ones, otherwise the answer is 402 INSUFFICIENT_LUVS with your balance and the requiredLuvs.
Your account's ceilings (personas, shots in progress, shots per 24 hours and shots per order) are the limits of GET /me, and usage says where you stand. A persona is never made from an uploaded photo: every persona is a fictional adult.
Luvs are bought in the store, and Luv+ includes Luvs every week. The pricing page lists the plans and what each one includes.
Reference
Every route
Routes are grouped by object. Each one lists the scope it needs, the fields it takes, the shape of its answer, the codes it can add to the common ones, and an example in curl and JavaScript. The same document is available for tools as OpenAPI.
Account
Who the key acts for, and the machine-readable description of this API.
GET
/meThe member behind the keyAny keyStart here. level is the highest content level your account may order, luvs.balance is null where nothing is charged, and engine says honestly whether photos are being drawn right now.
Scope
Any key
- Answers 200 on success.
Request
This route takes nothing.
Response
{ member, level, luvs: { balance }, limits, usage, prices: { photo }, engine }Errors
- 503economy_unavailable
Example
curl https://api.luv.club/api/v1/me \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/me", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "engine": { "available": true, "paused": false, "reason": null }, "level": 2, "limits": { "inProgress": 4, "perDay": 200, "perOrder": [ 1, 2, 4 ], "personas": 12 }, "luvs": { "balance": 120 }, "member": { "id": "usr_8f3a21c9", "name": "Alex" }, "prices": { "photo": 5 }, "usage": { "inProgress": 0, "orderedToday": 3, "personas": 1 } }GET
/openapi.jsonThe OpenAPI document of this APINo keyScope
No key
- Answers 200 on success.
Request
This route takes nothing.
Response
OpenAPI 3.1 documentErrors
Only the common errors.
Example
curl https://api.luv.club/api/v1/openapi.jsonconst response = await fetch("https://api.luv.club/api/v1/openapi.json"); const data = await response.json();Example answer { "openapi": "3.1.0", "info": { "title": "Luv API", "version": "1.0.0" }, "paths": { "/me": { "get": { "operationId": "getMe" } } } }
Scenes
The shoots you can order, at or below your account's content level.
GET
/scenesList scenesAny keyScope
Any key
- Answers 200 on success.
Request
This route takes nothing.
Response
{ scenes: Scene[] }Errors
Only the common errors.
Example
curl https://api.luv.club/api/v1/scenes \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/scenes", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "scenes": [ { "aspectRatio": "3:4", "id": "cafe-window", "level": 1, "summary": "A quiet corner table and a coffee.", "tags": [ "café", "daylight", "candid" ], "title": "Café window seat" } ] }
Personas
A persona is a fictional adult with a pinned face. Make one from words and a chosen face.
GET
/personasList your personaspersonas:readScope
personas:read
- Answers 200 on success.
Request
This route takes nothing.
Response
{ personas: Persona[], limit: number, canCreate: boolean }Errors
Only the common errors.
Example
curl https://api.luv.club/api/v1/personas \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/personas", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "canCreate": true, "limit": 12, "personas": [ { "age": 27, "avatarUrl": "https://cdn.luv.club/studio/personas/3f2c9d1e.jpg", "createdAt": "2026-10-12T09:14:03.000Z", "description": "Warm brown eyes, long dark hair, an easy smile", "id": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "lookCount": 1, "name": "Mila", "personality": "Playful and curious, always up for a story", "ready": true, "shotCount": 0, "tagline": "Sea and sun" } ] }GET
/personas/:idGet a personapersonas:readScope
personas:read
- Answers 200 on success.
Request
Path
idstringrequiredThe persona's id.
Response
{ persona: Persona }Errors
- 404persona_not_found
Example
curl https://api.luv.club/api/v1/personas/3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47 \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/personas/3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "persona": { "age": 27, "avatarUrl": "https://cdn.luv.club/studio/personas/3f2c9d1e.jpg", "createdAt": "2026-10-12T09:14:03.000Z", "description": "Warm brown eyes, long dark hair, an easy smile", "id": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "lookCount": 1, "name": "Mila", "personality": "Playful and curious, always up for a story", "ready": true, "shotCount": 0, "tagline": "Sea and sun" } }POST
/personas/draftsStart a personapersonas:writeFour faces are drawn from the description. Two drafts may be open at once, and a draft counts toward the persona limit until it is chosen or discarded.
Scope
personas:write
- Answers 202 on success.
- Honours an Idempotency-Key header.
Request
Header
Idempotency-Keystring (8–100)optionalMakes repeating the request safe.
Body
namestring (1–40)requiredHer name.
ageinteger (18–45)requiredWhole years: she is always an adult.
descriptionstring (10–600)requiredHer look and vibe, in words.
Response
{ draft: PersonaDraft }Errors
- 400invalid_input
- 409persona_limit
- 409too_many_drafts
- 409request_in_progress
- 422unsafe_prompt
- 422idempotency_key_reused
- 503engine_unavailable
Example
curl https://api.luv.club/api/v1/personas/drafts \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: draft-2026-10-12-mila" \ -d '{ "age": 27, "description": "Warm brown eyes, long dark hair, an easy smile", "name": "Mila" }'const response = await fetch("https://api.luv.club/api/v1/personas/drafts", { method: "POST", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "draft-2026-10-12-mila", }, body: JSON.stringify({ age: 27, description: "Warm brown eyes, long dark hair, an easy smile", name: "Mila", }), }); const data = await response.json();Example answer { "draft": { "candidates": [], "error": null, "id": "5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30", "state": "drawing" } }GET
/personas/drafts/:idGet a persona draftpersonas:readAsk every 3 seconds while state is drawing, or listen for persona.draft.ready and persona.draft.failed.
Scope
personas:read
- Answers 200 on success.
Request
Path
idstringrequiredThe draft's id.
Response
{ draft: PersonaDraft }Errors
- 404not_found
Example
curl https://api.luv.club/api/v1/personas/drafts/5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30 \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/personas/drafts/5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "draft": { "candidates": [ { "id": "c1d8f3a2-5b7e-4a90-8e21-6f0c9b3d7a14", "imageUrl": "https://cdn.luv.club/studio/drafts/c1d8f3a2.png" }, { "id": "7a4e0d93-2b6c-4d15-9f8a-5c3e1b7d2a69", "imageUrl": "https://cdn.luv.club/studio/drafts/7a4e0d93.png" } ], "error": null, "id": "5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30", "state": "ready" } }POST
/personas/drafts/:id/chooseChoose a facepersonas:writeMakes the persona and pins the face. Choosing again on a finished draft returns the same persona.
Scope
personas:write
- Answers 201 on success.
Request
Path
idstringrequiredThe draft's id.
Body
candidateIdstringrequiredThe id of one of the draft's faces.
personalitystring | null (≤600)optionalHow she is: how she talks and acts in chat.
Response
{ persona: Persona }Errors
- 400invalid_input
- 404not_found
- 409conflict
- 409persona_limit
- 422unsafe_prompt
- 503engine_unavailable
Example
curl https://api.luv.club/api/v1/personas/drafts/5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30/choose \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "candidateId": "c1d8f3a2-5b7e-4a90-8e21-6f0c9b3d7a14", "personality": "Playful and curious, always up for a story" }'const response = await fetch("https://api.luv.club/api/v1/personas/drafts/5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30/choose", { method: "POST", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ candidateId: "c1d8f3a2-5b7e-4a90-8e21-6f0c9b3d7a14", personality: "Playful and curious, always up for a story", }), }); const data = await response.json();Example answer { "persona": { "age": 27, "avatarUrl": "https://cdn.luv.club/studio/personas/3f2c9d1e.jpg", "createdAt": "2026-10-12T09:14:03.000Z", "description": "Warm brown eyes, long dark hair, an easy smile", "id": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "lookCount": 1, "name": "Mila", "personality": "Playful and curious, always up for a story", "ready": true, "shotCount": 0, "tagline": "Sea and sun" } }DELETE
/personas/drafts/:idDiscard a persona draftpersonas:writeScope
personas:write
- Answers 200 on success.
Request
Path
idstringrequiredThe draft's id.
Response
{ ok: true }Errors
- 404not_found
- 409conflict
Example
curl https://api.luv.club/api/v1/personas/drafts/5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30 \ -X DELETE \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/personas/drafts/5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "ok": true }PATCH
/personas/:idChange a personapersonas:writeA field you leave out is kept. An empty string or null clears a tagline or a personality.
Scope
personas:write
- Answers 200 on success.
Request
Path
idstringrequiredThe persona's id.
Body
namestring (1–40)optionalHer name.
taglinestring | null (≤80)optionalA short line under her name.
personalitystring | null (≤600)optionalHow she is: how she talks and acts in chat.
Response
{ persona: Persona }Errors
- 400invalid_input
- 404persona_not_found
- 422unsafe_prompt
Example
curl https://api.luv.club/api/v1/personas/3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47 \ -X PATCH \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tagline": "Sea and sun" }'const response = await fetch("https://api.luv.club/api/v1/personas/3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ tagline: "Sea and sun", }), }); const data = await response.json();Example answer { "persona": { "age": 27, "avatarUrl": "https://cdn.luv.club/studio/personas/3f2c9d1e.jpg", "createdAt": "2026-10-12T09:14:03.000Z", "description": "Warm brown eyes, long dark hair, an easy smile", "id": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "lookCount": 1, "name": "Mila", "personality": "Playful and curious, always up for a story", "ready": true, "shotCount": 0, "tagline": "Sea and sun" } }DELETE
/personas/:idDelete a personapersonas:writeRemoves her at once, with her looks, shots and chats. This cannot be undone.
Scope
personas:write
- Answers 200 on success.
Request
Path
idstringrequiredThe persona's id.
Response
{ ok: true }Errors
- 404persona_not_found
Example
curl https://api.luv.club/api/v1/personas/3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47 \ -X DELETE \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/personas/3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "ok": true }
Looks
An outfit saved in words, for one persona or for all of them.
GET
/looksList lookslooks:readScope
looks:read
- Answers 200 on success.
Request
Query
personaIdstringoptionalOnly this persona's looks, plus the ones that fit any persona.
Response
{ looks: Look[] }Errors
Only the common errors.
Example
curl "https://api.luv.club/api/v1/looks?personaId=3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47" \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/looks?personaId=3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "looks": [ { "coverUrl": null, "createdAt": "2026-10-12T09:14:03.000Z", "id": "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", "level": 1, "name": "Linen summer", "outfit": "white linen shirt, high-waisted shorts, small gold hoops", "personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47" } ] }POST
/looksCreate a looklooks:writeLeave personaId out for a look that fits any persona. A look above your account's content level is refused.
Scope
looks:write
- Answers 201 on success.
- Honours an Idempotency-Key header.
Request
Header
Idempotency-Keystring (8–100)optionalMakes repeating the request safe.
Body
namestring (1–60)requiredA name for the look.
outfitstring (1–300)requiredClothes, hair, make-up, accessories, in words.
level1 | 2 | 3requiredThe content level the look needs.
personaIdstring | nulloptionalThe persona the look is for; null or left out for any persona.
coverShotIdstring | nulloptionalOne of your shots, to show as the look's cover.
Response
{ look: Look }Errors
- 400invalid_input
- 403adult-content-level-required
- 404persona_not_found
- 404shot_not_found
- 409request_in_progress
- 422unsafe_prompt
- 422idempotency_key_reused
Example
curl https://api.luv.club/api/v1/looks \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: look-linen-summer" \ -d '{ "level": 1, "name": "Linen summer", "outfit": "white linen shirt, high-waisted shorts, small gold hoops", "personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47" }'const response = await fetch("https://api.luv.club/api/v1/looks", { method: "POST", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "look-linen-summer", }, body: JSON.stringify({ level: 1, name: "Linen summer", outfit: "white linen shirt, high-waisted shorts, small gold hoops", personaId: "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", }), }); const data = await response.json();Example answer { "look": { "coverUrl": null, "createdAt": "2026-10-12T09:14:03.000Z", "id": "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", "level": 1, "name": "Linen summer", "outfit": "white linen shirt, high-waisted shorts, small gold hoops", "personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47" } }PATCH
/looks/:idChange a looklooks:writeScope
looks:write
- Answers 200 on success.
Request
Path
idstringrequiredThe look's id.
Body
namestring (1–60)optionalA name for the look.
outfitstring (1–300)optionalClothes, hair, make-up, accessories, in words.
level1 | 2 | 3optionalThe content level the look needs.
personaIdstring | nulloptionalThe persona the look is for; null or left out for any persona.
coverShotIdstring | nulloptionalOne of your shots, to show as the look's cover.
Response
{ look: Look }Errors
- 400invalid_input
- 403adult-content-level-required
- 404look_not_found
- 404persona_not_found
- 422unsafe_prompt
Example
curl https://api.luv.club/api/v1/looks/0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03 \ -X PATCH \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "outfit": "white linen shirt, denim shorts" }'const response = await fetch("https://api.luv.club/api/v1/looks/0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ outfit: "white linen shirt, denim shorts", }), }); const data = await response.json();Example answer { "look": { "coverUrl": null, "createdAt": "2026-10-12T09:14:03.000Z", "id": "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", "level": 1, "name": "Linen summer", "outfit": "white linen shirt, high-waisted shorts, small gold hoops", "personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47" } }DELETE
/looks/:idDelete a looklooks:writeScope
looks:write
- Answers 200 on success.
Request
Path
idstringrequiredThe look's id.
Response
{ ok: true }Errors
- 404look_not_found
Example
curl https://api.luv.club/api/v1/looks/0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03 \ -X DELETE \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/looks/0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "ok": true }
Shots
A shot is one photo, drawn on request and charged when it is delivered.
POST
/shotsOrder shotsshots:writeA new order needs aspectRatio and level, and at least a scene, a look or some words. A remix (remixOf) copies the look, scene, words, format and level of a public shot or one of your own and changes only the persona; those fields are then ignored. Cancelled shots keep their slot among your shots in progress until the engine ends their job.
Scope
shots:write
- Answers 202 on success.
- Needs an Idempotency-Key header.
Request
Header
Idempotency-Keystring (8–100)requiredMakes repeating the request safe.
Body
personaIdstringrequiredThe persona to shoot.
lookIdstringoptionalOne of your looks, for this persona or for any.
sceneIdstringoptionalA scene id from GET /scenes.
promptstring (≤600)optionalYour own words about the picture.
aspectRatio"9:16" | "3:4" | "1:1" | "16:9"optionalThe format of the photo. Required unless remixOf is set.
level1 | 2 | 3optionalThe content level of the order, at most your account's. Required unless remixOf is set.
count1 | 2 | 4optionalHow many photos: 1, 2 or 4. 1 by default.
remixOfstringoptionalA shot whose recipe is copied; only the persona changes.
Response
{ shots: Shot[] }Errors
- 400invalid_input
- 402INSUFFICIENT_LUVS
- 403adult-content-level-required
- 404persona_not_found
- 404look_not_found
- 404scene_not_found
- 404shot_not_found
- 409too_many_in_progress
- 409persona_not_ready
- 409look_not_for_persona
- 422unsafe_prompt
- 429daily_limit
- 503engine_unavailable
- 503economy_unavailable
Example
curl https://api.luv.club/api/v1/shots \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-2026-10-12-0001" \ -d '{ "aspectRatio": "3:4", "count": 1, "level": 1, "lookId": "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", "personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "prompt": "She laughs at something off camera", "sceneId": "cafe-window" }'const response = await fetch("https://api.luv.club/api/v1/shots", { method: "POST", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "order-2026-10-12-0001", }, body: JSON.stringify({ aspectRatio: "3:4", count: 1, level: 1, lookId: "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", personaId: "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", prompt: "She laughs at something off camera", sceneId: "cafe-window", }), }); const data = await response.json();Example answer { "shots": [ { "caption": null, "createdAt": "2026-10-12T09:14:03.000Z", "failure": null, "id": "a7e1b5c3-92d4-4f06-8b3a-1c6e9d2f4a58", "level": 1, "likeCount": 0, "likedByMe": false, "personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "personaName": "Mila", "recipe": { "aspectRatio": "3:4", "level": 1, "lookId": "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", "lookName": "Linen summer", "outfit": "white linen shirt, high-waisted shorts, small gold hoops", "prompt": "She laughs at something off camera", "remixOf": null, "sceneId": "cafe-window", "sceneTitle": "Café window seat", "seed": 482913 }, "rejectReason": null, "remixCount": 0, "visibility": "private", "deliveredAt": null, "height": null, "imageUrl": null, "luvs": 0, "status": "queued", "width": null } ] }GET
/shotsList your shotsshots:readScope
shots:read
- Answers 200 on success.
Request
Query
personaIdstringoptionalOnly this persona's shots.
status"queued" | "running" | "done" | "failed" | "refused" | "cancelled"optionalOnly shots in this state.
cursorstringoptionalThe nextCursor of the previous page.
limitinteger (1–48)optionalPage size: 24 by default.
Response
{ shots: Shot[], nextCursor: string | null }Errors
- 400invalid_input
Example
curl "https://api.luv.club/api/v1/shots?personaId=3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47&status=done" \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/shots?personaId=3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47&status=done", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "nextCursor": null, "shots": [ { "caption": null, "createdAt": "2026-10-12T09:14:03.000Z", "failure": null, "id": "a7e1b5c3-92d4-4f06-8b3a-1c6e9d2f4a58", "level": 1, "likeCount": 0, "likedByMe": false, "personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "personaName": "Mila", "recipe": { "aspectRatio": "3:4", "level": 1, "lookId": "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", "lookName": "Linen summer", "outfit": "white linen shirt, high-waisted shorts, small gold hoops", "prompt": "She laughs at something off camera", "remixOf": null, "sceneId": "cafe-window", "sceneTitle": "Café window seat", "seed": 482913 }, "rejectReason": null, "remixCount": 0, "visibility": "private", "deliveredAt": "2026-10-12T09:16:41.000Z", "height": 2048, "imageUrl": "https://cdn.luv.club/studio/shots/a7e1b5c3.png", "luvs": 5, "status": "done", "width": 1536 } ] }GET
/shots/:idGet a shotshots:readAsk every 4 seconds while status is queued or running, or listen for shot.delivered and shot.failed.
Scope
shots:read
- Answers 200 on success.
Request
Path
idstringrequiredThe shot's id.
Response
{ shot: Shot }Errors
- 404shot_not_found
Example
curl https://api.luv.club/api/v1/shots/a7e1b5c3-92d4-4f06-8b3a-1c6e9d2f4a58 \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/shots/a7e1b5c3-92d4-4f06-8b3a-1c6e9d2f4a58", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "shot": { "caption": null, "createdAt": "2026-10-12T09:14:03.000Z", "failure": null, "id": "a7e1b5c3-92d4-4f06-8b3a-1c6e9d2f4a58", "level": 1, "likeCount": 0, "likedByMe": false, "personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "personaName": "Mila", "recipe": { "aspectRatio": "3:4", "level": 1, "lookId": "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", "lookName": "Linen summer", "outfit": "white linen shirt, high-waisted shorts, small gold hoops", "prompt": "She laughs at something off camera", "remixOf": null, "sceneId": "cafe-window", "sceneTitle": "Café window seat", "seed": 482913 }, "rejectReason": null, "remixCount": 0, "visibility": "private", "deliveredAt": "2026-10-12T09:16:41.000Z", "height": 2048, "imageUrl": "https://cdn.luv.club/studio/shots/a7e1b5c3.png", "luvs": 5, "status": "done", "width": 1536 } }DELETE
/shots/:idCancel or delete a shotshots:writeA shot being drawn is cancelled and never charged; a finished one is deleted, and leaves the feed and any look cover.
Scope
shots:write
- Answers 200 on success.
Request
Path
idstringrequiredThe shot's id.
Response
{ ok: true }Errors
- 404shot_not_found
Example
curl https://api.luv.club/api/v1/shots/a7e1b5c3-92d4-4f06-8b3a-1c6e9d2f4a58 \ -X DELETE \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/shots/a7e1b5c3-92d4-4f06-8b3a-1c6e9d2f4a58", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "ok": true }
Feed
The community feed and the Editor's picks, read only. Publishing, likes and reports stay with a person in the app.
GET
/feedRead the community feedfeed:readPublic shots at or below your account's content level.
Scope
feed:read
- Answers 200 on success.
Request
Query
sort"new" | "top"optionalnew (the default) or top.
personastringoptionalOnly this persona's shots: the persona.id of a feed shot. The nextCursor then works only with the same persona.
cursorstringoptionalThe nextCursor of the previous page.
limitinteger (1–48)optionalPage size: 24 by default.
Response
{ shots: FeedShot[], nextCursor: string | null }Errors
- 400invalid_input
Example
curl "https://api.luv.club/api/v1/feed?sort=top" \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/feed?sort=top", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "nextCursor": null, "shots": [ { "author": { "id": "usr_2b7d90e4", "name": "Sam" }, "caption": "Late summer", "height": 2048, "id": "9c3f6a1d-4e82-4b07-a5d9-0f7e2c8b1a34", "imageUrl": "https://cdn.luv.club/studio/shots/9c3f6a1d.png", "level": 1, "likeCount": 12, "likedByMe": false, "lookName": "Linen summer", "persona": { "avatarUrl": "https://cdn.luv.club/studio/personas/3f2c9d1e.jpg", "id": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "name": "Mila" }, "publishedAt": "2026-10-11T18:30:00.000Z", "remixCount": 2, "sceneTitle": "Café window seat", "width": 1536 } ] }GET
/picksRead the Editor's picksfeed:readScope
feed:read
- Answers 200 on success.
Request
This route takes nothing.
Response
{ shelves: { id, title, shots: FeedShot[] }[] }Errors
Only the common errors.
Example
curl https://api.luv.club/api/v1/picks \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/picks", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "shelves": [ { "id": "summer", "shots": [ { "author": { "id": "usr_2b7d90e4", "name": "Sam" }, "caption": "Late summer", "height": 2048, "id": "9c3f6a1d-4e82-4b07-a5d9-0f7e2c8b1a34", "imageUrl": "https://cdn.luv.club/studio/shots/9c3f6a1d.png", "level": 1, "likeCount": 12, "likedByMe": false, "lookName": "Linen summer", "persona": { "avatarUrl": "https://cdn.luv.club/studio/personas/3f2c9d1e.jpg", "id": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "name": "Mila" }, "publishedAt": "2026-10-11T18:30:00.000Z", "remixCount": 2, "sceneTitle": "Café window seat", "width": 1536 } ], "title": "Late summer" } ] }
Webhooks
Manage the endpoints that are told when something happens.
GET
/webhooksList webhook endpointswebhooks:writeNeeds webhooks:write, as there is no read scope for webhooks.
Scope
webhooks:write
- Answers 200 on success.
Request
This route takes nothing.
Response
{ webhooks: Webhook[] }Errors
Only the common errors.
Example
curl https://api.luv.club/api/v1/webhooks \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/webhooks", { headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "webhooks": [ { "createdAt": "2026-10-12T09:14:03.000Z", "disabledAt": null, "disabledReason": null, "events": [ "shot.delivered", "shot.failed" ], "id": "e4c0a8d2-6b31-4f97-a5e8-3d7b1c9f0a62", "lastDeliveryAt": null, "lastStatus": null, "url": "https://example.com/luv/webhooks" } ] }POST
/webhooksAdd a webhook endpointwebhooks:writeThe signing secret is in the answer once. The address must be a public HTTPS host name on port 443 or 8443: no IP addresses, no private or local names.
Scope
webhooks:write
- Answers 201 on success.
Request
Body
urlstring (https, ≤2048)requiredWhere deliveries are sent.
eventsWebhookEvent[] (1+)requiredThe events to receive, without repeats.
Response
{ webhook: Webhook, secret: string }Errors
- 400invalid_input
- 409webhook_limit
Example
curl https://api.luv.club/api/v1/webhooks \ -H "Authorization: Bearer $LUV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "events": [ "shot.delivered", "shot.failed" ], "url": "https://example.com/luv/webhooks" }'const response = await fetch("https://api.luv.club/api/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ events: ["shot.delivered", "shot.failed"], url: "https://example.com/luv/webhooks", }), }); const data = await response.json();Example answer { "secret": "whsec_EXAMPLE_0123456789abcdef", "webhook": { "createdAt": "2026-10-12T09:14:03.000Z", "disabledAt": null, "disabledReason": null, "events": [ "shot.delivered", "shot.failed" ], "id": "e4c0a8d2-6b31-4f97-a5e8-3d7b1c9f0a62", "lastDeliveryAt": null, "lastStatus": null, "url": "https://example.com/luv/webhooks" } }DELETE
/webhooks/:idDelete a webhook endpointwebhooks:writeScope
webhooks:write
- Answers 200 on success.
Request
Path
idstringrequiredThe endpoint's id.
Response
{ ok: true }Errors
- 404webhook_not_found
Example
curl https://api.luv.club/api/v1/webhooks/e4c0a8d2-6b31-4f97-a5e8-3d7b1c9f0a62 \ -X DELETE \ -H "Authorization: Bearer $LUV_API_KEY"const response = await fetch("https://api.luv.club/api/v1/webhooks/e4c0a8d2-6b31-4f97-a5e8-3d7b1c9f0a62", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.LUV_API_KEY}`, }, }); const data = await response.json();Example answer { "ok": true }
Objects
The objects are the Studio's own. The one difference of vocabulary: the Studio calls a persona a companion, the API says personaId everywhere.
PersonaA persona. ready means her face is pinned and approved, so she can be shot.Persona { "age": 27, "avatarUrl": "https://cdn.luv.club/studio/personas/3f2c9d1e.jpg", "createdAt": "2026-10-12T09:14:03.000Z", "description": "Warm brown eyes, long dark hair, an easy smile", "id": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "lookCount": 1, "name": "Mila", "personality": "Playful and curious, always up for a story", "ready": true, "shotCount": 0, "tagline": "Sea and sun" }PersonaDraftA persona being made. state is drawing, ready or failed; a ready draft carries up to four faces to choose from.PersonaDraft { "candidates": [ { "id": "c1d8f3a2-5b7e-4a90-8e21-6f0c9b3d7a14", "imageUrl": "https://cdn.luv.club/studio/drafts/c1d8f3a2.png" }, { "id": "7a4e0d93-2b6c-4d15-9f8a-5c3e1b7d2a69", "imageUrl": "https://cdn.luv.club/studio/drafts/7a4e0d93.png" } ], "error": null, "id": "5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30", "state": "ready" }LookAn outfit in words. personaId is null for a look that fits any persona.Look { "coverUrl": null, "createdAt": "2026-10-12T09:14:03.000Z", "id": "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", "level": 1, "name": "Linen summer", "outfit": "white linen shirt, high-waisted shorts, small gold hoops", "personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47" }SceneA shoot you can order, with the content level it needs.Scene { "aspectRatio": "3:4", "id": "cafe-window", "level": 1, "summary": "A quiet corner table and a coffee.", "tags": [ "café", "daylight", "candid" ], "title": "Café window seat" }ShotOne photo. status is queued, running, done, failed, refused or cancelled; failure says why there is no picture (face_drift, engine_failed, expired, insufficient_luvs or unsafe_prompt); luvs is what was charged, 0 until delivery.Shot { "caption": null, "createdAt": "2026-10-12T09:14:03.000Z", "failure": null, "id": "a7e1b5c3-92d4-4f06-8b3a-1c6e9d2f4a58", "level": 1, "likeCount": 0, "likedByMe": false, "personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "personaName": "Mila", "recipe": { "aspectRatio": "3:4", "level": 1, "lookId": "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03", "lookName": "Linen summer", "outfit": "white linen shirt, high-waisted shorts, small gold hoops", "prompt": "She laughs at something off camera", "remixOf": null, "sceneId": "cafe-window", "sceneTitle": "Café window seat", "seed": 482913 }, "rejectReason": null, "remixCount": 0, "visibility": "private", "deliveredAt": "2026-10-12T09:16:41.000Z", "height": 2048, "imageUrl": "https://cdn.luv.club/studio/shots/a7e1b5c3.png", "luvs": 5, "status": "done", "width": 1536 }FeedShotA public shot as other members see it.FeedShot { "author": { "id": "usr_2b7d90e4", "name": "Sam" }, "caption": "Late summer", "height": 2048, "id": "9c3f6a1d-4e82-4b07-a5d9-0f7e2c8b1a34", "imageUrl": "https://cdn.luv.club/studio/shots/9c3f6a1d.png", "level": 1, "likeCount": 12, "likedByMe": false, "lookName": "Linen summer", "persona": { "avatarUrl": "https://cdn.luv.club/studio/personas/3f2c9d1e.jpg", "id": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47", "name": "Mila" }, "publishedAt": "2026-10-11T18:30:00.000Z", "remixCount": 2, "sceneTitle": "Café window seat", "width": 1536 }WebhookA webhook endpoint. disabledAt is set when it was switched off.Webhook { "createdAt": "2026-10-12T09:14:03.000Z", "disabledAt": null, "disabledReason": null, "events": [ "shot.delivered", "shot.failed" ], "id": "e4c0a8d2-6b31-4f97-a5e8-3d7b1c9f0a62", "lastDeliveryAt": null, "lastStatus": null, "url": "https://example.com/luv/webhooks" }
Webhooks
Hear it when it happens
Register up to 3 HTTPS endpoints in Settings or with POST /webhooks. Luv sends a signed request to each one when an event it listens to happens, so you never have to poll. An endpoint hears about everything of yours, whether it was made through the API or in the app.
Events
shot.deliveredA shot was drawn and delivered.
data holds:
shotshot.failedA shot failed, was refused or was cancelled; failure says why, and is null for a cancelled one.
data holds:
shotpersona.draft.readyThe faces of a persona draft are ready.
data holds:
draftpersona.draft.failedA persona draft could not be drawn.
data holds:
draftpersona.createdA face was chosen and the persona exists.
data holds:
persona
A delivery
A delivery is a POST with a JSON body: id, type, createdAt and data. These headers come with it.
Luv-Event-IdThe event's id. It is the same on every attempt: deduplicate on it.
evt_5e8b2d7a9c14Luv-Event-TypeThe event type, such as shot.delivered.
shot.deliveredLuv-TimestampWhen this attempt was made, in seconds since the epoch.
1791796601Luv-Signaturev1= and the hex HMAC-SHA256 of the timestamp, a dot and the raw body, with the endpoint's secret.
v1=a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
{
"createdAt": "2026-10-12T09:16:41.000Z",
"data": {
"shot": {
"caption": null,
"createdAt": "2026-10-12T09:14:03.000Z",
"failure": null,
"id": "a7e1b5c3-92d4-4f06-8b3a-1c6e9d2f4a58",
"level": 1,
"likeCount": 0,
"likedByMe": false,
"personaId": "3f2c9d1e-7a54-4b86-9c0e-5d1b8a6f2e47",
"personaName": "Mila",
"recipe": {
"aspectRatio": "3:4",
"level": 1,
"lookId": "0b9a4c6e-d317-4825-a6f1-2e8b5d9c7f03",
"lookName": "Linen summer",
"outfit": "white linen shirt, high-waisted shorts, small gold hoops",
"prompt": "She laughs at something off camera",
"remixOf": null,
"sceneId": "cafe-window",
"sceneTitle": "Café window seat",
"seed": 482913
},
"rejectReason": null,
"remixCount": 0,
"visibility": "private",
"deliveredAt": "2026-10-12T09:16:41.000Z",
"height": 2048,
"imageUrl": "https://cdn.luv.club/studio/shots/a7e1b5c3.png",
"luvs": 5,
"status": "done",
"width": 1536
}
},
"id": "evt_5e8b2d7a9c14",
"type": "shot.delivered"
}Verify the signature
Compute the HMAC-SHA256 of the timestamp, a dot and the raw body, with the endpoint's secret. Compare it with the signature in constant time, and refuse a timestamp that is too far from your clock (300 seconds in the example below). Use the raw bytes you received: parsing the JSON first changes them.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const SECRET = process.env.LUV_WEBHOOK_SECRET; // whsec_EXAMPLE_0123456789abcdef
const TOLERANCE_SECONDS = 300;
function isGenuine(rawBody, headers) {
const timestamp = headers["luv-timestamp"];
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
return false; // missing, malformed or too old: someone may be replaying it
}
const expected = createHmac("sha256", SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const signature = headers["luv-signature"] ?? "";
const given = signature.startsWith("v1=") ? signature.slice(3) : "";
const a = Buffer.from(expected, "hex");
const b = Buffer.from(given, "hex");
return a.length === b.length && timingSafeEqual(a, b); // constant-time compare
}
const handled = new Set(); // keep these in your database; deliveries can repeat
const app = express();
// The signature covers the exact bytes Luv sent, so read the raw body, not parsed JSON.
app.post("/luv/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!isGenuine(rawBody, req.headers)) {
return res.sendStatus(400);
}
const event = JSON.parse(rawBody);
if (!handled.has(event.id)) {
handled.add(event.id);
if (event.type === "shot.delivered") {
console.log(event.data.shot.imageUrl);
}
}
res.sendStatus(200); // any 2xx within 10 seconds counts as delivered
});
app.listen(3000);Retries and guarantees
Answer with any 2xx within 10 seconds. Anything else, a redirect included, is a failure, and Luv tries again after about 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then drops the delivery. An endpoint whose last 20 deliveries were all dropped is switched off, and you are told in Settings.
Deliveries are at-least-once and may arrive out of order, so make your handler idempotent and key it on Luv-Event-Id. Luv resolves your host name again at every delivery and never connects to a private address.
Settings shows the latest deliveries of each endpoint, with the attempt and the status your server answered.
Ready when you are
Create a key, run the quickstart, and see your first shot arrive.
Create an API key