Rails-first, idempotent synchronisation between your ActiveRecord models and your CRM. HubSpot is supported out of the box; other CRMs can be plugged in via adapters.
This gem has been designed by Capsens, a fintech web and mobile agency based in Paris.
In internal products, it is common to persist domain data in Rails while also mirroring a subset of it into a CRM for marketing, sales or support workflows. Etlify provides a small, dependable toolkit to declare which models are CRM-backed, serialise them into CRM payloads, and synchronise them in an idempotent fashion so repeated calls are safe and efficient.
Etlify sits beside your app; it does not try to own your domain or background processing. It integrates naturally with ActiveRecord and ActiveJob so you keep your current architecture and simply “switch on” CRM sync where you need it.
| Area | What you get | Why it helps |
|---|---|---|
| DSL | include Etlify::Model + etlified_with(...) on your models |
Opt-in sync with a single line; clear, local intent |
| Serialisers | A base class to turn a model into a CRM payload | Keeps mapping logic where it belongs; easy to test |
| Adapters | HubSpot, Airtable & Intercom adapters included; plug your own | Swap CRMs without touching model code |
| Idempotence | Stable digest of the last synced payload | Avoids redundant API calls; safe to retry |
| Jobs | crm_sync! enqueues an ActiveJob; batch sync via BatchSyncJob |
Fits your queue; built-in rate limiting |
| Delete | crm_delete! to remove a record from the CRM |
Keeps both sides consistent |
- Ruby: 3.0+
- Rails: 6.1+ (ActiveRecord & ActiveJob)
- Datastore: A relational database supported by ActiveRecord for storing sync state.
- Threading/Jobs: Any ActiveJob backend (Sidekiq, Delayed Job, etc.).
These ranges reflect typical modern Rails setups. If you run older stacks, test in your environment.
Add the gem to your application:
# Gemfile
gem "etlify"Then install and run the generators:
bundle install
# Install initializer(s)
bin/rails generate etlify:install
# Install sync-state tables
bin/rails generate etlify:migration CreateCrmSynchronisations
bin/rails generate etlify:migration create_etlify_pending_syncs
bin/rails db:migrate
# Generate a serializer for a model (optional helper)
bin/rails generate etlify:serializer User
# => creates app/serializers/etlify/user_serializer.rbYou may create your own serializer class manually as long as it responds to
#new(record)and#as_crm_payload.
Create config/initializers/etlify.rb:
# config/initializers/etlify.rb
require "etlify"
Etlify.configure do |config|
Etlify::CRM.register(
:hubspot,
adapter: Etlify::Adapters::HubspotV3Adapter.new(
access_token: ENV["HUBSPOT_PRIVATE_APP_TOKEN"]
),
options: {
job_class: "Etlify::SyncObjectWorker",
max_sync_errors: 5
}
)
# will provide DSL below for models
# hubspot_etlified_with(...)
# Etlify::CRM.register(
# :another_crm, adapter: Etlify::Adapters::AnotherAdapter,
# options: { job_class: Etlify::SyncJob }
# )
# will provide DSL below for models
# another_crm_etlified_with(...)
# @digest_strategy = Etlify::Digest.method(:stable_sha256)
# @job_queue_name = "low"
endEach CRM registration accepts an enabled: flag (default true). When set to
false, Etlify behaves as if everything had worked for that CRM but does
nothing: no adapter call, no job enqueued, no write to crm_synchronisations.
This is handy in development or test environments where you do not want to
touch the real CRM.
Etlify::CRM.register(
:hubspot,
adapter: Etlify::Adapters::HubspotV3Adapter.new(
access_token: ENV["HUBSPOT_PRIVATE_APP_TOKEN"]
),
enabled: Rails.env.production? || Rails.env.staging?,
options: { job_class: Etlify::SyncJob }
)
Etlify::CRM.register(
:another_crm,
adapter: AnotherAdapter.new,
# enabled: true # default
)Behavior when a CRM is disabled:
record.hubspot_sync!andrecord.hubspot_delete!returntrue(truthy, as if everything had succeeded) without enqueuing any job.- Internal
Etlify::Synchronizer.call/Etlify::Deleter.callreturn the symbol:disabled(useful for logging and batch statistics). Etlify::StaleRecords::BatchSyncsilently skips the disabled CRM and still processes the enabled ones.
# app/models/user.rb
class User < ApplicationRecord
include Etlify::Model
has_many :investments, dependent: :destroy
hubspot_etlified_with(
serializer: UserSerializer,
crm_object_type: "contacts",
# How to find the record on the CRM side: a unique CRM property and how
# to resolve its value from the record (method symbol or proc).
match_by: {property: :email, value: :email},
# Only sync when an email exists
sync_if: ->(user) { user.email.present? },
# useful if your object serialization includes dependencies
dependencies: [:investments],
# buffer sync until these associations have a crm_id
sync_dependencies: [:users_profile]
)
endmatch_by fully decouples matching (how Etlify finds the CRM record)
from the payload (what the serializer syncs):
property: the unique CRM property used to match (e.g.:emailfor HubSpot contacts, a merge field for Airtable).value: how to resolve the value from the record — a method name Symbol (value: :email) or a Proc (value: ->(user) { user.email }).
The serializer payload is synced as-is. The matching property is only written at creation time (when the payload does not already include it). It is never written on an existing record unless the serializer explicitly includes it in the payload. This gives you the choice:
- Keep the property in the serializer → it is synced like any other field.
- Leave it out of the serializer → it is only used to match, and only set
at creation. For HubSpot contacts matched by email, this prevents
overwriting a contact's primary email when the platform email is one of
its secondary emails (
hs_additional_emails).
Once a record has a crm_id, updates go through it directly and the
matching value is not needed anymore (it may resolve blank). When a record
has no crm_id yet and its matching value resolves blank, the sync
fails explicitly (:error + error_count bump) instead of guessing.
By default, the StaleRecords::Finder scans all records of an etlified model.
If only a subset of records should ever be synced (e.g. only marketplace operations),
you can pass a stale_scope lambda that returns an ActiveRecord scope:
class Trading::Operation < ApplicationRecord
include Etlify::Model
scope :marketplace, -> { where(category: "marketplace") }
hubspot_etlified_with(
serializer: TradingOperationSerializer,
crm_object_type: "deals",
match_by: {property: :id, value: :id},
sync_if: ->(op) { op.marketplace? },
stale_scope: -> { marketplace }
)
endWhen a record fails to sync repeatedly (CRM misconfigured, server error, etc.),
Etlify increments an error_count on its CrmSynchronisation row. Once the
count reaches the configured limit, the record is excluded from
StaleRecords::Finder automatic retries.
The default limit is 3. You can change it globally or per CRM:
# Global default (config/initializers/etlify.rb)
Etlify.configure do |config|
config.max_sync_errors = 10
end
# Per-CRM override (takes precedence over global)
Etlify::CRM.register(
:hubspot,
adapter: Etlify::Adapters::HubspotV3Adapter.new(
access_token: ENV["HUBSPOT_PRIVATE_APP_TOKEN"]
),
options: { max_sync_errors: 5 }
)To re-enable sync after fixing the root cause:
sync_line = CrmSynchronisation.find_by(
resource: user, crm_name: "hubspot"
)
sync_line.reset_error_count!Upgrading? Run
rails g etlify:add_error_count && rails db:migrateto add theerror_countcolumn.
When a model's CRM payload references another model's crm_id (e.g. an Airtable record ID), that dependency must be synced first. The sync_dependencies option handles this automatically:
class Trading::Operation < ApplicationRecord
include Etlify::Model
has_one :buyer_profile, through: :buyer, source: :profile
airtable_etlified_with(
serializer: TradingOperationSerializer,
crm_object_type: "deals",
match_by: {property: :id, value: :id},
sync_dependencies: [:buyer_profile]
)
endHow it works:
- Before syncing, Etlify checks each
sync_dependencyassociation for acrm_id. It first looks in theCrmSynchronisationtable (etlified models), then falls back to a direct#{crm_name}_idcolumn on the model (legacy models, e.g.airtable_id). - If any dependency is missing a
crm_id, the sync is buffered: anEtlify::PendingSyncrow is created and the dependency is enqueued for sync. The method returns:buffered. - Once the dependency is successfully synced (
:synced), Etlify flushes all its pending dependents by re-enqueuing them viacrm_sync!.
Note: This requires the
etlify_pending_syncstable. Runrails g etlify:migration create_etlify_pending_syncs && rails db:migrateif you haven't already.
# app/serializers/etlify/user_serializer.rb
class UserSerializer
attr_accessor :user
# your serializer must implement #intiialize(object) #and as_crm_payload
def initialize(user)
@user = user
end
# Must return a Hash that matches your CRM field names
def as_crm_payload # or #to_h
{
email: user.email,
firstname: user.first_name,
lastname: user.last_name
}
end
enduser = User.find(1)
# Async by default (enqueues an Etlify::SyncJob by default)
# The job class can be overriden when registering the CRM
user.hubspot_crm_sync! # or user.#{registered_crm_name}_crm_sync!
# Run inline (no job)
user.crm_sync!(async: false)# Inline delete (not enqueued)
user.hubspot_crm_delete! # or user.#{registered_crm_name}_crm_delete!# app/serializers/etlify/company_serializer.rb
class CompanySerializer
attr_accessor :company
def initialize(company)
@company = company
end
# Keep serialisation small and predictable
def as_crm_payload
{
name: company.name,
domain: company.domain,
hs_lead_status: company.lead_status
}
end
endBeyond single-record sync, Etlify provides a batch resynchronisation API that targets all “stale” records (those whose data has changed since the last CRM sync). This is useful for:
- recovering from CRM or worker outages,
- triggering periodic re-syncs (cron jobs),
- testing/debugging your serialization logic on a controlled dataset.
# Enqueue (default): one BatchSyncJob per CRM and per batch_size slice
Etlify::StaleRecords::BatchSync.call
# Restrict to specific models
Etlify::StaleRecords::BatchSync.call(models: [User, Company])
# Restrict to a specific CRM
Etlify::StaleRecords::BatchSync.call(crm_name: :hubspot)
# Or both
Etlify::StaleRecords::BatchSync.call(
crm_name: :hubspot,
models: [User, Company]
)
# Run inline (no jobs), useful for scripts/maintenance or testing
Etlify::StaleRecords::BatchSync.call(async: false)
# Adjust SQL batch size (number of IDs per batch)
Etlify::StaleRecords::BatchSync.call(batch_size: 1_000)Return value The method returns a stats Hash:
{
total: Integer, # number of records processed (or counted)
per_model: { “User” => 42, ...}, # per-model breakdown
errors: Integer # number of errors in async:false mode
}Etlify::StaleRecords::Finderscans all etlified models (those that called#{crm_name}_etlified_with) and builds, for each, a SQL relation selecting only the PKs of stale records.- A record is considered stale if:
- it has no
crm_synchronisationrow, or - its
last_synced_atis older than the maxupdated_atamong:- its own row,
- and its declared dependencies (via
dependencies:inetlified_with, supportingbelongs_to,has_one,has_many,has_* :through, and polymorphicbelongs_to).
- it has no
Etlify::StaleRecords::BatchSyncthen iterates by ID batches:- in async: true mode (default): collects all stale record IDs and
enqueues one
BatchSyncJobper CRM and perbatch_sizeslice of pairs. Each job processes its own slice, respecting the configured rate limit, so a failure (e.g. aNet::ReadTimeout) is bounded to one slice instead of the full stale population.stats[:total]counts what was discovered; chunks dropped by the enqueue-time dedup lock are reported instats[:skipped_chunks]; - in async: false mode: load each record and pass it to
Etlify::Synchronizer.call(record)inline (errors are logged and counted without interrupting the batch).
- in async: true mode (default): collects all stale record IDs and
enqueues one
Individual
model.crm_sync!calls still useSyncJob(one job per record) for immediate, on-demand sync.BatchSyncJobis used only byBatchSync.
CRM APIs enforce rate limits (e.g. HubSpot: ~100 requests/10s, Airtable: 5 requests/s). Etlify provides built-in rate limiting at the adapter level (per HTTP request), so multi-request operations like search + upsert are correctly throttled.
Etlify::CRM.register(
:hubspot,
adapter: Etlify::Adapters::HubspotV3Adapter.new(
access_token: ENV[“HUBSPOT_PRIVATE_APP_TOKEN”]
),
options: {
rate_limit: { max_requests: 100, period: 10.seconds },
max_sync_errors: 5,
}
)max_requests: maximum number of HTTP requests allowed in the period.period: time window in seconds.
When rate_limit is not configured, no throttling is applied (current behaviour preserved).
- At
CRM.registertime, ifrate_limitis configured and the adapter supportsrate_limiter=, aRateLimiteris permanently installed on the adapter. - Every HTTP request in the adapter calls
rate_limiter.throttle!, which sleeps the minimum necessary time to stay within the rate limit. - All sync paths are throttled:
BatchSyncJob, individualSyncJob, inlinecrm_sync!(async: false), and pending sync flushes — they all go through the same adapter. - If the CRM returns a 429 (Rate Limited) response despite throttling,
BatchSyncJobre-enqueues itself with the remaining records after a backoff delay (default: 10 seconds). - A cache-based lock deduplicates
BatchSyncJobenqueues: discovery runs (no explicit pairs, e.g. cron-triggered) are limited to one per CRM at a time, while chunk jobs (explicit pairs) are locked per content — independent chunks for the same CRM run in parallel, but identical enqueues (includingRateLimitedre-enqueues) are deduplicated. The dedup requires a cache store shared across processes (e.g. Redis or Memcached): with a per-processMemoryStore, jobs enqueued from different processes are not deduplicated.
To support rate limiting in a custom adapter, add a rate_limiter= accessor and call @rate_limiter&.throttle! before each HTTP request:
class MyCrmAdapter
attr_accessor :rate_limiter
private
def request(method, path, body: nil)
@rate_limiter&.throttle!
# ... perform HTTP request
end
end- Production: prefer
async: truewith a dedicated, low-priority queue via ActiveJob. - Rate limits: configure
rate_limitper CRM to avoid 429 errors and letBatchSyncJobhandle throttling automatically. - Stable payloads: ensure your serializers produce deterministic Hashes to benefit from idempotence.
- Dependencies: declare
dependencies:accurately inetlified_withso indirect changes trigger resyncs. - Batch size: adjust
batch_sizeto your DB to balance throughput and memory.
- Before sending anything to the CRM, Etlify builds the payload via your serializer and computes a stable digest (SHA-256 by default) of that payload.
- Etlify stores the last successful digest alongside the CRM ID for that record in your application database.
- On subsequent syncs, if the new digest equals the last stored digest, Etlify skips the remote call and returns
:not_modified. - If the digest differs, Etlify upserts the record remotely and updates the stored digest.
You can customise the digest strategy:
Etlify.config.digest_strategy = lambda do |payload|
# Always use deterministic JSON generation for hashing
Digest::SHA256.hexdigest(JSON.dump(payload))
endTip: Keep your serializer output stable (e.g. avoid unordered hashes or volatile timestamps) so that digests are meaningful.
Etlify ships with Etlify::Adapters::HubspotV3Adapter. It supports native objects (e.g. contacts, companies, deals) and custom objects by API name.
Etlify.configure do |config|
Etlify::CRM.register(
:hubspot,
adapter: Etlify::Adapters::HubspotV3Adapter.new(
access_token: ENV["HUBSPOT_PRIVATE_APP_TOKEN"]
),
options: {job_class: "Etlify::SyncObjectWorker"}
)
endobject_type: the target entity, e.g."contacts","companies","deals", or the API name of a custom object.match_property/match_value(mandatory): the unique property and value used to find an existing record (e.g."email"for contacts). For contacts matched by email, the search also covers secondary emails (hs_additional_emails).- If no match is found, the adapter creates a new record, setting
match_propertyfrommatch_valuewhen the payload does not already carry it. - The payload is synced as-is: the matching property is never written on an existing record unless the payload explicitly includes it (this prevents overwriting a contact's primary email when matched through a secondary email).
crm_id: if known, the search is skipped entirely and the record is updated directly.
class User < ApplicationRecord
include Etlify::Model
hubspot_etlified_with(
serializer: UserSerializer,
crm_object_type: "contacts",
match_by: {property: :email, value: :email},
sync_if: ->(user) { user.email.present? }
)
end
# Later
user.hubspot_crm_sync! # Adapter performs an upsertclass Subscription < ApplicationRecord
include Etlify::Model
hubspot_etlified_with(
serializer: SubscriptionSerializer,
crm_object_type: "p1234567_subscription", # Custom object API name
match_by: {property: :id, value: :id}
)
endThe HubSpot adapter provides two additional methods for bulk operations, using HubSpot's native batch endpoints (up to 100 inputs per request, auto-sliced):
adapter = Etlify::CRM.registry[:hubspot].adapter
# Batch upsert via HubSpot's native /batch/upsert
# Returns a Hash {input value => hs_object_id}
adapter.batch_upsert!(
object_type: "contacts",
inputs: [
{value: "a@example.com", properties: {firstname: "Alice"}},
{value: "b@example.com", properties: {firstname: "Bob"}},
],
match_property: "email"
)
# Batch delete (archive) via /batch/archive
# Returns true
adapter.batch_delete!(
object_type: "contacts",
crm_ids: ["101", "102", "103"]
)Rate limiting: HubSpot enforces rate limits per private app. Batch operations process up to 100 records per request (vs 1 for single-record calls), significantly reducing the number of API calls.
Etlify ships with Etlify::Adapters::AirtableV0Adapter. It uses Net::HTTP (no external dependency) and supports both single-record and batch operations.
Etlify.configure do |config|
Etlify::CRM.register(
:airtable,
adapter: Etlify::Adapters::AirtableV0Adapter.new(
access_token: ENV["AIRTABLE_TOKEN"],
base_id: ENV["AIRTABLE_BASE_ID"]
),
options: {
rate_limit: { max_requests: 5, period: 1.second },
}
)
endobject_type: the Airtable table ID or name (e.g."tblContacts","Contacts").match_property/match_value(mandatory): field name and value used to search for existing records viafilterByFormula. If a match is found, the record is updated with the payload as-is; otherwise a new record is created with the match field injected when the payload does not carry it.crm_id: if provided (e.g."recXXXXXXXX"), the adapter skips the search and updates the record directly.
class User < ApplicationRecord
include Etlify::Model
airtable_etlified_with(
serializer: UserSerializer,
crm_object_type: "tblContacts",
match_by: {property: :Email, value: :email},
sync_if: ->(user) { user.email.present? }
)
end
# Later
user.airtable_crm_sync!The Airtable adapter provides two additional methods for bulk operations, using Airtable's native batch endpoints (up to 10 records per request, auto-sliced):
adapter = Etlify::CRM.registry[:airtable].adapter
# Batch upsert via Airtable's native performUpsert
# Returns a Hash {input value => record_id}
adapter.batch_upsert!(
object_type: "tblContacts",
inputs: [
{value: "a@example.com", properties: {Name: "Alice"}},
{value: "b@example.com", properties: {Name: "Bob"}},
],
match_property: "Email"
)
# Batch delete
adapter.batch_delete!(
object_type: "tblContacts",
crm_ids: ["recAAA", "recBBB", "recCCC"]
)Rate limiting: Airtable enforces 5 requests/second/base. Batch operations process up to 10 records per request (vs 1 for single-record calls), increasing effective throughput to 50 records/second.
Etlify ships with Etlify::Adapters::IntercomAdapter. It uses Net::HTTP (no external dependency) and targets the Intercom REST API. The adapter is generic on object_type: the value declared in crm_object_type: is interpolated directly in the URL, so the same adapter handles "contacts", "companies", and any other Intercom resource that exposes the standard POST /{type}, PUT /{type}/{id}, DELETE /{type}/{id}, POST /{type}/search quadruplet.
Etlify.configure do |config|
Etlify::CRM.register(
:intercom,
adapter: Etlify::Adapters::IntercomAdapter.new(
access_token: ENV["INTERCOM_ACCESS_TOKEN"],
region: :eu, # :us (default), :eu, :au
api_version: "2.14" # default
),
options: {
rate_limit: { max_requests: 1_000, period: 10.seconds },
}
)
endobject_type: the Intercom resource (e.g."contacts","companies").match_by: the matching property (e.g."external_id"for contacts,"company_id"for companies) is used to find the record viaPOST /{object_type}/searchwith the query{field:, operator: "=", value:}. The payload is synced as-is: the matching property is only written at creation time (when the payload does not already carry it) and never on an existing record.crm_id: if known (already persisted incrm_synchronisations), the adapter skips the search andPUTs directly on/{object_type}/{crm_id}.- For contacts matched by
email, the matching value is lowercased before search, and a payloademailis lowercased before create/update (parity with the HubSpot adapter). - Intercom does not expose batch endpoints:
batch_upsert!,batch_update!andbatch_delete!loop sequentially over the single-record calls. The configuredrate_limitis enforced on every HTTP call, so behaviour stays correct under high volumes.
class User < ApplicationRecord
include Etlify::Model
intercom_etlified_with(
serializer: UserIntercomSerializer,
crm_object_type: "contacts",
match_by: {property: :external_id, value: ->(user) { user.id.to_s }},
sync_if: ->(user) { user.email.present? }
)
end
# Later
user.intercom_crm_sync!class Company < ApplicationRecord
include Etlify::Model
intercom_etlified_with(
serializer: CompanyIntercomSerializer,
crm_object_type: "companies",
match_by: {property: :company_id, value: ->(company) { company.id.to_s }}
)
endIntercom returns errors as {"type": "error.list", "errors": [{"code", "message", "field"}, ...]}. The adapter maps them to the standard Etlify hierarchy:
| HTTP status | Raised class |
|---|---|
| 401, 403 | Etlify::Unauthorized |
| 404 | Etlify::NotFound |
| 409, 422 | Etlify::ValidationFailed |
| 429 | Etlify::RateLimited |
| other 4xx/5xx | Etlify::ApiError |
| transport | Etlify::TransportError |
delete! returns false on 404 (already-gone) instead of raising, mirroring the HubSpot and Airtable adapters.
Implement the following interface:
module Etlify
module Adapters
class MyCrmAdapter
# Must return the remote CRM ID as a String.
# Contract:
# - When crm_id is provided, update directly (no search).
# - Otherwise, find the record through match_property/match_value.
# - Sync the payload as-is: never write match_property on an existing
# record unless the payload includes it; only set it at creation
# when the payload does not carry it.
def upsert!(object_type:, payload:, match_property:, match_value:, crm_id: nil)
# Call your CRM API to create or update
# Return the CRM id (e.g. "12345")
end
# Must return true/false
def delete!(object_type:, crm_id:)
# Call your CRM API to delete the record
# Return true when the remote says it has been removed
end
# Optional, enables the batch path of BatchSyncJob for first-time
# syncs. inputs is an Array of {value:, properties:} hashes; must
# return a Hash mapping each input's value (as provided) to the CRM id.
# def batch_upsert!(object_type:, inputs:, match_property:)
# end
# Optional, enables the batch path for records that already have a
# crm_id. records is an Array of {crm_id:, properties:} hashes; must
# return an identity Hash {crm_id => crm_id}.
# def batch_update!(object_type:, records:)
# end
end
end
endKeep your adapter stateless and pure. Pass all needed options explicitly and let your initializer construct it with credentials.
- Start small: sync only the fields you truly need in your serializer. You can add more later.
- Stable payloads: avoid non-deterministic fields (timestamps, random IDs) in the payload; they defeat idempotence.
- Guard with
sync_if: skip incomplete records (e.g. no email) to reduce noise. - Queue selection: route
SyncJobto a dedicated low-priority queue to keep UX jobs snappy.
-
Nothing seems to happen when I call
crm_sync!Ensure you ran the migration generator and migrated the database. Also verify yoursync_ifpredicate returnstrueand the serializer returns a Hash. -
My payload keeps re-syncing even when nothing changed Confirm your serializer output is stable and keys are consistently ordered/typed. If you add transient data, the digest will change on every run.
-
How do I force a refresh? Change the payload (or clear the stored digest for that record) and run
crm_sync!again. You can also add a temporary flag inside your serializer if needed. -
Where is the CRM ID stored? Etlify maintains sync state (last digest and remote ID) in your app’s database so it can skip or delete correctly.
-
Can I batch synchronise? Use
Etlify::BatchSync::StaleRecordsSyncer.call(...). Keep batches small and let your queue handle back-pressure.
- Credentials present and valid (e.g.
HUBSPOT_PRIVATE_APP_TOKEN). - Adapter set (default is a no-op NullAdapter).
- Jobs worker running (when using async).
- Serializer returns a Hash with the expected field names.
- Database table for sync state exists and is reachable.
Run the test suite:
bundle exec rspec# In your spec
fake_adapter = instance_double("Adapter")
allow(fake_adapter).to receive(:upsert!).and_return("crm_123")
allow(fake_adapter).to receive(:delete!).and_return(true)
# Override the registry for this CRM (ex: :hubspot)
Etlify::CRM.register(
:hubspot,
adapter: fake_adapter,
options: {}
)
user = create(:user, email: "someone@example.com")
# Enqueue or perform a sync for this CRM
user.hubspot_sync!(async: false)
expect(fake_adapter).to have_received(:upsert!).with(
object_type: "contacts",
payload: hash_including(email: "someone@example.com"),
match_property: anything,
match_value: anything,
crm_id: nil
)Etlify::Adapters::NullAdapter(default; no-op). It enforces the same contract as the real adapters: a blankmatch_valuewithout a knowncrm_idraisesArgumentError, so dev/test surfaces the same:errorresults as production instead of fake-syncing with a generated id.Etlify::Adapters::HubspotV3Adapter(API v3, with native batch support)Etlify::Adapters::AirtableV0Adapter(API v0, with native batch support)Etlify::Adapters::IntercomAdapter(REST API, sequential batch via single-record loop)
MIT — see LICENSE.
This library is maintained internally. Please open an issue if you need enhancements or have questions.