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