Stock images & AI generation

Wire the design editor's stock-image search and AI-generation tabs through same-origin proxy routes you host. The canvas never holds a vendor key — your Unsplash / Pixabay / AI keys live on your server, behind URLs you pass via the services prop.

No keys in the browser

The Canvas editor’s Image Browser has tabs that talk to privileged, billed vendor APIs: stock-image search (Unsplash / Pixabay) and AI image generation. Shipping vendor keys to the browser would hand them to anyone who opens devtools, so the canvas package never reads one and never calls a vendor directly. Instead it speaks a small wire contract against same-origin proxy URLs you host — the same pattern as grantUrl:

  • You host a route (e.g. /api/image-search). The vendor keys live there, server-only.
  • You pass the route’s URL to the editor via the services prop — available on SnowconeCanvas, RealtimeEmbed, and RealtimeCanvas.
  • A tab whose service isn’t configured doesn’t render at all. With no services prop the Image Browser shows only the URL/upload tab; no user-facing error ever mentions configuration internals.
Requires @snowcone-app/canvas ≥ 0.7.0 (the services prop) and @snowcone-app/sdk ≥ 0.7.0 (createImageSearchHandler).

Quickstart: stock-image search

Two pieces: a route file (server) and one prop (browser).

1. The proxy route. createImageSearchHandler from @snowcone-app/sdk/server is a drop-in implementation of the wire contract — Unsplash and/or Pixabay behind one route, interleaved results, attribution, and caching handled:

tsx
// app/api/image-search/route.ts — your same-origin image-search proxy.
// Vendor keys live HERE, server-only — never NEXT_PUBLIC_* / VITE_*.
import { createImageSearchHandler } from '@snowcone-app/sdk/server';

const handler = createImageSearchHandler({
  unsplashAccessKey: process.env.UNSPLASH_ACCESS_KEY,
  pixabayApiKey: process.env.PIXABAY_API_KEY, // optional second source
  appName: 'your-app-name', // Unsplash attribution UTMs (utm_source)
  // gate: async (req) => { … }, // optional auth / rate-limit / Turnstile check
});

// One handler serves both methods: GET = search, POST = select tracking.
export const GET = handler;
export const POST = handler;

The keys are read from process.env on the server — never through a NEXT_PUBLIC_* / VITE_* prefix. A build-time public prefix inlines the value into the shipped JS, which is exactly what this design exists to prevent.

The optional gate runs before every request — the same SignGate shape as createMockupSignHandler and createRealtimeGrantHandler: return false for a 403, or a Response to short-circuit (e.g. a 401 challenge). Put your auth, rate-limit, or Turnstile check there so the route isn’t an open quota funnel.

2. The prop.

tsx
// One prop wires the Image Browser's privileged tabs. Same pattern as grantUrl:
// same-origin proxy URLs; your vendor keys stay on your server.
'use client';
import {
  RealtimeEmbed,
  RealtimeCanvas,
  RealtimeMockup,
  design,
} from '@snowcone-app/canvas/embed';

const d = design({ name: 'Front', width: 1480, height: 2328 })
  .text('HELLO', { anchor: 'center', name: 'headline' });

export default function Editor() {
  return (
    <RealtimeEmbed
      shop="YOUR_SHOP_ID"
      grantUrl="/api/realtime/grant"
      product={{ productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC', placements: ['Front'] }}
      design={d}
      services={{
        imageSearchUrl: '/api/image-search', // stock-image search tab
        generateUrl: '/api/generate-image',  // AI generation tab (optional)
      }}
    >
      {/* pro-studio / compact-customizer chrome opens the Image Browser */}
      <RealtimeCanvas kit="pro-studio" />
      <RealtimeMockup />
    </RealtimeEmbed>
  );
}

The same services prop exists on SnowconeCanvas (the full editor — see its key props) and on RealtimeCanvas, where it overrides, per canvas, whatever the surrounding RealtimeEmbed provides.

Bring your own vendor keys

Unsplash (unsplashAccessKey). Sign up at unsplash.com/developers — it takes about two minutes: create an app, copy its Access Key. The demo tier (50 requests/hour) is fine for development; apply for production (1,000 requests/hour) before you launch. The handler bakes in Unsplash’s API guidelines for you: attribution links carry the required UTM params (utm_source=<appName>), and selecting a photo triggers the download event via its download_location (pinned to api.unsplash.com, so the route can’t be abused to fetch arbitrary URLs with your key attached).

Pixabay (pixabayApiKey, optional). A free key comes with a Pixabay account. Two terms to plan for: Pixabay requires identical requests to be cached for 24 hours — the handler sets Cache-Control: public, max-age=300, s-maxage=86400 on search responses, so put a CDN in front if you expect volume — and it forbids permanent hotlinking of image files. If you enable Pixabay, ingest selected images to your own storage rather than serving Pixabay URLs forever.

With both keys configured, the sources are fetched in parallel and the results interleaved; if one vendor errors, the other still answers.

The wire contract

Implementing the route yourself (a non-JS server, a different stock vendor)? The contract is small, and the response types are exported from @snowcone-app/canvas for TS servers:

ts
// The wire contract for services.imageSearchUrl.
import type { ImageSearchResponse, ImageSearchResult } from '@snowcone-app/canvas';

// GET {imageSearchUrl}?query=<string>&page=<1-based int>&perPage=<int, default 30>
//   → 200 ImageSearchResponse — { results: ImageSearchResult[], page, hasMore }
//   Empty query = a featured/popular feed (the tab's initial grid).
//   Errors: non-2xx with JSON { error: string }.
//
// POST {imageSearchUrl}  body: { action: 'track-select', id, source, downloadLocation? }
//   → 200 { ok: true }
//   Fired (fire-and-forget) when a user selects a result — report usage
//   upstream where the source requires it (Unsplash's download event).

Each ImageSearchResult carries id, source ('unsplash' | 'pixabay'), intrinsic width/height, alt, renditions in urls (thumb/small/regular/full), an attribution block the tab displays, and — for Unsplash — the downloadLocation echoed back on track-select.

AI generation (generateUrl)

The AI tab speaks an even smaller contract, and here the SDK deliberately does not ship a handler: your server holds the AI vendor key and picks the model.

POST {generateUrl}  body: { prompt, width?, height?, numberResults? }
  → 200 { images: [{ imageUrl: string }] }
  Errors: non-2xx with JSON { error: string } (or { message }).

Any vendor works as long as you return that shape. If you have a Snowcone secret key with the ai:generate scope, Snowcone’s own generate endpoint already returns a superset of it:

tsx
// app/api/generate-image/route.ts — your AI-generation proxy. YOUR server
// holds the AI vendor key and picks the model; the canvas only ever calls
// this same-origin URL.
export async function POST(req: Request) {
  const { prompt, width, height, numberResults } = await req.json();

  const upstream = await fetch('https://api.snowcone.app/ai-generations/generate', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.SNOWCONE_API_KEY!, // sk_… key, server-only
    },
    body: JSON.stringify({ prompt, width, height, numberResults }),
  });
  if (!upstream.ok) {
    return Response.json({ error: 'Generation failed' }, { status: 502 });
  }

  const { images } = await upstream.json();
  return Response.json({ images }); // [{ imageUrl, … }] — extra fields are fine
}
Generation is billed, so gate this route the same way you gate signing — see Gate AI generation & writes. Who meters whom — Snowcone’s org-level balance vs. the per-user limits only you can enforce — is spelled out in Billing & limits.

Background removal (bgRemoveUrl)

The editor’s “remove background” button (on the image toolbar) is wired exactly like the other tabs: a same-origin proxy URL you host, with your key server-side. Unlike generateUrl, the SDK ships a drop-in handler for it — createBgRemoveHandler from @snowcone-app/sdk/server — because it proxies a fixed Snowcone endpoint (POST /ai-generations/remove-background) rather than an arbitrary vendor:

tsx
// app/api/bg-remove/route.ts — your background-removal proxy.
// Your shop-scoped key (ai:bg-remove scope) lives HERE, server-only.
import { createBgRemoveHandler } from '@snowcone-app/sdk/server';

export const POST = createBgRemoveHandler({
  shopKey: process.env.SNOWCONE_SHOP_API_KEY!, // shk_… key, ai:bg-remove scope
  // gate: async (req) => { … }, // optional auth / rate-limit / Turnstile check
});

It takes your shop-scoped key (a shk_… key with the ai:bg-remove scope — see secret keys) and the same optional gate as the other handlers. It validates the incoming imageUrl is an http(s) URL (rejecting data: / blob: sources and over-length URLs) before spending the key, then forwards the backend’s response verbatim.

Then add the one services prop. When you supply bgRemoveUrl (and no explicit removeBackground provider), the canvas synthesizes the provider for you — deproxying a /api/image-proxy?url=… source back to its original URL, POSTing { imageUrl }, and reading the { imageUrl } back:

tsx
// One more services prop — the editor's "remove background" button.
services={{
  imageSearchUrl: '/api/image-search',
  generateUrl: '/api/generate-image',
  bgRemoveUrl: '/api/bg-remove', // "remove background" button
}}

The wire contract: POST {bgRemoveUrl} body { imageUrl } (an http(s) URL) → 200 { imageUrl } (the background-removed image), non-2xx with JSON { error } on failure.

Background removal is billed (per-op vendor spend), so gate this route the same way you gate signing and AI generation — keep it from being an open quota funnel.

Providers & standalone ImagePanel

The URL props are shorthands. For full control, pass provider objects instead — services.imageSearch (an ImageSearchProvider: search(params) + optional trackSelect(result)) and services.generate (a GenerateProvider: generate(params)). An explicit provider wins over its URL shorthand. The shorthands themselves are built with createProxyImageSearch(url) / createProxyGenerate(url), also exported if you want to wrap or compose them.

Hosts that mount the image browser outside SnowconeCanvas (own chrome, own panels) can’t rely on the canvas-internal provider — pass services directly to the standalone ImagePanel:

tsx
// Mounting the image browser OUTSIDE <SnowconeCanvas> (host-owned chrome)?
// The ambient provider can't reach it there — pass services directly:
import { useState } from 'react';
import { ImagePanel } from '@snowcone-app/canvas/advanced';

function HostImageBrowser() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Add image</button>
      <ImagePanel
        isOpen={open}
        onOpenChange={setOpen}
        services={{ imageSearchUrl: '/api/image-search' }}
      />
    </>
  );
}

Background removal follows the same shape: services.removeBackground (a BgRemoveProvider: (imageUrl) => Promise<string>) wins over its bgRemoveUrl shorthand, and the shorthand is built with createProxyBgRemove(url) (also exported). (CanvasServices also reserves a vectors slot — dev playground only for now, no public wire contract yet.)

Where it leads