BankingCircle.Webhook.Verifier (banking_circle v1.0.0)

Copy Markdown View Source

Decrypts and verifies incoming Banking Circle webhook payloads.

Banking Circle secures webhook bodies with AES-256-GCM, using a 32-character pre-shared key set when you create the subscription (see BankingCircle.Webhooks.create_subscription/2). Per the docs, a received payload carries three companion values:

  • a checksum (44 base64 characters → 32 raw bytes)
  • an authentication tag (24 base64 characters → 16 raw bytes)
  • a nonce (16 base64 characters → 12 raw bytes)

This module treats the GCM authentication tag as the cryptographic integrity guarantee (verified automatically by AEAD decryption itself — decryption fails closed if the tag doesn't match). The checksum field is additionally verified as a SHA-256 digest of the ciphertext, which is the most common convention for this kind of "checksum" field; if your account's payload docs specify a different checksum construction (e.g. HMAC-SHA256 over ciphertext+AAD), override it via the :checksum_fun option rather than trusting this default blindly — verify this against a real captured payload from your sandbox before relying on it in production.

Usage in a Plug/Phoenix controller

def webhook(conn, _params) do
  {:ok, raw_body, conn} = Plug.Conn.read_body(conn)

  case BankingCircle.Webhook.Verifier.verify_and_decrypt(raw_body,
         checksum: get_req_header(conn, "x-bc-checksum") |> List.first(),
         tag: get_req_header(conn, "x-bc-auth-tag") |> List.first(),
         nonce: get_req_header(conn, "x-bc-nonce") |> List.first(),
         key: Application.fetch_env!(:my_app, :bc_webhook_key)
       ) do
    {:ok, event} ->
      MyApp.WebhookHandler.handle(event)
      send_resp(conn, 200, "")

    {:error, reason} ->
      Logger.warning("Rejected Banking Circle webhook: #{inspect(reason)}")
      send_resp(conn, 400, "")
  end
end

Header names above (x-bc-checksum etc.) are illustrative — confirm the exact header names Banking Circle uses for your subscription against the "Setting up Webhooks" guide, since this client doesn't hardcode them.

Summary

Functions

Verifies the checksum, verifies+decrypts via AES-256-GCM, and JSON-decodes the plaintext.

Types

verify_opts()

@type verify_opts() :: [
  checksum: String.t(),
  tag: String.t(),
  nonce: String.t(),
  key: String.t(),
  checksum_fun: (binary() -> binary())
]

Functions

verify_and_decrypt(ciphertext, opts)

@spec verify_and_decrypt(binary(), verify_opts()) :: {:ok, term()} | {:error, atom()}

Verifies the checksum, verifies+decrypts via AES-256-GCM, and JSON-decodes the plaintext.

Returns {:ok, decoded_event} or {:error, reason} where reason is one of :invalid_checksum, :decryption_failed (bad tag/key/nonce — treat as tampered or wrong key), or :invalid_json.