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 onlyinstead 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.
- Forward the path and query as-is. The widget already includes
/api(and/widgetfor hosted shells). Do not prepend/apiagain, or you will produce/api/api/...and get 404s. - Allow
GET,HEAD,POST,PUT, andOPTIONS. The widget probes availability withHEAD /api/v1/cfti/:surveyIdbefore rendering. DroppingHEADmeans the survey never appears. - Forward both
/apiand/widget. Hosted shells load their script and stylesheet from/widget/v1/...on the same origin asproxyUrl. Static shells (@opengovsg/confetti/static) only need/api. - Forward only the headers you actually need. In practice that is
x-cfti-pk,Content-Type, andOrigin, plus anX-Forwarded-Forvalue if you want to pass through the client IP. Avoid blindly proxying the browser's full header set. - Always send an
Originupstream. Confetti checks it against Authorised Domains and rejects requests that arrive without one. Same-originGETandHEADrequests (including the availability probe) carry noOriginheader, so fall back to the origin your proxy is mounted on and make sure that origin is whitelisted. - Add CORS headers so cross-origin embeds can read the proxied response.
Handle
OPTIONSpreflights 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.
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
-
Create a Worker in the Cloudflare dashboard or with Wrangler:
npm create cloudflare@latest confetti-proxy -- --type hello-world -
Replace the generated
fetchhandler with the sample (or your adapted version). If you need stricter CORS than the example, add your own allowlist around the returned CORS headers. -
Deploy (
npx wrangler deploy) and note the Worker URL, for examplehttps://confetti-proxy.<your-subdomain>.workers.dev. For production, attach a custom route on your own domain such ashttps://your-app.gov.sg/confetti-proxy/*and follow Custom path prefixes below. -
Pass the Worker base URL as
proxyUrlin 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
proxyUrlto that full base (https://your-app.gov.sg/confetti-proxy), and strip the/confetti-proxyprefix 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
| Check | Why |
|---|---|
proxyUrl points at your Worker | Widget traffic leaves your app via that origin |
Worker forwards /api and /widget without prepending /api | Matches the current widget URL shape |
HEAD is allowed and forwarded | Availability probe before render |
| Only the required request headers are forwarded | Avoids coupling the proxy to browser internals |
x-cfti-pk is forwarded and Origin is always sent upstream | Auth and Authorised Domains checks |
| CORS headers are returned for cross-origin requests | Browser can read responses |
| Site origin is in Authorised Domains | Confetti 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?