Skip to main content
POST /v2/storefront/preview-token mints a short-lived signed token that the Template Designer Live Preview iframe uses to render the merchant’s draft storefront. The token is bound to a specific shop and expires after 10 minutes.
Closes Phase 3 of DEV-AUDIT #199. The Template Designer Live Preview surface in the shop-builder dashboard calls this endpoint, embeds the returned previewUrl into an iframe, and the storefront verifier (separate ticket) HMAC-checks the token before rendering the draft.

When to use this

  • The Template Designer’s “Live Preview” CTA — mint a token, embed previewUrl into the iframe src
  • Any merchant-facing tool that needs a time-bounded preview link to a draft storefront
  • Do NOT use this for production publishing — published storefronts don’t need a token

Authentication

Merchant JWT (MerchantOnlyJwtGuard). The endpoint also asserts req.user.shopId === body.shopId at the controller layer — a merchant can only mint preview tokens for their own shop. Cross-shop mint attempts return 403.

Request

POST /v2/storefront/preview-token
Body:
FieldTypeNotes
shopIdstringThe shop’s Mongo _id. MUST equal req.user.shopId.

Curl example

curl -s -X POST "https://apiv3.droplinked.com/v2/storefront/preview-token" \
  -H "Authorization: Bearer $MERCHANT_JWT" \
  -H "Content-Type: application/json" \
  -d '{"shopId":"66c0fe34a9f1b1d3e1c2b001"}' | jq .

Response (200)

{
  "previewToken": "eyJzaG9wSWQiOiI2NmMwZmUzNGE5ZjFiMWQzZTFjMmIwMDEiLCJleHBpcmVzQXQiOiIyMDI2LTA2LTE0VDEyOjM0OjU2LjAwMFoiLCJraW5kIjoic3RvcmVmcm9udC1wcmV2aWV3In0.a1b2c3d4e5f6...",
  "expiresAt": "2026-06-14T12:34:56.000Z",
  "previewUrl": "https://droplinked.io/demo-shop/_preview?token=eyJzaG9wSWQiOiI2NmMw..."
}

Field reference

FieldTypeNotes
previewTokenstringSigned token in format <payload-b64url>.<sig-b64url> (see Token format below).
expiresAtISO-8601 string10 minutes from mint.
previewUrlstringFull URL with token embedded — drop straight into iframe src. Uses droplinked.io (NOT .com) per storefront URL discipline.

Token format

<payload-b64url>.<sig-b64url> where:
  • payload — UTF-8 JSON { shopId: string, expiresAt: ISO-8601 string, kind: "storefront-preview" } then base64url-encoded
  • sig — HMAC-SHA-256 of the payload bytes under PREVIEW_TOKEN_SECRET (or JWT_SECRET fallback for dev), then base64url-encoded
The two parts are joined with a . separator. Shopfront verifier:
import { createHmac } from 'crypto'

function verifyPreviewToken(token: string, secret: string): { shopId: string; expiresAt: Date } | null {
  const [payloadB64, sigB64] = token.split('.')
  if (!payloadB64 || !sigB64) return null

  const expectedSig = createHmac('sha256', secret)
    .update(payloadB64)
    .digest('base64url')
  if (sigB64 !== expectedSig) return null

  const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'))
  if (payload.kind !== 'storefront-preview') return null
  const expiresAt = new Date(payload.expiresAt)
  if (Number.isNaN(expiresAt.getTime()) || expiresAt < new Date()) return null

  return { shopId: payload.shopId, expiresAt }
}

Why HMAC over JWT

The shopfront verifier is a single shared-secret check at the storefront edge. HMAC avoids:
  • JWKS round-trips (no key fetch from a JWKS endpoint at request time)
  • Asymmetric key rotation surface (no private/public key pair, no kid)
  • Edge-cache invalidation on key rotation
  • The full jose library dependency at the storefront edge
The fixed-shape payload ({shopId, expiresAt, kind}) is also stricter than typical JWT claim taxonomy — iss/aud/jti/sub buy nothing for a 10-minute single-purpose token. If we ever need rotation, audience-narrowing, or a kid, the swap is one file behind PreviewTokenService.mint — the controller response shape does not change.

TTL + rate limiting

  • TTL: 10 minutes from mint. Hard-coded constant PREVIEW_TOKEN_TTL_MS in the service.
  • Rate limit: not applied at v1. The cross-shop guard limits abuse blast radius to “self-DoS your own preview.” Revisit if cross-merchant abuse signal appears.
  • Single chokepoint: all minting goes through PreviewTokenService.mint so audit-log + rotation hooks can be added in one place.

Error responses

StatusWhen
400Malformed shopId (not a valid Mongo ObjectId).
401Missing / invalid JWT.
403Cross-shop mint attempt (req.user.shopId !== body.shopId).
500No PREVIEW_TOKEN_SECRET or JWT_SECRET configured (server misconfiguration).