Module: PyrxSynapse

Defined in:
lib/pyrx_synapse/client.rb,
lib/pyrx_synapse/errors.rb,
lib/pyrx_synapse/version.rb,
lib/pyrx_synapse/webhooks.rb,
lib/pyrx_synapse/contacts_client.rb,
lib/pyrx_synapse/templates_client.rb

Defined Under Namespace

Modules: Parsers Classes: BatchTrackResponse, BulkContactResponse, Client, ContactListMeta, ContactListResponse, ContactResponse, ContactsClient, SendResponse, SynapseAuthError, SynapseError, SynapsePlanLimitError, SynapseRateLimitError, SynapseValidationError, TemplatePreviewResponse, TemplateResponse, TemplatesClient, TrackResponse

Constant Summary collapse

RETRYABLE_STATUSES =
[429, 500, 502, 503, 504].freeze
RETRYABLE_EXCEPTIONS =
[Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError].freeze
MAX_BACKOFF_SEC =
30.0
JITTER_MAX_SEC =
0.5
VERSION =
"0.1.0"
TOLERANCE_SECONDS =

5 minutes

300

Class Method Summary collapse

Class Method Details

.map_error(status, body, retry_after = nil) ⇒ Object

Map an HTTP status and response body to the appropriate error subclass.



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/pyrx_synapse/errors.rb', line 54

def self.map_error(status, body, retry_after = nil)
  body = {} unless body.is_a?(Hash)

  message = body["detail"]&.to_s || body["message"]&.to_s || "HTTP #{status}"
  code = body.key?("code") ? body["code"].to_s : nil
  request_id = body.key?("request_id") ? body["request_id"].to_s : nil

  if status == 429
    return SynapseRateLimitError.new(
      message, status: status, code: code, request_id: request_id,
      retry_after: retry_after || 60.0
    )
  end

  if status == 403 && code == "plan_limit_reached"
    return SynapsePlanLimitError.new(
      message, status: status, code: code, request_id: request_id,
      limit_type: body.fetch("limit_type", "").to_s,
      current: safe_int(body["current"]),
      maximum: safe_int(body["maximum"]),
      plan: body.fetch("plan", "").to_s
    )
  end

  if status == 401 || status == 403
    return SynapseAuthError.new(message, status: status, code: code, request_id: request_id)
  end

  if status == 422
    raw_errors = body["errors"]
    errors = []
    if raw_errors.is_a?(Array)
      raw_errors.each do |entry|
        next unless entry.is_a?(Hash)

        errors << {
          field: entry.fetch("field", "").to_s,
          message: (entry["message"] || entry["msg"] || "").to_s
        }
      end
    end
    return SynapseValidationError.new(
      message, status: status, code: code, request_id: request_id, errors: errors
    )
  end

  SynapseError.new(message, status: status, code: code, request_id: request_id)
end

.verify_webhook(payload, headers, secret, disable_timestamp_check: false) ⇒ Hash

Verify a webhook signature from Synapse (Svix format).

Parameters:

  • payload (String)

    the raw request body as a string

  • headers (Hash<String, String>)

    must contain svix-id, svix-timestamp, svix-signature

  • secret (String)

    the webhook signing secret (optionally prefixed with “whsec_”)

  • disable_timestamp_check (Boolean) (defaults to: false)

    skip timestamp tolerance check (testing only)

Returns:

  • (Hash)

    the parsed webhook event payload

Raises:

  • (ArgumentError)

    if signature is invalid, timestamp expired, or headers missing



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/pyrx_synapse/webhooks.rb', line 18

def self.verify_webhook(payload, headers, secret, disable_timestamp_check: false)
  svix_id = headers["svix-id"]
  svix_timestamp = headers["svix-timestamp"]
  svix_signature = headers["svix-signature"]

  if !svix_id || svix_id.empty? ||
     !svix_timestamp || svix_timestamp.empty? ||
     !svix_signature || svix_signature.empty?
    raise ArgumentError, "Missing required webhook headers: svix-id, svix-timestamp, svix-signature"
  end

  # Validate timestamp (prevent replay attacks)
  unless disable_timestamp_check
    begin
      timestamp_sec = Integer(svix_timestamp)
    rescue ArgumentError, TypeError
      raise ArgumentError, "Invalid svix-timestamp header"
    end

    now_sec = Time.now.to_i
    diff = (now_sec - timestamp_sec).abs
    if diff > TOLERANCE_SECONDS
      raise ArgumentError,
            "Webhook timestamp too old (#{diff}s > #{TOLERANCE_SECONDS}s tolerance)"
    end
  end

  # Decode secret: strip "whsec_" prefix and base64-decode
  secret_raw = secret.start_with?("whsec_") ? secret[6..] : secret
  secret_bytes = Base64.strict_decode64(secret_raw)

  # Compute expected signature
  signed_content = "#{svix_id}.#{svix_timestamp}.#{payload}"
  expected_bytes = OpenSSL::HMAC.digest("SHA256", secret_bytes, signed_content)

  # Compare against all provided signatures (svix sends multiple for key rotation)
  signatures = svix_signature.split(" ")
  verified = false

  signatures.each do |sig|
    parts = sig.split(",", 2)
    next if parts.length < 2

    sig_value = parts[1]
    begin
      sig_bytes = Base64.strict_decode64(sig_value)
      if sig_bytes.bytesize == expected_bytes.bytesize &&
         OpenSSL.fixed_length_secure_compare(sig_bytes, expected_bytes)
        verified = true
        break
      end
    rescue ArgumentError
      # Skip malformed signatures
      next
    end
  end

  raise ArgumentError, "Webhook signature verification failed" unless verified

  # Parse and return the event
  begin
    JSON.parse(payload)
  rescue JSON::ParserError
    raise ArgumentError, "Failed to parse webhook payload as JSON"
  end
end