API reference

Build with layerrender.

One REST API for uploading, modifying, and rendering Photoshop documents. Submit a job, poll or subscribe for status, and download a flat image when it's done.

Introduction

A REST API for rendering Photoshop documents.

Upload a PSD, describe a list of modifications (text swaps, layer visibility toggles, image replacements, color changes, opacity tweaks…), and the API returns a flat PNG, JPEG, or WebP. Renders run asynchronously — you submit a job, then poll for status or subscribe to live updates via SSE or webhooks.

Base URL: https://layerrender.com

Quickstart

From zero to a rendered file in three calls.

1. Create an API key under Settings → API keys. Keys are shown once at creation time — copy it immediately.

2. Upload a PSD. The response includes the storage key you'll use as the render source.

bash
curl -X POST https://layerrender.com/api/v1/upload \
  -H "Authorization: Bearer lr_..." \
  -H "Content-Type: application/octet-stream" \
  -H "X-Filename: ad-template.psd" \
  --data-binary @ad-template.psd

3. Submit a render. The response gives you a jobId.

bash
curl -X POST https://layerrender.com/api/v1/render \
  -H "Authorization: Bearer lr_..." \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "type": "r2", "key": "user/<your-id>/upload/abc-ad-template.psd" },
    "modifications": [
      { "type": "text", "target": "Headline", "text": "Spring sale" },
      { "type": "visibility", "target": "Holiday badge", "visible": false }
    ],
    "output": { "format": "png" }
  }'

4. Poll status until completed, then download.

bash
curl "https://layerrender.com/api/v1/render/status?jobId=<id>" \
  -H "Authorization: Bearer lr_..."

# When status === "completed":
curl -L "https://layerrender.com/api/v1/render/result/<id>" \
  -H "Authorization: Bearer lr_..." \
  -o result.png

Authentication

All /api/v1/* requests require an API key passed as a Bearer token in the Authorization header. Keys start with the prefix lr_.

http
Authorization: Bearer lr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys are scoped to your user and inherit your plan's rate limits. Revoking a key in the dashboard takes effect immediately; subsequent calls return 401 invalid_auth.

PSD sources

The source field on render and metadata requests accepts two forms.

Uploaded file — reference a previously uploaded PSD by its storage key.

json
{ "source": { "type": "r2", "key": "user/<your-id>/upload/<filename>" } }

The key must belong to your user (it always starts with user/<your-id>/) or the API returns 403 forbidden_key.

Remote URL — fetch the PSD from a public URL.

json
{ "source": { "type": "url", "url": "https://example.com/ad.psd" } }

The URL must respond with the raw PSD bytes (no auth flow, no redirects to login).

Modifications

Eight types of edits you can apply to layers by name.

TypeWhat it doesExtra fields
visibilityShow or hide a layer.{ visible: true | false }
opacitySet a layer's opacity (0–1).{ opacity: 0.5 }
blendModeChange a layer's blend mode.{ blendMode: "multiply" }
positionMove a layer to new (left, top) coords.{ left: 120, top: 80 }
textReplace a text layer's content.{ text: "Spring sale", color?: "#222" }
imageReplaceSwap a layer's bitmap with a new image.{ imageBase64: "<base64>" }
colorReplaceRecolor pixels matching a hex value.{ from: "#1e88e5", to: "#f43f5e" }
recolorRepaint every pixel in the layer, keep alpha.{ color: "#7c3aed", mode?: "luminance" | "flat" }

Modifications target layers by their PSD layer name. If the name doesn't match anything in the file the modification is silently skipped (the job still succeeds). Use the metadata endpoint to inspect layer names ahead of time. A single render accepts up to 100 modifications; more returns 400 invalid_request.

json
{
  "modifications": [
    { "type": "visibility",  "target": "Holiday badge", "visible": false },
    { "type": "opacity",     "target": "Background",    "opacity": 0.6 },
    { "type": "blendMode",   "target": "Overlay",       "blendMode": "multiply" },
    { "type": "position",    "target": "Logo",          "left": 120, "top": 80 },
    { "type": "text",        "target": "Headline",      "text": "Spring sale", "color": "#222" },
    { "type": "imageReplace","target": "Hero photo",    "imageBase64": "<base64-png>" },
    { "type": "colorReplace","target": "CTA button",    "from": "#1e88e5", "to": "#f43f5e" },
    { "type": "recolor",     "target": "Brand mark",    "color": "#7c3aed", "mode": "luminance" }
  ]
}

Rendering limitations

What the v1 render engine does and doesn't reproduce.

The renderer flattens a PSD by compositing each layer's raster with its opacity and blend mode. It is built for programmatic and templated documents (text swaps, image/color replacement, layer toggles) rather than pixel-perfect Photoshop parity. Keep these limits in mind when designing source files — none of them produce an error, so an unsupported feature renders differently from Photoshop rather than failing.

  • Text is re-rasterized as a single line in a single style (first style run). Multi-line paragraphs, mixed styles, custom kerning/leading, and exact font metrics are approximated — embed text as a flattened image if you need exact typesetting.
  • colorReplace matches pixels by exact RGB value. Anti-aliased edges and gradients keep their original color. For solid-fill recoloring that preserves shading, use recolor instead.
  • Clipping masks and group (pass-through) blend modes are not applied — group contents composite normally with the group's opacity.
  • Layer effects (drop shadow, stroke, glow, gradient overlay), adjustment layers, and smart objects are not rendered. Rasterize or flatten them in the source PSD if their result must appear in the output.
  • Vector masks and layer masks beyond a layer's own raster bounds may not clip exactly. Flatten masked artwork for predictable results.

For the cleanest results, design templates with flat raster layers, name the layers you intend to target, and pre-flatten anything that relies on Photoshop-only compositing.

Render API

Submit, check status, and download results.

POST/api/v1/render

Queue a render job. Returns jobId immediately; rendering happens asynchronously.

json
Request body:
{
  "source":        { "type": "r2", "key": "user/<id>/upload/file.psd" },
  "modifications": [ ... ],
  "output": {
    "format":    "png" | "jpeg" | "webp",
    "quality":   1..100,        // jpeg/webp only
    "maxWidth":  2000,           // optional, downscales if exceeded
    "maxHeight": 2000            // optional
  }
}

Response (202):
{ "success": true, "data": { "jobId": "abc...", "status": "queued", "estimatedTime": 12 } }
GET/api/v1/render/status?jobId=<id>

Poll job status. Owner-scoped.

json
Response:
{
  "success": true,
  "data": {
    "jobId":       "abc...",
    "status":      "queued" | "processing" | "completed" | "failed" | "cancelled",
    "createdAt":   "2026-05-14T12:34:56.000Z",
    "completedAt": "2026-05-14T12:34:58.123Z",
    "runtimeMs":   1547,
    "errorMessage": null
  }
}
GET/api/v1/render/result/{jobId}

Stream the rendered file once the job is completed.

Returns the encoded image bytes with Content-Type matching the output format. Returns 409 job_not_completed if the job is still in progress, 410 output_not_found if the file was deleted, or 404 if the jobId isn't yours.

Batch render

Submit up to 50 renders in one call.

POST/api/v1/render/batch
json
Request body:
{
  "jobs": [
    { "source": {...}, "modifications": [...], "output": {...} },
    { "source": {...}, "modifications": [...], "output": {...} },
    ...
  ]
}

Response (202):
{
  "success": true,
  "data": {
    "batchId":  "abc...",
    "jobCount": 12,
    "jobs": [
      { "jobId": "...", "status": "queued" },
      ...
    ]
  }
}

Per-plan batch caps: free 5, payg 25, pro 25, enterprise 50 — exceeding it returns 400 batch_too_large. Each job becomes its own render job — query status individually or filter by batchId in the dashboard.

Live status (SSE)

Server-Sent Events stream of status changes.

GET/api/v1/render/stream?jobId=<id>

Opens a long-lived HTTP response with Content-Type: text/event-stream. The server pushes a status event whenever the job transitions. The stream closes automatically when the job reaches a terminal state (completed, failed, cancelled) or after 5 minutes.

javascript
const es = new EventSource(
  "/api/v1/render/stream?jobId=" + jobId,
  { withCredentials: true } // browser only; for server clients pass Authorization header
);
es.addEventListener("status", (ev) => {
  const job = JSON.parse(ev.data);
  if (job.status === "completed") {
    window.open("/api/v1/render/result/" + job.jobId);
    es.close();
  }
});

Metadata

POST/api/v1/metadata

Inspect a PSD's dimensions, color mode, and full layer tree.

json
Request body:
{ "source": { "type": "r2", "key": "user/<id>/upload/file.psd" } }

Response:
{
  "success": true,
  "data": {
    "width":          1920,
    "height":         1080,
    "colorMode":      "RGB",
    "bitDepth":       8,
    "layerCount":     12,
    "layerNames":     ["Background", "Logo", "Headline", ...],
    "layerStructure": [
      { "name": "Background", "type": "layer", "visible": true, "opacity": 100 },
      {
        "name": "Group 1", "type": "group", "visible": true, "opacity": 100,
        "children": [
          { "name": "Logo", "type": "layer", "visible": true, "opacity": 100 }
        ]
      },
      { "name": "Headline", "type": "layer", "visible": true, "opacity": 100, "textContent": "Default text" }
    ]
  }
}

Templates

Reusable PSDs with named slots — render with key→value JSON.

A template is a PSD plus a list of named slots. Each slot ties a layer to a variable name and a type. To render a template, you POST the slot values instead of a full modifications array.

POST/api/v1/templates

Create a template from a previously uploaded PSD.

json
{
  "name":     "Square ad",
  "psdKey":   "user/<id>/upload/...psd",
  "psdWidth": 1080,
  "psdHeight": 1080,
  "slots": [
    { "key": "headline",  "layerName": "Headline",  "type": "text",  "label": "Headline copy" },
    { "key": "logo",      "layerName": "Logo",      "type": "image", "label": "Brand logo" },
    { "key": "show_badge","layerName": "Sale badge","type": "visibility" }
  ]
}
GET/api/v1/templates

List your templates.

GET/api/v1/templates/{id}
PATCH/api/v1/templates/{id}

Update name/description/slots.

DELETE/api/v1/templates/{id}

Removes the template row and its PSD from storage.

POST/api/v1/templates/{id}/render

Queue a render from a template using slot values.

json
{
  "values": {
    "headline":   "Spring sale",
    "logo":       "<base64-png>",
    "show_badge": false
  },
  "output": { "format": "png" }
}

Webhooks

Get a signed POST when jobs change state.

POST/api/v1/webhooks

Register an endpoint. Returns the signing secret exactly once.

json
{
  "url":    "https://your-api.example.com/webhooks/layerrender",
  "events": ["job.completed", "job.failed"],
  "active": true
}

Response:
{
  "success": true,
  "data": {
    "id":            "abc...",
    "url":           "...",
    "events":        ["job.completed", "job.failed"],
    "active":        true,
    "signingSecret": "..."     // shown once, store it
  }
}
GET/api/v1/webhooks

List your endpoints (signing secrets are never returned again).

PATCH/api/v1/webhooks/{id}
DELETE/api/v1/webhooks/{id}

Delivery format — each event is a POST with a JSON body and three headers.

http
POST https://your-api.example.com/webhooks/layerrender
Content-Type: application/json
X-LayerRender-Event:     job.completed
X-LayerRender-Delivery:  <unique-delivery-id>
X-LayerRender-Signature: <hex-encoded HMAC-SHA256 of the raw body, using your signing secret>

{
  "event":     "job.completed",
  "timestamp": 1747234567890,
  "data": {
    "jobId":     "...",
    "outputKey": "user/<id>/render/....png",
    "width":     1920,
    "height":    1080,
    "format":    "png",
    "runtimeMs": 1547
  }
}

Verifying the signature (Node)

javascript
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, headerSignature, signingSecret) {
  const expected = createHmac("sha256", signingSecret).update(rawBody).digest("hex");
  return (
    expected.length === headerSignature.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(headerSignature))
  );
}

Retries — delivery is retried up to 6 times with exponential backoff (30s base) on any non-2xx response or transport error. Each delivery attempt is recorded; persistent failures stop after the final retry.

Available events: job.started, job.completed, job.failed.

Credits & billing

Only relevant on the Pay-per-use plan.

Pay-per-use accounts spend render points. Top up a credit pack in the dashboard and the API will debit your balance after each completed render. The point cost depends on render duration and canvas size:

text
points = max(1, runtimeBand + sizeBonus)   // capped at 14

runtimeBand       sizeBonus
≤ 5s   → 1 pt    ≤ 4 MP   → 0 pt
≤ 15s  → 2 pt    ≤ 8 MP   → 1 pt
≤ 30s  → 3 pt    ≤ 16 MP  → 2 pt
≤ 60s  → 5 pt    > 16 MP  → 4 pt
> 60s  → 10 pt

Base rate: $0.05 per point. Most renders end up at 1 pt.

GET/api/v1/credits/balance

Current balance + lifetime totals.

json
Response:
{
  "success": true,
  "data": {
    "tier":                     "payg",
    "balancePoints":            2341,
    "balanceCents":             11705,    // $117.05
    "pointPriceCents":          5,
    "lifetimePurchasedPoints":  3000,
    "lifetimeConsumedPoints":   659
  }
}
GET/api/v1/credits/transactions

Last 50 ledger entries (set ?limit= up to 200).

json
Response:
{
  "success": true,
  "data": [
    {
      "id":           "...",
      "type":         "topup" | "debit" | "refund" | "adjustment",
      "points":       2200,           // positive on credits, negative on debits
      "dollarsCents": 10000,           // null for debits
      "jobId":        null,            // set for "debit" rows
      "note":         "Top-up: Standard pack",
      "createdAt":    "..."
    }
  ]
}

Insufficient credits — submits are accepted while your balance is ≥ 1 pt. The debit happens at completion, so a long render can push your balance slightly negative. The next submit then returns 402 insufficient_credits until you top up.

Top-ups — packs are listed on the pricing page. Request one via the dashboard /dashboard/billing page; we invoice manually and credit your account once paid. (Stripe self-serve coming.)

Rate limits & tiers

PlanAPI calls / dayAPI calls / monthMax upload size
Free53010 MB
Pay-per-use5,000 soft100,00050 MB
Pro1,00030,00050 MB
EnterpriseCustomCustom100 MB

Every /api/v1/* response includes the current usage in headers:

http
X-RateLimit-Limit-Daily:     1000
X-RateLimit-Remaining-Daily: 974
X-RateLimit-Limit-Monthly:   30000
X-RateLimit-Remaining-Monthly: 28411
X-User-Tier:                 pro

Exceeding either limit returns 429 rate_limit_exceeded. File-size caps (413 file_too_large) apply at upload time.

Error format

All failures use a stable envelope.

json
{
  "success": false,
  "error": {
    "code":    "invalid_request" | "invalid_auth" | "rate_limit_exceeded" | ...,
    "message": "human-readable details",
    "status":  400
  }
}

Common codes: invalid_auth (401), forbidden_key (403), job_not_found (404), job_not_completed (409), file_too_large (413), invalid_content_type (415), rate_limit_exceeded (429), server_error (500).