Module: EasyLabs::Webhooks

Defined in:
lib/easylabs/webhooks.rb

Overview

Webhook signature verifier and the canonical event-type catalog. Mirrors ‘EasyWebhooks` from `@easylabs/node`.

The Easy API signs every outbound webhook delivery with HMAC-SHA256 over the raw request body using the per-endpoint signing secret (returned only once on ‘client.webhooks.register`). The signature arrives in the `x-easy-webhook-signature` header in the format `sha256=<hex>`.

Examples:

Verify and parse an inbound webhook

event = EasyLabs::Webhooks.construct_event(
  payload:   request.body.read,
  signature: request.headers["X-Easy-Webhook-Signature"],
  secret:    ENV.fetch("EASY_WEBHOOK_SECRET"),
)

case event[:type]
when "payment.created" then handle_payment_created(event[:data])
end

Constant Summary collapse

SIGNATURE_PREFIX =
"sha256="
EVENT_TYPES =

Frozen list of every event the API can emit. Mirrors ‘EASY_EVENT_TYPES` in @easylabs/common.

%w[
  payment.created
  payment.updated
  refund.created
  refund.updated
  authorization.created
  authorization.updated
  authorization.voided
  subscription.created
  subscription.updated
  subscription.deleted
  subscription.paused
  subscription.resumed
  subscription.trial_will_end
  subscription.pending_update_applied
  subscription.pending_update_expired
  invoice.created
  invoice.finalized
  invoice.paid
  invoice.payment_failed
  invoice.upcoming
  invoice.voided
  invoice.marked_uncollectible
  revenue_recovery.action_completed
  coupon.created
  coupon.updated
  coupon.deleted
  promotion_code.created
  promotion_code.updated
  promotion_code.deleted
  identity.created
  identity.updated
  settlement.created
  dispute.created
  dispute.updated
  checkout.session.completed
  checkout.session.crypto_confirmed
  test.webhook
].freeze

Class Method Summary collapse

Class Method Details

.construct_event(payload:, signature:, secret:) ⇒ Hash

Verify the signature on a webhook delivery and parse the JSON payload. Raises InvalidRequestError (with a specific ‘code`) when the signature is missing, malformed, or does not match — never returns silently on a bad signature.

Parameters:

  • payload (String)

    the exact raw request body. Do NOT re-serialize a parsed hash; the JSON whitespace must match what the API signed.

  • signature (String)

    the value of the ‘x-easy-webhook-signature` header on the inbound request.

  • secret (String)

    the per-endpoint signing secret returned once by ‘client.webhooks.register`.

Returns:

  • (Hash)

    the parsed event envelope (id, type, created_at, created, api_version, data, …). Symbol keys.



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/easylabs/webhooks.rb', line 89

def construct_event(payload:, signature:, secret:)
  if signature.nil? || signature.empty?
    raise_invalid("WEBHOOK_SIGNATURE_MISSING",
                  "Missing webhook signature header")
  end

  unless signature.start_with?(SIGNATURE_PREFIX)
    raise_invalid(
      "WEBHOOK_SIGNATURE_FORMAT_INVALID",
      "Webhook signature must be prefixed with '#{SIGNATURE_PREFIX}'"
    )
  end

  provided_hex = signature[SIGNATURE_PREFIX.length..]
  unless provided_hex.match?(/\A[0-9a-f]+\z/i) && provided_hex.length.even?
    raise_invalid(
      "WEBHOOK_SIGNATURE_FORMAT_INVALID",
      "Webhook signature is not valid hex"
    )
  end

  expected_hex = OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, payload.to_s)

  provided_bytes = [provided_hex].pack("H*")
  expected_bytes = [expected_hex].pack("H*")

  unless provided_bytes.bytesize == expected_bytes.bytesize &&
         OpenSSL.fixed_length_secure_compare(provided_bytes, expected_bytes)
    raise_invalid(
      "WEBHOOK_SIGNATURE_MISMATCH",
      "Webhook signature does not match expected value"
    )
  end

  begin
    JSON.parse(payload, symbolize_names: true)
  rescue JSON::ParserError
    raise_invalid("WEBHOOK_BODY_INVALID_JSON", "Webhook body is not valid JSON")
  end
end

.raise_invalid(code, message) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



131
132
133
# File 'lib/easylabs/webhooks.rb', line 131

def raise_invalid(code, message)
  raise EasyLabs::InvalidRequestError.new(message, status: 400, code: code)
end