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

# Integrate with HubSpot

> Build a two-way sync between a Mighty Network and HubSpot with the Mighty API: members to contacts, and HubSpot activity back to tags and invites

This guide walks through building a HubSpot integration on the [Mighty API](/api). When you're done, you'll have a small backend service that:

* Creates or updates a **HubSpot contact** whenever someone joins your Mighty Network, buys a plan, or is tagged
* **Loads your existing members** into HubSpot
* Reacts to **HubSpot activity** by tagging members or inviting new people to your Network

The examples use Node.js 18+ and Express, but every step is plain HTTP and GraphQL, so you can port it to any language.

## How it works

```mermaid theme={null}
flowchart LR
  subgraph Mighty["Mighty Network"]
    WH[Webhook callback]
    GQL[Mighty API<br/>GraphQL]
  end
  subgraph You["Your integration service"]
    R1["/webhooks/mighty"]
    R2["/webhooks/hubspot"]
  end
  subgraph HS["HubSpot"]
    CRM[CRM API]
    WF[Workflow webhook]
  end
  WH -- member events --> R1 -- create or update contact --> CRM
  WF -- contact events --> R2 -- tag or invite --> GQL
```

* **Mighty → HubSpot:** You register a webhook with the `createWebhookCallback` mutation. Mighty sends member events to your service, and your service writes them to HubSpot.
* **HubSpot → Mighty:** A HubSpot workflow calls your service, and your service calls the Mighty API to tag or invite the person.

Your service calls the Mighty API with an OAuth token that a **Network Host** grants once, when they connect the integration.

## Before you begin

You need:

* A Mighty Network on the **Scale Plan or above**, and a Host account on it. OAuth applications and webhooks are plan-gated features.
* A HubSpot account with permission to create a [private app](https://developers.hubspot.com/docs/api/private-apps). The HubSpot → Mighty direction also needs workflows that can **Send a webhook**, which depends on your HubSpot subscription.
* A server with a public `https://` URL to run the integration service.

### Member emails

HubSpot matches contacts by email, so check how your Network exposes member emails before you start:

* Webhook payloads return an **empty** email when the member hasn't consented to commercial email, and a **masked** email (`a***@***.***`) unless your Network's plan includes member-email visibility.
* The Mighty API returns plain email addresses only to a Host token that carries the `read:userinfo` scope, on a plan that includes member-email visibility. Otherwise, addresses come back obfuscated.

Your integration should skip members without a usable email rather than create broken contacts. The examples below do this with an `isUsableEmail` check.

## Step 1: Set up HubSpot

1. In HubSpot, [create a private app](https://developers.hubspot.com/docs/api/private-apps) for the integration.
2. On the **Scopes** tab, add `crm.objects.contacts.read` and `crm.objects.contacts.write`.
3. Create the app and copy its **access token**. Store it on your server as `HUBSPOT_TOKEN`.
4. [Create custom contact properties](https://knowledge.hubspot.com/properties/create-and-edit-properties) for the Mighty data you want to keep. This guide uses:

| Label | Internal name | Field type |
| - | - | - |
| Mighty member ID | `mighty_member_id` | Single-line text |
| Mighty joined at | `mighty_joined_at` | Date picker |
| Mighty plan | `mighty_plan` | Single-line text |
| Mighty tags | `mighty_tags` | Multi-line text |

## Step 2: Create an OAuth application

1. In your Network, go to **Network Admin** > **Integrations** > **OAuth Applications** and click **New OAuth Application**.
2. Choose client type **Confidential**. Your integration runs on a server, so it can keep a client secret. See [Backend web apps](/api/oauth-client-architectures#backend-web-apps).
3. Register your service's callback URL as the redirect URI, for example `https://hubspot-sync.example.com/oauth/callback`.
4. Select these scopes:

| Scope | Why the integration needs it |
| - | - |
| `read:userinfo` | Read members' plain email addresses |
| `host:read:network_members` | List members for the backfill and look members up by email |
| `host:write:network_members` | Tag members and send invites |
| `host:write:network_integrations` | Register the webhook that sends events to your service |

5. Save the application and store the **Client ID** and **Client Secret** on your server as `MIGHTY_CLIENT_ID` and `MIGHTY_CLIENT_SECRET`.

<Note>
  Because this application requests `host:` scopes, only Hosts of the Network can complete the sign-in. That's what you want here: a Host connects the integration once, and it acts with that Host's permissions.
</Note>

For every option on this form, see [OAuth Applications](/oauth-applications).

## Step 3: Connect the Network and call the API

A Host connects the integration by going through the [Authorization Code flow](/api/authentication#authorization-code-flow) once. Your service redirects them to `https://YOUR_SUBDOMAIN.mn.co/oauth/authorize` with the scopes from Step 2, validates `state` on the callback, and exchanges the code for an access token and a refresh token. Store the refresh token encrypted on your server.

Access tokens expire after one hour, so wrap every Mighty API call in a helper that refreshes the token when it needs to:

```javascript mighty.js theme={null}
const NETWORK = process.env.MIGHTY_NETWORK; // Your subdomain, e.g. "my-community"
const USER_AGENT = "hubspot-sync/1.0 (+https://example.com)";

// Replace with your own encrypted storage
import { tokenStore } from "./token-store.js";

async function refreshAccessToken() {
  const response = await fetch(`https://${NETWORK}.mn.co/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: await tokenStore.getRefreshToken(),
      client_id: process.env.MIGHTY_CLIENT_ID,
      client_secret: process.env.MIGHTY_CLIENT_SECRET,
    }),
  });

  if (!response.ok) {
    // invalid_grant means a Host has to reconnect the integration
    throw new Error(`Token refresh failed: ${response.status}`);
  }

  const tokens = await response.json();
  // The refresh token can rotate, so always save the new one
  await tokenStore.save(tokens);
  return tokens.access_token;
}

export async function mighty(query, variables = {}) {
  let accessToken = await tokenStore.getAccessToken();
  if (!accessToken || (await tokenStore.isExpired())) {
    accessToken = await refreshAccessToken();
  }

  const response = await fetch(`https://api.mn.co/networks/${NETWORK}/graphql`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
      "User-Agent": USER_AGENT,
    },
    body: JSON.stringify({ query, variables }),
  });

  // Bot protection (403), rate limits (429), and outages return non-JSON bodies
  if (!response.ok) {
    throw new Error(`Mighty API request failed: ${response.status} ${await response.text()}`);
  }

  const { data, errors } = await response.json();
  // GraphQL returns HTTP 200 for most errors, so always check the errors array
  if (errors?.length) {
    throw new Error(errors.map((e) => e.message).join("; "));
  }
  return data;
}
```

<Warning>
  Every request to `api.mn.co` needs a non-empty `User-Agent` header. Requests without one are blocked and return an HTML page with HTTP `403`.
</Warning>

Confirm the connection works by asking who you're authenticated as:

```javascript theme={null}
const { me } = await mighty(`query { me { id name } }`);
console.log(`Connected as ${me.name}`);
```

## Step 4: Write contacts to HubSpot

Add a helper that creates or updates HubSpot contacts, matched on email. Sending contacts in batches of up to 100 keeps you well inside HubSpot's [API limits](https://developers.hubspot.com/docs/api/usage-details).

```javascript hubspot.js theme={null}
export function isUsableEmail(email) {
  // Skip empty emails and masked ones like a***@***.***
  return Boolean(email) && !email.includes("***");
}

export async function upsertContacts(contacts) {
  const inputs = contacts
    .filter((c) => isUsableEmail(c.email))
    .map(({ email, ...properties }) => ({ idProperty: "email", id: email, properties }));

  for (let i = 0; i < inputs.length; i += 100) {
    const response = await fetch(
      "https://api.hubapi.com/crm/v3/objects/contacts/batch/upsert",
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ inputs: inputs.slice(i, i + 100) }),
      }
    );

    if (!response.ok) {
      throw new Error(`HubSpot upsert failed: ${response.status} ${await response.text()}`);
    }
  }
}
```

Because every write matches on email, sending the same member twice updates the existing contact instead of creating a duplicate.

## Step 5: Register a webhook

Now tell your Network to send member events to your service. Generate a long random secret, store it as `MIGHTY_WEBHOOK_SECRET`, and register the webhook with `createWebhookCallback`. Mighty sends the secret as a Bearer token on every delivery so your service can verify it.

```javascript register-webhook.js theme={null}
import { mighty } from "./mighty.js";

const data = await mighty(
  `mutation RegisterWebhook($input: CreateWebhookCallbackInput!) {
    createWebhookCallback(input: $input) {
      webhookCallback { id url includedEvents }
      errors
    }
  }`,
  {
    input: {
      url: "https://hubspot-sync.example.com/webhooks/mighty",
      apiKey: process.env.MIGHTY_WEBHOOK_SECRET,
      includedEvents: [
        "MEMBER_JOINED",
        "MEMBER_UPDATED",
        "MEMBER_PURCHASED",
        "MEMBER_SUBSCRIPTION_CANCELED",
        "MEMBER_TAG_ADDED",
      ],
    },
  }
);

console.log(data.createWebhookCallback);
```

Run this once. To change the URL, secret, or events later, use `updateWebhookCallback`. To list the webhooks already registered on your Network, query `network { webhookCallbacks { nodes { id url includedEvents disabled } } }`.

<Tip>
  Only subscribe to the events you handle. If you omit `includedEvents`, the webhook receives every event type, including posts, comments, and polls.
</Tip>

## Step 6: Handle webhook deliveries

Each delivery is an HTTP `POST` with a JSON body:

```json theme={null}
{
  "event_id": "4f9c2e...",
  "event_timestamp": "2026-09-24T17:02:11.482913Z",
  "event_type": "MemberJoinedHook",
  "payload": {
    "member": {
      "id": 5550123,
      "email": "alex@example.com",
      "first_name": "Alex",
      "last_name": "Rivera",
      "created_at": "2026-09-24T17:02:10Z"
    },
    "space_id": 12345,
    "network_id": 12345
  }
}
```

The `event_type` is the event name followed by `Hook`, such as `MemberJoinedHook` or `MemberPurchasedHook`. Payload fields use `snake_case`.

This endpoint verifies the secret, acknowledges the delivery right away, and then writes to HubSpot:

```javascript server.js theme={null}
import express from "express";
import { upsertContacts } from "./hubspot.js";

const app = express();
app.use(express.json());

const processedEvents = new Set(); // Use a database table in production

app.post("/webhooks/mighty", async (req, res) => {
  const secret = process.env.MIGHTY_WEBHOOK_SECRET;
  if (!secret || req.get("Authorization") !== `Bearer ${secret}`) {
    return res.sendStatus(401);
  }

  // Respond first; deliveries time out after 10 seconds
  res.sendStatus(200);

  const { event_id, event_type, event_timestamp, payload } = req.body;
  if (processedEvents.has(event_id)) return; // Retried delivery

  try {
    await handleMightyEvent(event_type, payload, event_timestamp);
    // Mark the event done only after it succeeds, so a failed one can be replayed
    processedEvents.add(event_id);
  } catch (error) {
    // The delivery is already acknowledged, so Mighty won't retry it.
    // Queue the event for replay instead of dropping it.
    console.error(`Failed to process ${event_type} ${event_id}`, error);
  }
});

async function handleMightyEvent(eventType, payload, eventTimestamp) {
  switch (eventType) {
    case "MemberJoinedHook": {
      // This event also fires when a member joins a Space.
      // A Network join has space_id equal to network_id.
      if (payload.space_id !== payload.network_id) return;
      const { member } = payload;
      return upsertContacts([
        {
          email: member.email,
          firstname: member.first_name,
          lastname: member.last_name,
          mighty_member_id: String(member.id),
          // Use the delivery time, not member.created_at (account creation, not Network join)
          mighty_joined_at: eventTimestamp?.slice(0, 10),
        },
      ]);
    }

    case "MemberUpdatedHook": {
      const { member } = payload;
      return upsertContacts([
        {
          email: member.email,
          firstname: member.first_name,
          lastname: member.last_name,
        },
      ]);
    }

    case "MemberPurchasedHook":
      return upsertContacts([
        {
          email: payload.member_email,
          mighty_member_id: String(payload.member_id),
          mighty_plan: payload.plan?.name,
        },
      ]);

    case "MemberSubscriptionCanceledHook":
      // Clear the plan, or set a "canceled" property that a HubSpot workflow acts on
      return upsertContacts([{ email: payload.email, mighty_plan: "" }]);

    case "MemberTagAddedHook":
      // Store tags however suits your CRM; here, append to a text property
      return appendTag(payload.member, payload.tag.title);
  }
}

app.listen(3000);
```

Write `appendTag` to fit your CRM. For example, read the contact's current `mighty_tags` value from HubSpot and append the new tag, or add the contact to a HubSpot list named after the tag.

Before you rely on the payload shape for a new event, check it in the [webhook reference](/admin-api#webhooks). Webhooks registered through the Mighty API and through Network Admin use the same delivery format.

## Step 7: Backfill existing members

Webhooks only cover events from now on. To load everyone who joined before you registered the webhook, page through the member roster once and send each page to HubSpot:

```javascript backfill.js theme={null}
import { mighty } from "./mighty.js";
import { upsertContacts } from "./hubspot.js";

const MEMBERS_QUERY = `
  query Members($after: String) {
    network {
      members(first: 50, after: $after, sort: RESOURCE_ID) {
        nodes { resourceId email firstName lastName joinedAt }
        pageInfo { hasNextPage endCursor }
      }
    }
  }
`;

let after = null;
let total = 0;

do {
  const { network } = await mighty(MEMBERS_QUERY, { after });
  const { nodes, pageInfo } = network.members;

  await upsertContacts(
    nodes.map((m) => ({
      email: m.email,
      firstname: m.firstName,
      lastname: m.lastName,
      mighty_member_id: m.resourceId,
      mighty_joined_at: m.joinedAt?.slice(0, 10),
    }))
  );

  total += nodes.length;
  after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
} while (after);

console.log(`Backfilled ${total} members`);
```

Sort by `RESOURCE_ID` for a backfill. Other sort keys, such as the default `LAST_VISIT`, can change while you page, so a member can be skipped or returned twice. Always page from `pageInfo.endCursor` until `hasNextPage` is `false`. See [Query cost limits](/api#query-cost-limits) for how page size affects cost.

## Step 8: Send HubSpot changes to Mighty

Now go the other way. When something happens in HubSpot, like a deal closing or a contact joining a list, a HubSpot workflow calls your service and your service updates the Network.

### Set up the HubSpot workflow

1. In HubSpot, create a contact-based [workflow](https://knowledge.hubspot.com/workflows/create-workflows) with the enrollment trigger you want, such as **Lifecycle stage is Customer**.
2. Add a **Send a webhook** action with method `POST` and the URL `https://hubspot-sync.example.com/webhooks/hubspot`.
3. Include the contact's **Email**, **First name**, and **Last name** in the request body.
4. Add authentication so your service can verify the request. Send a long random secret in an `X-Sync-Secret` header and store it as `HUBSPOT_WEBHOOK_SECRET`. If you use HubSpot's request signature instead, verify that in place of the header check below.

### Tag the member, or invite them if they're new

Your service looks up the contact in your Network by email. If they're already a member, it adds a tag. If not, it sends them an invite.

```javascript server.js theme={null}
import { mighty } from "./mighty.js";

const CUSTOMER_TAG_ID = process.env.MIGHTY_CUSTOMER_TAG_ID;

app.post("/webhooks/hubspot", async (req, res) => {
  const secret = process.env.HUBSPOT_WEBHOOK_SECRET;
  if (!secret || req.get("X-Sync-Secret") !== secret) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);

  const { email, firstname, lastname } = req.body;

  try {
    const { network } = await mighty(
      `query FindMember($email: String!) {
        network { memberByEmail(email: $email) { id } }
      }`,
      { email }
    );

    if (network.memberByEmail) {
      await mighty(
        `mutation TagMember($input: CreateTagMembershipsInput!) {
          createTagMemberships(input: $input) { grantedMembers { id } errors }
        }`,
        { input: { tagId: CUSTOMER_TAG_ID, memberIds: [network.memberByEmail.id] } }
      );
    } else {
      await mighty(
        `mutation Invite($input: CreateInvitesInput!) {
          createInvites(input: $input) { count ignoredRecipients errors }
        }`,
        { input: { recipients: [{ email, firstName: firstname, lastName: lastname }] } }
      );
    }
  } catch (error) {
    // An unhandled rejection here would crash the process
    console.error(`Failed to sync HubSpot contact ${email}`, error);
  }
});
```

To find the tag's ID, query your Network's tags once and save the one you want:

```graphql theme={null}
query {
  network {
    tags(term: "Customer", first: 5) {
      nodes { id resourceId title }
    }
  }
}
```

`createInvites` can also invite people to a specific Space with `spaceId`, or to a paid or free plan with `planId`. For example, you could invite a contact straight to your premium plan when their HubSpot deal closes.

<Note>
  `memberByEmail` returns `null` both when no member has that email and when your Host token can't see the member's email, for example because of the member's email-sharing consent. It's also rate limited for lookups that don't find anyone. If an invite is `ignoredRecipients`, the person is already a member or already has a pending invite.
</Note>

## Run it in production

* **Respond fast.** Return a `2xx` response before doing any HubSpot work. Deliveries time out after 10 seconds and are retried with backoff for about two hours. If a webhook has 5 consecutive failed deliveries and the first failure is at least 3 days old, it's automatically disabled. Once disabled, no more deliveries are attempted and its `disabled` field is `true`. To re-enable it, fix your endpoint, then call `updateWebhookCallback`.
* **Ignore duplicates.** Retries can deliver the same event more than once. Store each `event_id` you've processed in a database, not in memory.
* **Don't lose failed events.** Your endpoint acknowledges each delivery before it writes to HubSpot, so Mighty doesn't retry an event that fails afterward. Put deliveries on a durable queue, or store failed events, and replay them once HubSpot is reachable again.
* **Check every GraphQL response.** The Mighty API returns HTTP `200` for most errors, and mutations also return an `errors` array in their payload. See [Errors](/api#errors).
* **Handle a disconnected Host.** If a token refresh returns `invalid_grant`, the Host revoked access, changed their password, or the application was deleted. Alert your team so a Host can reconnect the integration.
* **Watch your quotas.** Mighty API usage counts against your Network's quota (see [Rate Limits](/api#rate-limits)), and HubSpot enforces its own limits. Batch writes where you can.
* **Track schema changes.** Subscribe to the [changelog](/api/changelog) so you hear about new fields and deprecations before they affect your integration.

## Next steps

<CardGroup cols={2}>
  <Card title="Authentication" icon="key" href="/api/authentication">
    Implement the OAuth flow the Host uses to connect the integration.
  </Card>

  <Card title="GraphQL Schema Explorer" icon="compass" href="/api/graphql-explorer">
    Explore member, tag, and invite fields to sync more data to HubSpot.
  </Card>

  <Card title="OAuth Client Architectures" icon="sitemap" href="/api/oauth-client-architectures">
    Keep tokens on your server and avoid the token proxy anti-pattern.
  </Card>

  <Card title="Webhook reference" icon="bolt" href="/admin-api#webhooks">
    See every webhook event and its payload.
  </Card>
</CardGroup>
