# Webhook reference for developers

> Events, headers, the JSON payload, how to verify signatures in Node and Python, and the retry rules for Hearsay webhooks.

Source: https://docs.hearsay.co.in/integrations/webhook-reference

This page is for developers receiving Hearsay webhooks on their own server. To set one up, see [Send responses to other tools with webhooks](https://docs.hearsay.co.in/integrations/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](#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` |

> **Note: Why PuppetRush?**
>
> PuppetRush was Hearsay's earlier name. The header names stay as they are so existing integrations keep working.

## Payload

Free text and contact details are included: it's your data, going to an address you set up.

```json
{
  "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](https://docs.hearsay.co.in/share/links-and-channels), `response.hidden` holds the [link fields](https://docs.hearsay.co.in/build/link-fields), and `embed` and `pageUrl` are set when the response came from a [website embed](https://docs.hearsay.co.in/share/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 read `First > Second`. Grids read `Row: Column; Row: Column`.
- Contact questions read `Label: value; …`, and also get one key per detail: the question id followed by `_name`, `_email` and 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:

1. Compute the HMAC over the raw request body, before any JSON parsing.
2. Compare it with `v1` in constant time.
3. Reject deliveries whose `t` is more than five minutes from now. This stops replays.

**Node (Express)**

```js
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)**

```python
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, use `event` together with `response.id`: each response sends `response.created` once and, when it earns a coupon, `reward.issued` once.
- 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](https://docs.hearsay.co.in/help/contact-support).
