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

# Webhooks

> Receive real-time notifications when interviews complete, with duration, quality score, and respondent data.

Get notified when an interview completes. Configure a webhook URL and we'll POST a signed payload with interview results every time a respondent finishes.

***

## `PUT /v1/webhooks`

Create or update the webhook configuration for your project. One webhook per project.

Returns the webhook configuration. On first creation, includes the `signing_secret`.

### Request

<ParamField header="Authorization" type="string" required>
  Bearer token. See [Authentication](/api/authentication).
</ParamField>

<ParamField body="url" type="string" required>
  HTTPS endpoint to receive events.
</ParamField>

<ParamField body="events" type="string[]" required>
  Event types to subscribe to. Currently: `["interview.completed"]`.
</ParamField>

<RequestExample>
  ```bash cURL theme={null}
  curl -X PUT https://app.userjourneys.ai/api/v1/webhooks \
    -H "Authorization: Bearer uj_live_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://your-app.com/webhooks/userjourneys",
      "events": ["interview.completed"]
    }'
  ```

  ```javascript Node.js theme={null}
  const response = await fetch(
    "https://app.userjourneys.ai/api/v1/webhooks",
    {
      method: "PUT",
      headers: {
        Authorization: "Bearer uj_live_your_key_here",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        url: "https://your-app.com/webhooks/userjourneys",
        events: ["interview.completed"],
      }),
    }
  );
  const data = await response.json();
  // data.signing_secret — save this, only shown once
  ```

  ```python Python theme={null}
  import requests

  response = requests.put(
      "https://app.userjourneys.ai/api/v1/webhooks",
      headers={"Authorization": "Bearer uj_live_your_key_here"},
      json={
          "url": "https://your-app.com/webhooks/userjourneys",
          "events": ["interview.completed"],
      },
  )
  data = response.json()
  # data["signing_secret"] — save this, only shown once
  ```
</RequestExample>

<ResponseExample>
  ```json 201 — Created theme={null}
  {
    "url": "https://your-app.com/webhooks/userjourneys",
    "events": ["interview.completed"],
    "signing_secret": "whsec_5b69ff6a12af94c6e3901a061180ca73"
  }
  ```

  ```json 200 — Updated theme={null}
  {
    "url": "https://your-app.com/webhooks/userjourneys",
    "events": ["interview.completed"]
  }
  ```
</ResponseExample>

<Warning>
  The `signing_secret` is only returned on first creation (201). Store it securely — you'll need it to verify payloads. Lost or need to rotate your secret? Delete the webhook and create a new one. Deletion only requires your API key.
</Warning>

***

## `GET /v1/webhooks`

Retrieve the current webhook configuration.

Returns the URL and subscribed events. Does not return the signing secret.

```bash theme={null}
curl https://app.userjourneys.ai/api/v1/webhooks \
  -H "Authorization: Bearer uj_live_your_key_here"
```

Returns `404` if no webhook is configured.

***

## `DELETE /v1/webhooks`

Remove the webhook configuration.

```bash theme={null}
curl -X DELETE https://app.userjourneys.ai/api/v1/webhooks \
  -H "Authorization: Bearer uj_live_your_key_here"
```

Returns `204` on success.

***

## Event: `interview.completed`

Sent after interview processing completes, typically **5–30 seconds** after the respondent finishes.

### Headers

| Header                | Value                                     |
| --------------------- | ----------------------------------------- |
| `Content-Type`        | `application/json`                        |
| `X-Webhook-Event`     | `interview.completed`                     |
| `X-Webhook-Signature` | HMAC-SHA256 signature of the request body |

### Payload

<ResponseField name="event" type="string">
  `"interview.completed"`
</ResponseField>

<ResponseField name="timestamp" type="string">
  ISO 8601 timestamp of when the event was sent.
</ResponseField>

<ResponseField name="data" type="object">
  <Expandable title="properties">
    <ResponseField name="interview_id" type="string">
      Interview ID (UUID). Use this with [`GET /v1/interviews/:id`](/api/interviews#get-v1interviewsid) to fetch the full transcript.
    </ResponseField>

    <ResponseField name="study_id" type="string | null">
      Study this interview belongs to. Matches the `id` in [`GET /v1/studies`](/api/studies).
    </ResponseField>

    <ResponseField name="study_name" type="string | null">
      Study display name.
    </ResponseField>

    <ResponseField name="session_id" type="string">
      Deprecated alias for `interview_id`. Use `interview_id` instead.
    </ResponseField>

    <ResponseField name="experiment_id" type="string | null">
      Deprecated alias for `study_id`. Use `study_id` instead.
    </ResponseField>

    <ResponseField name="experiment_name" type="string | null">
      Deprecated alias for `study_name`. Use `study_name` instead.
    </ResponseField>

    <ResponseField name="interview_link" type="string | null">
      Public interview URL for this experiment.
    </ResponseField>

    <ResponseField name="status" type="string">
      `"completed"`
    </ResponseField>

    <ResponseField name="started_at" type="string">
      Interview start time (ISO 8601).
    </ResponseField>

    <ResponseField name="completed_at" type="string | null">
      Interview end time (ISO 8601).
    </ResponseField>

    <ResponseField name="duration_secs" type="integer | null">
      Interview length in seconds.
    </ResponseField>

    <ResponseField name="quality_score" type="string | null">
      `"insightful"`, `"successful"`, or `"unsuccessful"`.
    </ResponseField>

    <ResponseField name="language" type="string | null">
      Detected language code (e.g. `"en"`, `"es"`, `"de"`).
    </ResponseField>

    <ResponseField name="reference_id" type="string | null">
      Custom identifier passed via the `?reference_id=` query parameter on the interview link. Use this to match completions back to users in your system.
    </ResponseField>

    <ResponseField name="respondent_email" type="string | null">
      Email entered during or after the interview. `null` if not collected.
    </ResponseField>

    <ResponseField name="respondent_id" type="string | null">
      Respondent ID, if triggered via a respondent-specific link.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseExample>
  ```json interview.completed theme={null}
  {
    "event": "interview.completed",
    "timestamp": "2026-03-11T14:32:00.123Z",
    "data": {
      "interview_id": "a1b2c3d4-5678-90ab-cdef-111111111111",
      "study_id": "e5f6a7b8-1234-56cd-ef78-222222222222",
      "study_name": "Onboarding Feedback",
      "session_id": "a1b2c3d4-5678-90ab-cdef-111111111111",
      "experiment_id": "e5f6a7b8-1234-56cd-ef78-222222222222",
      "experiment_name": "Onboarding Feedback",
      "interview_link": "https://app.userjourneys.ai/i/xK9mR2pQ",
      "status": "completed",
      "started_at": "2026-03-11T14:20:00.000Z",
      "completed_at": "2026-03-11T14:30:00.456Z",
      "duration_secs": 580,
      "quality_score": "insightful",
      "language": "en",
      "reference_id": "user_12345",
      "respondent_email": null,
      "respondent_id": null
    }
  }
  ```
</ResponseExample>

### Retries

If your endpoint returns a non-2xx status or times out (10s limit), we retry up to 3 times:

| Attempt   | Delay      |
| --------- | ---------- |
| 1st retry | 10 seconds |
| 2nd retry | 1 minute   |
| 3rd retry | 5 minutes  |

After all retries are exhausted, the event is dropped.

<Tip>
  Use `session_id` to deduplicate in case you receive the same event more than once.
</Tip>

***

## Verifying signatures

Every webhook includes an `X-Webhook-Signature` header. Verify it to confirm the request came from userjourneys.ai.

```javascript Node.js theme={null}
import crypto from "node:crypto";

function verifyWebhookSignature(body, signature, secret) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(body).digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Express example
app.post("/webhooks/userjourneys", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-webhook-signature"];
  const isValid = verifyWebhookSignature(req.body, signature, SIGNING_SECRET);

  if (isValid === false) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(req.body);
  // Handle the event...

  res.status(200).send("OK");
});
```

```python Python theme={null}
import hashlib
import hmac

def verify_webhook_signature(body: bytes, signature: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

# Flask example
@app.route("/webhooks/userjourneys", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-Webhook-Signature")
    is_valid = verify_webhook_signature(
        request.data, signature, SIGNING_SECRET
    )

    if not is_valid:
        return "Invalid signature", 401

    event = request.get_json()
    # Handle the event...

    return "OK", 200
```

<Warning>
  Use timing-safe comparison (`crypto.timingSafeEqual`, `hmac.compare_digest`) to prevent timing attacks.
</Warning>

***

## Typical integration

Mark users after they complete an interview so you stop showing the prompt:

```javascript theme={null}
app.post("/webhooks/userjourneys", express.raw({ type: "application/json" }), async (req, res) => {
  const signature = req.headers["x-webhook-signature"];
  if (verifyWebhookSignature(req.body, signature, SIGNING_SECRET) === false) {
    return res.status(401).send("Invalid signature");
  }

  const { event, data } = JSON.parse(req.body);

  if (event === "interview.completed" && data.reference_id) {
    await db.users.update({
      where: { id: data.reference_id },
      data: { interview_completed: true },
    });
  }

  res.status(200).send("OK");
});
```
