Overview
Webhooks allow you to receive real-time notifications when events occur in the Gather API. This guide walks you through setting up and handling webhooks.
Setting Up Webhooks
Webhooks are configured per organization/team via the Gather API’s webhook configuration endpoints (/qsi/gather/webhooks — create, list, get, update, delete). See the Webhooks API reference for events and delivery details.
Configuration Requirements
Webhook URL : Your endpoint URL that will receive webhook events
Event Types : Which events you want to receive
Webhook Secret : Secret key for signature verification
Webhook Endpoint Setup
Your webhook endpoint should:
Accept POST requests
Respond quickly (within 5 seconds)
Return appropriate HTTP status codes
Verify webhook signatures
Basic Endpoint Structure
JavaScript (Express)
Python (Flask)
const express = require ( 'express' );
const crypto = require ( 'crypto' );
const app = express ();
const WEBHOOK_SECRET = process . env . WEBHOOK_SECRET ;
// Use raw body for signature verification
app . use ( express . json ({
verify : ( req , res , buf ) => {
req . rawBody = buf ;
}
}));
function verifySignature ({ rawBody , signature }) {
const computedSignature = crypto
. createHmac ( 'sha256' , WEBHOOK_SECRET )
. update ( rawBody )
. digest ( 'hex' );
return crypto . timingSafeEqual (
Buffer . from ( computedSignature ),
Buffer . from ( signature )
);
}
app . post ( '/webhooks/qualifi' , ( req , res ) => {
const signature = req . headers [ 'x-qualifi-signature' ];
const event = req . headers [ 'x-qualifi-event' ];
if ( ! verifySignature ({ rawBody: req . rawBody , signature })) {
return res . status ( 401 ). send ( 'Invalid signature' );
}
const payload = req . body ;
switch ( event ) {
case 'candidate_interview.completed' :
handleInterviewCompleted ( payload );
break ;
case 'candidate_interview.status_changed' :
handleStatusChanged ( payload );
break ;
case 'question.audio_generated' :
handleAudioGenerated ( payload );
break ;
// ... other events
}
res . status ( 200 ). send ( 'OK' );
});
function handleInterviewCompleted ( payload ) {
console . log ( 'Interview completed:' , payload . candidateInterviewId );
// Process the completed interview
}
function handleStatusChanged ( payload ) {
console . log ( 'Status changed:' , payload . candidateInterviewId , payload . status );
}
app . listen ( 3000 );
from flask import Flask, request, jsonify
import hmac
import hashlib
import os
app = Flask( __name__ )
WEBHOOK_SECRET = os.environ.get( 'WEBHOOK_SECRET' )
def verify_signature ( raw_body : bytes , signature : str ) -> bool :
computed_signature = hmac.new(
WEBHOOK_SECRET .encode(),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed_signature, signature)
@app.route ( '/webhooks/qualifi' , methods = [ 'POST' ])
def webhook ():
signature = request.headers.get( 'X-Qualifi-Signature' )
event = request.headers.get( 'X-Qualifi-Event' )
if not verify_signature(request.get_data(), signature):
return jsonify({ 'error' : 'Invalid signature' }), 401
payload = request.get_json()
if event == 'candidate_interview.completed' :
handle_interview_completed(payload)
elif event == 'candidate_interview.status_changed' :
handle_status_changed(payload)
elif event == 'question.audio_generated' :
handle_audio_generated(payload)
return jsonify({ 'status' : 'OK' }), 200
def handle_interview_completed ( payload ):
print ( f "Interview completed: { payload[ 'candidateInterviewId' ] } " )
def handle_status_changed ( payload ):
print ( f "Status changed: { payload[ 'candidateInterviewId' ] } -> { payload[ 'status' ] } " )
if __name__ == '__main__' :
app.run( port = 3000 )
Handling Events
Interview Completed
When a candidate completes an interview, the request includes X-Qualifi-Event: candidate_interview.completed and a flat JSON body:
{
"candidateInterviewId" : "uuid" ,
"candidateId" : "uuid" ,
"interviewId" : "uuid" ,
"completedAt" : "2024-01-01T00:00:00.000Z"
}
Action : Fetch interview results and process them:
async function handleInterviewCompleted ( payload ) {
const results = await fetch (
`https://api.prod.qualifi.hr/qsi/gather/candidate-interviews/ ${ payload . candidateInterviewId } /results` ,
{
headers: {
'x-api-key' : apiKey
}
}
);
const interviewResults = await results . json ();
// Process results: save to database, send notifications, etc.
}
Audio Generated
When question audio generation completes (X-Qualifi-Event: question.audio_generated):
{
"aiAudioVersionId" : "uuid" ,
"batchId" : "uuid" ,
"narratorId" : "uuid" ,
"audioUrl" : "https://..." ,
"text" : "Question text" ,
"createdAt" : "2024-01-01T00:00:00.000Z"
}
Action : Update your system with the audio URL.
Status Changed
When candidate interview status changes (X-Qualifi-Event: candidate_interview.status_changed):
{
"candidateInterviewId" : "uuid" ,
"candidateId" : "uuid" ,
"interviewId" : "uuid" ,
"status" : "new_response" ,
"previousStatus" : null ,
"updatedAt" : "2024-01-01T00:00:00.000Z"
}
Action : Update your system’s status tracking. Use status for the new value; previousStatus may be null if the prior status was not captured.
When status changes to new_response, you may receive both candidate_interview.status_changed and candidate_interview.completed webhooks.
Best Practices
Verify Signatures : Always verify webhook signatures to ensure authenticity
Idempotency : Handle duplicate webhook deliveries gracefully using event IDs or timestamps
Quick Response : Respond to webhooks quickly (within 5 seconds) to avoid retries
Error Handling : Return appropriate HTTP status codes (200 for success, 4xx/5xx for errors)
Logging : Log all webhook events for debugging and auditing
Async Processing : Process webhook data asynchronously if operations take time
Retry Logic
The Gather API retries failed webhook deliveries:
First attempt : Immediate
Second attempt : After 1 minute
Third attempt : After 5 minutes
If all retry attempts fail, the webhook delivery is marked as failed. Ensure your endpoint is available and handles errors gracefully.
Testing Webhooks
Use a tool like ngrok to test webhooks locally:
# Start your local server
npm start
# In another terminal, expose it via ngrok
ngrok http 3000
# Use the ngrok URL as the webhook URL in your webhook configuration
Webhooks API Complete webhook API reference
Error Handling Learn about error handling best practices
Best Practices Review API best practices