Webhooks

Structure, signature and delivery of the webhook requests for answers and completed participations.

For every event, PollHero sends a signed POST request with a JSON body to the endpoint URL of your project. This way answers reach your CRM, your data warehouse, Slack or an automation tool such as Zapier, Make or n8n without any polling. The rest of the Data Export API returns data on request, webhooks bring it to you as it is created.

PropertyValue
AvailabilityEnterprise plan
Levelproject, applies to all surveys of the project
Eventsquestion_answered, survey_completed
FormatPOST with a JSON body, UTF-8
Verificationtoken header and HMAC-SHA256 signature

Setting up a webhook

Webhooks are set up per project. The settings can be changed by owners of the account and admins of the project.

Step 1: Open the project settings, scroll to the Webhook section and set the Enable webhook toggle to ON.

Step 2: Enable one or both events: Send answers (event question_answered) and Send survey completions (event survey_completed). An Endpoint URL field appears below each active event. Enter the URL of your receiver there. Both events can go to the same URL or to different ones.

Step 3: Click Send test request next to the field. PollHero sends a sample request with "test": true to the URL you entered and shows you whether your endpoint responded successfully. The test works before you save.

Step 4: Save the project settings. The first time you save with an active event, PollHero creates the Verification token and the Signing secret. You will find both in the Verification section afterwards and can take them over with Copy.

As long as Enable webhook is OFF, nothing is sent, even if individual events are active.

Endpoint requirements

  • The URL must be publicly reachable via http or https. PollHero rejects addresses in private networks and host names such as localhost.
  • The endpoint must respond with a 2xx status within 15 seconds. The connection has to be established within 5 seconds.
  • PollHero does not follow redirects. A 301 or 302 status counts as a failed delivery.
  • Ideally, respond with 200 right away and process the content in the background afterwards.

Events

EventSentContent
question_answeredas soon as an answer to a question has been savedthe single answer with question, participation and respondent
survey_completedas soon as a participation is completedall answers of the participation
  • Skipped questions do not trigger an event.
  • If someone changes an answer within the same participation, question_answered is sent again with a new messageId.
  • survey_completed is sent once per participation.
  • If PII masking is active, the answers arrive already masked.
  • Answers from the CSV import do not trigger events.
  • Sentiment analysis runs after the delivery. Use the Data Export API to get the sentiment of a free text answer.

Structure of the request

Headers

HeaderContent
Content-Typeapplication/json
User-AgentPollHero-Webhook/1.0
X-PollHero-Eventquestion_answered or survey_completed
X-PollHero-DeliveryID of the message, identical to messageId in the body. Stays the same on retries.
X-PollHero-Tokenverification token of the project
X-PollHero-SignatureHMAC-SHA256 of the request body, see Verifying the signature

Common fields

Every body starts with the same fields:

FieldTypeDescription
messageIdstringUnique ID of the message. Stays the same on retries so you can detect duplicates.
deliveryTriesintegerNumber of the delivery attempt, starting at 1.
eventstringquestion_answered or survey_completed
timestampstringTime of the event, ISO 8601 in UTC
testbooleanOnly included in the test request, where it is true.

Event question_answered

{
  "messageId": "0e0938c0-1e85-413b-8ffd-5a0e3a26340a",
  "deliveryTries": 1,
  "event": "question_answered",
  "timestamp": "2026-10-27T11:51:20.000000Z",
  "survey": {
    "id": "21cba834-ad20-4f3b-929d-5abfff8c96ae",
    "name": "Post-purchase survey"
  },
  "question": {
    "id": "1165ac0a-1fcf-451a-a361-db75602d1283",
    "body": "How likely are you to recommend us?",
    "type": "nps"
  },
  "response": {
    "user_id": "23decc90-820a-40bf-92ea-db493c71bfa0",
    "collected_at": "2026-10-27T11:51:19.000000Z",
    "completed": false,
    "url": "https://shop.example.com/checkout/thank-you",
    "order_id": "1232"
  },
  "answer": {
    "answer": { "content": 9, "tag": "promoter" }
  },
  "respondent": {
    "id": "23decc90-820a-40bf-92ea-db493c71bfa0",
    "attributes": {
      "email": "jane@example.com",
      "customer_type": "returning",
      "total_purchases": 3,
      "plan": "pro"
    }
  }
}
FieldDescription
survey.id, survey.nameID and name of the survey. The ID is the same as in the Data Export API.
question.idID of the question, as in the Data Export API
question.bodyquestion text
question.typequestion type in the API notation, for example nps, single, text
responsethe participation the answer belongs to, see below
answerthe answer. answer holds a single value, answers a list, for example for Multi Select, Ranking and Matrix. The structure per question type matches the API, see Answers by question type.
respondentthe respondent, see below

Event survey_completed

{
  "messageId": "7c1d2a9e-5b7f-4d0e-9a3c-1f2e3d4c5b6a",
  "deliveryTries": 1,
  "event": "survey_completed",
  "timestamp": "2026-10-27T11:52:01.000000Z",
  "survey": {
    "id": "21cba834-ad20-4f3b-929d-5abfff8c96ae",
    "name": "Post-purchase survey"
  },
  "response": {
    "user_id": "23decc90-820a-40bf-92ea-db493c71bfa0",
    "collected_at": "2026-10-27T11:51:19.000000Z",
    "completed": true,
    "url": "https://shop.example.com/checkout/thank-you",
    "order_id": "1232",
    "completed_at": "2026-10-27T11:52:01.000000Z",
    "time_taken": 42,
    "device_type": "Desktop",
    "browser": "Chrome",
    "country": "Germany",
    "utm": {
      "source": "newsletter",
      "medium": "email",
      "campaign": "autumn",
      "term": null,
      "content": null
    }
  },
  "answers": [
    {
      "question_id": "1165ac0a-1fcf-451a-a361-db75602d1283",
      "question_type": "nps",
      "question_body": "How likely are you to recommend us?",
      "answer": { "content": 9, "tag": "promoter" }
    },
    {
      "question_id": "8d2f6b1c-3a4e-4f5d-9c7b-2e1f0a9b8c7d",
      "question_type": "text",
      "question_body": "What is the reason for your rating?",
      "answer": "Fast delivery, happy to order again."
    }
  ],
  "respondent": {
    "id": "23decc90-820a-40bf-92ea-db493c71bfa0",
    "attributes": {
      "email": "jane@example.com",
      "customer_type": "returning",
      "total_purchases": 3,
      "plan": "pro"
    }
  }
}

answers contains all answered questions in the order they were answered. Each entry additionally carries question_id, question_type and question_body.

The response object

FieldTypeDescription
user_idstringanonymous PollHero user ID of the browser, identical to respondent.id
collected_atstringtime of the first answer in this participation
completedbooleanThe participation is completed.
urlstring or nullpage on which the survey was answered
order_idstring or nullorder ID (transaction_id) of the order this participation belongs to, see Purchase tracking. null if there is no matching order.

Only for survey_completed:

FieldTypeDescription
completed_atstringtime of completion
time_takeninteger or nullduration of the participation in seconds
device_type, browser, countrystring or nulldevice, browser and country of the respondent
utmobjectsource, medium, campaign, term, content of the page on which the survey was shown

The order is matched via the PollHero user ID: the order of the same person in the same project that is closest to the start of the participation counts, at most one hour before or after it. If your shop reports the order only after the first answer, order_id can still be null in question_answered, while survey_completed and the Data Export API already contain it.

The respondent object

FieldTypeDescription
idstringanonymous PollHero user ID
attributesobjectthe user attributes of the person: the fixed attributes email, customer_id, customer_type, total_revenue, total_purchases, total_aov, country and language when set, plus all custom attributes from identify. Without attributes the object is empty.

Unlike the Data Export API, you always get all attributes here without requesting them, as an object with the attribute names as keys.

Verifying the signature

Every request carries two features that let your endpoint tell that it really comes from PollHero:

  • X-PollHero-Token contains the verification token of the project. Compare it with the value from the project settings.
  • X-PollHero-Signature contains the HMAC-SHA256 of the unmodified request body, computed with the signing secret, as lowercase hexadecimal.

Compute the signature over the raw bytes of the body before you parse the JSON. Re-serialized JSON yields a different signature. Compare with a constant-time function.

// Node.js with Express: keep the raw body
import crypto from "node:crypto";
import express from "express";

const app = express();
app.post("/pollhero-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const expected = crypto
    .createHmac("sha256", process.env.POLLHERO_SIGNING_SECRET)
    .update(req.body)
    .digest("hex");
  const given = req.get("X-PollHero-Signature") ?? "";

  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const payload = JSON.parse(req.body);
  // start processing in the background ...
  res.sendStatus(200);
});
<?php
$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', $body, getenv('POLLHERO_SIGNING_SECRET'));
$given = $_SERVER['HTTP_X_POLLHERO_SIGNATURE'] ?? '';

if (! hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

$payload = json_decode($body, true);
http_response_code(200);

With Regenerate in the Verification section you create a new token and a new secret. The old values stop working at that moment, so your receiver has to get the new values right away.

Delivery and retries

  • A delivery counts as successful when your endpoint responds with a 2xx status. Any other status, a timeout or a connection error counts as a failure.
  • After a failure, PollHero tries up to four more times: after 1, 5, 15 and 60 minutes. deliveryTries counts the attempts, messageId stays the same. After that the message is dropped.
  • The order is not guaranteed. Two answers can arrive in a different order than they were given. Use timestamp or collected_at if the order matters.
  • Webhooks run asynchronously. The request usually arrives a few seconds after the answer.

If 100 delivery attempts in a row fail, PollHero disables the webhook of the project automatically. The project settings then show a notice with the time. Events from the time the webhook was disabled are not sent later. Check your endpoint and set Enable webhook to ON again afterwards.

Tips for the receiver

  • Respond to every request with 200 right away and process the data afterwards. This avoids timeouts and retries.
  • Store messageId and ignore messages you already know. With retries, the same message can arrive more than once.
  • For the complete history or a reconciliation, use the Data Export API. Webhooks only deliver events from the moment they were enabled.

On this page