# Sync Applicant (ATS) to Employee Onboarding (HRIS)

This guide syncs a hired applicant from an ATS into an HRIS automatically. Apideck fires an
`ats.applicant.updated` webhook when the applicant record changes; your app fetches the applicant's
details from the ATS API, transforms them into an employee record, and pushes that update through the
HRIS API. The example below uses Teamtailor as the ATS connector and Humaans as the HRIS connector, but the
same webhook-driven flow works with any Apideck ATS/HRIS pair:

- [**Teamtailor**](https://developers.apideck.com/apis/ats/teamtailor) as ATS provider
- [**Humaans**](https://developers.apideck.com/apis/hris/humaans-io) as HRIS destination

**Trigger**: an applicant gets updated ⇒ the employee gets updated

See the full example on [GitHub](https://github.com/apideck-samples/ats-to-hris).

## Flow diagram

**Highlevel flow**

![High-level sequence diagram of Apideck syncing an applicant from an ATS connector to an HRIS connector](/guides/sync-applicant/mermaid.png)

**Detailed flow**

![Untitled](/guides/sync-applicant/Untitled.png)

## Scenario

1. Apideck monitors the ATS system for changes
2. Apideck detects an update for a candidate
3. Apideck broadcasts a [webhook event](https://developers.apideck.com/apis/ats/reference#operation/applicantUpdated) `ats.applicant.updated`
4. Client application receives webhook event `ats.applicant.updated`
5. Client requests “[applicant details](https://developers.apideck.com/apis/ats/reference#operation/applicantsOne)” via Unify API
6. Client transforms the applicant details into an employee model
7. Client executes a “update employee” request via Unify API

## Screenshots

**Teamtailer - Updated applicant**

[https://app.teamtailor.com/companies/YTf4HVdoBks/candidates/segment/all/candidate/31066324](https://app.teamtailor.com/companies/YTf4HVdoBks/candidates/segment/all/candidate/31066324)

![Untitled](/guides/sync-applicant/Untitled%201.png)

**Client** - handling the incoming applicant trigger and starting the update employee process based on a webhook.

![Untitled](/guides/sync-applicant/Untitled%202.png)

**Humaans - Updated employee**

[https://app.humaans.io/](https://app.humaans.io/)

![Untitled](/guides/sync-applicant/Untitled%203.png)

### Client POC Application

1. Create a new folder
2. Place the files below in the folder
3. Execute the command `npm install`, this will install all the dependencies
4. Configure the correct API key & other settings in the `.env` file
5. Execute the command `npm run start`, to start the client application, which will run on: 127.0.0.1:7777
6. The unsigned request below is a negative control: it must return `401` without any ATS lookup or HRIS update. For an authenticated test, configure an Apideck webhook delivery URL ending in `/start` and use Apideck's test delivery. See [signature verification](/guides/webhook-signature-verification).

The listener binds to `127.0.0.1` for local development. A public delivery URL needs an explicitly configured HTTPS reverse proxy or development tunnel forwarding only `/start` to this listener. Signature verification authenticates the body; it does **not** prevent replay. Before production, persist and deduplicate `x-apideck-idempotency-key` and use the durable receipt/worker pattern in the [webhooks guide](/guides/webhooks). This single-consumer sample rejects events for a different consumer or ATS connection.

```bash
curl --location --request POST '127.0.0.1:7777/start' \
--header 'Content-Type: application/json' \
--data-raw '{
  "payload": {
    "event_type": "ats.applicant.updated",
    "unified_api": "ats",
    "service_id": "teamtailor",
    "consumer_id": "test-consumer",
    "event_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
    "entity_id": "31066324",
    "entity_url": "https://unify.apideck.com/ats/applicants/31066324",
    "entity_type": "applicant",
    "occurred_at": "2023-09-22T00:00:00.000Z"
  }
}'
```

**Files**

_package.json_ file

```json
{
  "name": "app-ats-hris",
  "version": "1.0.0",
  "description": "Sample webhook listener for ATS to HRIS app",
  "scripts": {
    "start": "node app.mjs"
  },
  "dependencies": {
    "@apideck/unify": "latest",
    "concurrently": "^8.2.1",
    "dotenv": "^16.3.1",
    "express": "^4.18.1",
    "ngrok": "^5.0.0-beta.2"
  }
}
```

_.env_ file

```bash
APP_ID="<YOUR_APP_ID>"
API_KEY="<YOUR_API_KEY>"
CONSUMER_ID="test-consumer"
SERVICE_ID_ATS="teamtailor"
SERVICE_ID_HRIS="humaans-io"
```

_app.mjs_ file

```jsx
import crypto from 'crypto'
import dotenv from 'dotenv'
import express from 'express'
import { Apideck } from '@apideck/unify'

dotenv.config()

// Initiate Unify SDK
const apideck = new Apideck({
  apiKey: process.env.API_KEY ?? '',
  appId: process.env.APP_ID ?? '',
  consumerId: process.env.CONSUMER_ID ?? ''
})

// Accept only JSON, at most 100 KiB, without decompression.
const app = express()
app.disable('x-powered-by')
app.use(express.json({ limit: '100kb', inflate: false }))
const port = process.env.port || 7777

//
const currentDate = new Date()
const year = currentDate.getFullYear()
const month = String(currentDate.getMonth() + 1).padStart(2, '0') // Months are 0-indexed
const day = String(currentDate.getDate()).padStart(2, '0')

const formattedDate = `${year}-${month}-${day}`

// Match WebhookClient.generateSignature: English, case-insensitive key order; retain array order.
const sortObjectKeys = (obj) => {
  if (obj === null || typeof obj !== 'object') return obj
  if (Array.isArray(obj)) return obj.map((item) => sortObjectKeys(item))
  return Object.keys(obj)
    .sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
    .reduce((result, key) => {
      result[key] = sortObjectKeys(obj[key])
      return result
    }, {})
}

// Verify that the request was signed by Apideck (x-apideck-signature, HMAC-SHA256 with your API key)
const verifyApideckSignature = (req, res, next) => {
  const signature = req.headers['x-apideck-signature']
  if (typeof signature !== 'string' || !/^[a-f0-9]{64}$/.test(signature) || !process.env.API_KEY) {
    return res.status(401).send('Missing or invalid signature')
  }

  if (!req.is('application/json') || !req.body || Array.isArray(req.body)) {
    return res.status(400).send('Expected a JSON object')
  }

  const calculated = crypto
    .createHmac('sha256', process.env.API_KEY)
    .update(JSON.stringify(sortObjectKeys(req.body)))
    .digest('hex')

  const calculatedBuffer = Buffer.from(calculated)
  const signatureBuffer = Buffer.from(signature)

  // timingSafeEqual throws on different lengths, so compare lengths first
  if (
    calculatedBuffer.length !== signatureBuffer.length ||
    !crypto.timingSafeEqual(calculatedBuffer, signatureBuffer)
  ) {
    return res.status(401).send('Invalid signature')
  }

  next()
}

// Only accept signed POST requests, the only method Apideck uses to deliver webhooks
app.post('/start', verifyApideckSignature, async function (req, res) {
  // Apideck validates a delivery URL with this signed, non-event message.
  if (req.body.message === 'Validating delivery URL' && !req.body.payload) {
    return res.status(200).send('Webhook endpoint verified')
  }
  const payload = req.body.payload
  if (
    payload?.event_type !== 'ats.applicant.updated' ||
    payload.consumer_id !== process.env.CONSUMER_ID ||
    payload.service_id !== process.env.SERVICE_ID_ATS ||
    typeof payload.entity_id !== 'string' || !payload.entity_id.trim()
  ) {
    return res.status(400).send('Unexpected webhook event or identity')
  }
  // Initiate variables
  let employee = null

  // Build the response
  const msg = {
    message: 'Thank you for the message'
  }

  // Process the request
  const webhook_id = payload.entity_id

  // Lookup the ATS candidate
  if (webhook_id) {
    try {
      const { data } = await apideck.ats.applicants.get({
        id: webhook_id,
        serviceId: process.env.SERVICE_ID_ATS
      })
      console.log('ATS applicant retrieved')
      // Set the employee object
      employee = {
        first_name: data.first_name,
        last_name: data.last_name,
        title: 'Product Manager',
        employment_start_date: formattedDate,
        employee_number: data?.id,
        employment_status: 'active',
        employment_role: {
          type: 'employee',
          sub_type: 'full_time'
        },
        gender: 'female',

        phone_numbers: data?.phone_numbers,
        emails: data?.emails,
        addresses: [
          {
            type: 'primary',
            city: 'Antwerp',
            country: 'BE'
          }
        ]
      }

      // Buildup the response
      msg.ats_candidate = {
        id: data.id,
        first_name: data?.first_name || '-',
        last_name: data?.last_name || '-'
      }
    } catch (error) {
      console.error('ATS lookup failed; inspect the request in Apideck Logs')
    }
  }

  if (employee) {
    try {
      const { data } = await apideck.hris.employees.update({
        employee: employee,
        serviceId: process.env.SERVICE_ID_HRIS
      })
      console.log('HRIS employee updated')
      msg.hris_employee = {
        id: data?.id,
        url: `https://app.humaans.io/?profile=${data?.id}`
      }
    } catch (error) {
      console.error('HRIS update failed; inspect the request in Apideck Logs')
    }
  }

  res.json(msg)
})

// Keep parser errors value-free: never echo request bodies, signatures or API keys.
app.use((error, req, res, next) => {
  const status = [400, 413, 415].includes(error.status) ? error.status : 400
  res.status(status).send('Invalid webhook body')
})

const server = app.listen(port, '127.0.0.1', function () {
  console.log(`Unify app listening on 127.0.0.1:${port}`)
})
server.requestTimeout = 10000
server.headersTimeout = 10000
server.setTimeout(10000)

```

### Troubleshooting

Humaans has a unique employee validation based on the email address, so make sure that the employee “Amelia Earhart” does not exist in Humaans.

1. Goto Humaans > People [https://app.humaans.io/](https://app.humaans.io/)

1. Goto “Amelia Earhart” > “Full Profile”

   ![Untitled](/guides/sync-applicant/Untitled%204.png)

![Untitled](/guides/sync-applicant/Untitled%205.png)

1. Offboard “Amelia Earhart”
   1. Click “Offboarding” from the sidebar
   2. Click “Confirm and Schedule offboarding
   3. Next click “Delete this profile”

![Untitled](/guides/sync-applicant/Untitled%206.png)

![Untitled](/guides/sync-applicant/Untitled%207.png)

![Untitled](/guides/sync-applicant/Untitled%208.png)

![Untitled](/guides/sync-applicant/Untitled%209.png)
