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

> Use Mighty API webhooks and GraphQL to keep ActiveCampaign contacts, lists, and tags in sync with your Mighty Network

This guide walks you through a direct integration between your Mighty Network and [ActiveCampaign](https://www.activecampaign.com/). When you finish, you'll have:

* **Real-time sync from Mighty to ActiveCampaign.** When a member joins, leaves, updates their profile, gets tagged, buys a plan, cancels, or completes a course, a Mighty webhook calls your server, and your server updates the matching ActiveCampaign contact.
* **A one-time backfill.** A script that pages through your existing members with the Mighty API and adds them to ActiveCampaign.
* **Optional sync from ActiveCampaign back to Mighty.** An ActiveCampaign automation that tags the member in your Network, for example when a lead finishes a nurture sequence.

```mermaid theme={null}
flowchart LR
  M[Mighty Network] -- webhook --> S[Your integration server]
  S -- REST API --> A[ActiveCampaign]
  A -- automation webhook --> S
  S -- GraphQL --> M
```

<Tip>
  If you'd rather not host any code, [Zapier](/for-hosts/analytics-and-integrations/can-i-use-zapier-with-mighty-networks) can connect Mighty Networks to ActiveCampaign without it. Use this guide when you want full control over the mapping, the ability to backfill existing members, or more volume than a no-code plan allows.
</Tip>

## Prerequisites

* A Mighty Network on the **Scale plan or above**. You need it for OAuth applications and webhooks.
* A **Network Host** account on that Network. You authorize the integration as this Host.
* An **ActiveCampaign** account with permission to view API settings.
* A server that can receive HTTPS requests from the public internet. The examples use Node.js 18 or later with [Express](https://expressjs.com/), but any language works.

## Step 1: Get your ActiveCampaign API credentials

1. In ActiveCampaign, go to **Settings** > **Developer**.
2. Copy your **API URL** (for example, `https://youraccount.api-us1.com`) and your **API Key**.
3. Create the list you want members added to, then note its numeric ID. The ID appears in the list's URL in ActiveCampaign, and `GET /api/3/lists` returns it too.

Store these as environment variables on your server:

```bash theme={null}
AC_API_URL=https://youraccount.api-us1.com
AC_API_KEY=your-activecampaign-api-key
AC_LIST_ID=1
```

<Warning>
  Your ActiveCampaign API key has full access to your account. Keep it on your server. Never put it in client-side code or source control.
</Warning>

## Step 2: Create an OAuth application in Mighty

The integration calls the Mighty API as a Network Host, so it needs an OAuth application and a Host's authorization.

1. In your Network, go to **Network Admin** > **Integrations** > **OAuth Applications** and click **New OAuth Application**.

2. Choose the **Confidential** client type, because the integration runs on your server.

3. Register the redirect URI your server handles, for example `https://your-server.example.com/oauth/callback`.

4. Select these scopes:

   | Scope | Why the integration needs it |
   | - | - |
   | `host:write:network_integrations` | Register the webhook that sends member events to your server. |
   | `host:read:network_members` | Read the member roster for the backfill, and look up members by email. |
   | `read:userinfo` | Read member email addresses in plain text rather than obfuscated. |
   | `host:write:network_members` | Optional. Tag members in Mighty from ActiveCampaign ([Step 6](#step-6-optional-sync-tags-from-activecampaign-back-to-mighty)). |

5. Save the application, then copy the **Client ID** and **Client Secret**.

Next, complete the [Authorization Code flow](/api/authentication#authorization-code-flow) once, signed in as a Network Host. Store the resulting **refresh token** securely on your server. It seeds the token helper in [Step 5](#step-5-backfill-existing-members), which the backfill and the optional reverse sync both use to get access tokens. The webhook you register in Step 4 keeps delivering events on its own.

```bash theme={null}
MIGHTY_NETWORK=your-subdomain
MIGHTY_CLIENT_ID=your-client-id
MIGHTY_CLIENT_SECRET=your-client-secret
MIGHTY_REFRESH_TOKEN=your-refresh-token
```

For more on client types, redirect URIs, and scopes, see [OAuth Applications](/oauth-applications).

## Step 3: Build the webhook receiver

Mighty delivers each webhook as an HTTPS `POST` with a JSON body:

```json theme={null}
{
  "event_id": "4f9c0b6e...",
  "event_timestamp": "2026-09-25T17:04:12.381920Z",
  "event_type": "MemberJoinedHook",
  "payload": {
    "member": {
      "id": 67890,
      "email": "jordan@example.com",
      "first_name": "Jordan",
      "last_name": "Lee"
    },
    "space_id": 12345,
    "network_id": 12345
  }
}
```

The `event_type` is the event name with a `Hook` suffix, for example `MemberJoinedHook`. When you register the webhook with an API key, Mighty sends that key in an `Authorization: Bearer` header on every delivery. Check it before you trust the request.

### ActiveCampaign helpers

Start with a small module that wraps the ActiveCampaign endpoints the integration uses. It queues requests to stay under ActiveCampaign's rate limit of 5 requests per second.

```javascript activecampaign.js theme={null}
const { AC_API_URL, AC_API_KEY, AC_LIST_ID } = process.env;

// Queue every request so each one starts at least 200 ms after the last, even
// when several webhooks arrive at once. This stays under 5 requests per second.
let queue = Promise.resolve();

function nextSlot() {
  const slot = queue;
  queue = queue.then(() => new Promise((resolve) => setTimeout(resolve, 200)));
  return slot;
}

async function ac(path, { method = "GET", body } = {}) {
  await nextSlot();

  const res = await fetch(`${AC_API_URL}/api/3${path}`, {
    method,
    headers: { "Api-Token": AC_API_KEY, "Content-Type": "application/json" },
    body: body && JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`ActiveCampaign ${method} ${path}: ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}

// Mighty leaves out an email the member hasn't agreed to share, and masks it
// (a***@***.***) when your plan or token can't see it. Skip both.
export function usableEmail(email) {
  return email && !email.includes("*") ? email : null;
}

// Create the contact, or update the one with this email. Returns its ID.
export async function syncContact({ email, firstName, lastName }) {
  const { contact } = await ac("/contact/sync", {
    method: "POST",
    body: { contact: { email, firstName, lastName } },
  });
  return contact.id;
}

export async function subscribeToList(contactId) {
  await ac("/contactLists", {
    method: "POST",
    body: { contactList: { list: AC_LIST_ID, contact: contactId, status: 1 } },
  });
}

const tagIds = new Map();

async function tagId(name, { create = true } = {}) {
  if (tagIds.has(name)) return tagIds.get(name);
  const { tags } = await ac(`/tags?search=${encodeURIComponent(name)}`);
  let tag = tags.find((t) => t.tag === name);
  if (!tag) {
    if (!create) return null;
    ({ tag } = await ac("/tags", {
      method: "POST",
      body: { tag: { tag: name, tagType: "contact", description: "Synced from Mighty Networks" } },
    }));
  }
  tagIds.set(name, tag.id);
  return tag.id;
}

export async function addTag(contactId, name) {
  await ac("/contactTags", {
    method: "POST",
    body: { contactTag: { contact: contactId, tag: await tagId(name) } },
  });
}

export async function removeTag(contactId, name) {
  const id = await tagId(name, { create: false });
  if (!id) return;
  const { contactTags } = await ac(`/contacts/${contactId}/contactTags`);
  const match = contactTags.find((ct) => String(ct.tag) === String(id));
  if (match) await ac(`/contactTags/${match.id}`, { method: "DELETE" });
}
```

### The webhook endpoint

The server maps each Mighty event to ActiveCampaign updates. Adjust the tag names to fit how you segment contacts.

```javascript server.js theme={null}
import crypto from "node:crypto";
import express from "express";
import { usableEmail, syncContact, subscribeToList, addTag, removeTag } from "./activecampaign.js";

const { WEBHOOK_SECRET } = process.env;
// Without a secret, the expected header would be "Bearer undefined", which anyone can send.
if (!WEBHOOK_SECRET) throw new Error("Set WEBHOOK_SECRET before starting the server");
const app = express();

// Find or create the ActiveCampaign contact for a member in a webhook payload.
async function contactFor(member) {
  const email = usableEmail(member.email);
  if (!email) return null;
  return syncContact({ email, firstName: member.first_name, lastName: member.last_name });
}

const handlers = {
  // Fires for the Network and for each Space. A Network join has space_id equal to network_id.
  async MemberJoinedHook({ member, space_id, network_id }) {
    const contactId = await contactFor(member);
    if (!contactId) return;
    if (space_id === network_id) {
      await subscribeToList(contactId);
      await removeTag(contactId, "Mighty: Former member");
      await addTag(contactId, "Mighty: Member");
    } else {
      await addTag(contactId, `Mighty Space: ${space_id}`);
    }
  },

  async MemberLeftHook({ member, space_id, network_id }) {
    const contactId = await contactFor(member);
    if (!contactId) return;
    if (space_id === network_id) {
      await removeTag(contactId, "Mighty: Member");
      await addTag(contactId, "Mighty: Former member");
    } else {
      await removeTag(contactId, `Mighty Space: ${space_id}`);
    }
  },

  async MemberUpdatedHook({ member }) {
    await contactFor(member);
  },

  async MemberTagAddedHook({ member, tag }) {
    const contactId = await contactFor(member);
    if (contactId) await addTag(contactId, `Mighty tag: ${tag.title}`);
  },

  async MemberTagRemovedHook({ member, tag }) {
    const contactId = await contactFor(member);
    if (contactId) await removeTag(contactId, `Mighty tag: ${tag.title}`);
  },

  // Purchase and cancellation payloads carry member fields at the top level.
  async MemberPurchasedHook(p) {
    const email = usableEmail(p.member_email);
    if (!email) return;
    const contactId = await syncContact({ email, firstName: p.member_first_name, lastName: p.member_last_name });
    // plan.name is optional; fall back to plan.id so the tag is never "Mighty plan: undefined".
    await addTag(contactId, `Mighty plan: ${p.plan.name ?? p.plan.id}`);
  },

  async MemberSubscriptionCanceledHook(p) {
    const email = usableEmail(p.email);
    if (!email) return;
    const contactId = await syncContact({ email, firstName: p.first_name, lastName: p.last_name });
    const plan = p.plan.name ?? p.plan.id;
    await removeTag(contactId, `Mighty plan: ${plan}`);
    await addTag(contactId, `Mighty canceled: ${plan}`);
  },

  async MemberCourseProgressCompletedHook({ member, course }) {
    const contactId = await contactFor(member);
    if (contactId) await addTag(contactId, `Mighty course completed: ${course.name}`);
  },
};

// Deliveries can repeat. Replace this with a durable store (a database table, Redis) in production.
const processed = new Set();

app.post("/webhooks/mighty", express.json(), async (req, res) => {
  const expected = Buffer.from(`Bearer ${WEBHOOK_SECRET}`);
  const received = Buffer.from(req.get("authorization") ?? "");
  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return res.sendStatus(401);
  }

  const { event_id, event_type, payload } = req.body;
  const handler = handlers[event_type];
  if (!handler || processed.has(event_id)) return res.sendStatus(200);

  try {
    await handler(payload);
    processed.add(event_id);
    res.sendStatus(200);
  } catch (err) {
    console.error(`Failed to process ${event_type} ${event_id}`, err);
    res.sendStatus(500); // Any non-2xx response makes Mighty retry the delivery later.
  }
});

app.listen(process.env.PORT ?? 3000);
```

Generate a long random value for `WEBHOOK_SECRET`, for example with `openssl rand -hex 32`. You register the same value with Mighty in the next step.

<Note>
  Mighty expects a `2xx` response within 10 seconds. The example calls ActiveCampaign before it responds, which works for most Networks. But the queue in `activecampaign.js` spaces requests 200 ms apart, so a handler that makes several ActiveCampaign calls takes about a second to finish. If several events arrive at once, they queue behind each other and can add up to more than 10 seconds. If you expect bursts of activity, such as a large import or a launch, put incoming events on a queue, respond `200` right away, and process the queue in a background worker.
</Note>

## Step 4: Register the webhook

Deploy the receiver, then register its URL with the `createWebhookCallback` mutation. You can run it in the GraphiQL explorer at `https://your-subdomain.mn.co/admin/headless-api/explorer`, or send it with any GraphQL client and a Host access token.

```graphql theme={null}
mutation RegisterActiveCampaignWebhook {
  createWebhookCallback(
    input: {
      url: "https://your-server.example.com/webhooks/mighty"
      apiKey: "YOUR_WEBHOOK_SECRET"
      includedEvents: [
        MEMBER_JOINED
        MEMBER_LEFT
        MEMBER_UPDATED
        MEMBER_TAG_ADDED
        MEMBER_TAG_REMOVED
        MEMBER_PURCHASED
        MEMBER_SUBSCRIPTION_CANCELED
        MEMBER_COURSE_PROGRESS_COMPLETED
      ]
    }
  ) {
    webhookCallback {
      resourceId
      url
      includedEvents
    }
    errors
  }
}
```

Subscribe only to the events your handlers use. If you omit `includedEvents`, the webhook receives every event type, including posts, comments, and reactions.

To confirm it works, join the Network with a test account. A contact tagged `Mighty: Member` should appear in ActiveCampaign within a minute or so.

## Step 5: Backfill existing members

Webhooks only cover changes from now on. To bring over the members you already have, run a one-time script that pages through the roster with the Mighty API.

The backfill and the optional reverse sync in [Step 6](#step-6-optional-sync-tags-from-activecampaign-back-to-mighty) both need a Mighty access token. Each refresh returns a new refresh token and invalidates the old one, so a shared helper needs to persist the refresh token it gets back and reuse it next time, not the one you started with. This one stores it in a JSON file and caches the access token in memory until shortly before it expires.

```javascript mighty.js theme={null}
import { readFile, writeFile } from "node:fs/promises";

const { MIGHTY_NETWORK, MIGHTY_CLIENT_ID, MIGHTY_CLIENT_SECRET, MIGHTY_REFRESH_TOKEN } = process.env;
const TOKEN_FILE = new URL("./.mighty-token.json", import.meta.url);
const USER_AGENT = "activecampaign-sync/1.0 (+https://your-server.example.com)";

let cached = null; // { accessToken, expiresAt }
let refreshing = null;

async function currentRefreshToken() {
  try {
    return JSON.parse(await readFile(TOKEN_FILE, "utf8")).refresh_token;
  } catch {
    return MIGHTY_REFRESH_TOKEN; // First run: fall back to the token from Step 2.
  }
}

async function refresh() {
  const res = await fetch(`https://${MIGHTY_NETWORK}.mn.co/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": USER_AGENT },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: await currentRefreshToken(),
      client_id: MIGHTY_CLIENT_ID,
      client_secret: MIGHTY_CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Token refresh failed: ${res.status}`);
  const token = await res.json();
  await writeFile(TOKEN_FILE, JSON.stringify({ refresh_token: token.refresh_token }));
  cached = { accessToken: token.access_token, expiresAt: Date.now() + (token.expires_in - 60) * 1000 };
  return cached.accessToken;
}

export async function getAccessToken() {
  if (cached && Date.now() < cached.expiresAt) return cached.accessToken;
  // Share one in-flight refresh across concurrent callers instead of racing.
  if (!refreshing) refreshing = refresh().finally(() => (refreshing = null));
  return refreshing;
}

export async function mighty(query, variables) {
  const res = await fetch(`https://api.mn.co/networks/${MIGHTY_NETWORK}/graphql`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${await getAccessToken()}`,
      "Content-Type": "application/json",
      "User-Agent": USER_AGENT,
    },
    body: JSON.stringify({ query, variables }),
  });
  // Bot protection (403) and rate limits (429) return non-JSON bodies.
  if (!res.ok) throw new Error(`Mighty API request failed: ${res.status}`);
  const { data, errors } = await res.json();
  if (errors) throw new Error(JSON.stringify(errors));
  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`. Set `USER_AGENT` to your app's name and URL.
</Warning>

<Note>
  A JSON file is fine for a single-server script. In production, store the refresh token in a database or secret store instead.
</Note>

```javascript backfill.js theme={null}
import { usableEmail, syncContact, subscribeToList, addTag } from "./activecampaign.js";
import { mighty } from "./mighty.js";

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

let after = null;
let synced = 0;

do {
  const { network } = await mighty(MEMBERS_QUERY, { after });
  const { nodes, pageInfo } = network.members;
  for (const member of nodes) {
    const email = usableEmail(member.email);
    if (!email) continue;
    const contactId = await syncContact({ email, firstName: member.firstName, lastName: member.lastName });
    await subscribeToList(contactId);
    await addTag(contactId, "Mighty: Member");
    for (const tag of member.tags) await addTag(contactId, `Mighty tag: ${tag.title}`);
    synced += 1;
  }

  after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
} while (after);

console.log(`Synced ${synced} members to ActiveCampaign`);
```

Sorting by `RESOURCE_ID` returns each member exactly once, even if members visit or join while the script runs. Always page from `pageInfo.endCursor`. See [Query cost limits](/api#query-cost-limits) for how page size affects a query's cost.

Run the backfill after you register the webhook. Otherwise you can miss members who join between the two steps. Running it again is safe, because `contact/sync` updates existing contacts rather than creating duplicates.

## Step 6 (optional): Sync tags from ActiveCampaign back to Mighty

You can also act on your Network from ActiveCampaign. For example, when a lead finishes a nurture sequence, tag them in Mighty so a Mighty automation can welcome them to a Space.

1. In Mighty, create the tag you want to apply, then find its ID with this query:

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

2. Add an endpoint to your server that looks up the member by email and applies the tag. ActiveCampaign's automation webhooks send form-encoded contact data and can't set an authorization header, so put a secret in the URL instead.

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

   const { AC_WEBHOOK_SECRET, MIGHTY_TAG_ID } = process.env;

   app.post("/webhooks/activecampaign", express.urlencoded({ extended: true }), async (req, res) => {
     if (!AC_WEBHOOK_SECRET || req.query.secret !== AC_WEBHOOK_SECRET) return res.sendStatus(401);

     try {
       const email = req.body.contact?.email;
       const { network } = await mighty(
         `query ($email: String!) { network { memberByEmail(email: $email) { resourceId } } }`,
         { email },
       );
       // Not every contact is a member of your Network.
       if (!network.memberByEmail) return res.sendStatus(200);

       await mighty(
         `mutation ($tagId: ID!, $memberIds: [ID!]!) {
            createTagMemberships(input: { tagId: $tagId, memberIds: $memberIds }) { errors }
          }`,
         { tagId: MIGHTY_TAG_ID, memberIds: [network.memberByEmail.resourceId] },
       );
       res.sendStatus(200);
     } catch (err) {
       console.error("Failed to tag member from ActiveCampaign automation", err);
       res.sendStatus(500);
     }
   });
   ```

   Failed `memberByEmail` lookups are rate limited per Network, so point the ActiveCampaign automation only at contacts you know are members, for example ones tagged `Mighty: Member`, rather than your whole list. Past the limit, the query returns a `THROTTLED` error.

3. In ActiveCampaign, open the automation and add a **Webhook** action pointing at `https://your-server.example.com/webhooks/activecampaign?secret=YOUR_AC_WEBHOOK_SECRET`.

## Troubleshooting

<AccordionGroup>
  <Accordion title="Contacts aren't being created">
    Check your server logs for `401` responses. A `401` means the `apiKey` you registered doesn't match `WEBHOOK_SECRET`. Then check the health of the webhook in Mighty:

    ```graphql theme={null}
    query {
      network {
        webhookCallbacks(first: 10) {
          nodes { url disabled disabledAt consecutiveFailures }
        }
      }
    }
    ```

    A rising `consecutiveFailures` count means your endpoint is returning errors or timing out. Mighty retries failed deliveries, and pauses a webhook that keeps failing. See [Circuit breaker](/admin-api#circuit-breaker).
  </Accordion>

  <Accordion title="Some members are skipped">
    The integration skips members whose email it can't read. A webhook payload leaves out the email of a member who hasn't agreed to share it, and masks it (`a***@***.***`) when your plan doesn't include member email visibility. The GraphQL `email` field is also obfuscated unless your token has the `read:userinfo` scope and belongs to a Network Host.
  </Accordion>

  <Accordion title="A member who changed their email has two contacts">
    `contact/sync` matches contacts by email, so a new address creates a new contact. To keep one contact per member, store the ActiveCampaign contact ID against the Mighty member ID (`payload.member.id`). On `MemberUpdatedHook`, update that contact's email through `PUT /api/3/contacts/{id}`.
  </Accordion>

  <Accordion title="ActiveCampaign returns 429 errors">
    ActiveCampaign allows 5 requests per second per account. The queue in `activecampaign.js` enforces that limit within one process only, and the backfill script runs as a separate process from `server.js`. If you run the backfill while the webhook receiver is live, each enforces the limit on its own requests, and their combined traffic can still exceed 5 requests per second. Run the backfill during a maintenance window, or add a shared rate limiter, such as one backed by Redis, if you run several servers or workers.
  </Accordion>
</AccordionGroup>

## Related

<CardGroup cols={2}>
  <Card title="Authentication" icon="key" href="/api/authentication">
    Run the OAuth flow and refresh access tokens.
  </Card>

  <Card title="Webhooks" icon="bolt" href="/admin-api#webhooks">
    See delivery behavior, retries, and the payload for every event.
  </Card>

  <Card title="Mighty API" icon="diagram-project" href="/api">
    Explore the GraphQL endpoint, rate limits, and errors.
  </Card>

  <Card title="OAuth Applications" icon="shield-keyhole" href="/oauth-applications">
    Configure client types, redirect URIs, and scopes.
  </Card>
</CardGroup>
