Constaia
Integrations

Phoenix

Validate documents with Constaia from Elixir and Phoenix using Req and form_multipart, Plug.Upload and webhooks verified over the raw body.

This guide integrates Constaia into a Phoenix app: a module that calls the REST API with Req, a controller that receives a %Plug.Upload{} and forwards it, and a webhook verified over the raw body, which Phoenix does not keep by default.

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

Requirements

  • Elixir 1.15 or later and Phoenix 1.7 or 1.8.
  • A test key ck_test_… from the dashboard (see Authentication).
  • The whsec_… secret of a webhook endpoint (see Webhooks).

Installation

mix.exs
defp deps do
  [
    # …your Phoenix dependencies (includes :jason)
    {:req, "~> 0.5"}
  ]
end
mix deps.get

Configuration

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

The key stays on the server; never send it to LiveView JavaScript hooks or any client.

Client

form_multipart sends the file part (with its filename) and the options part (JSON as text). Every call carries an Idempotency-Key; since Req retries the same request, the key is kept and there is no double charge. The retry function only retries 429 responses, and Req waits as long as Retry-After says.

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"] is "completed" (HTTP 200) or "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)},
      # A synchronous analysis can take up to 30 s before returning 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 comes with Ecto. If your app doesn't use Ecto, any random string up to 255 characters works, for example Base.encode16(:crypto.strong_rand_bytes(16), case: :lower).

Controller that receives the document

Keep upload.filename: in test mode the result depends on the name. Your backend decides expect and checks; don't take them as-is from the client.

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

  # Add your authentication here: every analysis spends credits.
  def create(conn, %{"file" => %Plug.Upload{} = upload} = params) do
    %{size: size} = File.stat!(upload.path)

    cond do
      size == 0 -> render_error(conn, 400, "The document is missing.")
      size > @max_bytes -> render_error(conn, 413, "Max 20 MB.")
      true -> analyze(conn, upload, params["full_name"])
    end
  end

  def create(conn, _params), do: render_error(conn, 400, "The document is missing.")

  defp analyze(conn, upload, full_name) do
    options = %{
      expect: "es_dni",
      checks: put_holder(%{not_expired: true}, full_name),
      storage: "none",
      language: "en"
    }

    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: the result will arrive by webhook (or call 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, "The document could not be verified. Please try again.")

      {:error, exception} ->
        Logger.warning("Constaia: #{Exception.message(exception)}")
        render_error(conn, 502, "The document could not be verified. Please try again.")
    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 is:

  • 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.
  • Revisar some reason with severity warning: human review.

code values are stable; message values are localised according to language. For a field: get_in(analysis, ["fields", "document_number", "value"]). More in Verdicts.

200 vs 202

A synchronous analysis waits up to 30 s. If it finishes, you get 200 with status: "completed"; if not, 202 with status: "queued" or "processing" and the result arrives by webhook. With async: true in the options the API answers 202 right away. analysis.review_required is sent in addition to analysis.completed when the verdict is review.

Webhook with verified signature

Plug.Parsers consumes the body when it parses the JSON. To verify the signature you need the exact bytes, so keep them with your own body_reader, only on the webhook route. Also raise the multipart limit, which is 8 MB by default.

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 is stable across retries: dedupe on it in your database.
  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"])}")
    # Queue heavy work (for example, with Oban) and answer within 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

The :api pipeline applies no CSRF protection, so the webhook needs no exceptions. If /api/verify goes through the :browser pipeline, send the CSRF token from your form.

Test mode

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

More files in Test mode.

Errors

Error responses have the shape { "error": { "type", "code", "message", "param", "request_id" } }, which the client turns into %MyApp.Constaia.Error{}. Branch on type and code, never on the message, and log request_id. Full table in Errors.

Production checklist

  • A ck_live_… key (requires a verified email) in your secret manager or an environment variable.
  • Authentication and a per-user limit on /api/verify: every analysis spends credits.
  • A 20 MB limit before calling the API.
  • One Idempotency-Key per logical operation, reused on retries (Idempotency).
  • Limit concurrency towards Constaia (2 req/s per key on the free plan, 10 on paid), for example with Task.async_stream/3 and max_concurrency (Rate limits).
  • A webhook registered for 202 responses and async analyses; dedupe on webhook-id and answer fast.
  • Review storage and keep_results in Storage and privacy.

Next steps

On this page