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.
| Property | Value |
|---|---|
| Availability | Enterprise plan |
| Level | project, applies to all surveys of the project |
| Events | question_answered, survey_completed |
| Format | POST with a JSON body, UTF-8 |
| Verification | token 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
httporhttps. PollHero rejects addresses in private networks and host names such aslocalhost. - The endpoint must respond with a
2xxstatus within 15 seconds. The connection has to be established within 5 seconds. - PollHero does not follow redirects. A
301or302status counts as a failed delivery. - Ideally, respond with
200right away and process the content in the background afterwards.
Events
| Event | Sent | Content |
|---|---|---|
question_answered | as soon as an answer to a question has been saved | the single answer with question, participation and respondent |
survey_completed | as soon as a participation is completed | all answers of the participation |
- Skipped questions do not trigger an event.
- If someone changes an answer within the same participation,
question_answeredis sent again with a newmessageId. survey_completedis 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
| Header | Content |
|---|---|
Content-Type | application/json |
User-Agent | PollHero-Webhook/1.0 |
X-PollHero-Event | question_answered or survey_completed |
X-PollHero-Delivery | ID of the message, identical to messageId in the body. Stays the same on retries. |
X-PollHero-Token | verification token of the project |
X-PollHero-Signature | HMAC-SHA256 of the request body, see Verifying the signature |
Common fields
Every body starts with the same fields:
| Field | Type | Description |
|---|---|---|
messageId | string | Unique ID of the message. Stays the same on retries so you can detect duplicates. |
deliveryTries | integer | Number of the delivery attempt, starting at 1. |
event | string | question_answered or survey_completed |
timestamp | string | Time of the event, ISO 8601 in UTC |
test | boolean | Only 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"
}
}
}| Field | Description |
|---|---|
survey.id, survey.name | ID and name of the survey. The ID is the same as in the Data Export API. |
question.id | ID of the question, as in the Data Export API |
question.body | question text |
question.type | question type in the API notation, for example nps, single, text |
response | the participation the answer belongs to, see below |
answer | the 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. |
respondent | the 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
| Field | Type | Description |
|---|---|---|
user_id | string | anonymous PollHero user ID of the browser, identical to respondent.id |
collected_at | string | time of the first answer in this participation |
completed | boolean | The participation is completed. |
url | string or null | page on which the survey was answered |
order_id | string or null | order ID (transaction_id) of the order this participation belongs to, see Purchase tracking. null if there is no matching order. |
Only for survey_completed:
| Field | Type | Description |
|---|---|---|
completed_at | string | time of completion |
time_taken | integer or null | duration of the participation in seconds |
device_type, browser, country | string or null | device, browser and country of the respondent |
utm | object | source, 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
| Field | Type | Description |
|---|---|---|
id | string | anonymous PollHero user ID |
attributes | object | the 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-Tokencontains the verification token of the project. Compare it with the value from the project settings.X-PollHero-Signaturecontains 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
2xxstatus. 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.
deliveryTriescounts the attempts,messageIdstays 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
timestamporcollected_atif 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
200right away and process the data afterwards. This avoids timeouts and retries. - Store
messageIdand 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.