Zero-dependency Ruby SDK for the Synapse API. Published as pyrx-synapse on RubyGems.org. Uses only Ruby stdlib (net/http, json, openssl).
Requires Ruby 3.0+ .
Bundler:
ruby
1 require "pyrx_synapse"
2
3 client = PyrxSynapse::Client.new(
4 api_key: "psk_live_your_api_key",
5 workspace_id: "your_workspace_id"
6 )
7
8 # Track an event
9 client.track(
10 external_id: "user_123",
11 event_name: "purchase_completed",
12 attributes: {
13 "order_id" => "ord_456",
14 "amount" => 99.99,
15 "currency" => "USD",
16 }
17 )
18
19 # Identify a contact
20 client.identify(
21 external_id: "user_123",
23 first_name: "Jane",
24 last_name: "Doe",
25 properties: { "plan" => "pro", "signup_source" => "website" },
26 tags: ["paying", "beta-tester"]
27 )
28
29 # Send a transactional email
30 client.send_email(
31 template_slug: "order-confirmation",
32 to: {
33 "user_id" => "user_123",
35 "first_name" => "Jane",
36 },
37 attributes: {
38 "order_id" => "ord_456",
39 "items" => [{ "name" => "Widget", "price" => 99.99 }],
40 }
41 )
The method is send_email, not send. This avoids conflicting with Ruby's built-in Object#send.
ruby
1 client = PyrxSynapse::Client.new(
2 api_key: "psk_live_xxx", # Required. API key from workspace settings.
3 workspace_id: "ws_xxx", # Required. Your workspace ID.
4 base_url: "https://...", # Default: https://synapse-api.pyrx.tech
5 timeout: 30, # Default: 30 seconds
6 max_retries: 3, # Default: 3. Set to 0 to disable retries.
7 )
Parameter Type Default Description api_keyString required Your Synapse API key (psk_live_* or psk_test_*) workspace_idString required Your workspace identifier base_urlString https://synapse-api.pyrx.techAPI base URL timeoutInteger 30 Request timeout in seconds max_retriesInteger 3 Retry count for 429/5xx errors. Set to 0 to disable.
Environment detection: The SDK detects test or live from your API key prefix (psk_test_* vs psk_live_*), available via client.environment.
Retry behavior: The SDK automatically retries on 429, 500, 502, 503, and 504 with exponential backoff and jitter (capped at 30s). On 429, uses the Retry-After header when present. Network errors (Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError) are also retried. Client errors (400, 401, 403, 404, 422) are never retried.
ruby
1 result = client.track(
2 external_id: "user_123",
3 event_name: "purchase_completed",
4 attributes: {
5 "order_id" => "ord_456",
6 "amount" => 99.99,
7 "currency" => "USD",
8 },
9 contact: {
11 "first_name" => "Jane",
12 },
13 idempotency_key: "purchase_ord_456", # optional, prevents duplicate processing
14 occurred_at: "2026-04-29T10:30:00Z", # optional, defaults to now
15 )
16
17 puts result.event_id # "evt_8f14e45f-..."
18 puts result.status # "accepted"
Parameter Type Required Description external_idString Yes Your unique user identifier event_nameString Yes Event name (e.g., purchase_completed) attributesHash No Arbitrary key-value event data contactHash No Contact fields to upsert alongside the event idempotency_keyString No Prevents duplicate processing (7-day TTL) occurred_atString No ISO 8601 timestamp. Defaults to server time.
Track up to 50 events in a single request.
ruby
1 result = client.track_batch(
2 events: [
3 { "external_id" => "user_1", "event_name" => "page_view", "attributes" => { "page" => "/pricing" } },
4 { "external_id" => "user_2", "event_name" => "page_view", "attributes" => { "page" => "/docs" } },
5 { "external_id" => "user_1", "event_name" => "button_clicked", "attributes" => { "button" => "upgrade" } },
6 ]
7 )
8
9 puts result.accepted # 3
10 puts result.rejected # 0
Create or update (upsert) a contact by external_id.
ruby
1 contact = client.identify(
2 external_id: "user_123",
4 first_name: "Jane",
5 last_name: "Doe",
6 phone: "+1234567890",
7 timezone: "America/New_York",
8 locale: "en-US",
9 properties: { "plan" => "pro", "signup_source" => "website" },
10 tags: ["paying", "beta-tester"]
11 )
12
13 puts contact.id # UUID
14 puts contact.external_id # "user_123"
Upsert up to 1,000 contacts in a single request.
ruby
1 result = client.identify_batch(
2 contacts: [
3 { "external_id" => "user_1", "email" => "[email protected] ", "first_name" => "Alice" }, 4 { "external_id" => "user_2", "email" => "[email protected] ", "first_name" => "Bob" }, 5 ],
6 on_conflict: "merge" # "merge" | "skip" | "replace"
7 )
8
9 puts result.total # 2
10 puts result.created # 1
11 puts result.updated # 1
Send a one-off email using an NLT template, without a flow.
ruby
1 result = client.send_email(
2 template_slug: "otp-verification",
3 to: {
4 "user_id" => "user_123",
6 "first_name" => "Jane",
7 },
8 attributes: {
9 "otp_code" => "847293",
10 "expiry_minutes" => 10,
11 },
12 idempotency_key: "otp_user_123_#{Time.now.to_i}"
13 )
14
15 puts result.status # "sent" or "suppressed"
16 puts result.email_log_id # "el_8f14e45f-..."
Requires a data-scoped API key (or higher). The template must exist in your workspace.
The client.contacts sub-client provides full CRUD operations. Requires a management or full scoped API key.
ruby
1 result = client.contacts.list(
2 search: "jane",
3 page: 1,
4 per_page: 25,
5 sort_by: "created_at",
6 sort_order: "desc"
7 )
8
9 puts result.meta.total # 142
10 puts result.meta.total_pages # 6
11
12 result.data.each do |c|
13 puts "#{c.email} #{c.first_name}"
14 end
ruby
contact = client.contacts.get("contact_uuid")
ruby
1 client.contacts.update("user_123", {
3 "add_tags" => ["vip"],
4 "remove_tags" => ["trial"],
5 })
ruby
client.contacts.delete("user_123")
The client.templates sub-client manages email templates. Requires a management or full scoped API key.
ruby
templates = client.templates.list
ruby
template = client.templates.get("welcome-email")
ruby
1 template = client.templates.create({
2 "name" => "Welcome Email",
3 "slug" => "welcome-email",
4 "subject" => "Welcome, [first name of contact]!",
5 "body_html" => "<h1>Welcome!</h1><p>Thanks for joining.</p>",
6 "sender_name" => "PYRX Team",
8 })
ruby
1 template = client.templates.update("welcome-email", {
2 "subject" => "Welcome aboard, [first name of contact]!",
3 })
ruby
1 preview = client.templates.preview("welcome-email", {
3 "trigger_event" => { "order_id" => "ord_123" },
4 })
5
6 puts preview.subject # Rendered subject
7 puts preview.html # Rendered HTML
8 puts preview.suppressed # false
9 puts preview.suppressed_reason # nil
ruby
client.templates.delete("old-template")
Verify incoming webhook signatures to ensure requests are authentically from Synapse. This is a module-level method -- no client instance needed.
ruby
1 require "pyrx_synapse"
2
3 # In your webhook endpoint handler:
4 payload = request.body.read # raw request body string
5 headers = {
6 "svix-id" => request.headers["svix-id"],
7 "svix-timestamp" => request.headers["svix-timestamp"],
8 "svix-signature" => request.headers["svix-signature"],
9 }
10 secret = ENV["SYNAPSE_WEBHOOK_SECRET"] # e.g. "whsec_..."
11
12 begin
13 event = PyrxSynapse.verify_webhook(payload, headers, secret)
14 puts event["type"] # e.g. "email.delivered"
15 rescue ArgumentError => e
16 # Invalid signature, expired timestamp, or missing headers
17 puts "Webhook rejected: #{e.message}"
18 end
The verification checks:
All three svix-* headers are present
The timestamp is within 5 minutes (replay attack protection)
The HMAC-SHA256 signature matches (supports multiple signatures for key rotation)
The SDK provides typed error classes for every failure mode.
ruby
1 require "pyrx_synapse"
2
3 begin
4 client.track(external_id: "u1", event_name: "test")
5 rescue PyrxSynapse::SynapsePlanLimitError => e
6 puts "Plan limit: #{e.limit_type} (#{e.current}/#{e.maximum})"
7 puts "Current plan: #{e.plan}"
8 rescue PyrxSynapse::SynapseRateLimitError => e
9 puts "Rate limited. Retry after #{e.retry_after}s"
10 rescue PyrxSynapse::SynapseValidationError => e
11 e.errors.each { |err| puts "#{err[:field]}: #{err[:message]}" }
12 rescue PyrxSynapse::SynapseAuthError => e
13 puts "Authentication failed: #{e.message}"
14 rescue PyrxSynapse::SynapseError => e
15 puts "API error #{e.status}: #{e.message}"
16 end
Error Class HTTP Status Properties When PyrxSynapse::SynapseErrorAny status, message, code, request_idBase class for all API errors PyrxSynapse::SynapseAuthError401, 403 messageInvalid or expired API key, scope mismatch PyrxSynapse::SynapseValidationError422 errors[] with :field + :messageRequest body validation failed PyrxSynapse::SynapseRateLimitError429 retry_after (seconds)Rate limit exceeded (auto-retried) PyrxSynapse::SynapsePlanLimitError403 limit_type, current, maximum, planPlan limit reached
For production deployments, load credentials from environment variables.
ruby
1 require "pyrx_synapse"
2
3 client = PyrxSynapse::Client.new(
4 api_key: ENV.fetch("SYNAPSE_API_KEY"),
5 workspace_id: ENV.fetch("SYNAPSE_WORKSPACE_ID")
6 )
bash
1 export SYNAPSE_API_KEY=psk_live_a1b2c3d4e5f67890abcdef1234567890
2 export SYNAPSE_WORKSPACE_ID=your_workspace_id
Method Description Required Scope client.track(...)Track a single event dataclient.track_batch(...)Track up to 50 events dataclient.identify(...)Upsert a single contact dataclient.identify_batch(...)Upsert up to 1,000 contacts dataclient.send_email(...)Send a transactional email dataclient.contacts.list(...)List contacts with pagination managementclient.contacts.get(id)Get a single contact managementclient.contacts.update(id, data)Update a contact managementclient.contacts.delete(id)Delete a contact managementclient.templates.listList all templates managementclient.templates.get(slug)Get a template by slug managementclient.templates.create(params)Create a template managementclient.templates.update(slug, params)Update a template managementclient.templates.preview(slug, data)Preview rendered template managementclient.templates.delete(slug)Delete a template managementPyrxSynapse.verify_webhook(payload, headers, secret)Verify webhook signature --
ruby
1 # config/initializers/synapse.rb
2 require "pyrx_synapse"
3
4 SYNAPSE = PyrxSynapse::Client.new(
5 api_key: ENV.fetch("SYNAPSE_API_KEY"),
6 workspace_id: ENV.fetch("SYNAPSE_WORKSPACE_ID")
7 )
ruby
1 # app/controllers/signups_controller.rb
2 class SignupsController < ApplicationController
3 def create
4 user = User.create!(user_params)
5
6 # Identify the new user
7 SYNAPSE.identify(
8 external_id: user.id.to_s,
9 email: user.email,
10 first_name: user.first_name,
11 properties: { "plan" => user.plan },
12 tags: ["new-signup"]
13 )
14
15 # Track the signup event (triggers flows)
16 SYNAPSE.track(
17 external_id: user.id.to_s,
18 event_name: "user_signed_up",
19 attributes: { "plan" => user.plan, "source" => "web" }
20 )
21
22 render json: { success: true }, status: :created
23 end
24 end
ruby
1 require "sinatra"
2 require "pyrx_synapse"
3
4 synapse = PyrxSynapse::Client.new(
5 api_key: ENV.fetch("SYNAPSE_API_KEY"),
6 workspace_id: ENV.fetch("SYNAPSE_WORKSPACE_ID")
7 )
8
9 post "/track" do
10 data = JSON.parse(request.body.read)
11
12 synapse.track(
13 external_id: data["user_id"],
14 event_name: data["event"],
15 attributes: data.fetch("properties", {})
16 )
17
18 content_type :json
19 { status: "accepted" }.to_json
20 end
ruby
1 # app/controllers/webhooks_controller.rb
2 class WebhooksController < ApplicationController
3 skip_before_action :verify_authenticity_token
4
5 def synapse
6 payload = request.body.read
7 headers = {
8 "svix-id" => request.headers["svix-id"],
9 "svix-timestamp" => request.headers["svix-timestamp"],
10 "svix-signature" => request.headers["svix-signature"],
11 }
12
13 begin
14 event = PyrxSynapse.verify_webhook(
15 payload, headers, ENV.fetch("SYNAPSE_WEBHOOK_SECRET")
16 )
17 # Process the event
18 Rails.logger.info "Webhook received: #{event['type']}"
19 head :ok
20 rescue ArgumentError => e
21 Rails.logger.warn "Webhook rejected: #{e.message}"
22 head :bad_request
23 end
24 end
25 end