This is Joonow's public jobs feed specification for applicant tracking systems (ATS) and career sites. With it you build the feed, check it in the validator and request the integration without talking to anyone. The program is free: see what your ATS gets.
Summary
- One XML file at a public URL, ideally with
https://, with all of your clients' open jobs. - Each job needs a stable id, title, description, apply URL and company.
- Recommended: the email of the person responsible for the job (
email). It never shows on the site and moves the job up in the ranking (why include it). - Recommended: the company's website and logo (
companyurl,companylogo); the description (companydescription) is optional. The logo shows on the jobs, in search and in the directory, and all three help build the company page (why include them). - We read the feed every 6 hours (the schedule is agreed on approval), with a conditional GET (
ETagandLast-Modified). - A job that leaves the file is closed on the second read in a row without it.
- Applications happen on your site: the button takes people to the job URL with
utm_source=joonow. Or, with applications by webhook turned on, candidates apply on Joonow and the signed application reaches your system. - Before requesting, validate in the validator: it shows each job's errors and stores nothing.
Accepted formats
| Format | When to use it | Element for each job |
|---|---|---|
| Joonow XML (recommended) | You are building the feed now or already generate the Indeed XML. | <job> |
| Indeed XML | Your ATS already publishes the standard Indeed XML (requisitionid, sourcename, remotetype). |
<job> |
| LinkedIn XML | Your ATS already generates the LinkedIn jobs XML (partnerJobId, applyUrl). |
<job> |
All three use a <source> root with a list of <job> elements. Element names are case-insensitive and text can come inside CDATA.
Joonow XML
It is the XML many ATS already generate for Indeed, with a few extra fields (expiration, hybrid model and level). Download the example XML and the XSD schema.
<?xml version="1.0" encoding="UTF-8"?>
<source>
<publisher>Your ATS name</publisher>
<publisherurl>https://www.your-ats.com</publisherurl>
<lastBuildDate>2026-10-30T09:00:00Z</lastBuildDate>
<job>
<referencenumber>ACME-4821</referencenumber>
<title><![CDATA[Junior Support Analyst]]></title>
<company><![CDATA[Acme Logistics]]></company>
<email>recruiting@acme.example</email>
<companyurl>https://www.acme.example/</companyurl>
<companylogo>https://www.acme.example/img/logo-acme-256.png</companylogo>
<companydescription><![CDATA[Logistics operator with warehouses in Pernambuco and Bahia.]]></companydescription>
<url><![CDATA[https://acme.your-ats.com/jobs/4821]]></url>
<date>2026-10-28T13:00:00Z</date>
<expirationdate>2026-12-15T23:59:59Z</expirationdate>
<city><![CDATA[Austin]]></city>
<state>TX</state>
<country>US</country>
<description><![CDATA[<p>Customer support...</p><h3>Requirements</h3><ul><li>...</li></ul>]]></description>
<salary><![CDATA[US$ 3800 to US$ 4200 per month]]></salary>
<jobtype>fulltime</jobtype>
<category>Professional</category>
<remotetype>Hybrid remote</remotetype>
</job>
</source>
Job fields
| Element | Required | Type | Example | How Joonow uses it |
|---|---|---|---|---|
referencenumber |
Yes | Text, up to 200 characters | ACME-4821 |
Identifies the job from one read to the next. It must be unique and stable: if it changes, it becomes a different job. Without it, we use the apply URL. |
title |
Yes | Text, up to 200 characters | Junior Support Analyst |
Job title, in search and on the page. An ALL CAPS title becomes normal case (acronyms stay). |
description |
Yes | Simple HTML, up to 50 KB | <p>…</p><ul><li>…</li></ul> |
Job description. Only p, br, ul, ol, li, strong, b, em, i, u, h2, h3, h4, a, blockquote and hr are kept; everything else (scripts, styles, images) is removed. |
url |
Yes | https:// URL |
https://acme.your-ats.com/jobs/4821 |
Destination of the "Apply" button, with utm_source=joonow. It must open the job itself. |
company |
Yes | Text | Acme Logistics |
Company name: it creates the company page with all its jobs. Empty becomes "Confidential". |
city |
Recommended | Text | Austin |
City: filters, location search and city pages. |
state |
Recommended | Code or name | TX |
State or region. In Brazil, the state code (UF) or the full name. |
country |
Recommended | 2-letter ISO code or name | US |
Country. Empty uses the feed's default country, set on approval. |
date |
Recommended | Date (ISO 8601 or RFC 1123) | 2026-10-28T13:00:00Z |
Posting date: sorting, "posted X days ago" and the job's age. Without it, we use the read date; a future date becomes today. |
email |
Recommended | Email, up to 254 characters | recruiting@acme.example |
Work email of the person responsible for the job. It never shows on the site. The job gets ranking points and, if it gets reported, the Joonow team has someone to talk to (why include it). An invalid one is ignored, without rejecting the job. |
companyurl |
Recommended | Absolute URL | https://www.acme.example/ |
The company's own website (not the ATS, a social profile or a jobs page). It becomes the link on the company page and helps us find the logo and social profiles (why include them). |
companylogo |
Recommended | Absolute image URL | https://www.acme.example/img/logo.png |
Company logo in PNG, JPEG or WebP, 128 px or larger (square works best). Joonow downloads a copy and serves it from its own site. |
companydescription |
Optional | Text or simple HTML, up to 4,000 characters | Logistics operator with warehouses in the Northeast. |
What the company does, for its page. Repeat the same text on all of the company's jobs. |
expirationdate |
Optional | Date (ISO 8601) | 2026-12-15T23:59:59Z |
Expiration: after it, the job is closed. A job that arrives already expired is rejected. |
salary |
Optional | Text with an amount or range | US$ 3800 to US$ 4200 per month |
Salary, only when the employer discloses it. We recognize currency (R$, US$, €), ranges and period (hour, day, week, month, year). |
jobtype |
Optional | Code | fulltime |
Contract type (values below). |
category |
Optional | Code | Professional |
Job level (values below). Other values are ignored. |
isRemote |
Optional | Yes or No |
Yes |
Yes = fully remote; No = on-site. |
remotetype |
Optional | Text | Hybrid remote |
Fully remote = fully remote; Hybrid remote = hybrid. Used when isRemote is absent. |
The root's publisher, publisherurl and lastBuildDate elements are optional: they help identify the file but do not change the jobs.
Why include the job owner's email
The email element (posterEmail in the LinkedIn XML) is the work email of whoever is responsible for the job: the recruiter or the employer's HR team mailbox. It is optional, but worth it:
- The job ranks higher. The email adds points to the listing's quality score, like the salary and a complete description, and that score breaks ties among the jobs that match a search: with the email, the job moves ahead of equivalent ones without it. Showing up earlier usually brings more views and more applications.
- There is someone to talk to before the job comes down. When the job is reported for something that can be fixed (wrong details, a swapped link), the Joonow team writes to that email before deciding whether to take it down.
- The email stays with the Joonow team. It never appears on the job, in search, in the API or to candidates, it is not sold or used for marketing, and it is deleted 30 days after the job comes down (privacy policy).
Send one complete address per job (recruiting@company.com). "No-reply" mailboxes don't count. An invalid email is ignored without rejecting the job (it goes live without the point), and the validator shows how many jobs include the email.
Why include the company website, logo and description
companyurl, companylogo and companydescription build the page of each client company on Joonow, with the same value on all of its jobs:
- The logo shows everywhere: on the job card, in search, on the open job and in the company directory, and it goes into the job's JSON-LD for Google.
- They help build the company page: the website becomes the page's link once checked, the social profiles come from it, and the description goes, together with the company's own website, into the short text we write about it (we never publish the description as it came).
- None of this changes the job: website, logo and description belong to the company. A new logo doesn't rewrite the jobs, and an invalid value is ignored without rejecting the job.
The logo is downloaded once and served by Joonow (we never load images from your server on the page). If the company claims its page, what it writes replaces what came in the feed. The validator shows how many jobs include the logo and the website.
Accepted values
jobtype (contract type): fulltime (permanent, full time), parttime (part time), contract (contractor, freelancer), temporary (temporary), internship (internship) and volunteer (volunteer). The Brazilian Portuguese labels are accepted too: Efetivo – CLT, Estatutário, Estágio, Prestador de Serviços - PJ, Freelancer, Autônomo, Cooperado, Temporário, Tempo Determinado and Intermitente.
category (level): the codes Apprentice, Intern, Entry (early career), Operational, Professional, Management (coordination and management) and Executive (leadership), or the Portuguese labels Aprendiz, Estágio, Trainee, Operacional, Profissional, Supervisão/Coordenação and Gerencial e Executivos.
Work model: isRemote with Yes or No, or remotetype with Fully remote, Hybrid remote or On-site. "COVID-19" (only temporarily remote) gets no work model.
What not to use
Our reader understands the priority element for compatibility, but it is not part of the program: sponsored jobs are not arranged through the feed. phone and internal ATS fields are ignored. Apart from the email of the person responsible for the job, do not send personal data in the feed (never candidate data).
Indeed XML
If your ATS already publishes the standard Indeed XML, choose "Indeed XML" in the validator. The differences from Joonow XML:
- the id can come in
referencenumberorrequisitionid; - the company can come in
companyorsourcename; jobtypein English (fulltime,parttime,contract,temporary,internship,volunteer,per diem);remotetypewithFully remoteorHybrid remote;emailis read as the email of the person responsible for the job, used as in Joonow XML (why include it);categoryandexperienceare ignored.
LinkedIn XML
If your ATS already generates the LinkedIn jobs XML, choose "LinkedIn XML". We read:
| Element | Use |
|---|---|
partnerJobId |
Stable job id |
title, description, applyUrl |
Title, description and apply URL |
company, companyId |
Company; the group's companyId together with the name separates each brand |
city, state, country |
Location |
listDate, expirationDate |
Posting and expiration |
salaries/salary/lowEnd/amount, highEnd/amount, currencyCode, period |
Structured salary (HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY) |
jobType |
FULL_TIME, PART_TIME, CONTRACT, TEMPORARY, INTERNSHIP, VOLUNTEER |
experienceLevel |
INTERNSHIP, ENTRY_LEVEL, ASSOCIATE, MID_SENIOR_LEVEL, DIRECTOR, EXECUTIVE |
workplaceTypes |
On-site, Hybrid or Remote |
posterEmail |
Email of the person responsible for the job, used like email in Joonow XML (why include it) |
How we read the feed
- Frequency: every 6 hours by default. The schedule is agreed on approval, and the partner dashboard has a "Request a read now" button.
- Conditional GET: we send
If-None-MatchandIf-Modified-Sincewith the previous read'sETagandLast-Modified. A304response ends the read without downloading anything; a file identical to the previous one also stores nothing. - Compression: use HTTP compression (
Content-Encoding: gzip,deflateorbr). A downloadable.gzfile is not accepted. - Size: up to 2 GB uncompressed and 15 minutes of download.
- Address: a public URL,
httporhttps(preferhttps), up to 5 redirects. Internal network addresses are blocked. - Robot:
JoonowFeedBot/1.0 (+https://www.joonow.com/robots.txt). If you have a firewall, allow this User-Agent. - XML: UTF-8, without
<!DOCTYPE>or external entities. A file with a DTD is rejected for security reasons.
Job lifecycle
- New: it goes in on the next read and shows up on the site within minutes.
- Changed: we update it when the job's content changes (title, description, location, salary, dates…).
- Closed: when it is missing from the file in two reads in a row, when its expiration (
expirationdate) passes, or when the apply link is down in two checks 24 hours apart. - Maximum age: a job that is never renewed (its
datenever moves forward) is closed after 60 days; no job stays live for more than 180 days. To keep a job, republish it with a new date. - After closing: the page shows "job closed" with similar jobs and, after 30 days, answers 410 to search engines.
Always send the complete inventory on every read: a partial file closes the jobs that were left out.
Safety valve
So that a broken feed does not take down your clients' jobs, the read is quarantined when:
- more than 5% of the records are rejected; or
- the number of valid jobs drops more than 30% compared to the previous read (with 50 jobs or more).
In quarantine nothing is published or closed, and the partner dashboard shows why. Fix the file and request a new read.
Confidential company
A job without company shows as Confidential: it has no company page and is not in the directory. If your ATS puts its own name in place of the company on confidential jobs, tell us in the integration request: we treat that name as "Confidential", and your brand never shows up as the hiring company.
Salary
- Send it only when the employer discloses it: we never estimate salaries.
- A single amount or a range, with currency and period:
US$ 4000,US$ 3800 to US$ 4200 per month,US$ 25/hour. - Write thousands without a separator or with a dot (
3800or3.800): a comma is always read as the decimal mark, so3,800becomes 3.80. - Without a period, we assume monthly. Jobs with a salary are included in the salary filter and in Google's structured data.
Location and language
- City and state where the person will work. A fully remote job can come without a city, with
isRemote=Yes. - Country as a 2-letter ISO code (
BR,US,MX…). Without a country, the feed's default country applies. - Language: one feed per language (Portuguese, Spanish or English). The language is set on approval; for jobs in more than one language, send separate feeds.
Applying
- Use the direct job URL on your ATS, with
https://. - No home page, job list, URL shortener, WhatsApp, Telegram or email: the job gets a warning or is rejected.
- We add
utm_source=joonow&utm_medium=referral(if the URL already hasutm_source, we keep yours). That way you measure applications coming from Joonow in your analytics. - Want the application in your system without the candidate leaving Joonow? Turn on applications by webhook.
Quality and review
Each job goes through risk rules before going live: asking the candidate for payment, applying through a messaging app, personal email in the description, shortened links, very short descriptions, salaries far from the role's reality, among others. Above the limit, the job is held for review by the team and does not show up until approved. The validator shows these signals before the integration.
Best practices for jobs to look good
- Clear title with just the role: "HR Analyst", not "JOB 123 – HR ANALYST URGENT!!!".
- Complete description (duties, requirements, benefits) in simple HTML with headings and lists. More than 200 characters.
- Salary whenever the employer allows it.
- The job owner's email on every job: it ranks higher and, if it gets reported, the team has someone to talk to.
- Company website and logo on every job (and the description, if you have one): the logo shows in search and they help build the company page.
- Correct city and state; remote jobs marked as remote.
- Real dates for posting and, when there is one, expiration.
- Stable ids: editing the job does not change the
referencenumber. - One job, one record: the same job in several cities becomes one record per city, each with its own id.
On Google
Every Joonow job has its own page with JobPosting structured data built from your feed: baseSalary only with a disclosed salary, validThrough only when there is an expiration, remote jobLocationType only for fully remote jobs and the company name as hiringOrganization. Complete and correct data in the feed helps jobs show up in Google's job search.
Applications by webhook
Besides sending jobs, your ATS can receive applications from Joonow. With the webhook on, people who apply to a job in your feeds do it on Joonow itself: they see the company, your system as the destination and the list of what will be sent, they confirm, and Joonow delivers the application to your system, which creates the record on behalf of the client company. Jobs from partners that didn't turn it on work as always: the candidate goes to the apply URL. The ATS integration terms apply.
How to turn it on
In the partner panel, under Applications:
- Accept the integration terms (the version, the date and who accepted are recorded).
- Enter the HTTPS URL that receives deliveries: port 443, a domain name (not an IP) and no username or password in the URL. We don't follow redirects.
- Generate the secret key. It's shown once: keep it in a secrets vault.
- Send the test application (fake data,
"test": true) and answer 2xx. - Turn receiving on. Changing the URL turns it off until a new test.
The delivery
Each delivery is a POST to your URL, with Content-Type: application/json; charset=utf-8, a UTF-8 body and these headers:
| Header | Example | What it's for |
|---|---|---|
Joonow-Event-Id |
0193f5c2-7b1e-7c4a-9d2e-5a1f0c3b8e21 |
Unique delivery id (the same id as the body): your idempotency key. |
Joonow-Event-Type |
application.submitted |
Event type (the same type as the body). |
Joonow-Timestamp |
1793628191 |
Signing time, in Unix seconds. |
Joonow-Signature |
v1=5d4a… |
HMAC-SHA256 of the key over {timestamp}.{body}, in hex. |
User-Agent |
JoonowWebhooks/1.0 |
Identifies Joonow (not proof: the signature is the proof). |
Your server has 10 seconds to answer.
Application body (application.submitted, version 1)
{
"id": "0193f5c2-7b1e-7c4a-9d2e-5a1f0c3b8e21",
"type": "application.submitted",
"version": "1",
"created_at": "2026-11-02T14:03:11Z",
"test": false,
"data": {
"application": { "id": "0193f5c2-7b1e-7c4a-9d2e-5a1f0c3b8e21", "submitted_at": "2026-11-02T14:03:11Z", "source": "joonow" },
"job": {
"external_id": "ACME-4821",
"joonow_id": "k3j9x2m4pq",
"title": "Junior Support Analyst",
"company": "Acme Logistics",
"url": "https://www.joonow.com/en/job/junior-support-analyst-k3j9x2m4pq"
},
"candidate": {
"name": "Maria Souza",
"email": "maria.souza@example.com",
"phone": "+1 305 555 0100",
"location": { "city": "Miami", "region": "FL", "country": "US" },
"experiences": [
{ "title": "Customer service representative", "company": "Example Store", "start": "2023-04", "current": true, "summary": "Customer service and after-sales." }
],
"education": [
{ "course": "Business Administration", "institution": "Example College", "level": "undergraduate", "start": "2019-02", "end": "2022-12", "current": false }
],
"skills": ["Customer service", "Excel"],
"languages": [{ "name": "Spanish", "level": "intermediate" }],
"salary_expectation": { "amount": 3000, "currency": "USD", "period": "month" }
},
"answers": [{ "question": "Are you available to work on Saturdays?", "answer": "Yes" }],
"resume": {
"file_name": "maria-souza.pdf",
"content_type": "application/pdf",
"size_bytes": 182344,
"url": "https://www.joonow.com/api/partners/v1/resumes/4bZ9qT…",
"expires_at": "2026-11-05T14:03:12Z"
}
}
}
| Field | Always sent? | What it is |
|---|---|---|
id |
yes | Delivery id (UUID), equal to the Joonow-Event-Id header. |
type |
yes | application.submitted. |
version |
yes | 1. New fields may appear without a version change; a breaking change becomes 2, with 90 days' notice. Ignore what you don't know. |
created_at |
yes | When the candidate confirmed (ISO 8601, UTC). |
test |
yes | true for the panel's test application: answer 2xx, but don't create anyone. |
data.application.id |
yes | Application id (the same as the delivery). Use it to send the status back. |
data.application.source |
yes | joonow. |
data.job.external_id |
yes | The job id in your feed (referencenumber, partnerJobId). |
data.job.joonow_id, title, company, url |
yes | The job as it is on Joonow. |
data.candidate.name, email |
yes | Name and email of the candidate's account. |
data.candidate.phone |
if filled in | As the candidate typed it. |
data.candidate.location |
if filled in | city, region (state or province) and country (ISO 3166-1, 2 letters). |
data.candidate.links |
reserved | List of { "type", "url" }. Not sent yet: it comes when the profile has links. |
data.candidate.experiences |
if filled in | title, company, start and end (YYYY-MM), current, summary. |
data.candidate.education |
if filled in | course, institution, level (high_school, technical, undergraduate, postgraduate, masters, doctorate, course), start, end, current. |
data.candidate.skills |
if filled in | List of strings. |
data.candidate.languages |
if filled in | name and level (basic, intermediate, advanced, fluent, native). |
data.candidate.salary_expectation |
if filled in | amount, currency (ISO 4217) and period (hour, day, week, month, year). |
data.answers |
yes (may be empty) | question and answer: the answers to the job's questions. |
data.resume |
if there is a resume | file_name, content_type, size_bytes and the file: url with expires_at (link) or content_base64 (inside the delivery). |
Only what the candidate filled in and saw on the confirmation screen is sent: empty fields are left out (no null and no empty lists, except answers). Sensitive data (such as disability) is not sent in this version, and neither are the job's sensitive questions: the confirmation screen doesn't show them.
Resume
- Temporary link (default):
data.resume.urlis a signedGETvalid for 3 days from each delivery attempt. Download it right away and store it in your system. The link needs no login: treat it as a secret and don't log it. Once expired, or after the candidate deletes the account, it answers 404. - Inside the delivery: choose it in the panel; the file goes in
data.resume.content_base64(PDF or DOCX, up to 5 MB).
Verifying the signature
- Read the raw body, the bytes as they arrived, before parsing JSON.
- Check that
Joonow-Timestampis at most 5 minutes from your clock, either way. - Compute
HMAC-SHA256(key, "{Joonow-Timestamp}.{body}")in lowercase hex and prefix it withv1=. - Compare, in constant time, with each item of
Joonow-Signature(comma separated). One match is enough: while rotating the key there are two, the new key's and the previous one's, for 24 hours. - No valid signature or outside the window: answer 401 and store nothing.
import crypto from 'node:crypto';
function validSignature(rawBody, headers, key) {
const t = headers['joonow-timestamp'] ?? '';
if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = Buffer.from('v1=' + crypto.createHmac('sha256', key).update(t + '.').update(rawBody).digest('hex'));
return (headers['joonow-signature'] ?? '').split(',').map((s) => Buffer.from(s.trim()))
.some((s) => s.length === expected.length && crypto.timingSafeEqual(s, expected));
}
Response, idempotency and retries
- Answer 2xx only after storing the application. The response body is ignored.
- The same
idmay arrive more than once (a retry after a network failure, or the candidate resending an application that didn't arrive). If you already have thatid, answer 2xx without creating it again. - Any other answer, or none within 10 seconds, is a failure. A redirect (3xx) is a failure too.
- We retry with growing intervals (1 min, 5 min, 30 min, 2 h, 6 h and then every 12 h) up to 72 hours after the confirmation. Then we stop and email the candidate and your ATS contact.
- 410 Gone means "this job no longer accepts applications": we stop right away and let people know.
- The panel shows each delivery, the attempts, the last response and the failures.
Status back
So the candidate can follow along on Joonow, send each application's status to https://www.joonow.com/api/partners/v1/events, with POST, Content-Type: application/json and the same Joonow-Timestamp and Joonow-Signature, computed with your key the same way.
{ "type": "application.in_review", "application_id": "0193f5c2-7b1e-7c4a-9d2e-5a1f0c3b8e21", "occurred_at": "2026-11-03T10:15:00Z" }
type:application.received(stored),application.in_review(being reviewed) orapplication.closed(the process ended for the candidate). The status only moves forward: a late event doesn't move it back.- Responses:
200with{"ok":true,"applied":true}(orfalse, when nothing changed),400for an unknown event,401for a wrong signature, time or id,413above 16 KB and429when there are too many. - The test application accepts the status too: use it to test the way back.
- The candidate sees the status only in their own area and gets an email when the application goes into review or is closed.
Correction and erasure requests
When a candidate asks Joonow to correct or erase their data, we notify every system that received an application from them (LGPD, art. 18, § 6), through the same webhook, with the same signature and the same retries:
{
"id": "0193f611-2c40-7e2b-8a51-0d9e7f3a1c55",
"type": "candidate.erasure_requested",
"version": "1",
"created_at": "2026-11-20T09:00:00Z",
"test": false,
"data": {
"reason": "account_deleted",
"candidate": { "email": "maria.souza@example.com" },
"applications": [{ "id": "0193f5c2-7b1e-7c4a-9d2e-5a1f0c3b8e21", "job_external_id": "ACME-4821", "submitted_at": "2026-11-02T14:03:11Z" }],
"respond_by": "2026-12-05T09:00:00Z"
}
}
candidate.erasure_requested: erase (or ask the client company to erase) what came from Joonow.reasonisaccount_deleted(the candidate deleted the account) orcandidate_request(asked through the privacy center).candidate.correction_requested:data.candidatecarries the current data, in the same format as the application, to replace what you received.- Pass it on to the client company and confirm within 15 days (
respond_by) with an event back:{ "type": "candidate.erasure_completed", "request_id": "<request id>" }(orcandidate.correction_completed).
Security
- HTTPS only, with TLS 1.2 or newer. Reject deliveries without a valid signature or outside the 5-minute window.
- Keep the key in a secrets vault and rotate it in the panel if you suspect a leak.
- Don't log application bodies or resume links in open logs.
- Deliveries come from IP addresses that may change: trust the signature, not the IP.
How to request the integration
- Build the feed following this specification.
- Paste the URL into the validator and fix what it points out.
- With the feed validated, fill in the request right there. The team reviews it within 2 business days.
- Once approved, you get access to the partner dashboard by email, with reads, live jobs, rejections and clicks per month.
Technical questions? Talk to us through Contact us.