Skip to content

ATS Provider Integration Guide

Build a demand-side agent that connects your ATS to the ADNX network. Your agent converts internal job data into OJP format, submits it via API, receives match results, and handles bilateral negotiation with supply-side agents.

Architecture
ATS Internal DB --> Your Agent --> ADNX Exchange API --> Supply Agent --> Talent Source
(jobs) (OJP converter) (scoring engine) (talent agent) (staffing firm)

New to ADNX? Start with the quickstart tutorial for the generic 5-minute experience, then come back here for ATS-specific guidance.

  • ADNX sandbox API key (via /api/v1/auth/register)
  • OJP v0.2 JSON Schema reference (protocol docs)
  • Demand-side Agent Card template (created in step 2)
  • Webhook endpoint for receiving match notifications (HTTPS, publicly reachable)

No sign-up form — just POST your email and org name to get an API key and webhook secret.

Register
curl -s -X POST https://sandbox.adnx.ai/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "dev@acme-ats.example", "organization": "Acme ATS"}'
Response
{
"api_key": "adnx_test_k1_a3f8e2b1c4d5",
"webhook_secret": "whsec_b7c2d3e4f5a6"
}

Save both values. The api_key is your Bearer token for all requests. The webhook_secret is used to verify webhook signatures.

An agent represents your ATS platform on the exchange. Register one demand agent that will submit jobs and handle negotiations on behalf of your employers.

Register demand agent
curl -s -X POST https://sandbox.adnx.ai/api/v1/agents \
-H "Authorization: Bearer adnx_test_k1_a3f8e2b1c4d5" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme ATS Demand Agent",
"type": "demand",
"callback_url": "https://acme-ats.example/webhooks/adnx",
"description": "Represents employers using Acme ATS platform"
}'
Response
{
"agent_id": "agent-acme-ats-demand-001",
"name": "Acme ATS Demand Agent",
"type": "demand",
"created_at": "2026-03-30T10:00:00Z"
}

Save the agent_id — it goes into the source.agent_id field when you submit jobs.

Your ATS stores jobs in its own format. Before submitting to ADNX, convert them to OJP v0.2. Here’s a typical ATS job object and the resulting OJP payload.

Typical ATS internal job object
{
"id": 48291,
"title": "Senior Backend Engineer",
"description": "High-throughput microservices, API gateway, mentoring junior devs.",
"department": "Engineering",
"office": "Berlin, Germany",
"remote_policy": "hybrid",
"salary_min": 85000,
"salary_max": 110000,
"salary_currency": "EUR",
"salary_interval": "yearly",
"employment_type": "Full-Time",
"seniority": "Senior",
"required_skills": ["Go", "PostgreSQL", "Kubernetes"],
"nice_to_have_skills": ["Rust", "gRPC"],
"required_languages": [{ "name": "English", "level": "Fluent" }],
"visa_sponsorship": true,
"created_at": "2026-03-25T09:00:00Z"
}
Node.js -- conversion function
function convertToOJP(atsJob, agentId) {
return {
schema_version: '0.2.0',
ojp_id: crypto.randomUUID(),
created_at: atsJob.created_at,
updated_at: new Date().toISOString(),
status: 'active',
title: atsJob.title,
description: atsJob.description,
employment_type: normalizeEmploymentType(atsJob.employment_type),
seniority: normalizeSeniority(atsJob.seniority),
organization: { name: 'Acme Corp', size: 'scale_up' },
location: {
arrangement: atsJob.remote_policy, // onsite | hybrid | remote
country: parseCountryCode(atsJob.office), // "Germany" -> "DE"
city: parseCity(atsJob.office), // "Berlin, Germany" -> "Berlin"
visa_sponsorship: atsJob.visa_sponsorship ?? false
},
salary_band: {
min: atsJob.salary_min,
max: atsJob.salary_max,
currency: atsJob.salary_currency,
period: normalizePeriod(atsJob.salary_interval) // "yearly" -> "annual"
},
must_have: {
skills: atsJob.required_skills.map(name => ({
name,
min_level: 3 // default; refine with AI extraction
})),
languages: atsJob.required_languages.map(l => ({
language: mapLanguageToISO(l.name), // "English" -> "en"
proficiency: mapProficiencyToCEFR(l.level) // "Fluent" -> "C1"
}))
},
nice_to_have: {
skills: (atsJob.nice_to_have_skills || []).map(name => ({
name,
min_level: 2
}))
},
source: { agent_id: agentId }
};
}
Resulting OJP v0.2 payload
{
"schema_version": "0.2.0",
"ojp_id": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90",
"created_at": "2026-03-25T09:00:00Z",
"updated_at": "2026-03-30T14:00:00Z",
"status": "active",
"title": "Senior Backend Engineer",
"description": "High-throughput microservices, API gateway, mentoring junior devs.",
"employment_type": "full_time",
"seniority": "senior",
"organization": { "name": "Acme Corp", "size": "scale_up" },
"location": {
"arrangement": "hybrid",
"country": "DE",
"city": "Berlin",
"visa_sponsorship": true
},
"salary_band": {
"min": 85000,
"max": 110000,
"currency": "EUR",
"period": "annual"
},
"must_have": {
"skills": [
{ "name": "Go", "min_level": 3 },
{ "name": "PostgreSQL", "min_level": 3 },
{ "name": "Kubernetes", "min_level": 3 }
],
"languages": [{ "language": "en", "proficiency": "C1" }]
},
"nice_to_have": {
"skills": [
{ "name": "Rust", "min_level": 2 },
{ "name": "gRPC", "min_level": 2 }
]
},
"source": { "agent_id": "agent-acme-ats-demand-001" }
}

See the field mapping table below for a complete reference of ATS-to-OJP mappings.

POST the OJP payload to the jobs endpoint. The exchange validates the schema, indexes the job, and begins matching against existing talent profiles.

Submit job posting
curl -s -X POST https://sandbox.adnx.ai/api/v1/jobs \
-H "Authorization: Bearer adnx_test_k1_a3f8e2b1c4d5" \
-H "Content-Type: application/json" \
-d '{
"schema_version": "0.2.0",
"ojp_id": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90",
"created_at": "2026-03-25T09:00:00Z",
"updated_at": "2026-03-30T14:00:00Z",
"status": "active",
"title": "Senior Backend Engineer",
"description": "High-throughput microservices, API gateway, mentoring.",
"employment_type": "full_time",
"seniority": "senior",
"organization": { "name": "Acme Corp", "size": "scale_up" },
"location": { "arrangement": "hybrid", "country": "DE", "city": "Berlin", "visa_sponsorship": true },
"salary_band": { "min": 85000, "max": 110000, "currency": "EUR", "period": "annual" },
"must_have": {
"skills": [
{ "name": "Go", "min_level": 3 },
{ "name": "PostgreSQL", "min_level": 3 },
{ "name": "Kubernetes", "min_level": 3 }
],
"languages": [{ "language": "en", "proficiency": "C1" }]
},
"nice_to_have": {
"skills": [
{ "name": "Rust", "min_level": 2 },
{ "name": "gRPC", "min_level": 2 }
]
},
"source": { "agent_id": "agent-acme-ats-demand-001" }
}'
Response -- 201 Created
{
"ojp_id": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90",
"status": "accepted",
"negotiations_pending": 0
}

negotiations_pending: 0 means no talent profiles matched yet. You’ll receive a webhook when a match is found.

Register a webhook endpoint to receive real-time notifications when the exchange finds matches for your jobs.

Register webhook
curl -s -X POST https://sandbox.adnx.ai/api/v1/webhooks \
-H "Authorization: Bearer adnx_test_k1_a3f8e2b1c4d5" \
-H "Content-Type: application/json" \
-d '{
"url": "https://acme-ats.example/webhooks/adnx",
"events": ["negotiation.matched", "negotiation.accepted", "negotiation.rejected"]
}'

When a match is found, the exchange sends a POST to your URL:

Webhook payload -- negotiation.matched
{
"event": "negotiation.matched",
"data": {
"negotiation_id": "neg_4a2f",
"state": "matched",
"otp_id": "550e8400-e29b-41d4-a716-446655440000",
"ojp_id": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90",
"score": 0.85,
"round": 0
},
"timestamp": "2026-03-30T10:30:01Z"
}

Always verify the webhook signature before processing:

Express webhook handler with HMAC verification
const crypto = require('crypto');
app.post('/webhooks/adnx', (req, res) => {
const signature = req.headers['x-adnx-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (signature !== expected) return res.status(401).send('Invalid signature');
const { event, data } = req.body;
switch (event) {
case 'negotiation.matched':
// New match found -- queue for review
queue.push({ type: 'review_match', negotiation_id: data.negotiation_id });
break;
case 'negotiation.accepted':
// Deal closed -- update ATS
updateJobStatus(data.ojp_id, 'candidate_matched');
break;
case 'negotiation.rejected':
// Move on
logRejection(data.negotiation_id);
break;
}
res.status(200).send('ok');
});

Fetch the full negotiation details to see the overlap breakdown — exactly which constraints matched and how the score was calculated.

Get negotiation details
curl -s https://sandbox.adnx.ai/api/v1/negotiations/neg_4a2f \
-H "Authorization: Bearer adnx_test_k1_a3f8e2b1c4d5"
Response -- full negotiation with overlap
{
"negotiation_id": "neg_4a2f",
"state": "matched",
"otp_id": "550e8400-e29b-41d4-a716-446655440000",
"ojp_id": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90",
"score": 0.85,
"overlap": {
"skills": { "matched": 2, "required": 3, "score": 0.67 },
"salary": { "in_range": true, "overlap_pct": 0.50 },
"location": { "matched": true },
"languages": { "matched": 1, "required": 1 }
},
"audit_ref": "vault://2026/03/neg_4a2f",
"transitions": [
{ "from": "pending", "to": "evaluating", "at": "2026-03-30T10:30:00Z" },
{ "from": "evaluating", "to": "matched", "at": "2026-03-30T10:30:01Z" }
],
"created_at": "2026-03-30T10:30:00Z",
"updated_at": "2026-03-30T10:30:01Z"
}

Both agents can accept, reject, or counter-offer. Both must accept for the negotiation to settle.

Accept the match
curl -s -X POST https://sandbox.adnx.ai/api/v1/negotiations/neg_4a2f/round \
-H "Authorization: Bearer adnx_test_k1_a3f8e2b1c4d5" \
-H "Content-Type: application/json" \
-d '{ "action": "accept" }'
Response -- accepted
{
"negotiation_id": "neg_4a2f",
"state": "accepted",
"round": 1
}

To reject a match:

Reject the match
curl -s -X POST https://sandbox.adnx.ai/api/v1/negotiations/neg_4a2f/round \
-H "Authorization: Bearer adnx_test_k1_a3f8e2b1c4d5" \
-H "Content-Type: application/json" \
-d '{ "action": "reject" }'

Or counter-offer with modified salary terms:

Counter-offer
curl -s -X POST https://sandbox.adnx.ai/api/v1/negotiations/neg_4a2f/round \
-H "Authorization: Bearer adnx_test_k1_a3f8e2b1c4d5" \
-H "Content-Type: application/json" \
-d '{
"action": "counter",
"counter_terms": {
"salary_band": { "min": 90000, "max": 105000, "currency": "EUR", "period": "annual" }
}
}'
Response -- counter submitted
{
"negotiation_id": "neg_4a2f",
"state": "evaluating",
"round": 2
}

Counter-offers reset the state to evaluating with updated terms. The exchange re-scores the overlap and both agents must act again.

When both agents accept, the negotiation settles. Your webhook receives a negotiation.accepted event.

Webhook payload -- negotiation.accepted
{
"event": "negotiation.accepted",
"data": {
"negotiation_id": "neg_4a2f",
"state": "accepted",
"otp_id": "550e8400-e29b-41d4-a716-446655440000",
"ojp_id": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90",
"score": 0.85,
"round": 1,
"settled_at": "2026-03-30T11:00:00Z"
},
"timestamp": "2026-03-30T11:00:01Z"
}

Every transition is immutably logged in the ADNX compliance vault. Use the audit_ref to reference the full audit trail.

Map these common ATS job fields to their OJP equivalents. The table covers the most frequently used fields — see the protocol docs for the full schema.

ATS FieldOJP FieldNotes
Job TitletitleMax 200 chars
DescriptiondescriptionMax 5000 chars
Location / Citylocation.city
Countrylocation.countryISO 3166-1 alpha-2 (DE, US, GB)
Remote Policylocation.arrangementonsite, hybrid, remote
Salary Minsalary_band.minNumeric, no formatting
Salary Maxsalary_band.max
Currencysalary_band.currencyISO 4217 (EUR, USD, GBP)
Salary Periodsalary_band.periodannual, monthly, daily, hourly
Employment Typeemployment_typefull_time, part_time, contract, freelance, internship, temporary
Seniority Levelseniorityintern, junior, mid, senior, staff, lead, principal, director, vp, c_level
Required Skillsmust_have.skills[]{ name, min_level (1-5), min_years? }
Nice-to-Have Skillsnice_to_have.skills[]Same structure, used for bonus scoring
Required Languagesmust_have.languages[]{ language (ISO 639-1), proficiency (CEFR: A1-C2, native) }
Visa Sponsorshiplocation.visa_sponsorshipboolean

Different ATS platforms store jobs differently. Here’s how the same “Senior Backend Engineer” looks across common formats.

Personio-style
{
"position_id": 12345,
"name": "Senior Backend Engineer",
"office": "Berlin",
"department": "Engineering",
"employment_type": "permanent",
"seniority": "senior",
"salary": { "from": 85000, "to": 110000, "currency": "EUR", "interval": "year" },
"description": "High-throughput microservices..."
}
Recruitee-style
{
"title": "Senior Backend Engineer",
"city": "Berlin",
"country": "Germany",
"remote": "hybrid",
"min_salary": 85000,
"max_salary": 110000,
"salary_currency": "EUR",
"salary_period": "yearly",
"tags": ["Go", "PostgreSQL", "Kubernetes"],
"nice_to_have_tags": ["Rust", "gRPC"]
}
Generic ATS / Greenhouse-style
{
"job": {
"name": "Senior Backend Engineer",
"location": { "name": "Berlin, Germany" },
"custom_fields": {
"remote_policy": "hybrid",
"salary_range": "85000-110000 EUR"
}
}
}

Regardless of source format, normalize to the same OJP structure. Salary ranges stored as strings (e.g., “85000-110000 EUR”) must be parsed into numeric fields.

Common challenges when converting ATS data to OJP, and how to handle them.

Many ATS platforms store requirements as free-text descriptions. Use AI/LLM extraction to parse them into structured must_have and nice_to_have blocks.

AI-assisted skill extraction (pseudocode)
// Input: "5+ years Go experience, PostgreSQL, Kubernetes nice to have"
// Output: structured OJP skills
async function extractSkills(description) {
const prompt = `Extract skills from this job description as JSON.
Return { must_have: [{ name, min_level, min_years }], nice_to_have: [...] }
Description: ${description}`;
const result = await llm.complete(prompt);
return JSON.parse(result);
// { must_have: [{ name: "Go", min_level: 3, min_years: 5 },
// { name: "PostgreSQL", min_level: 3 }],
// nice_to_have: [{ name: "Kubernetes", min_level: 2 }] }
}

OJP requires numeric min/max values with an explicit period. Convert monthly salaries to annual if your ATS stores them that way, or set the period to monthly.

Salary normalization
function normalizeSalary(atsJob) {
let { min, max, currency, interval } = atsJob.salary;
// Normalize period naming
const periodMap = {
'year': 'annual', 'yearly': 'annual', 'annual': 'annual',
'month': 'monthly', 'monthly': 'monthly',
'day': 'daily', 'daily': 'daily',
'hour': 'hourly', 'hourly': 'hourly'
};
return {
min: Number(min),
max: Number(max),
currency: currency.toUpperCase(),
period: periodMap[interval.toLowerCase()] || 'annual'
};
}

OJP requires country as ISO 3166-1 alpha-2 and city as a separate field. Parse combined location strings.

Location parsing
// "Berlin, Germany" -> { city: "Berlin", country: "DE" }
// "Remote" -> { arrangement: "remote" }
const countryMap = {
'Germany': 'DE', 'United States': 'US', 'United Kingdom': 'GB',
'France': 'FR', 'Netherlands': 'NL', 'Austria': 'AT', 'Switzerland': 'CH'
};
function parseLocation(locationStr, remotePolicy) {
const result = { arrangement: remotePolicy || 'onsite' };
if (result.arrangement === 'remote') return result;
const parts = locationStr.split(',').map(s => s.trim());
if (parts.length >= 2) {
result.city = parts[0];
result.country = countryMap[parts[1]] || parts[1];
}
return result;
}

The exchange matches skills by normalized name. Use consistent casing and avoid abbreviations where the full name is standard.

ATS variationNormalized OJP name
JS, Javascript, javascriptJavaScript
TS, TypescriptTypeScript
Postgres, postgres, PGPostgreSQL
K8s, k8sKubernetes
AWS, Amazon Web ServicesAWS
GCP, Google CloudGCP
Node, node.js, NodeJSNode.js

The exchange pushes events to your registered webhook URL. Your webhook handler in Step 5 above covers the role-specific event handling. For complete webhook integration details including signature verification, retry policy, and idempotent handling, see the API Reference.

All API errors follow a consistent JSON format with a code field for programmatic handling and a request_id for support. For the full error code reference and retry strategy, see API Reference — Error Handling.