This tutorial parses a resume from Java with nothing but the JDK’s
java.net.http.HttpClient and Jackson. You build the
multipart/form-data body yourself (the JDK client has no helper
for it), send it to the HireLayer resume parsing API, read the JSON into
records, and handle each documented error with retries that never charge you
twice. A Spring Boot controller closes the article.
The code needs Java 17 or later. The same API is covered for Python and Node.js, PHP and C# and .NET.
What you need
- Java 17 or later. The models are records and the error handling uses switch expressions.
- Jackson 2.17 or later for JSON, with the
jsr310module forLocalDate. In Maven:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.18.2</version>
</dependency>
- An API key from the dashboard, in the
HIRELAYER_API_KEYenvironment variable of the server. Never embed it in an Android app or a desktop client, where it can be extracted. - A test resume, ideally already tried in the live demo so you know what the JSON looks like.
The request the API expects
Every value below comes from the CV Extract V3 reference.
| Item | Value |
|---|---|
| Endpoint | POST https://hirelayer.co/api/v3/parser, one file per
request, result in the same response. |
| Authentication | X-API-Key header. Authorization: Bearer is
not accepted. |
| Body | multipart/form-data: required file; optional
application_id (echoed in
info_resume.application_id),
do_not_store_data and webhook_url. |
| Files | PDF, DOC, DOCX, ODT, PPT, PPTX, ODP, XLS, RTF, TXT, JPG/JPEG, PNG and BMP, detected from the content. Encoded upload up to 6 MiB, so about 4.5 MB of file. |
| Timing | About 35 seconds in general, more for scans. 504 after
145 seconds. Client timeout: at least 150 seconds. |
| Cost | 1 credit per 200. Failed calls are free. |
Sending do_not_store_data=true means the resume file is not kept:
info_resume.url is null and the cropped photo link
in info_resume.face_url expires 10 minutes after parsing, at
info_resume.face_url_expires_at. The default, false,
stores the file. This tutorial sends true.
Build the multipart body
HttpRequest.BodyPublishers can send bytes, strings and files, but
not a form. A multipart body is simple enough to write by hand: each part
starts with -- and the boundary, has its own headers, a blank
line, the content and a CRLF, and the body ends with the boundary followed by
--.
package com.example.hirelayer;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/** Minimal multipart/form-data writer: java.net.http has no built-in one. */
final class MultipartBody {
private final String boundary = "hirelayer-" + UUID.randomUUID();
private final ByteArrayOutputStream parts = new ByteArrayOutputStream();
MultipartBody field(String name, String value) {
writePart("form-data; name=\"" + name + "\"", null, value.getBytes(StandardCharsets.UTF_8));
return this;
}
MultipartBody file(String name, String fileName, byte[] content) {
// Quoted name and filename; keep the filename to safe ASCII characters.
String safeName = fileName.replaceAll("[^A-Za-z0-9._-]", "_");
writePart("form-data; name=\"" + name + "\"; filename=\"" + safeName + "\"",
"application/octet-stream", content);
return this;
}
String contentType() {
return "multipart/form-data; boundary=" + boundary;
}
byte[] toByteArray() {
var body = new ByteArrayOutputStream(parts.size() + 64);
body.writeBytes(parts.toByteArray());
body.writeBytes(("--" + boundary + "--\r\n").getBytes(StandardCharsets.US_ASCII));
return body.toByteArray();
}
private void writePart(String disposition, String contentType, byte[] content) {
var headers = new StringBuilder()
.append("--").append(boundary).append("\r\n")
.append("Content-Disposition: ").append(disposition).append("\r\n");
if (contentType != null) {
headers.append("Content-Type: ").append(contentType).append("\r\n");
}
headers.append("\r\n");
parts.writeBytes(headers.toString().getBytes(StandardCharsets.UTF_8));
parts.writeBytes(content);
parts.writeBytes("\r\n".getBytes(StandardCharsets.US_ASCII));
}
}
Three details matter. Line endings must be \r\n, not
\n. The name and filename values must
be in double quotes, as above; the endpoint does not read unquoted values or
the filename* form. And the Content-Type header of
the request must carry the same boundary. A random UUID makes a collision with
the file content practically impossible.
The whole body is held in memory. With files capped at about 4.5 MB that is fine, and it makes retries trivial: the same byte array can be sent again.
Map the JSON to records
The API returns snake_case keys. Jackson’s
PropertyNamingStrategies.SNAKE_CASE maps them to camelCase record
components, so info_candidate becomes
infoCandidate() and work_experiences becomes
workExperiences() without an annotation on every field.
package com.example.hirelayer;
import java.time.LocalDate;
import java.util.List;
public record ParsedResume(
String requestId,
String upstreamStatus,
List<String> warnings,
ResumeInfo infoResume,
Candidate infoCandidate,
List<WorkExperience> workExperiences,
List<Education> educations,
List<SpokenLanguage> languages,
List<Skill> skills,
List<String> certifications) {
public boolean partial() {
return "partial".equals(upstreamStatus);
}
public record ResumeInfo(
String applicationId, String language, String url, String faceUrl, String faceUrlExpiresAt) {}
public record Candidate(
String fullName,
String firstName,
String lastName,
String email,
String phoneNumber,
String jobTitle,
String educationLevel,
String experienceLevel,
String linkedinUrl,
String githubUrl,
Location location) {}
public record Location(
String city, String postalCode, String country, String countryCode, Double latitude, Double longitude) {}
public record WorkExperience(
String companyName,
String jobTitle,
String description,
String contractType,
LocalDate startDate,
LocalDate endDate,
Boolean currentlyActive,
Double experienceDuration) {}
public record Education(
String degreeTitle, String schoolName, String degreeType, LocalDate startDate, LocalDate endDate) {}
public record SpokenLanguage(String language, String level) {}
public record Skill(String skillTitle, String skillType, String status) {}
}
- Unknown fields are ignored because the client disables
FAIL_ON_UNKNOWN_PROPERTIES. Add more fields from the response schema when you need them, such asmobility,rome_jobsorinterests. - Boxed types for optional values. Most fields are
nullwhen the resume does not mention them, so the models useBooleanandDouble, neverbooleanordouble, which would silently turnnullintofalseor0. - Dates are
LocalDate. The API sendsYYYY-MM-DD. In work history, a year on its own becomes January 1 or December 31, andendDateisnullfor a current role. - Closed lists.
contract_type,experience_level,education_leveland languageleveluse fixed English values, so you can convert them to enums.degree_typeis not normalized.
A client with timeouts and retries
The client turns non-200 responses into an exception that keeps
the status, the API’s code and the request ID:
package com.example.hirelayer;
import java.io.IOException;
public final class HireLayerException extends IOException {
private final int status;
private final String code;
private final String requestId;
public HireLayerException(int status, String message, String code, String requestId) {
super(message);
this.status = status;
this.code = code;
this.requestId = requestId;
}
public int status() { return status; }
public String code() { return code; }
public String requestId() { return requestId; }
}
And here is the client itself. Create one instance and share it: both
HttpClient and Jackson’s ObjectMapper are
thread-safe and expensive to build.
package com.example.hirelayer;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.io.IOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
public final class HireLayerClient {
private static final URI PARSER_URL = URI.create("https://hirelayer.co/api/v3/parser");
private static final Set<Integer> RETRYABLE = Set.of(429, 500, 502, 503, 504);
private static final int MAX_ATTEMPTS = 4;
private static final long MAX_FILE_BYTES = 4_500_000;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final ObjectMapper json = JsonMapper.builder()
.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.addModule(new JavaTimeModule())
.build();
private final String apiKey;
public HireLayerClient(String apiKey) {
this.apiKey = Objects.requireNonNull(apiKey, "HIRELAYER_API_KEY is not set");
}
public ParsedResume parse(Path file, String applicationId) throws IOException, InterruptedException {
return parse(Files.readAllBytes(file), file.getFileName().toString(), applicationId, true);
}
public ParsedResume parse(byte[] file, String fileName, String applicationId, boolean doNotStoreData)
throws IOException, InterruptedException {
if (file.length == 0 || file.length > MAX_FILE_BYTES) {
throw new IllegalArgumentException("The file is empty or larger than 4.5 MB");
}
var multipart = new MultipartBody()
.file("file", fileName, file)
.field("do_not_store_data", String.valueOf(doNotStoreData));
if (applicationId != null) {
multipart.field("application_id", applicationId);
}
// HttpRequest is immutable, so the same request can be sent again on retry.
var request = HttpRequest.newBuilder(PARSER_URL)
.timeout(Duration.ofSeconds(150))
.header("X-API-Key", apiKey)
.header("Accept", "application/json")
.header("Content-Type", multipart.contentType())
.POST(HttpRequest.BodyPublishers.ofByteArray(multipart.toByteArray()))
.build();
for (int attempt = 1; ; attempt++) {
HttpResponse<byte[]> response;
try {
response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
} catch (ConnectException | HttpConnectTimeoutException e) {
// No connection was made, so the parse never started: safe to send again.
if (attempt == MAX_ATTEMPTS) {
throw e;
}
Thread.sleep(backoff(attempt).toMillis());
continue;
}
if (response.statusCode() == 200) {
return json.readValue(response.body(), ParsedResume.class);
}
var error = toException(response);
if (!RETRYABLE.contains(response.statusCode()) || attempt == MAX_ATTEMPTS) {
throw error;
}
Thread.sleep(retryAfter(response).orElse(backoff(attempt)).toMillis());
}
}
private HireLayerException toException(HttpResponse<byte[]> response) {
String message = "HTTP " + response.statusCode();
String code = null;
try {
var body = json.readValue(response.body(), ApiError.class);
if (body.error() != null) {
message = body.error();
}
code = body.code();
} catch (IOException notJson) {
// For example an HTML error page from a proxy.
}
String requestId = response.headers().firstValue("x-parser-request-id").orElse(null);
return new HireLayerException(response.statusCode(), message, code, requestId);
}
private static Optional<Duration> retryAfter(HttpResponse<?> response) {
return response.headers().firstValue("Retry-After")
.filter(value -> value.matches("\\d+"))
.map(value -> Duration.ofSeconds(Long.parseLong(value)));
}
// 1 s, 2 s, 4 s… plus up to 250 ms of jitter.
private static Duration backoff(int attempt) {
return Duration.ofMillis((1000L << (attempt - 1)) + ThreadLocalRandom.current().nextLong(250));
}
record ApiError(String error, String code) {}
}
The JDK client has no request timeout by default, so set one on every request.
It also has two separate timeouts: connectTimeout on the client
covers opening the connection, and timeout on the request covers
waiting for the response, which is where the 150 seconds belong. The retry
rules follow the errors and retries guide:
500,502,503and504are retried with exponential backoff and jitter, or after the number of seconds inRetry-Afterwhen the response has that header.ConnectExceptionandHttpConnectTimeoutExceptionare retried because no connection was made, so nothing was parsed or charged.4xxresponses andHttpTimeoutExceptionon the response are not retried. There is no idempotency key, and a request your client gave up on can still complete and be charged.429is not part of HireLayer’s documented errors, since it enforces no per-second rate limit. It is in the retry set only in case a proxy in your own network returns it.
Compare your records with a real response
Upload a resume in the live demo to see every field, then check status codes and limits in the API reference.
Handle each error status
Every error body is JSON with an error message; file and parsing
errors add a code. The x-parser-request-id response
header identifies the request, as request_id does in a successful
body.
| Status | Cause | Action |
|---|---|---|
| 400 | Missing file part, or INVALID_FILE: empty or
unsupported file, or an invalid do_not_store_data. | Fix the request or ask for another file. |
| 401 | Missing API Key or Invalid API Key. | Fix the key. Do not retry. |
| 403 | Insufficient credits available. | Top up, then retry. |
| 413 | Encoded upload over 6 MiB. | Ask for a file under 4.5 MB. |
| 415 | Body is not multipart/form-data. | Fix the request. |
| 422 | DOCUMENT_NOT_A_RESUME, DOCUMENT_TEXT_EMPTY,
DOCUMENT_UNREADABLE, DOCUMENT_TOO_LARGE. | Explain the problem to the candidate. |
| 500, 502, 503, 504 | Temporary failure, including PARSER_UNAVAILABLE. | Retry with backoff. |
Running out of credits gives a 403, not a 402. The
example below maps each case to an action with a switch expression:
package com.example.hirelayer;
import java.nio.file.Path;
public final class ParseResumeExample {
public static void main(String[] args) throws Exception {
var client = new HireLayerClient(System.getenv("HIRELAYER_API_KEY"));
try {
ParsedResume resume = client.parse(Path.of("resume.pdf"), "candidate-123");
if (resume.partial()) {
// The data is usable. Warnings are human-readable notes: log them, don't parse them.
System.out.println("Partial parse " + resume.requestId() + ": " + resume.warnings());
}
for (var job : resume.workExperiences()) {
System.out.printf("%s at %s, from %s%n", job.jobTitle(), job.companyName(), job.startDate());
}
} catch (HireLayerException e) {
String action = switch (e.status()) {
case 401 -> "Check HIRELAYER_API_KEY: it is missing, invalid or revoked.";
case 403 -> "Out of credits: pause the queue and top up before retrying.";
case 413 -> "File too large: ask for a file under 4.5 MB.";
case 422 -> switch (String.valueOf(e.code())) {
case "DOCUMENT_NOT_A_RESUME" -> "This document does not look like a resume.";
case "DOCUMENT_TEXT_EMPTY" -> "No readable text, even with OCR: ask for another file.";
case "DOCUMENT_UNREADABLE" -> "The file is corrupted or cannot be opened.";
case "DOCUMENT_TOO_LARGE" -> "Too much text: ask for a shorter version.";
default -> e.getMessage();
};
case 400, 415 -> "INVALID_FILE".equals(e.code())
? "Empty or unsupported file: ask for another one."
: "Bug in the request: fix the multipart body.";
default -> "Still failing after retries: try again later.";
};
System.err.printf("HireLayer %d %s (request %s): %s%n", e.status(), e.code(), e.requestId(), action);
}
}
}
A 200 can be partial. When an optional step (OCR of some pages,
the photo, geocoding, occupation codes) is skipped,
upstream_status is "partial" and
warnings says what was skipped. The data is still usable; log the
warnings for review instead of parsing their text.
Use it from Spring Boot
In Spring Boot, expose the client as a bean and read the key from the
environment through application.properties. Raise the upload
limits while you are there: Spring rejects files over 1 MB by default, and
many resumes are larger.
# src/main/resources/application.properties
hirelayer.api-key=${HIRELAYER_API_KEY}
# Spring rejects uploads over 1 MB by default
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB
package com.example.hirelayer;
import java.io.IOException;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@Configuration
class HireLayerConfig {
@Bean
HireLayerClient hireLayerClient(@Value("${hirelayer.api-key}") String apiKey) {
return new HireLayerClient(apiKey);
}
}
@RestController
class ResumeController {
private static final Set<String> ALLOWED = Set.of("pdf", "doc", "docx", "odt", "rtf", "txt", "jpg", "jpeg", "png");
private final HireLayerClient hireLayer;
ResumeController(HireLayerClient hireLayer) {
this.hireLayer = hireLayer;
}
@PostMapping(path = "/candidates/resume", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<Map<String, Object>> upload(@RequestParam("resume") MultipartFile resume)
throws IOException, InterruptedException {
String extension = StringUtils.getFilenameExtension(resume.getOriginalFilename());
extension = extension == null ? "" : extension.toLowerCase(Locale.ROOT);
if (!ALLOWED.contains(extension) || resume.isEmpty() || resume.getSize() > 4_500_000) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Upload a PDF, Word, text or image resume under 4.5 MB."));
}
try {
var parsed = hireLayer.parse(resume.getBytes(), "resume." + extension, null, true);
return ResponseEntity.ok(Map.of(
"requestId", parsed.requestId(),
"positions", parsed.workExperiences().size()));
} catch (HireLayerException e) {
if (e.status() == 400 || e.status() == 413 || e.status() == 422) {
return ResponseEntity.unprocessableEntity().body(Map.of("error", e.getMessage()));
}
throw e;
}
}
}
The controller validates the extension and size, sends a neutral file name,
and returns 422 to your own front end for file problems.
Authentication, credit and server errors propagate to your normal exception
handling. If you would rather use Spring’s RestClient,
MultipartBodyBuilder produces correctly quoted parts; keep the
same timeout and retry rules.
A parse blocks a request thread for tens of seconds. For one upload at a time that is acceptable, especially with virtual threads on Java 21. For imports, queue the files and run a small pool of workers instead; the bulk parsing pipeline guide explains how to size it and avoid duplicates, and the ATS integration guide covers mapping the result into your own schema.
Production checklist
- The key is read from the environment on the server, with one key per environment, and is never logged.
- Every request has a 150-second timeout, and the connection timeout is short.
- Multipart parts use CRLF line endings and quoted
nameandfilenamevalues. - Uploads are checked for type and size (about 4.5 MB) before sending.
- Only
5xxresponses and failed connections are retried. - Failures are logged with status,
codeandx-parser-request-id, never with the resume text. do_not_store_datamatches your retention policy.- Results are checked on a sample of your own resumes, as described in the accuracy guide.
Frequently asked questions
Can I use OkHttp instead of java.net.http?
Yes. MultipartBody.Builder with
addFormDataPart("file", "resume.pdf", body) builds the same
request with quoted values. Set callTimeout or
readTimeout to at least 150 seconds, since OkHttp’s default
read timeout is 10 seconds.
Does this code work on Java 11?
The HTTP client does, since it arrived in Java 11. Records and switch expressions need Java 16 and 14 respectively; on Java 11, replace the records with classes and the switch expression with a classic switch.
Why not use Gson instead of Jackson?
Gson works too, with
FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES. Jackson is
used here because it supports records and LocalDate with its
standard modules, and it is already on the classpath of most Spring
applications.
How many resumes can I parse in parallel?
One request parses one file. There is no per-second rate limit, but capacity is shared: start with a few concurrent requests and increase gradually while watching latency.
What should I store from the response?
Keep request_id for support, map the fields your product
needs into your own schema, and store the raw JSON only if your retention
policy allows it.
Sources and further reading
- HireLayer API documentation: Parse a resume (V3)
- HireLayer API documentation: Errors and retries
- Java SE 17 API: java.net.http.HttpClient
- RFC 7578: Returning Values from Forms (multipart/form-data)
- Jackson Databind
Louis Desclous
Published on · Reading time: 12 minutes

