Constaia
Integrations

.NET

Integrate Constaia into ASP.NET Core 8+ with a minimal API, IHttpClientFactory, MultipartFormDataContent, System.Text.Json and verified HMAC webhooks.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

This guide integrates Constaia into an ASP.NET Core minimal API (.NET 8 or later): a typed client registered with IHttpClientFactory, an endpoint that receives an IFormFile and forwards it, records with System.Text.Json and a webhook endpoint with a verified signature.

Official .NET SDK: coming soon

There is no official NuGet package yet. Here you call the REST API directly. If you prefer a generated client, the OpenAPI spec is at https://api.constaia.com/openapi.json (for example, with NSwag or Kiota).

Requirements

  • .NET 8 or later (JsonNamingPolicy.SnakeCaseLower exists since .NET 8).
  • A test key ck_test_… from the dashboard (see Authentication).
  • The whsec_… secret of a webhook endpoint (see Webhooks).

Installation

You don't need extra packages:

dotnet new web -n ConstaiaDemo
cd ConstaiaDemo

Configuration

In development, store the key with user-secrets; in production, as an environment variable. Both reach IConfiguration under the same name.

dotnet user-secrets init
dotnet user-secrets set CONSTAIA_API_KEY ck_test_...
dotnet user-secrets set CONSTAIA_WEBHOOK_SECRET whsec_...

The key stays on the server; never ship it in a Blazor WebAssembly or MAUI client or any frontend.

Models

Records with the part of the analysis you use. SnakeCaseLower maps NotExpired to not_expired, RequestId to request_id, and so on. Metadata keys are not transformed.

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);

Typed client

It sends multipart/form-data with the file part (with its filename) and the options part (JSON as text). It uses one Idempotency-Key per operation and retries 429 responses honouring Retry-After with the same key, so there is no double charge. Errors are read from the API's error envelope.

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) or "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)
        {
            // Empty or non-JSON body (for example, an intermediate proxy).
        }

        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));
    }
}

The Content-Disposition is built by hand, with quotes, because MultipartFormDataContent.Add(content, name, fileName) sends unquoted names and adds filename*, which not every multipart parser treats the same way. The file part may arrive without an exact Content-Type; that's fine, because the API detects the real type (JPEG, PNG, WEBP, HEIC or PDF) from the content.

Webhook verification

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 registers the typed client, raises the Kestrel limit to just over 20 MB and exposes the endpoint that receives the document and the webhook endpoint. Keep file.FileName: in test mode the result depends on the name. Your backend decides expect and checks; don't take them as-is from the client.

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("CONSTAIA_API_KEY is missing");

builder.Services.AddHttpClient<ConstaiaClient>(client =>
{
    client.BaseAddress = new Uri("https://api.constaia.com/");
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
    // A synchronous analysis can take up to 30 s before returning 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();

// Add your authentication here: every analysis spends credits.
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 = "The document is missing." } }, statusCode: 400);
    if (file.Length > MaxBytes)
        return Results.Json(new { error = new { message = "Max 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: "en");

    try
    {
        await using var stream = file.OpenReadStream();
        var analysis = await constaia.AnalyzeAsync(stream, file.FileName, file.ContentType, options, ct);

        if (analysis.Status != "completed")
            // 202: the result will arrive by webhook (or call 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 = "The document could not be verified. Please try again." } }, statusCode: 502);
    }
}).DisableAntiforgery(); // Token-authenticated API; with cookies, use 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("CONSTAIA_WEBHOOK_SECRET is missing");
    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 is stable across retries: dedupe on it in your database.
    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);
            // Queue heavy work (Channel, BackgroundService…) and answer within 15 s.
            break;
        }
        // batch.completed, credits.low, test…
    }
    return Results.NoContent();
});

app.Run();

verdict.status is:

  • Válido the type matches and there are no warnings: accept.
  • No válido some reason with severity error (wrong type, expired, holder mismatch…): reject.
  • Revisar some reason with severity warning: human review.

Code values are stable; Message values are localised according to language. For a field: analysis.Fields?["document_number"].Value. More in Verdicts.

200 vs 202

A synchronous analysis waits up to 30 s. If it finishes, you get 200 with status: "completed"; if not, 202 with status: "queued" or "processing" and the result arrives by webhook. With Async: true in AnalyzeOptions the API answers 202 right away. analysis.review_required is sent in addition to analysis.completed when the verdict is review.

Test mode

dotnet run

With a ck_test_… key no credits are spent and the result depends on the filename (it must be a real image or PDF: rename any photo). Use the port that dotnet run prints.

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
FileVerdictReason
dni_valid.jpgvalid"Valid until 12/03/2031."
dni_expired.jpginvalidnot_expired with severity error
blurry.jpgreviewlow_quality with severity warning; warnings blurry and low_quality
dni_valid.jpg with full_name=Juan Pérezinvalidholder with severity error

More files in Test mode.

Errors

Error responses have the shape { "error": { "type", "code", "message", "param", "request_id" } }, which the client turns into ConstaiaException. Branch on Type and Code, never on the message, and log RequestId. Full table in Errors.

Production checklist

  • A ck_live_… key (requires a verified email) in your secret manager or an environment variable.
  • Authentication and a per-user limit on /api/verify: every analysis spends credits.
  • A 20 MB limit before calling the API.
  • One Idempotency-Key per logical operation, reused on retries (Idempotency).
  • Limit concurrency towards Constaia (2 req/s per key on the free plan, 10 on paid), for example with a SemaphoreSlim (Rate limits).
  • A webhook registered for 202 responses and async analyses; dedupe on webhook-id and answer fast.
  • Review storage and keep_results in Storage and privacy.

Next steps

Sur cette page