PaperTiger.WebhookDelivery (PaperTiger v1.1.0)

Copy Markdown View Source

Manages webhook event delivery to registered endpoints.

This GenServer delivers webhook events with:

  • Stripe-compatible HMAC SHA256 signing of payloads
  • Exponential backoff retry logic (max 5 attempts)
  • Detailed delivery attempt tracking in Event object
  • Concurrent delivery to multiple endpoints
  • Optional synchronous mode for testing

Delivery Modes

By default, webhooks are delivered asynchronously. For testing, you can enable synchronous mode so API calls block until webhooks are delivered:

config :paper_tiger, webhook_mode: :sync

In sync mode, deliver_event_sync/2 is used which blocks until the webhook is delivered (or fails after all retries).

Delivery adapter (host-owned delivery)

How and where the signed request is sent is a pluggable PaperTiger.WebhookDelivery.Adapter:

config :paper_tiger, webhook_delivery_adapter: MyApp.WebhookSink

The default is PaperTiger.WebhookDelivery.HTTPAdapter, which performs the HTTP POST itself (historical behavior). A host embedding PaperTiger implements the behaviour to take durable ownership of delivery — persist the webhook so it survives restarts, then deliver/retry on its own schedule. The adapter contract ({:ok, %Response{}} = terminal success / ownership taken; {:error, reason} = PaperTiger retries) is explicit and enforced by a real function return, so a missing or crashing host cannot silently drop webhooks. See PaperTiger.WebhookDelivery.Adapter.

This is separate from :webhook_mode (:sync/:async/:collect), which controls when delivery is dispatched, not where it goes.

Telemetry

[:paper_tiger, :webhook, :delivering] is emitted for every delivery, in every adapter, immediately after the payload is signed and the headers are built and immediately before the adapter is called.

  • measurements: %{system_time: System.system_time()}
  • metadata: %{event: map, webhook: map, url: String.t(), payload: String.t(), headers: [{String.t(), String.t()}], signature_header: String.t(), timestamp: integer(), namespace: term()}

This event is observability only — it is not the delivery mechanism. Delivery handoff is the Adapter behaviour. Use this event for metrics and tracing, not for correctness-critical work.

Architecture

  • Async delivery: deliver_event/2 - Queues a delivery task (default)
  • Sync delivery: deliver_event_sync/2 - Blocks until complete
  • Signing: sign_payload/2 - Creates Stripe-compatible HMAC SHA256 signature
  • Signed request construction: build_signed_request/3 - Produces the exact raw body, Stripe-Signature header, and HTTP headers without delivering the webhook
  • HTTP client: Uses Req library for reliable, timeout-aware requests
  • Retry strategy: Exponential backoff (1s, 2s, 4s, 8s, 16s)
  • Tracking: Stores delivery attempts in Event object via Store.Events

Stripe Signature Format

The Stripe-Signature header follows Stripe's format:

Stripe-Signature: t={timestamp},v1={signature}

Where:

  • t = Unix timestamp when webhook was created
  • v1 = HMAC SHA256 signature of "{timestamp}.{payload}" using webhook secret

Examples

# Deliver an event asynchronously (default)
{:ok, _ref} = PaperTiger.WebhookDelivery.deliver_event("evt_123", "we_456")

# Deliver an event synchronously (for testing)
{:ok, :delivered} = PaperTiger.WebhookDelivery.deliver_event_sync("evt_123", "we_456")

# Manually create a signature for testing
signature = PaperTiger.WebhookDelivery.sign_payload("body", "secret")

# Build the exact webhook request a controller test should verify
request = PaperTiger.WebhookDelivery.build_signed_request(event, webhook)

Summary

Functions

Builds a Stripe-compatible signed webhook request without delivering it.

Returns a specification to start this module under a supervisor.

Delivers a webhook event to a specific endpoint.

Delivers a webhook event synchronously, waiting for completion.

Signs a payload using HMAC SHA256 (Stripe-compatible).

Starts the WebhookDelivery GenServer.

Functions

build_signed_request(event, webhook, opts \\ [])

@spec build_signed_request(map(), map(), keyword()) ::
  PaperTiger.WebhookDelivery.Request.t()

Builds a Stripe-compatible signed webhook request without delivering it.

The returned PaperTiger.WebhookDelivery.Request contains the exact raw JSON body and Stripe-Signature header that PaperTiger will hand to the delivery adapter. Tests can pass request.payload and request.signature_header directly to Stripe.Webhook.construct_event/5.

By default, the signature timestamp uses wall-clock time (System.system_time(:second)), matching Stripe's webhook delivery semantics. PaperTiger's simulated resource clock controls object timestamps, not webhook signature freshness.

Options:

  • :namespace - PaperTiger namespace to attach to the request.
  • :timestamp - Signature timestamp override, useful for stale-signature tests.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

deliver_event(event_id, webhook_endpoint_id)

@spec deliver_event(String.t(), String.t()) :: {:ok, reference()} | {:error, term()}

Delivers a webhook event to a specific endpoint.

This function queues the delivery asynchronously. Multiple calls with different webhook_endpoint_ids deliver to all endpoints.

Parameters

  • event_id - ID of the event to deliver (e.g., "evt_123")
  • webhook_endpoint_id - ID of the webhook endpoint (e.g., "we_456")

Returns

  • {:ok, reference} - Delivery queued successfully
  • {:error, reason} - Delivery could not be queued

Examples

{:ok, _ref} = PaperTiger.WebhookDelivery.deliver_event("evt_123", "we_456")

deliver_event_sync(event_id, webhook_endpoint_id)

@spec deliver_event_sync(String.t(), String.t()) ::
  {:ok, :delivered | :failed} | {:error, term()}

Delivers a webhook event synchronously, waiting for completion.

Unlike deliver_event/2, this function blocks until the webhook has been delivered (or fails after all retries). Use this in test environments where you need webhooks to be processed before assertions.

Parameters

  • event_id - ID of the event to deliver (e.g., "evt_123")
  • webhook_endpoint_id - ID of the webhook endpoint (e.g., "we_456")

Returns

  • {:ok, :delivered} - Webhook delivered successfully
  • {:ok, :failed} - Delivery failed after all retries
  • {:error, reason} - Event or webhook not found

Examples

{:ok, :delivered} = PaperTiger.WebhookDelivery.deliver_event_sync("evt_123", "we_456")

sign_payload(payload, secret)

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

Signs a payload using HMAC SHA256 (Stripe-compatible).

Creates the signature component for the Stripe-Signature header. The actual signature is computed on "{timestamp}.{payload}".

Parameters

  • payload - JSON string (or any string data) to sign
  • secret - Webhook secret from the webhook endpoint

Returns

String containing the hex-encoded HMAC SHA256 signature.

Examples

signature = PaperTiger.WebhookDelivery.sign_payload(payload, "whsec_...")
# Returns: "abcd1234..."

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Starts the WebhookDelivery GenServer.