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.
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.
Prerequisites
Section titled “Prerequisites”- 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)
1. Register for a sandbox key
Section titled “1. Register for a sandbox key”No sign-up form — just POST your email and org name to get an API key and webhook secret.
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"}'from adnx import ADNXClient
result = ADNXClient.register("dev@acme-ats.example", "Acme ATS")api_key = result["api_key"]webhook_secret = result["webhook_secret"]import { ADNXClient } from "@adnx/sdk";
const result = await ADNXClient.register("dev@acme-ats.example", "Acme ATS");const { apiKey, webhookSecret } = result;{ "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.
2. Register your demand agent
Section titled “2. Register your demand agent”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.
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" }'from adnx import ADNXClient
client = ADNXClient(api_key="adnx_test_k1_a3f8e2b1c4d5")agent = client.register_agent( name="Acme ATS Demand Agent", type="demand", callback_url="https://acme-ats.example/webhooks/adnx", description="Represents employers using Acme ATS platform",)import { ADNXClient } from "@adnx/sdk";
const client = new ADNXClient({ apiKey: "adnx_test_k1_a3f8e2b1c4d5" });const agent = await client.registerAgent({ name: "Acme ATS Demand Agent", type: "demand", callbackUrl: "https://acme-ats.example/webhooks/adnx", description: "Represents employers using Acme ATS platform",});{ "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.
3. Convert your job data to OJP
Section titled “3. Convert your job data to OJP”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.
{ "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"}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 } };}{ "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.
4. Submit the job to ADNX
Section titled “4. Submit the job to ADNX”POST the OJP payload to the jobs endpoint. The exchange validates the schema, indexes the job, and begins matching against existing talent profiles.
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" } }'from adnx import ADNXClient
client = ADNXClient(api_key="adnx_test_k1_a3f8e2b1c4d5")ojp_payload = convert_to_ojp(ats_job, "agent-acme-ats-demand-001")result = client.submit_job(ojp_payload)import { ADNXClient } from "@adnx/sdk";
const client = new ADNXClient({ apiKey: "adnx_test_k1_a3f8e2b1c4d5" });const ojpPayload = convertToOJP(atsJob, "agent-acme-ats-demand-001");const result = await client.submitJob(ojpPayload);{ "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.
5. Handle match webhooks
Section titled “5. Handle match webhooks”Register a webhook endpoint to receive real-time notifications when the exchange finds matches for your jobs.
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"] }'from adnx import ADNXClient
client = ADNXClient(api_key="adnx_test_k1_a3f8e2b1c4d5")client.register_webhook( url="https://acme-ats.example/webhooks/adnx", events=["negotiation.matched", "negotiation.accepted", "negotiation.rejected"],)import { ADNXClient } from "@adnx/sdk";
const client = new ADNXClient({ apiKey: "adnx_test_k1_a3f8e2b1c4d5" });await client.registerWebhook( "https://acme-ats.example/webhooks/adnx", ["negotiation.matched", "negotiation.accepted", "negotiation.rejected"]);When a match is found, the exchange sends a POST to your URL:
{ "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:
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');});6. Review the match
Section titled “6. Review the match”Fetch the full negotiation details to see the overlap breakdown — exactly which constraints matched and how the score was calculated.
curl -s https://sandbox.adnx.ai/api/v1/negotiations/neg_4a2f \ -H "Authorization: Bearer adnx_test_k1_a3f8e2b1c4d5"from adnx import ADNXClient
client = ADNXClient(api_key="adnx_test_k1_a3f8e2b1c4d5")negotiation = client.get_negotiation("neg_4a2f")import { ADNXClient } from "@adnx/sdk";
const client = new ADNXClient({ apiKey: "adnx_test_k1_a3f8e2b1c4d5" });const negotiation = await client.getNegotiation("neg_4a2f");{ "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"}7. Negotiate
Section titled “7. Negotiate”Both agents can accept, reject, or counter-offer. Both must accept for the negotiation to settle.
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" }'from adnx import ADNXClient
client = ADNXClient(api_key="adnx_test_k1_a3f8e2b1c4d5")result = client.submit_round("neg_4a2f", action="accept")import { ADNXClient } from "@adnx/sdk";
const client = new ADNXClient({ apiKey: "adnx_test_k1_a3f8e2b1c4d5" });const result = await client.submitRound({ negotiationId: "neg_4a2f", action: "accept",});{ "negotiation_id": "neg_4a2f", "state": "accepted", "round": 1}To reject a 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" }'from adnx import ADNXClient
client = ADNXClient(api_key="adnx_test_k1_a3f8e2b1c4d5")result = client.submit_round("neg_4a2f", action="reject")import { ADNXClient } from "@adnx/sdk";
const client = new ADNXClient({ apiKey: "adnx_test_k1_a3f8e2b1c4d5" });const result = await client.submitRound({ negotiationId: "neg_4a2f", action: "reject",});Or counter-offer with modified salary terms:
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" } } }'from adnx import ADNXClient
client = ADNXClient(api_key="adnx_test_k1_a3f8e2b1c4d5")result = client.submit_round( "neg_4a2f", action="counter", counter_terms={ "salary_band": {"min": 90000, "max": 105000, "currency": "EUR", "period": "annual"} },)import { ADNXClient } from "@adnx/sdk";
const client = new ADNXClient({ apiKey: "adnx_test_k1_a3f8e2b1c4d5" });const result = await client.submitRound({ negotiationId: "neg_4a2f", action: "counter", counterTerms: { salary_band: { min: 90000, max: 105000, currency: "EUR", period: "annual" }, },});{ "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.
8. Settlement
Section titled “8. Settlement”When both agents accept, the negotiation settles. Your webhook receives a
negotiation.accepted event.
{ "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.
Field Mapping
Section titled “Field Mapping”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 Field | OJP Field | Notes |
|---|---|---|
| Job Title | title | Max 200 chars |
| Description | description | Max 5000 chars |
| Location / City | location.city | |
| Country | location.country | ISO 3166-1 alpha-2 (DE, US, GB) |
| Remote Policy | location.arrangement | onsite, hybrid, remote |
| Salary Min | salary_band.min | Numeric, no formatting |
| Salary Max | salary_band.max | |
| Currency | salary_band.currency | ISO 4217 (EUR, USD, GBP) |
| Salary Period | salary_band.period | annual, monthly, daily, hourly |
| Employment Type | employment_type | full_time, part_time, contract, freelance, internship, temporary |
| Seniority Level | seniority | intern, junior, mid, senior, staff, lead, principal, director, vp, c_level |
| Required Skills | must_have.skills[] | { name, min_level (1-5), min_years? } |
| Nice-to-Have Skills | nice_to_have.skills[] | Same structure, used for bonus scoring |
| Required Languages | must_have.languages[] | { language (ISO 639-1), proficiency (CEFR: A1-C2, native) } |
| Visa Sponsorship | location.visa_sponsorship | boolean |
ATS format examples
Section titled “ATS format examples”Different ATS platforms store jobs differently. Here’s how the same “Senior Backend Engineer” looks across common formats.
{ "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..."}{ "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"]}{ "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.
OJP Conversion Patterns
Section titled “OJP Conversion Patterns”Common challenges when converting ATS data to OJP, and how to handle them.
Free-text to structured skills
Section titled “Free-text to structured skills”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.
// 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 }] }}Salary normalization
Section titled “Salary normalization”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.
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' };}Location formatting
Section titled “Location formatting”OJP requires country as ISO 3166-1 alpha-2
and city as a separate field. Parse combined location strings.
// "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;}Skills taxonomy
Section titled “Skills taxonomy”The exchange matches skills by normalized name. Use consistent casing and avoid abbreviations where the full name is standard.
| ATS variation | Normalized OJP name |
|---|---|
JS, Javascript, javascript | JavaScript |
TS, Typescript | TypeScript |
Postgres, postgres, PG | PostgreSQL |
K8s, k8s | Kubernetes |
AWS, Amazon Web Services | AWS |
GCP, Google Cloud | GCP |
Node, node.js, NodeJS | Node.js |
Webhook Integration
Section titled “Webhook Integration”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.
Error Handling
Section titled “Error Handling”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.