Signed URLs

A mockup URL is public by default. Lock your shop down so only URLs you minted render — and route every render through one gate you control.

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.

1

Get a shop + secret

Mint a sandbox shop or copy both from your dashboard, then keep the secret on your server.

bash
# 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_…
2

Add a sign route

One drop-in handler signs the mockup URLs your client builds.

tsx
// 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
});
3

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

tsx
// 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>
4

Lock the shop

Turn on require_signed_urls for the shop in your dashboard. From now on the edge rejects any unsigned URL.

5

Verify

A signed URL renders; the same URL without its signature 403s.

bash
# 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"             # → 403
Sample shop IDs are always YOUR_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.

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

tsx
// 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 edge
Signed URLs don’t expire — the signature is an HMAC over the URL, with no timestamp. SSR-signed pages are cache-safe and work as an og: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.

tsx
// 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.

tsx
// 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>
Forgetting 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.

tsx
// 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.

tsx
// 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.

Turnstile is a human check, not a rate limit: it keeps bots and scripts from anonymously burning your budget, but pair it with per-user/per-session limits at your gate (it already knows who’s asking) and keep your plan’s spend cap as the hard ceiling.

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.

Locking the shop to signed URLs is what forces all traffic through your gate: a request straight to the resolver without a valid signature is rejected, so there’s no way around your auth, limits, and metering.

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.