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": 6,
"completed_at": "2025-11-07T18:27:29Z",
"incentive_in_cents": 500,
"duration_in_minutes": 5
}
}Payload Fields:
-
id(integer): The participation ID. Use this to fetch full participation details via the List study participants endpoint. -
completed_at(string): ISO8601 timestamp when the participation was marked as completed. -
duration_in_minutes(integer or null): The duration of the participation in minutes. This can be:- The study's configured duration (most common case)
- A custom duration if the participation was rescheduled with a different time slot
nullfor edge cases where the participation was completed without booking/starting and the study has no configured duration
-
incentive_in_cents(integer or null): The incentive amount in cents (e.g.,5000for $50.00)- Will be
nullif no incentive is configured for the study.
- Will be
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 = 'chave';
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