ConfettiDocs
For developers

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.

  1. Open your survey and go to Settings.
  2. Under Webhook settings, enter your Webhook URL.
  3. Optionally set a Webhook secret (recommended for production).
  4. 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

DetailValue
MethodPOST
Content-Typeapplication/json
User-AgentConfetti-Webhook
Timeout5 seconds per attempt
RetriesUp to 3 on 408, 429, 5xx (exponential backoff)
RedirectsNot 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 of raw, 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:

  1. Read the raw request body (before parsing JSON).
  2. Compute HMAC-SHA256(secret, rawBody).
  3. 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:

ApproachHow 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
MiddlewareA small public endpoint receives Confetti's POST, verifies the signature, transforms the payload, then calls Slack / Front / Zendesk
Zapier or MakeUse a Catch Hook as the Confetti webhook URL, then map fields to the destination action in the UI
API pollingSkip 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/json

Required 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/json

comment (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:

  1. Create a pipe and set the trigger to Webhook → Catch raw webhook. Plumber accepts POST (also GET/PUT/PATCH) to the URL it provides.
  2. Copy that Plumber webhook URL into Confetti's Webhook settings and save.
  3. 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).
  4. Add actions:

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):

  1. Create a Catch Hook (Zapier) or Custom webhook (Make) trigger.
  2. Paste that URL into Confetti's webhook settings.
  3. Map Confetti fields (data.response.survey.title, data.response.data[].answer.stringified, data.response.metadata.userEmail) to Slack / Front / Zendesk / Sheets actions.
  4. 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

SymptomThings to check
No deliveriesWebhook URL saved on the correct survey's Settings tab; URL is publicly reachable
401 / signature failuresVerify against the raw body; secret matches what is saved in Confetti
Duplicate ticketsMake your handler idempotent (dedupe on response.id)
Missing metadataMetadata 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?

On this page