Your recipes, callable from your code.
Three steps to your first generation
- 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.
- 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.
- 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.
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
/generationsRun a recipe. Returns 202 with a job id; the work continues in the background.
/generations/{job_id}Poll a job. Carries the output, the credits it used, and any error.
/recipesList the recipes you have saved to your own account.
/recipes/{recipe_id}Read one of your recipes, including the variable schema you fill in when calling it.
/catalog/recipesBrowse the BetterPicture recipe catalog. Filter by category or featured, search by name, and page through the results.
/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": "..." }.
{
"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.
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.
{
"error": {
"type": "insufficient_credits",
"message": "Not enough credits to run this recipe.",
"param": null
}
}| Status | type | Meaning |
|---|---|---|
| 400 | invalid_request_error | Malformed body, bad image input, or bad webhook_url |
| 401 | authentication_error | Missing, malformed, revoked, or expired key |
| 402 | insufficient_credits | Balance below the recipe cost |
| 403 | account_not_eligible | Key valid, but the account is not paid |
| 404 | recipe_not_found | Unknown recipe, or not yours |
| 404 | job_not_found | Unknown job, or not yours |
| 409 | idempotency_conflict | Key reused with a different body |
| 422 | variable_binding_error | Unknown, missing, mistyped, or over-length variable |
| 429 | rate_limit_error | Rate limit exceeded; see Retry-After |
| 500 | api_error | Unexpected 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.