For the canonical lifecycle glossary behind webhook-driven local projection updates, see Lifecycle Semantics. Use that guide for the meaning of active, canceling, paused, past_due, and ended states; use this guide for delivery, verification, and convergence wiring.

Keep webhook setup on the public host boundary. The recommended shape is:

  • mount /webhooks/stripe in a dedicated raw-body pipeline
  • mount /webhooks/braintree in a standard params pipeline that accepts bt_signature and bt_payload
  • implement a host handler with use Accrue.Webhook.Handler
  • configure the signing secret in config/runtime.exs
  • use replay through the supported admin and task surfaces

Route and raw body

Stripe signatures are checked against the original request body, so the webhook scope must use a parser pipeline with a raw body reader:

pipeline :accrue_webhook_raw_body do
  plug Plug.Parsers,
    parsers: [:json],
    pass: ["*/*"],
    json_decoder: Jason,
    body_reader: {Accrue.Webhook.CachingBodyReader, :read_body, []}
end

scope "/webhooks" do
  pipe_through :accrue_webhook_raw_body
  accrue_webhook "/stripe", :stripe
end

Braintree does not require Stripe's raw-body reader. The handler still belongs at the same host boundary, but the plug path reads bt_signature and bt_payload, then passes them through Signature.parse_braintree!/2 before Accrue persists the normalized event.

scope "/webhooks" do
  pipe_through :browser
  accrue_webhook "/braintree", :braintree
end

Host handler boundary

Use use Accrue.Webhook.Handler in a host-owned module:

defmodule MyApp.BillingHandler do
  use Accrue.Webhook.Handler

  @impl Accrue.Webhook.Handler
  def handle_event(type, event, ctx) do
    MyApp.Billing.handle_webhook(type, event, ctx)
  end
end

Signature failures and generic HTTP failures

Invalid signatures should return a generic 400. Host misconfiguration should surface as a generic server failure, with the actionable detail carried by the stable diagnostic code and linked fix path in the troubleshooting guide. Raw-body ordering and parser placement issues map to ACCRUE-DX-WEBHOOK-RAW-BODY — see Troubleshooting — ACCRUE-DX-WEBHOOK-RAW-BODY for the fix matrix row.

For Braintree, the support-visible failure shape is different: wrong portal_base_url or portal_mount_path does not break an upstream hosted redirect because there is none. It breaks the local checkout or billing-portal URL generation that the mounted portal returns to the host.

Processor-aware event handling

Stripe and Braintree share the same Accrue ingest boundary, but they do not prove the same thing:

  • Stripe webhook truth comes from upstream event delivery and hosted URLs.
  • Braintree webhook truth is normalized into Accrue event shapes and may finish local portal work by persisting accrue.portal.checkout.completed.

That local checkout-completion event is projection-backed. On Braintree, the authoritative completion story is not an upstream hosted redirect callback; it is the persisted local event that Accrue.Webhook.DefaultHandler reduces after the mounted checkout flow succeeds. The same reducer also normalizes Braintree subscription webhook kinds through normalize_braintree_type/1 so replay and recovery stay inside the existing invoice and subscription reducers.

Replay

Replay is for reprocessing persisted webhook events after you fix host setup or handler code. Use it when the host boundary is fixed, when the async dispatcher dead-lettered a row, or when Braintree local checkout completion or metered renewal recovery needs the persisted event stream to converge again.

Core entry points:

Verify the end-to-end proof path with:

mix test test/accrue_host_web/webhook_ingest_test.exs

Webhook delivery is the convergence path, not a separate lifecycle glossary. After lifecycle actions run, local projection truth may briefly lead or lag external provider state; the lifecycle guide defines what the resulting states mean, and webhook replay helps those local projections converge cleanly.

When triaging Braintree-specific failures, keep the support contract provider honest: replay fixes the persisted Accrue event path, not an upstream hosted checkout session. If portal_base_url, portal_mount_path, auth continuity, or Hosted Fields readiness is wrong, fix the host setup first, then replay the stored event so the local projection and telemetry catch back up.