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.
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.jsoncommitted 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:
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);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
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);
}
}// 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));
});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.
| Status | Meaning | Retry? |
|---|---|---|
| 200 | Raw PDF bytes, Content-Type: application/pdf | — |
400 invalid_params | Every validation failure at once, semicolon-separated (includes which CPF/CNPJ failed its check digit) | No — fix the payload |
401 unauthorized | Request did not come through the RapidAPI gateway | No |
| 405 / 413 | Wrong method / body over ~200 KB or 200 line items | No |
| 429 | Gateway quota exhausted | Only if quota can free up |
500 render_failed · 504 render_timeout | Transient server-side failure | Yes |
Calling it
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
// 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#
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:
[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