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.message}")
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 mismatchvalidate! refuses an eq_test_ key aimed at the production host, and an eq_live_ key aimed at localhost. (Staging / custom-domain hosts are not flagged.)
  • Cleartext — refuses an http:// base_url to any non-loopback host unless allow_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