Moov.Webhook (Moov v1.0.0)

Copy Markdown View Source

Verifies that an incoming webhook request really came from Moov, and parses its payload into a Moov.Webhook.Event.

Moov signs every webhook delivery with an HMAC-SHA512 of timestamp <> "|" <> nonce <> "|" <> webhookID, keyed with the signing secret shown in the Moov Dashboard for that webhook (https://dashboard.moov.io/developers/webhooks), and sends the result hex-encoded in the X-Signature header alongside the X-Timestamp, X-Nonce, and X-Webhook-ID headers it was computed from. See https://docs.moov.io/guides/webhooks/check-webhook-signatures/.

Usage with Plug/Phoenix

def webhook_controller(conn, _params) do
  {:ok, raw_body, conn} = Plug.Conn.read_body(conn)
  secret = Application.fetch_env!(:my_app, :moov_webhook_secret)

  case Moov.Webhook.construct_event(raw_body, conn.req_headers, secret) do
    {:ok, %Moov.Webhook.Event{type: "transfer.updated", data: data}} ->
      MyApp.Transfers.handle_update(data)
      send_resp(conn, 200, "")

    {:ok, _event} ->
      send_resp(conn, 200, "")

    {:error, :invalid_signature} ->
      send_resp(conn, 400, "invalid signature")

    {:error, reason} ->
      send_resp(conn, 400, "bad request: " <> inspect(reason))
  end
end

Note that for the signature to validate you must use the raw, unparsed request body - if you're using Phoenix, make sure no JSON parser has already consumed and re-serialized it (a custom Plug.Parsers body reader that caches the raw body is the standard fix).

Summary

Functions

Verifies the signature on a raw webhook request body and, if valid, JSON-decodes it into a Moov.Webhook.Event.

Computes the expected hex-encoded HMAC-SHA512 signature for the given header values and signing secret, without comparing it to anything. This is the exact value Moov puts in X-Signature.

Returns the list of webhook event type identifiers documented by Moov as of this package's release. This list is informational only - Moov may add new event types at any time and construct_event/3 will still parse them successfully (type is always returned as a plain string, never an atom).

The pure, lower-level signature check: given the four header values and the signing secret, returns true if signature matches the expected HMAC-SHA512, using a constant-time comparison.

Verifies the HMAC-SHA512 signature of a webhook delivery.

Functions

construct_event(raw_body, headers, secret)

@spec construct_event(String.t(), Enumerable.t(), String.t()) ::
  {:ok, Moov.Webhook.Event.t()}
  | {:error, :missing_signature_headers | :invalid_signature | term()}

Verifies the signature on a raw webhook request body and, if valid, JSON-decodes it into a Moov.Webhook.Event.

raw_body must be the exact, unmodified bytes Moov sent - decode it after verifying, never before.

expected_signature(timestamp, nonce, webhook_id, secret)

@spec expected_signature(String.t(), String.t(), String.t(), String.t()) :: String.t()

Computes the expected hex-encoded HMAC-SHA512 signature for the given header values and signing secret, without comparing it to anything. This is the exact value Moov puts in X-Signature.

known_event_types()

@spec known_event_types() :: [String.t()]

Returns the list of webhook event type identifiers documented by Moov as of this package's release. This list is informational only - Moov may add new event types at any time and construct_event/3 will still parse them successfully (type is always returned as a plain string, never an atom).

valid_signature?(timestamp, nonce, webhook_id, signature, secret)

@spec valid_signature?(String.t(), String.t(), String.t(), String.t(), String.t()) ::
  boolean()

The pure, lower-level signature check: given the four header values and the signing secret, returns true if signature matches the expected HMAC-SHA512, using a constant-time comparison.

verify_signature(headers, secret)

@spec verify_signature(Enumerable.t(), String.t()) ::
  :ok | {:error, :missing_signature_headers | :invalid_signature}

Verifies the HMAC-SHA512 signature of a webhook delivery.

headers may be a list of {key, value} tuples (as returned by Plug.Conn.req_headers/1) or a map; header name lookups are case-insensitive either way.

Returns :ok or {:error, :missing_signature_headers | :invalid_signature}.