> ## Documentation Index
> Fetch the complete documentation index at: https://docs.listenlabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive a signed HTTPS request when a response completes, a study closes, or recruitment launches

Listen sends an HTTPS `POST` to your endpoint when an event happens in your workspace. Each request is signed with a secret that only your endpoint and Listen know, so your receiver can check that the request came from Listen.

> [!NOTE]
>
> Only workspace Admins can see and change webhook endpoints. Webhooks are turned on per workspace. If you do not see the **Webhooks** section, contact [support@listenlabs.ai](mailto:support@listenlabs.ai).

## Set up an endpoint

**1. Open the Webhooks section**

Click **Account** in the menu at the bottom of the left sidebar. The **Developer** card has two sections: API keys and Webhooks.

**2. Add the endpoint**

Click **Add endpoint**. Enter your receiver URL, an optional description, the events to send, and any [custom headers](#custom-headers). Listen checks the URL when you save and tells you why it refuses one. See [Endpoint URL rules](#endpoint-url-rules).

![Add endpoint dialog with a URL, a description, two selected events and an Authorization header](https://mintcdn.com/listenlabs-b8522a99/a32uW7Yuv7Ula8W3/images/webhooks-add-endpoint.png?fit=max\&auto=format\&n=a32uW7Yuv7Ula8W3\&q=85\&s=4ad2df2562db92b38458404d6236e3b6)

**3. Copy the signing secret**

The secret starts with `whsec_`. It is shown only once. Store it in your receiver. If you lose it, click **Rotate secret** in **Manage** to get a new one.

![Endpoint added dialog showing the signing secret once, with an example value](https://mintcdn.com/listenlabs-b8522a99/a32uW7Yuv7Ula8W3/images/webhooks-signing-secret.png?fit=max\&auto=format\&n=a32uW7Yuv7Ula8W3\&q=85\&s=4c820160ad5895130e875986ef9152af)

**4. Send a test event**

Pick an event type and click **Send test event**. Then open **Manage** and look for **Delivered** under **Recent deliveries**.

A workspace can have up to 20 endpoints. Each endpoint has its own secret.

![Webhooks section with three endpoints, one switched off](https://mintcdn.com/listenlabs-b8522a99/a32uW7Yuv7Ula8W3/images/webhooks-endpoint-list.png?fit=max\&auto=format\&n=a32uW7Yuv7Ula8W3\&q=85\&s=764eadec52d7accfa4ae3fc2849a3b6a)

## Request format

Every request has a JSON body and three headers.

```http theme={null}
POST /webhooks/listen HTTP/1.1
Content-Type: application/json
User-Agent: ListenLabs-Webhooks
webhook-id: response.completed_3c5d2f6e-8a41-4f0b-9d2e-0b7a6c1e4f90_3fa1c09e
webhook-timestamp: 1793651400
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
  "id": "response.completed_3c5d2f6e-8a41-4f0b-9d2e-0b7a6c1e4f90_3fa1c09e",
  "type": "response.completed",
  "schemaVersion": 1,
  "occurredAt": "2026-11-02T20:30:00.000Z",
  "organizationId": "8b1f0c52-6d3e-4a7f-9c21-5e4d3b2a1f00",
  "isTest": false,
  "data": {
    "responseId": "3c5d2f6e-8a41-4f0b-9d2e-0b7a6c1e4f90",
    "readableResponseId": 123,
    "studyId": "1f2e3d4c-5b6a-4789-8a9b-0c1d2e3f4a5b",
    "studyLinkId": "a1b2c3d4",
    "startedAt": "2026-11-02T20:12:41.000Z",
    "completedAt": "2026-11-02T20:29:58.000Z",
    "screenerStatus": "qualified",
    "resourceUrl": "https://listenlabs.ai/api/public/v1/responses/a1b2c3d4/3c5d2f6e-8a41-4f0b-9d2e-0b7a6c1e4f90"
  }
}
```

**id** `string`

The event ID. It is also the `webhook-id` header. It stays the same when Listen sends the event again, so use it to drop duplicates.

**type** `string`

The event type. See [Events](#events).

**schemaVersion** `number`

The payload version. Currently `1`.

**occurredAt** `string`

When the event happened, as an ISO 8601 timestamp.

**organizationId** `string`

The ID of the workspace the event belongs to. Other API pages call the workspace an organization.

**isTest** `boolean`

`true` for events sent with **Send test event**. Test events carry placeholder IDs. Do not run real actions for them.

**data** `object`

The event fields. Each event type has its own shape.

## Verify the signature

Listen signs every request with the [Standard Webhooks](https://www.standardwebhooks.com) scheme. Verify each request before you trust its body, and reply `401` when the check fails.

```javascript Node.js (Express) theme={null}
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const webhook = new Webhook(process.env.LISTEN_WEBHOOK_SECRET); // starts with "whsec_"

app.post("/webhooks/listen", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    // Verify the raw body. Parsed and re-serialized JSON does not match the signature.
    event = webhook.verify(req.body, req.headers);
  } catch {
    return res.status(401).end();
  }
  res.status(204).end();
  handleEvent(event); // Do slow work after the reply.
});
```

To verify without a library:

1. Remove the `whsec_` prefix from the secret and base64-decode the rest. This is the HMAC key.
2. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw body}`.
3. Compute an HMAC-SHA256 of the signed content with the key, and base64-encode it.
4. Compare it with the value after `v1,` in `webhook-signature`, with a constant-time compare.
5. Reject the request if `webhook-timestamp` is more than 5 minutes from your clock.

## Respond and retries

Reply with a `2xx` status within 15 seconds. Reply first, then do slow work. The 15 seconds include the DNS lookup and the connection.

Listen sends the event again after any other result: a non-`2xx` status (`3xx`, `401` and other `4xx` included), a timeout, or a connection error. It waits 5 seconds, 30 seconds, 3 minutes, 18 minutes, 1.8 hours, 10 hours and 10 hours between attempts. After 8 attempts, about 22 hours, the delivery fails and is not sent again.

To slow Listen down, add a `Retry-After` header to the failed reply, in seconds or as an HTTP date. Listen waits at least that long before the next attempt, up to 10 hours per wait. It never retries sooner than the schedule above, and the delivery still gets at most 8 attempts.

* Listen does not follow redirects. Save the final URL.
* A retry has the same `webhook-id` and body, with a new timestamp and signature.
* Events can arrive out of order. For example, `response.ignored` can arrive before `response.completed` for the same response.
* Events that happen while an endpoint is switched off are never sent.
* If every delivery to an endpoint fails for 5 days, Listen switches the endpoint off. The Webhooks section shows this. Fix your receiver, then switch the endpoint on again.

## Endpoint URL rules

* The URL uses `https` on port `443` or `8443`.
* The host is on the public internet. Listen does not send to private or loopback addresses, or to hosts that resolve to them.
* The server has a valid certificate from a public certificate authority.
* The host is not a Listen Labs host.
* The URL has no username or password, and is at most 2048 characters.

Do not put a token in the URL. Send it in a custom header instead.

## Custom headers

Add up to 10 headers that Listen sends with every request to the endpoint, for example `Authorization: Bearer <token>`.

* Listen encrypts header values and never shows them again. To change a value, type a new one. To remove a header, delete its row.
* If you change the URL, enter every header value again. Listen sends a saved value only to the URL it was saved for.
* Names follow HTTP rules and are unique, ignoring case. Values are printable ASCII, up to 2048 characters.
* These names are reserved: `Host`, `Content-Type`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`, `Upgrade`, `TE`, `Trailer`, `Expect`, `User-Agent`, and any name that starts with `webhook-`, `proxy-` or `sec-`. The same names written with `_` in place of `-` are reserved too.

## Events

| Event | Sent when |
| - | - |
| `response.completed` | A qualified response finished and its analysis is ready |
| `response.ignored` | A response was hidden from the study's results |
| `study.completed` | A study closed |
| `study.response_limit_reached` | A study reached its response limit and closed |
| `recruitment.launched` | Panel recruitment launched or resumed for one target group |

An endpoint receives only the event types selected for it. Events for restricted studies are never sent.

### response.completed

Sent once per response, when a qualified response is complete and its analysis has finished.

```json theme={null}
{
  "responseId": "3c5d2f6e-8a41-4f0b-9d2e-0b7a6c1e4f90",
  "readableResponseId": 123,
  "studyId": "1f2e3d4c-5b6a-4789-8a9b-0c1d2e3f4a5b",
  "studyLinkId": "a1b2c3d4",
  "startedAt": "2026-11-02T20:12:41.000Z",
  "completedAt": "2026-11-02T20:29:58.000Z",
  "screenerStatus": "qualified",
  "resourceUrl": "https://listenlabs.ai/api/public/v1/responses/a1b2c3d4/3c5d2f6e-8a41-4f0b-9d2e-0b7a6c1e4f90"
}
```

* `completedAt` is when the analysis finished, not when the participant left the interview.
* `studyLinkId` is the study's `linkId`, the one [Get Responses](https://docs.listenlabs.com/api-v2/list-responses.md) takes.
* `resourceUrl` returns the transcript, URL parameters and summary. Call it with an API key from the same workspace in the `x-api-key` header. See [Get Single Response](https://docs.listenlabs.com/api-v2/get-response.md). It is `null` when the study has no study link.

```bash theme={null}
curl '<resourceUrl>' -H 'x-api-key: <api_key>'
```

### response.ignored

Sent when a response is hidden from the study's results, by a member of your workspace with **Hide Response** or by Listen. Hidden responses are left out of [Search Responses](https://docs.listenlabs.com/api-v2/search-responses.md) unless `includeHidden=true`.

```json theme={null}
{
  "responseId": "3c5d2f6e-8a41-4f0b-9d2e-0b7a6c1e4f90",
  "readableResponseId": 123,
  "studyId": "1f2e3d4c-5b6a-4789-8a9b-0c1d2e3f4a5b",
  "ignoredAt": "2026-11-03T09:14:05.000Z",
  "ignoreReason": "partial"
}
```

* `ignoreReason` is one of `fraud`, `video_quality`, `response_quality`, `testing`, `partial`, `hidden_by_non_superadmin`, `restarted`, or `null`. `hidden_by_non_superadmin` means a member of your workspace hid the response.
* It can be sent for a response that never got `response.completed`, for example a partial response.
* No event is sent when a response is unhidden.

### study.completed

Sent when a study closes, by hand or because it reached its response limit. It is not sent when you close a paused study, because a pause already marks the study closed. It is sent again if the study is reopened and closed again.

```json theme={null}
{
  "studyId": "1f2e3d4c-5b6a-4789-8a9b-0c1d2e3f4a5b",
  "name": "Checkout experience interviews",
  "status": "closed",
  "createdAt": "2026-10-20T15:02:11.000Z",
  "publishedAt": "2026-10-21T09:00:00.000Z",
  "ownerUserId": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
  "totalResponses": 42,
  "incidenceRate": 0.8
}
```

* `totalResponses` counts qualified responses that are not hidden, including responses still in progress. It is not the number of completed responses.
* `incidenceRate` is qualified responses divided by qualified plus screened-out responses, or `null`.

### study.response\_limit\_reached

Sent when a study reaches its response limit and closes. A `study.completed` event is sent at the same time. It is not sent if the study was paused when it reached the limit.

```json theme={null}
{
  "studyId": "1f2e3d4c-5b6a-4789-8a9b-0c1d2e3f4a5b",
  "responseLimit": 42,
  "currentCompletedCount": 42,
  "reachedAt": "2026-11-02T20:30:00.000Z"
}
```

### recruitment.launched

Sent when panel recruitment launches or resumes for one target group of a study. A study with several target groups sends one event per group. Share links, custom links and email recruitment send no event.

```json theme={null}
{
  "studyId": "1f2e3d4c-5b6a-4789-8a9b-0c1d2e3f4a5b",
  "recruitmentId": "9e8d7c6b-5a4f-4e3d-9c2b-1a0f9e8d7c6b",
  "targetGroupId": "0f1e2d3c-4b5a-4968-8778-695a4b3c2d1e",
  "targetRespondentCount": 100,
  "isResume": false,
  "launchedByUserId": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
  "launchedByUserEmail": "researcher@example.com",
  "launchedAt": "2026-11-02T18:00:00.000Z"
}
```

## Test events

**Send test event** sends one event of the chosen type to one endpoint, with `isTest: true`. The endpoint must be switched on and subscribed to that type. Its IDs are placeholders, so its `resourceUrl` finds no response. A test makes one attempt and is not retried. Each endpoint allows 5 test events a minute, and each workspace 20.

## Delivery log

**Manage** shows the last 50 deliveries of an endpoint from the past 30 days, with the status, the number of attempts and the last result.

![Manage dialog with recent deliveries in each status](https://mintcdn.com/listenlabs-b8522a99/a32uW7Yuv7Ula8W3/images/webhooks-delivery-log.png?fit=max\&auto=format\&n=a32uW7Yuv7Ula8W3\&q=85\&s=089b8cea34f84923f662a706c4e2126a)

| Status | Meaning |
| - | - |
| Sending | Waiting for the first attempt |
| Retrying | An attempt failed, and Listen will try again |
| Delivered | Your receiver replied `2xx` |
| Failed | All attempts failed, or Listen could not send the event |
| Cancelled | Before the next attempt, the endpoint was switched off or no longer subscribed to this event type, or the study became restricted or was deleted |

**Delivered** means your receiver replied `2xx`. Listen cannot see whether your receiver checked the signature.

## Rotate the secret

**Rotate secret** in **Manage** replaces the secret at once. The old secret stops working for the next request, including retries. Put the new secret in your receiver right away.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.