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

# Webhooks

> Events Vort posts to your server when a run finishes or fails and when a contact is revealed, and how to verify them.

Vort sends webhooks to the HTTPS URL you register with Vort. They tell your server when a run finishes or fails and when a contact is revealed, so you do not need to poll runs that no recruiter is watching. RFC 2119 keywords apply.

## Setup

* Give Vort one public `https://` URL on your server. Vort registers it for your partner account.
* Vort sends you a **webhook signing secret** out of band. It is distinct from your partner API key. Keep it server-side, like the key.
* Deliveries are signed with `x-vort-signature`. Verify every delivery before you parse it (see [Signature verification code](#signature-verification-code)).

## Events

| `event` | When | How many per run | Event fields (in addition to the envelope) |
| - | - | - | - |
| `run_completed` | The run reached `status: completed`. | at most one | `progress` (same shape as in `GET /runs`) |
| `run_failed` | The run reached `status: failed`. | at most one | — |
| `candidate_revealed` | A candidate's contact was revealed in this run. | one per revealed candidate | `candidate_id` |

Schemas: [run\_completed](/api-reference/webhooks/run-completed) · [run\_failed](/api-reference/webhooks/run-failed) · [candidate\_revealed](/api-reference/webhooks/candidate-revealed)

## Envelope

Present on every event:

<ResponseField name="event_id" type="string" required>
  Unique id of this event. Identical on every retry of the same event. Treat as opaque.
</ResponseField>

<ResponseField name="event" type="string" required>
  Event name. Ignore (but answer `2xx` to) events you do not know.
</ResponseField>

<ResponseField name="run_id" type="string (uuid)" required>
  The run the event belongs to.
</ResponseField>

<ResponseField name="organization_external_id" type="string" required>
  Your `external_id` for the customer, from the redeem call.
</ResponseField>

<ResponseField name="job_external_id" type="string" required>
  Your job id.
</ResponseField>

<ResponseField name="occurred_at" type="string (date-time)" required>
  When the event happened at Vort.
</ResponseField>

## Example delivery

```http theme={null}
POST /webhooks/vort HTTP/1.1
Host: api.your-ats.example
Content-Type: application/json
x-vort-signature: sha256=5d1c0f2e8b...<64 hex>

{"event_id":"6d0f3b8e-2c41-4f7a-9a15-0e8b7c2d4f90","event":"run_completed","run_id":"9f2b6c1e-3d4a-4e8f-b1c7-0a5d2e9f6b33","organization_external_id":"hrmony-tenant-5521","job_external_id":"job-4410","occurred_at":"2026-10-04T09:21:37Z","progress":{"confirmed":30,"target":30,"screened":912}}
```

## Delivery rules

<Warning>
  **Verify first.** `x-vort-signature` is `sha256=` followed by the lowercase hex HMAC-SHA256 of the **raw request body bytes**, keyed with your webhook secret. You MUST compute it over the raw bytes exactly as received (before any JSON parsing, re-encoding, or whitespace/Unicode normalization), compare in constant time, and reject a mismatch with `401` without processing it. Certification sends one request with a bad signature; it must be rejected.
</Warning>

* **Answer fast.** Return `2xx` as soon as the signature is verified and the event is queued; do the work asynchronously. Non-`2xx` responses and timeouts (10 s) are retried by Vort with backoff: up to 8 attempts over about 8 hours (after 1 min, 2 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h). After the last attempt the event is not sent again.
* **Answer at the registered URL itself.** Vort never follows redirects. Any `3xx` (including `301`, `302`, `307`, `308`) counts as a failed attempt and is retried like a `5xx`. If your endpoint moves, ask Vort to register the new URL.
* **Public HTTPS only.** The webhook URL must be `https://` on a public host. Vort does not send to `localhost`, `*.localhost`, `*.internal`, or an IP address in a private, loopback, link-local, carrier-grade NAT, multicast or reserved range (IPv4 or IPv6). Events for such a URL wait, unsent, until the URL is fixed.
* **Events wait at most 7 days.** Webhooks for a customer are paused while that customer's connection (or your whole partner account) is disabled, or while your webhook is not configured. They go out once it is enabled or configured again. An event still unsent 7 days after it happened is dropped. Use `GET /runs/{run_id}` to catch up on anything you may have missed.
* **At-least-once.** The same event can arrive more than once (retries). You SHOULD de-duplicate on `event_id`: store the ids you have processed and skip a repeat. Do not de-duplicate on `(run_id, event)`: `candidate_revealed` legitimately repeats within a run, once per candidate. The signature carries no timestamp, so `event_id` de-duplication is also your replay protection.
* **Webhooks are notifications, not state.** On `run_completed` / `run_failed`, fetch `GET /runs/{run_id}` for the authoritative candidates and notices.
* Ignore unknown fields.

## Signature verification code

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const WEBHOOK_SECRET = process.env.VORT_WEBHOOK_SECRET; // from Vort, out of band

  // express.raw keeps req.body as the exact bytes received. Do NOT put
  // express.json() in front of this route: it would parse and discard the raw body.
  app.post("/webhooks/vort", express.raw({ type: "application/json" }), (req, res) => {
    const header = req.get("x-vort-signature") || "";
    const expected =
      "sha256=" + crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.body).digest("hex");

    const a = Buffer.from(header.trim().toLowerCase(), "utf8");
    const b = Buffer.from(expected, "utf8");
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end();
    }

    const evt = JSON.parse(req.body.toString("utf8")); // parse only AFTER verifying
    res.status(204).end();                              // acknowledge fast
    queueVortEvent(evt);                                // dedupe on evt.event_id, then GET /runs/{run_id} async
  });
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import json
  import os

  from flask import Flask, abort, request

  app = Flask(__name__)
  WEBHOOK_SECRET = os.environ["VORT_WEBHOOK_SECRET"].encode()  # from Vort, out of band


  @app.post("/webhooks/vort")
  def vort_webhook():
      raw = request.get_data()  # exact raw bytes; do not read request.json before verifying
      header = request.headers.get("x-vort-signature", "").strip().lower()
      expected = "sha256=" + hmac.new(WEBHOOK_SECRET, raw, hashlib.sha256).hexdigest()

      if not hmac.compare_digest(header, expected):  # constant-time compare
          abort(401)

      event = json.loads(raw)   # parse only AFTER verifying
      queue_vort_event(event)   # dedupe on event_id, then GET /runs/{run_id} async
      return "", 204            # acknowledge fast
  ```

  ```php PHP (Laravel) theme={null}
  <?php
  // routes/api.php  (api routes are not CSRF-protected; if you use routes/web.php,
  // exclude this path from CSRF verification)
  use Illuminate\Http\Request;
  use Illuminate\Support\Facades\Route;

  Route::post('/webhooks/vort', function (Request $request) {
      $raw    = $request->getContent();                       // exact raw bytes
      $header = strtolower(trim((string) $request->header('x-vort-signature', '')));
      $secret = config('services.vort.webhook_secret');       // from Vort, out of band

      $expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);

      if (!hash_equals($expected, $header)) {                 // constant-time compare
          return response()->noContent(401);
      }

      // Parse only AFTER verifying. Do not use $request->all()/json() before this point
      // and never re-encode the payload to verify it.
      $event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
      \App\Jobs\HandleVortEvent::dispatch($event);            // dedupe on event_id, then GET /runs/{run_id}
      return response()->noContent();                         // 204, fast
  });
  ```
</CodeGroup>

## Testing webhooks in the sandbox

Once Vort has registered your URL and secret, `POST /sandbox/webhooks/test` with `{"event": "run_completed", "valid_signature": true}` queues a test delivery of that event to your registered URL and answers `202` with its `event_id`.

```bash curl theme={null}
curl -sS -X POST "$VORT_BASE/sandbox/webhooks/test" \
  -H "x-partner-key: $VORT_PARTNER_KEY" \
  -H "x-vort-org: $VORT_ORG" \
  -H "Content-Type: application/json" \
  -d '{"event":"run_completed","valid_signature":false}'
```

Set `valid_signature` to `false` to receive a delivery with a bad signature, which your handler MUST reject with `401` without processing it. Starting a run on the `cert-webhook` fixture queues that run's `run_completed` event. Use both for certification items C-18 and C-19. See [Test webhooks](/sandbox#test-webhooks) and [Send a test webhook](/api-reference/sandbox/test-webhook).
