---
title: "How to build a Candidate Assessment product with"
img: https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_build_a_candidate_assessment_product_with-icon.png
date: 2023-10-23T00:00:00.000Z
updated: "2026-10-23T16:25:05.268Z"
tag: Guides
integrations: 
description: "There are two ways to build a candidate assessment product on your customers' ATS. In the first, your product reads applications through Unified.to's ATS API,..."
url: "https://docs.unified.to/guides/how_to_build_a_candidate_assessment_product_with"
---

# How to build a Candidate Assessment product with
------
_October 23, 2023_

<iframe width="560" height="315" src="https://www.youtube.com/embed/0hfH39vh7Qk" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


There are two ways to build a candidate assessment product on your customers' ATS. In the first, your product reads applications through [Unified.to](https://unified.to/)'s ATS API, starts an assessment when an application reaches a status you choose, and writes the result back to the candidate record. It needs no ATS partnership and works on any ATS integration that returns applications. In the second, your packages are listed inside the ATS's own assessment module and recruiters order them there; [Unified.to](https://unified.to/)'s Assessment API handles that route on ATSes with an assessment module (see [How to Offer Assessment Packages and Receive Orders](https://unified.to/blog/how_to_offer_assessment_packages_and_receive_orders_with_unified_assessment_api)). This guide covers the first route.


The same data can feed AI screening or semantic resume matching; see the RAG section at the end.


## Who is this guide for?


It's for teams with a candidate assessment product (skills tests, background checks, structured interviews) who need to read new applications from their customers' ATS and return results to it.

- Read new applications from your customers' ATS (applicant tracking system) so your product can assess those candidates.
- Retrieve resumes and job descriptions to power RAG-based candidate ranking and semantic matching.

[Unified.to](https://unified.to/)'s ATS API covers 164 ATS integrations, including [Greenhouse](https://unified.to/integrations/greenhouse), [Lever](https://unified.to/integrations/lever), [SmartRecruiters](https://unified.to/integrations/smartrecruiters) and [SAP SuccessFactors](https://unified.to/integrations/successfactors) ([full list](https://unified.to/ats)).


## How to add ATS integrations to your product


Before we start, be sure to first read:


[Getting Started with Unified.to](https://unified.to/blog/start_here_getting_started_with_unified)

1. **Get an initial list of applications**
    1. When the customer authorizes an ATS connection, [read the initial set of applications for a job](https://docs.unified.to/ats/application/List_all_applications) (filtering by `job_id` and `updated_gte` where the integration supports them)

        ```typescript
        import { UnifiedTo } from '@unified-api/typescript-sdk';
        import { AtsApplication, AtsApplicationStatus } from '@unified-api/typescript-sdk/sdk/models/shared';
        
        const sdk = new UnifiedTo({
            security: {
                jwt: process.env.UNIFIED_API_KEY!,
            },
        });
        
        export async function getApplications(connectionId: string, trigger_status: AtsApplicationStatus, jobId: string, updatedGte?: string) {
            const applications: AtsApplication[] = [];
            const limit = 100;
            let offset = 0;
        
            while (true) {
                // listAtsApplications returns the array of applications directly
                const apps = await sdk.ats.listAtsApplications({
                    updatedGte, // ISO-8601 string, e.g. '2026-10-01T00:00:00Z'
                    jobId,
                    offset,
                    limit,
                    connectionId,
                });
        
                applications.push(...apps.filter((application) => application.jobId === jobId && application.status === trigger_status)); // job_id is honoured server-side on some integrations only
        
                if (apps.length === limit) {
                    offset += limit;
                } else {
                    break;
                }
            }
        
            return applications;
        }
        ```

2. **Set up to get updated applications**
    1. To receive new and updated applications, [create a webhook](https://docs.unified.to/unified/webhook/Create_webhook_subscription) for that `connection_id`. Application webhooks are native or virtual depending on the integration; virtual webhooks check at an interval you set, as often as every minute on paid plans, and a check that finds nothing isn't billed. Check the integration page for which applies.

        ```typescript
        import { createHmac, timingSafeEqual } from 'crypto';
        import { UnifiedTo } from '@unified-api/typescript-sdk';
        import { AtsApplicationStatus, Webhook, WebhookEvent, WebhookObjectType, WebhookType } from '@unified-api/typescript-sdk/sdk/models/shared';
        
        const sdk = new UnifiedTo({
            security: {
                jwt: process.env.UNIFIED_API_KEY!,
            },
        });
        
        // The payload arrives as JSON with snake_case field names
        interface IncomingWebhook {
            id: string;
            created_at: string;
            updated_at: string;
            workspace_id: string;
            connection_id: string;
            hook_url: string;
            object_type: string;
            interval: number;
            checked_at: string;
            integration_type: string;
            environment: string;
            event: 'created' | 'updated' | 'deleted';
            runs: string[];
            fields: string;
            webhook_type: WebhookType;
            is_healthy: boolean;
            page_max_limit: number;
        }
        
        interface IncomingApplication {
            id: string;
            candidate_id?: string;
            job_id?: string;
            status?: AtsApplicationStatus;
            original_status?: string;
            updated_at?: string;
        }
        
        interface IWebhookData<T> {
            data: T[]; // The data array contains objects of the webhook's object type (eg. ATS applications)
            webhook: IncomingWebhook; // The webhook object
            nonce: string; // random string
            sig256: string; // base64(HMAC-SHA256(workspace.secret, JSON.stringify(data) + nonce))
            external_xref?: string; // the external_xref you set on the connection
            type: 'INITIAL-PARTIAL' | 'INITIAL-COMPLETE' | 'VIRTUAL' | 'NATIVE';
        }
        
        export async function createApplicationsWebhook(connectionId: string, myWebhookUrl: string): Promise<Webhook> {
            // createUnifiedWebhook returns the Webhook object directly
            return await sdk.unified.createUnifiedWebhook({
                webhook: {
                    hookUrl: myWebhookUrl,
                    objectType: WebhookObjectType.AtsApplication,
                    event: WebhookEvent.Updated,
                    connectionId,
                },
            });
        }
        
        export async function handleUnifiedWebhook(incoming: IWebhookData<IncomingApplication>, trigger_status: AtsApplicationStatus) {
            if (incoming.webhook.object_type !== 'ats_application') {
                return; // not for us
            }
        
            const secret = process.env.WORKSPACE_SECRET;
            if (!secret) {
                throw new Error('WORKSPACE_SECRET is not set');
            }
        
            const computed = Buffer.from(
                createHmac('sha256', secret)
                    .update(JSON.stringify(incoming.data)) // must match the order and encoding received
                    .update(String(incoming.nonce))
                    .digest('base64'),
                'utf8',
            );
            const provided = Buffer.from(incoming.sig256 ?? '', 'utf8');
            if (computed.length !== provided.length || !timingSafeEqual(computed, provided)) {
                return; // signature mismatch: reject the payload
            }
        
            return incoming.data?.filter((application) => application.status === trigger_status);
        }
        ```

    2. Alternatively, create a polling schedule to [get new/updated applications](https://docs.unified.to/ats/application/List_all_applications), which would be similar to the code in 1a)
3. **Filter applications on a specific status**
    1. The [application](https://docs.unified.to/ats/application/model) carries a standard `status` (with the vendor's own label in `original_status`), so choose the status that should start the assessment.
    2. Application.Status options are `NEW` `REVIEWING` `SCREENING` `SUBMITTED` `FIRST_INTERVIEW` `SECOND_INTERVIEW` `THIRD_INTERVIEW` `BACKGROUND_CHECK` `OFFERED` `ACCEPTED` `HIRED` `REJECTED` `DECLINED` `WITHDRAWN`. Of these, `REVIEWING`, `SCREENING`, `FIRST_INTERVIEW`, `SECOND_INTERVIEW`, or `BACKGROUND_CHECK` should be considered.
4. **Email the candidate with the assessment based on the job**
    1. [Read the candidate](https://docs.unified.to/ats/candidate/Retrieve_a_candidate) from the `Application.candidate_id` field

        ```typescript
        import { UnifiedTo } from '@unified-api/typescript-sdk';
        
        const sdk = new UnifiedTo({
            security: {
                jwt: process.env.UNIFIED_API_KEY!,
            },
        });
        
        export async function readCandidate(connectionId: string, candidateId: string) {
        	return await sdk.ats.getAtsCandidate({
                id: candidateId,
                connectionId,
            });
        }
        ```

    2. Optional: [read the job](https://docs.unified.to/ats/job/Retrieve_a_job) from the `Application.job_id` field

        ```typescript
        import { UnifiedTo } from '@unified-api/typescript-sdk';
        
        const sdk = new UnifiedTo({
            security: {
                jwt: process.env.UNIFIED_API_KEY!,
            },
        });
        
        export async function readJob(connectionId: string, jobId: string) {
            return await sdk.ats.getAtsJob({
                id: jobId,
                connectionId,
            });
        }
        ```

    3. Determine which test to send to the candidate based on the `Job.id`, `Job.description` or `Job.name` fields
5. **Once the candidate has completed the assessment/test, notify the hiring manager and/or recruiter**
    1. [Read the employee](https://docs.unified.to/hris/employee/Retrieve_an_employee) from the `Job.hiring_manager_ids` or `Job.recruiter_ids` fields, on integrations that support the HR employee object on the same connection (Greenhouse and Lever do), and email them from `Employee.emails`

        ```typescript
        import { UnifiedTo } from '@unified-api/typescript-sdk';
        
        const sdk = new UnifiedTo({
            security: {
                jwt: process.env.UNIFIED_API_KEY!,
            },
        });
        
        export async function readEmployee(connectionId: string, employeeId: string) {
            return await sdk.hris.getHrisEmployee({
                id: employeeId,
                connectionId,
            });
        }
        ```

    2. Optional: [write a PDF report](https://docs.unified.to/ats/document/Create_a_document) to the application or candidate in the customer's ATS, on integrations that support document creation (Greenhouse and Workable attach to the application; Lever to the candidate)

        ```typescript
        import { UnifiedTo } from '@unified-api/typescript-sdk';
        import { AtsDocumentType } from '@unified-api/typescript-sdk/sdk/models/shared';
        
        const sdk = new UnifiedTo({
            security: {
                jwt: process.env.UNIFIED_API_KEY!,
            },
        });
        
        // Greenhouse and Workable attach documents by applicationId; Lever, Ashby and SmartRecruiters by candidateId
        export async function createDocument(connectionId: string, target: { applicationId?: string; candidateId?: string }, documentUrl: string, filename: string, type: AtsDocumentType) {
            return await sdk.ats.createAtsDocument({
                atsDocument: {
                    ...target,
                    type,
                    documentUrl,
                    filename,
                },
                connectionId,
            });
        }
        ```

    3. Optional: [create an activity](https://docs.unified.to/ats/activity/Create_an_activity) (a note on the candidate or application) on integrations that support it, such as Greenhouse, Lever, Workable and Ashby. Where the integration accepts scorecard creation (SmartRecruiters, Ashby, Workday and SAP SuccessFactors among them; not Greenhouse or Lever, as of 19 October 2026), you can create a scorecard instead.
    4. Optional: [update the candidate](https://docs.unified.to/ats/candidate/Update_a_candidate)'s `tags` on integrations where tags are writable (Greenhouse and Lever are two). Read the candidate's current `tags`, add yours, and send the full list, so no existing tags are lost.

        ```typescript
        import { UnifiedTo } from '@unified-api/typescript-sdk';
        
        const sdk = new UnifiedTo({
            security: {
                jwt: process.env.UNIFIED_API_KEY!,
            },
        });
        
        export async function updateCandidateTags(connectionId: string, candidateId: string, newTags: string[]) {
            // read the current tags and send the merged list, so no existing tags are lost
            const candidate = await sdk.ats.getAtsCandidate({
                id: candidateId,
                connectionId,
            });
        
            return await sdk.ats.updateAtsCandidate({
                atsCandidate: {
                    tags: [...new Set([...(candidate.tags ?? []), ...newTags])],
                },
                id: candidateId,
                connectionId,
            });
        }
        ```


Write-back targets vary by ATS (as of 19 October 2026): activities (notes, created on 55 integrations) and documents (48) are the widest options, application status is writable on 33 of 125 application integrations, and scorecards can be created on the fewest (12, not Greenhouse or Lever). Check the integration page before you design around one.


### Advanced: Adding RAG-Based Resume Matching


Beyond triggering assessments, you can use the same data for retrieval-augmented generation (RAG):

1. Subscribe to application created and updated webhooks so new resumes are embedded as they arrive.
2. Retrieve the candidate's resume (an `ats_document` with `type` `RESUME`) and the job's `description`.
3. Chunk and embed both documents.
4. Store embeddings in your vector database with identifiers like `connection_id`, `candidate_id`, `job_id` and `updated_at`.
5. Retrieve the most relevant resume segments against the job description before generating screening insights or ranking scores.

[Unified.to](https://unified.to/) reads the ATS data at request time and returns it in one schema across ATS integrations, with no customer records at rest; embeddings and retrieval stay in your infrastructure.


## Keep learning

- [How to add a test connection](https://unified.to/blog/start_here_how_to_add_a_test_connection)
- [How your customers add integrations from your application](https://docs.unified.to/quick-start)
- [How to generate OAuth 2 credentials](https://unified.to/blog/start_here_how_to_generate_oauth2_credentials)

## How do you start?


Connect a test ATS, list its applications and register an application webhook; the steps above are the whole integration. [Start a 30-day free trial](https://app.unified.to/login) or [book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified).