Constaia
Integraciones

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

mix.exs
defp deps do
  [
    # …tus dependencias de Phoenix (incluye :jason)
    {:req, "~> 0.5"}
  ]
end
mix deps.get

Configuración

config/runtime.exs
config :my_app,
  constaia_api_key: System.get_env("CONSTAIA_API_KEY"),
  constaia_webhook_secret: System.get_env("CONSTAIA_WEBHOOK_SECRET")
.env
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.

lib/my_app/constaia.ex
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}
end

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

lib/my_app_web/controllers/document_verification_controller.ex
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
end

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

lib/my_app_web/cache_body_reader.ex
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 || [])])
end
lib/my_app_web/endpoint.ex
plug Plug.Parsers,
  parsers: [:urlencoded, {:multipart, length: 21_000_000}, :json],
  pass: ["*/*"],
  body_reader: {MyAppWeb.CacheBodyReader, :read_body, []},
  json_decoder: Phoenix.json_library()
lib/my_app/constaia_webhook.ex
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
end
lib/my_app_web/controllers/constaia_webhook_controller.ex
defmodule 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()
end
lib/my_app_web/router.ex
scope "/", MyAppWeb do
  pipe_through :api

  post "/api/verify", DocumentVerificationController, :create
  post "/webhooks/constaia", ConstaiaWebhookController, :create
end

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

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-Key por 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/3 y max_concurrency (Límites).
  • Webhook registrado para los 202 y los análisis async; deduplica por webhook-id y responde rápido.
  • Revisa storage y keep_results en Almacenamiento y privacidad.

Siguientes pasos

En esta página