> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cativa.digital/llms.txt
> Use this file to discover all available pages before exploring further.

# Sync members from your CRM

> Keep your Cativa community in sync with HubSpot, RD Station or any CRM via API Key.

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 — what's available today, what's coming soon, 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](/en/get-started/quickstart-api-key).
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?**

| HubSpot          | Cativa              |
| ---------------- | ------------------- |
| Contact ID       | User ID             |
| Email            | Email (natural key) |
| Tag `Pago Anual` | Badge `Premium`     |

The **recommended natural key is `email`** — it exists on both sides from sign-up. You can optionally create a custom property in HubSpot called `cativa_user_id` to cache the Cativa User ID after the first match and avoid email lookups on every run.

<Note>
  Cativa **does not currently expose an `external_id` field on User** that would let you bind directly to your CRM's key. The natural key is `email`. When that field is added to the public model, this page will be updated with the upsert-by-`external_id` option.
</Note>

## Two possible flows

<Tabs>
  <Tab title="Pull-based (periodic worker)">
    **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.

    ```
    cron 15 min ─→ pull HubSpot ─→ pull Cativa ─→ diff ─→ apply
    ```

    **Pros:** simple to implement, easy to debug, easy to backfill.
    **Cons:** delay (not real-time), wastes API calls when nothing changed.
  </Tab>

  <Tab title="Webhook-based (event-driven)">
    **When to use:** the CRM offers webhooks when a property changes (HubSpot Workflow, RD Station Automation), and you want second-level sync.

    The CRM fires a webhook to your server when a tag changes → your server calls the Cativa API to assign/remove the badge.

    ```
    HubSpot tag changes ─→ webhook to your server ─→ Cativa API
    ```

    **Pros:** low latency, efficient.
    **Cons:** more code (HMAC verification of the CRM webhook, retry/backoff, dedupe), and you still want **a weekly pull-based reconcile worker** to cover lost webhooks.

    You can also subscribe to **Cativa webhooks** (`user_received_badge`, `user_created`) to mirror community-side changes back into the CRM. The page [Subscribing and verifying webhooks](/en/webhooks/subscribing-and-verifying) shows how to verify the HMAC signature on Cativa deliveries.
  </Tab>
</Tabs>

## Look up an existing user on Cativa

Today there's **one public endpoint** you can call with your API Key:

`GET /social/v1/auth/me` — returns the user **owning the key** (it does not look up arbitrary users). Use it to sanity-check your credential and discover the `customer` (tenant) the key belongs to.

```bash theme={null}
curl https://apis.cativalab.digital/tenant/v1/auth/me \
  -H "Authorization: Bearer cativa_live_..."
```

<Note>
  Looking up an **arbitrary user by email** (which is the CRM-sync case) using a partner API Key is **coming soon**. Today this lookup uses an admin endpoint (`/social/v1/admin/users/email/{email}`) that **is not available to partner API Keys** — only to a tenant admin signed in to the Console.

  **Workaround today:** coordinate with the Cativa team at [dev@cativa.digital](mailto:dev@cativa.digital) to enable the lookup for your scenario, or use the `user_created` webhook event (see below) to populate your own email → Cativa User ID mapping table as new users come in.
</Note>

### Recommended strategy today: a mapping table populated via webhook

Instead of looking up by email on every sync, keep a table on your side:

```
crm_cativa_mapping
─────────────────
hubspot_contact_id  TEXT
cativa_user_id      UUID
email               TEXT
last_synced_at      TIMESTAMP
```

And populate it:

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` → `cativa_user_id` locally, no Cativa API call needed.

## Create a user from the CRM

<Note>
  The public endpoint for partners to **create users via API Key** is **coming soon**. Today, user creation happens when the user signs up themselves in the community (via Sign in with Cativa, an invite, or the tenant's public sign-up link).

  **Workarounds today:**

  * **Email invite** — generate the tenant's sign-up link and send it from your CRM (HubSpot Email, RD Station Email). When the lead clicks and finishes registration, the `user_created` event lands on your webhook and you can grant the badge right after.
  * **Bulk import** — for large initial backfills (thousands of contacts), the Cativa team can import a spreadsheet via Console. Coordinate at [dev@cativa.digital](mailto:dev@cativa.digital).

  When the public endpoint is available, this page will be updated with cURL and examples.
</Note>

## Assign a badge

<Note>
  Badge assignment via partner API Key is **coming soon**. Today, assigning/removing badges happens:

  * Manually in the tenant's Console (admin), or
  * Via bulk import (spreadsheet), or
  * In production, via platform-internal automated flows (paywall purchase, course completion) — not via direct partner API.

  When the public endpoint ships, this page will show the full cURL. To unblock your case in the meantime, open a ticket at [dev@cativa.digital](mailto:dev@cativa.digital).
</Note>

In code, plan your worker assuming the call will exist. Mental sketch of what it'll look like:

```js theme={null}
// When the endpoint is available:
async function assignBadge(cativaUserId, badgeId) {
  const res = await fetch(
    `https://apis.cativalab.digital/tenant/v1/.../badges/${badgeId}/users/${cativaUserId}`,
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${process.env.CATIVA_API_KEY}` }
    }
  );
  if (!res.ok) throw new Error(`Badge assign failed: ${res.status}`);
}

async function removeBadge(cativaUserId, badgeId) {
  const res = await fetch(
    `https://apis.cativalab.digital/tenant/v1/.../badges/${badgeId}/users/${cativaUserId}`,
    {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${process.env.CATIVA_API_KEY}` }
    }
  );
  if (!res.ok) throw new Error(`Badge remove failed: ${res.status}`);
}
```

Cativa guarantees that **assigning the same badge twice is idempotent** — the final state is the same. Same for removing a badge that wasn't assigned. That simplifies retries in your worker (see [Badges as Permissions](/en/concepts/badges-as-permissions)).

## Pull-based worker skeleton

Even with the write endpoints coming soon, model the worker today. What's inside `assignBadge`/`removeBadge` is what's on the wait list.

```js theme={null}
import { Client as HubSpotClient } from '@hubspot/api-client';

const hubspot = new HubSpotClient({ accessToken: process.env.HUBSPOT_TOKEN });
const PREMIUM_BADGE_ID = process.env.CATIVA_PREMIUM_BADGE_ID;

async function syncContact(contact) {
  const email = contact.properties.email;
  if (!email) return; // no email, no match

  // 1. Resolve email → Cativa User ID (local table populated via webhook)
  const mapping = await db.crmCativaMapping.findOne({ email });
  if (!mapping) {
    // User isn't on Cativa yet. Send an invite via the CRM.
    await sendInviteEmail(email);
    return;
  }

  // 2. Desired state (HubSpot is the source of truth)
  const shouldHavePremium = (contact.properties.lifecyclestage === 'customer'
    && contact.properties.deal_status === 'paid_annual');

  // 3. Current state (your local table, updated via the user_received_badge webhook)
  const hasPremium = await db.userBadges.exists({
    userId: mapping.cativaUserId,
    badgeId: PREMIUM_BADGE_ID
  });

  // 4. Apply the diff
  if (shouldHavePremium && !hasPremium) {
    await assignBadge(mapping.cativaUserId, PREMIUM_BADGE_ID);
  } else if (!shouldHavePremium && hasPremium) {
    await removeBadge(mapping.cativaUserId, PREMIUM_BADGE_ID);
  }
}

async function runSync() {
  let after;
  do {
    const page = await hubspot.crm.contacts.basicApi.getPage(100, after, [
      'email', 'lifecyclestage', 'deal_status'
    ]);
    for (const contact of page.results) {
      try {
        await syncContact(contact);
      } catch (err) {
        console.error(`Failed to sync ${contact.id}`, err);
        // Don't break the loop — log and continue.
      }
    }
    after = page.paging?.next?.after;
  } while (after);
}
```

## Handling conflicts

| Scenario                                                                         | What to do                                                                                                                                                                                                                                                                                                   |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Email changed in the CRM, hasn't changed on Cativa**                           | Cativa is the source of `cativa_user_id`. If the HubSpot contact's email changed and you can't match by the new email anymore, keep the cached `cativa_user_id` (in `crm_cativa_mapping`) and update the `email` column when it's different. The Cativa user still exists — only the CRM-side angle changed. |
| **CRM email never showed up on Cativa**                                          | The HubSpot contact has no Cativa user yet. Send an invite or skip until they sign up. Don't try to force-create them until the public endpoint is available.                                                                                                                                                |
| **Same email appears on two CRM contacts**                                       | Your call which contact is canonical (usually the most recent one, or the one with the most advanced `lifecyclestage`). Resolve before calling Cativa.                                                                                                                                                       |
| **Customer cancelled in the CRM but still has the badge on Cativa**              | This is exactly what the removal sync fixes. The `runSync` flow above covers it.                                                                                                                                                                                                                             |
| **Badge assigned by another source (paywall purchase) and the CRM doesn't know** | Cativa can grant badges via other paths (paywall purchase, course completion). If the CRM is **not** the source of truth for that specific badge, **don't remove** the badge in the diff — add a filter on your side (e.g. only sync badges that came via a CRM tag).                                        |

## Common errors

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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).
  </Accordion>

  <Accordion title="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-Id` on every received webhook — if it's missing later, you can open an investigation ticket.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Subscribe to Cativa webhooks" icon="webhook" href="/en/webhooks/subscribing-and-verifying">
    Set up listeners for `user_created` and `user_received_badge` to keep your mapping table fresh.
  </Card>

  <Card title="Grant access via purchase" icon="cart-shopping" href="/en/guides/grant-access-via-purchase">
    The specific case of "external purchase → badge" has its own patterns and gotchas.
  </Card>
</CardGroup>
