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

# Webhooks

> Subscribe to events and receive signed, real-time deliveries (API v2)

TalentUnveiled sends an HTTP `POST` to your endpoint whenever a subscribed event occurs. Subscribe an endpoint to specific events and verify each delivery's signature.

## Manage endpoints

You can manage webhook endpoints from **Developer Settings → Webhooks** in the [HR portal](https://hr.talentunveiled.com), or programmatically via the v2 API:

| Method · Path                                        | Purpose                                                           |
| ---------------------------------------------------- | ----------------------------------------------------------------- |
| `POST /api/v2/webhooks`                              | Create an endpoint. The `secret` is returned **once** — store it. |
| `GET /api/v2/webhooks` · `GET /api/v2/webhooks/{id}` | List / retrieve (secret is masked).                               |
| `PATCH /api/v2/webhooks/{id}`                        | Update `url`, `enabled`, or `event_types`.                        |
| `DELETE /api/v2/webhooks/{id}`                       | Remove an endpoint.                                               |
| `POST /api/v2/webhooks/{id}/rotate-secret`           | Rotate the signing secret (new secret returned once).             |
| `GET /api/v2/webhooks/{id}/deliveries`               | Audit delivery attempts (status, response code).                  |

**Subscriptions.** Set `event_types` to the list of events you want. An **empty list means all events**. Set `enabled: false` to pause an endpoint without deleting it. Handle event types you don't recognize **gracefully** — log them and still return `2xx`; the set can grow over time, and all-events subscribers also receive legacy events such as `interview_v2.completed` (described under [Events](#events)).

<Note>
  A URL can be registered **once per organization**. `POST /api/v2/webhooks` (and `PATCH /api/v2/webhooks/{id}` when you change `url`) returns `409` with code `already_exists` if that URL is already one of your own webhooks — distinct from the idempotency `409` (`idempotency_key_conflict`). Different organizations may register the same URL.
</Note>

## Payload envelope

Every webhook delivery has this envelope structure:

```json theme={null}
{
    "id": "evt_3f2c...",
    "created_at": "2026-06-21T12:00:00Z",
    "webhook_id": "550e8400-...",
    "event_type": "interview.completed",
    "payload": { }
}
```

| Field        | Description                                                             |
| ------------ | ----------------------------------------------------------------------- |
| `id`         | Stable event ID — **constant across delivery retries**. Dedupe on this. |
| `created_at` | When the event occurred.                                                |
| `webhook_id` | The configured webhook endpoint's ID.                                   |
| `event_type` | The event name.                                                         |
| `payload`    | The event body.                                                         |

Deliveries are **at-least-once** and **not ordered** — make your handler idempotent (key on `id`) and order-independent. The event reference below describes each event's **`payload`** contents; on the wire they always arrive inside the envelope above, so read `body.payload.…` (the `interview.completed` example is shown fully wrapped).

## Verifying signatures

Each request includes signing headers. Verify the signature before trusting a payload:

| Header              | Description                                                                                                         |
| ------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | Per-delivery message ID.                                                                                            |
| `webhook-timestamp` | Unix timestamp (seconds); reject if more than 5 minutes from now in **either** direction (too old or future-dated). |
| `webhook-signature` | `v1,<base64(HMAC-SHA256)>` over `{webhook-id}.{webhook-timestamp}.{raw-body}`, keyed by your `whsec_` secret.       |

## Delivery & retries

Return `2xx` quickly — verify the signature, enqueue, and process asynchronously. TalentUnveiled retries failed deliveries with exponential backoff:

| Behavior                                                           | Value                                                                                               |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| Attempts                                                           | Up to 6 (initial + 5 retries)                                                                       |
| Backoff                                                            | 2 min → 8 h, exponential (×4 per attempt)                                                           |
| Per-attempt timeout                                                | 10 s                                                                                                |
| Redirects                                                          | Not followed (a `3xx` counts as a failed delivery)                                                  |
| `2xx` from your endpoint                                           | Acknowledged — the delivery succeeds                                                                |
| Anything else (`3xx`, `4xx`, `5xx`, a timeout, or a network error) | Treated as a failure and **retried** per the schedule; after 6 failed attempts the event is dropped |

## Events

Every event whose payload has an `application` block carries `external_id` on it — the correlation id you set when creating the application (`null` when unset) — so any event (`application.*`, `interview.completed`, `cv_evaluation.completed`, `insights.completed`) can be routed back to your own record without storing the TalentUnveiled id.

### `application.created`

Fired when a new application is created (any source — API, candidate portal, or ATS import). The `payload` contains the `application` (`id`, `external_id`, `status`, `created_at`), `candidate`, and `job` (`id`, `title`). `external_id` is the correlation id you set when creating the application (`null` when unset), so you can route the event to your own record without storing the TalentUnveiled id. Its absence past your expected SLA is the signal a create was lost upstream.

### `application.status_changed`

Fired when an application's pipeline status changes. The `payload` adds `previous_status` alongside the `application`, `candidate`, and `job` blocks. Each transition is a distinct event, so a candidate moving `applied → screened → hired` delivers three events.

### `interview.completed`

Fired when a v3 interview finishes and scoring is ready. The `payload` carries enough to triage; fetch the full result (per-criterion scores, transcript) via `GET /api/v2/applications/{id}/interview`.

<Accordion title="Example delivery (full envelope)">
  ```json theme={null}
  {
      "id": "evt_3f2c...",
      "created_at": "2026-02-05T09:30:00Z",
      "webhook_id": "550e8400-...",
      "event_type": "interview.completed",
      "payload": {
          "application": { "id": "a3bd0538-...", "external_id": "candidate-9921", "status": "interviewed", "created_at": "2026-02-05T09:01:34Z" },
          "candidate": { "first_name": "Michael", "last_name": "Johnson", "phone_number": "+14155551002", "email": "michael.johnson@example.com" },
          "job": { "id": "e7211749-...", "title": "Product Manager" },
          "interview": {
              "id": "9856d399-...",
              "completed_at": "2026-02-05T09:30:00Z",
              "result": {
                  "overall_score": 3.75,
                  "is_eligible": true,
                  "summary": "Strong clinical background, limited pediatric exposure.",
                  "strengths": ["Clear communicator"],
                  "weaknesses": ["Limited pediatric experience"],
                  "red_flags": []
              }
          }
      }
  }
  ```
</Accordion>

<Warning>
  `is_eligible: false` means the candidate failed a *required* criterion — surface it to a recruiter, never auto-reject. `overall_score` is `0–5` (or `null` when not gradable), independent of eligibility.
</Warning>

### `cv_evaluation.completed`

Fired when a CV evaluation finishes scoring. The `payload` carries the `application` and a `cv_evaluation` block (`id`, `overall_score`); fetch the per-criterion breakdown via `GET /api/v2/applications/{id}/cv-evaluation`.

### `insights.completed`

Fired when the combined CV + interview insights finish. The `payload` carries the `application` and an `insights` block (`overall_score`, `hiring_recommendation`, `is_eligible`); fetch the full insights via `GET /api/v2/applications/{id}/insights`.

### `job_application.deleted`

Fired when an application is removed. The `payload` is a snapshot of the application (`id`, `status`, `created_at`, `candidate`, `job`) captured immediately before deletion — the definitive "record is gone" signal.

### `interview_v2.completed`

Fired when a **legacy (previous-engine)** interview finishes. Jobs created through API v2 use the v3 engine and emit `interview.completed` instead, so you only see `interview_v2.completed` for older jobs still on the previous interview engine — and only if you subscribe to all events (empty `event_types`). Treat it as a completion signal for those legacy interviews.
