Phoenix
Valida documentos con Constaia desde Elixir y Phoenix usando Req y form_multipart, Plug.Upload y webhooks verificados sobre el cuerpo crudo.
Esta guía integra Constaia en una app Phoenix: un módulo que llama a la API REST con Req,
un controlador que recibe un %Plug.Upload{} y lo reenvía, y un webhook verificado sobre el cuerpo crudo, que
Phoenix no conserva por defecto.
No hay SDK oficial para Elixir: llamas a la API REST directamente. Si prefieres un cliente generado, la
especificación OpenAPI está en https://api.constaia.com/openapi.json (por ejemplo, con el generador elixir de
openapi-generator).
Requisitos
- Elixir 1.15 o superior y Phoenix 1.7 o 1.8.
- Una clave de test
ck_test_…del panel (ver Autenticación). - El secreto
whsec_…de un endpoint de webhook (ver Webhooks).
Instalación
defp deps do
[
# …tus dependencias de Phoenix (incluye :jason)
{:req, "~> 0.5"}
]
endmix deps.getConfiguración
config :my_app,
constaia_api_key: System.get_env("CONSTAIA_API_KEY"),
constaia_webhook_secret: System.get_env("CONSTAIA_WEBHOOK_SECRET")CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...La clave se queda en el servidor; nunca la envíes a LiveView hooks de JavaScript ni a ningún cliente.
Cliente
form_multipart envía la parte file (con su nombre de fichero) y la parte options (JSON en texto). Cada llamada
lleva una Idempotency-Key; como Req reintenta la misma petición, la clave se mantiene y no hay doble cobro. La
función retry solo reintenta los 429, y Req espera lo que indique Retry-After.
defmodule MyApp.Constaia do
@base_url "https://api.constaia.com"
defmodule Error do
defexception [:status, :type, :code, :message, :param, :request_id]
end
# {:ok, analysis}: analysis["status"] es "completed" (HTTP 200) o "queued"/"processing" (HTTP 202).
def analyze(%Plug.Upload{} = upload, options) do
content = File.read!(upload.path)
client()
|> Req.post(
url: "/v1/analyze",
headers: %{"idempotency-key" => Ecto.UUID.generate()},
form_multipart: [
file: {content, filename: upload.filename, content_type: upload.content_type || "application/octet-stream"},
options: Jason.encode!(options)
]
)
|> handle()
end
def get_analysis(id) do
client() |> Req.get(url: "/v1/analyses/#{id}") |> handle()
end
defp client do
Req.new(
base_url: @base_url,
auth: {:bearer, Application.fetch_env!(:my_app, :constaia_api_key)},
# El análisis síncrono puede tardar hasta 30 s antes de devolver 202.
receive_timeout: 60_000,
retry: &retry_rate_limited?/2,
max_retries: 2
)
end
defp retry_rate_limited?(_request, %Req.Response{status: 429}), do: true
defp retry_rate_limited?(_request, _response_or_exception), do: false
defp handle({:ok, %Req.Response{status: status, body: body}}) when status in 200..299, do: {:ok, body}
defp handle({:ok, %Req.Response{status: status, body: body} = response}) do
error = if is_map(body), do: body["error"] || %{}, else: %{}
{:error,
%Error{
status: status,
type: error["type"],
code: error["code"],
message: error["message"] || "HTTP #{status}",
param: error["param"],
request_id: error["request_id"] || List.first(Req.Response.get_header(response, "x-request-id"))
}}
end
defp handle({:error, exception}), do: {:error, exception}
endEcto.UUID.generate/0 viene con Ecto. Si tu app no usa Ecto, cualquier cadena aleatoria de hasta 255 caracteres
sirve, por ejemplo Base.encode16(:crypto.strong_rand_bytes(16), case: :lower).
Controlador que recibe el documento
Conserva upload.filename: en modo test el resultado depende del nombre. Tu backend decide expect y checks; no
los aceptes tal cual del cliente.
defmodule MyAppWeb.DocumentVerificationController do
use MyAppWeb, :controller
require Logger
alias MyApp.Constaia
@max_bytes 20 * 1024 * 1024
# Añade aquí tu autenticación: cada análisis consume créditos.
def create(conn, %{"file" => %Plug.Upload{} = upload} = params) do
%{size: size} = File.stat!(upload.path)
cond do
size == 0 -> render_error(conn, 400, "Falta el documento.")
size > @max_bytes -> render_error(conn, 413, "Máximo 20 MB.")
true -> analyze(conn, upload, params["full_name"])
end
end
def create(conn, _params), do: render_error(conn, 400, "Falta el documento.")
defp analyze(conn, upload, full_name) do
options = %{
expect: "es_dni",
checks: put_holder(%{not_expired: true}, full_name),
storage: "none",
language: "es"
}
case Constaia.analyze(upload, options) do
{:ok, %{"status" => "completed"} = analysis} ->
json(conn, %{
id: analysis["id"],
status: get_in(analysis, ["verdict", "status"]),
reasons: analysis |> get_in(["verdict", "reasons"]) |> List.wrap() |> Enum.map(& &1["message"]),
warnings: analysis["warnings"]
})
{:ok, analysis} ->
# 202: el resultado llegará por webhook (o consulta get_analysis/1).
conn |> put_status(202) |> json(%{id: analysis["id"], status: analysis["status"]})
{:error, %Constaia.Error{} = e} ->
Logger.warning("Constaia #{e.status} #{e.code} request_id=#{e.request_id}")
if e.type == "invalid_request",
do: render_error(conn, 422, e.message),
else: render_error(conn, 502, "No se pudo verificar el documento. Inténtalo de nuevo.")
{:error, exception} ->
Logger.warning("Constaia: #{Exception.message(exception)}")
render_error(conn, 502, "No se pudo verificar el documento. Inténtalo de nuevo.")
end
end
defp put_holder(checks, name) when is_binary(name) and name != "",
do: Map.put(checks, :holder, %{full_name: name})
defp put_holder(checks, _name), do: checks
defp render_error(conn, status, message) do
conn |> put_status(status) |> json(%{error: %{message: message}})
end
endverdict.status vale:
- 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. - Revisar alguna razón con severidad
warning: revisión humana.
Los code son estables; los message vienen traducidos según language. Para un campo:
get_in(analysis, ["fields", "document_number", "value"]). Más en Veredictos.
200 frente a 202
El análisis síncrono espera hasta 30 s. Si termina, recibes 200 con status: "completed"; si no, 202 con
status: "queued" o "processing" y el resultado llega por webhook. Con async: true en las opciones la API
responde 202 al momento. analysis.review_required llega además de analysis.completed cuando el veredicto es
review.
Webhook con firma verificada
Plug.Parsers consume el cuerpo al parsear el JSON. Para verificar la firma necesitas los bytes exactos, así que
guárdalos con un body_reader propio, solo en la ruta del webhook. Sube también el límite de multipart, que por
defecto es de 8 MB.
defmodule MyAppWeb.CacheBodyReader do
def read_body(%Plug.Conn{request_path: "/webhooks/constaia"} = conn, opts) do
case Plug.Conn.read_body(conn, opts) do
{:ok, body, conn} -> {:ok, body, cache(conn, body)}
{:more, body, conn} -> {:more, body, cache(conn, body)}
other -> other
end
end
def read_body(conn, opts), do: Plug.Conn.read_body(conn, opts)
defp cache(conn, body), do: update_in(conn.assigns[:raw_body], &[body | (&1 || [])])
endplug Plug.Parsers,
parsers: [:urlencoded, {:multipart, length: 21_000_000}, :json],
pass: ["*/*"],
body_reader: {MyAppWeb.CacheBodyReader, :read_body, []},
json_decoder: Phoenix.json_library()defmodule MyApp.ConstaiaWebhook do
@tolerance 300
def verify(raw, id, timestamp, signatures, secret)
when is_binary(id) and is_binary(timestamp) and is_binary(signatures) do
with {ts, ""} <- Integer.parse(timestamp),
true <- abs(System.system_time(:second) - ts) <= @tolerance,
{:ok, key} <- secret |> String.replace_prefix("whsec_", "") |> Base.decode64(),
expected = :crypto.mac(:hmac, :sha256, key, "#{id}.#{timestamp}.#{raw}") |> Base.encode64(),
true <- signatures |> String.split(" ", trim: true) |> Enum.any?(&valid_signature?(&1, expected)) do
Jason.decode(raw)
else
_ -> {:error, :invalid_signature}
end
end
def verify(_raw, _id, _timestamp, _signatures, _secret), do: {:error, :missing_headers}
defp valid_signature?("v1," <> sig, expected), do: Plug.Crypto.secure_compare(sig, expected)
defp valid_signature?(_part, _expected), do: false
enddefmodule MyAppWeb.ConstaiaWebhookController do
use MyAppWeb, :controller
require Logger
alias MyApp.ConstaiaWebhook
@analysis_events ["analysis.completed", "analysis.review_required", "analysis.failed"]
def create(conn, _params) do
raw = conn.assigns |> Map.get(:raw_body, []) |> Enum.reverse() |> IO.iodata_to_binary()
msg_id = header(conn, "webhook-id")
secret = Application.fetch_env!(:my_app, :constaia_webhook_secret)
case ConstaiaWebhook.verify(raw, msg_id, header(conn, "webhook-timestamp"), header(conn, "webhook-signature"), secret) do
{:ok, event} ->
handle_event(msg_id, event)
send_resp(conn, 204, "")
{:error, _reason} ->
send_resp(conn, 400, "invalid signature")
end
end
# webhook-id es estable entre reintentos: deduplica con él en tu base de datos.
defp handle_event(msg_id, %{"type" => type, "data" => analysis}) when type in @analysis_events do
Logger.info("#{msg_id} #{type} #{analysis["id"]} #{get_in(analysis, ["verdict", "status"])}")
# Encola el trabajo pesado (por ejemplo, con Oban) y responde en menos de 15 s.
end
defp handle_event(_msg_id, _event), do: :ok
defp header(conn, name), do: conn |> get_req_header(name) |> List.first()
endscope "/", MyAppWeb do
pipe_through :api
post "/api/verify", DocumentVerificationController, :create
post "/webhooks/constaia", ConstaiaWebhookController, :create
endEl pipeline :api no aplica protección CSRF, así que el webhook no necesita excepciones. Si /api/verify va por el
pipeline :browser, envía el token CSRF desde tu formulario.
Probar en modo test
Con una clave ck_test_… no se gastan créditos y el resultado depende del nombre del 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:4000/api/verify
curl -F "file=@dni_expired.jpg" http://localhost:4000/api/verify
curl -F "file=@blurry.jpg" http://localhost:4000/api/verify
curl -F "file=@dni_valid.jpg" -F "full_name=Juan Pérez" http://localhost:4000/api/verify| 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 |
Más ficheros en Modo test.
Errores
Las respuestas de error tienen la forma { "error": { "type", "code", "message", "param", "request_id" } }, que el
cliente convierte en %MyApp.Constaia.Error{}. Decide por type y code, nunca por el mensaje, y registra
request_id. Tabla completa en Errores.
Checklist de producción
- Clave
ck_live_…(requiere email verificado) en tu gestor de secretos o variable de entorno. - Autenticación y límite por usuario en
/api/verify: cada análisis consume créditos. - Límite de 20 MB antes de llamar a la API.
- Una
Idempotency-Keypor operación lógica, reutilizada en los reintentos (Idempotencia). - Limita la concurrencia hacia Constaia (2 req/s por clave en el plan gratuito, 10 en el de pago), por ejemplo con
Task.async_stream/3ymax_concurrency(Límites). - Webhook registrado para los
202y los análisisasync; deduplica porwebhook-idy responde rápido. - Revisa
storageykeep_resultsen Almacenamiento y privacidad.
Siguientes pasos
Rust
Integra Constaia en Rust con reqwest multipart, tokio y serde, un handler de axum 0.8 que reenvía el fichero y webhooks verificados con hmac y sha2.
Vapor
Valida documentos con Constaia desde Swift en el servidor con Vapor 4, subida multipart con el cliente de Vapor, Codable y webhooks HMAC con swift-crypto.