Constaia
Integraciones

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

Gemfile
gem "faraday", "~> 2.12"
gem "faraday-multipart", "~> 1.1"
bundle install

Configuración

La clave se queda siempre en el servidor. Puedes usar credenciales cifradas o variables de entorno:

bin/rails credentials:edit
config/credentials.yml.enc (descifrado)
constaia:
  api_key: ck_test_...
  webhook_secret: whsec_...

O bien:

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

app/services/constaia_client.rb
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
end

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

app/controllers/document_verifications_controller.rb
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
end
config/routes.rb
Rails.application.routes.draw do
  post "/document_verifications", to: "document_verifications#create"
  post "/webhooks/constaia", to: "constaia_webhooks#create"
end

La 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 muestra reasons[].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.

app/lib/constaia_webhook.rb
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
end

Si app/lib no está en el autoload de tu app, colócalo en app/services o añade la carpeta a config.autoload_paths.

app/controllers/constaia_webhooks_controller.rb
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
end

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

app/jobs/constaia_event_job.rb
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
end

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

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-Key por 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 202 y los análisis async; deduplica por webhook-id y responde rápido.
  • Registra request_id en cada error.
  • Revisa la política de almacenamiento (storage, keep_results) en Almacenamiento y privacidad.

Siguientes pasos

En esta página