Developers

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://readingrock.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

400validation_failed (+field), invalid_json, image_unreachable_or_invalid
401unauthorized — bad key, signature, timestamp outside ±300s, or a replayed signed request
402monthly_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)
403scope_not_allowed — the key's scope does not cover this endpoint; api_not_on_plan
404not_found
409idempotency_conflict, request_in_progress, render_not_terminal
422unknown_or_unlicensed_variation
429rate_limited, daily_limit_exceeded — honor Retry-After; see X-SS-RateLimit-* / X-SS-Daily-*
5xxdispatch_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 (always null) }, 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]