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.
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
// 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"),
]
),
]
)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.
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.
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.
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.
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
}
}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 8080With 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| 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 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-Keyper 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
202responses andasyncanalyses; dedupe onwebhook-idand answer fast. - Review
storageandkeep_resultsin Storage and privacy.