How to build a Candidate Assessment product with

Updated:


October 23, 2023

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, 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's Assessment API handles that route on ATSes with an assessment module (see How to Offer Assessment Packages and Receive Orders). 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's ATS API covers 164 ATS integrations, including Greenhouse, Lever, SmartRecruiters and SAP SuccessFactors (full list).

How to add ATS integrations to your product

Before we start, be sure to first read:

Getting Started with Unified.to

  1. Get an initial list of applications
    1. When the customer authorizes an ATS connection, read the initial set of applications for a job (filtering by job_id and updated_gte where the integration supports them)
      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 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.
      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, which would be similar to the code in 1a)
  3. Filter applications on a specific status
    1. The application 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 from the Application.candidate_id field
      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 from the Application.job_id field
      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 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
      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 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)
      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 (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'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.
      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 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 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 or book a demo.

Share
All guidesATS Candidate modelAdvertising Report Metrics by IntegrationATS to Vector DB: How to Power Talent Intelligence with Real-Time DataBuilding AI applications with Unified and LangbaseConcur & Concur (Company) — Connection GuideConfigure Greenhouse Native Candidate WebhooksConnect Amazon Seller Central to Unified.toConnecting BambooHR via OAuth 2Connecting Google Workspace Integrations with a Service AccountCorrect WelcomeKit Scopes for Jungle IntegrationCreating Ads using the Unified Ads APICreating Walmart Items via UnifiedEnd-Users, Integrations, and ConnectionsEnriching Unified Objects with Custom MetadataEnterprise-managed authorization (EMA) with a static keyEnterprise-managed authorization (EMA) with a workspace secretEnterprise-managed authorization (EMA) with Microsoft Entra IDExtended observability — pushing API logs to ClickHouseExtended observability — pushing API logs to Grafana / LokiExtended Observability - Pushing API Logs to your Datadog InstanceFacebook Messenger Webhook SetupFireflies Integration GuideGetting started with WorkableHandling Delegated vs. Application Scopes in Microsoft IntegrationsHiBob & Unified: connection and time off guideHow long are logs retainedHow to access employees and usersHow to add API support for the Create Activity in CrelateHow to Associate a Connection ID with Your End-UserHow to build a candidate sourcing or job board app with Unified.toHow to build a Discord support bot with Unified.to and LangbaseHow to Build a Fintech Application with Unified's Payments APIHow to Build an E-Commerce Product Integration with UnifiedHow to build an invoicing system with Unified.toHow to build Enterprise Search using RAGHow to configure Bullhorn Redirect URI for creating a connectionHow to configure webhooks in HubSpotHow to configure webhooks in Thrive LearningHow to configure webhooks in Toast for UnifiedHow to Connect Anthropic to Real-Time SaaS Data with Unified.to MCP ServerHow to Connect Cohere to Real-Time SaaS Data with Unified.to MCP ServerHow to Connect Google Gemini to Real-Time SaaS Data with Unified.to MCP ServerHow to Connect LLMs to Real-Time SaaS Data with Unified.to MCP ServerHow to Connect OpenAI to Real-Time SaaS Data with Unified.to MCP ServerHow to Connect Zoom and Zoom PhoneHow to create a Connection In Microsoft TeamsHow to create a token-based connection in HighlevelHow to Create a Unified.to Connection to HighLevelHow to Create a Xero Connection in Unified.toHow to create and configure webhooksHow to create Connection with HubspotHow to customize portal URLs for Stripe and GoCardlessHow to filter webhook eventsHow-to Get a Private Support Channel for Your Unified IntegrationHow to get your 8x8 Connect API Key and Account ID: Step-by-step guideHow to get your ADP Workforce Now Oauth2 client ID, Oauth2 client secret, Oauth2 PEM certificate and Oauth2 private key: Step-by-step guideHow to get your Amazon S3 AWS Region, AWS S3 Key and AWS S3 Secret: Step-by-step guideHow to get your Ashby API Key: Step-by-step guideHow to get your Brex API token: Step-by-step guideHow to get your Bullhorn OAuth2 Client ID, OAuth2 Client Secret, API Username and API Password: Step-by-step guideHow to get your Ceridian Dayforce Username, Password and Client Namespace: Step-by-step guideHow to get your Crelate API key: Step-by-step guideHow to get your Dashlane API Key: Step-by-step guideHow to get your Deel Access Token: Step-by-step guideHow to get your Dialpad API Key: Step-by-step guideHow to get your Discord OAuth 2 credentials and bot tokenHow to get your ELMO client ID and client secret: Step-by-step guideHow to get your Gainsight Client ID, Client Secret and Gainsight API Domain: Step-by-step guideHow to get your Greenhouse API Key and Job board token: Step-by-step guideHow to get your Helpscout API Key: Step-by-step guideHow to get your HiBob Service User ID and Service User Token: Step-by-step guideHow to get your HighLevel API Key and Location ID: Step-by-step guideHow to get your HubSpot developer key and OAuth 2 credentials (Legacy Apps)How to get your Humaans API Key: Step-by-step guideHow to get your iCIMS API Username, API Password and Customer ID: Step-by-step guideHow to get your JobDiva Client ID, Username/email and Password: Step-by-step guideHow to get your Jobvite API Key, API Key Secret and Email: Step-by-step guideHow to get your Lever API Key: Step-by-step guideHow to get your Loxo Agency slug, API Key and Agency ID: Step-by-step guideHow to get your Microsoft Active Directory / Entra ID Client ID, Client Secret and Tenant ID: Step-by-step guideHow to get your Microsoft Azure AD OAuth 2 credentialsHow to get your OAuth 2 credentials for GmailHow to get your OAuth 2 credentials for Microsoft Dynamics 365How to get your OAuth 2 credentials in PipeDriveHow to get your OpenAI API Key: Step-by-step guideHow to get your Paycom SID/Username and Token/Password: Step-by-step guideHow to get your PCRecruiter Username, Password, Database ID, App Key and App ID: Step-by-step guideHow to get your SAP SuccessFactors (OpenID Connect) Username, Password, Client ID, Client Secret, Dependency Name, IAS Host and API URL: Step-by-step guideHow to get your SAP SuccessFactors Username, Company ID, Password and API URL: Step-by-step guideHow to get your Shopify Admin API access token and Store ID: Step-by-step guideHow to get your Wayfair Client ID, Client Secret and Vendor Encrypted Key: Step-by-step guideHow to get your Workable API Token and Subdomain: Step-by-step guideHow to get your yotpo APP Key / Store ID and Secret Key: Step-by-step guideHow to Handle Attachments in Invoice, Bill and Credit MemoHow to migrate or import your integrations into Unified.toHow to obtain your GitHub OAuth2 credentialsHow to Power Claude with Live Customer Data Using Unified MCPHow to receive assessment orders from an ATS withHow to register a Google developer app and get OAuth 2 credentialsHow to register a Salesforce developer app and get OAuth 2 credentialsHow to register a Slack developer account and get OAuth 2 credentialsHow to Register a Workday Developer App and Get OAUTH2 CredentialsHow to Register a Workday Developer App and Get OAUTH2/ SOAP CredentialsHow to Register a ZendeskSell Developer App and Get OAUTH2 CredentialsHow to Register a Zoho Developer App and Get OAUTH2 CredentialsHow to Register an ADP Developer App and Get OAUTH2 CredentialsHow to Register an Atlassian Developer App and Get OAUTH2 CredentialsHow to Register your Facebook OAuth2 ApplicationHow to Register your Google Ads OAuth2 ApplicationHow to Register your MetaAds OAuth2 applicationHow To Request & Write Raw Integration DataHow to Retrieve Microsoft Dynamics 365 Business Central CredentialsHow to set up a custom API URL with a CNAMEHow to set up a HiBob marketplace installHow to Set Up a Microsoft Teams Bot with UnifiedHow to set up and configure NotionHow to set up Google Business Profile API access and get OAuth 2 credentialsHow to Set Up LinkedIn Webhooks with UnifiedHow to set up native webhooks for QuickBooks Online with UnifiedHow to set up Slack webhooks using event subscriptionsHow to set up Telegram (bot) with Unified.toHow to set up your scopes in HubSpotHow to setup a Freshbooks developer appHow to setup a TikTok Shop applicationHow to Setup AWS Assume Role for AWS Secret ManagerHow to setup Oracle JD EdwardsHow to Setup Quickbooks DesktopHow to setup Sage 100 with Unified.toHow to troubleshoot unhealthy connectionsHow to troubleshoot unhealthy webhooksHow to use SCIM with the Unified APIHow to use the Passthrough APIHow to Use Unified.to's Generative AI API with OpenAI and ClaudeHow to use User Provisioning and Verification with UnifiedHow virtual webhook retry and recovery worksIntegration set-up guide for LeverManaging custom validation rules in SalesforceNetSuite Authentication Setup for UnifiedNotice of Deprecation of Fields and Objects (Nov 2025)Oracle HCM / Taleo OAuth Credentials GuideRetrieval-Augmented Generation (RAG)SalesForce (External Client Apps) on Multiple OrganizationsSAML Single-Sign-OnScaling MCP Tools with Anthropic's (& OpenAI's) Defer LoadingSet Environments for your Unified.to WorkspaceSetting up a Slack Bot Connection with Unified.toSetting up OAuth 2 Credentials for Greenhouse's APIsTrello Connection Guide in UnifiedUnderstanding OAuth2 Authorization flowsUnderstanding Response Time Headers in the Unified APIUnified Assessment API: Embed Assessments Inside ATS PlatformsUnified's MCP ServerUnified MCP Server with LangChain and LangGraphUnlock real-time data with virtual webhooksUse Unified to sign in your users into your applicationWorkable: Creating candidates and attaching them to jobsWorkday vs Workday Legacy — Why Unified.to Supports BothWorking with hierarchical tree data in Storage, Messaging, and KMS APIs
Are we missing anything? Let us know
Was this page helpful?