Webhook signature verification and event construction.
Polar uses the Standard Webhooks specification. Each webhook request includes three headers:
webhook-id— unique message identifierwebhook-timestamp— Unix epoch secondswebhook-signature—v1,<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)
endMost 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
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 morev1,<base64>HMAC-SHA256 signatures (space-separated)
@type verify_opts() :: [{:tolerance, pos_integer()}]
Options for webhook verification.
Functions
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).
@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 (seeheaders/0)secret- Webhook endpoint signing secret (whsec_...)opts- Seeverify_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
@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 (seeheaders/0)secret- Webhook endpoint signing secret (whsec_...)opts- Seeverify_opts/0