Scaylr mockup render API
Send one design, get back mockup JPEGs on Scaylr templates: T-shirts, hoodies, mugs, tumblers, posters, frames, totes, ornaments and more. Renders use the same engine as the Scaylr studio, so a mockup from the API looks exactly like the one the studio makes.
You can render on templates from the Scaylr library, on your own uploaded PSD templates and on templates you made with "Mockup from a photo". One job takes one design and up to 40 templates. You get one image link per template.
Base URL: https://api.scaylr.io. Every request and answer is JSON (UTF-8), except the design upload (multipart) and thumbnails (JPEG). A machine-readable description is at /openapi.json (OpenAPI 3.1).
Quick start
- Sign in at scaylr.io/api-dashboard and create a key. It looks like
sbk_ab12cd34_…and is shown once, so store it somewhere safe. A free Sandbox key needs no card. - Start a job with your design and a list of template ids.
- Check the job until it is finished (or let a webhook tell you), then download the images.
curl
export SCAYLR_KEY=sbk_...
# 1. find templates (here: mugs)
curl -s "https://api.scaylr.io/api/batch/templates?category=mug" \
-H "Authorization: Bearer $SCAYLR_KEY"
# 2. start a job: upload the design, pick templates
curl -s https://api.scaylr.io/api/batch/render \
-H "Authorization: Bearer $SCAYLR_KEY" \
-F design=@design.png \
-F 'request={"templates":["lib:white-mug-kitchen-counter","lib:white-tshirt-flat-lay-oak-floor"]}'
# 3. check the job (use the "id" from step 2)
curl -s https://api.scaylr.io/api/batch/jobs/JOB_ID \
-H "Authorization: Bearer $SCAYLR_KEY"
JavaScript (Node 18+ or a browser-free server; fetch is built in)
const KEY = process.env.SCAYLR_KEY;
const API = 'https://api.scaylr.io/api/batch';
const headers = { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' };
// design by URL (it must be a public https link to a PNG or JPEG)
let job = await fetch(`${API}/render`, {
method: 'POST', headers,
body: JSON.stringify({
design_url: 'https://example.com/designs/1042.png',
templates: ['lib:white-mug-kitchen-counter', 'lib:white-tshirt-flat-lay-oak-floor'],
options: { max_side: 2000 },
reference: 'order-1042',
}),
}).then(r => r.json());
if (job.error) throw new Error(`${job.error.code}: ${job.error.message}`);
// poll every 5 seconds until the job is finished
while (['queued', 'running'].includes(job.status)) {
await new Promise(r => setTimeout(r, 5000));
job = await fetch(`${API}/jobs/${job.id}`, { headers }).then(r => r.json());
}
for (const item of job.items) console.log(item.template, item.status, item.url || item.error);
Python (requests)
import os, time, json, requests
API = "https://api.scaylr.io/api/batch"
H = {"Authorization": f"Bearer {os.environ['SCAYLR_KEY']}"}
# upload the design file
with open("design.png", "rb") as f:
r = requests.post(f"{API}/render", headers=H, files={"design": f}, data={
"request": json.dumps({
"templates": ["lib:white-mug-kitchen-counter", "lib:white-tshirt-flat-lay-oak-floor"],
"options": {"fit": "fill"},
})
})
job = r.json()
r.raise_for_status()
while job["status"] in ("queued", "running"):
time.sleep(5)
job = requests.get(f"{API}/jobs/{job['id']}", headers=H).json()
for item in job["items"]:
print(item["template"], item["status"], item.get("url") or item.get("error"))
Authentication
Send your key in the Authorization header on every call (all endpoints except /api/batch/health):
Authorization: Bearer sbk_ab12cd34_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- A key belongs to one Scaylr account. It sees that account's own templates and collections, and the library templates its plan allows.
- Keep it secret: use it only from your server, never in a web page, an app people download, or a public repository. Anyone with the key can spend your renders.
- We store only a hash of the key. If you lose it, create a new one in the dashboard and revoke the old one. Revoking takes effect straight away.
- A missing, wrong or revoked key gets
401 unauthorized.
Template ids
Wherever the API takes a template, you can use one of these:
| Id | What it is | Who can use it |
|---|---|---|
lib:<slug> | A template from the Scaylr library, e.g. lib:white-mug-kitchen-counter | Free library templates: every plan. Pro library templates ("pro": true): paid API plans and pay as you go. |
mine:<uuid> | A PSD you uploaded to "My templates" in the studio | Only your account, while it has an active Scaylr Starter or Pro studio plan (or trial). Otherwise it shows "available": false. |
photo:<uuid> | A template you made with "Mockup from a photo" | Only your account. |
uc_<id> (also collection:uc_<id>) | One of your studio collections. In a render request it stands for all its templates, in the collection's order. | Only your account. |
A lib: prefix is optional: a bare slug means a library template. Get the ids from List templates and Collections.
Endpoints
List the templates this key may use
Query parameters (both optional):
| Parameter | Meaning |
|---|---|
category | Library categories such as tshirt, hoodie (also sweatshirts), mug, tumbler, tote, ornament, apparel, poster. Your own: mine; photo templates: photo or photo-<kind>. The answer's categories lists what exists. |
kind | library, mine or photo |
curl -s "https://api.scaylr.io/api/batch/templates?category=mug" -H "Authorization: Bearer $SCAYLR_KEY"
{
"count": 23,
"categories": { "mug": 23 },
"templates": [
{
"id": "lib:white-mug-kitchen-counter",
"kind": "library",
"name": "White Mug Mockup on Kitchen Counter",
"category": "mug",
"thumb": "https://assets.scaylr.io/templates/thumbs/....jpg",
"smart_objects": 1,
"smart_object_ratio": null,
"psd_bytes": 19300000,
"pro": false,
"available": true,
"width": 2048,
"height": 1529
}
]
}
availabletells you whether this key can render the template right now (a Pro template on Sandbox, or own templates without an active studio plan, showfalse).width/heightappear once the template has been opened at least once (by a render or by One template).- Own and photo templates have
"kind": "mine"/"photo", anupdated_attime and athumblink on this API (it needs your key).
One template, with its size and smart objects
Opens the PSD (a few seconds the first time, then cached) and tells you its size and its smart objects (the places a design goes). Use the smart-object index with the slots option.
curl -s https://api.scaylr.io/api/batch/templates/lib:white-mug-kitchen-counter -H "Authorization: Bearer $SCAYLR_KEY"
{
"id": "lib:white-mug-kitchen-counter",
"kind": "library",
"name": "White Mug Mockup on Kitchen Counter",
"category": "mug",
"pro": false,
"available": true,
"width": 2048,
"height": 1529,
"smart_object_slots": [
{ "index": 0, "name": "Design", "width": 2000, "height": 1447, "hidden": false,
"mug": { "angle": 150, "circ": 2.733 } }
]
}
If the renderer cannot open the PSD, the answer is still 200 but has "error" and "unsupported": true instead of the size; renders on it would fail. See Supported PSDs. An unknown id, or one this key may not use, gets 404 not_found with the reason.
The template's thumbnail
A JPEG preview. For library templates the answer is a 302 redirect to the public image on assets.scaylr.io; for mine: and photo: templates it is the JPEG itself (send your key). 404 if there is no thumbnail.
Your studio collections
The collections you made in the studio library, each with its template ids in the collection's order. Put a collection id in a render request to use all of it.
{
"count": 1,
"collections": [
{ "id": "uc_93352493-1b4", "name": "Portrait for Website", "created_at": 1793000000, "count": 3,
"templates": ["mine:bb33d7f2-9f56-4418-96ae-1e6eebff7f8f", "lib:oak-frame-cosy-living-room", "photo:2b1c0e9a-…"] }
]
}
Start a render job: one design, up to 40 templates
Send the design in one of two ways:
- Upload it as
multipart/form-data: a file fielddesignand a text fieldrequestholding the JSON below (withoutdesign_url). - Give a link with
Content-Type: application/json: the JSON below withdesign_url. The link must behttpsand answer within 20 seconds.
The design must be a PNG (a transparent background works best) or a JPEG; see design limits.
Request fields
| Field | Type | Meaning |
|---|---|---|
templates | array, required | Template ids (strings), collection ids, or objects { "id": "…", …options } to give one template its own options. At most 40 after collections are expanded. |
design_url | string | JSON requests only: the https link to the design. |
options | object | Render options for every template in the job. |
reference | string | Your own label, e.g. an order or product id (up to 200 characters). Returned in design.reference. |
callback_url | string | Optional https link we POST the finished job to. See Webhooks. |
Any other field gets 400 bad_request, so typos don't go unnoticed.
curl -s https://api.scaylr.io/api/batch/render \
-H "Authorization: Bearer $SCAYLR_KEY" -H "Content-Type: application/json" -d '{
"design_url": "https://example.com/designs/1042.png",
"templates": [
"lib:white-tshirt-flat-lay-oak-floor",
{ "id": "lib:white-hoodie-flat-lay-oak-floor", "scale": 0.8, "offset_y": -0.05 },
{ "id": "lib:white-mug-kitchen-counter", "mug": "wrap" },
"uc_93352493-1b4"
],
"options": { "max_side": 2000, "quality": 88 },
"reference": "order-1042",
"callback_url": "https://example.com/hooks/scaylr"
}'
The answer is 202 Accepted with the new job (the same shape as Get a job), a poll path and a Location header:
{
"id": "2165802a-1039-4984-a2e7-ede2b4b3c6e8",
"status": "queued",
"created_at": 1793116198,
"results_expire_at": 1795708198,
"design": { "type": "png", "width": 3000, "height": 4000, "bytes": 842877, "reference": "order-1042" },
"counts": { "total": 6, "done": 0, "failed": 0, "queued": 6, "running": 0 },
"renders_ahead_in_queue": 0,
"items": [ { "index": 0, "template": "lib:white-tshirt-flat-lay-oak-floor", "status": "queued", "options": { "max_side": 2000, "quality": 88 } } ],
"skipped": [ { "template": "lib:some-retired-template", "reason": "unknown template \"lib:some-retired-template\"" } ],
"poll": "/api/batch/jobs/2165802a-1039-4984-a2e7-ede2b4b3c6e8"
}
skipped only appears when a collection held templates that can't be used (no longer published, a Pro template on Sandbox, own templates without an active studio plan). A template you list yourself that can't be used is an error instead (400 bad_templates), and nothing is started.
Before the job starts, the API checks that your account has enough renders left this month for every template in it (renders already waiting count too). If not, nothing is started and you get 402 monthly_limit.
A job's status and its image links
{
"id": "2165802a-1039-4984-a2e7-ede2b4b3c6e8",
"status": "done",
"created_at": 1793116198,
"started_at": 1793116201,
"finished_at": 1793116231,
"results_expire_at": 1795708198,
"design": { "type": "png", "width": 3000, "height": 4000, "bytes": 842877, "reference": "order-1042" },
"counts": { "total": 2, "done": 2, "failed": 0, "queued": 0, "running": 0 },
"renders_ahead_in_queue": 0,
"items": [
{ "index": 0, "template": "lib:white-tshirt-flat-lay-oak-floor", "status": "done", "options": {},
"url": "https://assets.scaylr.io/api-renders/2165802a-…/01-white-tshirt-flat-lay-oak-floor.jpg",
"width": 2000, "height": 1493, "ms": 1375 },
{ "index": 1, "template": "lib:white-mug-kitchen-counter", "status": "done", "options": {}, "mug": "side",
"url": "https://assets.scaylr.io/api-renders/2165802a-…/02-white-mug-kitchen-counter.jpg",
"width": 2000, "height": 1493, "ms": 1610 }
]
}
Times are Unix seconds (UTC). A job can only be read with the key that started it; any other key gets 404. Fields per item: url, width, height when done; error when failed; mug (wrap or side) on mug and tumbler templates; ms = render time.
This month's renders, plan and credits
{
"plan": "starter",
"plan_name": "API Starter",
"period": { "start": 1793000000, "end": 1795592000 },
"included": 1500,
"used": 412,
"credits": 1000,
"waiting": 6,
"remaining": 2082,
"watermark": false,
"key": "ab12cd34",
"days": [ { "day": "2026-11-10", "renders": 120, "failed": 1, "from_credits": 0 } ]
}
remaining = plan renders left + credits − renders still waiting. period is your billing month on a monthly plan, otherwise the calendar month (UTC). used counts successful renders taken from the plan (renders paid with credits show in from_credits).
Is the API up? No key needed
{ "ok": true, "version": "2026-11-11" }
Render options
Put options in "options" for the whole job, or on one template object to override them for that template.
| Option | Values | Meaning |
|---|---|---|
fit | fill (default), fit, stretch | How the design fills the print area, like Fill / Fit / Stretch in the studio. |
scale | 0.1 – 4 (default 1) | Size of the design relative to what fit gives (0.8 = 80 %). |
offset_x, offset_y | −1 – 1 (default 0) | Move the design by this share of the print area (positive = right / down). |
mug | auto (default), wrap, side | Mugs and tumblers: full wrap or one side. auto guesses like the studio (a 2:1 design wraps). |
slots | "all" (default) or a list like [0, 2] | Which smart objects get the design (indexes from One template). Default: every visible one. |
max_side | 400 – 2048 (default 2048) | Long side of the JPEG in pixels. Sandbox: at most 1024. |
quality | 50 – 95 (default 88) | JPEG quality. |
Numbers must be JSON numbers (not strings). A wrong value or an unknown option gets 400 bad_options (or bad_templates when it is on one template).
Jobs, statuses and polling
| Job status | Meaning |
|---|---|
queued | Waiting for its turn. renders_ahead_in_queue says how many renders are before it. |
running | Templates are being rendered. |
done | Finished; every item is done. |
partial | Finished; some items done, some failed. |
failed | Finished; every item failed. |
Each item has its own status: queued, running, done (has url) or failed (has error, a sentence saying why).
- Polling: check every 5 seconds. One render usually takes 4–7 seconds once it starts, plus a few seconds to start up a batch. Each poll counts toward the per-minute request limit, so don't poll faster than once a second.
- Webhooks: add a
callback_urland we tell you when the job is finished. Polling keeps working too. - Queue: renders from several accounts share the queue fairly; higher plans are served more often when many accounts wait.
- Failed renders are free: only items that end
donecount toward your renders.
Image links expire after 30 days. Result links are public but unguessable (a random job id), and they are deleted 30 days after the job was created (results_expire_at), together with the job record. Copy the images to your own storage or straight into your shop (for example Shopify's productCreateMedia with the link as originalSource) soon after the job finishes. Your uploaded design is deleted as soon as the job finishes.
Webhooks
Add "callback_url": "https://…" to the render request. When the job finishes (done, partial or failed), Scaylr sends a POST to that link with the same JSON as GET /api/batch/jobs/<id> as the body (Content-Type: application/json).
- The link must use https.
- Answer with any 2xx status within 10 seconds. Do slow work (downloading images, updating your shop) after you answer.
- If your server doesn't answer with 2xx in time, we try again up to 3 more times: after about 1 minute, about 5 minutes and about 30 minutes.
- A job can arrive more than once (for example when your answer got lost). Use the job
idto skip ones you already handled. - Webhooks are part of every paid plan and pay as you go. On Sandbox, use polling.
Checking the signature
Every webhook carries a header that proves it came from Scaylr and wasn't changed:
Scaylr-Signature: t=1793116232,v1=5f2b…(64 hex characters)
t= when it was sent (Unix seconds).v1= HMAC-SHA256, in lowercase hex, of the text<t>.<raw body>(the timestamp, a dot, then the body exactly as received).- The HMAC secret is the SHA-256 of your full API key, written as 64 lowercase hex characters (use that text as the secret). You can work it out from the key you already have; nothing extra to store.
Check the signature against the raw body before parsing it, compare in constant time, and reject old timestamps (we suggest more than 5 minutes off).
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
// the webhook secret: SHA-256 of your full API key, as lowercase hex text
const SECRET = crypto.createHash('sha256').update(process.env.SCAYLR_KEY).digest('hex');
function verifyScaylr(rawBody, header) {
const parts = Object.fromEntries(String(header || '').split(',').map(p => p.trim().split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // older than 5 minutes
const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(parts.v1 || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/hooks/scaylr', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyScaylr(raw, req.get('Scaylr-Signature'))) return res.sendStatus(400);
res.sendStatus(200); // answer first ...
const job = JSON.parse(raw); // ... then do the work
for (const item of job.items) if (item.status === 'done') console.log(item.template, item.url);
});
app.listen(3000);
Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, request
# the webhook secret: SHA-256 of your full API key, as lowercase hex text
SECRET = hashlib.sha256(os.environ["SCAYLR_KEY"].encode()).hexdigest().encode()
def verify_scaylr(raw_body: bytes, header: str) -> bool:
try:
parts = dict(p.strip().split("=", 1) for p in (header or "").split(","))
t = int(parts["t"])
except (ValueError, KeyError):
return False
if abs(time.time() - t) > 300: # older than 5 minutes
return False
expected = hmac.new(SECRET, parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
app = Flask(__name__)
@app.post("/hooks/scaylr")
def scaylr_hook():
raw = request.get_data() # the raw bytes, before any parsing
if not verify_scaylr(raw, request.headers.get("Scaylr-Signature", "")):
return "", 400
job = json.loads(raw)
# keep this fast: queue the downloads and answer within 10 seconds
return "", 200
Supported PSDs
Renders use Scaylr's own rendering engine, the same one the studio uses. It handles smart objects with warps and perspective, clipping masks, layer and vector masks, blend modes, common adjustment layers and layer effects, and mug and tumbler wraps.
Photoshop has more features than any other renderer. If a template (most likely one of your own PSDs, mine:) uses a feature the engine doesn't support yet, that template's item ends failed with an error that starts with template not supported by the renderer: … and names what is missing. The rest of the job still renders, and the failed item doesn't count. To check a PSD before using it in a job, call One template and look for "unsupported": true.
Other item errors start with render failed: …. If the rendering browser itself stops on a template, that item is tried again, up to 3 times, before it is marked failed.
Errors
Every error has the same shape and a sentence you can show or log:
{ "error": { "code": "bad_templates", "message": "templates[1]: unknown template \"lib:white-mugg\"" } }
| HTTP | code | When |
|---|---|---|
| 400 | bad_request | The body can't be read, templates is missing or empty, an unknown field, no design sent. |
| 400 | bad_design | Not a PNG/JPEG, too many pixels, too large per side, or design_url could not be downloaded (not https, an error status, a timeout). |
| 400 | bad_options | A value in options is out of range or unknown. |
| 400 | bad_templates | An unknown template or collection, a template this key may not use, or a bad option on one template. The message lists each problem with its position. |
| 400 | too_many_templates | More than 40 templates after collections are expanded. |
| 401 | unauthorized | No key, a wrong key, or a revoked key. |
| 402 | monthly_limit | The job needs more renders than the account has left this month (plan renders + credits, minus renders already waiting). Buy credits or upgrade in the dashboard. |
| 403 | forbidden | The account for this key no longer exists. |
| 404 | not_found | Unknown path, template or job (or a job started by another key). |
| 413 | too_large | The uploaded design (or the whole upload) is larger than 30 MB. |
| 415 | bad_request | The Content-Type is neither application/json nor multipart/form-data. |
| 429 | rate_limited | More than 120 requests a minute (wait the seconds in the Retry-After header), or more than 60 jobs an hour. |
| 429 | daily_limit | The key would pass 2,000 renders in 24 hours. |
| 429 | queue_full | The key would have more than 400 renders waiting at once. Wait for some to finish. |
| 500 | internal | Something went wrong on our side. Try again; if it keeps happening, tell us. |
On any 429: if there is a Retry-After header, wait that many seconds; otherwise wait a minute or two and try again. A job turned away with 4xx was not started and costs nothing.
Limits
Per key
| Limit | Default | Error when passed |
|---|---|---|
| API requests | 120 per minute | 429 rate_limited + Retry-After |
| Jobs | 60 per hour | 429 rate_limited |
| Renders | 2,000 per 24 hours | 429 daily_limit |
| Renders waiting at once | 400 | 429 queue_full |
| Templates per job | 40 | 400 too_many_templates |
These are the same on every plan. Need more? Write to us (see Help); limits can be raised per key. Your plan's monthly renders are a separate limit per account (402 monthly_limit).
Design
- PNG or JPEG, at most 30 MB and 8,000 px per side.
- PNG: at most 40 megapixels (an interlaced PNG: 12 MP). JPEG: at most 12 megapixels. For big designs, send a PNG.
- The design is sized like in the studio (long side up to 2,000 px; mug wraps up to 3,200 px), so you don't need to send more than that.
Output
- JPEG, long side 400–2,048 px (default: the template's own size, up to 2,048), quality 50–95 (default 88).
- Sandbox: at most 1,024 px, with a "Scaylr API · sandbox" watermark.
Plans & pricing
One render = one template with your design = one JPEG. Prices in US dollars. Manage your plan and buy credits in the dashboard.
Pay as you go: credit packs
No monthly fee. Credits never expire. One credit = one render, with no watermark and every library template (2 keys).
| Pack | Price | Per render |
|---|---|---|
| 1,000 renders | $20 | 2 ¢ |
| 5,000 renders | $75 | 1.5 ¢ |
| 25,000 renders | $250 | 1 ¢ |
- On a monthly plan, the plan's renders are used first, then your credits.
- Without a monthly plan, an account with credits is on pay as you go; with no credits left it is back on Sandbox.
- Failed renders are free. There are no surprise bills: when renders run out, new jobs get
402 monthly_limituntil the next month, a plan change or more credits. - Webhooks are included on every plan except Sandbox.
The licence for using the mockups (your listings and your customers' listings on paid plans; Sandbox is for testing) is in the Scaylr API terms.
Help
Questions, a bug, or a template that won't render? Email info@scaylr.io with the job id. We reply within 24 hours. Check /api/batch/health to see if the API is up.