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
-
.map_error(status, body, retry_after = nil) ⇒ Object
Map an HTTP status and response body to the appropriate error subclass.
-
.verify_webhook(payload, headers, secret, disable_timestamp_check: false) ⇒ Hash
Verify a webhook signature from Synapse (Svix format).
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) = 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( , 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( , 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(, 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( , status: status, code: code, request_id: request_id, errors: errors ) end SynapseError.new(, 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).
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"] = headers["svix-timestamp"] svix_signature = headers["svix-signature"] if !svix_id || svix_id.empty? || ! || .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 begin = Integer() rescue ArgumentError, TypeError raise ArgumentError, "Invalid svix-timestamp header" end now_sec = Time.now.to_i diff = (now_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}.#{}.#{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 |