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.
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
gem "faraday", "~> 2.12"
gem "faraday-multipart", "~> 1.1"bundle installConfiguration
The key always stays on the server. Use encrypted credentials or environment variables:
bin/rails credentials:editconstaia:
api_key: ck_test_...
webhook_secret: whsec_...Or:
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.
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
endController 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.
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
endRails.application.routes.draw do
post "/document_verifications", to: "document_verifications#create"
post "/webhooks/constaia", to: "constaia_webhooks#create"
endThe 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 showreasons[].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.
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
endIf app/lib is not autoloaded in your app, put the file in app/services or add the folder to
config.autoload_paths.
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
endAnswer 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.
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
endTest 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| 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 |
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-Keyper 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
202responses andasyncanalyses; dedupe onwebhook-idand answer fast. - Log
request_idon every error. - Review the storage policy (
storage,keep_results) in Storage and privacy.
Next steps
Celery
Analyse documents in the background with Celery and the constaia SDK, with Retry-After aware retries, rate_limit, idempotency keys and batches of up to 100.
Go
Integrate Constaia in Go with net/http and the standard library: multipart upload, typed structs, 429 retries and HMAC webhook verification.