Webhook integrations
Send survey responses to other tools automatically.
Confetti webhooks push survey responses to your own endpoint the moment a user submits — so you can route feedback into Slack, a helpdesk, or any system that accepts HTTP POST requests.
For scheduled reports or aggregate metrics (CSAT breakdowns, response counts), use MCP reporting or the API. Webhooks deliver individual responses in real time; MCP and the API are for pulling data on demand.
Set up a webhook
Webhooks are configured per survey on the survey's Settings tab.
- Open your survey and go to Settings.
- Under Webhook settings, enter your Webhook URL.
- Optionally set a Webhook secret (recommended for production).
- Save.
Confetti sends a POST request to your URL whenever a new response is
submitted. Updating an existing response does not re-trigger the webhook.
Webhooks are marked Experimental. Payload shape and delivery behaviour may evolve — verify signatures and avoid coupling tightly to field ordering.
Delivery behaviour
| Detail | Value |
|---|---|
| Method | POST |
| Content-Type | application/json |
| User-Agent | Confetti-Webhook |
| Timeout | 5 seconds per attempt |
| Retries | Up to 3 on 408, 429, 5xx (exponential backoff) |
| Redirects | Not followed |
Your endpoint should respond with a 2xx status. Confetti does not wait for your
handler to finish downstream work — treat the webhook as an at-most-once
notification and make your endpoint idempotent if needed.
Payload format
Every delivery uses the same envelope. Today only one event type is supported:
response.created.
{
"event": "response.created",
"timestamp": "2026-07-16T07:43:00.000Z",
"data": {
"response": {
"id": "clx…",
"respondent": "user-abc123",
"data": [
{
"question": {
"id": "clx…",
"title": "How satisfied are you?"
},
"answer": {
"raw": 4,
"stringified": "4"
}
},
{
"question": {
"id": "clx…",
"title": "What could we improve?"
},
"answer": {
"raw": "Faster load times",
"stringified": "\"Faster load times\""
}
}
],
"metadata": {
"page": "https://example.gov.sg/dashboard",
"userEmail": "citizen@example.gov.sg"
},
"survey": {
"id": "clx…",
"title": "Product feedback",
"description": null,
"team": {
"id": "clx…",
"name": "My Team"
}
}
}
}
}Field notes:
data[].question— question ID and title, sorted by display order.answer.raw— the typed answer (string, number, string array, or bug-report object).answer.stringified— JSON-stringified form ofraw, useful for display or logging.metadata— client metadata passed when the response was submitted (e.g. page URL, user email). Configure this in your widget integration.warnings— optional metadata sanitation warnings. Omitted when no warning is reported.respondent— caller-supplied identifier from the widget; not necessarily an email.
Verify the signature
If you set a webhook secret, Confetti signs each delivery with HMAC-SHA256. The
signature is sent in the X-Confetti-Signature-256 header as sha256=<hex>.
To verify:
- Read the raw request body (before parsing JSON).
- Compute
HMAC-SHA256(secret, rawBody). - Compare with the hex value after
sha256=in the header (use a timing-safe comparison).
import { createHmac, timingSafeEqual } from 'node:crypto'
function verifyConfettiWebhook(rawBody, signatureHeader, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
const received = signatureHeader.replace(/^sha256=/, '')
return timingSafeEqual(Buffer.from(expected), Buffer.from(received))
}This follows the same pattern as GitHub webhook validation.
Routing to other tools
Confetti sends its own JSON format. Do not paste a Slack, Front, or Zendesk URL into Confetti's webhook settings — those services expect a different payload and will reject Confetti's envelope.
You need one of:
| Approach | How it works |
|---|---|
| Plumber (recommended for gov.sg) | Singapore Government no-code workflows — Catch raw webhook as the Confetti URL, then Slack / Custom API / Excel actions |
| Middleware | A small public endpoint receives Confetti's POST, verifies the signature, transforms the payload, then calls Slack / Front / Zendesk |
| Zapier or Make | Use a Catch Hook as the Confetti webhook URL, then map fields to the destination action in the UI |
| API polling | Skip webhooks and poll GET …/responses on a schedule |
The destination examples below show the transformed payloads your middleware,
Plumber Custom API step, or Zapier action should send. They assume you've already
parsed Confetti's response.created event.
Slack — channel notifications
For Singapore Government teams, the simplest path is Plumber → Slack (Catch raw webhook + Send a message). The rest of this section is for custom middleware or Zapier.
Slack incoming webhooks accept a
JSON body with at least a text field. Create an Incoming Webhooks-enabled
Slack app, add a webhook to a channel, then POST to the hooks.slack.com URL.
Minimal payload:
{
"text": "New feedback on Product feedback — rating 4/5"
}Richer message with Block Kit:
{
"text": "New feedback on Product feedback",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Product feedback*\nRating: *4*/5\n*What could we improve?* Faster load times\nPage: https://example.gov.sg/dashboard"
}
}
]
}Example middleware transform (Node.js):
function toSlackPayload(event) {
const { response } = event.data
const answers = response.data
.map((qa) => `*${qa.question.title}* ${qa.answer.stringified}`)
.join('\n')
const text = `New feedback on ${response.survey.title}`
return {
text,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${response.survey.title}*\n${answers}\nPage: ${response.metadata.page ?? '—'}`,
},
},
],
}
}
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toSlackPayload(event)),
})Slack returns ok on success. Malformed payloads (e.g. missing text) return
errors such as no_text or invalid_payload.
Front — helpdesk inbox
Front cannot ingest Confetti's payload directly. Use Plumber's Custom API action (or your own middleware) to transform the event and POST to Front.
Create a custom channel under Settings → Inboxes → Channels → Custom, then POST to Front's Receive custom messages endpoint:
POST https://api2.frontapp.com/channels/{channel_id}/incoming_messages
Authorization: Bearer {front_api_token}
Content-Type: application/jsonRequired fields: sender.handle and body. Optional: subject,
sender.name, body_format (markdown or html), and
metadata.thread_ref for threading.
{
"sender": {
"handle": "citizen@example.gov.sg",
"name": "Survey respondent"
},
"subject": "Confetti feedback: Product feedback",
"body": "**Rating:** 2/5\n\n**What could we improve?**\nFaster load times\n\n**Page:** https://example.gov.sg/dashboard",
"body_format": "markdown",
"metadata": {
"thread_ref": "confetti-response-clx…"
}
}Example middleware transform:
function toFrontPayload(event) {
const { response } = event.data
const handle =
response.metadata.userEmail ?? response.respondent ?? response.id
const body = response.data
.map((qa) => `**${qa.question.title}**\n${qa.answer.stringified}`)
.join('\n\n')
return {
sender: { handle, name: 'Survey respondent' },
subject: `Confetti feedback: ${response.survey.title}`,
body,
body_format: 'markdown',
metadata: { thread_ref: `confetti-response-${response.id}` },
}
}
await fetch(
`https://api2.frontapp.com/channels/${process.env.FRONT_CHANNEL_ID}/incoming_messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FRONT_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(toFrontPayload(event)),
},
)Front authenticates with a
Bearer API token. A successful
ingest returns HTTP 202 with status: accepted. Use metadata.thread_ref
(e.g. the Confetti response.id) if you want related messages grouped;
otherwise Front threads by sender.handle.
If teammates need to reply from a different Front inbox (not the custom channel), use Front's Import message API instead of Receive custom messages — see Front's custom channel docs.
Zendesk — support tickets
Create tickets with the Tickets API via Plumber Custom API, Zapier, or middleware:
POST https://{subdomain}.zendesk.com/api/v2/tickets.json
Authorization: Bearer {zendesk_access_token}
Content-Type: application/jsoncomment (with a body) is required. Optionally set subject and a
requester (name + email creates or matches a Zendesk user).
{
"ticket": {
"subject": "Low CSAT: Product feedback",
"comment": {
"body": "Rating: 2/5\n\nWhat could we improve?\nFaster load times\n\nPage: https://example.gov.sg/dashboard\nResponse ID: clx…"
},
"requester": {
"name": "Survey respondent",
"email": "citizen@example.gov.sg"
},
"priority": "normal"
}
}Example middleware transform (only open a ticket for low ratings):
function toZendeskTicket(event) {
const { response } = event.data
const rating = response.data.find((qa) => typeof qa.answer.raw === 'number')
if (!rating || rating.answer.raw > 2) return null
const body = response.data
.map((qa) => `${qa.question.title}\n${qa.answer.stringified}`)
.join('\n\n')
return {
ticket: {
subject: `Low CSAT: ${response.survey.title}`,
comment: {
body: `${body}\n\nPage: ${response.metadata.page ?? '—'}\nResponse ID: ${response.id}`,
},
requester: response.metadata.userEmail
? { name: 'Survey respondent', email: response.metadata.userEmail }
: undefined,
},
}
}
const ticket = toZendeskTicket(event)
if (ticket) {
await fetch(
`https://${process.env.ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets.json`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZENDESK_TOKEN}`,
'Content-Type': 'application/json',
// Avoid duplicate tickets on Confetti retries:
'Idempotency-Key': event.data.response.id,
},
body: JSON.stringify(ticket),
},
)
}Zendesk supports an
Idempotency-Key header
— use the Confetti response.id so webhook retries don't create duplicates.
Plumber — no-code routing (Singapore Government)
Plumber is the government no-code workflow tool (similar to Zapier). It can catch Confetti webhooks and fan out to Slack, email, Excel, or any HTTP API.
Verified against Plumber's docs:
- Create a pipe and set the trigger to
Webhook → Catch raw webhook.
Plumber accepts
POST(also GET/PUT/PATCH) to the URL it provides. - Copy that Plumber webhook URL into Confetti's Webhook settings and save.
- Submit a test response in Confetti, then click Check step in Plumber to
pull the payload fields (
event,data.response.survey.title, answer arrays, metadata, and so on). - Add actions:
- Slack → Send a message for channel notifications — map survey title and answers into the message body.
- Only continue if / If-then to filter (e.g. only continue when a rating answer is ≤ 2).
- Custom API
to POST the Front or Zendesk JSON shapes from the sections below
(
Authorizationheader + JSONDatabody). - M365 Excel or Email by Postman for logging or acknowledgements.
Plumber is cleared for data up to Confidential (Cloud-Eligible) and Sensitive (High). If your pipe sends data to non-government apps such as Slack or Telegram, only send Official (Open) data — see Plumber's introduction.
Plumber marks webhooks as an advanced feature with limited support; involve your engineers if the payload mapping is non-trivial.
Zapier / Make — alternative no-code routing
If you are not on Plumber (or need an integration Plumber does not offer):
- Create a Catch Hook (Zapier) or Custom webhook (Make) trigger.
- Paste that URL into Confetti's webhook settings.
- Map Confetti fields (
data.response.survey.title,data.response.data[].answer.stringified,data.response.metadata.userEmail) to Slack / Front / Zendesk / Sheets actions. - Optionally filter on rating answers so only low scores create tickets.
Google Sheets, M365 Excel, or Notion
- gov.sg: prefer Plumber's M365 Excel action after the Catch raw webhook trigger.
- Otherwise: Zapier/Make → Sheets / Notion, or middleware that appends a row via those APIs.
Map each data[].question.title to a column and store response.id for
deduplication.
Troubleshooting
| Symptom | Things to check |
|---|---|
| No deliveries | Webhook URL saved on the correct survey's Settings tab; URL is publicly reachable |
401 / signature failures | Verify against the raw body; secret matches what is saved in Confetti |
| Duplicate tickets | Make your handler idempotent (dedupe on response.id) |
| Missing metadata | Metadata is set client-side in the widget — see embedding docs |
For aggregate metrics and scheduled reports, see MCP reporting or Reporting via the API.
Was this page helpful?