CRM Integration
Connect Your CRM
Section titled “Connect Your CRM”Use the CRM Integration workflow to connect HubSpot, Salesforce, or any other CRM to GraphADV. Upload firm records from your system of record and maintain a durable link to GraphADV’s canonical firm IDs, so you can push updates back to your CRM and pull GraphADV insights without manual matching. The /account/firms/* endpoints replace one-off /firms/search calls with a fully auditable integration surface behind the GraphADV API proxy.
Workflow Overview
Section titled “Workflow Overview”- Deterministic resolution – SEC number, CRD number, and exact domain matches auto-confirm (candidate snapshots only include entries when fuzzy scoring ran).
- Candidate generation – Name, domain, and geography similarity return up to five candidates.
- Trigram scoring – Candidates are ranked with deterministic heuristics plus trigram-based fuzzy matching.
- Policy gate – High-confidence matches auto-confirm; everything else is stored as
ambiguous. - Durable persistence – Each outcome is persisted and exposed via
/account/firmsalong with timestamps, review reasons, candidate snapshots, and method metadata.
Resolver runs are tracked via the run_id returned from /account/firms/resolve, allowing GTM teams to replay a given ingestion or audit how many firms were auto-confirmed vs. surfaced for review. Locked records are skipped without any database writes, so mixed batches can include previously confirmed firms without fear of overwriting links.
Key Endpoints
Section titled “Key Endpoints”| Endpoint | Method | Purpose | Example Payload |
|---|---|---|---|
/account/firms/resolve | POST | Batch resolve CRM payloads and return linked, ambiguous, unmatched, or locked statuses | [{"external_firm_id":"crm_123","name":"Example Capital","domain":"examplecapital.com"}] |
/account/firms?status=ambiguous | GET | List firms that still require manual confirmation | — |
/account/firms/confirm | POST | Confirm an ambiguous match (optionally overriding the candidate) | {"external_firm_id":"crm_456","graphadv_firm_id":"c3fa21da-2d8e-4d2d-9a4c-9d4a5a6d1d40"} |
/account/firms/override | POST | Override the ambiguous match with a user-selected GraphADV firm | {"external_firm_id":"crm_789","graphadv_firm_id":"9a0f0ae8-04c5-40b1-80d4-3e7cd8a2a780","reason":"Parent RIA confirmed"} |
/account/firms/reject | POST | Clear the stored link for a CRM record so it re-enters the resolve queue | {"external_firm_id":"crm_987","reason":"Duplicate location"} |
/account/firms/unlock | POST | Unlock a previously linked firm (requires reason) | {"external_firm_id":"crm_987","reason":"Need to reprocess with updated CRM data"} |
Resolve Statuses
Section titled “Resolve Statuses”linked– Deterministic or high-confidence fuzzy match was auto-confirmed. Candidate snapshots include scored candidates when fuzzy logic ran; deterministic matches may return an empty list.ambiguous– Needs human review. Includescandidatesandreasonsfor explainability.unmatched– No viable candidates. Continue editing the CRM payload or rerun after enrichment.locked– Record was previously confirmed and marked immutable. Resolver skipped it entirely; call the/account/firms/unlockwith areasonbefore re-running resolve.
Locked links ensure the “never re-resolve” principle: once a reviewer confirms a mapping, /account/firms/resolve refuses to overwrite it and responds with status: "locked" plus the existing candidate snapshot (if any fuzzy candidates were generated previously). Unlock by calling /account/firms/unlock with the external_firm_id and a reason, which writes an audit log before allowing another resolve attempt.
Push any CRM export—HubSpot companies, Salesforce accounts, Microsoft Dynamics accounts, etc.—into /account/firms/resolve to register the GraphADV link and begin syncing insights back to your system.
Sample Resolve Request
Section titled “Sample Resolve Request”[ { "external_firm_id": "crm_123", // required "name": "Example Capital Management", "domain": "examplecapital.com", "sec_number": "801-55555", "crd_number": "123456", "address": "123 Main Street, New York, NY", "city": "New York", "state": "NY", "country": "US", "notes": "RIA based in NYC", "source": "salesforce" }]Response:
{ "run_id": "be7c25c6-70bf-4f16-9ad6-0dca2cd4db19", "results": [ { "external_firm_id": "crm_123", "external_firm_name": "Example Capital Management", "fuzzy_name_lookup": "example capital management", "status": "linked", // linked | ambiguous | unmatched | locked "graphadv_firm_id": "43f8ad30-ef86-4c4b-a080-01b9bf67f4b8", "confidence": 0.97, "method": "domain", // sec_number | crd | domain | fuzzy | ... "reasons": [ "Exact domain match" ], "candidates": [ { "firm_id": "43f8ad30-ef86-4c4b-a080-01b9bf67f4b8", "legal_name": "EXAMPLE CAPITAL MANAGEMENT LLC", "score": 0.97, "reasons": ["Exact domain match"], "city": "New York", "state": "NY", "country": "United States", "domain": "examplecapital.com", "aum_bucket": "$10B", "employee_bucket": "100+" } ] } ]}Once confirmed, downstream GTM tools can call /account/firms/{external_firm_id}/profile and reuse the GraphADV schema without juggling SEC/CRD/domain matching logic themselves.
Programmatic Review Loop
Section titled “Programmatic Review Loop”- Upload CRM records via
POST /account/firms/resolve. The proxy runs deterministic ID checks, domain matching, and a fuzzy name scorer immediately, then responds with per-record decisions plus up to five ranked candidates. - Fetch the review queue using
GET /account/firms?status=ambiguous. Each row includes the original payload (candidate_snapshot.input) and the scored candidates (candidate_snapshot.candidates). - Approve or override directly from your automation:
POST /account/firms/confirmto accept the stored candidate.POST /account/firms/overridewith your owngraphadv_firm_idif you prefer a different firm.POST /account/firms/rejectto clear a bad match.
- Automate at scale by applying your own confidence thresholds—e.g., auto-confirm any ambiguous match with score ≥0.95—and calling the confirm/override endpoints in bulk. Every change is immediately reflected by
/account/firms, so downstream/profile|signals|principalslookups work as soon as a record is confirmed.
Example: Fetch the Review Queue
Section titled “Example: Fetch the Review Queue”curl -X GET "$BASE_URL/account/firms?status=ambiguous&limit=2" \ -H "x-api-key: $GRAPHADV_API_KEY"import osimport requests
BASE_URL = os.getenv("BASE_URL", "https://api.graphadv.com")API_KEY = os.getenv("GRAPHADV_API_KEY")
response = requests.get( f"{BASE_URL}/account/firms", headers={"x-api-key": API_KEY}, params={"status": "ambiguous", "limit": 2},)review_queue = response.json()const BASE_URL = process.env.BASE_URL ?? 'https://api.graphadv.com';const API_KEY = process.env.GRAPHADV_API_KEY;
const response = await fetch( `${BASE_URL}/account/firms?status=ambiguous&limit=2`, { headers: { 'x-api-key': API_KEY } });const reviewQueue = await response.json();Response:
[ { "external_firm_id": "crm_456", "status": "ambiguous", "candidates": [ {"graphadv_firm_id": "c3fa21da-2d8e-4d2d-9a4c-9d4a5a6d1d40", "score": 0.88, "reasons": ["Domain overlap", "City match"]}, {"graphadv_firm_id": "d9bb6a01-5697-45f7-b3df-2a23793c0118", "score": 0.74, "reasons": ["Name similarity 0.82"]} ], "candidate_snapshot": { "input": {"name": "Blue Rock Capital", "domain": "bluerockcapital.co"}, "candidates": [ {"graphadv_firm_id": "c3fa21da-2d8e-4d2d-9a4c-9d4a5a6d1d40", "score": 0.88}, {"graphadv_firm_id": "d9bb6a01-5697-45f7-b3df-2a23793c0118", "score": 0.74} ] }, "last_reviewed_at": null }]Example: Confirm an Ambiguous Match
Section titled “Example: Confirm an Ambiguous Match”curl -X POST "$BASE_URL/account/firms/confirm" \ -H "x-api-key: $GRAPHADV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_firm_id": "crm_456", "graphadv_firm_id": "c3fa21da-2d8e-4d2d-9a4c-9d4a5a6d1d40", "review_notes": "Auto-confirmed above 0.9" }'import osimport requests
BASE_URL = os.getenv("BASE_URL", "https://api.graphadv.com")API_KEY = os.getenv("GRAPHADV_API_KEY")
payload = { "external_firm_id": "crm_456", "graphadv_firm_id": "c3fa21da-2d8e-4d2d-9a4c-9d4a5a6d1d40", "review_notes": "Auto-confirmed above 0.9",}
response = requests.post( f"{BASE_URL}/account/firms/confirm", headers={"x-api-key": API_KEY, "Content-Type": "application/json"}, json=payload,)result = response.json()const BASE_URL = process.env.BASE_URL ?? 'https://api.graphadv.com';const API_KEY = process.env.GRAPHADV_API_KEY;
const payload = { external_firm_id: 'crm_456', graphadv_firm_id: 'c3fa21da-2d8e-4d2d-9a4c-9d4a5a6d1d40', review_notes: 'Auto-confirmed above 0.9'};
const response = await fetch(`${BASE_URL}/account/firms/confirm`, { method: 'POST', headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(payload)});const confirmResult = await response.json();Response:
{ "external_firm_id": "crm_456", "status": "linked", "graphadv_firm_id": "c3fa21da-2d8e-4d2d-9a4c-9d4a5a6d1d40", "reviewed_by": "automation@customer.com", "reviewed_at": "2025-01-12T15:02:55Z", "notes": "Auto-confirmed above 0.9"}Example: Override with a Different Firm
Section titled “Example: Override with a Different Firm”curl -X POST "$BASE_URL/account/firms/override" \ -H "x-api-key: $GRAPHADV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_firm_id": "crm_789", "graphadv_firm_id": "9a0f0ae8-04c5-40b1-80d4-3e7cd8a2a780", "reason": "Account team confirmed entity is the parent RIA" }'import osimport requests
BASE_URL = os.getenv("BASE_URL", "https://api.graphadv.com")API_KEY = os.getenv("GRAPHADV_API_KEY")
payload = { "external_firm_id": "crm_789", "graphadv_firm_id": "9a0f0ae8-04c5-40b1-80d4-3e7cd8a2a780", "reason": "Account team confirmed entity is the parent RIA",}
response = requests.post( f"{BASE_URL}/account/firms/override", headers={"x-api-key": API_KEY, "Content-Type": "application/json"}, json=payload,)override_result = response.json()const BASE_URL = process.env.BASE_URL ?? 'https://api.graphadv.com';const API_KEY = process.env.GRAPHADV_API_KEY;
const payload = { external_firm_id: 'crm_789', graphadv_firm_id: '9a0f0ae8-04c5-40b1-80d4-3e7cd8a2a780', reason: 'Account team confirmed entity is the parent RIA'};
const response = await fetch(`${BASE_URL}/account/firms/override`, { method: 'POST', headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(payload)});const overrideResult = await response.json();Response:
{ "external_firm_id": "crm_789", "status": "linked", "graphadv_firm_id": "9a0f0ae8-04c5-40b1-80d4-3e7cd8a2a780", "method": "manual_override", "reason": "Account team confirmed entity is the parent RIA"}Example: Reject and Re-queue
Section titled “Example: Reject and Re-queue”curl -X POST "$BASE_URL/account/firms/reject" \ -H "x-api-key: $GRAPHADV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_firm_id": "crm_987", "reason": "Record is a duplicate location" }'import osimport requests
BASE_URL = os.getenv("BASE_URL", "https://api.graphadv.com")API_KEY = os.getenv("GRAPHADV_API_KEY")
payload = { "external_firm_id": "crm_987", "reason": "Record is a duplicate location",}
response = requests.post( f"{BASE_URL}/account/firms/reject", headers={"x-api-key": API_KEY, "Content-Type": "application/json"}, json=payload,)reject_result = response.json()const BASE_URL = process.env.BASE_URL ?? 'https://api.graphadv.com';const API_KEY = process.env.GRAPHADV_API_KEY;
const payload = { external_firm_id: 'crm_987', reason: 'Record is a duplicate location'};
const response = await fetch(`${BASE_URL}/account/firms/reject`, { method: 'POST', headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(payload)});const rejectResult = await response.json();Response:
{ "external_firm_id": "crm_987", "status": "unmatched", "reason": "Record is a duplicate location"}With these endpoints you can script a full loop: resolve batches, poll for ambiguous rows, confirm or override based on your thresholds, and fall back to manual review only when necessary.
CRM Mapper (Optional)
Section titled “CRM Mapper (Optional)”When customers send CRM exports with inconsistent field names, run them through the built-in AccountFirmRecordMapper to coerce records into the resolver’s schema. The mapper instructs OpenAI to normalize IDs/domains and stores any unused keys in unmapped_fields so operations teams keep full fidelity.
from src.services.account_firm_mapper import AccountFirmRecordMapper
raw_rows = [ {"crm_id": "A-123", "company": "Example Capital", "website": "https://examplecapital.com"}, {"account_guid": "B-999", "Firm Name": "Grey Rock Partners", "custom": "Original payload"},]
mapper = AccountFirmRecordMapper()mapped = mapper.map_records(raw_rows)
for record in mapped: payload = record.record.model_dump() print(payload["external_firm_id"], payload["domain"], payload["unmapped_fields"])Feed record.record objects directly into AccountFirmLinker.resolve_batch to get end-to-end CRM automation without requiring customers to hand-map every column.