Package overview
Overview
Synchronises records between your CRM and your outbound dialer. Real-time edits show up in the dialer within seconds. Bulk imports are buffered to a queue and drained at the dialer's rate limit, so they don't slow real-time traffic.
The integration handles the things that usually break this kind of pipeline: duplicate records when the source retries, queue backlogs during outages, dialer rate limits during large imports, and worker restarts mid-processing.
Currently supported pairs
| CRM | Dialer | Setup guide |
|---|---|---|
| Salesforce | Five9 | https://docs.quickflo.app/solutions/list-sync/salesforce/ |
The rest of this README documents the Salesforce → Five9 integration. If you need a different pair, contact your QuickFlo account team.
What it does
- Real-time edits in Salesforce land in the matching Five9 list within seconds.
- Bulk imports (Data Loader, mass edits) flow through a separate queue at Five9's bulk rate limit. They don't slow real-time traffic.
- Duplicates from CRM-side retries are recognised and skipped.
- Records persist in their queue before the webhook returns 200. Worker restarts and network blips don't lose them.
- Pause is non-destructive. The CRM keeps sending while paused; records queue; everything drains on unpause.
- Dry-run mode runs the whole flow except the dialer call. Useful for validating field mappings or replaying a record.
- Failed records carry a typed reason code. Re-queue them in bulk after fixing the root cause.
- Pre-built dashboards for queue health, throughput, and error breakdown ship with the package.
Quick start
Setting up Salesforce → Five9 takes about 30 minutes if you have your Five9 admin credentials and field mappings ready.
- Create a Five9 connection in QuickFlo named
five9. The credentials must be from a Five9 user with:- The Admin role, with User can use administrator services enabled
- Contact upload permission
- List upload permission
- Import this list-sync package into QuickFlo. Workflows, data stores, dashboards, and the webhook trigger all come in together.
- Install the QuickFlo Apex package in Salesforce. Setup at https://docs.quickflo.app/solutions/list-sync/salesforce/.
- Seed your settings and field mappings. Default templates are included; see the Settings and Field mappings sections below.
- Copy the webhook URL from the trigger's detail page. Configure your Salesforce Apex package to use it.
Test with dryRun: true first: edit a Salesforce record and watch for an entry in list_sync_logs. When that works, flip dryRun: false, edit another record, and verify it appears in the Five9 list.
For bulk: run a small Data Loader test (10 to 50 records) and watch list_sync_bulk_chunks for incoming chunks.
Day-to-day use
Three operator controls, all from the QuickFlo UI.
Pause
Set paused: true in settings to stop all processing. The webhook keeps accepting records; they just queue up until you unpause.
For finer control: pausedTrickle: true stops only real-time, pausedBulk: true stops only bulk. Useful for Five9 maintenance or a credential rotation that only affects one path.
Dry-run
Set dryRun: true to run everything end-to-end except the actual Five9 call. Useful for testing field mappings, replaying a problematic record, or validating routing before going live.
Retry failed records
The salesforce-five9-list-sync-retry-failed workflow re-queues every record currently in the failed state back to pending, up to a limit. Run it from the QuickFlo UI after you've fixed the root cause (rotated credentials, corrected a mapping, etc.) and the dispatcher will re-process them on its next cycle.
Scoped re-queue by
errorKind(e.g. onlyFIVE9_INVALID_ACCOUNT) or bynewerThana timestamp is a planned enhancement — the current workflow re-queues all failed records.
Settings
The list_sync_settings row with key "five9" controls operational behavior. The package ships with sensible defaults; the tables below describe what each knob does.
Master controls
| Field | Default | Purpose |
|---|---|---|
paused |
false |
Stop all processing |
pausedTrickle |
false |
Stop only real-time processing |
pausedBulk |
false |
Stop only bulk processing |
dryRun |
false |
Run everything except the actual Five9 call |
idempotencyTtlSeconds |
604800 (7 days) |
How long to remember a record's event ID for dedup |
Real-time path
| Field | Default | Purpose |
|---|---|---|
maxAttempts |
3 |
Retries per record before marking it failed |
dispatcherBatchLimit |
25 |
Records processed per dispatcher cycle |
retryBaseBackoffSeconds |
30 |
Wait between retries |
trickle.debounceMs |
3000 |
Collapses the near-simultaneous Salesforce INSERT+UPDATE burst into one Five9 call. Events for the same record land on one queue row (newest wins); the row isn't claimable until this much quiet has passed. Adds up to debounceMs of latency to every real-time sync — set it above your observed INSERT→UPDATE gap but low enough to still feel real-time. |
Bulk path
| Field | Default | Purpose |
|---|---|---|
bulk.maxAttempts |
3 |
Retries per chunk before marking it failed |
bulk.dispatcherBatchLimit |
10 |
Chunks processed per dispatcher cycle |
bulk.retryBaseBackoffSeconds |
60 |
Wait between retries |
Coalescing window (bulk.coalesce)
For Bulk delivery, many small chunks that arrive within a time window are coalesced into the fewest possible 50k-record CSV uploads by the flush-window workflow. These knobs tune that behavior.
| Field | Default | Purpose |
|---|---|---|
maxWaitSeconds |
30 |
How long a window stays open collecting chunks before it flushes. Higher = better coalescing (fewer Five9 calls), more delay before upload. |
maxChunksPerFlush |
500 |
Chunks claimed per flush. Bounds memory per flush execution (peak ≈ maxChunksPerFlush × avg records/chunk × fan-out ratio). A backlog beyond this drains via re-kick plus the recovery sweep. |
maxUploadRecords |
50000 |
Five9's hard per-call cap. The merged, post-fan-out payload is blind-sliced into groups no larger than this. Don't raise above 50000. |
uploadConcurrency |
4 |
How many slices upload concurrently through the shared rate limiter, to keep the UPLOADING token bucket saturated. Currently hardcoded to 4 inside flush-window; this setting is forward-looking until numeric-template coercion is confirmed. |
Five9 API pacing
The modes object controls how aggressively the integration calls Five9.
| Method | Default | Notes |
|---|---|---|
UPLOAD_SINGLE (real-time) |
0 (no pacing) |
Inline retries handle transient throttles. Set to ~500 ms only if you see sustained throttling (Five9 allows ~7200 single-record calls/hour). |
UPLOADING (bulk) |
3000 ms (1 call / 3 sec = 20 / min) |
Burst-paced for low latency. Five9 allows 20 uploads/min in a burst but only ~400/hour sustained, so a bulk that keeps uploading for more than ~20 minutes will hit the hourly cap at this setting. Bump to 9000 ms (1 call / 9 sec ≈ 400/hour) for long-running bulks that need to pace safely and indefinitely. Each upload carries up to 50k records, so the limit you hit is calls-per-hour, not record count. |
For the full math on rate limits and throughput, see Solution Limits.
Five9 sync modes
These control the Five9 list-update semantics applied to every upload (Five9's CRM add/update and list add/delete modes). The defaults match the common "keep the Five9 list in step with the CRM" case; change them only if you understand the corresponding Five9 behavior.
| Field | Default | Purpose |
|---|---|---|
crmAddMode |
ADD_NEW |
How records new to the CRM are added to Five9 |
crmUpdateMode |
UPDATE_ALL |
How existing records are updated in Five9 |
listAddMode |
ADD_ALL |
How records are added to the target Five9 list |
listDeleteMode |
DELETE_ALL |
How deletes propagate to the Five9 list |
Field mappings
The list_sync_field_mappings table holds one row per Salesforce object you sync. Add one for Lead, Contact, and any custom objects you want to push into Five9. A starter set is in data-stores/list_sync_field_mappings.json.
A Lead, organized by campaign, routes to a different Five9 list per campaign:
{
"key": "Lead",
"value": {
"fieldMappings": {
"fields": {
"FirstName": "first_name",
"LastName": "last_name",
"Phone": "number1",
"Email": "email",
},
"primaryKeys": ["number1"],
"callNowField": "",
"callTimeField": "",
},
"listNameByCampaign": {
"Spring Outreach 2026": "spring_outbound_leads",
"Fall Re-engagement": "fall_reengagement_leads",
},
"listName": "unsorted_leads", // fallback for any campaign not listed above
},
}
A Contact, not campaign-organized, just goes to one list:
{
"key": "Contact",
"value": {
"fieldMappings": {
"fields": {
"FirstName": "first_name",
"LastName": "last_name",
"Phone": "number1",
},
"primaryKeys": ["number1"],
},
"listName": "contacts_master",
},
}
fieldsmaps Salesforce field names to Five9 column names. All Five9-relevant fields go here.primaryKeysidentifies records in Five9 (used by thecrmAddMode/crmUpdateModematching described under Five9 sync modes).
Dialing records immediately (call now / call time)
Five9 reserves two list columns, call_now and call_time, for immediate-dial scheduling. They aren't regular fields, so Five9 rejects an upload that contains either column unless it's properly enabled. The mapping controls this per object:
callNowMode— Five9 enum, default"NONE". Set toNEW_CRM_ONLY,NEW_LIST_ONLY, orANYto dial matching records as they're added. While it's"NONE", thecall_nowcolumn is dropped from the upload automatically, so a leftover mapping to it never breaks the sync.callNowField— the column that flags call-now (map a Salesforce field tocall_nowto provide the per-record value). Sent only whencallNowModeis not"NONE".callTimeField— the column carrying a scheduled call time (map a Salesforce field tocall_time). When this is empty, thecall_timecolumn is dropped from the upload for the same reason.
So you enable the feature by setting callNowMode (and/or callTimeField); leave them at their defaults and the reserved columns simply never reach Five9. This applies on both paths — bulk drops the columns from the CSV, real-time strips them from the record.
Call-now ON — dial new records as they're added:
{
"key": "Lead",
"value": {
"fieldMappings": {
"fields": {
"FirstName": "first_name",
"LastName": "last_name",
"Phone": "number1",
"DialImmediately": "call_now", // Salesforce field -> the reserved call_now column
},
"primaryKeys": ["number1"],
"callNowField": "call_now", // designate which column is the call-now flag
"callTimeField": "",
"callNowMode": "ANY", // ON. NONE = off; NEW_CRM_ONLY / NEW_LIST_ONLY also valid
},
"listName": "hot_leads",
},
}
Call-now OFF — set callNowMode back to "NONE" (the default). The call_now column is then dropped from every upload automatically, even if a call_now field mapping is left in place — so flipping the switch is the only change needed. Same for call-time: a non-empty callTimeField turns it on, empty turns it off.
Which Five9 list a record lands in
The list name is resolved in priority order, so each object type just populates the layer that fits it. All of this is configured per object row in list_sync_field_mappings (not in list_sync_settings):
_list_name_overrideon the record itself — a Five9 column. Set it by mapping a Salesforce field to_list_name_overridein that object'sfieldMappings.fields, or by writing it in the record-builder extension point. Highest priority; lets one record target a specific list. Real-time only (bulk groups records into one CSV per list).listNameByCampaign[campaign]— thelistNameByCampaignobject on the object's row inlist_sync_field_mappings(a sibling offieldMappings). Routes by Salesforce campaign membership; populate this for campaign-organized objects likeLead.listName— thelistNamestring on the object's row inlist_sync_field_mappings(also a sibling offieldMappings). The object-type default, used when there's no campaign match (or no campaign map at all). This is the simple "one list per object" case, likeContact.- Campaign name — the last-resort fallback if none of the above resolve. Automatic; nothing to configure.
So every layer except the per-record override is just a field on that object's row in list_sync_field_mappings — the same rows the Field mappings examples above show. The same chain runs on both the real-time and bulk paths (bulk skips step 1), so a record routes the same way no matter which path delivers it.
Customizing records (and splitting one record into many)
Most syncs map one Salesforce record to one Five9 record. When you need more control, the package ships a single extension point: the salesforce-five9-list-sync-record-builder workflow. Edit it once and it applies to both the real-time and bulk paths.
It receives an array of { mapped, raw } pairs and returns the array of Five9 records to send:
mappedis the record after field mapping (Five9 column names). Your starting point.rawis the original Salesforce record, including fields that were not mapped. Use it to reach values like extra phone columns.
Return one record per input for a 1:1 reshape, or return more to fan out. Every record you return from one source record is sent to that source record's list.
Example: split a lead's phone fields into separate Five9 records
A Salesforce lead often carries several phone numbers (Phone, MobilePhone, and so on). To dial each as its own Five9 record, open the record-builder workflow and use the phone-split example already included there:
const PHONES = [
'Phone',
'MobilePhone',
'OtherPhone',
'HomePhone',
'AssistantPhone',
];
return {
records: initial.records.flatMap((r) =>
PHONES.map((field) => r.raw[field])
.filter(Boolean)
.map((number1) => ({ ...r.mapped, number1 })),
),
};
Good to know:
- The default is a 1:1 passthrough, so leaving the record-builder untouched preserves expected behavior.
- All records produced from one lead go to the same Five9 list. Routing split records to different lists is not supported.
- A split lead is deleted the same way it was added: a delete fans out across the same records, so nothing is orphaned.
- Dry-run still covers the whole flow, including the fan-out, without calling Five9.
- On the bulk path, the CSV columns come from your field mapping, so introducing a brand-new Five9 column through the record-builder takes effect on the real-time path; on the bulk path, keep to the mapped columns.
Monitoring
The package includes pre-built dashboards covering queue health, throughput, error breakdown, and recent activity. You can see what's pending, what's failed, and why, without writing queries.
Troubleshooting
Records aren't reaching Five9 at all. Check that paused and the path-specific pause flag are both false. Verify the Five9 connection is healthy. Check the webhook trigger's recent invocations to confirm Salesforce is reaching it.
Records failing with FIVE9_INVALID_ACCOUNT or FIVE9_AUTH_FAILED. Five9 credentials need rotation or the connection needs updating. Once fixed, run retry-failed to re-queue the failed records.
Records failing with FIVE9_INVALID_ARGUMENT or FIVE9_NOT_FOUND. Field mapping or list name issue. Check list_sync_field_mappings for the object type. Verify the Five9 list exists with the expected name and that the mapped columns exist on that list.
Records failing with FIVE9_RATE_LIMITED during large bulks. Your bulk volume is hitting Five9's hourly cap. Three options: bump modes.UPLOADING.rateLimitIntervalMs from 3000 to 9000 for smoother pacing, ask Five9 support to raise the rate limit on your tenant, or spread the bulk across a longer window. The retry-failed workflow can clean up the failed chunks once you've adjusted.
Bulk chunks stuck in in_flight status. A worker crashed during processing. Run the recovery workflow to reset them to pending. The dispatcher will pick them up on its next cycle.
How it works (for the curious)
You don't need to know any of this to use the integration. It's here for engineers who want to understand the design.
There are two paths into the integration:
Real-time path. Someone edits one record in Salesforce. Salesforce posts that one record to QuickFlo. QuickFlo writes it to a durable queue, returns 200 to Salesforce, and a background worker drains the queue by calling Five9's per-record API.
Bulk path. Salesforce sends a chunk of records (typically 200 from a Data Loader operation). QuickFlo writes the chunk to a separate durable queue, returns 200, and opens (or joins) a short coalescing window for that destination. When the window flushes, a background worker claims every buffered chunk for that window at once, merges them, slices the result into Five9's 50k-record CSV limit, and uploads the slices concurrently through a shared rate limiter. Coalescing many small deliveries into the fewest possible uploads is what keeps a steady stream of Data Loader chunks from exhausting Five9's hourly upload cap.
The two paths use independent Five9 API quotas, so a large bulk import never slows down real-time edits.
Both paths use the same primitives for reliability:
- Idempotency: each record carries a unique event ID; if Salesforce retries, the integration recognizes the duplicate and skips it.
- Atomic claim: workers grab records from the queue atomically, so two workers can run in parallel without claiming the same record.
- Durable persistence: records are in the queue before the webhook returns 200, so a worker crash mid-processing leaves them recoverable.
- Error classification: Five9 errors are mapped to a known taxonomy (rate limited, auth failed, invalid input, etc.) so the right thing happens automatically (retry transient errors, halt immediately on permanent ones).
For the full architecture writeup including state diagrams and durability guarantees, see Architecture deep-dive.
What's in this package
Workflows
| Workflow | What it does |
|---|---|
salesforce-five9-list-sync-webhook |
Receives all traffic from Salesforce |
salesforce-five9-list-sync-dispatcher |
Processes the real-time queue |
salesforce-five9-process-record |
Handles each individual real-time record |
salesforce-five9-list-sync-bulk-ingress |
Accepts bulk chunks from Salesforce; opens a coalescing window and schedules its flush |
salesforce-five9-list-sync-flush-window |
Drains one coalescing window: claims its buffered chunks, fans out, blind-slices to Five9's 50k cap, uploads slices concurrently through the shared rate limiter, finalizes each source chunk. Replaces the old bulk-dispatcher + process-chunk pair (1 chunk = 1 upload) |
salesforce-five9-list-sync-record-builder |
Extension point: builds and optionally fans out the Five9 records per source record (both paths) |
salesforce-five9-list-sync-recovery |
One-button heal-everything-stuck: re-queues crashed trickle + bulk work and re-dispatches orphaned windows |
salesforce-five9-list-sync-retry-failed |
Re-queues records that failed terminally |
Setup utilities
Run manually, not part of the sync path:
| Workflow | What it does |
|---|---|
list-sync-ingest-contact-fields |
Pulls your Five9 contact-field schema and caches the valid column names into list_sync_field_mappings (key Five9). Run once during setup so you know which Five9 columns to map to. |
list-sync-get-list-contacts |
Runs Five9's "List Details" report for one or more named lists and returns their current contents as rows. Use it to verify or reconcile what's actually in a Five9 list. |
Data stores
| Table | Contents |
|---|---|
list_sync_settings |
Your operational config |
list_sync_field_mappings |
Salesforce-to-Five9 field mappings |
list_sync_queue |
Records in the real-time queue |
list_sync_bulk_chunks |
Chunks in the bulk queue |
list_sync_bulk_windows |
Coalescing window markers (one open row per active window) |
list_sync_idempotency |
Dedup keys (TTL'd) |
list_sync_rate_limits |
Rate-limit pacing state |
list_sync_logs |
Audit trail of every sync attempt |
Dashboards
- Queue health (pending, in-flight, done, failed counts)
- Throughput (records synced per hour, by path)
- Error breakdown (failures grouped by reason)
- Recent activity (latest sync results)
Webhook trigger
One webhook URL. The Salesforce-side package posts every payload to it.
Reference
Salesforce → Five9
- Salesforce-side setup: https://docs.quickflo.app/solutions/list-sync/salesforce/
Other CRM/dialer pairs
Not yet supported. Contact your QuickFlo account team to scope a new pair.