Quick start#
# 0. One-time: tell us which restaurant this key represents.
curl https://api.pleny.in/v1/restaurants \
-H "Authorization: Bearer pleny_live_…" \
-d '{"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4"}'
# 1. Get a signed upload URL.
curl https://api.pleny.in/v1/uploads \
-H "Authorization: Bearer pleny_live_…" \
-d '{"contentType": "image/jpeg"}'
# → { "uploadUrl": "…", "publicUrl": "https://cdn.pleny.in/upl_5fK….jpg", … }
# 2. PUT your image bytes at uploadUrl.
curl -X PUT "https://blob…" -H "Content-Type: image/jpeg" --data-binary @dish.jpg
# 3. Rank the catalog against this dish + restaurant. Returns top 5.
curl https://api.pleny.in/v1/styles/match \
-H "Authorization: Bearer pleny_live_…" \
-d '{
"dishPhotoUrl": "https://cdn.pleny.in/upl_5fK….jpg",
"restaurant": { "placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4" }
}'
# 4. Enhance. Up to 20 dishes per batch.
curl https://api.pleny.in/v1/enhancements \
-H "Authorization: Bearer pleny_live_…" \
-d '{
"photos": [
{ "url": "https://cdn.pleny.in/upl_5fK….jpg", "dishName": "Paneer Tikka" },
{ "url": "https://cdn.pleny.in/upl_6gL….jpg", "dishName": "Dal Makhani" }
],
"styleId": "style_bercos…",
"aspectRatio": "1:1"
}'
# → { "id": "enh_9Hp…", "status": "queued", "photoCount": 2 }
# 5. Poll until completed (or use a webhook).
curl https://api.pleny.in/v1/enhancements/enh_9Hp… \
-H "Authorization: Bearer pleny_live_…"Architecture#
How the pieces fit together end to end. Three core flows drive the product: getting a restaurant set up, choosing the photographic style for a dish, and running the enhancement itself.
Registration flow
Sign-in, restaurant selection (Google Places), and brand-vibe capture — producing the saved profile every later request is scoped to.

Theme selection flow
How a photographic style is chosen for a dish — the top-5 catalog picker, the auto-pick fallback, and the moodboard (custom brief) path.

Enhancement flow
The per-batch pipeline: classify each dish, assemble the edit prompt from the chosen style brief, render, and surface results.

Authentication#
Pass your secret key on every request:
Authorization: Bearer pleny_live_…| Prefix | Purpose |
|---|---|
pleny_live_… | Production. Real credit consumption. |
pleny_test_… | Test mode. Free, watermarked outputs, sandboxed webhooks. |
Keys are scoped to one account.
Base URL & conventions#
Base URL: https://api.pleny.in/v1. URL-versioned, breaking changes ship as /v2. Additive changes ship in-place; ignore unknown fields.
Format: JSON (camelCase keys). Multipart only for file uploads, which use signed-URL PUTs.
IDs: prefixed + opaque — rst_, upl_, style_, enh_, img_, evt_.
Timestamps: ISO 8601 with timezone (2026-05-28T14:33:00Z).
Pagination: ?limit=20&startingAfter=<id>. Responses include hasMore and nextCursor.
Idempotency: any POST accepts Idempotency-Key: <uuid>. Replays within 24h return the original response.
Metadata: most resources accept a metadata object (string→string, 50 keys, 500-char values). Opaque to us, returned verbatim.
Errors & rate limits#
All errors share one shape:
{
"error": {
"type": "invalid_request_error",
"code": "missing_field",
"message": "photos[0].url is required.",
"param": "photos[0].url",
"requestId": "req_2k9P…"
}
}Include requestId in support tickets.
| Type | When |
|---|---|
invalid_request_error | Malformed request, bad types, unknown enum. |
authentication_error | Missing or expired API key. |
permission_error | Valid key, wrong account. |
not_found_error | Resource doesn’t exist. |
rate_limit_error | See Retry-After. |
quota_error | Out of photo credits (HTTP 402). |
api_error | Server failure. Safe to retry. |
Rate limits: 120 reads/min, 30 writes/min, 10 concurrent batches per account. Standard X-RateLimit-* headers on every response. Photo credits are a separate quota, tracked per account, billed independently.
Restaurants#
Requests need restaurant identity. Configure once via Google Place ID (preferred — we hydrate name, cuisine, city, lat/lng, sometimes a logo) or manually.
POST/v1/restaurants
Sets or replaces the saved restaurant.
| Field | Type | Required | Notes |
|---|---|---|---|
placeId | string | one of placeId / name | Google Place ID. We hydrate the rest. |
name | string | one of placeId / name | Required if no placeId. |
cuisine | string | with name | Cuisine slug. See Cuisine slugs. |
city | string | no | Auto-filled from placeId. |
websiteUrl | string | no | We extract brand palette + vibe. |
logoUrl | string | no | Brand logo URL. |
Response
{
"id": "rst_8Lp2Qw…",
"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"name": "Bercos",
"cuisine": "Asian",
"city": "Noida",
"websiteUrl": "https://bercos.com",
"logoUrl": null,
"brandTheme": {
"palette": ["#8B1E23"],
"vibe": "warm rustic Indo-Chinese",
"extractedFrom": "website"
},
"createdAt": "…",
"updatedAt": "…"
}GET/v1/restaurants
Returns the saved restaurant, or 404 if none set.
GET/v1/restaurants/search?q=<text>
Autocomplete over Google Places. Returns up to 5 candidates with placeId, name, address, cuisinePrimary. Use this to select your restaurant.
Uploads#
Two-step: POST for a signed URL, then PUT image bytes to it.
POST/v1/uploads
| Field | Type | Required | Notes |
|---|---|---|---|
contentType | string | yes | image/jpeg · image/png · image/webp · image/heic (auto-converted). |
{
"id": "upl_5fK…",
"uploadUrl": "https://blob…?token=…",
"publicUrl": "https://cdn.pleny.in/upl_5fK….jpg",
"expiresAt": "2026-05-28T14:48:00Z"
}uploadUrl is valid 15 min. publicUrl is permanent + CDN-cached. Max 25 MB.
Styles#
A style is a photographic recipe: surface, lighting, props, palette, camera angle, vessel vocabulary. You apply one per enhancement batch.
- Catalog (
type: "catalog") — pre-built, ready to use. - Custom (
type: "custom") — generated from a moodboard reference image you provide. ~15s to generate, cached on your account.
Three ways to pick a style for a batch:
- Match (recommended for UIs):
POST /v1/styles/matchreturns top-5 catalog styles ranked for your dish + restaurant. - Browse:
GET /v1/stylesfor raw catalog listing. - One reference image: pass
referenceImageUrldirectly onPOST /v1/enhancements(transient), or save it first viaPOST /v1/styles.
POST/v1/styles/match
Vision-ranks the catalog against a dish photo + restaurant. ~3–4s.
| Field | Type | Required | Notes |
|---|---|---|---|
dishPhotoUrl | string | yes | Public image URL. |
restaurant | object | no | { placeId } or { name, cuisine }. Defaults to account’s saved. |
limit | int | no | 1–8. Default 5. |
Response
{
"matches": [
{
"id": "style_bercos_indo_chinese_noida",
"name": "Bercos Indo-Chinese, Noida",
"cuisine": "Asian",
"previewImageUrl": "https://cdn.pleny.in/styles/bercos….jpg",
"why": "Warm earthy palette + glossy wok highlights match the masala plating"
}
// … up to 5
]
}Matches catalog styles only — custom styles are already tailored and don’t need re-ranking.
GET/v1/styles
List catalog + your custom styles. Query: cuisine, type, limit (1–100, default 20), startingAfter. briefMarkdown is omitted from list responses (call GET /v1/styles/{id} to fetch it).
GET/v1/styles/{id}
Returns the full style record including briefMarkdown.
POST/v1/styles
Create a custom style from one reference image (and optionally some dish photos for cuisine grounding).
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Display name. 60 chars. |
referenceImageUrl | string | yes | One moodboard image URL. Drives the aesthetic (surface, lighting, props, palette). |
dishPhotoUrls | string[] | no | 1–12 of your actual dishes. Grounds the brief in your cuisine + plating realism. |
restaurant | object | no | Per-request override. |
metadata | object | no | Opaque pass-through. |
Async (~15s). Response returns immediately with status: "generating". Poll or wait for the style.ready webhook.
{
"id": "style_2g6Pt…",
"type": "custom",
"name": "Casa Verde House Style",
"status": "generating",
"previewImageUrl": null,
"briefMarkdown": null,
"createdAt": "…"
}Enhancements#
A batch of 1–20 photos processed against a single style.
Lifecycle
queued → (generating_style?) → processing → completed | partialgenerating_style only appears when you used referenceImageUrl inline. partial = at least one succeeded + at least one failed. failed = none succeeded.
POST/v1/enhancements
Pick exactly one of styleId or referenceImageUrl.
| Field | Type | Required | Notes |
|---|---|---|---|
photos | object[] | yes | 1–20 photos. { url, dishName?, notes? }. |
styleId | string | one-of styleId / referenceImageUrl | A style id from /v1/styles. |
referenceImageUrl | string | one-of styleId / referenceImageUrl | One moodboard image to match. Transient — adds ~15s, not saved to account. |
aspectRatio | enum | no | original · 1:1 · 4:5 · 3:2. Default original. |
notes | string | no | Free-text direction applied to every photo. 500 chars. |
restaurant | object | no | Per-batch override. Useful for agencies with multiple restaurants per key. |
webhookUrl | string | no | Override the account webhook for this batch. |
metadata | object | no | Opaque pass-through. |
Passing both styleId and referenceImageUrl returns invalid_request_error / mutually_exclusive_fields.
Response (202)
{
"id": "enh_9Hp…",
"status": "queued",
"photoCount": 2,
"style": {
"id": "style_bercos…",
"type": "catalog",
"name": "Bercos Indo-Chinese, Noida"
},
"aspectRatio": "1:1",
"createdAt": "…",
"completedAt": null,
"metadata": null
}GET/v1/enhancements/{id}
{
"id": "enh_9Hp…",
"status": "processing",
"progress": { "completed": 1, "failed": 0, "total": 2 },
"photos": [
{
"id": "img_a1…",
"dishName": "Paneer Tikka",
"inputUrl": "https://cdn.pleny.in/upl_5fK….jpg",
"enhancedUrl": "https://cdn.pleny.in/enh_9Hp…/0.png",
"status": "completed",
"durationMs": 28430
},
{
"id": "img_b2…",
"dishName": "Dal Makhani",
"inputUrl": "https://cdn.pleny.in/upl_6gL….jpg",
"enhancedUrl": null,
"status": "processing"
}
],
"style": {
"id": "style_bercos…",
"type": "catalog",
"name": "Bercos Indo-Chinese, Noida"
},
"createdAt": "…",
"completedAt": null
}Photo statuses: queued, processing, completed, failed. A failed photo carries error.code (e.g. dish_not_recognized, rendering_failed, input_too_small).
GET/v1/enhancements
Standard pagination + optional status filter.
DELETE/v1/enhancements/{id}
Cancels an in-flight batch. Already-completed images are kept and billed; in-flight + queued ones abort.
Bring your own reference image inline
If you pass referenceImageUrl instead of styleId on POST /v1/enhancements, we generate a transient brief just for that batch (not saved to your account). Use this for one-off campaigns.
curl https://api.pleny.in/v1/enhancements \
-H "Authorization: Bearer pleny_live_…" \
-d '{
"photos": [{ "url": "https://cdn.pleny.in/upl_dish1.jpg", "dishName": "Paneer Tikka" }],
"referenceImageUrl": "https://cdn.pleny.in/upl_ref.jpg",
"aspectRatio": "1:1"
}'The batch passes through generating_style (~15s) before processing. The response includes a style: { type: "transient", briefMarkdown } block so you can capture and save the brief later via POST /v1/styles if it worked well.
Webhooks#
Configure a delivery URL per account or override per-request with webhookUrl. Skip polling.
| Event type | Fires when |
|---|---|
enhancement.queued | POST to /v1/enhancements succeeded. |
enhancement.style_generated | Transient brief ready (inline referenceImageUrl path). |
enhancement.image.completed | One photo finished (success or failure). |
enhancement.completed | Every photo resolved. |
enhancement.failed | Pre-flight failure (auth, quota, validation). |
style.ready | Custom style generation finished. |
style.failed | Custom style generation failed. |
Payload
{
"id": "evt_3kPm…",
"type": "enhancement.completed",
"createdAt": "…",
"data": { "object": { /* the resource — same shape as GET */ } }
}Dedupe on event id; we may deliver the same event twice during retries.
Signing. Each delivery includes Pleny-Signature: t=<unix>,v1=<hex>. Verify: HMAC-SHA256(webhookSecret, "{t}.{rawBody}"), constant-time compare, reject if |now - t| > 300s.
Retries. Non-2xx triggers exponential backoff over ~24h: 5s, 30s, 5m, 30m, 2h, 6h, 12h (7 attempts). After that the event is dead-lettered and replayable for 30 days.
Cuisine slugs#
north_indian · south_indian · asian · italian_pizza · american_burgers · bakery_cafe · healthy_bowls · multi_cuisine · tibetan_momos · custom · etc.