Constaia
Integraciones

.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.SnakeCaseLower existe 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 ConstaiaDemo

Configuració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.

Constaia/Models.cs
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.

Constaia/ConstaiaClient.cs
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

Constaia/ConstaiaWebhook.cs
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.

Program.cs
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 run

Con 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
FicheroVeredictoMotivo
dni_valid.jpgvalid"Vigente hasta el 12/03/2031."
dni_expired.jpginvalidnot_expired con severidad error: "Caducado el 15/06/2020."
blurry.jpgreviewlow_quality con severidad warning; avisos blurry y low_quality
dni_valid.jpg con full_name=Juan Pérezinvalidholder 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-Key por 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 202 y los análisis async; deduplica por webhook-id y responde rápido.
  • Revisa storage y keep_results en Almacenamiento y privacidad.

Siguientes pasos

En esta página