pleny

API reference

Turn phone-quality dish photos into menu-ready food photography. Upload images, pick a style (catalog match, raw catalog, or one reference image), 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_, 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": ["#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

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.

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.

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.