Constaia
Integrations

Vapor

Validate documents with Constaia from server-side Swift with Vapor 4, multipart upload with the Vapor client, Codable and HMAC webhooks with swift-crypto.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

This guide integrates Constaia into a Vapor 4 app: a route that decodes the File the user uploads, a client that forwards it to the REST API with Vapor's HTTP client, Codable structs with CodingKeys and a webhook verified with swift-crypto's HMAC<SHA256>.

There is no official Swift 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 swift-openapi-generator).

Server only

This code is for your Vapor backend. Never ship the key in an iOS or macOS app: the app uploads the photo to your backend (as in the Flutter guide) and your backend calls Constaia.

Requirements

  • Swift 5.9 or later and Vapor 4.
  • A test key ck_test_… from the dashboard (see Authentication).
  • The whsec_… secret of a webhook endpoint (see Webhooks).

Installation

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 loads .env on startup and the key is read with Environment.get("CONSTAIA_API_KEY").

Models

Codable structs with the part of the analysis you use. CodingKeys map the snake_case names. Since fields values change type depending on the document, only the es_dni fields you use are decoded here.

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?
}

/// Only the es_dni fields you use; the rest are ignored.
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"
    }
}

Client

content.encode(_:as: .formData) sends multipart/form-data with the file part (the File keeps its name and type) 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.

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) or "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")
        )
    }
}

Webhook verification

HMAC<SHA256>.isValidAuthenticationCode compares in constant time.

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

Routes

The verification route collects the whole body (up to 21 MB) and decodes the multipart into VerifyInput. Keep the filename: in test mode the result depends on it. Your backend decides expect and checks; don't take them as-is from the client. Errors are thrown as Abort, which Vapor serialises as { "error": true, "reason": "…" }. The webhook reads the raw body with req.body.string and verifies the signature before decoding it.

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 {
    // Add your authentication here: every analysis spends credits.
    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: "The document is missing.")
        }
        guard input.file.data.readableBytes <= 20 * 1024 * 1024 else {
            throw Abort(.payloadTooLarge, reason: "Max 20 MB.")
        }
        guard let apiKey = Environment.get("CONSTAIA_API_KEY") else {
            throw Abort(.internalServerError, reason: "CONSTAIA_API_KEY is missing")
        }

        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: "en")

        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: "The document could not be verified. Please try again.")
        }

        if analysis.status != "completed" {
            // 202: the result will arrive by webhook (or call 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: "CONSTAIA_WEBHOOK_SECRET is missing")
        }
        guard let raw = req.body.string,
              ConstaiaWebhook.verify(body: raw, headers: req.headers, secret: secret)
        else { return .badRequest }

        // webhook-id is stable across retries: dedupe on it in your database.
        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 ?? "-")")
            // Queue heavy work (Vapor Queues…) and answer within 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)
        // A synchronous analysis can take up to 30 s before returning 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 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?.documentNumber?.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

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

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: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
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. 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).
  • Respect the per-key limit (2 req/s on the free plan, 10 on paid) and Retry-After (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

Nesta página