Scaylr API

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

  1. 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.
  2. Start a job with your design and a list of template ids.
  3. 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

Template ids

Wherever the API takes a template, you can use one of these:

IdWhat it isWho can use it
lib:<slug>A template from the Scaylr library, e.g. lib:white-mug-kitchen-counterFree 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 studioOnly 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

GET/api/batch/templates

List the templates this key may use

Query parameters (both optional):

ParameterMeaning
categoryLibrary 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.
kindlibrary, 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
    }
  ]
}
GET/api/batch/templates/<id>

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.

GET/api/batch/templates/<id>/thumb

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.

GET/api/batch/collections

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-…"] }
  ]
}
POST/api/batch/render

Start a render job: one design, up to 40 templates

Send the design in one of two ways:

The design must be a PNG (a transparent background works best) or a JPEG; see design limits.

Request fields

FieldTypeMeaning
templatesarray, requiredTemplate ids (strings), collection ids, or objects { "id": "…", …options } to give one template its own options. At most 40 after collections are expanded.
design_urlstringJSON requests only: the https link to the design.
optionsobjectRender options for every template in the job.
referencestringYour own label, e.g. an order or product id (up to 200 characters). Returned in design.reference.
callback_urlstringOptional 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.

GET/api/batch/jobs/<id>

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.

GET/api/batch/usage

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

GET/api/batch/health

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.

OptionValuesMeaning
fitfill (default), fit, stretchHow the design fills the print area, like Fill / Fit / Stretch in the studio.
scale0.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).
mugauto (default), wrap, sideMugs 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_side400 – 2048 (default 2048)Long side of the JPEG in pixels. Sandbox: at most 1024.
quality50 – 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 statusMeaning
queuedWaiting for its turn. renders_ahead_in_queue says how many renders are before it.
runningTemplates are being rendered.
doneFinished; every item is done.
partialFinished; some items done, some failed.
failedFinished; every item failed.

Each item has its own status: queued, running, done (has url) or failed (has error, a sentence saying why).

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

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)

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\"" } }
HTTPcodeWhen
400bad_requestThe body can't be read, templates is missing or empty, an unknown field, no design sent.
400bad_designNot a PNG/JPEG, too many pixels, too large per side, or design_url could not be downloaded (not https, an error status, a timeout).
400bad_optionsA value in options is out of range or unknown.
400bad_templatesAn 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.
400too_many_templatesMore than 40 templates after collections are expanded.
401unauthorizedNo key, a wrong key, or a revoked key.
402monthly_limitThe 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.
403forbiddenThe account for this key no longer exists.
404not_foundUnknown path, template or job (or a job started by another key).
413too_largeThe uploaded design (or the whole upload) is larger than 30 MB.
415bad_requestThe Content-Type is neither application/json nor multipart/form-data.
429rate_limitedMore than 120 requests a minute (wait the seconds in the Retry-After header), or more than 60 jobs an hour.
429daily_limitThe key would pass 2,000 renders in 24 hours.
429queue_fullThe key would have more than 400 renders waiting at once. Wait for some to finish.
500internalSomething 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

LimitDefaultError when passed
API requests120 per minute429 rate_limited + Retry-After
Jobs60 per hour429 rate_limited
Renders2,000 per 24 hours429 daily_limit
Renders waiting at once400429 queue_full
Templates per job40400 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

Output

Plans & pricing

One render = one template with your design = one JPEG. Prices in US dollars. Manage your plan and buy credits in the dashboard.

Sandbox
$0
100 renders a month to test with. Watermark, max 1,024 px, free library templates only, 1 key.
API Starter
$19/month
1,500 renders a month. No watermark, every library template, 2 keys.
API Growth
$49/month
6,000 renders a month. 5 keys, higher queue priority.
API Scale
$149/month
25,000 renders a month. 10 keys, highest queue priority.

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

PackPricePer render
1,000 renders$202 ¢
5,000 renders$751.5 ¢
25,000 renders$2501 ¢

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.