.NET
Integrate Constaia into ASP.NET Core 8+ with a minimal API, IHttpClientFactory, MultipartFormDataContent, System.Text.Json and verified HMAC webhooks.
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.SnakeCaseLowerexists 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 ConstaiaDemoConfiguration
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.
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.
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
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.
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 runWith 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| File | Verdict | Reason |
|---|---|---|
dni_valid.jpg | valid | "Valid until 12/03/2031." |
dni_expired.jpg | invalid | not_expired with severity error |
blurry.jpg | review | low_quality with severity warning; warnings blurry and low_quality |
dni_valid.jpg with full_name=Juan Pérez | invalid | holder 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-Keyper 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
202responses andasyncanalyses; dedupe onwebhook-idand answer fast. - Review
storageandkeep_resultsin Storage and privacy.
Next steps
Ktor
Validate documents with Constaia in Kotlin and Ktor 3, receiving multipart on the server, sending it with the Ktor client and verifying HMAC webhooks.
Rust
Integrate Constaia in Rust with reqwest multipart, tokio and serde, an axum 0.8 handler that forwards the file and webhooks verified with hmac and sha2.