Nombaone.Webhooks (Nomba One v0.1.0)

View Source

Verify and parse incoming NombaOne webhook deliveries.

This is pure crypto — no API key or client needed, only the endpoint's signing secret (shown once at create/rotate). Every function is a plain module function.

Feed it the raw body

Decoding then re-encoding the JSON can reorder keys and change bytes, which breaks the signature. Capture the body before any framework parses it. In Phoenix, use a custom body reader that stashes the raw bytes (see the README), or read conn before Plug.Parsers.

Verify, then dedupe

case Nombaone.Webhooks.construct_event(raw_body, signature_header, secret) do
  {:ok, event} ->
    unless already_processed?(event.event.id) do   # at-least-once ⇒ dedupe
      handle(event)
    end
    send_resp(conn, 200, "")

  {:error, %Nombaone.WebhookVerificationError{} = error} ->
    send_resp(conn, 400, Exception.message(error))
end

Summary

Types

Options for verification. :tolerance is max clock skew in seconds (default 300), or :infinity to skip the age check.

Functions

Verify a delivery's signature and timestamp, then parse and return the typed event. This is the one call your handler needs.

Build a valid X-Nombaone-Signature header for a payload — to test your own handler without waiting on a real delivery. Options: :timestamp (unix seconds, defaults to now).

Verify a delivery's signature and timestamp only (no parse). Returns :ok, or {:error, %Nombaone.WebhookVerificationError{}} with a distinct message per failure mode.

Types

verify_opts()

@type verify_opts() :: [{:tolerance, non_neg_integer() | :infinity}]

Options for verification. :tolerance is max clock skew in seconds (default 300), or :infinity to skip the age check.

Functions

construct_event(payload, signature_header, secret, opts \\ [])

@spec construct_event(iodata(), String.t() | nil, String.t() | nil, verify_opts()) ::
  {:ok, Nombaone.WebhookEvent.t()}
  | {:error, Nombaone.WebhookVerificationError.t()}

Verify a delivery's signature and timestamp, then parse and return the typed event. This is the one call your handler needs.

Returns {:ok, %Nombaone.WebhookEvent{}}, or {:error, %Nombaone.WebhookVerificationError{}} on a missing/malformed header, a stale timestamp, an invalid signature, or a non-JSON body.

payload may be a binary or iodata (the raw request body); signature_header is the X-Nombaone-Signature value; secret is the endpoint's signing secret.

construct_event!(payload, signature_header, secret, opts \\ [])

@spec construct_event!(iodata(), String.t() | nil, String.t() | nil, verify_opts()) ::
  Nombaone.WebhookEvent.t()

Raising variant of construct_event/4.

generate_test_header(payload, secret, opts \\ [])

@spec generate_test_header(iodata(), String.t(), [{:timestamp, integer()}]) ::
  String.t()

Build a valid X-Nombaone-Signature header for a payload — to test your own handler without waiting on a real delivery. Options: :timestamp (unix seconds, defaults to now).

Example

payload = Jason.encode!(%{id: "nbo…whd", type: "invoice.paid", event: %{id: "nbo…evt", type: "invoice.paid", createdAt: "..."}, data: %{reference: "nbo…inv"}})
header = Nombaone.Webhooks.generate_test_header(payload, "whsec_test")
{:ok, event} = Nombaone.Webhooks.construct_event(payload, header, "whsec_test")

verify_signature(payload, header, secret, opts)

@spec verify_signature(iodata(), String.t() | nil, String.t() | nil, verify_opts()) ::
  :ok | {:error, Nombaone.WebhookVerificationError.t()}

Verify a delivery's signature and timestamp only (no parse). Returns :ok, or {:error, %Nombaone.WebhookVerificationError{}} with a distinct message per failure mode.