equipoise
Ruby client for the Equipoise External CRM Sync API.
A "producer" (a Rails app like AMP, a Stripe importer, a spreadsheet job, …)
uses this gem to upsert its people into an Equipoise account's CRM as Contacts.
The gem is a thin transport — all business logic (matching, de-duplication,
tag reconciliation, custom-field coercion) lives server-side in the receiver's
Crm::ContactSyncService. This client just handles auth, JSON, idempotency,
bounded retry/backoff, and typed errors.
The API contract (OpenAPI 3.1) and integration guide are documented with your Equipoise account at app.equipoi.se.
Pre-1.0: the client is stable and published to RubyGems, but the server API surface may still change before 1.0.
Install
# In the producer app's Gemfile
gem "equipoise"
Configure
api_key and base_url are read from EQUIPOISE_API_KEY / EQUIPOISE_API_URL
automatically, on every request — so rotating the secret in the environment
needs no redeploy. configure just sets the source (and any overrides):
Equipoise.configure do |c|
c.source = "AMP" # this producer's source id (case-sensitive)
end
Assign c.api_key explicitly only if you source the secret elsewhere (e.g.
Rails credentials) — an explicit value is captured and is no longer
rotation-aware. For a trusted TLS-terminating internal endpoint reached over
plain HTTP, set c.allow_insecure_http = true (otherwise an http:// URL to a
non-loopback host is refused, so the key is never sent in cleartext).
Under Rails, run rails g equipoise:install to generate
config/initializers/equipoise.rb.
Tuning (optional)
Three transport knobs are configurable and clamped to safe ranges, so a
producer's config can never make the client a bad citizen to the receiver — an
out-of-range value is clamped (and logged if a logger is set), never raised:
Equipoise.configure do |c|
c.read_timeout = 30 # seconds per request; clamped to 5..120
c.max_retries = 2 # transient-failure retries; clamped to 0..5
c.batch_size = 500 # contacts per batch_sync request; clamped to 1..500
end
For a large batch_sync backfill, keep read_timeout comfortably above how long
one full batch takes server-side (or lower batch_size) so a slow batch never
times out and gets re-sent.
Use
# Upsert one contact (201 created / 200 updated; no duplicate on email change).
res = Equipoise.client.contacts.sync(
external_id: user.id, email: user.email, name: user.name, tags: ["AMP"]
)
res.sync_status # => "created" | "updated"
# Safe retry of a single sync:
Equipoise.client.contacts.sync(
external_id: user.id, email: user.email, idempotency_key: request_id
)
# Batch backfill — chunks to batch_size (default 500), one bad item never fails it.
# It never raises mid-batch: per-item rejections and chunk-level transport
# failures both come back as failed items, so earlier successes are preserved.
result = Equipoise.client.contacts.batch_sync(
users.map { |u| { external_id: u.id, email: u.email } }
)
result.failed.each { |item| log(item[:external_id], item[:errors]) }
# Deactivate when someone leaves the producer (Contact is retained):
Equipoise.client.contacts.deactivate(external_id: user.id)
# Read back:
Equipoise.client.contacts.find(external_id: user.id)
# Record an external event on the contact's timeline:
Equipoise.client.contacts.record_activity(external_id: user.id, event_type: "completed_course", label: "Intro to Ruby")
# "subscribed"/"unsubscribed" toggle the contact's email subscription + log it:
Equipoise.client.contacts.unsubscribe(external_id: user.id)
Equipoise.client.contacts.subscribe(external_id: user.id)
Errors
Everything inherits from Equipoise::Error:
begin
Equipoise.client.contacts.sync(external_id: u.id, email: u.email)
rescue Equipoise::ValidationError => e
e.details # field-level errors
rescue Equipoise::RateLimitError => e
sleep(e.retry_after || 1) # honor Retry-After (nil if the server sent none)
rescue Equipoise::AuthenticationError, Equipoise::ForbiddenError => e
# bad/expired key, missing scope, or source-pinned mismatch
end
Retry taxonomy. Every error answers #retryable? (true for 429/5xx and
ConnectionError, false for 4xx), so a background job is trivially correct
without memorizing status codes:
class EquipoiseSyncJob < ApplicationJob
def perform(user) = Equipoise.client.contacts.sync(**user.equipoise_payload)
rescue Equipoise::Error => e
raise if e.retryable? # let the queue back off; permanent errors just discard
Rails.logger.warn("[equipoise] dropped: #{e.}")
end
Custom fields
Declare the typed fields your source owns (upsert by key); values then ride
along on a sync via custom_fields::
Equipoise.client.custom_fields.declare(
key: "tier", field_type: "select", options: { choices: %w[gold silver] }, source: "AMP"
)
Equipoise.client.contacts.sync(external_id: user.id, email: user.email, custom_fields: { tier: "gold" })
Rails models (optional)
class User < ApplicationRecord
include Equipoise::Syncable
def equipoise_external_id = id.to_s
def equipoise_contact_attributes
{ email:, name: full_name, tags: ["AMP"] }
end
end
The default equipoise_sync! posts inline on after_commit (create/update).
In production, override it to enqueue a job so a web request never blocks on the
round-trip. In development, ensure the queue runs inline or the post never fires.
Call deactivate_from_equipoise from your soft-delete path.
Testing (in a producer app)
Disable the gem in your test environment so a stray EQUIPOISE_API_KEY can never
make a live call — disabled calls short-circuit to a no-op before any network or
validation:
# spec/rails_helper.rb (or test env)
Equipoise.disable!
Safety guards
- Environment/key mismatch —
validate!refuses aneq_test_key aimed at the production host, and aneq_live_key aimed at localhost. (Staging / custom-domain hosts are not flagged.) - Cleartext — refuses an
http://base_url to any non-loopback host unlessallow_insecure_http = true.
Development
The spec suite is DB-less and Rails-less — it runs against a real loopback HTTP
mock server (spec/support/mock_server.rb), which is what proves use_ssl is
derived from the URL scheme rather than hardcoded.
bundle install
bundle exec rspec