This tutorial calls the HireLayer resume parsing API from C#. You send a CV
file with HttpClient and MultipartFormDataContent,
deserialize the JSON into records with System.Text.Json, and
handle the API’s documented errors with a retry policy that never pays twice
for the same file. The last part wires everything into ASP.NET Core with
IHttpClientFactory.
The code targets .NET 8 and C# 12. For other stacks, see the Python and Node.js guide, the PHP guide or the Java guide.
What you need
- The .NET 8 SDK or later. Everything used here ships with
it:
System.Net.Http,System.Net.Http.JsonandSystem.Text.Json. No NuGet package is required. - An API key from the dashboard, available to your server as
the
HIRELAYER_API_KEYenvironment variable or theHireLayer:ApiKeyconfiguration value. Never ship it in a Blazor WebAssembly, MAUI or desktop app: anyone can extract it from the binary. - A test resume. Try it in the live demo first to see the JSON you are about to map.
The request the API expects
These values come from the CV Extract V3 reference. The call is synchronous: one request, one file, one JSON response.
| Item | Value |
|---|---|
| Endpoint | POST https://hirelayer.co/api/v3/parser |
| Authentication | X-API-Key header. Authorization: Bearer is
not accepted. |
| Body | multipart/form-data: a required file part,
and optional application_id,
do_not_store_data and webhook_url fields. |
| Formats and size | 13 formats including PDF, DOC, DOCX, RTF, TXT, JPG and PNG, detected from the content. Encoded upload up to 6 MiB, so a file of about 4.5 MB. |
| Timing | Usually about 35 seconds, longer for scans. The API returns
504 after 145 seconds; use a client timeout of at least
150 seconds. |
| Cost | 1 credit per 200. Failed calls are free. |
With do_not_store_data=true, the resume file is not stored:
info_resume.url comes back null, and the cropped
photo in info_resume.face_url is a temporary link that expires 10
minutes after parsing (see info_resume.face_url_expires_at).
Without it, the file is stored. The examples below send true.
Parse a resume with HttpClient
This console program, using top-level statements, sends
resume.pdf and prints the candidate name and the number of
positions found.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var apiKey = Environment.GetEnvironmentVariable("HIRELAYER_API_KEY")
?? throw new InvalidOperationException("HIRELAYER_API_KEY is not set");
using var http = new HttpClient
{
BaseAddress = new Uri("https://hirelayer.co/"),
Timeout = TimeSpan.FromSeconds(150), // the default 100 s can cut a slow parse short
};
http.DefaultRequestHeaders.Add("X-API-Key", apiKey);
using var form = new MultipartFormDataContent();
var file = new ByteArrayContent(await File.ReadAllBytesAsync("resume.pdf"));
file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
// Quoted name and filename, and no filename*: see the next section.
file.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
{
Name = "\"file\"",
FileName = "\"resume.pdf\"",
};
form.Add(file);
form.Add(FormField("application_id", "candidate-123"));
form.Add(FormField("do_not_store_data", "true"));
using var response = await http.PostAsync("api/v3/parser", form);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
{
Console.Error.WriteLine($"HTTP {(int)response.StatusCode}: {json.GetProperty("error")}");
return 1;
}
Console.WriteLine(json.GetProperty("info_candidate").GetProperty("full_name"));
Console.WriteLine($"{json.GetProperty("work_experiences").GetArrayLength()} positions");
return 0;
static StringContent FormField(string name, string value) => new(value)
{
Headers = { ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = $"\"{name}\"" } },
};
Set HttpClient.Timeout explicitly. Its default is 100 seconds,
and a scanned resume can take longer than that to parse; when the timeout
fires you get a TaskCanceledException while the API may still
finish the job.
Fix the multipart header .NET sends by default
This is the one trap specific to .NET. The convenient overload
form.Add(content, "file", "resume.pdf") writes the part header
with unquoted values and an extra RFC 5987 filename* parameter.
The parser endpoint expects the plain quoted form, and cannot read the file
part otherwise:
# What form.Add(content, "file", "resume.pdf") sends by default:
Content-Disposition: form-data; name=file; filename=resume.pdf; filename*=utf-8''resume.pdf
# What the parser endpoint expects:
Content-Disposition: form-data; name="file"; filename="resume.pdf"
The fix is to set ContentDisposition yourself, with the quotes
included in Name and FileName, and add the part with
the single-argument form.Add(content), which leaves your header
alone. The quickstart above and the client below both do this for every part.
Because .NET rejects quotes inside a file name, pass a simple ASCII name such
as resume.pdf rather than the name the candidate uploaded; the
API detects the format from the content anyway.
Deserialize into records with System.Text.Json
The response uses snake_case keys. Since .NET 8,
JsonNamingPolicy.SnakeCaseLower maps them to PascalCase
properties without a [JsonPropertyName] attribute on each one.
Positional records keep the models short:
namespace HireLayer;
public sealed record ParsedResume(
string RequestId,
string? UpstreamStatus,
IReadOnlyList<string> Warnings,
ResumeInfo InfoResume,
Candidate InfoCandidate,
IReadOnlyList<WorkExperience> WorkExperiences,
IReadOnlyList<Education> Educations,
IReadOnlyList<SpokenLanguage> Languages,
IReadOnlyList<Skill> Skills,
IReadOnlyList<string> Certifications)
{
public bool IsPartial => UpstreamStatus == "partial";
}
public sealed record ResumeInfo(
string? ApplicationId,
string? Language,
string? Url,
string? FaceUrl,
string? FaceUrlExpiresAt);
public sealed record Candidate(
string? FullName,
string? FirstName,
string? LastName,
string? Email,
string? PhoneNumber,
string? JobTitle,
string? EducationLevel,
string? ExperienceLevel,
string? LinkedinUrl,
string? GithubUrl,
CandidateLocation? Location);
public sealed record CandidateLocation(
string? City,
string? PostalCode,
string? Country,
string? CountryCode,
double? Latitude,
double? Longitude);
public sealed record WorkExperience(
string? CompanyName,
string? JobTitle,
string? Description,
string? ContractType,
DateOnly? StartDate,
DateOnly? EndDate,
bool? CurrentlyActive,
double? ExperienceDuration);
public sealed record Education(
string? DegreeTitle,
string? SchoolName,
string? DegreeType,
DateOnly? StartDate,
DateOnly? EndDate);
public sealed record SpokenLanguage(string Language, string? Level);
public sealed record Skill(string SkillTitle, string SkillType, string Status);
- Only declare what you use.
System.Text.Jsonignores unknown properties by default, so you can add fields from the response schema later:info_candidate.birth_date,mobilityorrome_jobs, for example. - Most values are nullable. A field is
nullwhen the resume does not contain it, so the records use nullable types. Treatnullas unknown, not as “the candidate has none”. - Dates map to
DateOnly. The API returnsYYYY-MM-DD, whichSystem.Text.Jsonreads intoDateOnlynatively. In work history, a year on its own becomes January 1 or December 31, so a date is not always as precise as it looks. - Numbers are doubles.
experience_duration(months),ageand coordinates are declared as JSON numbers;double?accepts every value the schema allows. - Some strings are closed lists.
contract_type,experience_level,education_leveland languageleveluse fixed English values, so you can map them to enums in your own layer.degree_typeis not normalized.
A typed client with retries
The client below builds a fresh form for each attempt, turns error responses
into a HireLayerException, and retries only what the errors and retries guide marks as retryable.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace HireLayer;
public sealed class HireLayerException(HttpStatusCode status, string message, string? code, string? requestId)
: Exception(message)
{
public HttpStatusCode Status { get; } = status;
public string? Code { get; } = code;
public string? RequestId { get; } = requestId;
}
public sealed class HireLayerClient(HttpClient http)
{
private const int MaxAttempts = 4;
private const long MaxFileBytes = 4_500_000;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
public async Task<ParsedResume> ParseAsync(
byte[] file,
string fileName,
string? applicationId = null,
bool doNotStoreData = true,
CancellationToken cancellationToken = default)
{
if (file.Length is 0 or > MaxFileBytes)
throw new ArgumentException("The file is empty or larger than 4.5 MB.", nameof(file));
for (var attempt = 1; ; attempt++)
{
using var form = BuildForm(file, fileName, applicationId, doNotStoreData);
HttpResponseMessage response;
try
{
response = await http.PostAsync("api/v3/parser", form, cancellationToken);
}
catch (HttpRequestException ex) when (attempt < MaxAttempts && ex.HttpRequestError
is HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError)
{
// No connection was made, so the parse never started: safe to send again.
await Task.Delay(Backoff(attempt), cancellationToken);
continue;
}
using (response)
{
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<ParsedResume>(JsonOptions, cancellationToken)
?? throw new HireLayerException(response.StatusCode, "Empty response body", null, null);
}
var error = await ToExceptionAsync(response, cancellationToken);
if (!IsRetryable(response.StatusCode) || attempt == MaxAttempts)
throw error;
await Task.Delay(response.Headers.RetryAfter?.Delta ?? Backoff(attempt), cancellationToken);
}
}
}
private static MultipartFormDataContent BuildForm(
byte[] file, string fileName, string? applicationId, bool doNotStoreData)
{
var form = new MultipartFormDataContent();
var filePart = new ByteArrayContent(file);
filePart.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
filePart.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
{
Name = "\"file\"",
FileName = $"\"{fileName}\"", // an ASCII name such as resume.pdf
};
form.Add(filePart);
form.Add(Field("do_not_store_data", doNotStoreData ? "true" : "false"));
if (applicationId is not null)
form.Add(Field("application_id", applicationId));
return form;
}
private static StringContent Field(string name, string value) => new(value)
{
Headers = { ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = $"\"{name}\"" } },
};
private static bool IsRetryable(HttpStatusCode status) => (int)status is 429 or >= 500;
// 1 s, 2 s, 4 s… plus up to 250 ms of jitter.
private static TimeSpan Backoff(int attempt) =>
TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)) + TimeSpan.FromMilliseconds(Random.Shared.Next(250));
private static async Task<HireLayerException> ToExceptionAsync(
HttpResponseMessage response, CancellationToken cancellationToken)
{
ApiError? body = null;
try
{
body = await response.Content.ReadFromJsonAsync<ApiError>(JsonOptions, cancellationToken);
}
catch (Exception ex) when (ex is JsonException or NotSupportedException)
{
// Not JSON, for example an HTML error page from a proxy.
}
var requestId = response.Headers.TryGetValues("x-parser-request-id", out var values)
? values.FirstOrDefault()
: null;
return new HireLayerException(
response.StatusCode,
body?.Error ?? $"HTTP {(int)response.StatusCode}",
body?.Code,
requestId);
}
private sealed record ApiError(string? Error, string? Code);
}
- Retried:
500,502,503and504, with exponential backoff and jitter. When the response has aRetry-Afterheader,response.Headers.RetryAfter.Deltais used instead. - Retried before sending: DNS and connection failures,
detected with the
HttpRequestErrorproperty added in .NET 8. The request never reached the API, so nothing can have been charged. - Not retried: every
4xx, and your own timeout. There is no idempotency key, so a request you abandon can still complete and be charged. 429: HireLayer does not enforce a per-second rate limit and does not document a429. The client treats it as retryable only in case a proxy on your side returns one.
If you prefer Polly through Microsoft.Extensions.Http.Resilience,
check its timeouts before calling AddStandardResilienceHandler():
the standard pipeline cancels each attempt after 10 seconds and the whole call
after 30, then retries timeouts. For this endpoint that means abandoned parses
and duplicate charges. Raise the attempt and total timeouts above 150 seconds
and stop it from retrying on timeouts, or keep the explicit loop above.
See the JSON before you write the records
Run a resume through the live demo, compare the output with the original, and check every field and error code in the API reference.
Handle each error status
Error bodies are JSON with an error message and, for file and
parsing problems, a code. Each error response also has an
x-parser-request-id header, the equivalent of
request_id in a successful body. Log it with the status so
support can find the request.
| Status | Cause | Action |
|---|---|---|
| 400 | Missing file part, or INVALID_FILE (empty or
unsupported file, invalid do_not_store_data). | Fix the request or ask for another file. |
| 401 | Missing API Key or Invalid API Key. | Fix the configuration. 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 | Not multipart/form-data. | Fix the request. |
| 422 | DOCUMENT_NOT_A_RESUME, DOCUMENT_TEXT_EMPTY,
DOCUMENT_UNREADABLE, DOCUMENT_TOO_LARGE. | Tell the candidate what to change. |
| 500, 502, 503, 504 | Temporary failure, including PARSER_UNAVAILABLE. | Retry with backoff. |
An empty balance returns 403, not 402. Here is a
caller that maps each case to an action, inside a class that has the client
and an ILogger injected:
try
{
var resume = await hireLayer.ParseAsync(bytes, "resume.pdf", applicationId: "candidate-123");
if (resume.IsPartial)
logger.LogInformation("Partial parse {RequestId}: {Warnings}", resume.RequestId, resume.Warnings);
foreach (var job in resume.WorkExperiences)
Console.WriteLine($"{job.JobTitle} at {job.CompanyName}, from {job.StartDate:MMM yyyy}");
}
catch (HireLayerException ex)
{
var action = (int)ex.Status switch
{
401 => "Check the API key: it is missing, invalid or revoked.",
403 => "Out of credits: pause the queue and top up before retrying.",
413 => "File too large: ask for a file under 4.5 MB.",
422 => ex.Code switch
{
"DOCUMENT_NOT_A_RESUME" => "This document does not look like a resume.",
"DOCUMENT_TEXT_EMPTY" => "No readable text, even with OCR: ask for another file.",
"DOCUMENT_UNREADABLE" => "The file is corrupted or cannot be opened.",
"DOCUMENT_TOO_LARGE" => "Too much text: ask for a shorter version.",
_ => ex.Message,
},
400 when ex.Code == "INVALID_FILE" => "Empty or unsupported file: ask for another one.",
400 or 415 => "Bug in the request: fix the multipart body.",
_ => "Still failing after retries: try again later.",
};
logger.LogWarning("HireLayer {Status} {Code} (request {RequestId}): {Action}",
(int)ex.Status, ex.Code, ex.RequestId, action);
}
A 200 response can be partial: when an optional step (OCR of some
pages, the photo, geocoding, occupation codes) is skipped,
upstream_status is "partial" and
warnings explains what was skipped. The data is still usable. The
warnings are free text for people, so log them rather than parsing them.
Register it in ASP.NET Core
AddHttpClient<HireLayerClient>() registers a typed client:
the factory manages the underlying handlers, so you avoid both socket
exhaustion and stale DNS, and every instance gets the base address, timeout
and key header. This minimal API accepts an upload from your own front end and
returns a summary:
using HireLayer;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<HireLayerClient>(client =>
{
client.BaseAddress = new Uri("https://hirelayer.co/");
client.Timeout = TimeSpan.FromSeconds(150);
client.DefaultRequestHeaders.Add("X-API-Key",
builder.Configuration["HireLayer:ApiKey"]
?? throw new InvalidOperationException("HireLayer:ApiKey is not configured."));
});
var app = builder.Build();
string[] allowedExtensions = [".pdf", ".doc", ".docx", ".odt", ".rtf", ".txt", ".jpg", ".jpeg", ".png"];
app.MapPost("/candidates/resume", async (IFormFile resume, HireLayerClient hireLayer, CancellationToken ct) =>
{
var extension = Path.GetExtension(resume.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(extension) || resume.Length is 0 or > 4_500_000)
return Results.BadRequest(new { error = "Upload a PDF, Word, text or image resume under 4.5 MB." });
using var buffer = new MemoryStream();
await resume.CopyToAsync(buffer, ct);
try
{
var parsed = await hireLayer.ParseAsync(buffer.ToArray(), "resume" + extension, cancellationToken: ct);
return Results.Ok(new
{
parsed.RequestId,
parsed.InfoCandidate.FullName,
Positions = parsed.WorkExperiences.Count,
});
}
catch (HireLayerException ex) when ((int)ex.Status is 400 or 413 or 422)
{
return Results.UnprocessableEntity(new { error = ex.Message, code = ex.Code });
}
})
.DisableAntiforgery(); // token-authenticated API, no cookie session
app.Run();
Keep the key out of appsettings.json. In development, the Secret
Manager stores it outside the project folder; in production, an environment
variable with a double underscore maps to the same
HireLayer:ApiKey configuration key:
# Development: kept outside the project folder
dotnet user-secrets init
dotnet user-secrets set "HireLayer:ApiKey" "sk_..."
# Production: an environment variable, or your secret store
export HireLayer__ApiKey="sk_..."
Parsing inside the HTTP request is fine for one upload at a time, but the user
waits tens of seconds. For imports, put the files on a queue and let a
BackgroundService worker call ParseAsync with a
small, fixed concurrency. The bulk parsing pipeline guide and the ATS integration guide cover queues, duplicate protection and mapping into your own schema.
Production checklist
- The key comes from configuration on the server, one key per environment, and never appears in logs or client-side code.
HttpClient.Timeoutis at least 150 seconds, and so is any resilience pipeline or reverse proxy in front of the call.- Every multipart part has a quoted
name, and the file part a quoted ASCIIfilename. - Uploads are checked for extension and size (about 4.5 MB) before you send them.
- Only
5xxresponses and failed connections are retried. - Failures are logged with status,
codeandx-parser-request-id, never with resume content. do_not_store_datamatches your retention policy.- Results are checked on a sample of your own resumes; the accuracy guide shows how to score them.
Frequently asked questions
Is there an official NuGet package?
You do not need one. The API is a single multipart request, and everything in this tutorial uses types that ship with .NET 8. Keeping the client in your own code also lets you choose your retry and logging rules.
Does this work on .NET 6 or .NET Framework?
Not as written. JsonNamingPolicy.SnakeCaseLower,
HttpRequestError and primary constructors need .NET 8 and C#
12. On older runtimes, put [JsonPropertyName] on each
property, use regular constructors, and retry on
HttpRequestException only when you know the connection
failed.
Why do I get an error when I use form.Add(content, name, fileName)?
That overload writes unquoted name and filename
values plus a filename* parameter, which the endpoint does
not read. Set ContentDisposition with quoted values and add
the part with form.Add(content).
Can I stream the file instead of a byte array?
Yes, StreamContent works for a single attempt. The byte array
is used here because the file is at most about 4.5 MB and the same bytes
must be sent again on retry, which a forward-only stream such as an upload
body cannot do.
Can I parse several resumes at once?
One request parses one file. Run several requests in parallel with a
bounded concurrency, for example with Parallel.ForEachAsync
and a low MaxDegreeOfParallelism, and increase it gradually
while watching latency.
Sources and further reading
- HireLayer API documentation: Parse a resume (V3)
- HireLayer API documentation: Errors and retries
- Microsoft Learn: Make HTTP requests with IHttpClientFactory
- Microsoft Learn: Customize property names with System.Text.Json
- Microsoft Learn: Build resilient HTTP apps
Louis Desclous
Published on · Reading time: 11 minutes

