How to Set Up and Use Webhooks in Testlify

Webhooks enable Testlify to send real-time HTTP POST notifications to a URL you specify whenever key events occur — such as a candidate being invited, completing an assessment, or being rejected. Use webhooks to integrate Testlify with your ATS, HRIS, or automation tools without polling.

Testlify now uses Webhooks v2 as the default. If you set up webhooks before December 25th, 2025, your endpoints are on the legacy system. See the Migration Guide to move your endpoints to v2.

Before you begin

  • You need Admin or Developer access in your Testlify workspace.
  • Have a publicly accessible HTTPS endpoint ready to receive webhook payloads.
  • Optional: use Svix Play to generate a test URL and inspect payloads before connecting a real server.

Navigate to Webhooks

  1. Click Settings in the top navigation bar.
  2. In the left sidebar under Developers, click Webhooks.
  3. You will see two tabs: Webhooks v2 (default) and Webhooks v1 (legacy).


Add a Webhook Endpoint

  1. On the Endpoints tab, click + Add Endpoint.
  2. Enter your Endpoint URL (e.g. https://your-app.com/webhook).
  3. Optionally add a Description to identify this endpoint.
  4. Under Subscribe to events, select the event types you want to receive — or leave all unchecked to receive every event.
  5. Click Save.

Tip: Test your endpoint with Svix Play to inspect live payloads before wiring up your production server.


Edit or Delete an Endpoint

  • Edit: Click on an endpoint to open its settings. Update the URL, description, or subscribed events, then click Save.
  • Delete: Open the endpoint and select the delete option to permanently remove it from your workspace.

Webhook Events

Webhooks v2 events are grouped into two categories:

Assessment Events

  • assessment.archived — fired when an assessment is archived
  • assessment.created — fired when a new assessment is created
  • assessment.deleted — fired when an assessment is deleted
  • assessment.updated — fired when an assessment is updated

Candidate Events

  • candidate.completed — fired when a candidate finishes the assessment
  • candidate.disqualified — fired when a candidate is disqualified
  • candidate.enrolled — fired when a candidate starts an assessment
  • candidate.in_progress — fired after enrollment, once the candidate views the first question
  • candidate.invitation_expired — fired when a candidate's invite expires
  • candidate.invited — fired when a candidate is invited to an assessment
  • candidate.invited_for_interview — fired when a candidate is invited for an interview
  • candidate.rejected — fired when a candidate is rejected
  • candidate.score_updated — fired when a candidate's score is manually adjusted

Browse all events, full schemas, and example payloads in the Event Catalog tab or at hooks.testlify.com.


Logs

The Logs tab shows a full message log for all events sent to your endpoints. Each entry displays:

  • Event type (e.g. assessment.created, candidate.invited)
  • Message ID
  • Timestamp

Click any entry to view the full payload and the per-endpoint delivery status (Succeeded / Failed).


Activity

The Activity tab shows a historical delivery attempts chart over the last 6 hours, with totals for Successful and Failed attempts. Use it to monitor your webhook integration health at a glance.


Event Payload Example

Webhooks v2 payloads use a richer, nested data structure compared to v1. Below is a sample payload for the assessment.created event:

{
  "data": {
    "assessment": {
      "defaultLanguage": "en",
      "jobRoleId": "62f617d8ded0939121459181",
      "jobRoleName": "Backend Engineer",
      "language": "English",
      "name": "Backend Engineer",
      "title": "Backend Engineer"
    },
    "assessmentId": "69ae8827028c1d180671148e",
    "configuration": {
      "customSnapshotInterval": 120,
      "disableMobileAndTabletDevices": false,
      "enableInstructions": true,
      "enableNavigationToPreviousQuestions": true,
      "forceFullScreen": false,
      "generateAiInsight": true,
      "ipProctoringEnabled": false
    },
    "orgId": "6347ccdc9f898bd2b56cce42",
    "qualificationQuestions": [],
    "status": {
      "assessmentStatus": "DRAFT",
      "isArchived": false
    },
    "testLibraries": []
  },
  "event": "created",
  "success": true,
  "type": "assessment"
}

For full schemas and example payloads for all 13 event types, visit the Event Catalog.


Verify Webhook Signatures

Every Webhooks v2 delivery is signed so you can confirm it came from Testlify and not a forged request. Signatures follow the Svix-compatible HMAC-SHA256 scheme.

Find Your Signing Secret

  1. Go to Settings → Developers → Webhooks and open your endpoint.
  2. Under Signing secret, click the eye icon to reveal the secret (e.g. whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw).
  3. Store this secret in your application's environment variables. Never commit it to source control.

The signing secret is unique per endpoint. Rotating an endpoint generates a new secret while the old one remains valid for a 24-hour overlap, allowing zero-downtime rotation.

Headers Sent with Every Delivery

Header Description
svix-id Unique message identifier — use it to deduplicate retries
svix-timestamp Unix timestamp (seconds) when the message was sent
svix-signature Space-separated list of v1,<base64-signature> pairs (multiple appear during secret rotation)

How to Verify

The signed payload is: {svix-id}.{svix-timestamp}.{raw-request-body}. Compute an HMAC-SHA256 of that string using the secret bytes (the part after whsec_, base64-decoded), then compare against any signature in svix-signature.

Always use a constant-time comparison function and reject requests where the timestamp is older than 5 minutes to prevent replay attacks.

Node.js (Svix SDK — recommended)

npm install svix
import { Webhook } from "svix";

const secret = process.env.TESTLIFY_WEBHOOK_SECRET;

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const wh = new Webhook(secret);
  try {
    const payload = wh.verify(req.body, {
      "svix-id": req.headers["svix-id"],
      "svix-timestamp": req.headers["svix-timestamp"],
      "svix-signature": req.headers["svix-signature"],
    });
    // payload is the parsed, verified event
    res.status(200).send("ok");
  } catch (err) {
    res.status(400).send("invalid signature");
  }
});

Node.js (Manual Verification)

import crypto from "crypto";

function verify(secret, headers, rawBody) {
  const id = headers["svix-id"];
  const ts = headers["svix-timestamp"];
  const sigHeader = headers["svix-signature"];

  const tolerance = 5 * 60;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false;

  const secretBytes = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const signedPayload = `${id}.${ts}.${rawBody}`;
  const expected = crypto.createHmac("sha256", secretBytes)
    .update(signedPayload)
    .digest("base64");

  return sigHeader.split(" ").some(part => {
    const [, sig] = part.split(",");
    return sig && crypto.timingSafeEqual(
      Buffer.from(sig, "base64"),
      Buffer.from(expected, "base64")
    );
  });
}

Python (Svix SDK)

pip install svix
from svix.webhooks import Webhook, WebhookVerificationError

secret = os.environ["TESTLIFY_WEBHOOK_SECRET"]

@app.post("/webhook")
def handle(request):
    try:
        payload = Webhook(secret).verify(request.body, request.headers)
        return "ok", 200
    except WebhookVerificationError:
        return "invalid signature", 400

Python (Manual)

import hmac, hashlib, base64, time

def verify(secret, headers, raw_body):
    msg_id = headers["svix-id"]
    ts = headers["svix-timestamp"]
    sig_header = headers["svix-signature"]

    if abs(time.time() - int(ts)) > 5 * 60:
        return False

    secret_bytes = base64.b64decode(secret.removeprefix("whsec_"))
    signed_payload = f"{msg_id}.{ts}.{raw_body}".encode()
    expected = base64.b64encode(
        hmac.new(secret_bytes, signed_payload, hashlib.sha256).digest()
    ).decode()

    for part in sig_header.split(" "):
        _, sig = part.split(",", 1)
        if hmac.compare_digest(sig, expected):
            return True
    return False

Common Gotchas

  • Verify the raw body, not the parsed JSON. Re-serializing changes whitespace and breaks the signature. In Express, use express.raw() on the webhook route.
  • svix-signature may contain multiple signatures (space-separated) during secret rotation. Accept the request if any signature matches.
  • Reject stale timestamps. Without a tolerance check, an attacker who captures a signed request can replay it indefinitely.
  • Use constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest) to prevent timing attacks.

Need help? Contact support.

Did this answer your question? Thanks for the feedback There was a problem submitting your feedback. Please try again later.

Still need help? Contact Us Contact Us