# `LatticeStripe.Webhook`
[🔗](https://github.com/szTheory/lattice_stripe/blob/v2.2.2/lib/lattice_stripe/webhook.ex#L1)

Stripe webhook signature verification and event construction.

LatticeStripe.Webhook provides pure-functional HMAC-SHA256 signature verification
for incoming Stripe webhook payloads. It is designed to be used in a Plug pipeline
or any web framework — it has no Plug dependency itself.

## Usage

    # In a Plug or controller action, after reading the raw body:
    raw_body = conn.assigns[:raw_body]
    sig_header = Plug.Conn.get_req_header(conn, "stripe-signature") |> List.first()
    secret = Application.fetch_env!(:my_app, :stripe_webhook_secret)

    case LatticeStripe.Webhook.construct_event(raw_body, sig_header, secret) do
      {:ok, event} ->
        handle_event(event)
        send_resp(conn, 200, "ok")

      {:error, :missing_header} ->
        send_resp(conn, 400, "Missing Stripe-Signature header")

      {:error, :timestamp_expired} ->
        send_resp(conn, 400, "Webhook timestamp too old")

      {:error, reason} ->
        send_resp(conn, 400, "Signature verification failed: #{reason}")
    end

## Important: Raw Body Requirement

Stripe signs the **raw, unmodified request body**. Most web frameworks parse
the body and discard the original bytes. You must configure your framework to
preserve the raw body before calling these functions. See the LatticeStripe
Plug documentation for a ready-made solution.

## Replay Attack Protection

By default, `verify_signature/3` rejects webhooks with a timestamp older than
300 seconds (5 minutes). Override with `tolerance: seconds` in opts.

## Multiple Secrets (Secret Rotation)

Pass a list of secrets to verify against any of them. Useful during Stripe
webhook secret rotation — the new and old secret both work until rotation completes.

    Webhook.verify_signature(payload, header, [old_secret, new_secret])

## Snapshot events vs thin events: when to use which

Stripe ships **two** webhook payload shapes and LatticeStripe exposes a distinct
entry point for each:

- **Snapshot events** (the classic v1 webhook path — `object: "event"`, full event
  `data` embedded, Unix-integer `created`). Use `construct_event/4` and pattern-match
  `LatticeStripe.Event.t()`. This is what `/v1/webhook_endpoints` delivers and what
  every adopter using `LatticeStripe.Webhook.Plug` receives today.

- **Thin events** (the v2 event-destinations path — `object: "v2.core.event"`, no
  embedded `data`, ISO 8601 string `created`, `related_object` reference to the
  underlying resource). Use `parse_event_notification/4` and pattern-match
  `LatticeStripe.EventNotification.t()`. The notification carries only metadata +
  a `related_object` pointer; adopters fetch the full `%Event{}` via
  `fetch_event/3` and the underlying resource via `fetch_related_object/3`
  (both shipped in this same v1.5 milestone).

Calling the wrong entry point on a payload will silently produce a mostly-nil
struct (the JSON keys don't overlap between the two wire shapes). Route based on
which webhook endpoint Stripe is calling — `parse_event_notification/4` is for
`/v2/event-destinations` traffic, `construct_event/4` is for everything else.

## Stripe API Reference

See the [Stripe Webhooks documentation](https://docs.stripe.com/webhooks) for the full
webhook reference, event catalog, and delivery guarantees.

# `secret`

```elixir
@type secret() :: String.t() | [String.t(), ...]
```

# `verify_error`

```elixir
@type verify_error() ::
  :missing_header
  | :invalid_header
  | :no_matching_signature
  | :timestamp_expired
```

# `construct_event`

```elixir
@spec construct_event(String.t(), String.t() | nil, secret(), keyword()) ::
  {:ok, LatticeStripe.Event.t()} | {:error, verify_error()}
```

Verifies a Stripe webhook signature and, if valid, constructs a typed `%Event{}`.

This is the primary function for handling incoming webhooks. It:
1. Verifies the `Stripe-Signature` header using HMAC-SHA256
2. Checks the timestamp is within the tolerance window (replay attack protection)
3. Decodes the JSON payload into a `%LatticeStripe.Event{}` struct

## Parameters

- `payload` - The raw, unmodified request body string
- `sig_header` - The value of the `Stripe-Signature` header (e.g., `"t=1234,v1=abc..."`)
- `secret` - Your webhook signing secret (string or list of strings for rotation)
- `opts` - Options:
  - `:tolerance` - max age in seconds (default: 300). Set `0` to disable the
    staleness check (testing only — see WEBFIX-01 CHANGELOG entry and the
    inline comment on `check_tolerance/2` for the decision context).

## Returns

- `{:ok, %Event{}}` on success
- `{:error, verify_error()}` on failure — see `t:verify_error/0`

# `construct_event!`

```elixir
@spec construct_event!(String.t(), String.t() | nil, secret(), keyword()) ::
  LatticeStripe.Event.t()
```

Like `construct_event/4` but raises `SignatureVerificationError` on failure.

## Returns

- `%Event{}` on success
- Raises `LatticeStripe.Webhook.SignatureVerificationError` on failure

# `fetch_event`

```elixir
@spec fetch_event(
  LatticeStripe.Client.t(),
  LatticeStripe.EventNotification.t() | String.t()
) ::
  {:ok, LatticeStripe.Event.t()}
  | {:error, LatticeStripe.Error.t() | :no_event_id}
```

# `fetch_event`

```elixir
@spec fetch_event(
  LatticeStripe.Client.t(),
  LatticeStripe.EventNotification.t() | String.t(),
  keyword()
) ::
  {:ok, LatticeStripe.Event.t()}
  | {:error, LatticeStripe.Error.t() | :no_event_id}
```

Retrieves the full v2 `Event.t()` for a thin-event notification.

Issues `GET /v2/core/events/{id}` and decodes the response via
`LatticeStripe.Event.from_map/1`. Adopters call this after
`parse_event_notification/4` returns an `%EventNotification{}` and they need
the authoritative event state (e.g., for snapshot-style v2 events that have
no `related_object`).

Accepts either a `%LatticeStripe.EventNotification{}` (the `id` is extracted)
or a bare `String.t()` event ID. The `%Client{}` is passed explicitly per
Phase 47 D-04 — the notification struct stays pure serializable data with no
embedded credential material, safe for ETS / logs / distributed Erlang.

## Snapshot vs thin-event Event retrieval

For snapshot/v1 event IDs (returned by `construct_event/4` on the legacy
`/v1/webhooks` path), use `LatticeStripe.Event.retrieve/3` instead — it hits
`/v1/events/{id}` which differs from the v2 endpoint and returns a different
payload shape (no `related_object`, integer `created`).

## `created` wire-format note

On a `%Event{}` fetched via `fetch_event/3` from `/v2/core/events/{id}`, the
`created` field arrives as an **ISO 8601 string** (e.g.,
`"2026-03-09T13:00:28.435Z"`) — Stripe's wire format for v2 events. Snapshot
v1 events delivered through `construct_event/4` (and retrieved via
`Event.retrieve/3`) carry an integer Unix timestamp instead. The
`Event.@type t() :created` typespec is currently `integer() | nil`; this
asymmetry is documented and will be widened in a future patch (see Phase 47
Open Question 2). The runtime behavior is correct either way — `Event.from_map/1`
is infallible-deserialize and Dialyzer is not in use.

## Parameters

- `client` - A `%LatticeStripe.Client{}` struct
- `notification_or_id` - An `%EventNotification{}` (whose `id` is extracted)
  OR a bare event-ID string (e.g., `"evt_test_..."`)
- `opts` - Per-request overrides: `:api_version`, `:idempotency_key`, etc.
  (forwarded to `Client.request/2`)

## Returns

- `{:ok, %Event{}}` on success
- `{:error, %LatticeStripe.Error{}}` on HTTP failure
- `{:error, :no_event_id}` when called with `%EventNotification{id: nil}`
  (defensive — does NOT issue an HTTP request)

## Examples

    # From a parsed notification:
    {:ok, notif} = Webhook.parse_event_notification(payload, sig_header, secret)
    {:ok, %Event{} = event} = Webhook.fetch_event(client, notif)

    # From a bare ID:
    {:ok, %Event{}} = Webhook.fetch_event(client, "evt_test_65UIRNU7G1XbhCfOim416TgmEI4ASQ3jHxXt8RFwXoeVwO")

# `fetch_event!`

```elixir
@spec fetch_event!(
  LatticeStripe.Client.t(),
  LatticeStripe.EventNotification.t() | String.t(),
  keyword()
) :: LatticeStripe.Event.t()
```

Like `fetch_event/3` but raises on failure.

Returns `%Event{}` on success. Raises `LatticeStripe.Error` on HTTP failure
or on `{:error, :no_event_id}` (the typed atom is collapsed into a
`%LatticeStripe.Error{type: :invalid_request_error}` for consistent
exception semantics).

# `fetch_related_object`

```elixir
@spec fetch_related_object(
  LatticeStripe.Client.t(),
  LatticeStripe.EventNotification.t()
) ::
  {:ok, struct() | map()}
  | {:error,
     LatticeStripe.Error.t()
     | {:unknown_object_type, String.t()}
     | :no_related_object}
```

# `fetch_related_object`

```elixir
@spec fetch_related_object(
  LatticeStripe.Client.t(),
  LatticeStripe.EventNotification.t(),
  keyword()
) ::
  {:ok, struct() | map()}
  | {:error,
     LatticeStripe.Error.t()
     | {:unknown_object_type, String.t()}
     | :no_related_object}
```

Retrieves the typed underlying resource referenced by a thin-event notification's
`related_object`.

Looks up the LatticeStripe module for `notification.related_object.type` in the
internal object-type registry **before** issuing any HTTP request (Phase 47 D-05
fail-fast contract). On a known type, issues a GET against the
`notification.related_object.url` (Stripe ships the path verbatim) and decodes
the response into the corresponding struct.

## Typed-error contract (Phase 47 D-05 + D-07)

- `{:error, {:unknown_object_type, type}}` — `related_object.type` is not in
  `LatticeStripe.ObjectTypes.@object_map`. Stripe shipped a new resource type;
  opening a PR to add the module to the dispatch table resolves this. No HTTP
  request is issued in this case.
- `{:error, :no_related_object}` — `notification.related_object == nil`
  (snapshot-style v2 events). Adopters should call `fetch_event/3` to retrieve
  the full `%Event{}` instead.
- `{:error, %LatticeStripe.Error{}}` — standard HTTP failure (4xx/5xx,
  connection error, etc.).

## Parameters

- `client` - A `%LatticeStripe.Client{}` struct (passed explicitly per D-04 —
  the notification carries no embedded credential material)
- `notification` - An `%EventNotification{}` returned by
  `parse_event_notification/4`
- `opts` - Per-request overrides:
  - `:expand` - list of paths to expand inline; reuses v1.2 expand machinery
    via `Client.request/2`
  - `:api_version` - per-request Stripe API version override
  - `:idempotency_key` - per-request idempotency key

## Returns

- `{:ok, struct()}` - typed resource decoded via `ObjectTypes.maybe_deserialize/1`
- `{:error, %LatticeStripe.Error{} | {:unknown_object_type, String.t()} | :no_related_object}`

## Example

    case Webhook.parse_event_notification(payload, sig_header, secret) do
      {:ok, %EventNotification{
         related_object: %RelatedObject{type: "customer"}
       } = notif} ->
        {:ok, %LatticeStripe.Customer{} = customer} =
          Webhook.fetch_related_object(client, notif)

        handle_customer_event(customer)

      {:ok, %EventNotification{related_object: nil} = notif} ->
        # No related object — fetch the full event instead.
        {:ok, %Event{}} = Webhook.fetch_event(client, notif)

      {:error, reason} ->
        Logger.error("verify failed: #{inspect(reason)}")
    end

# `fetch_related_object!`

```elixir
@spec fetch_related_object!(
  LatticeStripe.Client.t(),
  LatticeStripe.EventNotification.t(),
  keyword()
) ::
  struct() | map()
```

Like `fetch_related_object/3` but raises on failure.

Collapses all error paths into `LatticeStripe.Error` for consistent exception
semantics:

- `{:error, %Error{}}` → raises the Error verbatim
- `{:error, {:unknown_object_type, type}}` → raises
  `%Error{type: :invalid_request_error, message: "Unknown Stripe object type: #{type}"}`
- `{:error, :no_related_object}` → raises
  `%Error{type: :invalid_request_error, message: "EventNotification has no related_object"}`

# `generate_test_signature`

```elixir
@spec generate_test_signature(String.t(), String.t(), keyword()) :: String.t()
```

Generates a Stripe-compatible webhook signature header for testing.

Use this in tests to produce a `Stripe-Signature` header that passes
`verify_signature/3`. This avoids hard-coding computed HMAC values in tests
and correctly simulates what Stripe's servers send.

## Parameters

- `payload` - The JSON-encoded payload string
- `secret` - The webhook signing secret
- `opts` - Options:
  - `:timestamp` - Unix timestamp integer to embed (default: current time)

## Returns

A `Stripe-Signature` header value string, e.g. `"t=1680000000,v1=abc123..."`.

## Example

    header = LatticeStripe.Webhook.generate_test_signature(payload, secret)
    {:ok, event} = LatticeStripe.Webhook.construct_event(payload, header, secret)

# `parse_event_notification`

```elixir
@spec parse_event_notification(String.t(), String.t() | nil, secret(), keyword()) ::
  {:ok, LatticeStripe.EventNotification.t()} | {:error, verify_error()}
```

Verifies a Stripe thin-event signature and decodes the payload into an `%EventNotification{}`.

This is the thin-event (`/v2/events`) counterpart to `construct_event/4`. It uses
the **same** HMAC-SHA256 verification primitive (`verify_signature/4`) — same wire
format for `Stripe-Signature`, same tolerance machinery, same error atoms — but
decodes the verified payload into `LatticeStripe.EventNotification.t()` instead of
`LatticeStripe.Event.t()`.

Thin events are delivered by Stripe `/v2/event-destinations` endpoints. The
notification carries only metadata + a `related_object` reference; adopters fetch
the full event with `fetch_event/3` and the underlying typed resource with
`fetch_related_object/3`.

For snapshot/v1 webhook payloads (`object: "event"`, full `data` embedded), use
`construct_event/4` instead. See the module docstring "Snapshot events vs thin
events: when to use which" for routing guidance.

## Parameters

- `payload` - The raw, unmodified request body string
- `sig_header` - The value of the `Stripe-Signature` header (e.g., `"t=1234,v1=abc..."`)
- `secret` - Your webhook signing secret (string or list of strings for rotation)
- `opts` - Options:
  - `:tolerance` - max age in seconds (default: 300). Set `0` to disable the
    staleness check (testing only — see WEBFIX-01 CHANGELOG entry).

## Returns

- `{:ok, %EventNotification{}}` on success — typed struct exposing `id`, `type`,
  `created`, `context`, `livemode`, and `related_object`.
- `{:error, :missing_header}` — no `Stripe-Signature` header was provided
- `{:error, :invalid_header}` — header is present but malformed
- `{:error, :no_matching_signature}` — HMAC doesn't match any provided secret
- `{:error, :timestamp_expired}` — timestamp older than tolerance

Identical 4-atom error set as `construct_event/4` (the verify boundary is shared).

## Example

    case LatticeStripe.Webhook.parse_event_notification(payload, sig_header, secret) do
      {:ok, %LatticeStripe.EventNotification{
         type: "v2.core.account.updated",
         related_object: %LatticeStripe.EventNotification.RelatedObject{
           type: "v2.core.account",
           id: account_id
         }} = notif} ->
        handle_account_update(notif, account_id)

      {:ok, %LatticeStripe.EventNotification{related_object: nil} = notif} ->
        # Snapshot-style v2 event (no related object) — fetch full event for context
        {:ok, %LatticeStripe.Event{} = event} =
          LatticeStripe.Webhook.fetch_event(client, notif)

        handle_full_event(event)

      {:error, :timestamp_expired} ->
        Logger.warning("Stripe webhook expired; check clock skew")

      {:error, reason} ->
        Logger.error("Stripe webhook verification failed: #{inspect(reason)}")
    end

# `parse_event_notification!`

```elixir
@spec parse_event_notification!(String.t(), String.t() | nil, secret(), keyword()) ::
  LatticeStripe.EventNotification.t()
```

Like `parse_event_notification/4` but raises `SignatureVerificationError` on failure.

Parity with `construct_event!/4`: returns the typed notification struct on success,
raises `LatticeStripe.Webhook.SignatureVerificationError` (carrying the `:reason`
atom from the 4-atom verify error set) on any verify failure.

## Returns

- `%EventNotification{}` on success
- Raises `LatticeStripe.Webhook.SignatureVerificationError` on failure

# `verify_signature`

```elixir
@spec verify_signature(String.t(), String.t() | nil, secret(), keyword()) ::
  {:ok, integer()} | {:error, verify_error()}
```

Verifies a Stripe webhook signature header against a payload and secret.

Performs timing-safe HMAC-SHA256 comparison via `Plug.Crypto.secure_compare/2`.
Returns the parsed timestamp integer on success (useful for logging).

## Parameters

- `payload` - The raw request body string
- `sig_header` - The `Stripe-Signature` header value
- `secret` - Signing secret or list of secrets (for rotation)
- `opts` - Options:
  - `:tolerance` - max timestamp age in seconds (default: 300)

## Returns

- `{:ok, timestamp}` where `timestamp` is a Unix integer on success
- `{:error, :missing_header}` — no header provided
- `{:error, :invalid_header}` — header is present but malformed
- `{:error, :timestamp_expired}` — timestamp older than tolerance
- `{:error, :no_matching_signature}` — HMAC doesn't match any provided secret

# `verify_signature!`

```elixir
@spec verify_signature!(String.t(), String.t() | nil, secret(), keyword()) ::
  integer()
```

Like `verify_signature/4` but raises `SignatureVerificationError` on failure.

## Returns

- `timestamp` (integer) on success
- Raises `LatticeStripe.Webhook.SignatureVerificationError` on failure

---

*Consult [api-reference.md](api-reference.md) for complete listing*
