.NET
Integra Constaia en ASP.NET Core 8+ con minimal API, IHttpClientFactory, MultipartFormDataContent, System.Text.Json y webhooks HMAC verificados.
Esta guía integra Constaia en una minimal API de ASP.NET Core (.NET 8 o superior): un cliente tipado registrado con
IHttpClientFactory, un endpoint que recibe un IFormFile y lo reenvía, records con System.Text.Json y un endpoint
de webhooks con la firma verificada.
SDK oficial de .NET: próximamente
Todavía no hay paquete NuGet oficial. Aquí llamas a la API REST directamente. Si prefieres un cliente generado, la
especificación OpenAPI está en https://api.constaia.com/openapi.json (por ejemplo, con NSwag o Kiota).
Requisitos
- .NET 8 o superior (
JsonNamingPolicy.SnakeCaseLowerexiste desde .NET 8). - Una clave de test
ck_test_…del panel (ver Autenticación). - El secreto
whsec_…de un endpoint de webhook (ver Webhooks).
Instalación
No necesitas paquetes adicionales:
dotnet new web -n ConstaiaDemo
cd ConstaiaDemoConfiguración
En desarrollo, guarda la clave con user-secrets; en producción, como variable de entorno. Ambas llegan a
IConfiguration con el mismo nombre.
dotnet user-secrets init
dotnet user-secrets set CONSTAIA_API_KEY ck_test_...
dotnet user-secrets set CONSTAIA_WEBHOOK_SECRET whsec_...La clave se queda en el servidor; nunca la incluyas en un cliente Blazor WebAssembly, MAUI ni en ningún frontend.
Modelos
Records con la parte del análisis que usas. SnakeCaseLower traduce NotExpired a not_expired, RequestId a
request_id, etc. Las claves de Metadata no se transforman.
using System.Text.Json;
namespace ConstaiaDemo.Constaia;
public sealed record Analysis(
string Id,
string Status,
bool Livemode,
DateTimeOffset? CreatedAt,
DocumentInfo? Document,
Verdict? Verdict,
Dictionary<string, Field>? Fields,
List<string>? Warnings,
AnalysisError? Error,
Dictionary<string, string>? Metadata);
public sealed record DocumentInfo(string Type, string? Label, double Confidence);
public sealed record Verdict(List<string> Expected, bool Match, string Status, List<Reason> Reasons);
public sealed record Reason(string Code, string Severity, string Message);
public sealed record Field(JsonElement Value, double? Confidence, bool? Validated);
public sealed record AnalysisError(string Code, string? Message);
public sealed record AnalyzeOptions(
string[] Expect,
Checks? Checks = null,
string? Storage = null,
bool? Async = null,
string? Language = null,
Dictionary<string, string>? Metadata = null);
public sealed record Checks(bool? NotExpired = null, Holder? Holder = null);
public sealed record Holder(string? FullName = null);
public sealed record ApiError(string? Type, string? Code, string? Message, string? Param, string? RequestId);
public sealed record ErrorEnvelope(ApiError? Error);
public sealed record WebhookEvent(string Type, DateTimeOffset? CreatedAt, JsonElement Data);Cliente tipado
Envía multipart/form-data con la parte file (con su nombre de fichero) y la parte options (JSON en texto). Usa
una Idempotency-Key por operación y reintenta los 429 respetando Retry-After con la misma clave, así que no hay
doble cobro. Los errores se leen del sobre error de la API.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ConstaiaDemo.Constaia;
public sealed class ConstaiaException(
int status, string? type, string? code, string message, string? param, string? requestId, TimeSpan retryAfter)
: Exception(message)
{
public int Status { get; } = status;
public string? Type { get; } = type;
public string? Code { get; } = code;
public string? Param { get; } = param;
public string? RequestId { get; } = requestId;
public TimeSpan RetryAfter { get; } = retryAfter;
}
public sealed class ConstaiaClient(HttpClient http)
{
private const int MaxRetries = 2;
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Status "completed" (HTTP 200) o "queued"/"processing" (HTTP 202).</summary>
public async Task<Analysis> AnalyzeAsync(
Stream file, string fileName, string? contentType, AnalyzeOptions options, CancellationToken ct = default)
{
using var buffer = new MemoryStream();
await file.CopyToAsync(buffer, ct);
var bytes = buffer.ToArray();
var optionsJson = JsonSerializer.Serialize(options, Json);
var idempotencyKey = Guid.NewGuid().ToString();
var safeName = fileName.Replace("\"", "");
for (var attempt = 0; ; attempt++)
{
using var form = new MultipartFormDataContent();
var filePart = new StreamContent(new MemoryStream(bytes));
if (MediaTypeHeaderValue.TryParse(contentType, out var mediaType))
filePart.Headers.ContentType = mediaType;
filePart.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
{
Name = "\"file\"",
FileName = $"\"{safeName}\"",
};
form.Add(filePart);
form.Add(new StringContent(optionsJson, Encoding.UTF8), "\"options\"");
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/analyze") { Content = form };
request.Headers.Add("Idempotency-Key", idempotencyKey);
using var response = await http.SendAsync(request, ct);
if (response.IsSuccessStatusCode)
return (await response.Content.ReadFromJsonAsync<Analysis>(Json, ct))!;
var error = await ToExceptionAsync(response, ct);
if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < MaxRetries)
{
await Task.Delay(error.RetryAfter, ct);
continue;
}
throw error;
}
}
public async Task<Analysis> GetAnalysisAsync(string id, CancellationToken ct = default)
{
using var response = await http.GetAsync($"v1/analyses/{Uri.EscapeDataString(id)}", ct);
if (!response.IsSuccessStatusCode) throw await ToExceptionAsync(response, ct);
return (await response.Content.ReadFromJsonAsync<Analysis>(Json, ct))!;
}
private static async Task<ConstaiaException> ToExceptionAsync(HttpResponseMessage response, CancellationToken ct)
{
ApiError? error = null;
try
{
error = (await response.Content.ReadFromJsonAsync<ErrorEnvelope>(Json, ct))?.Error;
}
catch (Exception e) when (e is JsonException or NotSupportedException)
{
// Cuerpo vacío o no JSON (por ejemplo, un proxy intermedio).
}
var requestId = error?.RequestId
?? (response.Headers.TryGetValues("X-Request-Id", out var values) ? values.FirstOrDefault() : null);
var status = (int)response.StatusCode;
return new ConstaiaException(
status,
error?.Type,
error?.Code,
error?.Message ?? $"HTTP {status}",
error?.Param,
requestId,
response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1));
}
}El Content-Disposition se construye a mano, con comillas, porque MultipartFormDataContent.Add(content, name, fileName) envía los nombres sin comillas y añade filename*, algo que no todos los parsers multipart tratan igual.
La parte file puede llegar sin Content-Type exacto; da igual, porque la API detecta
el tipo real (JPEG, PNG, WEBP, HEIC o PDF) por su contenido.
Verificación del webhook
using System.Security.Cryptography;
using System.Text;
namespace ConstaiaDemo.Constaia;
public static class ConstaiaWebhook
{
public static bool Verify(
string secret, string? id, string? timestamp, string? signatures, string body, int toleranceSeconds = 300)
{
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signatures))
return false;
if (!long.TryParse(timestamp, out var ts)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > toleranceSeconds)
return false;
var key = Convert.FromBase64String(
secret.StartsWith("whsec_", StringComparison.Ordinal) ? secret["whsec_".Length..] : secret);
var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{body}"));
foreach (var part in signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
var comma = part.IndexOf(',');
if (comma < 0 || part[..comma] != "v1") continue;
try
{
if (CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(part[(comma + 1)..]), expected))
return true;
}
catch (FormatException)
{
}
}
return false;
}
}Endpoints
Program.cs registra el cliente tipado, sube el límite de Kestrel a algo más de 20 MB y expone el endpoint que recibe
el documento y el del webhook. Conserva file.FileName: en modo test el resultado depende del nombre. Tu backend
decide expect y checks; no los aceptes tal cual del cliente.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using ConstaiaDemo.Constaia;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
const long MaxBytes = 20 * 1024 * 1024;
var builder = WebApplication.CreateBuilder(args);
var apiKey = builder.Configuration["CONSTAIA_API_KEY"]
?? throw new InvalidOperationException("Falta CONSTAIA_API_KEY");
builder.Services.AddHttpClient<ConstaiaClient>(client =>
{
client.BaseAddress = new Uri("https://api.constaia.com/");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
// El análisis síncrono puede tardar hasta 30 s antes de devolver 202.
client.Timeout = TimeSpan.FromSeconds(60);
});
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = MaxBytes + 1024 * 1024);
builder.Services.Configure<FormOptions>(o => o.MultipartBodyLengthLimit = MaxBytes + 1024 * 1024);
var app = builder.Build();
// Añade aquí tu autenticación: cada análisis consume créditos.
app.MapPost("/api/verify", async (
IFormFile file,
[FromForm(Name = "full_name")] string? fullName,
ConstaiaClient constaia,
ILogger<Program> logger,
CancellationToken ct) =>
{
if (file.Length == 0)
return Results.Json(new { error = new { message = "Falta el documento." } }, statusCode: 400);
if (file.Length > MaxBytes)
return Results.Json(new { error = new { message = "Máximo 20 MB." } }, statusCode: 413);
var options = new AnalyzeOptions(
Expect: ["es_dni"],
Checks: new Checks(
NotExpired: true,
Holder: string.IsNullOrWhiteSpace(fullName) ? null : new Holder(fullName)),
Storage: "none",
Language: "es");
try
{
await using var stream = file.OpenReadStream();
var analysis = await constaia.AnalyzeAsync(stream, file.FileName, file.ContentType, options, ct);
if (analysis.Status != "completed")
// 202: el resultado llegará por webhook (o consulta GetAnalysisAsync).
return Results.Accepted(value: new { id = analysis.Id, status = analysis.Status });
return Results.Ok(new
{
id = analysis.Id,
status = analysis.Verdict?.Status,
reasons = analysis.Verdict?.Reasons.Select(r => r.Message) ?? [],
warnings = analysis.Warnings,
});
}
catch (ConstaiaException e)
{
logger.LogWarning("Constaia {Status} {Code} request_id={RequestId}", e.Status, e.Code, e.RequestId);
return e.Type == "invalid_request"
? Results.Json(new { error = new { message = e.Message } }, statusCode: 422)
: Results.Json(new { error = new { message = "No se pudo verificar el documento. Inténtalo de nuevo." } }, statusCode: 502);
}
}).DisableAntiforgery(); // API autenticada por token; con cookies, usa AddAntiforgery/UseAntiforgery.
app.MapPost("/webhooks/constaia", async (HttpRequest request, IConfiguration config, ILogger<Program> logger) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var raw = await reader.ReadToEndAsync();
var secret = config["CONSTAIA_WEBHOOK_SECRET"]
?? throw new InvalidOperationException("Falta CONSTAIA_WEBHOOK_SECRET");
var h = request.Headers;
if (!ConstaiaWebhook.Verify(secret, h["webhook-id"], h["webhook-timestamp"], h["webhook-signature"], raw))
return Results.BadRequest();
var evt = JsonSerializer.Deserialize<WebhookEvent>(raw, ConstaiaClient.Json)!;
// webhook-id es estable entre reintentos: deduplica con él en tu base de datos.
switch (evt.Type)
{
case "analysis.completed" or "analysis.review_required" or "analysis.failed":
{
var analysis = evt.Data.Deserialize<Analysis>(ConstaiaClient.Json)!;
logger.LogInformation("{MessageId} {Type} {AnalysisId} {Verdict}",
h["webhook-id"].ToString(), evt.Type, analysis.Id, analysis.Verdict?.Status);
// Encola el trabajo pesado (Channel, BackgroundService…) y responde en menos de 15 s.
break;
}
// batch.completed, credits.low, test…
}
return Results.NoContent();
});
app.Run();verdict.status vale:
- Válido el tipo coincide y no hay avisos: acepta.
- No válido alguna razón con severidad
error(tipo distinto, caducado, titular distinto…): rechaza. - Revisar alguna razón con severidad
warning: revisión humana.
Los Code son estables; los Message vienen traducidos según language. Para un campo:
analysis.Fields?["document_number"].Value. Más en Veredictos.
200 frente a 202
El análisis síncrono espera hasta 30 s. Si termina, recibes 200 con status: "completed"; si no, 202 con
status: "queued" o "processing" y el resultado llega por webhook. Con Async: true en AnalyzeOptions la API
responde 202 al momento. analysis.review_required llega además de analysis.completed cuando el veredicto es
review.
Probar en modo test
dotnet runCon una clave ck_test_… no se gastan créditos y el resultado depende del nombre del fichero (tiene que ser una
imagen o un PDF real: renombra cualquier foto). Ajusta el puerto al que muestre dotnet run.
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:5000/api/verify
curl -F "file=@dni_expired.jpg" http://localhost:5000/api/verify
curl -F "file=@blurry.jpg" http://localhost:5000/api/verify
curl -F "file=@dni_valid.jpg" -F "full_name=Juan Pérez" http://localhost:5000/api/verify| Fichero | Veredicto | Motivo |
|---|---|---|
dni_valid.jpg | valid | "Vigente hasta el 12/03/2031." |
dni_expired.jpg | invalid | not_expired con severidad error: "Caducado el 15/06/2020." |
blurry.jpg | review | low_quality con severidad warning; avisos blurry y low_quality |
dni_valid.jpg con full_name=Juan Pérez | invalid | holder con severidad error |
Más ficheros en Modo test.
Errores
Las respuestas de error tienen la forma { "error": { "type", "code", "message", "param", "request_id" } }, que el
cliente convierte en ConstaiaException. Decide por Type y Code, nunca por el mensaje, y registra RequestId.
Tabla completa en Errores.
Checklist de producción
- Clave
ck_live_…(requiere email verificado) en tu gestor de secretos o variable de entorno. - Autenticación y límite por usuario en
/api/verify: cada análisis consume créditos. - Límite de 20 MB antes de llamar a la API.
- Una
Idempotency-Keypor operación lógica, reutilizada en los reintentos (Idempotencia). - Limita la concurrencia hacia Constaia (2 req/s por clave en el plan gratuito, 10 en el de pago), por ejemplo con un
SemaphoreSlim(Límites). - Webhook registrado para los
202y los análisisasync; deduplica porwebhook-idy responde rápido. - Revisa
storageykeep_resultsen Almacenamiento y privacidad.
Siguientes pasos
Ktor
Valida documentos con Constaia en Kotlin y Ktor 3, recibiendo multipart en el servidor, enviándolo con el cliente Ktor y verificando webhooks HMAC.
Rust
Integra Constaia en Rust con reqwest multipart, tokio y serde, un handler de axum 0.8 que reenvía el fichero y webhooks verificados con hmac y sha2.