Skip to content

Ruby SDK

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+.


Installation

bash
gem install pyrx-synapse

Bundler:

ruby
gem 'pyrx-synapse'
bash
bundle install

Quick Start

ruby
require "pyrx_synapse"
 
client = PyrxSynapse::Client.new(
api_key: "psk_live_your_api_key",
workspace_id: "your_workspace_id"
)
 
# Track an event
client.track(
external_id: "user_123",
event_name: "purchase_completed",
attributes: {
"order_id" => "ord_456",
"amount" => 99.99,
"currency" => "USD",
}
)
 
# Identify a contact
client.identify(
external_id: "user_123",
email: "[email protected]",
first_name: "Jane",
last_name: "Doe",
properties: { "plan" => "pro", "signup_source" => "website" },
tags: ["paying", "beta-tester"]
)
 
# Send a transactional email
client.send_email(
template_slug: "order-confirmation",
to: {
"user_id" => "user_123",
"email" => "[email protected]",
"first_name" => "Jane",
},
attributes: {
"order_id" => "ord_456",
"items" => [{ "name" => "Widget", "price" => 99.99 }],
}
)
Tip

Get your API key and workspace ID from the dashboard at Settings > API Keys.

Note

The method is send_email, not send. This avoids conflicting with Ruby's built-in Object#send.


Configuration

ruby
client = PyrxSynapse::Client.new(
api_key: "psk_live_xxx", # Required. API key from workspace settings.
workspace_id: "ws_xxx", # Required. Your workspace ID.
base_url: "https://...", # Default: https://synapse-api.pyrx.tech
timeout: 30, # Default: 30 seconds
max_retries: 3, # Default: 3. Set to 0 to disable retries.
)
ParameterTypeDefaultDescription
api_keyStringrequiredYour Synapse API key (psk_live_* or psk_test_*)
workspace_idStringrequiredYour workspace identifier
base_urlStringhttps://synapse-api.pyrx.techAPI base URL
timeoutInteger30Request timeout in seconds
max_retriesInteger3Retry 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.


Track Events

Single Event

ruby
result = client.track(
external_id: "user_123",
event_name: "purchase_completed",
attributes: {
"order_id" => "ord_456",
"amount" => 99.99,
"currency" => "USD",
},
contact: {
"email" => "[email protected]",
"first_name" => "Jane",
},
idempotency_key: "purchase_ord_456", # optional, prevents duplicate processing
occurred_at: "2026-04-29T10:30:00Z", # optional, defaults to now
)
 
puts result.event_id # "evt_8f14e45f-..."
puts result.status # "accepted"
ParameterTypeRequiredDescription
external_idStringYesYour unique user identifier
event_nameStringYesEvent name (e.g., purchase_completed)
attributesHashNoArbitrary key-value event data
contactHashNoContact fields to upsert alongside the event
idempotency_keyStringNoPrevents duplicate processing (7-day TTL)
occurred_atStringNoISO 8601 timestamp. Defaults to server time.

Batch Events

Track up to 50 events in a single request.

ruby
result = client.track_batch(
events: [
{ "external_id" => "user_1", "event_name" => "page_view", "attributes" => { "page" => "/pricing" } },
{ "external_id" => "user_2", "event_name" => "page_view", "attributes" => { "page" => "/docs" } },
{ "external_id" => "user_1", "event_name" => "button_clicked", "attributes" => { "button" => "upgrade" } },
]
)
 
puts result.accepted # 3
puts result.rejected # 0

Identify Contacts

Single Contact

Create or update (upsert) a contact by external_id.

ruby
contact = client.identify(
external_id: "user_123",
email: "[email protected]",
first_name: "Jane",
last_name: "Doe",
phone: "+1234567890",
timezone: "America/New_York",
locale: "en-US",
properties: { "plan" => "pro", "signup_source" => "website" },
tags: ["paying", "beta-tester"]
)
 
puts contact.id # UUID
puts contact.external_id # "user_123"
puts contact.email # "[email protected]"

Batch Identify

Upsert up to 1,000 contacts in a single request.

ruby
result = client.identify_batch(
contacts: [
{ "external_id" => "user_1", "email" => "[email protected]", "first_name" => "Alice" },
{ "external_id" => "user_2", "email" => "[email protected]", "first_name" => "Bob" },
],
on_conflict: "merge" # "merge" | "skip" | "replace"
)
 
puts result.total # 2
puts result.created # 1
puts result.updated # 1

Send Transactional Email

Send a one-off email using an NLT template, without a flow.

ruby
result = client.send_email(
template_slug: "otp-verification",
to: {
"user_id" => "user_123",
"email" => "[email protected]",
"first_name" => "Jane",
},
attributes: {
"otp_code" => "847293",
"expiry_minutes" => 10,
},
idempotency_key: "otp_user_123_#{Time.now.to_i}"
)
 
puts result.status # "sent" or "suppressed"
puts result.email_log_id # "el_8f14e45f-..."
Note

Requires a data-scoped API key (or higher). The template must exist in your workspace.


Contact Management

The client.contacts sub-client provides full CRUD operations. Requires a management or full scoped API key.

List Contacts

ruby
result = client.contacts.list(
search: "jane",
page: 1,
per_page: 25,
sort_by: "created_at",
sort_order: "desc"
)
 
puts result.meta.total # 142
puts result.meta.total_pages # 6
 
result.data.each do |c|
puts "#{c.email} #{c.first_name}"
end

Get a Contact

ruby
contact = client.contacts.get("contact_uuid")

Update a Contact

ruby
client.contacts.update("user_123", {
"email" => "[email protected]",
"add_tags" => ["vip"],
"remove_tags" => ["trial"],
})

Delete a Contact

ruby
client.contacts.delete("user_123")

Template Management

The client.templates sub-client manages email templates. Requires a management or full scoped API key.

List Templates

ruby
templates = client.templates.list

Get a Template

ruby
template = client.templates.get("welcome-email")

Create a Template

ruby
template = client.templates.create({
"name" => "Welcome Email",
"slug" => "welcome-email",
"subject" => "Welcome, [first name of contact]!",
"body_html" => "<h1>Welcome!</h1><p>Thanks for joining.</p>",
"sender_name" => "PYRX Team",
"from_email" => "[email protected]",
})

Update a Template

ruby
template = client.templates.update("welcome-email", {
"subject" => "Welcome aboard, [first name of contact]!",
})

Preview with Sample Data

ruby
preview = client.templates.preview("welcome-email", {
"contact" => { "first_name" => "Jane", "email" => "[email protected]" },
"trigger_event" => { "order_id" => "ord_123" },
})
 
puts preview.subject # Rendered subject
puts preview.html # Rendered HTML
puts preview.suppressed # false
puts preview.suppressed_reason # nil

Delete a Template

ruby
client.templates.delete("old-template")

Webhook Verification

Verify incoming webhook signatures to ensure requests are authentically from Synapse. This is a module-level method -- no client instance needed.

ruby
require "pyrx_synapse"
 
# In your webhook endpoint handler:
payload = request.body.read # raw request body string
headers = {
"svix-id" => request.headers["svix-id"],
"svix-timestamp" => request.headers["svix-timestamp"],
"svix-signature" => request.headers["svix-signature"],
}
secret = ENV["SYNAPSE_WEBHOOK_SECRET"] # e.g. "whsec_..."
 
begin
event = PyrxSynapse.verify_webhook(payload, headers, secret)
puts event["type"] # e.g. "email.delivered"
rescue ArgumentError => e
# Invalid signature, expired timestamp, or missing headers
puts "Webhook rejected: #{e.message}"
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)

Error Handling

The SDK provides typed error classes for every failure mode.

ruby
require "pyrx_synapse"
 
begin
client.track(external_id: "u1", event_name: "test")
rescue PyrxSynapse::SynapsePlanLimitError => e
puts "Plan limit: #{e.limit_type} (#{e.current}/#{e.maximum})"
puts "Current plan: #{e.plan}"
rescue PyrxSynapse::SynapseRateLimitError => e
puts "Rate limited. Retry after #{e.retry_after}s"
rescue PyrxSynapse::SynapseValidationError => e
e.errors.each { |err| puts "#{err[:field]}: #{err[:message]}" }
rescue PyrxSynapse::SynapseAuthError => e
puts "Authentication failed: #{e.message}"
rescue PyrxSynapse::SynapseError => e
puts "API error #{e.status}: #{e.message}"
end

Error Types

Error ClassHTTP StatusPropertiesWhen
PyrxSynapse::SynapseErrorAnystatus, message, code, request_idBase class for all API errors
PyrxSynapse::SynapseAuthError401, 403messageInvalid or expired API key, scope mismatch
PyrxSynapse::SynapseValidationError422errors[] with :field + :messageRequest body validation failed
PyrxSynapse::SynapseRateLimitError429retry_after (seconds)Rate limit exceeded (auto-retried)
PyrxSynapse::SynapsePlanLimitError403limit_type, current, maximum, planPlan limit reached

Environment Variables

For production deployments, load credentials from environment variables.

ruby
require "pyrx_synapse"
 
client = PyrxSynapse::Client.new(
api_key: ENV.fetch("SYNAPSE_API_KEY"),
workspace_id: ENV.fetch("SYNAPSE_WORKSPACE_ID")
)
bash
export SYNAPSE_API_KEY=psk_live_a1b2c3d4e5f67890abcdef1234567890
export SYNAPSE_WORKSPACE_ID=your_workspace_id

Full Method Reference

MethodDescriptionRequired Scope
client.track(...)Track a single eventdata
client.track_batch(...)Track up to 50 eventsdata
client.identify(...)Upsert a single contactdata
client.identify_batch(...)Upsert up to 1,000 contactsdata
client.send_email(...)Send a transactional emaildata
client.contacts.list(...)List contacts with paginationmanagement
client.contacts.get(id)Get a single contactmanagement
client.contacts.update(id, data)Update a contactmanagement
client.contacts.delete(id)Delete a contactmanagement
client.templates.listList all templatesmanagement
client.templates.get(slug)Get a template by slugmanagement
client.templates.create(params)Create a templatemanagement
client.templates.update(slug, params)Update a templatemanagement
client.templates.preview(slug, data)Preview rendered templatemanagement
client.templates.delete(slug)Delete a templatemanagement
PyrxSynapse.verify_webhook(payload, headers, secret)Verify webhook signature--

Framework Examples

Rails

ruby
# config/initializers/synapse.rb
require "pyrx_synapse"
 
SYNAPSE = PyrxSynapse::Client.new(
api_key: ENV.fetch("SYNAPSE_API_KEY"),
workspace_id: ENV.fetch("SYNAPSE_WORKSPACE_ID")
)
ruby
# app/controllers/signups_controller.rb
class SignupsController < ApplicationController
def create
user = User.create!(user_params)
 
# Identify the new user
SYNAPSE.identify(
external_id: user.id.to_s,
email: user.email,
first_name: user.first_name,
properties: { "plan" => user.plan },
tags: ["new-signup"]
)
 
# Track the signup event (triggers flows)
SYNAPSE.track(
external_id: user.id.to_s,
event_name: "user_signed_up",
attributes: { "plan" => user.plan, "source" => "web" }
)
 
render json: { success: true }, status: :created
end
end

Sinatra

ruby
require "sinatra"
require "pyrx_synapse"
 
synapse = PyrxSynapse::Client.new(
api_key: ENV.fetch("SYNAPSE_API_KEY"),
workspace_id: ENV.fetch("SYNAPSE_WORKSPACE_ID")
)
 
post "/track" do
data = JSON.parse(request.body.read)
 
synapse.track(
external_id: data["user_id"],
event_name: data["event"],
attributes: data.fetch("properties", {})
)
 
content_type :json
{ status: "accepted" }.to_json
end

Webhook Endpoint (Rails)

ruby
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
 
def synapse
payload = request.body.read
headers = {
"svix-id" => request.headers["svix-id"],
"svix-timestamp" => request.headers["svix-timestamp"],
"svix-signature" => request.headers["svix-signature"],
}
 
begin
event = PyrxSynapse.verify_webhook(
payload, headers, ENV.fetch("SYNAPSE_WEBHOOK_SECRET")
)
# Process the event
Rails.logger.info "Webhook received: #{event['type']}"
head :ok
rescue ArgumentError => e
Rails.logger.warn "Webhook rejected: #{e.message}"
head :bad_request
end
end
end