pleny

API reference

Turn phone-quality dish photos into menu-ready food photography and marketing posters. Upload images, pick a style (catalog match, raw catalog, or one reference image) or a poster template, poll or webhook for results.

Contents

Quick start#

bash
# 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.

Registration flow: sign in, pick restaurant via Places, capture brand vibe, save profile
Registration flow

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.

Theme selection flow: match top-5 catalog styles, auto-pick, or moodboard custom brief
Theme selection flow

Enhancement flow

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

Enhancement flow: classify dish, build prompt from style brief, render image, return results
Enhancement flow

Authentication#

Pass your secret key on every request:

text
Authorization: Bearer pleny_live_…
PrefixPurpose
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_, tpl_, pos_, pvr_, 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:

json
{
  "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.

TypeWhen
invalid_request_errorMalformed request, bad types, unknown enum.
authentication_errorMissing or expired API key.
permission_errorValid key, wrong account.
not_found_errorResource doesn’t exist.
rate_limit_errorSee Retry-After.
quota_errorOut of photo credits (HTTP 402).
api_errorServer 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.

FieldTypeRequiredNotes
placeIdstringone of placeId / nameGoogle Place ID. We hydrate the rest.
namestringone of placeId / nameRequired if no placeId.
cuisinestringwith nameCuisine slug. See Cuisine slugs.
citystringnoAuto-filled from placeId.
websiteUrlstringnoWe extract brand palette + vibe.
logoUrlstringnoBrand logo URL.

Response

json
{
  "id": "rst_8Lp2Qw…",
  "placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
  "name": "Bercos",
  "cuisine": "Asian",
  "city": "Noida",
  "websiteUrl": "https://bercos.com",
  "logoUrl": null,
  "brandTheme": {
    "palette": ["#c3a800"],
    "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

FieldTypeRequiredNotes
contentTypestringyesimage/jpeg · image/png · image/webp · image/heic (auto-converted).
json
{
  "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:

  1. Match (recommended for UIs): POST /v1/styles/match returns top-5 catalog styles ranked for your dish + restaurant.
  2. Browse: GET /v1/styles for raw catalog listing.
  3. One reference image: pass referenceImageUrl directly on POST /v1/enhancements (transient), or save it first via POST /v1/styles.

POST/v1/styles/match

Vision-ranks the catalog against a dish photo + restaurant. ~3–4s.

FieldTypeRequiredNotes
dishPhotoUrlstringyesPublic image URL.
restaurantobjectno{ placeId } or { name, cuisine }. Defaults to account’s saved.
limitintno1–8. Default 5.

Response

json
{
  "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).

FieldTypeRequiredNotes
namestringyesDisplay name. 60 chars.
referenceImageUrlstringyesOne moodboard image URL. Drives the aesthetic (surface, lighting, props, palette).
dishPhotoUrlsstring[]no1–12 of your actual dishes. Grounds the brief in your cuisine + plating realism.
restaurantobjectnoPer-request override.
metadataobjectnoOpaque pass-through.

Async (~15s). Response returns immediately with status: "generating". Poll or wait for the style.ready webhook.

json
{
  "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

text
queued → (generating_style?) → processing → completed | partial

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

FieldTypeRequiredNotes
photosobject[]yes1–20 photos. { url, dishName?, notes? }.
styleIdstringone-of styleId / referenceImageUrlA style id from /v1/styles.
referenceImageUrlstringone-of styleId / referenceImageUrlOne moodboard image to match. Transient — adds ~15s, not saved to account.
aspectRatioenumnooriginal · 1:1 · 4:5 · 3:2. Default original.
notesstringnoFree-text direction applied to every photo. 500 chars.
restaurantobjectnoPer-batch override. Useful for agencies with multiple restaurants per key.
webhookUrlstringnoOverride the account webhook for this batch.
metadataobjectnoOpaque pass-through.

Passing both styleId and referenceImageUrl returns invalid_request_error / mutually_exclusive_fields.

Response (202)

json
{
  "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}

json
{
  "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.

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

Posters#

Turn one dish photo into a finished marketing poster. This is style transfer, not generation from scratch: you match the dish against a catalog of ~76 reference poster templates, pick one, and we render variants that keep your food and adopt the template’s layout, palette, and typography — with your own copy and logo swapped in for the template’s baked-in text.

bash
# 1. Rank poster templates for a dish photo. Returns top 5 with their text slots.
curl https://api.pleny.in/v1/posters/match \
  -H "Authorization: Bearer pleny_live_…" \
  -d '{ "dishPhotoUrl": "https://cdn.pleny.in/upl_5fK….jpg" }'
# → { "matches": [ { "id": "tpl_festive_red…", "textSlots": [ … ] }, … ] }

# 2. Render the poster. Omit a slot to REMOVE that text; supply it to REPLACE it.
curl https://api.pleny.in/v1/posters \
  -H "Authorization: Bearer pleny_live_…" \
  -d '{
    "templateId": "tpl_festive_red…",
    "dishPhotoUrl": "https://cdn.pleny.in/upl_5fK….jpg",
    "logoUrl": "https://cdn.pleny.in/logo.png",
    "copy": {
      "brand": "Bercos",
      "dish-name": "Paneer Tikka",
      "price": "₹320",
      "offer": "Flat 20% off this weekend"
    },
    "aspectRatio": "4:5"
  }'
# → { "id": "pos_7Qd…", "status": "queued", "variantCount": 3 }

# 3. Poll until completed (or use a webhook).
curl https://api.pleny.in/v1/posters/pos_7Qd… \
  -H "Authorization: Bearer pleny_live_…"

Template text & slots

Every reference template carries the source restaurant’s own text (its brand name, dish name, price). We pre-extract those into typed textSlots, returned on each match. For every slot you either replace the text (pass copy for that slot) or remove it (omit the slot) — the template’s original wording never leaks through. Copy for a slot the template doesn’t have is added as fresh text.

Slot kindMaxNotes
brand60Auto-filled from your saved restaurant. Dropped when a logoUrl is supplied (the logo replaces the wordmark).
dish-name60Auto-filled from the match’s detected dish.
price30e.g. ₹320.
tagline120Short supporting line.
cta40e.g. Order now.
offer80Promo line.
offer-code30Coupon / promo code.
date40Event or validity date.
other120Anything else the template shows.

Logo. Pass logoUrl and we composite your actual logo into the template’s brand region (pixel-accurate, not model-drawn) and suppress the brand text slot. Omit it to render the brand as text instead.

Lifecycle

text
queued → rendering → completed | partial | failed

Each job renders variantCount independent variants (3 by default). partial = at least one variant succeeded and at least one failed. failed = none succeeded, or a pre-flight error (auth, quota, unknown template).

POST/v1/posters/match

Vision-ranks the poster catalog against a dish photo. ~3–5s. Returns the top matches with their textSlots so you know what copy to collect.

FieldTypeRequiredNotes
dishPhotoUrlstringyesPublic image URL.
restaurantobjectno{ placeId } or { name, cuisine }. Defaults to the account’s saved restaurant.
limitintno1–8. Default 5.

Response

json
{
  "userFood": { "dish": "Paneer Tikka", "cuisine": "north_indian" },
  "matches": [
    {
      "id": "tpl_festive_red_offer",
      "name": "Festive Red — Offer",
      "category": "offer",
      "thumbnailUrl": "https://cdn.pleny.in/posters/tpl_festive_red….jpg",
      "dishFit": 88,
      "styleFit": 81,
      "score": 84,
      "why": "Warm festive red + gold matches tandoori char; bold offer banner",
      "textSlots": [
        { "kind": "brand",     "description": "top wordmark" },
        { "kind": "dish-name", "description": "centered under the dish" },
        { "kind": "price",     "description": "yellow burst, lower-right" },
        { "kind": "offer",     "description": "red banner across the bottom" }
      ]
    }
    // … up to 5
  ]
}

GET/v1/posters/templates

Browse the raw template catalog. Query: category, limit (1–100, default 20), startingAfter. Each template includes its thumbnailUrl, styleTags, and textSlots. Fetch a single one with GET /v1/posters/templates/{id}.

POST/v1/posters

Render poster variants from a template + your dish photo. Async.

FieldTypeRequiredNotes
templateIdstringyesA template id from /v1/posters/match or /v1/posters/templates.
dishPhotoUrlstringyesPublic image URL of the dish.
copyobjectnoSlot-keyed text (see Template text & slots). Any omitted slot is removed from the output.
logoUrlstringnoComposited into the brand region; suppresses the brand text slot. Defaults to the saved logo.
aspectRatioenumno1:1 · 4:5 · 9:16 · 16:9. Default 1:1.
variantsintno1–4. Default 3.
restaurantobjectnoPer-request override.
webhookUrlstringnoOverride the account webhook for this job.
metadataobjectnoOpaque pass-through.

Response (202)

json
{
  "id": "pos_7Qd…",
  "status": "queued",
  "variantCount": 3,
  "template": { "id": "tpl_festive_red_offer", "name": "Festive Red — Offer" },
  "aspectRatio": "4:5",
  "createdAt": "…",
  "completedAt": null,
  "metadata": null
}

GET/v1/posters/{id}

json
{
  "id": "pos_7Qd…",
  "status": "rendering",
  "progress": { "completed": 1, "failed": 0, "total": 3 },
  "template": { "id": "tpl_festive_red_offer", "name": "Festive Red — Offer" },
  "variants": [
    { "id": "pvr_0", "index": 0, "status": "completed", "imageUrl": "https://cdn.pleny.in/pos_7Qd…/0.png" },
    { "id": "pvr_1", "index": 1, "status": "rendering", "imageUrl": null },
    { "id": "pvr_2", "index": 2, "status": "queued",    "imageUrl": null }
  ],
  "createdAt": "…",
  "completedAt": null
}

Variant statuses: queued, rendering, completed, failed. A failed variant carries error.code (e.g. rendering_failed, template_not_found, input_too_small).

GET/v1/posters

Standard pagination + optional status filter.

DELETE/v1/posters/{id}

Cancels an in-flight job. Already-completed variants are kept and billed; rendering + queued ones abort.

Webhooks#

Configure a delivery URL per account or override per-request with webhookUrl. Skip polling.

Event typeFires when
enhancement.queuedPOST to /v1/enhancements succeeded.
enhancement.style_generatedTransient brief ready (inline referenceImageUrl path).
enhancement.image.completedOne photo finished (success or failure).
enhancement.completedEvery photo resolved.
enhancement.failedPre-flight failure (auth, quota, validation).
style.readyCustom style generation finished.
style.failedCustom style generation failed.
poster.queuedPOST to /v1/posters succeeded.
poster.variant.completedOne poster variant finished (success or failure).
poster.completedEvery variant resolved.
poster.failedPre-flight failure (auth, quota, unknown template).

Payload

json
{
  "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.