To convert a PDF or DOCX resume to the JSON Resume format, parse the file into
structured fields, map those fields onto the JSON Resume schema, and validate
the result against the official schema.json. JSON Resume is an
open standard that describes a resume as one JSON document with sections such
as basics, work, education,
skills and languages. Here the parsing is one
POST /api/v3/parser request to HireLayer CV Extract,
authenticated with an X-API-Key header, and a Python script of
about 110 lines does the mapping and the validation.
| Key facts | Value |
|---|---|
| Schema version | JSON Resume 1.7.0, a Draft-07 JSON Schema (npm:
@jsonresume/schema) |
| Schema file | packages/schema/schema.json in the
jsonresume/jsonresume.org repo (the old
resume-schema repo is archived) |
| Format | Every field optional, no null; ISO 8601 dates such as
2023, 2023-04 or 2023-04-29 |
| Validation | resume validate (resume-cli, Node.js 18+) or Python
jsonschema with a FormatChecker |
| Rendering | resume serve and resume export (HTML, PDF),
with 67 community themes on npm |
| HireLayer endpoint | POST https://hirelayer.co/api/v3/parser, multipart
file, 1 credit per successful parse (Free plan: 50
credits a month) |
| Docs | jsonresume.org/schema, CV Extract reference (checked November 5, 2026) |
1. Parse the PDF or DOCX
The hard part isn't the JSON, it's reading the document: columns, tables, scans, dates written ten different ways. CV Extract takes one file per request (PDF, DOC, DOCX, ODT, RTF, TXT, HTML, Markdown, JPG, PNG and more, up to about 4.5 MB) and returns fixed fields in the same response.
Parsing usually takes about 35 seconds, longer for scans, and the API answers
504 after 145 seconds. Keep the client timeout at 150 seconds or
more, and retry only the 5xx errors. Failed calls aren't
charged.
This first block holds the imports and the API call:
import json
import os
import sys
import time
from pathlib import Path
from urllib.parse import urlparse
import requests
from jsonschema import Draft7Validator, FormatChecker
API_URL = "https://hirelayer.co/api/v3/parser"
SCHEMA_URL = (
"https://raw.githubusercontent.com/jsonresume/jsonresume.org"
"/master/packages/schema/schema.json"
)
def parse_resume(path: Path, attempts: int = 4) -> dict:
"""POST the PDF or DOCX to CV Extract V3, retrying 5xx errors."""
for attempt in range(1, attempts + 1):
with path.open("rb") as handle:
response = requests.post(
API_URL,
headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
files={"file": (path.name, handle)},
timeout=150, # the API answers 504 after 145 s
)
if response.status_code == 200:
return response.json()
if response.status_code < 500 or attempt == attempts:
request_id = response.headers.get("x-parser-request-id")
raise RuntimeError(
f"CV Extract {response.status_code} ({request_id}): {response.text[:300]}"
)
time.sleep(2**attempt)A 4xx (bad file, no credits, 422 not a resume) is
raised right away with the x-parser-request-id, as the errors guide recommends. Want to see the raw response first? Drop a file into the free online resume parser.
2. Map the fields
Left: the CV Extract V3 field, as named in the API reference. Right: where it lands in JSON Resume 1.7.0.
| CV Extract V3 | JSON Resume | Note |
|---|---|---|
info_candidate.full_name, job_title | basics.name, basics.label | As written in the resume. |
info_candidate.email, phone_number | basics.email, basics.phone | Phone in E.164 when valid. |
info_resume.face_url | basics.image | Only when face_url_expires_at is null. |
info_candidate.other_urls[0] | basics.url | https:// added when missing. |
info_candidate.location: full_address,
postal_code, city, country_code
, region | basics.location: address,
postalCode, city, countryCode,
region | ISO 3166-1 alpha-2 on both sides. |
linkedin_url, github_url | basics.profiles[] | network, username, full url. |
work_experiences[]: company_name,
job_title, description, city + country | work[]: name, position,
summary, location | Location as free text, e.g. London, United Kingdom. |
start_date, end_date,
currently_active | startDate, endDate | endDate omitted for a current entry. |
educations[]: school_name,
degree_title, description | education[]: institution,
studyType, summary | Same date rule as work. |
skills[]: skill_type,
skill_title | skills[]: name, keywords | One group per skill type. |
languages[]: language, level | languages[]: language, fluency | e.g. Professional Working Proficiency (B2). |
certifications[], interests[] | certificates[].name, interests[].name | Plain strings in the API. |
info_resume.date_parsing | meta.lastModified | YYYY-MM-DDThh:mm:ss |
Nothing in the CV Extract response feeds basics.summary,
objective, projects, awards,
publications or references, so those sections stay
out. In practice, you can add them by hand afterwards.
3. Write the converter
The mapping function follows the table line by line. Skills are grouped by
skill_type because themes render skills[] as a name
plus a list of keywords, and the photo is skipped when its link expires:
def to_json_resume(parsed: dict) -> dict:
"""Map a CV Extract V3 response onto JSON Resume 1.7.0."""
person, info = parsed["info_candidate"], parsed["info_resume"]
loc = person["location"]
skills: dict[str, list[str]] = {}
for skill in parsed["skills"]: # themes render skills grouped under a name
skills.setdefault(skill["skill_type"], []).append(skill["skill_title"])
return clean({
"$schema": SCHEMA_URL,
"basics": {
"name": person["full_name"],
"label": person["job_title"],
# a face_url with an expiry (do_not_store_data=true) dies after 10 minutes
"image": None if info["face_url_expires_at"] else info["face_url"],
"email": person["email"], "phone": person["phone_number"],
"url": as_url((person["other_urls"] or [None])[0]),
"location": {"address": loc["full_address"], "postalCode": loc["postal_code"],
"city": loc["city"], "countryCode": loc["country_code"],
"region": loc["region"]},
"profiles": [profile("LinkedIn", person["linkedin_url"]),
profile("GitHub", person["github_url"])],
},
"work": [{"name": job["company_name"], "position": job["job_title"],
"location": place(job["work_experience_city"],
job["work_experience_country"]),
"startDate": job["start_date"], "summary": job["description"],
"endDate": None if job["currently_active"] else job["end_date"]}
for job in parsed["work_experiences"]],
"education": [{"institution": edu["school_name"], "studyType": edu["degree_title"],
"startDate": edu["start_date"], "summary": edu["description"],
"endDate": None if edu["currently_active"] else edu["end_date"]}
for edu in parsed["educations"]],
"skills": [{"name": name, "keywords": list(dict.fromkeys(words))}
for name, words in skills.items()],
"languages": [{"language": lang["language"], "fluency": lang["level"]}
for lang in parsed["languages"]],
"certificates": [{"name": name} for name in parsed["certifications"]],
"interests": [{"name": name} for name in parsed["interests"]],
"meta": {"lastModified": info["date_parsing"]},
})The helpers and the entry point finish the script. clean() runs
last because most CV Extract fields are null when the resume
doesn't mention them, and JSON Resume rejects a null where
it expects a string:
def clean(value):
"""Drop None and empty values, recursively: JSON Resume rejects null."""
if isinstance(value, dict):
value = {k: clean(v) for k, v in value.items()}
return {k: v for k, v in value.items() if v not in (None, "", [], {})}
if isinstance(value, list):
return [v for v in map(clean, value) if v not in (None, "", [], {})]
return value
def as_url(value: str | None) -> str | None:
"""Resumes write "github.com/name", but the schema wants a full URI."""
return value if not value or "://" in value else f"https://{value}"
def place(city: str | None, country: str | None) -> str | None:
return ", ".join(part for part in (city, country) if part) or None
def profile(network: str, url: str | None) -> dict | None:
url = as_url(url)
if url:
username = urlparse(url).path.rstrip("/").split("/")[-1] or None
return {"network": network, "username": username, "url": url}
def main() -> None:
parsed = parse_resume(Path(sys.argv[1]))
resume = to_json_resume(parsed)
schema = requests.get(SCHEMA_URL, timeout=30).json()
validator = Draft7Validator(schema, format_checker=FormatChecker())
errors = [f"{e.json_path}: {e.message}" for e in validator.iter_errors(resume)]
if errors:
sys.exit("Invalid JSON Resume:\n" + "\n".join(errors))
json.dump(resume, sys.stdout, indent=2, ensure_ascii=False)
if __name__ == "__main__":
main()Honestly, as_url() only exists because of the first test run. The
fictional resume wrote github.com/… and
linkedin.com/in/…, the API returned them as written, and the
schema rejected both as not being a uri. Wich is exactly the kind
of thing validation is there to catch.
4. Validate and render
Install the dependencies, run the script, then check the file with the
official CLI. The format-nongpl extra matters: without it,
jsonschema silently skips the uri checks.
python -m pip install requests "jsonschema[format-nongpl]"
export HIRELAYER_API_KEY="your-key-from-the-dashboard"
python to_json_resume.py resume.pdf > resume.json
npm install -g resume-cli
npm install jsonresume-theme-elegant
resume validate --resume resume.json
resume serve --resume resume.json --theme elegant
resume export resume.pdf --resume resume.json --theme elegantresume validate exits non-zero on errors, so it fits in CI. From
Node.js, the @jsonresume/schema package exposes the same check as
a validate function. resume serve previews the file
locally, and a short theme name like elegant loads the
jsonresume-theme-elegant package. Here is a trimmed ouput from
that test resume:
{
"basics": {
"name": "Daniel OKAFOR",
"label": "Senior Backend Engineer",
"phone": "+447700900418",
"profiles": [
{ "network": "GitHub", "username": "dokafor-dev", "url": "https://github.com/dokafor-dev" }
]
},
"work": [
{ "name": "Deliveroo", "position": "Senior Backend Engineer", "startDate": "2022-01-01" }
],
"languages": [{ "language": "French", "fluency": "Limited Working Proficiency (B1)" }]
}For a batch, store the CV Extract JSON next to each resume.json
so you can re-map it later without paying for a second parse, and vendor a
copy of schema.json in your repo so a schema change never breaks
a deploy. The resume PDF to JSON API guide covers the raw parser output in more detail.
Fields that do not map 1:1
- Dates written as a year. CV Extract always returns
YYYY-MM-DD: "2015" becomes2015-01-01as a start and2015-12-31as an end. Trim them to the year if your theme shows full dates. - Degrees. The full
degree_titlegoes intostudyType; splitting out theareareliably across languages is guesswork. - Skills. There's no level per skill in the response, so
skills[].levelstays empty. - Volunteering. It arrives in
work_experienceswithcontract_typeVolunteering. Move those entries tovolunteer[]and renamenametoorganizationif you need the seperate section. - The photo. With
do_not_store_data=true(Enterprise plan),face_urlis a temporary link that expires 10 minutes after parsing. Download the image first if you want it inbasics.image. - Last names in capitals. CV Extract capitalizes the first name and writes the last name in capitals, so the name reads "Daniel OKAFOR". Title-case the last name with care (think "McDonald") if your theme expects natural case.
- Personal data.
birth_date,driver_license,mobilityand the full resume text are left out on purpose, since a JSON Resume often gets published. Put what you need under a namespacedcustomkey.
Frequently asked questions
What is the JSON Resume format?
An open, MIT-licensed standard that stores a resume as one JSON document, defined by a Draft-07 JSON Schema (version 1.7.0 on November 5, 2026). Every field is optional.
How do I convert my resume to JSON Resume?
Parse the PDF or DOCX with a resume parser API, map the fields to the
schema, drop null values, then run resume validate. The
Python script above does it in one command.
Can I convert a LinkedIn PDF export to JSON Resume?
Yes. A LinkedIn profile saved as PDF is a regular PDF, so the same script works unchanged.
How much does it cost to convert a resume?
The JSON Resume tools are free and open source. The parsing call costs 1 HireLayer credit per successful parse, and the Free plan includes 50 credits a month.
Check the fields on your own resume
Upload a PDF or DOCX in the live demo to see the raw CV Extract JSON, then follow the API reference for the full response schema.
Sources and further reading
- JSON Resume: home page and getting started
- JSON Resume: schema reference (version 1.7.0)
- JSON Resume: resume-cli commands and options
- jsonresume/resume-schema on GitHub (archived, moved notice)
- jsonresume/jsonresume.org: packages/schema (schema.json, README)
- HireLayer API documentation: CV Extract (V3)
- HireLayer API documentation: Errors and retries
- jsonschema (Python): validating formats
Louis Desclous
Published on · Reading time: 6 minutes
