Skip to main content
Node examples use the shared Node client (retriever.mjs). Problem: sourcing must never suggest your current customers. Solution: a scheduled job sends your CRM customers to the exclusion list. It keeps a registry of the CRM values it sent, including the ones that were already on the list. With prune: true, it also removes the registered values that left the CRM, with guardrails. If the app’s HubSpot or Attio sync also feeds the list, read the last section first.

The sync

Run it

loadRegistry(orgId, kind) and saveRegistry(orgId, kind, registry) are yours: they read and write one registry, for example a table in your database. loadRegistry returns a Set, empty the first time.
With one organization per client (model A in Multi-client), each client has its own registries and its own key. Catch errors per client, so that one refusal or one outage does not stop the others:
Never share a registry between organizations. Client A’s run would see client B’s values as stale: it would remove from A’s list values that are B’s customers, and forget them.

Good to know

  • Nothing is removed unless you pass prune: true. prune is false by default. The function then only returns pending: the number of registered values that left the CRM and are still excluded.
  • Removal works on the value, whoever added it. A value that this sync never sent is never removed. A value that the CRM has and that was also added by hand or by CSV is registered like the others, and removed when it leaves the CRM.
  • Prune cap. A run that would remove more than 20% (maxPruneRatio) of the registry, as it was before the run, is refused, in case the CRM export is cut short. The cap always allows minPrune removals (1 by default): without it, a registry of fewer than 5 values could never lose one. When the cap refuses a run, check the CRM export. If the removals are real, run once with a higher maxPruneRatio.
  • Empty export. The sync stops when the CRM export has no value that the server can read. If your CRM really has none for that list (no contact with a LinkedIn URL, for example), do not run that kind; to remove every value this sync sent, use releaseExclusions (entries it never sent stay).
  • Values. The code drops empty and non-string values, which fail the whole request with 400, and sends 1,000 values per call at most. The server returns any other value it cannot read in invalid and applies the rest. Some LinkedIn handles cannot be excluded through the API, for example /in/andré-dupont or /in/o'brien: see the rules in Exclusions.
  • People. Contacts without a LinkedIn URL are dropped: they are never excluded, unless their company’s domain is on the companies list. The list never filters contacts found row by row in a table. The registry holds handles, and removal sends https://www.linkedin.com/in/<handle>.
  • Reading the list. The code never reads the list back: it registers what it sends, with the same rules as the server. If you read the list with GET, compare people by handle, since GET returns a stored URL. The list pages by offset and can change between pages, so an entry can be skipped. See Reading the list.
  • Crashes and retries. Upsert and remove are idempotent by value: a replay leaves the same list. These routes ignore Idempotency-Key, so the call() client does not replay them after a 5xx or a network error. Run the whole sync again. The registry is saved before each upsert and after each removal, so a crash leaves at most a few values to remove again. Never run two syncs of the same list at the same time: they overwrite each other’s registry. Use a lock.
  • Organization. The list belongs to the organization of the key’s creator, and every member’s sourcing uses it. A key acts in the organization its creator belongs to at each request: if that account changes organization, the sync writes to and prunes another organization’s list, with no error. Use a dedicated account per organization and follow the rules in Multi-client. A key belongs to its creator: other members cannot see or revoke it.
  • Several clients in one organization (model B in Multi-client) share one list. Run this sync once over all their CRMs together: one sync per client would remove a domain that another client still has.

If you also use the HubSpot or Attio sync in the app

The app can sync HubSpot or Attio into the same list. It keeps company names, which sourcing uses to drop a company with no domain and no LinkedIn page, and a person whose result gives only an employer name. It also stores contacts without a LinkedIn URL, by name and company; people searches do not use those entries, only sourcing tasks do. This script stores only domains and LinkedIn handles. Use one mechanism per list. The two do not mix well:
  • When this script sends a value that the integration brought in, the value becomes owned by the API. The integration no longer removes it when it leaves the CRM. With both running and prune off, customers who left the CRM stay excluded.
  • When you remove through the API a value that the integration still lists, the integration adds it back on its next sync.
  • A CSV import in the app over an existing entry turns it into a CSV entry: the integration no longer removes it, and this script removes it only if it is in the registry.
  • An organization has one HubSpot and one Attio connection. With several clients in one organization (model B), the integration cannot cover their CRMs.
If you must run both, run this script with prune: true and make it select the same customers as the integration. The integration selects them in the app, separately for the companies list and the people list: by lifecycle stage in HubSpot, by list in Attio. By default it selects everything in the CRM, prospects included. Attio excludes every domain of a company, so the script must send all of them. Switching to the integration
  1. Export the list with GET (the app has no export). People stored by name and company are not in it, and company names are not returned.
  2. In the app, for each CRM and each list (companies and people), choose the lifecycle stages (HubSpot) or the lists (Attio).
  3. Stop the script. Run releaseExclusions once per kind. Without this step, every value the script sent stays excluded forever, owned by the API or still a manual or CSV entry. Note that releaseExclusions also removes the entries added by hand or by CSV that the registry recorded: removal works on the value. After step 4, the values that are in your step 1 export but not back on the list are the ones to check: add back, in the app, those you still want excluded.
  4. On each list, press Sync now, and turn on Sync every night (two switches per CRM). Between step 3 and this step, those customers are not excluded.
Switching to this script
  1. On each list, press Sync now one last time. Then turn off Sync every night, or disconnect the integration. Disconnecting does not delete its entries.
  2. Run the script with the same selection as the integration. It takes over every entry it sends.
  3. The integration’s entries for customers that the script does not send stay on the list. Delete them in the app’s exclusion settings, where each entry shows its source (HubSpot or Attio). POST /v1/exclusions/{kind}/remove also works for companies and for people with a LinkedIn URL. People stored by name and company can only be deleted in the app.