Introduction
Webhooks are a user-defined callback that is triggered by a business event within Great Question. For example, a webhook callback may notify you of a candidate's unsubscription.
In Great Question all webhook callbacks uses HTTP POST requests. A webhook subscription is a way for a web application to receive notifications about specific business events. We support the following events:
Events
Currently we support the following events:
candidate.unsubscribe
candidate.unsubscribeFired whenever a candidate clicks an Unsubscribe link, either on an email sent by Great Question or on the participant experience.
participation.completed
participation.completedTriggers when a participation is marked as completed. This can happen through:
- Manual completion via the UI
- Bulk completion operations
- API endpoint completion
- Integration-driven completions (Formsort, Qualtrics, AI moderation)
- Study closure with automatic completion
Payload
candidate.unsubscribe
candidate.unsubscribe{
"event": "candidate.unsubscribe",
"payload": {
"id": 7760157,
"email": "[email protected]",
"phone_number": "3948834098",
"unsubscribed_at": "2024-07-30T17:37:21Z",
"last_contacted_at": "2024-07-29T17:37:21Z"
}
}participation.completed
participation.completed{
"event": "participation.completed",
"payload": {
"id": 422958,
"status": "completed",
"completed_at": "2025-11-20T11:58:23Z",
"participant_email": "[email protected]",
"participant_name": "John Smith",
"duration_in_minutes": 30,
"incentive_in_cents": 5000,
"override_duration": false,
"candidate_event_url": "https://zoom.us/j/123456789?pwd=abc123",
"team_event_url": "https://zoom.us/j/987654321?pwd=def456",
"study": {
"id": 22340,
"title": "Customer Interview Study",
"research_goal": "Understand user onboarding pain points",
"status": "active",
"language": "en",
"style": "video_call",
"completed_participations_count": 12,
"duration_in_minutes": 30,
"currency": "USD",
"incentive_in_cents": 5000,
"incentive_method": "tremendous",
"incentive_title": "Amazon Gift Card",
"incentive_instructions": "Gift card will be sent via email within 24 hours",
"created_at": "2025-11-15T10:00:00Z",
"updated_at": "2025-11-20T11:58:23Z"
}
}
}Payload Fields:
Participation Fields
id— The participation ID. Use this to fetch full participation details via the List study participants endpoint.status— The participation status. Always "completed" for this event.completed_at— ISO8601 timestamp when the participation was marked as completed.participant_email— Email address of the participant who completed the study.participant_name— Full name of the participant who completed the study.duration_in_minutes— An integer or null value representing the actual participation duration in minutes. May differ from the study's default duration if overridden during scheduling or completion.incentive_in_cents— The reward amount in cents (e.g., 5000 = $50.00) for this specific participation. Can be overridden per participant via the API. Returns null if the study has no incentive configured.override_duration— Boolean flag indicating whether the participation duration differs from the study's default duration. Returnstruewhenduration_in_minutesdiffers fromstudy.duration_in_minutes,falseotherwise.candidate_event_url— The video conference join URL from the participant's calendar event. Returns null if no participant calendar event exists.team_event_url— The video conference join URL from the team's calendar event. Returns null if no team calendar event exists.
Study Object
study — Nested object containing complete study metadata:
id— The study ID.title— The study title.research_goal— The research objective or goal for the study. Returns null if not set.status— The study status. Possible values:active,draft,closed.language— The study language code (e.g., "en", "es", "fr").style— The study format. Possible values:video_call,unmoderated_test,survey,diary_study.completed_participations_count— The total number of completed participations for this study.duration_in_minutes— The study's default duration in minutes. Returns null if not configured.currency— The currency code for incentive amounts (e.g., "USD", "EUR", "GBP").incentive_in_cents— The study's default incentive amount in cents. Returns null if no incentive is configured.incentive_method— The incentive delivery method. Possible values:manual,tremendous,coupon,product,other, or null.incentive_title— Custom incentive title (e.g., "Amazon Gift Card"). Returns null if not set.incentive_instructions— Custom instructions for incentive delivery. Returns null if not set.created_at— ISO8601 timestamp when the study was created.updated_at— ISO8601 timestamp when the study was last updated.
Setting up
Please contact [email protected] to have a webhook endpoint set up.
Security
Each outgoing webhook request is signed. You should verify that any request you handle was sent by Great Question and has not been forged or tampered with. You should not process any requests with signatures that fail verification.
Signature header X-Signature-SHA256
X-Signature-SHA256Signatures are generated using an HMAC key and SHA256 digest of the message body. They are transmitted using the X-Signature-SHA256 request header and are Base64 encoded.
Validating signature
Here's an example with Ruby 3:
require 'openssl'
signature_header = "t=1722365103,v1=752608330d6906c09c4e2b742d5d5de5ffb3c1719ae7726afff30c8cb559d4e2"
encryption_key = 'provided-encryption-key'
event_payload = "{\"event\":\"candidate.unsubscribe\",\"payload\":{\"event\":\"candidate.unsubscribe\",\"payload\":{\"id\":7760157,\"email\":\"[email protected]\",\"phone_number\":\"3948834098\",\"unsubscribed_at\":\"2024-07-30T17:37:21Z\",\"last_contacted_at\":\"2024-07-29T17:37:21Z\"}}}"
# parsing signature header
parsed_signature = signature_header.split(',').map { |x| x.split('=') }.to_h.tap do |hash|
hash['t'] = hash['t'].to_i
end
#=> {
# "t": 1722365103,
# "vl": "752608330d6906c09c4e2b742d5d5de5ffb3c1719ae7726afff30c8cb559d4e2"
#}
timestamped_payload = "#{parsed_signature.fetch('t')}.#{event_payload}"
# validating signature
payload_verification = OpenSSL::HMAC.hexdigest('sha256', encryption_key, timestamped_payload)
signature = parsed_signature['v1']
identical = OpenSSL.secure_compare(payload_verification, signature)
raise 'Signature verification failed' unless identical
And an example with Node:
const crypto = require('crypto');
/**
* Parses the signature header into an object.
* @param {string} signatureHeader - The signature header string.
* @returns {Object} - The parsed signature object.
*/
function parseSignatureHeader(signatureHeader) {
return signatureHeader.split(',').reduce((acc, part) => {
const [key, value] = part.split('=');
acc[key] = key === 't' ? parseInt(value, 10) : value;
return acc;
}, {});
}
/**
* Generates an HMAC SHA-256 hash.
* @param {string} key - The encryption key.
* @param {string} data - The data to be hashed.
* @returns {string} - The generated hash.
*/
function generateHmacSha256(key, data) {
return crypto.createHmac('sha256', key).update(data).digest('hex');
}
/**
* Validates the signature.
* @param {string} signature - The provided signature.
* @param {string} payloadVerification - The computed hash.
* @returns {boolean} - True if the signature is valid, false otherwise.
*/
function isValidSignature(signature, payloadVerification) {
try {
return crypto.timingSafeEqual(Buffer.from(payloadVerification, 'hex'), Buffer.from(signature, 'hex'));
} catch (err) {
// Handling the error if the buffer lengths do not match
return false;
}
}
// Main function to validate the event payload
function validateEventPayload(signatureHeader, encryptionKey, eventPayload) {
const parsedSignature = parseSignatureHeader(signatureHeader);
const timestampedPayload = `${parsedSignature['t']}.${eventPayload}`;
const payloadVerification = generateHmacSha256(encryptionKey, timestampedPayload);
return isValidSignature(parsedSignature['v1'], payloadVerification);
}
// Example usage
const signatureHeader = "t=1722362123,v1=95f212a30646c2ace94e6f165577210d92048317e2069c6ecc5e28654a106185";
const encryptionKey = '<your-encryption-key>';
const eventPayload = "{\"event\":\"candidate.unsubscribe\",\"payload\":{\"id\":7770147,\"email\":\"[email protected]\",\"phone_number\":null,\"unsubscribed_at\":\"2024-07-30T17:55:23Z\",\"last_contacted_at\":null}}";
const isValid = validateEventPayload(signatureHeader, encryptionKey, eventPayload);
console.log(isValid);
// => returns false if invalid and true if valid