PolarExpress.Webhook (polar_express v0.1.5)

Copy Markdown View Source

Webhook signature verification and event construction.

Polar uses the Standard Webhooks specification. Each webhook request includes three headers:

  • webhook-id — unique message identifier
  • webhook-timestamp — Unix epoch seconds
  • webhook-signaturev1,<base64> HMAC-SHA256 signature

Usage

headers = %{
  "webhook-id" => msg_id,
  "webhook-timestamp" => timestamp,
  "webhook-signature" => signature
}

case PolarExpress.Webhook.construct_event(payload, headers, secret) do
  {:ok, event} -> handle_event(event)
  {:error, error} -> send_resp(conn, 400, error.message)
end

Most users should use PolarExpress.WebhookPlug instead, which handles all of this automatically.

Summary

Types

Standard Webhooks headers sent by Polar with every webhook request.

Options for webhook verification.

Functions

Compute the expected Base64-encoded HMAC-SHA256 signature for a payload.

Verify a webhook signature and construct a typed event struct.

Verify Standard Webhooks headers without constructing the event.

Types

headers()

@type headers() :: %{required(String.t()) => String.t()}

Standard Webhooks headers sent by Polar with every webhook request.

  • "webhook-id" — unique message identifier (used in signature and for deduplication)
  • "webhook-timestamp" — Unix epoch seconds as a string
  • "webhook-signature" — one or more v1,<base64> HMAC-SHA256 signatures (space-separated)

verify_opts()

@type verify_opts() :: [{:tolerance, pos_integer()}]

Options for webhook verification.

Functions

compute_signature(msg_id, timestamp, payload, secret)

@spec compute_signature(String.t(), integer(), binary(), String.t()) :: String.t()

Compute the expected Base64-encoded HMAC-SHA256 signature for a payload.

The signed content follows the Standard Webhooks format:

"{msg_id}.{timestamp}.{payload}"

The secret is used as raw bytes (Polar-specific — they pass the full secret string including the whsec_ prefix as the HMAC key, rather than Base64-decoding it as the Standard Webhooks spec suggests).

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

@spec construct_event(binary(), headers(), String.t(), verify_opts()) ::
  {:ok, PolarExpress.Resources.Event.t()} | {:error, PolarExpress.Error.t()}

Verify a webhook signature and construct a typed event struct.

Parses the JSON payload, verifies the HMAC-SHA256 signature against the Standard Webhooks headers, and deserializes the event data into the appropriate schema struct (e.g. PolarExpress.Schemas.Order for "order.paid"). Unknown event types are returned with raw map data.

Parameters

  • payload - Raw request body (binary string, NOT parsed JSON)
  • headers - Standard Webhooks headers (see headers/0)
  • secret - Webhook endpoint signing secret (whsec_...)
  • opts - See verify_opts/0

Examples

headers = %{
  "webhook-id" => "msg_2Lml0nCjGr...",
  "webhook-timestamp" => "1714000000",
  "webhook-signature" => "v1,K7rRz..."
}

case PolarExpress.Webhook.construct_event(raw_body, headers, secret) do
  {:ok, %PolarExpress.Resources.Event{type: "order.paid", data: order}} ->
    fulfill_order(order)

  {:error, %PolarExpress.Error{message: msg}} ->
    Logger.warning("Webhook rejected: #{msg}")
end

verify_headers(payload, headers, secret, opts \\ [])

@spec verify_headers(binary(), headers(), String.t(), verify_opts()) ::
  :ok | {:error, PolarExpress.Error.t()}

Verify Standard Webhooks headers without constructing the event.

Checks the required headers are present, the timestamp is within the tolerance window (bidirectional — rejects both stale and future-dated events), and at least one v1 signature matches the expected HMAC-SHA256 using constant-time comparison.

Parameters

  • payload - Raw request body (binary string)
  • headers - Standard Webhooks headers (see headers/0)
  • secret - Webhook endpoint signing secret (whsec_...)
  • opts - See verify_opts/0