Webhook API

Introduction

What is a webhook?

A webhook is a way to receive an event notification from a server. When certain events occur in CodeSignal, a JSON payload containing information about the event will be sent via an HTTP POST request to a specified endpoint URL.

What information is needed to create a webhook?

Each webhook needs the following information:
  • Endpoint URL: The URL that will receive POST payloads. This must be a valid URL that returns a 200 HTTP status after receiving each POST. When the webhook is created or updated, the URL will be POSTed with an empty payload, which must return a 200 status code within 10 seconds in order to successfully complete creating or updating the webhook.
  • Event types: The list of events the webhook will be notified about.
  • Secret key: A key used to sign every request, so that you can verify that it came from CodeSignal. We generate one automatically, and it can't be empty. See Authentication for how to verify signatures and which key formats are supported.
  • Owner emails: (optional) If the webhook cannot deliver its payload to the endpoint URL successfully, we will send a notification email to every provided email address. Read more about the retry policy below.
  • Custom headers: (optional) Custom HTTP request headers that we will send to your endpoint in addition to the signature headers. The following header names are reserved and can't be used (case-insensitive): Content-Type, X-CodeSignal-Signature, webhook-id, webhook-timestamp, and webhook-signature.

How do I create a webhook?

If you're not already using CodeSignal, start a conversation with us by requesting a demo.

If you're an existing customer, you can create, edit and view status of your webhooks from the Manage Webhooks page.
  • Go to the Manage Webhooks page to create a webhook, and hit the "add a webhook" button. Manage Webhooks page
  • Fill out all of the required fields, as well as optional ones if you need them. We will generate a Secret key automatically. You can replace it with a custom one, but we recommend keeping the generated key (see Secret key format). Create webhook modal
  • All newly created webhooks are initially active. Healthy webhook
  • A webhook that fails 25 times becomes "disabled". Disabled webhook
  • You can re-enable a webhook by updating its endpoint with a valid one.

Usage

Authentication

CodeSignal sends the following HTTP headers with every webhook request, so that you can verify that it came from CodeSignal and detect duplicate or replayed requests:
  • webhook-id: A unique identifier for the event. It stays the same when a delivery is retried, so you can use it to skip events you've already processed. If several of your webhooks are notified about the same event, CodeSignal sends the same webhook-id to each of their endpoints. Treat it as an opaque string, since its format may vary.
  • webhook-timestamp: When this delivery attempt was sent, in seconds since the Unix epoch. Each retry has a new timestamp.
  • webhook-signature: A signature of the event ID, timestamp, and full request body, as defined by the Standard Webhooks specification. This header is only sent if your secret key is in the Standard Webhooks format.
  • X-CodeSignal-Signature: A legacy signature that covers the endpoint URL, event type, and triggeredOn timestamp, but not the rest of the request body.
We recommend verifying webhook-signature, since it covers the full request body and supports replay detection.

Verifying webhook-signature

Since webhook-signature follows the Standard Webhooks specification, you can verify it with one of the official Standard Webhooks libraries, passing your full secret key, including the whsec_ prefix. For example, in Node.js:

import { Webhook } from 'standardwebhooks';

const webhook = new Webhook(secretKey);
// Throws if the signature is invalid or the timestamp is outside the allowed window
const event = webhook.verify(rawBody, request.headers);

Always verify against the raw request body, exactly as it was received. If you parse the JSON and serialize it again before verifying, even small formatting differences will make the signature fail to match. In Express, for example, use express.raw({ type: 'application/json' }) instead of express.json() for your webhook route.

To verify the signature without a library:
  1. Reject the request if any of the webhook-id, webhook-timestamp, or webhook-signature headers are missing, or if webhook-timestamp is too far from the current time (for example, more than five minutes), to protect against replayed requests.
  2. Remove the whsec_ prefix from your secret key and base64-decode the rest to get the signing key.
  3. Join the webhook-id header, the webhook-timestamp header, and the raw request body with periods (.) in between.
  4. Compute the HMAC-SHA256 of that string using the signing key, and base64-encode the result.
  5. The webhook-signature header contains one or more space-separated signatures, each in the form v1,<signature>. Compare your result with each v1 signature using a constant-time comparison.
import crypto from 'node:crypto';

function verifyWebhookSignature(secretKey, rawBody, headers) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!id || !timestamp || !signatureHeader) {
    return false;
  }

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 5 * 60) {
    return false;
  }

  const signingKey = Buffer.from(secretKey.slice('whsec_'.length), 'base64');
  const signedContent = `${id}.${timestamp}.${rawBody}`;
  const expected = crypto.createHmac('sha256', signingKey).update(signedContent).digest();

  return signatureHeader.split(' ').some((versionedSignature) => {
    const [version, signature = ''] = versionedSignature.split(',');
    const actual = Buffer.from(signature, 'base64');
    return (
      version === 'v1' &&
      actual.length === expected.length &&
      crypto.timingSafeEqual(actual, expected)
    );
  });
}

Secret key format

Secret keys generated by CodeSignal start with whsec_, followed by a base64-encoded random value, following the Standard Webhooks format. If you set a custom secret key that starts with whsec_, the rest of it must be standard, padded base64 (not URL-safe) that encodes at least 24 bytes. For example, you can generate one with echo "whsec_$(openssl rand -base64 32)".

If your secret key doesn't start with whsec_ (for example, if it was created before this format was introduced), your webhook will still receive webhook-id, webhook-timestamp, and X-CodeSignal-Signature, but not webhook-signature.

To start receiving webhook-signature, switch to a new key. The new key also changes X-CodeSignal-Signature, and CodeSignal starts using it as soon as you save the webhook, so follow these steps to avoid failed deliveries:
  1. Edit your webhook on the Manage Webhooks page, click "Regenerate key", and copy the new key. Don't save yet.
  2. Update your endpoint to accept signatures from either the old or new key.
  3. Save the webhook.
  4. Once deliveries are verifying with the new key, remove the old key from your endpoint.

If your webhook has an empty secret key, it keeps receiving events, but you need to generate a key before you can save changes to it.

Verifying X-CodeSignal-Signature (legacy)

CodeSignal continues to send the X-CodeSignal-Signature header, so existing integrations keep working. It's generated using the following algorithm:

import crypto from 'node:crypto';

function getSignature(secretKey, endpointUrl, { eventType = '', triggeredOn = '' }) {
  const plainText = `${endpointUrl}${eventType}${triggeredOn}`;
  const hash = crypto.createHmac('sha256', secretKey);
  hash.update(plainText);
  return hash.digest('hex');
}

Here, triggeredOn is the timestamp present in every event payload. This signature doesn't cover the rest of the request body and doesn't change between retries.

Event handling

For each webhook, we store a list of events the hook should be notified about. When events have been triggered, CodeSignal sends webhooks one event at a time, at a rate of about one per second. If a web hook needs to be notified about multiple events, events are guaranteed to be sent in the order they were triggered.

Errors and retry policy

If CodeSignal receives any response other than a 200 OK from the endpoint, the endpoint will be marked unhealthy, and CodeSignal will attempt to resend the event after a delay (up to 25 attempts total). Each retry has the same webhook-id header as the original attempt, and a new webhook-timestamp (see Authentication).

CodeSignal is looking for a 200 OK response from the endpoint URL in order to continue processing further events. Do not return 400 errors; they will not be respected and the webhook will be flagged as unhealthy. Instead, keep error-handling logic internal to your endpoint.

The delay is calculated using an exponential backoff strategy. In other words, the retry window will be short at first but will increase if the endpoint keeps failing. The delay between each failure and the next attempt is calculated with the formula below.

secondsToWait = (failCount - 1)^4 + 15 + (random(30) * failCount)

Assuming that random(30) always returns its average of 15, that would lead to the following retry attempts. (Not all retries are listed in the table, since this is just meant to give a sense of the retry backoff timeline.)

Total failed attempts Time until next attempt Time until next attempt, from first failure
1 30 seconds 30 seconds
2 46 seconds 1.3 minutes
3 1.2 minutes 2.5 minutes
4 2.6 minutes 5.1 minutes
6 12.2 minutes 23 minutes
11 2.8 hours 7.4 hours
14 8 hours 1 day
16 14 hours 2.1 days
24 3.2 days 16.6 days
25 n/a n/a

After the first few retries CodeSignal will also send an email notification to the owner emails associated with the webhook, if any. To make sure we don't spam you, CodeSignal will only send emails for each webhook after the 5th failure, the 14th failure, and every failure starting from the 16th.

When a webhook is unhealthy, events will be queued in the order they were triggered, and the queue will not be flushed until the first event in the queue can be processed successfully (by receiving a 200 OK response from the endpoint URL). If you are able to identify and resolve the issue with the endpoint URL, you can open the webhook settings modal, then test and save the fixed webhook in order to mark it as healthy again. Once it is marked as healthy, the webhook will immediately flush its queue of pending events.

After 25 consecutive failed delivery attempts, the webhook will be disabled and the events queue will be cleared. You can re-enable the webhook after updating the endpoint to return a valid 200 OK response.

Webhook payloads

All webhooks include the following fields in their payloads:
  • event: [string] The type of event
  • triggeredOn: [number] The timestamp of when the event was triggered, represented as number of milliseconds since the Unix epoch.
  • payload: [object] The list of events the webhook will be notified about.

Events for CodeSignal Assessments

Here is an overview of the end to end workflow of events for CodeSignal Assessments.


Diagram of webhooks end to end flow

Customer workflow


Diagram of webhooks triggered on customer flow

assessmentCreated

Fired when a new assessment is created.

Request format:
{
   event: 'assessmentCreated',
   triggeredOn: number,
   payload: {
     assessmentId: string,
     assessmentTitle: string,
     assessmentStatus: 'active' | 'archived' | 'deleted'
   }
 };
Sample request:
{
   event: 'assessmentCreated',
   triggeredOn: 1553720789347,
   payload: {
     assessmentId: 'lehu382hdleh29',
     assessmentTitle: 'General Coding Assessment',
     assessmentStatus: 'active'
   }
 };

assessmentUpdated

Fired when an assessment is updated.

Request format:
{
   event: 'assessmentUpdated',
   triggeredOn: number,
   payload: {
     assessmentId: string,
     assessmentTitle: string,
     assessmentStatus: 'active' | 'archived' | 'deleted'
   }
 };
Sample request:
{
   event: 'assessmentUpdated',
   triggeredOn: 1553720789347,
   payload: {
     assessmentId: 'lehu382hdleh29',
     assessmentTitle: 'General Coding Assessment',
     assessmentStatus: 'archived'
   }
 };

companyTestSessionCreated

Fired when a test session is created.

Request format:
{
   event: 'companyTestSessionCreated',
   triggeredOn: number,
   payload: {
     testSessionId: string,
     testId: string,
     testTitle: string,
     candidateEmail: string,
     candidateName: string,
     externalId?: string,
   }
 };
Sample request:
{
   event: 'companyTestSessionCreated',
   triggeredOn: 1553720789347,
   payload: {
     testSessionId: 'lehu382hdleh29',
     testId: 'dhey29hcl28hl28',
     testTitle: 'Software Engineer intern',
     candidateEmail: 'jane.doe@gmail.com',
     candidateName: 'Jane Doe'
   }
 };

Candidate workflow


Diagram of webhooks triggered by the CodeSignal Assessments product

preScreenCandidateDeclined

Fired when a candidate declines a test session invitation.

Request format:
{
  event: 'preScreenCandidateDeclined',
  triggeredOn: number,
  payload: {
    testSessionId: string,
    testId: string,
    testTitle: string,
    candidateEmail: string,
    candidateName: string,
    externalId?: string
  }
};
Sample request:
{
  event: 'preScreenCandidateDeclined',
  triggeredOn: 1553720789347,
  payload: {
    testSessionId: 'lehu382hdleh29',
    testId: 'dhey29hcl28hl28',
    testTitle: 'Software Engineer intern',
    candidateEmail: 'jane.doe@gmail.com',
    candidateName: 'Jane Doe'
  }
};

preScreenResultShared

Fired when a candidate shares their existing result with a test session invitation.

Request format:
{
  event: 'preScreenResultShared',
  triggeredOn: number,
  payload: {
    testSessionId: string,
    testId: string,
    testTitle: string,
    candidateEmail: string,
    candidateName: string,
    duration: number,
    score: number,
    maxScore: number | null | undefined,
    plagiarismLevel: number, // deprecated
    plagiarismLabel: 'none' | 'low' | 'medium' | 'high' | '-', // deprecated
    url: string,
    codingScore?: number | null, // deprecated; use versionedCodingScore instead
    versionedCodingScore?: {
      version: 'original' | 'codingScore2023',
      value: number,
    },
    externalId?: string
  }
};
Sample request:
{
  event: 'preScreenResultShared',
  triggeredOn: 1553720789347,
  payload: {
    testSessionId: 'lehu382hdleh29',
    testId: 'dhey29hcl28hl28',
    testTitle: 'Software Engineer intern',
    candidateEmail: 'jane.doe@gmail.com',
    candidateName: 'Jane Doe',
    duration: 3480000, // 58 minutes
    score: 900,
    maxScore: 1000,
    codingScore: 820,
    versionedCodingScore: {
      version: 'codingScore2023',
      value: 575
    },
    plagiarismLevel: 0,
    plagiarismLabel: '-',
    url: https://app.codesignal.com/test-result/oH3qeBC38oFsf7qB4?accessToken=A7HnBpab4aD7xm7Kp-iPhk2se5Wnxeg7x9GruANLzn
  }
};

preScreenExpired

Fired when a test session invitation cannot be fulfilled within the expiration window. The expiredReason field indicates whether the expiration is due to the test not being taken at all, or whether the test could not be certified by CodeSignal due to possibility/suspicion of cheating. If the latter, a rejectedReasons field will also be provided.

Request format:
{
  event: 'preScreenExpired',
  triggeredOn: number,
  payload: {
    testSessionId: string,
    testId: string,
    testTitle: string,
    candidateEmail: string,
    candidateName: string,
    externalId?: string,
    expiredReason: 'testNotTaken' | 'notCertified',
    rejectedReasons?: Array<string>,
  }
};
Sample request:
{
  event: 'preScreenExpired',
  triggeredOn: 1553720789347,
  payload: {
    testSessionId: 'lehu382hdleh29',
    testId: 'dhey29hcl28hl28',
    testTitle: 'Software Engineer intern',
    candidateEmail: 'jane.doe@gmail.com',
    candidateName: 'Jane Doe',
    expiredReason: 'notCertified',
    rejectedReasons: ['Unauthorized resource', 'Presence of others'],
  }
};

companyTestSessionStarted

Fired when a candidate starts a test session.

Request format:
{
  event: 'companyTestSessionStarted',
  triggeredOn: number,
  payload: {
    testSessionId: string,
    testId: string,
    testTitle: string,
    candidateEmail: string,
    candidateName: string,
    externalId?: string,
  }
};
Sample request:
{
  event: 'companyTestSessionStarted',
  triggeredOn: 1553720789347,
  payload: {
    testSessionId: 'lehu382hdleh29',
    testId: 'dhey29hcl28hl28',
    testTitle: 'Software Engineer intern',
    candidateEmail: 'jane.doe@gmail.com',
    candidateName: 'Jane Doe'
  }
};

preScreenResultVerificationPending

Fired when a candidate finishes a proctored test session.

Request format:
{
  event: 'preScreenResultVerificationPending',
  triggeredOn: number,
  payload: {
    testSessionId: string,
    testId: string,
    testTitle: string,
    candidateEmail: string,
    candidateName: string,
    duration: number,
    score: number,
    maxScore: number | null | undefined,
    codingScore?: number | null, // deprecated; use versionedCodingScore instead
    versionedCodingScore?: {
      version: 'original' | 'codingScore2023',
      value: number,
    },
    externalId?: string
  }
};
Sample request:
{
  event: 'preScreenResultVerificationPending',
  triggeredOn: 1553720789347,
  payload: {
    testSessionId: 'lehu382hdleh29',
    testId: 'dhey29hcl28hl28',
    testTitle: 'Software Engineer intern',
    candidateEmail: 'jane.doe@gmail.com',
    candidateName: 'Jane Doe',
    duration: 3480000, // 58 minutes
    score: 900,
    maxScore: 1000,
    codingScore: 820,
    versionedCodingScore: {
      version: 'codingScore2023',
      value: 575
    },
  }
};

companyTestSessionFinished

Fired when a non-proctored test session is finished.

Request format:
{
  event: 'companyTestSessionFinished',
  triggeredOn: number,
  payload: {
    testSessionId: string,
    testId: string,
    testTitle: string,
    candidateEmail: string,
    candidateName: string,
    duration: number, // milliseconds
    score: number,
    maxScore: number,
    codingScore?: number | null, // deprecated; use versionedCodingScore instead
    versionedCodingScore?: {
      version: 'original' | 'codingScore2023',
      value: number,
    },
    plagiarismLevel: number, // deprecated
    plagiarismLabel: 'none' | 'low' | 'medium' | 'high' | '-', // deprecated
    url: string,
    externalId?: string,
  }
};
Sample request:
{
  event: 'companyTestSessionFinished',
  triggeredOn: 1553720789347,
  payload: {
    testSessionId: 'lehu382hdleh29',
    testId: 'dhey29hcl28hl28',
    testTitle: 'Software Engineer intern',
    candidateEmail: 'jane.doe@gmail.com',
    candidateName: 'Jane Doe',
    duration: 3480000, // 58 minutes
    score: 900,
    maxScore: 1000,
    codingScore: 820,
    versionedCodingScore: {
      version: 'codingScore2023',
      value: 575
    },
    plagiarismLevel: 0,
    plagiarismLabel: '-',
    url: https://app.codesignal.com/test-result/oH3qeBC38oFsf7qB4?accessToken=A7HnBpab4aD7xm7Kp-iPhk2se5Wnxeg7x9GruANLzn
  }
};

preScreenIntegrityReviewUpdated

Fired when a non-proctored test session's integrity review status is updated.

Request format:
{
  event: 'preScreenIntegrityReviewUpdated',
  triggeredOn: number,
  payload: {
    testSessionId: string,
    testId: string,
    testTitle: string,
    candidateEmail: string,
    candidateName: string,
    integrityReviewSuggested: boolean,
    externalId?: string,
  }
};
Sample request:
{
  event: 'preScreenIntegrityReviewUpdated',
  triggeredOn: 1553720789347,
  payload: {
    testSessionId: 'lehu382hdleh29',
    testId: 'dhey29hcl28hl28',
    testTitle: 'Software Engineer intern',
    candidateEmail: 'jane.doe@gmail.com',
    candidateName: 'Jane Doe',
    integrityReviewSuggested: true
  }
};

CodeSignal Auditor workflow


Diagram of webhooks triggered on CodeSignal auditor flow

preScreenResultNotVerified

Fired when a test session is marked as not verified by CodeSignal. This means that either the session has been flagged for suspicious behavior, or a technical issue has been identified during the session (in which case the candidate will be automatically granted a retake).

Request format:
{
  event: 'preScreenResultNotVerified',
  triggeredOn: number,
  payload: {
    testSessionId: string,
    testId: string,
    testTitle: string,
    candidateEmail: string,
    candidateName: string,
    rejectedReasons?: Array<string>,
    externalId?: string,
  }
};
Sample request:
{
  event: 'preScreenResultNotVerified',
  triggeredOn: 1553720789347,
  payload: {
    testSessionId: 'lehu382hdleh29',
    testId: 'dhey29hcl28hl28',
    testTitle: 'Software Engineer intern',
    candidateEmail: 'jane.doe@gmail.com',
    candidateName: 'Jane Doe',
    rejectedReasons: ['Incomplete recording', 'Screen not visible],
  }
};

preScreenResultVerified

Fired when a proctored test session is verified by CodeSignal.

Request format:
{
  event: 'preScreenResultVerified',
  triggeredOn: number,
  payload: {
    testSessionId: string,
    testId: string,
    testTitle: string,
    candidateEmail: string,
    candidateName: string,
    duration: number, // milliseconds
    score: number,
    maxScore: number,
    codingScore?: number | null, // deprecated; use versionedCodingScore instead
    versionedCodingScore?: {
      version: 'original' | 'codingScore2023',
      value: number,
    },
    plagiarismLevel: number, // deprecated
    plagiarismLabel: 'none' | 'low' | 'medium' | 'high' | '-', // deprecated
    url: string,
    externalId?: string,
  }
};
Sample request:
{
  event: 'preScreenResultVerified',
  triggeredOn: 1553720789347,
  payload: {
    testSessionId: 'lehu382hdleh29',
    testId: 'dhey29hcl28hl28',
    testTitle: 'Software Engineer intern',
    candidateEmail: 'jane.doe@gmail.com',
    candidateName: 'Jane Doe',
    duration: 3480000, // 58 minutes
    score: 900,
    maxScore: 1000,
    codingScore: 820,
    versionedCodingScore: {
      version: 'codingScore2023',
      value: 575
    },
    plagiarismLevel: 0,
    plagiarismLabel: '-',
    url: https://app.codesignal.com/test-result/oH3qeBC38oFsf7qB4?accessToken=A7HnBpab4aD7xm7Kp-iPhk2se5Wnxeg7x9GruANLzn
  }
};

Events for CodeSignal Live Interviews

Live Interview webhooks process flow

Diagram of webhooks process flow for CodeSignal Live Interview

liveInterviewFinished

Fired when an interviewer finishes a live interview or when a live interview times out.

Request format:
{
  event: 'liveInterviewFinished',
  triggeredOn: number,
  payload: {
    interviewId: string,
    candidateName: string,
    candidateEmail: string,
    startDate: number,
    endDate: number,
    questionsSolved: number,
    questionsAttempted: number,
    url: string,
  }
};
Sample request:
{
  event: 'liveInterviewFinished',
  triggeredOn: 1553720789347,
  payload: {
    interviewId: 'zWve8sgiLJit6isiM',
    candidateName: 'Jane Doe',
    candidateEmail: 'jane.doe@gmail.com',
    startDate: 1727896812565,
    endDate: 1727896849868,
    questionsSolved: 1,
    questionsAttempted: 2,
    url: https://app.codesignal.com/client-dashboard/interviews?status=over&liveInterviewId=zWve8sgiLJit6isiM
  }
};

liveInterviewFeedbackUpdated

Fired when an interviewer adds or updates interview feedback.

Request format:
{
  event: 'liveInterviewFeedbackUpdated',
  triggeredOn: number,
  payload: {
    interviewId: string,
    interviewerId: string,
    feedback: {
      generalNotes: string,
      categories?: Array<{
        name: string,
        attributes: Array<{
          name: string,
          score?: number,
          notes?: string,
        }>,
      }>,
      recommendedLevel?: string,
      overallEvaluation?: string,
    }
  }
};
Sample request:
{
  event: 'liveInterviewFeedbackUpdated',
  triggeredOn: 1553720789347,
  payload: {
    interviewId: 'zWve8sgiLJit6isiM',
    interviewerId: 'qcmbuCuxpHELGYEJw',
    feedback: {
      generalNotes: 'Great candidate, very knowledgeable',
      categories: [
        {
          name: 'Problem Solving',
          attributes: [
            {
              name: 'Problem Solving',
              score: 4,
              notes: 'Great problem solving skills',
            },
            {
              name: 'Code Quality',
              score: 3,
            },
          ],
        },
        {
          name: 'Communication',
          attributes: [
            {
              name: 'Communication',
              score: 5,
              notes: 'Excellent communication skills',
            },
          ],
        },
      ],
      recommendedLevel: 'Senior',
      overallEvaluation: 'Strong Yes',
    }
  }
};