Constaia
Integraciones

Vapor

Valida documentos con Constaia desde Swift en el servidor con Vapor 4, subida multipart con el cliente de Vapor, Codable y webhooks HMAC con swift-crypto.

Esta guía integra Constaia en una app Vapor 4: una ruta que decodifica el File que sube el usuario, un cliente que lo reenvía a la API REST con el cliente HTTP de Vapor, structs Codable con CodingKeys y un webhook verificado con HMAC<SHA256> de swift-crypto.

No hay SDK oficial para Swift: 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 swift-openapi-generator).

Solo en el servidor

Este código es para tu backend Vapor. En una app iOS o macOS no incluyas la clave: la app sube la foto a tu backend (como en la guía de Flutter) y tu backend llama a Constaia.

Requisitos

  • Swift 5.9 o superior y Vapor 4.
  • Una clave de test ck_test_… del panel (ver Autenticación).
  • El secreto whsec_… de un endpoint de webhook (ver Webhooks).

Instalación

Package.swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "ConstaiaVapor",
    platforms: [.macOS(.v13)],
    dependencies: [
        .package(url: "https://github.com/vapor/vapor.git", from: "4.115.0"),
        .package(url: "https://github.com/apple/swift-crypto.git", "3.0.0" ..< "5.0.0"),
    ],
    targets: [
        .executableTarget(
            name: "App",
            dependencies: [
                .product(name: "Vapor", package: "vapor"),
                .product(name: "Crypto", package: "swift-crypto"),
            ]
        ),
    ]
)
.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

Vapor carga .env al arrancar y la clave se lee con Environment.get("CONSTAIA_API_KEY").

Modelos

Structs Codable con la parte del análisis que usas. Los CodingKeys mapean los nombres en snake_case. Como los valores de fields cambian de tipo según el documento, aquí se decodifican solo los campos de es_dni que usas.

Sources/App/Models.swift
import Vapor

struct Analysis: Codable {
    let id: String
    let status: String
    let livemode: Bool?
    let createdAt: String?
    let document: DocumentInfo?
    let verdict: Verdict?
    let fields: DniFields?
    let warnings: [String]?
    let error: AnalysisError?

    enum CodingKeys: String, CodingKey {
        case id, status, livemode, document, verdict, fields, warnings, error
        case createdAt = "created_at"
    }
}

struct DocumentInfo: Codable {
    let type: String
    let label: String?
    let confidence: Double?
}

struct Verdict: Codable {
    let expected: [String]?
    let match: Bool?
    let status: String
    let reasons: [Reason]
}

struct Reason: Codable {
    let code: String
    let severity: String
    let message: String
}

struct FieldValue<Value: Codable>: Codable {
    let value: Value?
    let confidence: Double?
    let validated: Bool?
}

/// Solo los campos de es_dni que usas; el resto se ignora.
struct DniFields: Codable {
    let documentNumber: FieldValue<String>?
    let birthDate: FieldValue<String>?
    let expiryDate: FieldValue<String>?

    enum CodingKeys: String, CodingKey {
        case documentNumber = "document_number"
        case birthDate = "birth_date"
        case expiryDate = "expiry_date"
    }
}

struct AnalysisError: Codable {
    let code: String
    let message: String?
}

struct AnalyzeOptions: Codable {
    var expect: [String]
    var checks: Checks?
    var storage: String?
    var async: Bool?
    var language: String?
    var metadata: [String: String]?
}

struct Checks: Codable {
    var notExpired: Bool?
    var holder: Holder?

    enum CodingKeys: String, CodingKey {
        case notExpired = "not_expired"
        case holder
    }
}

struct Holder: Codable {
    var fullName: String?

    enum CodingKeys: String, CodingKey {
        case fullName = "full_name"
    }
}

struct APIErrorBody: Codable {
    let type: String?
    let code: String?
    let message: String?
    let param: String?
    let requestId: String?

    enum CodingKeys: String, CodingKey {
        case type, code, message, param
        case requestId = "request_id"
    }
}

struct ErrorEnvelope: Codable {
    let error: APIErrorBody
}

struct WebhookEventType: Decodable {
    let type: String
}

struct WebhookEvent<Payload: Codable>: Codable {
    let type: String
    let createdAt: String?
    let data: Payload

    enum CodingKeys: String, CodingKey {
        case type, data
        case createdAt = "created_at"
    }
}

Cliente

content.encode(_:as: .formData) envía multipart/form-data con la parte file (el File conserva nombre y tipo) 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.

Sources/App/ConstaiaClient.swift
import Foundation
import Vapor

struct ConstaiaError: Error {
    let status: UInt
    let type: String?
    let code: String?
    let message: String
    let param: String?
    let requestId: String?
}

private struct AnalyzeUpload: Content {
    let file: File
    let options: String
}

struct ConstaiaClient {
    static let baseURL = "https://api.constaia.com"
    let client: any Client
    let apiKey: String
    var maxRetries = 2

    /// status "completed" (HTTP 200) o "queued"/"processing" (HTTP 202).
    func analyze(file: File, options: AnalyzeOptions) async throws -> Analysis {
        let optionsJSON = String(decoding: try JSONEncoder().encode(options), as: UTF8.self)
        let idempotencyKey = UUID().uuidString
        var attempt = 0

        while true {
            let response = try await client.post(URI(string: "\(Self.baseURL)/v1/analyze")) { req in
                req.headers.bearerAuthorization = BearerAuthorization(token: apiKey)
                req.headers.replaceOrAdd(name: "Idempotency-Key", value: idempotencyKey)
                try req.content.encode(AnalyzeUpload(file: file, options: optionsJSON), as: .formData)
            }

            if (200..<300).contains(response.status.code) {
                return try response.content.decode(Analysis.self)
            }
            if response.status == .tooManyRequests, attempt < maxRetries {
                attempt += 1
                let wait = response.headers.first(name: "Retry-After").flatMap { Int($0) } ?? 1
                try await Task.sleep(for: .seconds(wait))
                continue
            }
            throw toError(response)
        }
    }

    func getAnalysis(id: String) async throws -> Analysis {
        let response = try await client.get(URI(string: "\(Self.baseURL)/v1/analyses/\(id)")) { req in
            req.headers.bearerAuthorization = BearerAuthorization(token: apiKey)
        }
        guard (200..<300).contains(response.status.code) else { throw toError(response) }
        return try response.content.decode(Analysis.self)
    }

    private func toError(_ response: ClientResponse) -> ConstaiaError {
        let body = try? response.content.decode(ErrorEnvelope.self).error
        return ConstaiaError(
            status: response.status.code,
            type: body?.type,
            code: body?.code,
            message: body?.message ?? "HTTP \(response.status.code)",
            param: body?.param,
            requestId: body?.requestId ?? response.headers.first(name: "X-Request-Id")
        )
    }
}

Verificación del webhook

HMAC<SHA256>.isValidAuthenticationCode compara en tiempo constante.

Sources/App/ConstaiaWebhook.swift
import Crypto
import Foundation
import Vapor

enum ConstaiaWebhook {
    static let tolerance = 300

    static func verify(body: String, headers: HTTPHeaders, secret: String, now: Date = Date()) -> Bool {
        guard
            let id = headers.first(name: "webhook-id"),
            let timestamp = headers.first(name: "webhook-timestamp"),
            let signatures = headers.first(name: "webhook-signature"),
            let ts = Int(timestamp),
            abs(Int(now.timeIntervalSince1970) - ts) <= tolerance
        else { return false }

        let encoded = secret.hasPrefix("whsec_") ? String(secret.dropFirst("whsec_".count)) : secret
        guard let keyData = Data(base64Encoded: encoded) else { return false }
        let key = SymmetricKey(data: keyData)
        let signed = Data("\(id).\(timestamp).\(body)".utf8)

        for part in signatures.split(separator: " ") {
            let pieces = part.split(separator: ",", maxSplits: 1)
            guard pieces.count == 2, pieces[0] == "v1",
                  let signature = Data(base64Encoded: String(pieces[1])) else { continue }
            if HMAC<SHA256>.isValidAuthenticationCode(signature, authenticating: signed, using: key) {
                return true
            }
        }
        return false
    }
}

Rutas

La ruta de verificación recoge el cuerpo completo (hasta 21 MB) y decodifica el multipart en VerifyInput. Conserva el nombre del fichero: en modo test el resultado depende de él. Tu backend decide expect y checks; no los aceptes tal cual del cliente. Los errores salen como Abort, que Vapor serializa como { "error": true, "reason": "…" }. El webhook lee el cuerpo crudo con req.body.string y verifica la firma antes de decodificarlo.

Sources/App/routes.swift
import Foundation
import Vapor

struct VerifyInput: Content {
    var file: File
    var fullName: String?

    enum CodingKeys: String, CodingKey {
        case file
        case fullName = "full_name"
    }
}

struct VerifyResult: Content {
    let id: String
    let status: String?
    let reasons: [String]
    let warnings: [String]
}

struct PendingResult: Content {
    let id: String
    let status: String
}

func routes(_ app: Application) throws {
    // Añade aquí tu autenticación: cada análisis consume créditos.
    app.on(.POST, "api", "verify", body: .collect(maxSize: "21mb")) { req async throws -> Response in
        let input = try req.content.decode(VerifyInput.self)
        guard input.file.data.readableBytes > 0 else {
            throw Abort(.badRequest, reason: "Falta el documento.")
        }
        guard input.file.data.readableBytes <= 20 * 1024 * 1024 else {
            throw Abort(.payloadTooLarge, reason: "Máximo 20 MB.")
        }
        guard let apiKey = Environment.get("CONSTAIA_API_KEY") else {
            throw Abort(.internalServerError, reason: "Falta CONSTAIA_API_KEY")
        }

        var checks = Checks(notExpired: true)
        if let name = input.fullName, !name.trimmingCharacters(in: .whitespaces).isEmpty {
            checks.holder = Holder(fullName: name)
        }
        let options = AnalyzeOptions(expect: ["es_dni"], checks: checks, storage: "none", language: "es")

        let constaia = ConstaiaClient(client: req.client, apiKey: apiKey)
        let analysis: Analysis
        do {
            analysis = try await constaia.analyze(file: input.file, options: options)
        } catch let error as ConstaiaError {
            req.logger.warning("Constaia \(error.status) \(error.code ?? "-") request_id=\(error.requestId ?? "-")")
            if error.type == "invalid_request" {
                throw Abort(.unprocessableEntity, reason: error.message)
            }
            throw Abort(.badGateway, reason: "No se pudo verificar el documento. Inténtalo de nuevo.")
        }

        if analysis.status != "completed" {
            // 202: el resultado llegará por webhook (o consulta getAnalysis).
            let pending = PendingResult(id: analysis.id, status: analysis.status)
            return try await pending.encodeResponse(status: .accepted, for: req)
        }

        let result = VerifyResult(
            id: analysis.id,
            status: analysis.verdict?.status,
            reasons: analysis.verdict?.reasons.map(\.message) ?? [],
            warnings: analysis.warnings ?? []
        )
        return try await result.encodeResponse(for: req)
    }

    app.on(.POST, "webhooks", "constaia", body: .collect(maxSize: "1mb")) { req async throws -> HTTPStatus in
        guard let secret = Environment.get("CONSTAIA_WEBHOOK_SECRET") else {
            throw Abort(.internalServerError, reason: "Falta CONSTAIA_WEBHOOK_SECRET")
        }
        guard let raw = req.body.string,
              ConstaiaWebhook.verify(body: raw, headers: req.headers, secret: secret)
        else { return .badRequest }

        // webhook-id es estable entre reintentos: deduplica con él en tu base de datos.
        let msgId = req.headers.first(name: "webhook-id") ?? "-"
        let body = Data(raw.utf8)
        let type = try JSONDecoder().decode(WebhookEventType.self, from: body).type
        switch type {
        case "analysis.completed", "analysis.review_required", "analysis.failed":
            let event = try JSONDecoder().decode(WebhookEvent<Analysis>.self, from: body)
            req.logger.info("\(msgId) \(type) \(event.data.id) \(event.data.verdict?.status ?? "-")")
            // Encola el trabajo pesado (Vapor Queues…) y responde en menos de 15 s.
        default:
            break // batch.completed, credits.low, test…
        }
        return .noContent
    }
}
Sources/App/entrypoint.swift
import Vapor

@main
enum Entrypoint {
    static func main() async throws {
        var env = try Environment.detect()
        try LoggingSystem.bootstrap(from: &env)
        let app = try await Application.make(env)
        // El análisis síncrono puede tardar hasta 30 s antes de devolver 202.
        app.http.client.configuration.timeout = .init(connect: .seconds(10), read: .seconds(60))
        do {
            try routes(app)
            try await app.execute()
        } catch {
            app.logger.report(error: error)
            try? await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }
}

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?.documentNumber?.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

swift run App serve --hostname 0.0.0.0 --port 8080

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

curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:8080/api/verify
curl -F "file=@dni_expired.jpg" http://localhost:8080/api/verify
curl -F "file=@blurry.jpg" http://localhost:8080/api/verify
curl -F "file=@dni_valid.jpg" -F "full_name=Juan Pérez" http://localhost:8080/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 ConstaiaError. 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).
  • Respeta el límite por clave (2 req/s en el plan gratuito, 10 en el de pago) y Retry-After (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