Constaia
Integrations

Ruby on Rails

Validate Spanish IDs and other documents from Rails 7/8 with Faraday multipart, a service object, ActiveJob and HMAC-verified Constaia webhooks.

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 Rails 7 or 8 app: a service object that calls the REST API, a controller that receives the user's file, a webhook with a verified signature and an ActiveJob job that processes the events.

Official Ruby SDK: coming soon

There is no official gem yet. Here you call the REST API directly with Faraday. If you prefer a generated client, the OpenAPI spec is at https://api.constaia.com/openapi.json (for example, with openapi-generator).

Requirements

  • Ruby 3.2 or later and Rails 7.1 or 8.
  • A test key ck_test_… from the dashboard (see Authentication).
  • The whsec_… secret of a webhook endpoint if you use async mode (see Webhooks).

Installation

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

Configuration

The key always stays on the server. Use encrypted credentials or environment variables:

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

Or:

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

Service object

The client sends multipart/form-data with the file field and the options field (JSON as text), adds one Idempotency-Key per operation and retries 429 responses honouring Retry-After with the same key, so there is no double charge.

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

  # Returns the analysis (Hash). status "completed" (HTTP 200) or "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

Controller that receives the document

params[:file] is an ActionDispatch::Http::UploadedFile. Keep the original filename: in test mode the result depends on it. Your backend decides expect and checks; don't take them as-is from the browser.

app/controllers/document_verifications_controller.rb
class DocumentVerificationsController < ApplicationController
  MAX_BYTES = 20.megabytes

  # Add your authentication here: every analysis spends credits.

  def create
    file = params.require(:file)
    return render_error("Max 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: "en",
        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: the result will arrive by webhook (or poll 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("The document could not be verified. Please try again.", 502)
    end
  end

  private

  def current_user_id
    # Replace with your authenticated user.
    "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

The Constaia response carries verdict.status:

  • 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 and show reasons[].message.
  • Revisar some reason with severity warning (low quality, low confidence): send to human review.

Reason code values are stable; message values are localised according to language. More in Verdicts and Checks.

200 vs 202

A synchronous analysis waits up to 30 s. If it finishes, you get 200 with status: "completed". If not, you get 202 with status: "queued" or "processing" and the result arrives by webhook. That is why the client timeout is 60 s. If you don't want to block the user's request, send async: true in options: the API answers 202 right away and your webhook receives analysis.completed.

Webhook with verified signature

Constaia signs every event with Standard Webhooks. You need the raw body (request.raw_post), not the already parsed params.

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

If app/lib is not autoloaded in your app, put the file in app/services or add the folder to config.autoload_paths.

app/controllers/constaia_webhooks_controller.rb
class ConstaiaWebhooksController < ApplicationController
  skip_forgery_protection
  # Also skip your user authentication here (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

Answer 2xx within 15 s and process in the background. webhook-id is stable across retries: use it so you never process the same event twice.

app/jobs/constaia_event_job.rb
class ConstaiaEventJob < ApplicationJob
  queue_as :default

  def perform(message_id, event)
    # Deduplication: a unique index in your database is better.
    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("Analysis #{analysis['id']}: #{analysis.dig('verdict', 'status')}")
      # Update your model with analysis["verdict"] and analysis["fields"].
    when "analysis.review_required"
      # Sent in addition to analysis.completed when the verdict is review.
    when "analysis.failed"
      Rails.logger.warn("Analysis #{analysis['id']} failed: #{analysis.dig('error', 'code')}")
    end
  end
end

Test events sent from the dashboard arrive with type: "test": the case ignores them.

Test mode

With a ck_test_… key no credits are spent and the result depends on the filename. The file 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: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
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

If you call Rails with curl outside a browser, the CSRF protection of ApplicationController will block the request: test it with your real authentication or from an API controller. Full list of files in Test mode.

Errors

Every error response has the shape { "error": { "type", "code", "message", "param", "request_id" } }. Branch on type and code, never on message, and keep request_id in your logs for support. The most common ones here: unsupported_file_type (415), file_too_large (413), unreadable_image (422), insufficient_credits (402) and rate_limited (429). Full table in Errors.

Production checklist

  • Switch to a ck_live_… key (requires a verified email) stored in credentials or your secret manager.
  • Protect your endpoint with authentication and a per-user limit: every analysis spends credits.
  • Enforce the size limit (20 MB) before calling the API.
  • Send an Idempotency-Key per logical operation and reuse it on retries (Idempotency).
  • Respect the per-key limit (2 req/s on the free plan, 10 on paid) and Retry-After (Rate limits).
  • Register a webhook for 202 responses and async analyses; dedupe on webhook-id and answer fast.
  • Log request_id on every error.
  • Review the storage policy (storage, keep_results) in Storage and privacy.

Next steps

Nesta página