Integration guide · C# / .NET

Generate a Brazilian invoice PDF in C# (.NET)

IHttpClientFactory, source-generated JSON, Polly retry policies and a minimal API endpoint — plus the socket-exhaustion mistake that new HttpClient() still causes in 2026.

Updated 2026-08-07 · 8 min read · by the FaturaPDF team

The .NET side of this integration is short, but two things routinely go wrong: HttpClient lifetime management, and JSON property naming. The API's fields are Portuguese (emitente, valor_unitario), which will not match your C# conventions unless you say so explicitly.

Two references worth keeping open alongside this page: the CPF/CNPJ check-digit algorithm (implemented in C# below) and BRL and date formatting, which explains why the DTOs use decimal and why you should not format money before sending it. If you are still deciding whether to call an API at all, the approaches comparison is the honest version.

Requirements

  • .NET 8 or newer (the code works on .NET 6 with minor syntax changes).
  • A key from the RapidAPI listing, stored in user secrets or configuration — not in appsettings.json committed to git.

The DTOs

Use records with explicit [JsonPropertyName]. Setting a global snake_case policy is tempting, but it would also rewrite Numero to numero correctly and ValorUnitario to valor_unitario correctly — right up until a field does not follow the pattern. Explicit is safer:

csharpDtos.cs
using System.Text.Json.Serialization;

namespace FaturaPdf;

public sealed record Party(
    [property: JsonPropertyName("nome")] string Nome,
    [property: JsonPropertyName("documento")] string? Documento = null,
    [property: JsonPropertyName("endereco")] string? Endereco = null,
    [property: JsonPropertyName("email")] string? Email = null);

public sealed record Item(
    [property: JsonPropertyName("descricao")] string Descricao,
    [property: JsonPropertyName("valor_unitario")] decimal ValorUnitario,
    [property: JsonPropertyName("quantidade")] decimal Quantidade = 1);

public sealed record InvoiceRequest
{
    [JsonPropertyName("tipo")]            public string Tipo { get; init; } = "fatura";
    [JsonPropertyName("numero")]          public string? Numero { get; init; }
    [JsonPropertyName("data")]            public string? Data { get; init; }
    [JsonPropertyName("vencimento")]      public string? Vencimento { get; init; }
    [JsonPropertyName("emitente")]        public required Party Emitente { get; init; }
    [JsonPropertyName("destinatario")]    public required Party Destinatario { get; init; }
    [JsonPropertyName("itens")]           public required IReadOnlyList<Item> Itens { get; init; }
    [JsonPropertyName("desconto")]        public decimal? Desconto { get; init; }
    [JsonPropertyName("forma_pagamento")] public string? FormaPagamento { get; init; }
    [JsonPropertyName("observacoes")]     public string? Observacoes { get; init; }
    [JsonPropertyName("pix_copia_cola")]  public string? PixCopiaCola { get; init; }
    [JsonPropertyName("mostrar_valor_por_extenso")] public bool? MostrarValorPorExtenso { get; init; }
}

public sealed record ApiErrorBody(
    [property: JsonPropertyName("error")] string Error,
    [property: JsonPropertyName("message")] string Message);
Nulls must not be serialized

With decimal? Desconto = null, the default serializer emits "desconto": null. The API treats explicit null as absent for optional fields, so it works — but the payload is noisier and you lose the ability to distinguish "not set" from "set to nothing" in your own logs. Set DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull as shown below.

The client, registered properly

csharpInvoiceClient.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;

namespace FaturaPdf;

public sealed class InvoiceApiException : Exception
{
    public InvoiceApiException(HttpStatusCode status, string code, string message)
        : base($"[{(int)status}] {code}: {message}")
    {
        Status = status;
        Code = code;
        // invalid_params packs every problem into one semicolon-separated message.
        Problems = code == "invalid_params"
            ? message.Split("; ", StringSplitOptions.RemoveEmptyEntries)
            : Array.Empty<string>();
    }

    public HttpStatusCode Status { get; }
    public string Code { get; }
    public string[] Problems { get; }
}

public sealed class InvoiceClient(HttpClient http)
{
    private static readonly JsonSerializerOptions Json = new()
    {
        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
    };

    public async Task<byte[]> GenerateAsync(InvoiceRequest request, CancellationToken ct = default)
    {
        using var response = await http.PostAsJsonAsync("/invoice", request, Json, ct);

        if (response.StatusCode != HttpStatusCode.OK)
        {
            ApiErrorBody? body = null;
            try { body = await response.Content.ReadFromJsonAsync<ApiErrorBody>(ct); }
            catch (JsonException) { /* not JSON - fall through to the generic message */ }

            throw new InvoiceApiException(
                response.StatusCode,
                body?.Error ?? "http_error",
                body?.Message ?? response.ReasonPhrase ?? "unknown error");
        }

        return await response.Content.ReadAsByteArrayAsync(ct);
    }
}
csharpProgram.cs
// Program.cs
builder.Services.AddHttpClient<InvoiceClient>(client =>
{
    var host = "brazilian-invoice-receipt-pdf-api-cpf-cnpj.p.rapidapi.com";
    client.BaseAddress = new Uri($"https://{host}/");
    client.DefaultRequestHeaders.Add("X-RapidAPI-Key", builder.Configuration["RapidApi:Key"]);
    client.DefaultRequestHeaders.Add("X-RapidAPI-Host", host);
    client.Timeout = TimeSpan.FromSeconds(20);
})
// Polly v8: retry only what is transient, never a 400.
.AddResilienceHandler("faturapdf", pipeline =>
{
    pipeline.AddRetry(new Polly.Retry.RetryStrategyOptions<HttpResponseMessage>
    {
        MaxRetryAttempts = 3,
        BackoffType = Polly.DelayBackoffType.Exponential,
        UseJitter = true,
        Delay = TimeSpan.FromMilliseconds(400),
        ShouldHandle = new Polly.PredicateBuilder<HttpResponseMessage>()
            .HandleResult(r => r.StatusCode is HttpStatusCode.InternalServerError
                                            or HttpStatusCode.BadGateway
                                            or HttpStatusCode.ServiceUnavailable
                                            or HttpStatusCode.GatewayTimeout)
            .Handle<HttpRequestException>()
            .Handle<TimeoutException>(),
    });
    pipeline.AddTimeout(TimeSpan.FromSeconds(15));
});
Never new HttpClient() per request

Each instance holds its own connection pool, and disposing it leaves the socket in TIME_WAIT. Under load you exhaust ephemeral ports and start getting SocketException: Only one usage of each socket address is normally permitted — from code that looks perfectly correct. AddHttpClient<T> hands you a pooled, rotated handler and fixes both this and stale DNS.

StatusMeaningRetry?
200Raw PDF bytes, Content-Type: application/pdf
400 invalid_paramsEvery validation failure at once, semicolon-separated (includes which CPF/CNPJ failed its check digit)No — fix the payload
401 unauthorizedRequest did not come through the RapidAPI gatewayNo
405 / 413Wrong method / body over ~200 KB or 200 line itemsNo
429Gateway quota exhaustedOnly if quota can free up
500 render_failed · 504 render_timeoutTransient server-side failureYes

Calling it

csharpUsage.cs
var request = new InvoiceRequest
{
    Numero     = "0001/2026",
    Data       = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"),
    Vencimento = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(15)).ToString("yyyy-MM-dd"),
    Emitente   = new Party("Atlas Solucoes Digitais LTDA", "11.222.333/0001-81",
                           "Av. Paulista, 1000 - Sao Paulo/SP"),
    Destinatario = new Party("Comercio Silva & Filhos ME", "22.333.444/0001-81"),
    Itens = new[]
    {
        new Item("Consultoria de implementacao", 250m, 20),
        new Item("Licenca mensal", 349.90m),
    },
    Desconto       = 100m,
    FormaPagamento = "PIX",
};

try
{
    byte[] pdf = await client.GenerateAsync(request, ct);
    await File.WriteAllBytesAsync("fatura.pdf", pdf, ct);
}
catch (InvoiceApiException ex) when (ex.Code == "invalid_params")
{
    foreach (var problem in ex.Problems)
        logger.LogWarning("Invoice field error: {Problem}", problem);
    throw;
}
decimal, not double

Use decimal for money everywhere in C#. System.Text.Json serializes 349.90m as 349.90, exactly. Serializing a double can emit 349.89999999999998, which the API would accept and round to the same cents — but you have now put a float artefact in your logs and your database, and eventually one will not round the way you expect.

Returning the PDF from ASP.NET Core

csharpInvoicesController.cs
// Controller
[HttpGet("orders/{id:guid}/invoice.pdf")]
public async Task<IActionResult> GetInvoice(Guid id, CancellationToken ct)
{
    var order = await _orders.FindAsync(id, ct);
    if (order is null) return NotFound();

    byte[] pdf;
    try
    {
        pdf = await _invoices.GenerateAsync(BuildRequest(order), ct);
    }
    catch (InvoiceApiException ex)
    {
        _logger.LogError(ex, "Invoice generation failed for {OrderId}", id);
        return StatusCode(StatusCodes.Status502BadGateway);
    }

    // fileDownloadName omitted => Content-Disposition: inline (renders in the browser).
    return File(pdf, "application/pdf", $"fatura-{order.Number}.pdf");
}

// Minimal API
app.MapGet("/orders/{id:guid}/invoice.pdf",
    async (Guid id, InvoiceClient invoices, OrderRepository orders, CancellationToken ct) =>
    {
        var order = await orders.FindAsync(id, ct);
        if (order is null) return Results.NotFound();

        var pdf = await invoices.GenerateAsync(BuildRequest(order), ct);
        return Results.File(pdf, "application/pdf", $"fatura-{order.Number}.pdf");
    });

Passing fileDownloadName sets Content-Disposition: attachment and forces a download. Omit it (File(pdf, "application/pdf")) to let the browser render it inline instead — the choice is purely about UX.

CPF/CNPJ validation in C#

csharpTaxId.cs
namespace FaturaPdf;

public static class TaxId
{
    private static readonly int[] CpfW1  = [10, 9, 8, 7, 6, 5, 4, 3, 2];
    private static readonly int[] CpfW2  = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2];
    private static readonly int[] CnpjW1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
    private static readonly int[] CnpjW2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];

    public static bool IsValid(string? value)
    {
        var d = Digits(value);
        return d.Length switch { 11 => IsValidCpf(d), 14 => IsValidCnpj(d), _ => false };
    }

    public static bool IsValidCpf(string? value)
    {
        var d = Digits(value);
        if (d.Length != 11 || AllSame(d)) return false;
        return CheckDigit(d, CpfW1) == d[9] - '0' && CheckDigit(d, CpfW2) == d[10] - '0';
    }

    public static bool IsValidCnpj(string? value)
    {
        var d = Digits(value);
        if (d.Length != 14 || AllSame(d)) return false;
        return CheckDigit(d, CnpjW1) == d[12] - '0' && CheckDigit(d, CnpjW2) == d[13] - '0';
    }

    private static int CheckDigit(string digits, int[] weights)
    {
        var sum = 0;
        for (var i = 0; i < weights.Length; i++) sum += (digits[i] - '0') * weights[i];
        var rest = sum % 11;
        return rest < 2 ? 0 : 11 - rest;
    }

    private static bool AllSame(string d)
    {
        for (var i = 1; i < d.Length; i++) if (d[i] != d[0]) return false;
        return true;
    }

    private static string Digits(string? value) =>
        string.IsNullOrEmpty(value) ? string.Empty : new string(value.Where(char.IsAsciiDigit).ToArray());

    public static string Mask(string? value) => Digits(value) switch
    {
        { Length: 11 } d => $"{d[..3]}.{d[3..6]}.{d[6..9]}-{d[9..]}",
        { Length: 14 } d => $"{d[..2]}.{d[2..5]}.{d[5..8]}/{d[8..12]}-{d[12..]}",
        _ => value ?? string.Empty,
    };
}

As a data-annotation attribute for model binding:

csharpCpfOrCnpjAttribute.cs
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class CpfOrCnpjAttribute : ValidationAttribute
{
    public override bool IsValid(object? value) =>
        value is null || (value is string s && (s.Length == 0 || TaxId.IsValid(s)));

    public override string FormatErrorMessage(string name) =>
        $"{name} is not a valid CPF or CNPJ.";
}

// Usage:
public sealed class CustomerDto
{
    [Required, StringLength(200)] public string Name { get; set; } = "";
    [CpfOrCnpj]                   public string? TaxId { get; set; }
}

.NET-specific notes

Culture will bite you

If you format money yourself for display, value.ToString("C") under pt-BR gives R$ 1.234,56 and under en-US gives $1,234.56. Server culture is often invariant. For display use value.ToString("C", CultureInfo.GetCultureInfo("pt-BR")). For the payload, do not format at all — send the raw decimal and let the serializer emit 349.90. Sending "R$ 349,90" as a string is a 400.

Source-generated JSON for AOT

If you publish with Native AOT or trimming, add a JsonSerializerContext for the DTOs; reflection-based serialization is trimmed away and you get a runtime error that is confusing on first encounter.

Cancellation is not optional

Pass the request's CancellationToken all the way through. Without it, a client that disconnects leaves your worker waiting on the upstream call for the full timeout.

Frequently asked questions

Is there a NuGet package?

No. The client above is the whole integration and has no dependency beyond System.Net.Http.Json (in the framework since .NET 5). If you want a generated client, run NSwag or Kiota against the OpenAPI spec.

Why does my JSON have the wrong field names?

Because C# property names are PascalCase and the API expects Portuguese snake_case. Either annotate every property with [JsonPropertyName], as shown, or set a naming policy — but the explicit attributes are safer since the mapping is not purely mechanical.

Can I use this from a Blazor WebAssembly app?

Not directly: the API key would ship to the browser and CORS is not open. Call it from your server project and expose your own endpoint to the Blazor client.

Generate these PDFs from your own code

FaturaPDF turns a JSON payload into a ready-to-send Brazilian invoice or receipt PDF — validated CPF/CNPJ, R$ formatting, amount in words, optional PIX QR. Free tier: 20 documents/month, no credit card.

Get an API key on RapidAPI → Or try the free browser generator

Related guides