Signed URLs
Quickstart (5 minutes)
The whole signed-shop setup, end to end. Already shipping public URLs? This is the upgrade path — flip the shop and add a sign route; nothing else about your mockup URLs changes.
Get a shop + secret
Mint a sandbox shop or copy both from your dashboard, then keep the secret on your server.
# Mint a shop + secret (no signup), or grab both from your dashboard.
curl -X POST https://api.snowcone.app/shops/sandbox -d '{}'
# → { "shop_id": "ab3dPq7Rms", "shop_secret": "scsec_…", … }
# Keep the secret on your server only:
# .env → SNOWCONE_SHOP_SECRET=scsec_…Add a sign route
One drop-in handler signs the mockup URLs your client builds.
// app/api/sign/route.ts — the SDK gives you a drop-in handler.
// `gate` is your auth / rate-limit / Turnstile check (return false,
// a Response, or allow). Optional.
import { createMockupSignHandler } from "@snowcone-app/sdk/server";
export const POST = createMockupSignHandler({
secret: process.env.SNOWCONE_SHOP_SECRET!,
gate: myAuthOrTurnstileCheck, // optional
});Point your UI at it
If you use @snowcone-app/ui, hand <Shop> a signer. (Building URLs server-side instead? Pass secret to getMockupUrl — see below — and skip this.)
// Point <Shop> at that route. bffSigner batches — a grid of N images
// makes ~1 /api/sign request, not N. Create it once (stable queue).
import { Shop } from "@snowcone-app/ui";
import { bffSigner } from "@snowcone-app/sdk";
const signMockupUrl = bffSigner("/api/sign");
<Shop shop="YOUR_SHOP_ID" signMockupUrl={signMockupUrl}>
{/* product pages, grids, live editor — all render signed */}
</Shop>Lock the shop
Turn on require_signed_urls for the shop in your dashboard. From now on the edge rejects any unsigned URL.
Verify
A signed URL renders; the same URL without its signature 403s.
# A signed URL renders (302 → image). The same URL without the
# &signature is rejected — proof the lockdown is on.
curl -sI "https://img.snowcone.app/BEEB77?asset=…&shop=YOUR_SHOP_ID&signature=…" # → 302
curl -sI "https://img.snowcone.app/BEEB77?asset=…&shop=YOUR_SHOP_ID" # → 403YOUR_SHOP_ID — never borrow another shop’s ID; the signature is verified against that shop’s secret.Sign on your server
The signature is an HMAC of the URL with your shop secret, so it MUST be computed on your server. When you build the URL server-side, pass secret to getMockupUrl and it appends a verified &signature. Signing always runs server-side; the only question is when.
import { getMockupUrl } from "@snowcone-app/sdk";
// Server-side only — the secret never reaches the browser.
const src = getMockupUrl(artwork, "hoodie-black", {
shop: "YOUR_SHOP_ID",
secret: process.env.SNOWCONE_SHOP_SECRET, // appends a verified &signature
});During render (default)
For a page that already knows its mockups, sign as you render. It’s a local HMAC — synchronous, no await, no client round-trip. The signed URLs ship in the HTML and load straight from the edge.
// You already have the mockups as data — sign each one as you render.
const srcs = mockups.map((m) =>
getMockupUrl(m.artwork, m.product, {
shop: "YOUR_SHOP_ID",
secret: SHOP_SECRET,
}),
);
// signed URLs ship in the HTML; <img>s load straight from the edgeog:image; a signature stops working only when you rotate the shop’s secret. The signature is computed over the URL path + query only (host-independent), so a CDN or proxy in front changes nothing.After load
When a URL doesn’t exist at render time — a variant the shopper picks, an infinite-scroll page, artwork changed in a live editor — your sign route signs it. It’s your gate: put auth, rate limits, and a bot check in front via gate. The browser builds the unsigned URL as usual, then asks your server to sign it.
// app/api/sign/route.ts — the SDK gives you a drop-in handler.
// `gate` is your auth / rate-limit / Turnstile check (return false,
// a Response, or allow). Optional.
import { createMockupSignHandler } from "@snowcone-app/sdk/server";
export const POST = createMockupSignHandler({
secret: process.env.SNOWCONE_SHOP_SECRET!,
gate: myAuthOrTurnstileCheck, // optional
});A typical gate lets the cheapest trusted signal win: a logged-in session signs with no friction; a guest who passed a challenge recently rides a short-lived render-session cookie; only a brand-new guest sees an (invisible) Cloudflare Turnstile challenge that mints that cookie — so the challenge is rare, not per-image.
Client UIs (@snowcone-app/ui)
Our React components build mockup <img> URLs on the client (product pages, grids, the live editor). They can’t hold your secret, so for a signed shop you hand <Shop> a signMockupUrl built from bffSigner (it batches and dedupes for you). The UI signs every mockup URL through it before rendering; omit it for an L0 shop and URLs render as built.
// Point <Shop> at that route. bffSigner batches — a grid of N images
// makes ~1 /api/sign request, not N. Create it once (stable queue).
import { Shop } from "@snowcone-app/ui";
import { bffSigner } from "@snowcone-app/sdk";
const signMockupUrl = bffSigner("/api/sign");
<Shop shop="YOUR_SHOP_ID" signMockupUrl={signMockupUrl}>
{/* product pages, grids, live editor — all render signed */}
</Shop>signMockupUrl on a signed shop shows broken images with a Missing required signature error — the UI logs a dev-mode hint pointing here when that happens.Realtime sessions
The live mockup WebSocket authorizes with a short-lived session token, not a URL signature. Proxy the grant through your server so the secret stays there; for a locked-down shop the grant request must be signed — which the SDK handler does for you. The browser opens the socket with ?token= and renews before it expires.
// app/api/realtime-grant/route.ts — mint a short-lived WS session
// token, signing the grant server-side so the secret stays there.
import { createRealtimeGrantHandler } from "@snowcone-app/sdk/server";
export const POST = createRealtimeGrantHandler({
secret: process.env.SNOWCONE_SHOP_SECRET!,
shop: "YOUR_SHOP_ID",
});
// The browser opens the WS with ?token= (RenderSession / <Shop> do this).Gate AI generation & writes
The gate isn’t signing-specific. Anything your server does with a credential a browser could abuse belongs behind it — most of all AI generation. A render or generation key your server holds (so the browser never sees it) does real, billed work; a proxy route that spends it without a gate is an open funnel anyone can drain.
// app/api/generate/route.ts
// Your server holds the shop's AI key (the browser never sees it). Gate the
// request the SAME way you gate signing, so generation isn't an open, billed
// funnel. `renderGate` is the very function you pass to
// createMockupSignHandler({ gate }): a logged-in session or a recent
// render-session cookie passes; a brand-new visitor solves one invisible
// Turnstile that mints the cookie — shared with signing, so they're never
// challenged twice.
export async function POST(req: Request) {
const pass = await renderGate(req);
if (pass !== true) return pass; // 401 challenge → the browser retries with a token
// Trusted caller — now it's safe to spend the server-held key.
return fetch("https://api.snowcone.app/ai-generations/generate", {
method: "POST",
headers: {
"x-api-key": process.env.SNOWCONE_SHOP_KEY!,
"content-type": "application/json",
},
body: await req.text(),
});
}Reuse one gate across signing and generation. Because the render-session cookie is shared, a visitor who already passed the check to see signed mockups isn’t challenged again to generate, and signed-in customers never see it at all — so the Turnstile is a rare, invisible speed bump, not a per-action prompt.
Rate limits & per-user usage
Routing every render through your own sign route (and the realtime grant) gives you one choke point to meter. Because the gate already knows who is asking — a logged-in user, or an anonymous render-session — you get clean per-user attribution and can apply tiered rate limits: generous for signed-in customers, tighter for anonymous traffic. The edge additionally enforces per-IP limits on every render, so an unsigned or scraped URL can’t be replayed at volume.
Troubleshooting
403 Missing required signature — the URL reached the edge unsigned. Server-built? Pass secret to getMockupUrl. Client/UI-built? Give <Shop> a signMockupUrl (or route your client through /api/sign).
403 Invalid signature — the signature doesn’t match the shop’s secret. Your SNOWCONE_SHOP_SECRET must equal the shop’s current signing secret; re-check after a rotate.
Images break only in a grid or after scroll — your sign route is failing for those requests. Confirm POST /api/sign returns 200 for an allowed request; a too-strict gate is the usual cause.
Realtime won’t connect on a locked shop — the grant must be signed. Use createRealtimeGrantHandler; the keyless grant path is rejected once require_signed_urls is on.

