Skip to main content
v1 · REST · public beta

Your recipes, callable from your code.

Every style you have saved in BetterPicture is an endpoint. Send a photo and the values you want filled in, and get back a generated image or video — from a cron job, a checkout flow, or a batch of ten thousand product shots.

API access requires a paid account. Running out of credits pauses calls; it does not revoke the key.

Three steps to your first generation

  1. 01

    Save a recipe

    Enhance a photo in the app, then save the result as a recipe. That recipe is the thing the API runs — prompt, model, and any variables you want to fill in per call.

  2. 02

    Create a key

    Settings → API keys. The key and its webhook signing secret are shown once and never again, so copy both before you close the dialog.

  3. 03

    Call it

    POST the recipe id, a reference photo, and your variable values. Poll the job, or let a webhook tell you when it lands.

create a generation
curl -X POST https://betterpicture.xyz/api/v1/generations \
  -H "Authorization: Bearer $BP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4821" \
  -d '{
    "recipe_id": "rcp_7Kd9...",
    "image_url": "https://example.com/photo.jpg",
    "variables": { "headline": "Summer Sale" }
  }'

Returns 202 with a job_id. Images usually land in 10–30 seconds; videos take minutes.

Endpoints

https://betterpicture.xyz/api/v1

POST/generations

Run a recipe. Returns 202 with a job id; the work continues in the background.

GET/generations/{job_id}

Poll a job. Carries the output, the credits it used, and any error.

GET/recipes

List the recipes you have saved to your own account.

GET/recipes/{recipe_id}

Read one of your recipes, including the variable schema you fill in when calling it.

GET/catalog/recipes

Browse the BetterPicture recipe catalog. Filter by category or featured, search by name, and page through the results.

GET/catalog/recipes/{recipe_id}

Read one catalog recipe, including its full prompt.

Polling a job

status moves through queued, processing, then succeeded or failed. For a video recipe, output is { "video": "..." }.

GET /generations/{job_id}
{
  "job_id": "job_3nQ8...",
  "status": "succeeded",
  "kind": "image",
  "recipe_id": "rcp_7Kd9...",
  "output": { "images": ["https://..."] },
  "credits_used": 1,
  "error": null,
  "created_at": "2026-08-18T10:00:00Z",
  "completed_at": "2026-08-18T10:00:24Z"
}

Built for unattended code

The things that bite when nobody is watching the terminal.

Retries never double-bill

Send an Idempotency-Key. Replaying it with the same body returns the original job and charges nothing further; replaying it with a different body is a 409 rather than a surprise.

Signed webhooks

Pass a webhook_url and we POST the finished job to it, signed with HMAC-SHA256. The body is byte-identical to the polling response, so one parser handles both.

One credit balance

API generations draw on the same credits as the app, at the same rate. No separate plan, no second ledger to reconcile.

Verifying a webhook

Every delivery carries an X-BetterPicture-Signature header of the form t=…,v1=…, where v1 is HMAC-SHA256 over "{t}.{raw body}", keyed with the signing secret shown when you created the key.

Verify against the raw body, before any JSON parsing — re-serializing changes the bytes and the signature will never match. The timestamp is inside the signed payload, so a captured request cannot be re-dated and replayed.

Delivery retries with exponential backoff up to 8 attempts. Any non-2xx is retried, so return 200 as soon as you have durably accepted the payload.

verify-webhook.js
const crypto = require("crypto");

function verify(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=")),
  );

  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;

  // Reject replays of a captured request.
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");

  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Errors

Every failure uses one envelope, so you can branch on error.type rather than parsing prose.

envelope
{
  "error": {
    "type": "insufficient_credits",
    "message": "Not enough credits to run this recipe.",
    "param": null
  }
}
StatustypeMeaning
400invalid_request_errorMalformed body, bad image input, or bad webhook_url
401authentication_errorMissing, malformed, revoked, or expired key
402insufficient_creditsBalance below the recipe cost
403account_not_eligibleKey valid, but the account is not paid
404recipe_not_foundUnknown recipe, or not yours
404job_not_foundUnknown job, or not yours
409idempotency_conflictKey reused with a different body
422variable_binding_errorUnknown, missing, mistyped, or over-length variable
429rate_limit_errorRate limit exceeded; see Retry-After
500api_errorUnexpected failure on our side

A recipe or job belonging to someone else returns 404, not 403 — we do not confirm that an id exists.

Start with a key

Create one in Settings, copy it and the signing secret, and make your first call in a minute.