external-id binding, badge assignment) and the design decisions (natural key, conflicts, idempotency) you need to make.
Scenario
Your marketing team uses HubSpot. Every time a lead is qualified and taggedPago Anual there, you want that person to show up in your Cativa community with the Premium badge (which unlocks the VIP group, the paid course, etc.). When the tag is removed (or the customer churns), the badge needs to come off.
Cativa is the source of truth for the community (who’s in the group, who completed which lesson, who posted what). The CRM is the source of truth for the commercial relationship (lead, opportunity, customer, churn). The integration bridges the two.
Prerequisites
- Cativa API Key — generated in the Console (Developers > API Keys). See Quick Start: API Key. Every call below uses
Authorization: Bearer cativa_live_.... - API access to your CRM — HubSpot token, RD Station token, etc. Cativa does not ship a CRM SDK; use the official ones (
@hubspot/api-client,pipedrive-node-sdk, etc.). - An integration server running on your side — could be a Node worker, a Cloud Function/Lambda, an Airflow job, anything that runs code with access to both sides.
Conceptual mapping
The central question: how do you recognize that contact1234 in HubSpot is the same user 01HQ7Z3X4Y... on Cativa?
The recommended natural key is
email (it exists on both sides from sign-up). After the first match, cache the binding on both sides: store the CRM Contact ID on the Cativa user via PATCH /admin/users/{userId}/external-id, and optionally store the Cativa User ID in a CRM custom property (e.g. cativa_user_id). That way you stop relying on email lookups on every run.
Cativa exposes an external-ID field on User, writable via
PATCH /admin/users/{userId}/external-id. Use it as the canonical way to bind your CRM’s key to the Cativa user. With the binding persisted on the user itself, the local mapping table (described below) becomes optional — just a cache optimization on your side.Two possible flows
- Pull-based (periodic worker)
- Webhook-based (event-driven)
When to use: you don’t have a CRM webhook, the volume is small (a few thousand contacts), or a delay of minutes/hours is acceptable.A worker on your side runs on a cadence (e.g. every 15 minutes), reads the CRM state, reads the Cativa state, computes the diff, applies it.Pros: simple to implement, easy to debug, easy to backfill.
Cons: delay (not real-time), wastes API calls when nothing changed.
Look up and bind an existing user
Matching by email is the heart of the sync. Do it once per contact and persist the binding on the Cativa user itself.1
Resolve the email to the Cativa user
GET /admin/users/email/{email} returns the user with that email in your key’s tenant. This is the canonical path for CRM sync.200 OK response (illustrative; full schema in the API Reference, Users tag):404 Not Found — treat that as “create the user” (section below):2
Store the CRM Contact ID on the Cativa user
Capture the With the binding persisted on the Cativa user itself, subsequent syncs no longer need to redo the email lookup.
id from the response and immediately store your CRM Contact ID on the user via PATCH /admin/users/{userId}/external-id:200 OK response (illustrative) with the binding already reflected:GET /tenant/api/v2/auth/me returns the key’s owning user and the customer (tenant) it belongs to:
200 OK response (illustrative):
Optional: a local mapping table as cache
Theexternal-id on the Cativa user already resolves the binding. If you still want to avoid every round-trip to the Cativa API (for latency or cost), keep a cache table on your side:
- Subscribe a Cativa listener to the
user_createdevent. Every time someone joins the community, the webhook arrives withUser.IdandUser.Email. Insert/update that row. - Subscribe a listener to
user_received_badge. Every badge change updates the row.
email to cativa_user_id locally. Treat the table as a cache: the source of truth for the binding is still the external-id stored on the Cativa user.
Create a user from the CRM
When the CRM contact doesn’t exist on Cativa yet (the email lookup returns 404), create the user withPOST /admin/users. Set the externalId in the same request:
201 Created response (illustrative) — store the id (with the externalId already bound) for subsequent syncs:
Assign a badge
Badge assignment via an admin API Key is available. There are two forms, under/admin/membership/badges:
By email (ideal for CRM, no need to resolve the userId first):
userId cached):
200 OK response (illustrative; full schema in the API Reference, Badge tag):
DELETE on the same resource by id:
Cativa guarantees that assigning the same badge twice is idempotent — the final state is the same (the second call returns success without duplicating). Same for removing a badge that wasn’t assigned. That simplifies retries in your worker (see Badges as Permissions).
Pull-based worker skeleton
With the write endpoints available, the worker below runs end to end. TheassignBadge/removeBadge functions (defined above) already hit the real API.
Handling conflicts
Common errors
The sync removed badges granted by other sources
The sync removed badges granted by other sources
When your worker is the sole authority on a badge and sees no reason for it in the CRM, it removes it. But if the badge was assigned by another source (paywall purchase, manual import), the sync wipes it out by mistake.Fix: keep a list of “CRM-managed badges” in your worker. Only assign and remove those. Badges outside the list are skipped by the diff.
Rate limit while processing many contacts
Rate limit while processing many contacts
On large syncs (thousands of contacts) you can hit rate limits on the CRM or on Cativa. Implement:
- Exponential backoff on 429 (Cativa respects
Retry-After). - Pagination on the CRM (e.g.
getPage(100, after, ...)on HubSpot). - Bounded parallelism (e.g.
p-limit(5)in Node).
user_created webhook never arrived — user missed the mapping table
user_created webhook never arrived — user missed the mapping table
If you only populate
crm_cativa_mapping via webhook, every lost delivery becomes a gap. That’s why we recommend:- A weekly reconcile job that pulls every CRM contact with an email and tries to match against known users.
- Logging the
X-Cativa-Execution-Idon every received webhook — if it’s missing later, you can open an investigation ticket.
Different email between CRM and Cativa for the same user
Different email between CRM and Cativa for the same user
Happens when the user buys with a personal email and joins the community with a corporate one (or vice-versa). No automatic fix — you need an extra property in the CRM (e.g.
community_email) and use that for the lookup instead of the primary email.Next steps
Subscribe to Cativa webhooks
Set up listeners for
user_created and user_received_badge to keep your mapping table fresh.Grant access via purchase
The specific case of “external purchase → badge” has its own patterns and gotchas.
