> ## 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.

# Grant access via external purchase

> Hotmart, Kiwify, Eduzz or Stripe — receive the purchase webhook and assign the right badge on Cativa.

You sell a course or digital product on an external gateway (Hotmart, Kiwify, Eduzz, Stripe Checkout) and want the purchase to **automatically** unlock access to a group, course or space inside your Cativa community. This guide walks through the recommended architecture, what's available today and what's coming soon.

## Scenario

A customer buys **"Premium Course"** on Hotmart. Within seconds they get access to the **"Premium Students"** group and to the course inside your Cativa community.

The bridge between both sides is the [badge-as-permission](/en/concepts/badges-as-permissions) concept: the `Premium` badge is configured in the Console as the access requirement on that group and course. Once the user gets the badge, access shows up automatically. When the badge is removed, access is gone.

## Prerequisites

1. **Cativa API Key** — generated in the Console (Developers > API Keys). See [Quick Start: API Key](/en/get-started/quickstart-api-key).
2. **Badge configured in the Console** — create the `Premium` badge (or whatever your product is called) and configure it as the access requirement on the group/course. See [Badges as Permissions](/en/concepts/badges-as-permissions).
3. **Purchase webhook from your gateway** — Hotmart, Kiwify, Eduzz and Stripe fire a webhook to **your server's** endpoint when a purchase is confirmed. **Do not point the gateway webhook directly at Cativa** — you need a proxy server that receives, validates and translates the event.
4. **A reliable buyer email** — every gateway sends the email in the purchase payload. That's the join key with the Cativa user.

## Architecture

```
   Customer buys on Hotmart
            │
            ▼
   Hotmart webhook ──► Your server (proxy) ──► Cativa API (assign badge)
                                                       │
                                                       ▼
                                         Cativa user_received_badge webhook
                                                       │
                                                       ▼
                                              Email/CRM/Analytics
```

You're the broker between gateway and Cativa. That gives you control to:

* Validate the gateway webhook (each one has its own signature — check the gateway docs).
* Handle idempotency (Hotmart can fire the same webhook twice).
* Log the gateway purchase ID for audit/reconciliation.
* Decide the right badge based on which product was purchased.

You can optionally subscribe to the **Cativa** `user_received_badge` webhook to fire a welcome email, update your CRM, or log an analytics event.

## Implementation

<Steps>
  <Step title="Receive the gateway webhook">
    Each gateway has its own webhook format and its own signing mechanism. Configure the webhook **in the gateway dashboard** pointing to an endpoint on your server (e.g. `https://myapp.com/webhooks/hotmart`).

    Cativa **does not document** the gateway webhook formats — refer to the official docs:

    * [Hotmart](https://developers.hotmart.com/docs/en/v1/webhook/)
    * [Kiwify](https://docs.kiwify.com.br/produto/webhooks)
    * [Eduzz](https://developers.eduzz.com/api/webhooks)
    * [Stripe](https://stripe.com/docs/webhooks)

    Receiver skeleton (Express, Hotmart example):

    ```js theme={null}
    import express from 'express';

    const app = express();

    app.post(
      '/webhooks/hotmart',
      express.json(),
      async (req, res) => {
        // 1. Verify the gateway signature here (per gateway docs)

        const event = req.body;
        // Hotmart uses event types like "PURCHASE_APPROVED", "PURCHASE_CANCELED", etc.

        if (event.event === 'PURCHASE_APPROVED') {
          await handlePurchase({
            email: event.data.buyer.email,
            productId: event.data.product.id,
            purchaseId: event.data.purchase.transaction
          });
        }

        if (event.event === 'PURCHASE_CANCELED' ||
            event.event === 'PURCHASE_REFUNDED' ||
            event.event === 'PURCHASE_CHARGEBACK') {
          await handleCancellation({
            email: event.data.buyer.email,
            productId: event.data.product.id,
            purchaseId: event.data.purchase.transaction
          });
        }

        // Reply 2xx fast — process the rest in the background if you can.
        res.status(200).send('ok');
      }
    );
    ```

    <Warning>
      Always **verify the gateway webhook signature** before trusting the data. Without it, anyone who finds your URL could grant arbitrary badges.
    </Warning>
  </Step>

  <Step title="Identify the user on Cativa by email">
    Two situations today:

    **Case A — the buyer already has a Cativa account:** you need to find their `User.Id`.

    <Note>
      The public endpoint for partners to look up an arbitrary user by email is **coming soon**. Today, that lookup uses an admin endpoint that's not available to partner API Keys.

      **Recommended workaround:** keep a local `email → cativa_user_id` table populated by Cativa's `user_created` webhook. Every time someone joins the community, you save the row. When the purchase arrives, you look up locally with no API call.
    </Note>

    ```js theme={null}
    async function resolveCativaUserId(email) {
      const row = await db.userMap.findOne({ email });
      return row?.cativaUserId ?? null;
    }
    ```

    **Case B — the buyer does not have a Cativa account yet:** you need them to sign up first.

    <Note>
      User creation directly via partner API Key is **coming soon**. Workaround today: send an email with the tenant's sign-up link plus a note that the badge will be granted once they join. When they sign up, the `user_created` webhook lands on your server and you complete the flow (see step 4).
    </Note>

    ```js theme={null}
    async function inviteBuyer({ email, productId, purchaseId }) {
      // Save the grant intent to complete it when user_created arrives
      await db.pendingGrants.insert({
        email,
        productId,
        purchaseId,
        createdAt: new Date()
      });

      // Send email with the tenant's sign-up link
      await mailer.send({
        to: email,
        subject: 'Your access to Premium Course',
        text: `Sign up at https://{customerName}.cativa.digital/signup?email=${encodeURIComponent(email)} to unlock your access.`
      });
    }
    ```
  </Step>

  <Step title="Assign the badge">
    Map the gateway `productId` to the Cativa `badgeId` configured in the Console.

    <Note>
      Badge assignment via partner API Key is **coming soon**. Today, assigning/removing badges happens in the Console (manually or via import) or through internal platform flows. When the public endpoint is available, this page will be updated with the full cURL. To unblock your case in the meantime, open a ticket at [dev@cativa.digital](mailto:dev@cativa.digital).
    </Note>

    Mental sketch of what it'll look like:

    ```js theme={null}
    const PRODUCT_TO_BADGE = {
      'hotmart_product_123': 'badge_uuid_premium',
      'hotmart_product_456': 'badge_uuid_mentoring_2026'
    };

    async function handlePurchase({ email, productId, purchaseId }) {
      const badgeId = PRODUCT_TO_BADGE[productId];
      if (!badgeId) {
        console.warn(`No badge mapped for product ${productId}`);
        return;
      }

      const cativaUserId = await resolveCativaUserId(email);
      if (!cativaUserId) {
        await inviteBuyer({ email, productId, purchaseId });
        return;
      }

      // When the endpoint is available:
      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}`);

      await db.purchases.insert({
        purchaseId,
        cativaUserId,
        badgeId,
        grantedAt: new Date()
      });
    }
    ```

    Cativa guarantees that **assigning the same badge twice is idempotent** (see [Badges as Permissions](/en/concepts/badges-as-permissions)) — so retries caused by timeouts or gateway redelivery do not double-grant.
  </Step>

  <Step title="(Optional) React to the user_received_badge webhook">
    Cativa fires `user_received_badge` every time a badge is assigned (regardless of whether it came from the API, Console, or another flow). Subscribe a listener to it if you want to:

    * Send a welcome email with a link to the community
    * Update the contact status in your CRM
    * Track conversion in analytics

    See [Subscribing and verifying webhooks](/en/webhooks/subscribing-and-verifying) for the listener-subscription steps and HMAC verification. The event payload is documented at [user\_received\_badge](/en/webhooks/events/user-received-badge).

    Receiver skeleton:

    ```js theme={null}
    import express from 'express';
    import crypto from 'crypto';

    const SECRET = process.env.CATIVA_WEBHOOK_SECRET; // whsec_...

    app.post(
      '/webhooks/cativa',
      express.raw({ type: 'application/json' }),
      async (req, res) => {
        // HMAC verification — see /webhooks/subscribing-and-verifying
        const sigHeader = req.header('X-Cativa-Signature') ?? '';
        const parts = Object.fromEntries(
          sigHeader.split(',').map(p => p.split('='))
        );
        const ts = Number(parts.t);
        const sig = parts.v1;
        if (Math.abs(Date.now() / 1000 - ts) > 300) return res.status(400).end();
        const expected = crypto
          .createHmac('sha256', SECRET)
          .update(`${ts}.${req.body.toString('utf8')}`)
          .digest('hex');
        if (!crypto.timingSafeEqual(
          Buffer.from(expected, 'hex'),
          Buffer.from(sig, 'hex')
        )) return res.status(401).end();

        const event = JSON.parse(req.body.toString('utf8'));

        if (event.BadgeName === 'Premium') {
          await sendWelcomeEmail(event.User.Email, event.User.FirstName);
          await analytics.track('Purchase Activated', {
            email: event.User.Email,
            badge: event.BadgeName
          });
        }

        res.status(200).send('ok');
      }
    );
    ```
  </Step>
</Steps>

## Cancellation and chargeback

When the gateway cancels or charges back, you want to **remove the badge** to revoke access.

```js theme={null}
async function handleCancellation({ email, productId, purchaseId }) {
  const badgeId = PRODUCT_TO_BADGE[productId];
  if (!badgeId) return;

  const cativaUserId = await resolveCativaUserId(email);
  if (!cativaUserId) return;

  // When the endpoint is available:
  await fetch(
    `https://apis.cativalab.digital/tenant/v1/.../badges/${badgeId}/users/${cativaUserId}`,
    {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${process.env.CATIVA_API_KEY}` }
    }
  );

  await db.purchases.update({ purchaseId }, { revokedAt: new Date() });
}
```

<Note>
  Badge removal via partner API Key is on the **same waitlist** as assignment. Same workaround: today it's done in the Console; the public endpoint is shipping soon.
</Note>

Removing the badge is enough — group, course and space access tied to the badge disappears immediately.

## Common errors

<AccordionGroup>
  <Accordion title="Buyer doesn't have a Cativa account, paid and ended up without access">
    Most common case. The customer bought on Hotmart before joining your community.

    **Fix:** step 2 above covers it — register the intent in `pendingGrants` and send the invite. Subscribe the `user_created` webhook and, when sign-up happens, complete the assignment:

    ```js theme={null}
    // user_created listener
    if (event.event === 'user_created') {
      const pending = await db.pendingGrants.findAll({ email: event.User.Email });
      for (const grant of pending) {
        await handlePurchase({
          email: event.User.Email,
          productId: grant.productId,
          purchaseId: grant.purchaseId
        });
        await db.pendingGrants.delete({ id: grant.id });
      }
    }
    ```
  </Accordion>

  <Accordion title="Different email between gateway and Cativa for the same user">
    Happens when the customer pays with a personal email and joins the community with a corporate one. No automatic fix.

    **Fix:** offer an "I already bought, but I'm logged in with a different email" page in your app where the customer enters the purchase email. You validate the purchase ID locally and assign the badge to the **logged-in** user (not to the purchase email).
  </Accordion>

  <Accordion title="Gateway webhook fired twice — did the user get the badge twice?">
    Badge assignment in Cativa is idempotent: applying the same badge twice produces the same final state. But for safety, store the gateway `purchaseId` in a local table and check before:

    ```js theme={null}
    if (await db.purchases.exists({ purchaseId })) {
      return; // already processed
    }
    ```

    This also helps audit/reconcile later (e.g. financial report vs grants).
  </Accordion>

  <Accordion title="Gateway offers &#x22;recurring subscription&#x22; — how do I handle monthly renewal?">
    Each gateway fires a webhook when the renewal is charged successfully (e.g. Hotmart `SUBSCRIPTION_CHARGE_SUCCESS`). Treat it as an idempotent `handlePurchase` — re-apply the badge (no effect if already there). If the renewal fails (e.g. card declined), treat it as `handleCancellation`.
  </Accordion>

  <Accordion title="Customer refunded in < 7 days but kept using the community">
    You need to **react to chargeback fast** — the gateway webhook arrives, you remove the badge, access to the resources tied to it disappears. Don't rely on a nightly batch job for this.
  </Accordion>

  <Accordion title="Multiple badges for the same product (course + bonus)">
    Map a product to **multiple badges** when needed. Example: product `Premium Course` unlocks both `Premium` (course access) and `Mentoring-2026` (mentoring group access). Make two assignments inside `handlePurchase`.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Cativa webhooks" icon="webhook" href="/en/webhooks/subscribing-and-verifying">
    Subscribe listeners for `user_created` (cover the "bought before signing up" case) and `user_received_badge` (trigger post-access actions).
  </Card>

  <Card title="Sync members from your CRM" icon="arrows-rotate" href="/en/guides/sync-members-from-crm">
    If you also run a CRM, combine this flow with tag sync to have a single hub of permissions.
  </Card>
</CardGroup>
