Module: Sendly::Webhooks

Defined in:
lib/sendly/webhooks.rb

Overview

Webhook utilities for verifying and parsing Sendly webhook events.

Examples:

In a Rails controller

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def handle
    signature = request.headers['X-Sendly-Signature']
    timestamp = request.headers['X-Sendly-Timestamp']
    payload = request.raw_post

    begin
      event = Sendly::Webhooks.parse_event(payload, signature, ENV['WEBHOOK_SECRET'], timestamp: timestamp)

      case event.type
      when 'message.delivered'
        puts "Message delivered: #{event.data.id}"
      when 'message.failed'
        puts "Message failed: #{event.data.error}"
      end

      head :ok
    rescue Sendly::WebhookSignatureError
      head :unauthorized
    end
  end
end

Defined Under Namespace

Modules: ListHealthEventSource

Constant Summary collapse

SIGNATURE_TOLERANCE_SECONDS =
300
EVENT_MESSAGE_QUEUED =

Webhook event type string constants. Use these when subscribing instead of string literals so you catch typos at load time.

"message.queued"
EVENT_MESSAGE_SENT =
"message.sent"
EVENT_MESSAGE_DELIVERED =
"message.delivered"
EVENT_MESSAGE_FAILED =
"message.failed"
EVENT_MESSAGE_BOUNCED =
"message.bounced"
EVENT_MESSAGE_RETRYING =
"message.retrying"
EVENT_MESSAGE_RECEIVED =
"message.received"
EVENT_MESSAGE_OPT_OUT =
"message.opt_out"
EVENT_MESSAGE_OPT_IN =
"message.opt_in"
EVENT_VERIFICATION_CREATED =
"verification.created"
EVENT_VERIFICATION_DELIVERED =
"verification.delivered"
EVENT_VERIFICATION_VERIFIED =
"verification.verified"
EVENT_VERIFICATION_EXPIRED =
"verification.expired"
EVENT_VERIFICATION_FAILED =
"verification.failed"
EVENT_VERIFICATION_RESENT =
"verification.resent"
EVENT_VERIFICATION_DELIVERY_FAILED =
"verification.delivery_failed"
EVENT_CONTACT_AUTO_FLAGGED =
"contact.auto_flagged"
EVENT_CONTACT_MARKED_VALID =
"contact.marked_valid"
EVENT_CONTACTS_LOOKUP_COMPLETED =
"contacts.lookup_completed"
EVENT_CONTACTS_BULK_MARKED_VALID =
"contacts.bulk_marked_valid"

Class Method Summary collapse

Class Method Details

.generate_signature(payload, secret, timestamp: nil) ⇒ String

Generate a webhook signature for testing purposes.

Parameters:

  • payload (String)

    The payload to sign

  • secret (String)

    The secret to use for signing

  • timestamp (String, nil) (defaults to: nil)

    Optional timestamp to include in signature

Returns:

  • (String)

    The signature in the format “sha256=…”



126
127
128
129
# File 'lib/sendly/webhooks.rb', line 126

def generate_signature(payload, secret, timestamp: nil)
  signed_payload = timestamp ? "#{timestamp}.#{payload}" : payload
  'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)
end

.parse_event(payload, signature, secret, timestamp: nil) ⇒ WebhookEvent

Parse and validate a webhook event.

Parameters:

  • payload (String)

    Raw request body as string

  • signature (String)

    X-Sendly-Signature header value

  • secret (String)

    Your webhook secret from dashboard

  • timestamp (String, nil) (defaults to: nil)

    X-Sendly-Timestamp header value (recommended)

Returns:

Raises:



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/sendly/webhooks.rb', line 104

def parse_event(payload, signature, secret, timestamp: nil)
  unless verify_signature(payload, signature, secret, timestamp: timestamp)
    raise WebhookSignatureError, 'Invalid webhook signature'
  end

  data = JSON.parse(payload, symbolize_names: true)

  unless data[:id] && data[:type] && data[:data]
    raise WebhookSignatureError, 'Invalid event structure'
  end

  WebhookEvent.new(data)
rescue JSON::ParserError => e
  raise WebhookSignatureError, "Failed to parse webhook payload: #{e.message}"
end

.verify_signature(payload, signature, secret, timestamp: nil) ⇒ Boolean

Verify webhook signature from Sendly.

Parameters:

  • payload (String)

    Raw request body as string

  • signature (String)

    X-Sendly-Signature header value

  • secret (String)

    Your webhook secret from dashboard

  • timestamp (String, nil) (defaults to: nil)

    X-Sendly-Timestamp header value (recommended)

Returns:

  • (Boolean)

    True if signature is valid, false otherwise



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/sendly/webhooks.rb', line 79

def verify_signature(payload, signature, secret, timestamp: nil)
  return false if payload.nil? || payload.empty?
  return false if signature.nil? || signature.empty?
  return false if secret.nil? || secret.empty?

  if timestamp
    signed_payload = "#{timestamp}.#{payload}"
    return false if (Time.now.to_i - timestamp.to_i).abs > SIGNATURE_TOLERANCE_SECONDS
  else
    signed_payload = payload
  end

  expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)

  secure_compare(expected, signature)
end