# Reverse ETL for FDEs: getting AI scores into Salesforce, where the sales team actually works

> To a sales team, an accurate scoring model whose output sits only in a warehouse table might as well not exist.

Original: https://fdetimes.net/en/guides/reverse-etl-ai-scores-salesforce/

You have just put a churn prediction model into production. Scores are calculated every night and written neatly to a table in the warehouse, and accuracy on the test set is solid. Two weeks later, the head of sales asks: "Where do I see that score?"

That question shows the project is only halfway done. The sales team lives in Salesforce, the support team lives in Zendesk, and nobody opens your dashboard. An AI result that has not appeared on the screens people use every day has not yet created any value for the customer.

For an FDE, this final stretch often decides whether a deployment succeeds or fails. The technique for doing it has a name: reverse ETL.

## Reverse of what?

To understand reverse ETL, look at the forward direction first. In ETL, as Snowflake describes it, data is extracted from source systems such as applications, databases or APIs, transformed, then loaded into a central store. Data flows from many places into one.

Reverse ETL is the mirror image of that flow: processed data in the central store is moved regularly back into the operational systems where people work. Typical destinations are CRMs such as Salesforce or support tools such as Zendesk, and a very familiar task is pushing customer segments from the warehouse into Salesforce.

In FDE work, the output of an AI model is exactly that "processed data". Churn scores, ticket classification labels and call summaries all need to travel this last mile.

**Key point:** AI results only have value when they sit exactly where users are working.

## A worked example, from score table to CRM field

Imagine your customer is a B2B software company with 20,000 accounts in Salesforce. Your model writes results every night to the table `ai_outputs.churn_scores`, containing an internal account ID, a score from 0 to 1, a one-sentence reason generated by an LLM and the scoring time.

The goal: three new fields appear on every account page in Salesforce, `AI_Churn_Score__c`, `AI_Churn_Reason__c` and `AI_Scored_At__c`. Sales open an account and see them immediately, without switching tabs.

The first step is to write a model, meaning a dataset defined in SQL whose structure matches the target system. Hightouch's documentation describes a model in exactly these terms: a reusable dataset written in SQL, dbt or a visual builder. This is the layer where you shape the AI output before sending it.

```sql
-- model: churn_scores_for_salesforce
select
  m.salesforce_account_id          as sf_id,
  round(s.churn_score, 2)          as ai_churn_score,
  left(s.reason, 255)              as ai_churn_reason,
  s.scored_at                      as ai_scored_at
from ai_outputs.churn_scores s
join crm_mapping.accounts m
  on s.account_id = m.internal_account_id
where s.scored_at = (select max(scored_at) from ai_outputs.churn_scores)
  and m.salesforce_account_id is not null
```

Note three small decisions in that SQL. The join with the mapping table deals with the fact that internal IDs are not Salesforce IDs. The `round` function keeps the score readable, and `left(..., 255)` trims the reason to fit the length limit of the text field.

These details sound trivial, but they are exactly what makes a sync fail at 2am.

## Why not send all 20,000 rows every night?

The second step is the sync. According to Hightouch's documentation, a sync delivers modelled data to sales, support and internal tools, on a schedule or when the data changes. The tool runs on the warehouse itself, so customer data is not copied into a separate system.

The naive approach is to send all 20,000 rows every night. Metabase points to exactly this trap: sync only the records that have actually changed, so you do not exhaust the destination system's API rate limit. Suppose only about 300 accounts each night have a score different from the day before: you send 300 updates instead of 20,000, or 1.5% of the volume.

If you build it yourself, a simple diff mechanism is to keep a snapshot table of the last sync. Use `is distinct from` rather than `<>`, because `<>` returns NULL and misses changes when either value is empty:

```sql
select c.*
from churn_scores_for_salesforce c
left join sync_state.last_sent l on c.sf_id = l.sf_id
where l.sf_id is null
   or c.ai_churn_score  is distinct from l.ai_churn_score
   or c.ai_churn_reason is distinct from l.ai_churn_reason
```

Update `last_sent` only when the CRM API returns success. Failed records then reappear automatically in the next diff instead of quietly disappearing.

## Who is allowed to edit this field?

The third step is the one most often skipped: data ownership. Airbyte notes that with two-way sync you must handle conflicts when a record is edited in both systems. Picture a salesperson who finds a score of 0.82 absurd, changes it by hand to 0.3, and then watches that night's sync overwrite it.

The safe approach is to avoid two-way sync by design. The `AI_*` fields are owned by the warehouse and set to read-only for users in the CRM. If sales want to give feedback, create a separate field such as `AI_Feedback__c`, owned by users, and pull it back into the warehouse with ordinary ETL.

That feedback in turn becomes valuable data for evaluating the model.

| Field | Written by | Data direction |
|---|---|---|
| `AI_Churn_Score__c`, `AI_Churn_Reason__c` | Warehouse | Warehouse → CRM (reverse ETL) |
| `AI_Feedback__c` | Sales in the CRM | CRM → Warehouse (ETL) |
| `Owner`, `Stage` | Sales in the CRM | Not synced back |

The customer should sign off on this short table before you switch the sync on. When someone asks why a number was overwritten, the answer is already on paper.

## Steps to take on the customer site

In practice the sequence runs as follows. Start by sitting next to end users and asking which screen, which field and what time of day they want to see the result. Then check the identifier mapping table between the warehouse and the CRM: an account with no matching key has nowhere for its score to be written.

Next, write the SQL model to match the exact types and lengths of the target fields, then build the mechanism that sends only changed records. Agree the field ownership table with the customer, run a test on a CRM sandbox with a few dozen records, and only then switch on the production schedule, with alerts when the error rate rises.

If the customer's tool is not Salesforce or Zendesk but an internal system, allow extra time. Portable acknowledges that integrating with existing tools can be complex and laborious, and some tools need custom-built connectors.

Before reading on, test yourself: if you drop the `left` function and the LLM generates a 300-character reason, which field breaks, and what happens to that record the following night? Answer: `AI_Churn_Reason__c` is rejected, `last_sent` is not updated, so the account reappears in every diff and fails every time.

That is exactly why error-rate alerts must exist from day one. A "resend on failure" mechanism is only safe when someone can see repeated failures.

## Mistakes that make a project look finished when it is not

The most common mistake is sending the whole table on every run, which works fine in the first week and then hits the rate limit as the number of accounts grows. Next comes leaving AI fields editable by users, creating conflicts nobody can trace.

Another mistake is sending raw output: a score of 0.8234719 and a 2,000-character reason that nobody reads. Then there is estimating integration work as if every destination had a ready-made connector. The subtlest mistake is never checking whether users actually look at the field.

If you have worked on a project like the churn example above, do not write "built a churn model" on your CV.

A good model solves the data problem. Reverse ETL solves the rest: making sure that on Monday morning, a salesperson opens an account and knows straight away whom to call first.

**Try this week:**

- Write a SQL model that returns exactly the columns the CRM needs for an AI result you already have, with an identifier key that matches the CRM
- Add a snapshot table and a diff query to count how many records actually change each day
- Draw up a field ownership table for a system a customer uses: which fields the warehouse writes and which fields users write

## Sources

- [What Is ELT (Extract, Load, Transform)? Process & Concepts](https://www.snowflake.com/guides/what-etl)

- [ETL vs Reverse ETL: An Overview, Key Differences, & Use Cases](https://portable.io/learn/etl-vs-reverse-etl)

- [ETL vs Reverse ETL vs Data Activation | Airbyte](https://airbyte.com/data-engineering-resources/etl-vs-reverse-etl-vs-data-activation)

- [Welcome | Hightouch Docs](https://hightouch.com/docs)

- [What is reverse ETL? (Metabase Glossary)](https://www.metabase.com/glossary/reverse-etl)
