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.

Base URLhttps://api.luv.club/api/v1
curl https://api.luv.club/api/v1/me \
  -H "Authorization: Bearer $LUV_API_KEY"
OpenAPI document
  • 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.

  1. Get a key

    Open Settings, then API access, and create a key. For this quickstart tick personas:read, personas:write, shots:read and shots: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"
  2. Check who you are

    GET /me works 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"
  3. 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 for persona.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"}'
  4. 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-Key header 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}'
  5. Get the picture

    Ask for the shot every 4 seconds while it is queued or running, or have Luv tell you: add a webhook endpoint for shot.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"

    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"]}'

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
Authorization: Bearer luv_live_EXAMPLE_0123456789abcdef

A 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.

Scopes
ScopeAllows
personas:read

List and read your personas and persona drafts.

GET /personasGET /personas/:idGET /personas/drafts/:id

personas:write

Create, edit and delete personas and drafts.

POST /personas/draftsPOST /personas/drafts/:id/chooseDELETE /personas/drafts/:idPATCH /personas/:idDELETE /personas/:id

looks:read

List your looks.

GET /looks

looks:write

Create, edit and delete looks.

POST /looksPATCH /looks/:idDELETE /looks/:id

shots:read

List and read your shots.

GET /shotsGET /shots/:id

shots:write

Order shots, which spends Luvs, and cancel or delete them.

POST /shotsDELETE /shots/:id

feed:read

Read the community feed and the Editor's picks.

GET /feedGET /picks

webhooks:write

Add and remove webhook endpoints through the API.

GET /webhooksPOST /webhooksDELETE /webhooks/:id

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

  • 401invalid_api_key

    The key is missing, malformed, unknown, revoked or expired.

  • 403insufficient_scope

    The key does not have the scope this route needs; requiredScope names it.

  • 403account_banned

    The account that owns the key is banned.

  • 403tenant_inactive

    This service is not active for the account.

  • 429rate_limited

    Too many requests. Wait for Retry-After seconds.

  • 400invalid_input

    The request is not valid; issues says where. A body over 16 KB is answered 413 with this code.

  • 422idempotency_key_reused

    The Idempotency-Key was used before with a different body.

  • 409request_in_progress

    A request with this Idempotency-Key is still running.

  • 402INSUFFICIENT_LUVS

    Not enough Luvs. The answer carries balance and requiredLuvs.

  • 403adult-content-level-required

    The order, look or scene needs a higher content level than the account has.

  • 404not_found

    No such draft, or no such route.

  • 404persona_not_found

    No such persona among yours.

  • 404look_not_found

    No such look among yours.

  • 404scene_not_found

    No such scene at your level.

  • 404shot_not_found

    No such shot: not yours, or not public when remixing.

  • 404webhook_not_found

    No such webhook endpoint among yours.

  • 409conflict

    The draft is still drawing, its faces are not ready or expired, or another face was already chosen.

  • 409persona_limit

    You have the most personas an account may have, drafts included.

  • 409too_many_drafts

    Two drafts are open already. Choose or discard one.

  • 409too_many_in_progress

    You have the most shots in progress. The answer carries inProgress and limit.

  • 409persona_not_ready

    Her face is not pinned and approved yet.

  • 409look_not_for_persona

    That look belongs to another persona.

  • 409webhook_limit

    You have the most webhook endpoints there can be. Delete one first.

  • 422unsafe_prompt

    A text was refused by the safety rules. field names which one.

  • 429daily_limit

    The shots-per-24-hours ceiling is reached. The answer carries limit and orderedToday.

  • 503engine_unavailable

    The picture engine is not available right now. Nothing was charged: repeat the request later.

  • 503economy_unavailable

    Luvs 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.

See pricing

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.

Open the OpenAPI document

Account

Who the key acts for, and the machine-readable description of this API.

  • GET/meThe member behind the keyAny key

    Start 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"
    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 key

    Scope

    No key

    • Answers 200 on success.

    Request

    This route takes nothing.

    Response

    OpenAPI 3.1 document

    Errors

    Only the common errors.

    Example

    curl https://api.luv.club/api/v1/openapi.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 key

    Scope

    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"
    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:read

    Scope

    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"
    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:read

    Scope

    personas:read

    • Answers 200 on success.

    Request

    Path

    • idstringrequired

      The 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"
    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:write

    Four 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)optional

      Makes repeating the request safe.

    Body

    • namestring (1–40)required

      Her name.

    • ageinteger (18–45)required

      Whole years: she is always an adult.

    • descriptionstring (10–600)required

      Her 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"
    }'
    Example answer
    {
      "draft": {
        "candidates": [],
        "error": null,
        "id": "5d2e7b19-0c4a-4f68-b3a7-9e1f2c8d6a30",
        "state": "drawing"
      }
    }
  • GET/personas/drafts/:idGet a persona draftpersonas:read

    Ask 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

    • idstringrequired

      The 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"
    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:write

    Makes 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

    • idstringrequired

      The draft's id.

    Body

    • candidateIdstringrequired

      The id of one of the draft's faces.

    • personalitystring | null (≤600)optional

      How 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"
    }'
    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:write

    Scope

    personas:write

    • Answers 200 on success.

    Request

    Path

    • idstringrequired

      The 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"
    Example answer
    {
      "ok": true
    }
  • PATCH/personas/:idChange a personapersonas:write

    A 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

    • idstringrequired

      The persona's id.

    Body

    • namestring (1–40)optional

      Her name.

    • taglinestring | null (≤80)optional

      A short line under her name.

    • personalitystring | null (≤600)optional

      How 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"
    }'
    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:write

    Removes her at once, with her looks, shots and chats. This cannot be undone.

    Scope

    personas:write

    • Answers 200 on success.

    Request

    Path

    • idstringrequired

      The 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"
    Example answer
    {
      "ok": true
    }

Looks

An outfit saved in words, for one persona or for all of them.

  • GET/looksList lookslooks:read

    Scope

    looks:read

    • Answers 200 on success.

    Request

    Query

    • personaIdstringoptional

      Only 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"
    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:write

    Leave 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)optional

      Makes repeating the request safe.

    Body

    • namestring (1–60)required

      A name for the look.

    • outfitstring (1–300)required

      Clothes, hair, make-up, accessories, in words.

    • level1 | 2 | 3required

      The content level the look needs.

    • personaIdstring | nulloptional

      The persona the look is for; null or left out for any persona.

    • coverShotIdstring | nulloptional

      One 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"
    }'
    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:write

    Scope

    looks:write

    • Answers 200 on success.

    Request

    Path

    • idstringrequired

      The look's id.

    Body

    • namestring (1–60)optional

      A name for the look.

    • outfitstring (1–300)optional

      Clothes, hair, make-up, accessories, in words.

    • level1 | 2 | 3optional

      The content level the look needs.

    • personaIdstring | nulloptional

      The persona the look is for; null or left out for any persona.

    • coverShotIdstring | nulloptional

      One 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"
    }'
    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:write

    Scope

    looks:write

    • Answers 200 on success.

    Request

    Path

    • idstringrequired

      The 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"
    Example answer
    {
      "ok": true
    }

Shots

A shot is one photo, drawn on request and charged when it is delivered.

  • POST/shotsOrder shotsshots:write

    A 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)required

      Makes repeating the request safe.

    Body

    • personaIdstringrequired

      The persona to shoot.

    • lookIdstringoptional

      One of your looks, for this persona or for any.

    • sceneIdstringoptional

      A scene id from GET /scenes.

    • promptstring (≤600)optional

      Your own words about the picture.

    • aspectRatio"9:16" | "3:4" | "1:1" | "16:9"optional

      The format of the photo. Required unless remixOf is set.

    • level1 | 2 | 3optional

      The content level of the order, at most your account's. Required unless remixOf is set.

    • count1 | 2 | 4optional

      How many photos: 1, 2 or 4. 1 by default.

    • remixOfstringoptional

      A 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"
    }'
    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:read

    Scope

    shots:read

    • Answers 200 on success.

    Request

    Query

    • personaIdstringoptional

      Only this persona's shots.

    • status"queued" | "running" | "done" | "failed" | "refused" | "cancelled"optional

      Only shots in this state.

    • cursorstringoptional

      The nextCursor of the previous page.

    • limitinteger (1–48)optional

      Page 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"
    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:read

    Ask 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

    • idstringrequired

      The 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"
    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:write

    A 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

    • idstringrequired

      The 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"
    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:read

    Public shots at or below your account's content level.

    Scope

    feed:read

    • Answers 200 on success.

    Request

    Query

    • sort"new" | "top"optional

      new (the default) or top.

    • personastringoptional

      Only this persona's shots: the persona.id of a feed shot. The nextCursor then works only with the same persona.

    • cursorstringoptional

      The nextCursor of the previous page.

    • limitinteger (1–48)optional

      Page 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"
    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:read

    Scope

    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"
    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:write

    Needs 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"
    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:write

    The 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)required

      Where deliveries are sent.

    • eventsWebhookEvent[] (1+)required

      The 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"
    }'
    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:write

    Scope

    webhooks:write

    • Answers 200 on success.

    Request

    Path

    • idstringrequired

      The 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"
    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.delivered

    A shot was drawn and delivered.

    data holds: shot

  • shot.failed

    A shot failed, was refused or was cancelled; failure says why, and is null for a cancelled one.

    data holds: shot

  • persona.draft.ready

    The faces of a persona draft are ready.

    data holds: draft

  • persona.draft.failed

    A persona draft could not be drawn.

    data holds: draft

  • persona.created

    A 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-Id

    The event's id. It is the same on every attempt: deduplicate on it.

    evt_5e8b2d7a9c14
  • Luv-Event-Type

    The event type, such as shot.delivered.

    shot.delivered
  • Luv-Timestamp

    When this attempt was made, in seconds since the epoch.

    1791796601
  • Luv-Signature

    v1= and the hex HMAC-SHA256 of the timestamp, a dot and the raw body, with the endpoint's secret.

    v1=a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
Example delivery
{
  "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.

Receiver in JavaScript
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