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: :syncIn 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.WebhookSinkThe 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-Signatureheader, 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 createdv1= 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
@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.
Returns a specification to start this module under a supervisor.
See Supervisor.
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")
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")
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 signsecret- 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..."
@spec start_link(keyword()) :: GenServer.on_start()
Starts the WebhookDelivery GenServer.