TruelayerClient.Webhooks (truelayer_client v1.0.0)

Copy Markdown View Source

TrueLayer webhook signature verification, replay-attack protection, and typed dispatch.

Security model

  1. HMAC-SHA256 verification — constant-time comparison prevents timing attacks
  2. Replay protection — events older than :webhook_replay_tolerance_sec are rejected
  3. Typed dispatch — handlers registered per event type via on/3

Usage in a Phoenix controller

Add a CacheBodyReader plug to preserve the raw body for signature verification, then call process/4 in your controller:

defmodule MyAppWeb.WebhookController do
  use MyAppWeb, :controller

  def truelayer(conn, _params) do
    raw_body = conn.assigns[:raw_body]
    sig = get_req_header(conn, "tl-signature") |> List.first()
    ts  = get_req_header(conn, "tl-timestamp")  |> List.first()

    case TruelayerClient.Webhooks.process(client, raw_body, sig, ts) do
      :ok                -> send_resp(conn, 200, "")
      {:error, :bad_sig} -> send_resp(conn, 401, "invalid signature")
      {:error, :replay}  -> send_resp(conn, 401, "event too old")
      {:error, reason}   -> send_resp(conn, 500, inspect(reason))
    end
  end
end

Registering handlers

TruelayerClient.Webhooks.on(client, TruelayerClient.Webhooks.payment_executed(), fn event ->
  %{"payload" => %{"payment_id" => id}} = event
  MyApp.Payments.finalize(id)
  :ok
end)

Event type constants

Use the named functions (payment_executed/0, etc.) rather than raw strings to avoid typos:

TruelayerClient.Webhooks.on(client, TruelayerClient.Webhooks.refund_executed(), handler)

Summary

Functions

Account holder verification completed.

Account holder verification failed.

Signup+ authorization URI expired.

Mandate authorized by the PSU.

Mandate failed.

Mandate revoked.

Merchant account payment failed.

Merchant account payment settled.

Create a new handler registry (ETS :bag table). Called once per client.

Register a handler for a specific event type.

Register a fallback handler called for any event type with no registered handler.

Payment authorized by the PSU.

Payment successfully executed.

Payment failed.

Payment link payment executed.

Payment funds settled into the merchant account.

Payout executed.

Payout failed.

Verify and dispatch a raw webhook payload.

Refund executed.

Refund failed.

VRP payment executed.

VRP payment failed.

Types

event_type()

@type event_type() :: String.t()

handler_fn()

@type handler_fn() :: (map() -> :ok | {:error, term()})

registry()

@type registry() :: :ets.tid()

Functions

account_holder_verification_completed()

Account holder verification completed.

account_holder_verification_failed()

Account holder verification failed.

identity_authorization_expired()

Signup+ authorization URI expired.

mandate_authorized()

Mandate authorized by the PSU.

mandate_failed()

Mandate failed.

mandate_revoked()

Mandate revoked.

merchant_account_payment_failed()

Merchant account payment failed.

merchant_account_payment_settled()

Merchant account payment settled.

new_registry()

@spec new_registry() :: registry()

Create a new handler registry (ETS :bag table). Called once per client.

on(map, event_type, handler_fn)

@spec on(TruelayerClient.t(), event_type(), handler_fn()) :: :ok

Register a handler for a specific event type.

Multiple handlers per event type are supported; all are called in registration order. If a handler returns {:error, reason}, dispatch halts and the error is propagated, causing TrueLayer to retry the webhook delivery.

Example

TruelayerClient.Webhooks.on(client, TruelayerClient.Webhooks.payment_executed(), fn event ->
  id = get_in(event, ["payload", "payment_id"])
  MyApp.Payments.handle_executed(id)
  :ok
end)

on_fallback(map, handler_fn)

@spec on_fallback(TruelayerClient.t(), handler_fn()) :: :ok

Register a fallback handler called for any event type with no registered handler.

payment_authorized()

Payment authorized by the PSU.

payment_executed()

Payment successfully executed.

payment_failed()

Payment failed.

payment_settled()

Payment funds settled into the merchant account.

payout_executed()

Payout executed.

payout_failed()

Payout failed.

process(client, body, signature, timestamp)

@spec process(TruelayerClient.t(), binary(), String.t() | nil, String.t() | nil) ::
  :ok | {:error, term()}

Verify and dispatch a raw webhook payload.

Parameters

  • clientTruelayerClient.t() with webhook configuration
  • body — raw request body binary (must not be parsed before calling this)
  • signature — value of the Tl-Signature request header
  • timestamp — value of the Tl-Timestamp request header (RFC 3339)

Return values

  • :ok — verified and successfully dispatched
  • {:error, :bad_sig} — signature verification failed
  • {:error, :replay} — event is outside the replay tolerance window
  • {:error, {:decode_error, reason}} — JSON parsing failed
  • {:error, reason} — a registered handler returned an error

refund_executed()

Refund executed.

refund_failed()

Refund failed.

vrp_payment_executed()

VRP payment executed.

vrp_payment_failed()

VRP payment failed.