Skip to main content
Most partners already run a CRM (HubSpot, RD Station, Pipedrive, ActiveCampaign, Salesforce) as the source of truth for contacts. This guide shows how to keep the Cativa community in sync with that CRM using the admin API (email lookup, user creation, 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 tagged Pago 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

  1. Cativa API Key — generated in the Console (Developers > API Keys). See Quick Start: API Key. Every call below uses Authorization: Bearer cativa_live_....
  2. 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.).
  3. 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 contact 1234 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

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):
If the email doesn’t exist in the tenant, the endpoint returns 404 Not Found — treat that as “create the user” (section below):
2

Store the CRM Contact ID on the Cativa user

Capture the 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:
With the binding persisted on the Cativa user itself, subsequent syncs no longer need to redo the email lookup.
If your key owns a user and you just want to check the credential and tenant, 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

The external-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:
And populate it via webhook:
  1. Subscribe a Cativa listener to the user_created event. Every time someone joins the community, the webhook arrives with User.Id and User.Email. Insert/update that row.
  2. Subscribe a listener to user_received_badge. Every badge change updates the row.
After that, your sync worker resolves 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 with POST /admin/users. Set the externalId in the same request:
201 Created response (illustrative) — store the id (with the externalId already bound) for subsequent syncs:
The full body schema is in the API Reference tab, under the Users tag. If you’d rather let the user set their own password, send the tenant’s sign-up link from your CRM (HubSpot Email, RD Station Email) instead of creating via API. For large initial backfills (thousands of contacts), the Cativa team can also import a spreadsheet via Console. Coordinate at dev@cativa.digital.

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):
By id (when you already have the Cativa userId cached):
200 OK response (illustrative; full schema in the API Reference, Badge tag):
To remove, use DELETE on the same resource by id:
In the worker, the assign and remove functions call these endpoints directly:
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. The assignBadge/removeBadge functions (defined above) already hit the real API.

Handling conflicts

Common errors

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.
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).
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-Id on every received webhook — if it’s missing later, you can open an investigation ticket.
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.