This reference covers what an assessment provider must build to deliver a production-grade integration with Workable ATS or Carerix. It targets engineers responsible for the connector layer: authentication, identifier mapping, invitation triggering, results synchronization, webhook security, and GDPR-aligned data handling.
The integration lifecycle follows a consistent pattern regardless of which ATS you target:
The ATS is the source of truth for all candidate and job records. Your platform holds the assessment session state; it does not replicate candidate profiles.
Before writing any code, confirm all of the following:
candidate_id, job_id, and your own invitation_id in a durable store and look them up on every incoming eventWorkable's Assessment Provider API uses a static access token passed in the Authorization HTTP header. Per Workable's "Assessment Providers" developer documentation (workable.readme.io), every inbound call from Workable to your endpoint is authenticated this way:
Authorization: Bearer <your_access_token>
You receive this token when your integration is provisioned through the Workable partner program. Rotate the token at least every 90 days or immediately on suspected compromise. Store it in a secrets manager (AWS Secrets Manager, HashiCorp Vault, or equivalent) rather than in environment variables on shared hosts.
On your side, validate the token on every request before processing any payload. Return 401 Unauthorized for missing or invalid tokens. Workable's own error-handling spec expects HTTP status codes including 400, 401, 409, and 422 with a structured error body.
{
"error": {
"code": "INVALID_TOKEN",
"message": "The provided bearer token is not recognized."
}
}
Carerix builds integrations through its official partner program and exposes a GraphQL API for approved partners. The exact authentication contract (OAuth 2.0 client credentials or API key) is confirmed during partner onboarding. If you are building a new connector, the starting point is the Carerix "Become an Integration Partner" page, which documents the enrollment process and expected timeline.
Operationally, assume GraphQL mutations for write operations (creating an assessment invitation record, updating a candidate) and queries for reads (fetching candidate or vacancy data). All traffic must be TLS 1.2 or higher.
A robust mapping contract prevents orphaned assessment sessions and duplicate invitations. These are the required identifier pairs your data store must maintain.
candidate.id applicant_id Primary key for all candidate operations job.shortcode role_id Links to the assessment template configured for that role stage.name trigger_stage The stage value that fires the invitation invitation_id (your field) session_id Must be stored and echoed in all result payloadsFor the invitation payload, you only need to transfer the fields required to send the assessment: first_name, last_name, email, and language. Do not forward fields like cover_letter, resume_url, CV text content, or any custom screening question answers unless your integration explicitly requires them and the candidate has consented to that scope of sharing.
In Carerix's GraphQL schema, the analogous objects are Candidate and Vacancy. Map:
Candidate.id to your applicant_idVacancy.id to your role_idinvitation_id returned from your platformCarerix uses string-based GUIDs for its object identifiers. Confirm the exact field names during partner onboarding, as the GraphQL schema evolves with platform updates.
Send only what is needed. The invitation delivery requires email and optionally first_name and preferred_language. Everything else (date of birth, address, nationality, custom questionnaire responses) stays in the ATS. Your platform should never store those fields even if they appear in the inbound payload.
Workable supports candidate event subscriptions via its /subscriptions endpoint. You register a webhook subscriber with filters scoped to stage-change events. The subscription args let you filter by event type, so you can target only candidate_moved or equivalent stage-transition events without receiving every candidate activity.
When a candidate moves into the configured assessment stage, Workable sends a POST to your registered callback URL. The payload includes at minimum:
{
"event_type": "candidate_moved",
"event_id": "evt_01HXYZ",
"candidate": {
"id": "cand_7890",
"name": "Alex van der Berg",
"email": "[email protected]"
},
"job": {
"id": "job_4567",
"shortcode": "DEV001"
},
"stage": {
"name": "Assessment",
"previous_name": "Phone Screen"
}
}
On receiving this event, your service looks up the assessment template mapped to job.shortcode, creates an invitation, and records the invitation_id against candidate.id + job.id in your store. Respond 200 OK within 5 seconds. Do all heavy processing asynchronously.
At minimum, your invitation creation call should accept:
applicant_id (mapped from ATS candidate ID)role_id (mapped from ATS job ID)emaillanguage (default to en if not available)assessment_template_id (resolved from your stage-to-template config)Optional parameters that should be configurable per client:
allow_retake (boolean, default false)proctoring_enabled (boolean)expiry_hours (integer, e.g., 72)If the candidate is moved back out of the assessment stage or disqualified, your integration must:
invitation_id exists for that candidate_id + job_id pairIf the candidate re-enters the stage (re-invite scenario), generate a new invitation_id and archive the previous one. Do not reuse invitation identifiers. Enforce this with a unique constraint at the database level.
Support both patterns if possible. Push (your platform POSTs results to a Workable-registered callback) is preferred for latency, but some ATS configurations rely on polling. Workable's Assessment Providers documentation confirms results can be received via callback or polling.
A standard result payload from your platform should include:
{
"event_type": "assessment_completed",
"event_id": "res_evt_9921",
"invitation_id": "inv_3344",
"attempt_id": "att_5566",
"candidate_id": "cand_7890",
"job_id": "job_4567",
"status": "completed",
"completed_at": "2026-09-09T14:33:00Z",
"scores": {
"overall": 72,
"dimensions": {
"cognitive_ability": 68,
"conscientiousness": 81,
"verbal_reasoning": 74
}
},
"report_url": "https://app.assessmentplatform.com/reports/att_5566"
}
The status field should use a controlled vocabulary: completed, abandoned, expired, failed. The ATS side maps these to display labels; your contract should never rely on free-text status strings.
For Workable, POST the results payload to the registered callback URL provided during assessment creation, or via the Assessment Provider results endpoint. The ATS then surfaces scores on the candidate profile, which is the workflow Workable supports for providers like Test Partnership (results appear on the candidate's Workable profile).
For Carerix, use a GraphQL mutation to update the candidate record with a custom assessment field or a structured note containing the scores and report URL.
Use Workable's /subscriptions endpoint to register your callback URL. Workable's webhook subscription reference confirms that subscription args filter the event stream, so set filters to only receive events relevant to your integration (stage changes for jobs where your assessment template is active).
Every inbound webhook from your platform to the ATS, and every inbound callback from the ATS to your endpoint, must be signed. Use HMAC-SHA256:
import hmac
import hashlib
def verify_signature(payload_bytes: bytes, received_sig: str, secret: str) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
payload_bytes,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received_sig)
Include a X-Webhook-Timestamp header and reject payloads where the timestamp is more than 5 minutes old. This prevents replay attacks.
Workable and most ATS platforms retry failed webhook deliveries. Your endpoint must be idempotent: processing the same event_id twice must produce the same outcome without side effects (no duplicate invitations, no duplicate result writes).
Implementation pattern:
event_id to a deduplication table with a TTL of at least 24 hoursevent_id already exists, return 200 OK immediately without processingcurl -X POST https://callback.workable.com/assessment-results \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=<hmac_value>" \
-H "X-Webhook-Timestamp: 2026-09-09T14:33:01Z" \
-d '{
"invitation_id": "inv_3344",
"candidate_id": "cand_7890",
"status": "completed",
"scores": { "overall": 72 }
}'
curl -X GET https://api.assessmentplatform.com/v1/templates \
-H "Authorization: Bearer <your_access_token>"
Response:
{
"templates": [
{ "id": "tmpl_001", "name": "Cognitive + Personality Bundle", "language": "en" },
{ "id": "tmpl_002", "name": "Logistiek instapniveau NL", "language": "nl" }
]
}
curl -X POST https://api.assessmentplatform.com/v1/invitations \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-d '{
"applicant_id": "cand_7890",
"role_id": "job_4567",
"template_id": "tmpl_001",
"email": "[email protected]",
"first_name": "Alex",
"language": "en",
"expiry_hours": 72
}'
Response:
{
"invitation_id": "inv_3344",
"status": "pending",
"invitation_url": "https://app.assessmentplatform.com/start/inv_3344",
"expires_at": "2026-09-12T14:33:00Z"
}
const crypto = require("crypto");
const express = require("express");
const app = express();
app.use(express.raw({ type: "application/json" }));
app.post("/webhooks/ats", (req, res) => {
const sig = req.headers["x-webhook-signature"];
const ts = req.headers["x-webhook-timestamp"];
const age = Date.now() - new Date(ts).getTime();
if (age > 5 * 60 * 1000) return res.status(400).send("Timestamp too old");
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.replace("sha256=", "")))) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body);
// Idempotency check, then enqueue for processing
res.sendStatus(200);
});
Run through each scenario in a staging environment before enabling production traffic.
Test case Expected outcome Valid token, correct payload200 OK, invitation created Expired or invalid token 401 Unauthorized, no invitation created Candidate ID not found in ATS 404 Not Found, error logged Job/stage not mapped to a template 422 Unprocessable Entity, alert fired Duplicate event_id received 200 OK, no duplicate invitation Webhook signature mismatch 401 Unauthorized, request rejected Results push times out (5xx from ATS) Retry with exponential backoff Candidate disqualified mid-assessment Invitation cancelled, session expired Out-of-order result event (arrived before invitation record) Hold in queue for 30s, re-attempt lookup401 All requests rejected Invalid or rotated bearer token Regenerate token via partner admin; check secrets manager sync 404 Invitation creation fails candidate_id or job_id not present in your mapping store Verify webhook received the stage-change event; check dedup table for missed events 409 Duplicate invitation rejected Same candidate_id + job_id already has an active invitation Cancel existing before creating new; or return the existing invitation_id 422 Payload validation error Missing required field or invalid enum value Inspect error body for field-level detail; check status vocabulary against spec 5xx Results callback failing ATS endpoint down or misconfigured callback URL Engage Workable partner support; hold results in dead-letter queue pending resolutionFor failed deliveries still in the dead-letter queue after 24 hours, your admin UI should expose a manual replay button scoped to individual invitation_id records, and an export of pending result payloads for manual insertion if needed.
The only fields that must travel from the ATS to your assessment platform to create an invitation are email, first_name (for the email greeting), and preferred_language. Everything else remains in the ATS. If your platform cannot operate without additional fields, document the justification under GDPR Article 6 before enabling the transfer.
Do not store raw CV text, screening question answers, or recruiter evaluation notes. These fields may appear in some ATS webhook payloads. Strip them at the ingestion layer before they reach your database.
Two consent events must be logged in your audit trail with a UTC timestamp:
Log the mechanism (ATS consent field, in-product checkbox, etc.) alongside the timestamp.
For EU-based employers, personal data processed through your integration should be stored within the EU. Selection Lab, for example, stores all personal data in Frankfurt and applies local LLMs to strip personally identifying information from conversational intake data before it reaches any scoring model. This design pattern is worth replicating: any AI-assisted scoring component should operate on anonymized or pseudonymized inputs wherever technically feasible.
Retention periods should be configured per client and enforced automatically. A reasonable default is 12 months from the assessment completion date, after which assessment records are deleted or anonymized. Clients in regulated industries may require shorter windows; build this as a configurable parameter, not a hard-coded constant.
If any component of your assessment platform uses an AI model to generate scores or recommendations, it qualifies as a high-risk AI system under Annex III of the EU AI Act (employment-related decisions). Required controls include:
Platforms like Selection Lab address this by combining transparent, dimension-level scoring reports with explainable AI outputs that recruiters can review directly in the ATS candidate view. This traceability is what EU AI Act compliance concretely requires at the integration layer: every automated score that influences a hiring decision must be auditable end-to-end.
A complete integration covering authentication, identifier mapping, stage-triggered invitations, results synchronization, webhook security, and GDPR-compliant data handling typically takes 2 to 10 weeks depending on the complexity of the ATS environment and the number of assessment templates to configure. Build in at least one week for end-to-end testing against a staging ATS tenant before enabling production traffic.

This reference covers what an assessment provider must build to deliver a production-grade integration with Workable ATS or Carerix. It targets engineers responsible for the connector layer: authentication, identifier mapping, invitation triggering, results synchronization, webhook security, and GDPR-aligned data handling.
The integration lifecycle follows a consistent pattern regardless of which ATS you target:
The ATS is the source of truth for all candidate and job records. Your platform holds the assessment session state; it does not replicate candidate profiles.
Before writing any code, confirm all of the following:
candidate_id, job_id, and your own invitation_id in a durable store and look them up on every incoming eventWorkable's Assessment Provider API uses a static access token passed in the Authorization HTTP header. Per Workable's "Assessment Providers" developer documentation (workable.readme.io), every inbound call from Workable to your endpoint is authenticated this way:
Authorization: Bearer <your_access_token>
You receive this token when your integration is provisioned through the Workable partner program. Rotate the token at least every 90 days or immediately on suspected compromise. Store it in a secrets manager (AWS Secrets Manager, HashiCorp Vault, or equivalent) rather than in environment variables on shared hosts.
On your side, validate the token on every request before processing any payload. Return 401 Unauthorized for missing or invalid tokens. Workable's own error-handling spec expects HTTP status codes including 400, 401, 409, and 422 with a structured error body.
{
"error": {
"code": "INVALID_TOKEN",
"message": "The provided bearer token is not recognized."
}
}
Carerix builds integrations through its official partner program and exposes a GraphQL API for approved partners. The exact authentication contract (OAuth 2.0 client credentials or API key) is confirmed during partner onboarding. If you are building a new connector, the starting point is the Carerix "Become an Integration Partner" page, which documents the enrollment process and expected timeline.
Operationally, assume GraphQL mutations for write operations (creating an assessment invitation record, updating a candidate) and queries for reads (fetching candidate or vacancy data). All traffic must be TLS 1.2 or higher.
A robust mapping contract prevents orphaned assessment sessions and duplicate invitations. These are the required identifier pairs your data store must maintain.
candidate.id applicant_id Primary key for all candidate operations job.shortcode role_id Links to the assessment template configured for that role stage.name trigger_stage The stage value that fires the invitation invitation_id (your field) session_id Must be stored and echoed in all result payloadsFor the invitation payload, you only need to transfer the fields required to send the assessment: first_name, last_name, email, and language. Do not forward fields like cover_letter, resume_url, CV text content, or any custom screening question answers unless your integration explicitly requires them and the candidate has consented to that scope of sharing.
In Carerix's GraphQL schema, the analogous objects are Candidate and Vacancy. Map:
Candidate.id to your applicant_idVacancy.id to your role_idinvitation_id returned from your platformCarerix uses string-based GUIDs for its object identifiers. Confirm the exact field names during partner onboarding, as the GraphQL schema evolves with platform updates.
Send only what is needed. The invitation delivery requires email and optionally first_name and preferred_language. Everything else (date of birth, address, nationality, custom questionnaire responses) stays in the ATS. Your platform should never store those fields even if they appear in the inbound payload.
Workable supports candidate event subscriptions via its /subscriptions endpoint. You register a webhook subscriber with filters scoped to stage-change events. The subscription args let you filter by event type, so you can target only candidate_moved or equivalent stage-transition events without receiving every candidate activity.
When a candidate moves into the configured assessment stage, Workable sends a POST to your registered callback URL. The payload includes at minimum:
{
"event_type": "candidate_moved",
"event_id": "evt_01HXYZ",
"candidate": {
"id": "cand_7890",
"name": "Alex van der Berg",
"email": "[email protected]"
},
"job": {
"id": "job_4567",
"shortcode": "DEV001"
},
"stage": {
"name": "Assessment",
"previous_name": "Phone Screen"
}
}
On receiving this event, your service looks up the assessment template mapped to job.shortcode, creates an invitation, and records the invitation_id against candidate.id + job.id in your store. Respond 200 OK within 5 seconds. Do all heavy processing asynchronously.
At minimum, your invitation creation call should accept:
applicant_id (mapped from ATS candidate ID)role_id (mapped from ATS job ID)emaillanguage (default to en if not available)assessment_template_id (resolved from your stage-to-template config)Optional parameters that should be configurable per client:
allow_retake (boolean, default false)proctoring_enabled (boolean)expiry_hours (integer, e.g., 72)If the candidate is moved back out of the assessment stage or disqualified, your integration must:
invitation_id exists for that candidate_id + job_id pairIf the candidate re-enters the stage (re-invite scenario), generate a new invitation_id and archive the previous one. Do not reuse invitation identifiers. Enforce this with a unique constraint at the database level.
Support both patterns if possible. Push (your platform POSTs results to a Workable-registered callback) is preferred for latency, but some ATS configurations rely on polling. Workable's Assessment Providers documentation confirms results can be received via callback or polling.
A standard result payload from your platform should include:
{
"event_type": "assessment_completed",
"event_id": "res_evt_9921",
"invitation_id": "inv_3344",
"attempt_id": "att_5566",
"candidate_id": "cand_7890",
"job_id": "job_4567",
"status": "completed",
"completed_at": "2026-09-09T14:33:00Z",
"scores": {
"overall": 72,
"dimensions": {
"cognitive_ability": 68,
"conscientiousness": 81,
"verbal_reasoning": 74
}
},
"report_url": "https://app.assessmentplatform.com/reports/att_5566"
}
The status field should use a controlled vocabulary: completed, abandoned, expired, failed. The ATS side maps these to display labels; your contract should never rely on free-text status strings.
For Workable, POST the results payload to the registered callback URL provided during assessment creation, or via the Assessment Provider results endpoint. The ATS then surfaces scores on the candidate profile, which is the workflow Workable supports for providers like Test Partnership (results appear on the candidate's Workable profile).
For Carerix, use a GraphQL mutation to update the candidate record with a custom assessment field or a structured note containing the scores and report URL.
Use Workable's /subscriptions endpoint to register your callback URL. Workable's webhook subscription reference confirms that subscription args filter the event stream, so set filters to only receive events relevant to your integration (stage changes for jobs where your assessment template is active).
Every inbound webhook from your platform to the ATS, and every inbound callback from the ATS to your endpoint, must be signed. Use HMAC-SHA256:
import hmac
import hashlib
def verify_signature(payload_bytes: bytes, received_sig: str, secret: str) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
payload_bytes,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received_sig)
Include a X-Webhook-Timestamp header and reject payloads where the timestamp is more than 5 minutes old. This prevents replay attacks.
Workable and most ATS platforms retry failed webhook deliveries. Your endpoint must be idempotent: processing the same event_id twice must produce the same outcome without side effects (no duplicate invitations, no duplicate result writes).
Implementation pattern:
event_id to a deduplication table with a TTL of at least 24 hoursevent_id already exists, return 200 OK immediately without processingcurl -X POST https://callback.workable.com/assessment-results \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=<hmac_value>" \
-H "X-Webhook-Timestamp: 2026-09-09T14:33:01Z" \
-d '{
"invitation_id": "inv_3344",
"candidate_id": "cand_7890",
"status": "completed",
"scores": { "overall": 72 }
}'
curl -X GET https://api.assessmentplatform.com/v1/templates \
-H "Authorization: Bearer <your_access_token>"
Response:
{
"templates": [
{ "id": "tmpl_001", "name": "Cognitive + Personality Bundle", "language": "en" },
{ "id": "tmpl_002", "name": "Logistiek instapniveau NL", "language": "nl" }
]
}
curl -X POST https://api.assessmentplatform.com/v1/invitations \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-d '{
"applicant_id": "cand_7890",
"role_id": "job_4567",
"template_id": "tmpl_001",
"email": "[email protected]",
"first_name": "Alex",
"language": "en",
"expiry_hours": 72
}'
Response:
{
"invitation_id": "inv_3344",
"status": "pending",
"invitation_url": "https://app.assessmentplatform.com/start/inv_3344",
"expires_at": "2026-09-12T14:33:00Z"
}
const crypto = require("crypto");
const express = require("express");
const app = express();
app.use(express.raw({ type: "application/json" }));
app.post("/webhooks/ats", (req, res) => {
const sig = req.headers["x-webhook-signature"];
const ts = req.headers["x-webhook-timestamp"];
const age = Date.now() - new Date(ts).getTime();
if (age > 5 * 60 * 1000) return res.status(400).send("Timestamp too old");
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.replace("sha256=", "")))) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body);
// Idempotency check, then enqueue for processing
res.sendStatus(200);
});
Run through each scenario in a staging environment before enabling production traffic.
Test case Expected outcome Valid token, correct payload200 OK, invitation created Expired or invalid token 401 Unauthorized, no invitation created Candidate ID not found in ATS 404 Not Found, error logged Job/stage not mapped to a template 422 Unprocessable Entity, alert fired Duplicate event_id received 200 OK, no duplicate invitation Webhook signature mismatch 401 Unauthorized, request rejected Results push times out (5xx from ATS) Retry with exponential backoff Candidate disqualified mid-assessment Invitation cancelled, session expired Out-of-order result event (arrived before invitation record) Hold in queue for 30s, re-attempt lookup401 All requests rejected Invalid or rotated bearer token Regenerate token via partner admin; check secrets manager sync 404 Invitation creation fails candidate_id or job_id not present in your mapping store Verify webhook received the stage-change event; check dedup table for missed events 409 Duplicate invitation rejected Same candidate_id + job_id already has an active invitation Cancel existing before creating new; or return the existing invitation_id 422 Payload validation error Missing required field or invalid enum value Inspect error body for field-level detail; check status vocabulary against spec 5xx Results callback failing ATS endpoint down or misconfigured callback URL Engage Workable partner support; hold results in dead-letter queue pending resolutionFor failed deliveries still in the dead-letter queue after 24 hours, your admin UI should expose a manual replay button scoped to individual invitation_id records, and an export of pending result payloads for manual insertion if needed.
The only fields that must travel from the ATS to your assessment platform to create an invitation are email, first_name (for the email greeting), and preferred_language. Everything else remains in the ATS. If your platform cannot operate without additional fields, document the justification under GDPR Article 6 before enabling the transfer.
Do not store raw CV text, screening question answers, or recruiter evaluation notes. These fields may appear in some ATS webhook payloads. Strip them at the ingestion layer before they reach your database.
Two consent events must be logged in your audit trail with a UTC timestamp:
Log the mechanism (ATS consent field, in-product checkbox, etc.) alongside the timestamp.
For EU-based employers, personal data processed through your integration should be stored within the EU. Selection Lab, for example, stores all personal data in Frankfurt and applies local LLMs to strip personally identifying information from conversational intake data before it reaches any scoring model. This design pattern is worth replicating: any AI-assisted scoring component should operate on anonymized or pseudonymized inputs wherever technically feasible.
Retention periods should be configured per client and enforced automatically. A reasonable default is 12 months from the assessment completion date, after which assessment records are deleted or anonymized. Clients in regulated industries may require shorter windows; build this as a configurable parameter, not a hard-coded constant.
If any component of your assessment platform uses an AI model to generate scores or recommendations, it qualifies as a high-risk AI system under Annex III of the EU AI Act (employment-related decisions). Required controls include:
Platforms like Selection Lab address this by combining transparent, dimension-level scoring reports with explainable AI outputs that recruiters can review directly in the ATS candidate view. This traceability is what EU AI Act compliance concretely requires at the integration layer: every automated score that influences a hiring decision must be auditable end-to-end.
A complete integration covering authentication, identifier mapping, stage-triggered invitations, results synchronization, webhook security, and GDPR-compliant data handling typically takes 2 to 10 weeks depending on the complexity of the ATS environment and the number of assessment templates to configure. Build in at least one week for end-to-end testing against a staging ATS tenant before enabling production traffic.