This page is for developers receiving Hearsay webhooks on their own server. To set one up, see Send responses to other tools with webhooks.
Events
| Event | When it's sent |
|---|---|
response.created |
A customer submits a response. Sent for every stored response, including ones whose coupon was withheld. |
reward.issued |
A customer receives a coupon. The payload adds the code, the message and the expiry. |
test |
You chose Send test on the Integrations page. |
Each webhook listens to one or both of the first two events, for every survey in the workspace or only the surveys you picked.
The request
Every delivery is an HTTPS POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-PuppetRush-Signature |
t=<unix seconds>,v1=<signature>. See Verify the signature. |
X-PuppetRush-Event |
The event, such as response.created |
X-PuppetRush-Delivery |
The delivery's id, the same as id in the body |
User-Agent |
PuppetRush-Webhooks/1 |
Payload
Free text and contact details are included: it's your data, going to an address you set up.
{
"version": 1,
"id": "evt_4f1c…",
"event": "response.created",
"createdAt": "2026-10-01T09:30:12.411Z",
"accountId": "acct_…",
"survey": { "id": "k3P9xQ", "title": "How was your visit?", "version": 3, "brandId": "default" },
"response": {
"id": "7c0e…",
"createdAt": "2026-10-01T09:30:11.902Z",
"source": "counter-qr",
"ref": null,
"device": "mobile",
"language": "en",
"completionMs": 41250,
"suspect": false,
"ending": { "id": "e_thanks", "title": "Thanks for your feedback" },
"hidden": { "email": "asha@example.com", "plan": "pro" },
"embed": "popup",
"pageUrl": "https://yourshop.com/thank-you"
},
"answers": [
{ "questionId": "q_Xp3k9QmZ2a", "question": "How was your visit today?", "type": "rating", "value": 5 },
{ "questionId": "q_7RtB2wLc0e", "question": "What did you order?", "type": "checkbox", "value": ["Cortado", "Croissant"], "optionIds": ["o_k2m9x1aa", "o_p3q8z0bb"] },
{ "questionId": "q_Jd4nV8sYq1", "question": "Anything we should add to the menu?", "type": "comment", "value": "Oat milk cortado" }
],
"fields": {
"q_Xp3k9QmZ2a": "5",
"q_7RtB2wLc0e": "Cortado; Croissant",
"q_Jd4nV8sYq1": "Oat milk cortado",
"q_M0wZ6hTa3f": ""
},
"questions": {
"q_Xp3k9QmZ2a": "How was your visit today?",
"q_7RtB2wLc0e": "What did you order?",
"q_Jd4nV8sYq1": "Anything we should add to the menu?",
"q_M0wZ6hTa3f": "Can we have your name?"
},
"reward": { "code": "BREW15-7KQ2", "message": "15% off your next visit", "expiresOn": "2026-12-31" }
}
reward is present on reward.issued. response.source is the channel, response.hidden holds the link fields, and embed and pageUrl are set when the response came from a website embed.
answers
The questions the customer answered, in the survey's order, with the wording of the survey version they answered. Choice answers give the labels, with stable option ids in optionIds. File answers add a download link.
fields
The same answers flattened into one text value per question, keyed by question id. This is the easiest shape for Zapier, Make and spreadsheets.
- A skipped question is still there as an empty string, so a column you map stays in place.
- Several choices are joined with
;. Rankings readFirst > Second. Grids readRow: Column; Row: Column. - Contact questions read
Label: value; …, and also get one key per detail: the question id followed by_name,_emailand so on. - File questions list the file names.
- Statement blocks take no answer and have no key.
questions
The same keys as fields, each holding the question's wording, so a tool that only shows key names can still tell questions apart. A contact detail's key holds the question and the detail, as in Your details — Email.
Question ids don't change when you reword a question, so your mappings keep working. A question you add later arrives as a new key.
Verify the signature
The X-PuppetRush-Signature header looks like t=1759311012,v1=5f2b…. The signature is the hex HMAC-SHA256 of <t>.<raw body>, keyed with the webhook's signing secret.
To verify a delivery:
- Compute the HMAC over the raw request body, before any JSON parsing.
- Compare it with
v1in constant time. - Reject deliveries whose
tis more than five minutes from now. This stops replays.
Node (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Verify against the raw body, before any JSON parsing.
app.post('/hearsay', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-PuppetRush-Signature') || '';
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.HEARSAY_SECRET)
.update(`${t}.${req.body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
const valid = v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!fresh || !valid) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// … store event.fields, send event.reward.code to your CRM …
res.sendStatus(200);
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/hearsay")
def hearsay():
header = request.headers.get("X-PuppetRush-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
body = request.get_data() # the raw bytes
expected = hmac.new(os.environ["HEARSAY_SECRET"].encode(),
f"{parts.get('t')}.".encode() + body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts.get("t", 0))) < 300
if not fresh or not hmac.compare_digest(parts.get("v1", ""), expected):
abort(400)
event = request.get_json()
return "", 200
If you lose the secret, choose New secret on the Integrations page. The old one stops working straight away.
Retries and repeats
- Answer with any 2xx status within 10 seconds. Anything else is a failed attempt, and Hearsay tries again with a growing wait: 5 attempts in all.
- An event that fails all 5 attempts counts as one failed delivery. A delivery that gets through resets the count.
- After 25 failed deliveries in a row, the webhook is switched off, the Integrations page says why, and the workspace's owners and admins get an email. Fix the endpoint and switch it back on.
- Deliveries can arrive more than once and out of order, and each attempt has a new top-level
id. To ignore repeats, useeventtogether withresponse.id: each response sendsresponse.createdonce and, when it earns a coupon,reward.issuedonce. - The last 7 days of deliveries, with status codes, are under Deliveries on each webhook.
Test events
Send test posts an event of type test straight away, built from one of the webhook's surveys: its latest real response, or sample answers when it has none yet. If the webhook listens to reward.issued, the test includes that response's coupon, or the code TEST-CODE.
Tests aren't retried and don't count towards switching a webhook off.
Questions about webhooks? See Contact support.