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
- Click Settings in the top navigation bar.
- In the left sidebar under Developers, click Webhooks.
- You will see two tabs: Webhooks v2 (default) and Webhooks v1 (legacy).

Add a Webhook Endpoint

- On the Endpoints tab, click + Add Endpoint.
- Enter your Endpoint URL (e.g.
https://your-app.com/webhook). - Optionally add a Description to identify this endpoint.
- Under Subscribe to events, select the event types you want to receive — or leave all unchecked to receive every event.
- 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
- Go to Settings → Developers → Webhooks and open your endpoint.
- Under Signing secret, click the eye icon to reveal the secret (e.g.
whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw). - 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-signaturemay 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.