Ruby on Rails
Valida DNI y otros documentos desde Rails 7/8 con Faraday multipart, un service object, ActiveJob y webhooks de Constaia verificados con HMAC.
Esta guía integra Constaia en una app Rails 7 u 8: un service object que llama a la API REST, un controlador que recibe el fichero del usuario, un webhook con la firma verificada y un job de ActiveJob que procesa los eventos.
SDK oficial de Ruby: próximamente
Todavía no hay gema oficial. Aquí llamas a la API REST directamente con Faraday. Si prefieres un cliente generado,
la especificación OpenAPI está en https://api.constaia.com/openapi.json (por ejemplo, con openapi-generator).
Requisitos
- Ruby 3.2 o superior y Rails 7.1 u 8.
- Una clave de test
ck_test_…del panel (ver Autenticación). - El secreto
whsec_…de un endpoint de webhook si vas a usar el modo asíncrono (ver Webhooks).
Instalación
gem "faraday", "~> 2.12"
gem "faraday-multipart", "~> 1.1"bundle installConfiguración
La clave se queda siempre en el servidor. Puedes usar credenciales cifradas o variables de entorno:
bin/rails credentials:editconstaia:
api_key: ck_test_...
webhook_secret: whsec_...O bien:
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...Service object
El cliente envía multipart/form-data con el campo file y el campo options (JSON en texto), añade una
Idempotency-Key por operación y reintenta los 429 respetando Retry-After con la misma clave, así que no hay
doble cobro.
require "faraday"
require "faraday/multipart"
require "json"
require "securerandom"
class ConstaiaClient
BASE_URL = "https://api.constaia.com"
MAX_RETRIES = 2
class Error < StandardError
attr_reader :status, :type, :code, :param, :request_id, :retry_after
def initialize(status:, type:, code:, message:, param: nil, request_id: nil, retry_after: nil)
super(message)
@status = status
@type = type
@code = code
@param = param
@request_id = request_id
@retry_after = retry_after
end
end
def self.api_key
Rails.application.credentials.dig(:constaia, :api_key) || ENV.fetch("CONSTAIA_API_KEY")
end
def initialize(api_key: self.class.api_key)
@conn = Faraday.new(
url: BASE_URL,
headers: { "Authorization" => "Bearer #{api_key}" },
request: { timeout: 60, open_timeout: 10 }
) do |f|
f.request :multipart
end
end
# Devuelve el análisis (Hash). status "completed" (HTTP 200) o "queued"/"processing" (HTTP 202).
def analyze(io:, filename:, content_type:, options:, idempotency_key: SecureRandom.uuid)
attempts = 0
begin
io.rewind if io.respond_to?(:rewind)
payload = {
file: Faraday::Multipart::FilePart.new(io, content_type || "application/octet-stream", filename),
options: options.to_json
}
handle(@conn.post("/v1/analyze", payload, "Idempotency-Key" => idempotency_key))
rescue Error => e
attempts += 1
raise unless e.status == 429 && attempts <= MAX_RETRIES
sleep(e.retry_after || 1)
retry
end
end
def get_analysis(id)
handle(@conn.get("/v1/analyses/#{id}"))
end
private
def handle(response)
body = parse(response.body)
return body if response.status.between?(200, 299)
err = body["error"] || {}
raise Error.new(
status: response.status,
type: err["type"],
code: err["code"],
message: err["message"] || "HTTP #{response.status}",
param: err["param"],
request_id: err["request_id"] || response.headers["x-request-id"],
retry_after: response.headers["retry-after"]&.to_i
)
end
def parse(raw)
raw.to_s.empty? ? {} : JSON.parse(raw)
rescue JSON::ParserError
{}
end
endControlador que recibe el documento
params[:file] es un ActionDispatch::Http::UploadedFile. Conserva el nombre original: en modo test el resultado
depende de él. Tu backend decide expect y checks; no los aceptes tal cual del navegador.
class DocumentVerificationsController < ApplicationController
MAX_BYTES = 20.megabytes
# Añade aquí tu autenticación: cada análisis consume créditos.
def create
file = params.require(:file)
return render_error("Máximo 20 MB.", 413) if file.size > MAX_BYTES
checks = { not_expired: true }
checks[:holder] = { full_name: params[:full_name] } if params[:full_name].present?
analysis = ConstaiaClient.new.analyze(
io: file.tempfile,
filename: file.original_filename,
content_type: file.content_type,
options: {
expect: "es_dni",
checks: checks,
storage: "none",
language: "es",
metadata: { user_id: current_user_id.to_s }
}
)
if analysis["status"] == "completed"
render json: {
id: analysis["id"],
status: analysis.dig("verdict", "status"),
reasons: Array(analysis.dig("verdict", "reasons")).map { |r| r["message"] },
warnings: analysis["warnings"]
}
else
# 202: el resultado llegará por webhook (o consulta GET /v1/analyses/:id).
render json: { id: analysis["id"], status: analysis["status"] }, status: 202
end
rescue ConstaiaClient::Error => e
Rails.logger.warn("Constaia #{e.status} #{e.code} request_id=#{e.request_id}")
if e.type == "invalid_request"
render_error(e.message, 422)
else
render_error("No se pudo verificar el documento. Inténtalo de nuevo.", 502)
end
end
private
def current_user_id
# Sustituye por tu usuario autenticado.
"anonymous"
end
def render_error(message, status)
render json: { error: { message: message } }, status: status
end
endRails.application.routes.draw do
post "/document_verifications", to: "document_verifications#create"
post "/webhooks/constaia", to: "constaia_webhooks#create"
endLa respuesta de Constaia trae verdict.status:
- 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 y muestrareasons[].message. - Revisar alguna razón con severidad
warning(calidad baja, confianza baja): envía a revisión humana.
Los code de las razones son estables; los message vienen traducidos según language. Más en
Veredictos y Checks.
200 frente a 202
El análisis síncrono espera hasta 30 s. Si termina, recibes 200 con status: "completed". Si no, recibes 202
con status: "queued" o "processing" y el resultado llega por webhook. Por eso el timeout del cliente es de 60 s.
Si prefieres no bloquear la petición del usuario, envía async: true en options: la API responde 202 al
momento y tu webhook recibe analysis.completed.
Webhook con firma verificada
Constaia firma cada evento con Standard Webhooks. Necesitas el cuerpo crudo
(request.raw_post), no los parámetros ya parseados.
require "base64"
require "json"
require "openssl"
module ConstaiaWebhook
class VerificationError < StandardError; end
TOLERANCE = 300
module_function
def verify!(payload, msg_id:, timestamp:, signature:, secret:)
raise VerificationError, "missing headers" if [msg_id, timestamp, signature].any?(&:blank?)
ts = Integer(timestamp, exception: false)
raise VerificationError, "bad timestamp" if ts.nil? || (Time.now.to_i - ts).abs > TOLERANCE
key = Base64.decode64(secret.delete_prefix("whsec_"))
expected = Base64.strict_encode64(
OpenSSL::HMAC.digest("SHA256", key, "#{msg_id}.#{timestamp}.#{payload}")
)
valid = signature.split(" ").any? do |part|
version, sig = part.split(",", 2)
version == "v1" && sig.present? && ActiveSupport::SecurityUtils.secure_compare(sig, expected)
end
raise VerificationError, "invalid signature" unless valid
JSON.parse(payload)
end
endSi app/lib no está en el autoload de tu app, colócalo en app/services o añade la carpeta a
config.autoload_paths.
class ConstaiaWebhooksController < ApplicationController
skip_forgery_protection
# Salta también aquí tu autenticación de usuario (skip_before_action …).
def create
event = ConstaiaWebhook.verify!(
request.raw_post,
msg_id: request.headers["webhook-id"],
timestamp: request.headers["webhook-timestamp"],
signature: request.headers["webhook-signature"],
secret: Rails.application.credentials.dig(:constaia, :webhook_secret) || ENV.fetch("CONSTAIA_WEBHOOK_SECRET")
)
ConstaiaEventJob.perform_later(request.headers["webhook-id"], event)
head :no_content
rescue ConstaiaWebhook::VerificationError
head :bad_request
end
endResponde 2xx en menos de 15 s y procesa en segundo plano. webhook-id es estable entre reintentos: úsalo para no
procesar dos veces el mismo evento.
class ConstaiaEventJob < ApplicationJob
queue_as :default
def perform(message_id, event)
# Deduplicación: mejor con un índice único en tu base de datos.
return unless Rails.cache.write("constaia:webhook:#{message_id}", true, unless_exist: true, expires_in: 4.days)
analysis = event["data"]
case event["type"]
when "analysis.completed"
Rails.logger.info("Análisis #{analysis['id']}: #{analysis.dig('verdict', 'status')}")
# Actualiza tu modelo con analysis["verdict"] y analysis["fields"].
when "analysis.review_required"
# Llega además de analysis.completed cuando el veredicto es review.
when "analysis.failed"
Rails.logger.warn("Análisis #{analysis['id']} fallido: #{analysis.dig('error', 'code')}")
end
end
endLos eventos de prueba que envías desde el panel llegan con type: "test": el case los ignora.
Probar en modo test
Con una clave ck_test_… no se gastan créditos y el resultado depende del nombre del fichero. El 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:3000/document_verifications
curl -F "file=@dni_expired.jpg" http://localhost:3000/document_verifications
curl -F "file=@blurry.jpg" http://localhost:3000/document_verifications
curl -F "file=@dni_valid.jpg" -F "full_name=Juan Pérez" http://localhost:3000/document_verifications| Fichero | Veredicto | Motivo |
|---|---|---|
dni_valid.jpg | valid | "Vigente hasta el 12/03/2031." |
dni_expired.jpg | invalid | not_expired con severidad error: "Caducado el 15/06/2020." |
blurry.jpg | review | low_quality con severidad warning; avisos blurry y low_quality |
dni_valid.jpg con full_name=Juan Pérez | invalid | holder con severidad error |
Si usas curl contra Rails desde fuera del navegador, recuerda que la protección CSRF de ApplicationController
bloqueará la petición: pruébalo con tu autenticación real o desde un controlador de API. Lista completa de ficheros
en Modo test.
Errores
Todas las respuestas de error tienen la forma { "error": { "type", "code", "message", "param", "request_id" } }.
Decide por type y code, nunca por message, y guarda request_id en tus logs para soporte. Los más habituales
aquí: unsupported_file_type (415), file_too_large (413), unreadable_image (422), insufficient_credits (402) y
rate_limited (429). Tabla completa en Errores.
Checklist de producción
- Cambia a una clave
ck_live_…(requiere email verificado) guardada en credenciales o en tu gestor de secretos. - Protege tu endpoint con autenticación y límite por usuario: cada análisis consume créditos.
- Limita el tamaño (20 MB) antes de llamar a la API.
- Envía
Idempotency-Keypor operación lógica y reutilízala 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). - Registra un webhook para los
202y los análisisasync; deduplica porwebhook-idy responde rápido. - Registra
request_iden cada error. - Revisa la política de almacenamiento (
storage,keep_results) en Almacenamiento y privacidad.