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.
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.psd3. Submit a render. The response gives you a jobId.
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.
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.pngAuthentication
All /api/v1/* requests require an API key passed as a Bearer token in the Authorization header. Keys start with the prefix lr_.
Authorization: Bearer lr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKeys 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.
{ "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.
{ "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.
| Type | What it does | Extra fields |
|---|---|---|
| visibility | Show or hide a layer. | { visible: true | false } |
| opacity | Set a layer's opacity (0–1). | { opacity: 0.5 } |
| blendMode | Change a layer's blend mode. | { blendMode: "multiply" } |
| position | Move a layer to new (left, top) coords. | { left: 120, top: 80 } |
| text | Replace a text layer's content. | { text: "Spring sale", color?: "#222" } |
| imageReplace | Swap a layer's bitmap with a new image. | { imageBase64: "<base64>" } |
| colorReplace | Recolor pixels matching a hex value. | { from: "#1e88e5", to: "#f43f5e" } |
| recolor | Repaint 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.
{
"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.
colorReplacematches pixels by exact RGB value. Anti-aliased edges and gradients keep their original color. For solid-fill recoloring that preserves shading, userecolorinstead.- 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.
/api/v1/renderQueue a render job. Returns jobId immediately; rendering happens asynchronously.
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 } }/api/v1/render/status?jobId=<id>Poll job status. Owner-scoped.
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
}
}/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.
/api/v1/render/batchRequest 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.
/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.
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
/api/v1/metadataInspect a PSD's dimensions, color mode, and full layer tree.
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.
/api/v1/templatesCreate a template from a previously uploaded PSD.
{
"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" }
]
}/api/v1/templatesList your templates.
/api/v1/templates/{id}/api/v1/templates/{id}Update name/description/slots.
/api/v1/templates/{id}Removes the template row and its PSD from storage.
/api/v1/templates/{id}/renderQueue a render from a template using slot values.
{
"values": {
"headline": "Spring sale",
"logo": "<base64-png>",
"show_badge": false
},
"output": { "format": "png" }
}Webhooks
Get a signed POST when jobs change state.
/api/v1/webhooksRegister an endpoint. Returns the signing secret exactly once.
{
"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
}
}/api/v1/webhooksList your endpoints (signing secrets are never returned again).
/api/v1/webhooks/{id}/api/v1/webhooks/{id}Delivery format — each event is a POST with a JSON body and three headers.
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)
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:
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 ptBase rate: $0.05 per point. Most renders end up at 1 pt.
/api/v1/credits/balanceCurrent balance + lifetime totals.
Response:
{
"success": true,
"data": {
"tier": "payg",
"balancePoints": 2341,
"balanceCents": 11705, // $117.05
"pointPriceCents": 5,
"lifetimePurchasedPoints": 3000,
"lifetimeConsumedPoints": 659
}
}/api/v1/credits/transactionsLast 50 ledger entries (set ?limit= up to 200).
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
| Plan | API calls / day | API calls / month | Max upload size |
|---|---|---|---|
| Free | 5 | 30 | 10 MB |
| Pay-per-use | 5,000 soft | 100,000 | 50 MB |
| Pro | 1,000 | 30,000 | 50 MB |
| Enterprise | Custom | Custom | 100 MB |
Every /api/v1/* response includes the current usage in headers:
X-RateLimit-Limit-Daily: 1000
X-RateLimit-Remaining-Daily: 974
X-RateLimit-Limit-Monthly: 30000
X-RateLimit-Remaining-Monthly: 28411
X-User-Tier: proExceeding 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.
{
"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).