Skip to content

How to Parse a Resume to JSON in C# with HttpClient (.NET 8)

Upload a CV to a resume parser API with HttpClient and MultipartFormDataContent, map the JSON to C# records with System.Text.Json, and retry only what is safe.

Published · 11 minutes read

Cover reading “Parse a resume to JSON in C# and .NET” with a candidate icon and a code icon

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.Json and System.Text.Json. No NuGet package is required.
  • An API key from the dashboard, available to your server as the HIRELAYER_API_KEY environment variable or the HireLayer:ApiKey configuration 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.

ItemValue
EndpointPOST https://hirelayer.co/api/v3/parser
AuthenticationX-API-Key header. Authorization: Bearer is not accepted.
Bodymultipart/form-data: a required file part, and optional application_id, do_not_store_data and webhook_url fields.
Formats and size13 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.
TimingUsually about 35 seconds, longer for scans. The API returns 504 after 145 seconds; use a client timeout of at least 150 seconds.
Cost1 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.Json ignores unknown properties by default, so you can add fields from the response schema later: info_candidate.birth_date, mobility or rome_jobs, for example.
  • Most values are nullable. A field is null when the resume does not contain it, so the records use nullable types. Treat null as unknown, not as “the candidate has none”.
  • Dates map to DateOnly. The API returns YYYY-MM-DD, which System.Text.Json reads into DateOnly natively. 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), age and coordinates are declared as JSON numbers; double? accepts every value the schema allows.
  • Some strings are closed lists. contract_type, experience_level, education_level and language level use fixed English values, so you can map them to enums in your own layer. degree_type is 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, 503 and 504, with exponential backoff and jitter. When the response has a Retry-After header, response.Headers.RetryAfter.Delta is used instead.
  • Retried before sending: DNS and connection failures, detected with the HttpRequestError property 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 a 429. 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.

StatusCauseAction
400Missing file part, or INVALID_FILE (empty or unsupported file, invalid do_not_store_data).Fix the request or ask for another file.
401Missing API Key or Invalid API Key.Fix the configuration. Do not retry.
403Insufficient credits available.Top up, then retry.
413Encoded upload over 6 MiB.Ask for a file under 4.5 MB.
415Not multipart/form-data.Fix the request.
422DOCUMENT_NOT_A_RESUME, DOCUMENT_TEXT_EMPTY, DOCUMENT_UNREADABLE, DOCUMENT_TOO_LARGE.Tell the candidate what to change.
500, 502, 503, 504Temporary 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.Timeout is 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 ASCII filename.
  • Uploads are checked for extension and size (about 4.5 MB) before you send them.
  • Only 5xx responses and failed connections are retried.
  • Failures are logged with status, code and x-parser-request-id, never with resume content.
  • do_not_store_data matches 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

  1. HireLayer API documentation: Parse a resume (V3)
  2. HireLayer API documentation: Errors and retries
  3. Microsoft Learn: Make HTTP requests with IHttpClientFactory
  4. Microsoft Learn: Customize property names with System.Text.Json
  5. Microsoft Learn: Build resilient HTTP apps

Louis Desclous

Published on · Reading time: 11 minutes