Render Engine API v1
Server-to-server rendering: your backend submits a photo URL and product variations; we return a photorealistic transformation. Async by design — submit, then receive a signed webhook (or poll). We store no images: inputs are fetched transiently and handed to the render engine; results are engine-hosted URLs you must download promptly. Job metadata is retained 90 days for billing and support, then purged.
Two halves share one base URL and one signing scheme: the Render
Engine API below (keys issued by StoneSwap) and the Management
API under /v1/manage (keys your portal owner mints on Developers → API).
Each key carries a scope — engine, manage or both — and the
other half answers 403 scope_not_allowed.
Base URL
https://stoneswap.studio/api/v1
Authentication — signed requests
You hold a key id (public) and a secret (sk_live_… /
sk_test_…, shown once at issuance). Derive your signing key once:
signing_key = hex( SHA-256( secret ) ) // lowercase hex string
We never store your raw secret — only this derived key. Sign every request:
canonical = METHOD + "\n" // e.g. "POST"
+ TARGET + "\n" // path from /v1 PLUS the query string exactly as sent:
// "/v1/renders"
// "/v1/manage/users?email=jane%40example.com"
+ TIMESTAMP + "\n" // unix seconds, ±300s tolerated
+ hex( SHA-256( raw_body ) ) // sha256 of "" for GET
signature = hex( HMAC-SHA256( signing_key, canonical ) )
TARGET is the path starting at /v1, then — when there is one —
? and the query string byte for byte as you send it (no re-encoding, no
re-ordering, no trailing slash). A signature is therefore good for one exact request, never
for "any query on that path".
X-SS-Key-Id: key_9f3a1b2c4d5e6f70 X-SS-Timestamp: 1765912345 X-SS-Signature: 3f8a…
PHP:
$signingKey = hash('sha256', $secret);
$target = '/v1/renders'; // a GET: '/v1/manage/users?email=' . rawurlencode($email)
$canonical = "POST\n{$target}\n{$ts}\n" . hash('sha256', $rawBody); // METHOD in upper case
$signature = hash_hmac('sha256', $canonical, $signingKey);
Node:
const crypto = require('crypto');
const signingKey = crypto.createHash('sha256').update(secret).digest('hex');
const target = '/v1/renders'; // a GET: '/v1/manage/users?email=' + encodeURIComponent(email)
const canonical = `POST\n${target}\n${ts}\n` +
crypto.createHash('sha256').update(rawBody).digest('hex');
const signature = crypto.createHmac('sha256', signingKey).update(canonical).digest('hex');
Each signed request may be sent once. We remember the signature of every accepted
POST/PUT/DELETE — and, on the Management API, of every
GET as well — for 15 minutes; a second copy of the same signed request — a replay — is
refused with 401 unauthorized. Retrying is still trivial: a fresh timestamp yields a
fresh signature (use an Idempotency-Key so the retry cannot double-apply). Timestamps
have one-second resolution, so two byte-identical requests sent within the same second share a
signature and the second one is refused as a replay — space identical calls a second apart. Optional
per-key IP allow-lists accept exact addresses and CIDR blocks (203.0.113.0/24,
2001:db8::/32).
Idempotency
Add Idempotency-Key: <up to 64 chars> to any POST. The same key with the
same path and body within 24 hours replays the original response (header
Idempotent-Replay: true) instead of double-charging; the same key with a different
body — or on a different path — returns 409 idempotency_conflict.
POST /v1/renders
{
"image": { "url": "https://your-cdn.com/photo.jpg" },
"guidance": { "url": "https://your-cdn.com/photo-with-strokes.jpg" }, // optional
"items": [
{ "variation_id": "A1B2C3D4E5F6", "context": "Used as the main patio paver" }
], // 1–5 items
"environment": { "time_of_day": "evening", "accent_lighting": "modern" },
"instructions": "Keep the maple tree", // ≤500 chars
"model": "nano_2", // optional
"webhook_url": "https://your-app.com/hooks/ss", // optional per-request override (https)
"metadata": { "your_user_id": "u_123" } // ≤2KB, echoed back verbatim
}
202 Accepted → {"render_id":"r_…","status":"pending","estimated_seconds":50}.
Images must be public JPEG/PNG/WEBP, 200–8000px per edge. If guidance is present it
replaces image as what the engine sees; keep your clean photo for before/after UI.
Do not put personal data in metadata — it is stored with the job row.
GET /v1/renders/{render_id}
{
"render_id": "r_…", "status": "pending" | "success" | "error",
"result_url": "https://…jpg" | null, // DOWNLOAD IMMEDIATELY — we keep no copy
"error": null | "generation_failed",
"metadata": { … }, "timings": { "queued_at": "…", "completed_at": "…" }
}
Unknown ids return 404.
Webhooks (recommended)
On terminal status we POST to your configured (or per-request) URL:
{
"event": "render.completed" | "render.failed",
"event_id": "evt_…", // unique — your dedupe key
"render_id": "r_…", "status": "success" | "error",
"result_url": "https://…jpg" | null, "error": null | "generation_failed",
"metadata": { … },
"billing": { "renders": 1, "sandbox": false }, // what the job cost you: one render (0 on a sandbox key)
"timestamp": 1765912399
}
Headers: X-SS-Timestamp, X-SS-Event-Id, and
X-SS-Signature = hex( HMAC-SHA256( webhook_secret, timestamp + "." + raw_body ) )
with your whsec_… secret. Reject drift > 5 minutes. Respond 2xx within 10s;
otherwise we retry at 30s, 5m, 30m, and 2h, then dead-letter (visible via
GET /v1/webhook-deliveries?render_id=…).
PHP verification:
$ts = $_SERVER['HTTP_X_SS_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_SS_SIGNATURE'] ?? '';
$raw = file_get_contents('php://input');
$ok = abs(time() - (int)$ts) <= 300
&& hash_equals(hash_hmac('sha256', $ts . '.' . $raw, $webhookSecret), $sig);
Node verification:
const ok = Math.abs(Date.now()/1000 - Number(ts)) <= 300 &&
crypto.timingSafeEqual(
Buffer.from(crypto.createHmac('sha256', webhookSecret).update(`${ts}.${raw}`).digest('hex')),
Buffer.from(sig));
GET /v1/catalog
Your licensed variations (the ids items[] accepts). Query params:
updated_since (ISO 8601), product_type, limit (≤500),
offset (best-effort while the hourly catalog sync runs), or cursor (the
next_cursor of the previous page — rows ordered by variation_id, stable).
Fields per row:
variation_id, product_id, brand, product_type, product_name, color_category, color_display_name, variation_name, is_default, thumbnail_url, thumbnail_small_url
Descriptions, reference imagery and matching keywords are StoneSwap render-engine internals and are not exposed.
POST /v1/renders/{render_id}/retry
Body {"instructions":"…"} — creates a linked new render of a finished job with
fresh instructions (same photo and products), billed per contract. Parent must not be pending.
Sandbox
sk_test_… keys hit the same endpoints with full validation, no charge, a
SAMPLE result after a realistic delay, and real signed webhooks — integrate before contracts
finish. Rate/volume limits still apply.
Errors
400 | validation_failed (+field), invalid_json, image_unreachable_or_invalid |
401 | unauthorized — bad key, signature, timestamp outside ±300s, or a replayed signed request |
402 | monthly_volume_exceeded — contract cap reached; quota_exhausted; prepaid_exhausted — the prepaid design balance is empty and this plan pauses at zero (resumes the moment a purchase is recorded) |
403 | scope_not_allowed — the key's scope does not cover this endpoint; api_not_on_plan |
404 | not_found |
409 | idempotency_conflict, request_in_progress, render_not_terminal |
422 | unknown_or_unlicensed_variation |
429 | rate_limited, daily_limit_exceeded — honor Retry-After; see X-SS-RateLimit-* / X-SS-Daily-* |
5xx | dispatch_failed, render_unavailable, not_available — retry with backoff |
Every response carries a request_id — include it when contacting support.
A failed render's error in the webhook and on GET /v1/renders/{id} is
always the closed word generation_failed; the portal's own render.failed
event carries a closed reason code (safety_block, timeout,
dispatch_failed, submit_failed, not_funded,
generation_failed) — never the engine's raw error text.
Management API — /v1/manage
Your back-office channel: keep your CRM in step with your visualizer, honour erasure
requests, and read the durable deletion log without depending on a webhook. Available on every
plan and every account type. Keys are minted by your portal owner (Developers → API; the secret
is shown once) with scope manage. Signing is identical to the Render Engine API —
the canonical TARGET simply starts with /v1/manage and carries the query
string exactly as sent — and every signed request, GETs included, is accepted once
(a replayed signature answers 401).
Limits: 60 calls per minute and 5,000 per UTC day per key
(429 rate_limited / 429 daily_limit_exceeded with Retry-After;
advisory headers X-SS-RateLimit-Limit, X-SS-RateLimit-Remaining,
X-SS-Daily-Limit, X-SS-Daily-Remaining). Audit: every
call — reads included — is written to your portal's audit log as api:key_….
GET /v1/manage this key, its limits, the endpoint list
GET /v1/manage/users?email=|user_id= look one customer up (exact match)
GET /v1/manage/users/{user_id} one customer
POST /v1/manage/users/{user_id}/credits {"delta": 3, "reason": "goodwill"} free credits, ±1…1000
POST /v1/manage/users/{user_id}/block {"reason": "…"} hold the account (no erasure)
POST /v1/manage/users/{user_id}/unblock
POST /v1/manage/users/{user_id}/blacklist {"reason": "…"} ban + erase (email and devices black-listed)
POST /v1/manage/users/{user_id}/erase {"note": "…"} erase on request (NOT black-listed)
GET /v1/manage/leads?since=&status=&user_type=&limit=&cursor= your leads, decrypted, newest first
GET /v1/manage/leads/{id}
GET /v1/manage/deletions?since=&reason=&limit=&cursor= the durable deletion log
GET /v1/manage/events?since=&event=&limit=&cursor= the recent event stream (30 days)
Lists page by cursor: pass the previous answer's next_cursor
(null on the last page); limit is 1–200 (default 50);
since is ISO 8601 or YYYY-MM-DD.
A customer object:
{
"user_id": "c4f1…", "email": "[email protected]", "name": "Jane Doe", "first_name": "Jane", "last_name": "Doe",
"role": "homeowner" | "contractor", "profession": null | "contractor" | "designer", "business_name": null, "business_website": null, "phone": "…",
"postal": "H2X 1Y4", "country": "CA", "region": "QC", "city": "Montréal",
"status": "active" | "flagged" | "blocked" | "deleted", "verified": true, "deleted": false, "deleted_at": null,
"renders_total": 12, "last_render_at": "…", "free_balance": 3, "paid_balance": 0,
"consent_marketing": false, "created_at": "…", "last_active": "…"
}
POST …/credits adds (or removes) free render credits — the same wallet
your portal's Adjust button uses; paid credits only ever come from a purchase. Send an
Idempotency-Key: the same key with the same path and body replays the original answer
(Idempotent-Replay: true) instead of crediting twice; the same key for a different
customer or a different body is 409 idempotency_conflict. The answer is
{"ok":true,"user_id":…,"delta":3,"before":2,"after":5,"free_balance":5,"paid_balance":0};
409 would_go_negative when a removal exceeds the balance, 409 account_deleted
for an erased account.
POST …/blacklist and POST …/erase both erase the account (photos,
renders, forms, profile — a tombstone with only an email hash remains) and both produce a
user.deleted event (reason api_blacklist / api_erasure,
requested_by api:key_…) plus an entry in the deletion log. Only the black-list also
bans the email and every device; its reason is kept in your portal's audit log only
(the erase note is the one that appears on the deletion-log entry — it is published to
every portal role, so keep it free of personal data). POST …/block puts the account on hold
(sign-in and renders stop, data kept; user.blocked fires) until …/unblock.
A deletion-log entry (GET …/deletions): { id, user_id, email_hash, reason,
requested_by, note, at, webhook: { status, delivered_at, endpoints, resendable }, event_id,
bell_sent, acknowledged: { at, by }, leads_removed, renders_removed } — reasons are
self_service | erasure_request | safety_ban | portal_blacklist | api_blacklist | api_erasure.
If you keep copies of an erased customer outside StoneSwap you must delete them — this feed
exists so that duty never depends on a webhook.
A lead (GET …/leads) is decrypted for you: { id, source, user_type, profession, status,
owner, user_id, name, email, phone, business, website, consent, location: { address, city, region,
postal, country }, project: { budget, timeline, details, has_contractor, free_credits }, render_id,
created_at }. Sign-up records and dealer-owned kiosk leads are not included.
Versioning
URL-versioned. Additive changes only within /v1; breaking changes ship as
/v2 with a 12-month overlap.
Powered by StoneSwap.ai — attribution per your agreement. No lead capture, no consumer accounts, no image persistence on our side. StoneSwap Inc. · Hamilton, Ontario · [email protected]