ConfettiDocs
For developers

Cloudflare Workers proxy

Route Confetti widget traffic through your own origin with a Cloudflare Worker.

By default the widget talks to https://confetti.gov.sg directly. If your site's Content-Security-Policy (or network policy) blocks third-party hosts, point the widget at a proxy on your own origin instead.

Pass that origin as proxyUrl on any template. The widget then calls:

{proxyUrl}/api/v1/cfti/...
{proxyUrl}/widget/v1/...   # hosted (CDN) shells only

instead of https://confetti.gov.sg. Your proxy forwards those paths to Confetti server-to-server and returns the response to the browser.

<PopoverConfetti
  surveyId="<your-survey-id>"
  publishableKey="<your-publishable-key>"
  proxyUrl="https://confetti-proxy.your-domain.workers.dev"
/>

Prefer @opengovsg/confetti/static if you only need to avoid loading scripts from Confetti's CDN. Static shells still call the Confetti API unless you also set proxyUrl.

What the proxy must do

These requirements apply to any self-hosted proxy (Cloudflare Workers, nginx, your app server, and so on). They match the current widget contract. See the 0.4 → 0.5 migration if you are upgrading an older proxy.

  1. Forward the path and query as-is. The widget already includes /api (and /widget for hosted shells). Do not prepend /api again, or you will produce /api/api/... and get 404s.
  2. Allow GET, HEAD, POST, PUT, and OPTIONS. The widget probes availability with HEAD /api/v1/cfti/:surveyId before rendering. Dropping HEAD means the survey never appears.
  3. Forward both /api and /widget. Hosted shells load their script and stylesheet from /widget/v1/... on the same origin as proxyUrl. Static shells (@opengovsg/confetti/static) only need /api.
  4. Forward only the headers you actually need. In practice that is x-cfti-pk, Content-Type, and Origin, plus an X-Forwarded-For value if you want to pass through the client IP. Avoid blindly proxying the browser's full header set.
  5. Always send an Origin upstream. Confetti checks it against Authorised Domains and rejects requests that arrive without one. Same-origin GET and HEAD requests (including the availability probe) carry no Origin header, so fall back to the origin your proxy is mounted on and make sure that origin is whitelisted.
  6. Add CORS headers so cross-origin embeds can read the proxied response. Handle OPTIONS preflights locally (no need to hit Confetti).

Also whitelist your website under Team settings → Authorised Domains, and point CSP connect-src (plus script-src / style-src for hosted shells) at your proxy origin instead of https://confetti.gov.sg.

Example: Cloudflare Worker

The sample below is a starting point for a Cloudflare Worker, not a supported product. Adapt the upstream host, CORS policy, and error handling to your environment.

src/index.js
const CONFETTI_ORIGIN = 'https://confetti.gov.sg'

function corsHeaders(request) {
  const origin = request.headers.get('Origin')
  if (!origin) return {}
  return {
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, x-cfti-pk',
    'Access-Control-Max-Age': '86400',
    Vary: 'Origin',
  }
}

// If the Worker is mounted under a path prefix such as /confetti-proxy,
// strip that prefix from pathname before this check and before forwarding.
function isProxiedPath(pathname) {
  return pathname.startsWith('/api/') || pathname.startsWith('/widget/')
}

export default {
  async fetch(request) {
    const cors = corsHeaders(request)
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        status: 204,
        headers: cors,
      })
    }

    const requestUrl = new URL(request.url)
    if (!isProxiedPath(requestUrl.pathname)) {
      return new Response('Not found', { status: 404, headers: cors })
    }

    // Forward the path and query unchanged. Do not prepend /api.
    const upstreamUrl = new URL(
      `${requestUrl.pathname}${requestUrl.search}`,
      CONFETTI_ORIGIN,
    )

    const headers = new Headers()
    const publishableKey = request.headers.get('x-cfti-pk')
    if (publishableKey) headers.set('x-cfti-pk', publishableKey)

    const contentType = request.headers.get('Content-Type')
    if (contentType) headers.set('Content-Type', contentType)

    // Same-origin GET/HEAD requests have no Origin header. Confetti requires
    // one, so fall back to the origin this Worker is mounted on. Whitelist
    // that origin in Authorised Domains.
    const origin = request.headers.get('Origin') ?? requestUrl.origin
    headers.set('Origin', origin)

    const clientIp = request.headers.get('CF-Connecting-IP')
    if (clientIp) headers.set('X-Forwarded-For', clientIp)

    try {
      const upstream = await fetch(upstreamUrl, {
        method: request.method,
        headers,
        body: ['GET', 'HEAD'].includes(request.method)
          ? undefined
          : request.body,
        redirect: 'follow',
        cf: { cacheTtl: 0 },
      })

      const responseHeaders = new Headers(upstream.headers)
      for (const [key, value] of Object.entries(cors)) {
        responseHeaders.set(key, value)
      }
      responseHeaders.delete('transfer-encoding')

      return new Response(upstream.body, {
        status: upstream.status,
        statusText: upstream.statusText,
        headers: responseHeaders,
      })
    } catch (error) {
      const detail = error instanceof Error ? error.message : String(error)
      return new Response(JSON.stringify({ error: 'Bad gateway', detail }), {
        status: 502,
        headers: {
          'Content-Type': 'application/json',
          ...cors,
        },
      })
    }
  },
}

Deploy

  1. Create a Worker in the Cloudflare dashboard or with Wrangler:

    npm create cloudflare@latest confetti-proxy -- --type hello-world
  2. Replace the generated fetch handler with the sample (or your adapted version). If you need stricter CORS than the example, add your own allowlist around the returned CORS headers.

  3. Deploy (npx wrangler deploy) and note the Worker URL, for example https://confetti-proxy.<your-subdomain>.workers.dev. For production, attach a custom route on your own domain such as https://your-app.gov.sg/confetti-proxy/* and follow Custom path prefixes below.

  4. Pass the Worker base URL as proxyUrl in your embed, whitelist your site in Confetti, and update CSP as needed.

Custom path prefixes

If the Worker is mounted under a path (for example https://your-app.gov.sg/confetti-proxy), either:

  • set proxyUrl to that full base (https://your-app.gov.sg/confetti-proxy), and strip the /confetti-proxy prefix before forwarding to Confetti, or
  • terminate the path at the edge so the Worker still sees /api/... and /widget/... at the root of the request URL.

The widget resolves paths relative to proxyUrl, so the base you pass must be the origin (plus optional path) that actually serves /api and /widget.

Checklist

CheckWhy
proxyUrl points at your WorkerWidget traffic leaves your app via that origin
Worker forwards /api and /widget without prepending /apiMatches the current widget URL shape
HEAD is allowed and forwardedAvailability probe before render
Only the required request headers are forwardedAvoids coupling the proxy to browser internals
x-cfti-pk is forwarded and Origin is always sent upstreamAuth and Authorised Domains checks
CORS headers are returned for cross-origin requestsBrowser can read responses
Site origin is in Authorised DomainsConfetti rejects unknown browser origins
CSP allows the proxy on connect-src (and script/style if hosted)Otherwise the browser blocks the proxied calls

Need help?

Email confetti@open.gov.sg or reach out in Slack #confetti (internal use only).

Was this page helpful?

On this page