Constaia
Integrations

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.

This guide uses reqwest to call the REST API, serde for the types, an axum 0.8 server that receives the user's file with the Multipart extractor and forwards it, and a webhook verified with the hmac, sha2 and base64 crates.

There is no official Rust SDK: 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 the rust generator of openapi-generator).

Requirements

  • A recent stable Rust (2021 edition).
  • A test key ck_test_… from the dashboard (see Authentication).
  • The whsec_… secret of a webhook endpoint (see Webhooks).

Installation

Cargo.toml
[package]
name = "constaia-axum"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = { version = "0.8", features = ["multipart"] }
base64 = "0.22"
hmac = "0.12"
reqwest = { version = "0.12", features = ["json", "multipart"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4"] }
.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

The key stays on the server. Never compile it into a binary you distribute, a desktop app or a WebAssembly module.

Client

serde types with the part of the analysis you use and an analyze function that 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. Bytes is cloned without copying the content on each attempt.

src/constaia.rs
use std::{collections::HashMap, time::Duration};

use axum::body::Bytes;
use reqwest::{
    header::RETRY_AFTER,
    multipart::{Form, Part},
    StatusCode,
};
use serde::{Deserialize, Serialize};

const BASE_URL: &str = "https://api.constaia.com";
const MAX_RETRIES: u32 = 2;

#[derive(Debug, Deserialize, Serialize)]
pub struct Analysis {
    pub id: String,
    pub status: String,
    #[serde(default)]
    pub livemode: bool,
    pub created_at: Option<String>,
    pub document: Option<DocumentInfo>,
    pub verdict: Option<Verdict>,
    #[serde(default)]
    pub fields: HashMap<String, Field>,
    #[serde(default)]
    pub warnings: Vec<String>,
    pub error: Option<AnalysisError>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct DocumentInfo {
    #[serde(rename = "type")]
    pub doc_type: String,
    pub label: Option<String>,
    pub confidence: Option<f64>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Verdict {
    #[serde(default)]
    pub expected: Vec<String>,
    #[serde(default)]
    pub r#match: bool,
    pub status: String,
    #[serde(default)]
    pub reasons: Vec<Reason>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Reason {
    pub code: String,
    pub severity: String,
    pub message: String,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Field {
    #[serde(default)]
    pub value: serde_json::Value,
    pub confidence: Option<f64>,
    pub validated: Option<bool>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct AnalysisError {
    pub code: String,
    pub message: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
struct ApiErrorBody {
    #[serde(rename = "type")]
    kind: Option<String>,
    code: Option<String>,
    message: Option<String>,
    param: Option<String>,
    request_id: Option<String>,
}

#[derive(Debug, Deserialize)]
struct ErrorEnvelope {
    error: ApiErrorBody,
}

#[derive(Debug, thiserror::Error)]
pub enum ConstaiaError {
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("Constaia {status} {code:?}: {message} (request_id={request_id:?})")]
    Api {
        status: u16,
        kind: Option<String>,
        code: Option<String>,
        message: String,
        param: Option<String>,
        request_id: Option<String>,
    },
}

#[derive(Clone)]
pub struct Constaia {
    http: reqwest::Client,
    api_key: String,
}

impl Constaia {
    pub fn new(api_key: String) -> Self {
        let http = reqwest::Client::builder()
            // A synchronous analysis can take up to 30 s before returning 202.
            .timeout(Duration::from_secs(60))
            .build()
            .expect("reqwest client");
        Self { http, api_key }
    }

    /// status "completed" (HTTP 200) or "queued"/"processing" (HTTP 202).
    pub async fn analyze(
        &self,
        file: Bytes,
        filename: &str,
        content_type: &str,
        options: &serde_json::Value,
    ) -> Result<Analysis, ConstaiaError> {
        let options_json = options.to_string();
        let idempotency_key = uuid::Uuid::new_v4().to_string();
        let mut attempt = 0;

        loop {
            let part = Part::stream_with_length(file.clone(), file.len() as u64)
                .file_name(filename.to_owned())
                .mime_str(content_type)?;
            let form = Form::new().part("file", part).text("options", options_json.clone());

            let res = self
                .http
                .post(format!("{BASE_URL}/v1/analyze"))
                .bearer_auth(&self.api_key)
                .header("Idempotency-Key", &idempotency_key)
                .multipart(form)
                .send()
                .await?;

            if res.status().is_success() {
                return Ok(res.json::<Analysis>().await?);
            }

            let retry_after = res
                .headers()
                .get(RETRY_AFTER)
                .and_then(|v| v.to_str().ok())
                .and_then(|v| v.parse::<u64>().ok())
                .unwrap_or(1);
            if res.status() == StatusCode::TOO_MANY_REQUESTS && attempt < MAX_RETRIES {
                attempt += 1;
                tokio::time::sleep(Duration::from_secs(retry_after)).await;
                continue;
            }
            return Err(api_error(res).await);
        }
    }

    pub async fn get_analysis(&self, id: &str) -> Result<Analysis, ConstaiaError> {
        let res = self
            .http
            .get(format!("{BASE_URL}/v1/analyses/{id}"))
            .bearer_auth(&self.api_key)
            .send()
            .await?;
        if !res.status().is_success() {
            return Err(api_error(res).await);
        }
        Ok(res.json().await?)
    }
}

async fn api_error(res: reqwest::Response) -> ConstaiaError {
    let status = res.status().as_u16();
    let header_request_id = res
        .headers()
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .map(str::to_owned);
    let body = res
        .json::<ErrorEnvelope>()
        .await
        .map(|e| e.error)
        .unwrap_or_default();
    ConstaiaError::Api {
        status,
        kind: body.kind,
        code: body.code,
        message: body.message.unwrap_or_else(|| format!("HTTP {status}")),
        param: body.param,
        request_id: body.request_id.or(header_request_id),
    }
}

Webhook verification

Mac::verify_slice compares in constant time. The signature is computed over the raw body, so the handler takes Bytes, not Json.

src/webhook.rs
use std::time::{SystemTime, UNIX_EPOCH};

use axum::http::HeaderMap;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;
const TOLERANCE_SECS: i64 = 300;

#[derive(Debug)]
pub enum WebhookError {
    MissingHeader,
    BadTimestamp,
    BadSecret,
    BadSignature,
    BadPayload,
}

pub fn verify_webhook(secret: &str, headers: &HeaderMap, body: &[u8]) -> Result<serde_json::Value, WebhookError> {
    let header = |name: &str| {
        headers
            .get(name)
            .and_then(|v| v.to_str().ok())
            .ok_or(WebhookError::MissingHeader)
    };
    let id = header("webhook-id")?;
    let timestamp = header("webhook-timestamp")?;
    let signatures = header("webhook-signature")?;

    let ts: i64 = timestamp.parse().map_err(|_| WebhookError::BadTimestamp)?;
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|_| WebhookError::BadTimestamp)?
        .as_secs() as i64;
    if (now - ts).abs() > TOLERANCE_SECS {
        return Err(WebhookError::BadTimestamp);
    }

    let key = STANDARD
        .decode(secret.strip_prefix("whsec_").unwrap_or(secret))
        .map_err(|_| WebhookError::BadSecret)?;

    for part in signatures.split_whitespace() {
        let Some(("v1", sig)) = part.split_once(',') else { continue };
        let Ok(sig) = STANDARD.decode(sig) else { continue };
        let mut mac = HmacSha256::new_from_slice(&key).map_err(|_| WebhookError::BadSecret)?;
        mac.update(id.as_bytes());
        mac.update(b".");
        mac.update(timestamp.as_bytes());
        mac.update(b".");
        mac.update(body);
        if mac.verify_slice(&sig).is_ok() {
            return serde_json::from_slice(body).map_err(|_| WebhookError::BadPayload);
        }
    }
    Err(WebhookError::BadSignature)
}

axum server

The Multipart extractor reads the user's file. axum limits the body to 2 MB by default: DefaultBodyLimit raises it to just over 20 MB. Keep file_name(): in test mode the result depends on the name. Your backend decides expect and checks; don't take them as-is from the client.

src/main.rs
mod constaia;
mod webhook;

use axum::{
    body::Bytes,
    extract::{multipart::MultipartError, DefaultBodyLimit, Multipart, State},
    http::{HeaderMap, StatusCode},
    response::{IntoResponse, Response},
    routing::post,
    Json, Router,
};
use serde_json::json;

use crate::constaia::{Constaia, ConstaiaError};
use crate::webhook::verify_webhook;

const MAX_BYTES: usize = 20 * 1024 * 1024;

#[derive(Clone)]
struct AppState {
    constaia: Constaia,
    webhook_secret: String,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let state = AppState {
        constaia: Constaia::new(std::env::var("CONSTAIA_API_KEY")?),
        webhook_secret: std::env::var("CONSTAIA_WEBHOOK_SECRET")?,
    };

    let app = Router::new()
        .route("/api/verify", post(verify))
        .route("/webhooks/constaia", post(webhook))
        .layer(DefaultBodyLimit::max(MAX_BYTES + 1024 * 1024))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

struct AppError(StatusCode, String);

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        (self.0, Json(json!({ "error": { "message": self.1 } }))).into_response()
    }
}

impl From<MultipartError> for AppError {
    fn from(e: MultipartError) -> Self {
        AppError(StatusCode::BAD_REQUEST, e.body_text())
    }
}

// Add your authentication here: every analysis spends credits.
async fn verify(State(state): State<AppState>, mut multipart: Multipart) -> Result<Response, AppError> {
    let mut file = None;
    let mut full_name = None;

    while let Some(field) = multipart.next_field().await? {
        let name = field.name().unwrap_or_default().to_owned();
        match name.as_str() {
            "file" => {
                let filename = field.file_name().unwrap_or("document").to_owned();
                let content_type = field.content_type().unwrap_or("application/octet-stream").to_owned();
                file = Some((field.bytes().await?, filename, content_type));
            }
            "full_name" => full_name = Some(field.text().await?),
            _ => {}
        }
    }

    let Some((bytes, filename, content_type)) = file else {
        return Err(AppError(StatusCode::BAD_REQUEST, "The document is missing.".into()));
    };
    if bytes.is_empty() {
        return Err(AppError(StatusCode::BAD_REQUEST, "The document is missing.".into()));
    }
    if bytes.len() > MAX_BYTES {
        return Err(AppError(StatusCode::PAYLOAD_TOO_LARGE, "Max 20 MB.".into()));
    }

    let mut checks = json!({ "not_expired": true });
    if let Some(name) = full_name.filter(|n| !n.trim().is_empty()) {
        checks["holder"] = json!({ "full_name": name });
    }
    let options = json!({
        "expect": "es_dni",
        "checks": checks,
        "storage": "none",
        "language": "en",
    });

    let analysis = match state.constaia.analyze(bytes, &filename, &content_type, &options).await {
        Ok(a) => a,
        Err(ConstaiaError::Api { status, kind, code, message, request_id, .. }) => {
            eprintln!("Constaia {status} {code:?} request_id={request_id:?}");
            return Err(if kind.as_deref() == Some("invalid_request") {
                AppError(StatusCode::UNPROCESSABLE_ENTITY, message)
            } else {
                AppError(StatusCode::BAD_GATEWAY, "The document could not be verified. Please try again.".into())
            });
        }
        Err(e) => {
            eprintln!("Constaia: {e}");
            return Err(AppError(StatusCode::BAD_GATEWAY, "The document could not be verified. Please try again.".into()));
        }
    };

    if analysis.status != "completed" {
        // 202: the result will arrive by webhook (or call get_analysis).
        let body = Json(json!({ "id": analysis.id, "status": analysis.status }));
        return Ok((StatusCode::ACCEPTED, body).into_response());
    }

    let reasons: Vec<&str> = analysis
        .verdict
        .as_ref()
        .map(|v| v.reasons.iter().map(|r| r.message.as_str()).collect())
        .unwrap_or_default();
    Ok(Json(json!({
        "id": analysis.id,
        "status": analysis.verdict.as_ref().map(|v| v.status.as_str()),
        "reasons": reasons,
        "warnings": analysis.warnings,
    }))
    .into_response())
}

async fn webhook(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> StatusCode {
    let event = match verify_webhook(&state.webhook_secret, &headers, &body) {
        Ok(event) => event,
        Err(_) => return StatusCode::BAD_REQUEST,
    };

    // webhook-id is stable across retries: dedupe on it in your database.
    let msg_id = headers.get("webhook-id").and_then(|v| v.to_str().ok()).unwrap_or_default();
    match event["type"].as_str() {
        Some(kind @ ("analysis.completed" | "analysis.review_required" | "analysis.failed")) => {
            let data = &event["data"];
            println!("{msg_id} {kind} {} {}", data["id"], data["verdict"]["status"]);
            // Queue heavy work (tokio::spawn, a queue…) and answer within 15 s.
        }
        _ => {} // batch.completed, credits.low, test…
    }
    StatusCode::NO_CONTENT
}

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.get("document_number").map(|f| &f.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 the options the API answers 202 right away. analysis.review_required is sent in addition to analysis.completed when the verdict is review.

Test mode

export $(cat .env | xargs) && cargo 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).

curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:3000/api/verify
curl -F "file=@dni_expired.jpg" http://localhost:3000/api/verify
curl -F "file=@blurry.jpg" http://localhost:3000/api/verify
curl -F "file=@dni_valid.jpg" -F "full_name=Juan Pérez" http://localhost:3000/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 ConstaiaError::Api. Branch on kind (the API's type) and code, never on the message, and log request_id. 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 tokio::sync::Semaphore (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

On this page