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 only one kind of event: candidate.unsubscribe which is fired whenever a candidate clicks an Unsubscribe link, either on an email sent by Great Question or on the participant experience.

Payload

Here's an example of a payload sent from the candidate.unsubscribe event:

{
  "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"
  }
}

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

Signatures 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