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

> Sync Mighty Network members, plans, and tags into Salesforce Contacts with the Mighty API, and push Salesforce changes back

## Overview

This guide walks you through building a service that connects a Mighty Network to Salesforce with the [Mighty API](/api). When you finish, you have:

* **A backfill** that copies every member of your Network into Salesforce as a Contact
* **A live sync** that updates those Contacts within moments of a member joining, editing their profile, buying a plan, or gaining a tag
* **A write-back path** that tags members in your Network when your team updates a Contact in Salesforce

The examples use Node.js with [Express](https://expressjs.com/) and the [jsforce](https://jsforce.github.io/) Salesforce client, but every step is plain HTTP and GraphQL, so you can port it to any language.

### How the pieces fit

```mermaid theme={null}
flowchart LR
  M[Mighty Network] -- webhooks --> S[Sync service]
  S -- GraphQL queries and mutations --> M
  S -- REST API upserts --> SF[Salesforce]
  SF -- flow callouts --> S
```

Your sync service sits between the two systems. It holds a host's Mighty API token and a Salesforce integration credential, and it's the only component that talks to both.

A small service is the simplest way to receive Mighty's webhooks. Receiving them inside Salesforce would mean exposing a public Apex endpoint through a guest-user site, which Salesforce's own security guidance advises against.

<Tip>
  If you only need to create or update Salesforce records when something happens in your Network, and don't need a backfill or custom logic, try [Mighty's Zapier integration](/for-hosts/analytics-and-integrations/can-i-use-zapier-with-mighty-networks) first. It connects to Salesforce without code.
</Tip>

The service treats each webhook as a signal, not as the data itself. When a member event arrives, the service reads that member's current record from the Mighty API and upserts it into Salesforce. Because every upsert writes the latest state, duplicate or out-of-order deliveries can't leave a Contact stale.

## Before you begin

You need:

* **A Mighty Network on the Scale plan or above.** OAuth applications, which issue Mighty API tokens, are available from Scale up. Webhooks follow your plan too.
* **A host account on the Network.** The integration reads the member roster and registers webhooks, which only hosts can do.
* **Member email visibility on your plan**, if you want to match members to Contacts by email. The Mighty API returns a member's email to hosts only when the Network's plan includes member-email visibility and the member has consented to sharing it. Without it, `email` is `null` and you match on the Mighty member ID instead.
* **A Salesforce org** with API access and permission to create custom fields and external client apps.
* **An HTTPS endpoint** where your sync service can receive webhooks and the OAuth callback.

<Tip>
  Consider authorizing the integration with a dedicated host account, such as `integrations@yourcompany.com`, rather than a person's account. The token acts as that host, so reads restricted to Network Hosts and Moderators — like the member roster — stop returning data if that account drops below both roles, and the token stops working entirely if they change their password.
</Tip>

## Step 1: Map your data

Decide which member data belongs in Salesforce before you write any code. This guide syncs the following fields onto the standard Contact object:

| Mighty API field | Salesforce Contact field | Notes |
| - | - | - |
| `Member.resourceId` | `Mighty_Member_ID__c` | The upsert key. Matches the member ID in webhook payloads. |
| `Member.firstName` | `FirstName` | |
| `Member.lastName` | `LastName` | Required in Salesforce. Fall back to `name` when it's empty. |
| `Member.email` | `Email` | `null` without member-email visibility or consent, and masked without the `read:userinfo` scope. |
| `Member.memberType` | `Mighty_Member_Type__c` | For example `FULL_MEMBER`. |
| `Member.joinedAt` | `Mighty_Joined_At__c` | |
| `Member.lastActiveAt` | `Mighty_Last_Active_At__c` | |
| `Member.tags[].title` | `Mighty_Tags__c` | Semicolon-separated. |
| `PaymentSubscription.plan.name` | `Mighty_Plan__c` | The member's most recent subscription. |
| `PaymentSubscription.status` | `Mighty_Subscription_Status__c` | For example `ACTIVE` or `CANCELED`. Only shows up when the query's `statuses` argument includes `CANCELED` — see [Step 6](#step-6-backfill-existing-members). |
| (none) | `Mighty_Status__c` | Set to `Member` or `Left` by the sync service. |

Browse the [GraphQL Schema Explorer](/api/graphql-explorer) to find other fields worth syncing, such as custom field answers (`Member.customFieldResponses`), badges, or course progress. The GraphQL type behind every `Member.*` field above is named `Member`, not `User`.

## Step 2: Prepare Salesforce

<Steps>
  <Step title="Create the custom fields">
    In **Setup** > **Object Manager** > **Contact** > **Fields & Relationships**, create the custom fields from the mapping table. Create `Mighty_Member_ID__c` as a **Text** field and select both **Unique** and **External ID**, so you can upsert on it. Use **Date/Time** for the timestamp fields, **Long Text Area** for `Mighty_Tags__c`, and **Text** for the rest.
  </Step>

  <Step title="Create an integration user">
    Create a Salesforce user for the integration, and give it a permission set that grants API access plus read and edit access to Contact and the new fields.
  </Step>

  <Step title="Create an external client app">
    In **Setup**, open **External Client App Manager** and create an external client app with OAuth enabled. In its OAuth policies, select **Enable Client Credentials Flow** and set the integration user as the user the flow runs as. Note the consumer key and consumer secret. See Salesforce's guide to [configuring the client credentials flow for external client apps](https://help.salesforce.com/s/articleView?id=sf.meta_configure_client_credentials_flow_for_external_client_apps.htm\&type=5).

    Since Spring '26, Salesforce blocks creating new connected apps unless Salesforce Support grants an exception, so use an external client app. An existing connected app with the client credentials flow enabled also works.
  </Step>
</Steps>

## Step 3: Create a Mighty OAuth application

In your Network, go to **Network Admin** > **Integrations** > **OAuth Applications** and click **New OAuth Application**. See [OAuth Applications](/oauth-applications) for every option.

* **Client type:** **Confidential**. Your sync service runs on a server, so it can keep a Client Secret.
* **Redirect URI:** your service's callback route, for example `https://sync.example.com/oauth/callback`.
* **Scopes:** request only what the integration uses:

| Scope | Used for |
| - | - |
| `host:read:network_members` | Reading the member roster, profiles, and tags |
| `host:read:network_plans` | Reading plans and member subscriptions |
| `host:write:network_integrations` | Registering the webhook in [Step 7](#step-7-keep-salesforce-current-with-webhooks) |
| `host:write:network_members` | Tagging members from Salesforce in [Step 8](#step-8-write-changes-back-to-mighty). Skip it for a one-way sync. |
| `read:userinfo` | Reading real member email addresses instead of the masked form. Skip it if you only match members by ID. |

Save the application, then store the **Client ID** and **Client Secret** in your secret manager.

<Note>
  Every scope above except `read:userinfo` is a host scope, so only a host of the Network can complete the authorization — that's intended, since the integration always acts as a host. `read:userinfo` is a contributor scope that any authorizing member grants for themselves; here that's still your dedicated host account. Combined with member-email visibility on your plan, it's what lets `Member.email` come back as a real address instead of a masked one like `j***@***.***`.
</Note>

## Step 4: Authorize the integration once

A host signs in through the [Authorization Code flow](/api/authentication#authorization-code-flow) one time. Your service keeps the resulting refresh token and uses it to mint fresh access tokens from then on, with no further sign-ins.

Add two routes to your service: one that starts the flow and one that receives the callback.

```javascript server.js theme={null}
import crypto from 'node:crypto';
import express from 'express';
import session from 'express-session';
import { store } from './store.js'; // your encrypted token storage

const SUBDOMAIN = process.env.MIGHTY_SUBDOMAIN; // e.g. "my-community"
const OAUTH_BASE = `https://${SUBDOMAIN}.mn.co/oauth`;
const REDIRECT_URI = 'https://sync.example.com/oauth/callback';
const SCOPES = [
  'host:read:network_members',
  'host:read:network_plans',
  'host:write:network_integrations',
  'host:write:network_members',
  'read:userinfo',
].join(' ');

const app = express();
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));

app.get('/connect', (req, res) => {
  const state = crypto.randomBytes(32).toString('base64url');
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
  req.session.oauth = { state, verifier };

  const url = new URL(`${OAUTH_BASE}/authorize`);
  url.search = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.MIGHTY_CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: SCOPES,
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });
  res.redirect(url.toString());
});

app.get('/oauth/callback', async (req, res) => {
  const saved = req.session.oauth;
  delete req.session.oauth;
  if (!saved || req.query.state !== saved.state) {
    return res.status(400).send('State mismatch');
  }
  if (req.query.iss !== `https://${SUBDOMAIN}.mn.co`) {
    return res.status(400).send('Unexpected issuer');
  }

  const tokenRes = await fetch(`${OAUTH_BASE}/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: req.query.code,
      redirect_uri: REDIRECT_URI,
      client_id: process.env.MIGHTY_CLIENT_ID,
      client_secret: process.env.MIGHTY_CLIENT_SECRET,
      code_verifier: saved.verifier,
    }),
  });
  if (!tokenRes.ok) return res.status(502).send('Token exchange failed');

  const token = await tokenRes.json();
  await store.saveMightyToken({
    accessToken: token.access_token,
    refreshToken: token.refresh_token,
    expiresAt: Date.now() + token.expires_in * 1000,
    scope: token.scope,
  });
  res.send('Connected. You can close this window.');
});
```

Protect `/connect` behind your own admin login, deploy the service, and have the integration host visit it. After they approve, check the stored `scope`. It lists what was actually granted, which can be less than you asked for.

## Step 5: Call the Mighty API from your service

Access tokens expire after one hour. The helper below refreshes the token when it's close to expiry, saves the rotated refresh token, and sends every GraphQL request with the headers the API requires.

```javascript mighty.js theme={null}
import { store } from './store.js';

const SUBDOMAIN = process.env.MIGHTY_SUBDOMAIN;
const ENDPOINT = `https://api.mn.co/networks/${SUBDOMAIN}/graphql`;

let refreshing = null;

async function accessToken() {
  const token = await store.getMightyToken();
  if (token.expiresAt - Date.now() > 60_000) return token.accessToken;

  // Share one in-flight refresh across concurrent callers, so a burst of
  // requests doesn't redeem the same refresh token more than once.
  refreshing ??= refreshToken(token).finally(() => {
    refreshing = null;
  });
  return refreshing;
}

async function refreshToken(token) {
  const res = await fetch(`https://${SUBDOMAIN}.mn.co/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: token.refreshToken,
      client_id: process.env.MIGHTY_CLIENT_ID,
      client_secret: process.env.MIGHTY_CLIENT_SECRET,
    }),
  });
  if (!res.ok) {
    // invalid_grant means the host must visit /connect again
    throw new Error(`Mighty token refresh failed: ${res.status} ${await res.text()}`);
  }

  const fresh = await res.json();
  await store.saveMightyToken({
    accessToken: fresh.access_token,
    refreshToken: fresh.refresh_token ?? token.refreshToken, // refresh tokens can rotate
    expiresAt: Date.now() + fresh.expires_in * 1000,
    scope: fresh.scope,
  });
  return fresh.access_token;
}

// Thrown when the API responds 200 with a GraphQL `errors` array. `code` is
// the first error's `extensions.code` (for example NOT_FOUND or FORBIDDEN),
// so callers can react to a specific failure instead of only logging it.
export class MightyApiError extends Error {
  constructor(errors) {
    super(errors.map((e) => `${e.extensions?.code ?? 'ERROR'}: ${e.message}`).join('; '));
    this.name = 'MightyApiError';
    this.errors = errors;
    this.code = errors[0]?.extensions?.code;
  }
}

export async function mighty(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${await accessToken()}`,
      'Content-Type': 'application/json',
      'User-Agent': 'acme-salesforce-sync/1.0 (+https://sync.example.com)',
    },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Mighty API HTTP ${res.status}`);

  const { data, errors } = await res.json();
  if (errors?.length) throw new MightyApiError(errors);
  return data;
}
```

<Warning>
  Keep the `User-Agent` header. Requests to `api.mn.co` without one are blocked and come back as an HTML page with HTTP `403`, which shows up in your code as a JSON parse error.
</Warning>

<Note>
  `refreshing` only collapses concurrent refreshes within one process. If you run your sync service as multiple instances, put a distributed lock (for example, a database row or a Redis key) around the refresh call too, or route token refresh through a single instance.
</Note>

Set up the Salesforce side with jsforce and the client credentials flow:

```javascript salesforce.js theme={null}
import jsforce from 'jsforce';

let connPromise;

async function connect() {
  const conn = new jsforce.Connection({
    instanceUrl: process.env.SF_INSTANCE_URL, // e.g. https://acme.my.salesforce.com
    oauth2: {
      clientId: process.env.SF_CLIENT_ID,
      clientSecret: process.env.SF_CLIENT_SECRET,
      loginUrl: process.env.SF_INSTANCE_URL,
    },
    // The client credentials flow issues no refresh token, so tell jsforce
    // how to get a new session when the current one expires
    refreshFn: (c, callback) => {
      c.authorize({ grant_type: 'client_credentials' })
        .then(() => callback(null, c.accessToken))
        .catch(callback);
    },
  });
  await conn.authorize({ grant_type: 'client_credentials' });
  return conn;
}

export function salesforce() {
  // Cache the in-flight promise, not the connection, so concurrent callers
  // wait on the same authorization instead of racing to create their own.
  // Clear it on failure so the next call retries instead of staying broken.
  if (!connPromise) {
    connPromise = connect().catch((err) => {
      connPromise = null;
      throw err;
    });
  }
  return connPromise;
}
```

jsforce refreshes Salesforce sessions on its own only when it holds a refresh token, and the client credentials flow doesn't issue one. The `refreshFn` above requests a new session whenever Salesforce reports the current one expired.

## Step 6: Backfill existing members

The backfill pages through the member roster and upserts each page into Salesforce. It uses two queries: one for member profiles and one for subscriptions.

```graphql Members query theme={null}
query Members($after: String) {
  network {
    members(first: 50, after: $after, sort: MEMBER_NAME) {
      pageInfo {
        hasNextPage
        endCursor
      }
      nodes {
        resourceId
        name
        firstName
        lastName
        email
        memberType
        joinedAt
        lastActiveAt
        tags {
          title
        }
      }
    }
  }
}
```

```graphql Subscriptions query theme={null}
query Subscriptions($after: String) {
  network {
    paymentSubscriptions(
      first: 50
      after: $after
      statuses: [ACTIVE, TRIALING, TRIALING_INCOMPLETE, PAST_DUE, PAST_DUE_INCOMPLETE, CANCELED, INCOMPLETE, INCOMPLETE_EXPIRED, INCOMPLETE_CANCELED, IMPORTED, IMPORTED_TRIALING, INSTALLMENTS_COMPLETE]
    ) {
      pageInfo {
        hasNextPage
        endCursor
      }
      nodes {
        status
        member {
          resourceId
        }
        plan {
          name
        }
      }
    }
  }
}
```

`members` and `paymentSubscriptions` return at most 50 records per page. The members roster is only available to hosts and moderators; any other token gets an empty list, not an error.

<Warning>
  `paymentSubscriptions` omits canceled subscriptions unless `statuses` explicitly includes `CANCELED` — and passing any `statuses` list replaces the default rather than adding to it, so a list of only `CANCELED` would exclude everything else. List every value from `PaymentSubscriptionStatus`, as above, to get the member's most recent subscription regardless of its status.
</Warning>

The backfill loads subscriptions first, because they're returned newest first. The first subscription seen for each member is their most recent one.

```javascript backfill.js theme={null}
import { mighty } from './mighty.js';
import { salesforce } from './salesforce.js';
import { MEMBERS_QUERY, SUBSCRIPTIONS_QUERY } from './queries.js';

// The mask `Member.email` falls back to without the `read:userinfo` scope or
// member-email consent, e.g. "j***@***.***" -- never a value worth writing.
const MASKED_EMAIL_SUFFIX = '***@***.***';

export function toContact(member, subscription) {
  const hasRealEmail = member.email && !member.email.endsWith(MASKED_EMAIL_SUFFIX);
  return {
    Mighty_Member_ID__c: member.resourceId,
    FirstName: member.firstName,
    LastName: member.lastName || member.name || 'Unknown',
    // Leave Email unset rather than overwrite an existing address with null
    // or a masked address
    ...(hasRealEmail && { Email: member.email }),
    Mighty_Member_Type__c: member.memberType,
    Mighty_Joined_At__c: member.joinedAt,
    Mighty_Last_Active_At__c: member.lastActiveAt,
    Mighty_Tags__c: member.tags.map((t) => t.title).join(';'),
    Mighty_Plan__c: subscription?.plan?.name ?? null,
    Mighty_Subscription_Status__c: subscription?.status ?? null,
    Mighty_Status__c: 'Member',
  };
}

async function* pages(query, pick) {
  let after = null;
  do {
    const connection = pick(await mighty(query, { after }));
    yield connection.nodes;
    after = connection.pageInfo.hasNextPage ? connection.pageInfo.endCursor : null;
  } while (after);
}

export async function backfill() {
  const latestSubscription = new Map();
  for await (const subs of pages(SUBSCRIPTIONS_QUERY, (d) => d.network.paymentSubscriptions)) {
    for (const sub of subs) {
      const id = sub.member?.resourceId;
      if (id && !latestSubscription.has(id)) latestSubscription.set(id, sub);
    }
  }

  const sf = await salesforce();
  for await (const members of pages(MEMBERS_QUERY, (d) => d.network.members)) {
    const contacts = members.map((m) => toContact(m, latestSubscription.get(m.resourceId)));
    const results = await sf.sobject('Contact').upsert(contacts, 'Mighty_Member_ID__c', { allOrNone: false });
    results.forEach((r, i) => {
      if (!r.success) console.error('Upsert failed', contacts[i].Mighty_Member_ID__c, r.errors);
    });
  }
}
```

<Note>
  Upserting on `Mighty_Member_ID__c` creates a new Contact for every member the first time it runs. If your org already has Contacts for many of your members, run a one-time match first: look up existing Contacts by email and write each member's `resourceId` into `Mighty_Member_ID__c`, so the backfill updates them instead of creating duplicates.
</Note>

Every query is checked against a [cost limit](/api#query-cost-limits) before it runs. The queries above stay well under it. If you add nested connections to the members query, such as `customFieldResponses`, lower `first` or fetch those fields in a separate query, and watch `extensions.cost.complexity` on the response.

## Step 7: Keep Salesforce current with webhooks

With the backfill done, register a webhook so your service hears about changes as they happen. Run this once, for example from a setup script:

```graphql theme={null}
mutation RegisterWebhook($input: CreateWebhookCallbackInput!) {
  createWebhookCallback(input: $input) {
    webhookCallback {
      id
      url
      includedEvents
    }
    errors
  }
}
```

```json Variables theme={null}
{
  "input": {
    "url": "https://sync.example.com/mighty/webhooks",
    "apiKey": "LONG_RANDOM_SECRET",
    "includedEvents": [
      "MEMBER_JOINED",
      "MEMBER_UPDATED",
      "MEMBER_LEFT",
      "MEMBER_TAG_ADDED",
      "MEMBER_TAG_REMOVED",
      "MEMBER_PURCHASED",
      "MEMBER_PLAN_CHANGED",
      "MEMBER_SUBSCRIPTION_RENEWED",
      "MEMBER_SUBSCRIPTION_CANCELED",
      "MEMBER_REMOVED_FROM_PLAN"
    ]
  }
}
```

Generate `apiKey` with a secure random generator and store it in your secret manager. Mighty sends it as a Bearer token in the `Authorization` header of every delivery, so your endpoint can reject requests that don't come from Mighty. Always pass `includedEvents`: if you omit it, the callback subscribes to every event, including posts and comments.

Each delivery is a JSON `POST` with an `event_id`, an `event_timestamp`, and a `payload`. See [Webhooks](/admin-api#webhooks) for the payload of each event. Member events carry the member's ID in `payload.member.id`; plan and subscription events carry it in `payload.member_id`.

<Note>
  `MEMBER_JOINED`, `MEMBER_UPDATED`, and `MEMBER_LEFT` fire once for the Network and again for every Space a member joins, updates their profile in, or leaves — a course, a community, any Space. A Network with many Spaces delivers far more of these events than its member count alone implies. The payload's `space_id` and `network_id` match only for the Network-level event; a Space-level event carries that Space's own ID in `space_id`. `syncMember` below doesn't need to tell them apart: it always re-reads the member's current state from `member(id:)`, so a Space-level `MEMBER_LEFT` — someone leaving a single Space while remaining in the Network — finds the member still resolves and re-upserts their current profile instead of taking the "left" branch. Only a Network-level `MEMBER_LEFT`, where the ID genuinely no longer resolves, does that.
</Note>

When a delivery arrives, the service looks up the member's current profile and subscription in a single query and upserts the Contact:

```graphql Member query theme={null}
query Member($id: ID!) {
  network {
    member(id: $id) {
      resourceId
      name
      firstName
      lastName
      email
      memberType
      joinedAt
      lastActiveAt
      tags {
        title
      }
    }
    paymentSubscriptions(
      memberId: $id
      first: 1
      statuses: [ACTIVE, TRIALING, TRIALING_INCOMPLETE, PAST_DUE, PAST_DUE_INCOMPLETE, CANCELED, INCOMPLETE, INCOMPLETE_EXPIRED, INCOMPLETE_CANCELED, IMPORTED, IMPORTED_TRIALING, INSTALLMENTS_COMPLETE]
    ) {
      nodes {
        status
        plan {
          name
        }
      }
    }
  }
}
```

```javascript sync.js theme={null}
import { mighty, MightyApiError } from './mighty.js';
import { salesforce } from './salesforce.js';
import { toContact } from './backfill.js';
import { MEMBER_QUERY } from './queries.js';

export async function syncMember(memberId) {
  const sf = await salesforce();
  let network;

  try {
    ({ network } = await mighty(MEMBER_QUERY, { id: memberId }));
  } catch (err) {
    if (!(err instanceof MightyApiError)) throw err;

    if (err.code === 'NOT_FOUND') {
      // member(id:) raises NOT_FOUND when the ID no longer resolves in the
      // roster this token can see -- the member left the Network. Update
      // only a Contact that already exists, so no other fields change.
      await sf.sobject('Contact')
        .find({ Mighty_Member_ID__c: memberId })
        .update({ Mighty_Status__c: 'Left' });
      return;
    }

    if (err.code === 'FORBIDDEN') {
      // member(id:) is open to Network Hosts and Moderators, not just Hosts.
      // FORBIDDEN means the account backing this token dropped below both --
      // not that the member left -- so don't mark any Contact Left over it.
      throw new Error('Mighty token lost host or moderator access');
    }

    throw err;
  }

  const [subscription] = network.paymentSubscriptions.nodes;
  await sf.sobject('Contact').upsert(toContact(network.member, subscription), 'Mighty_Member_ID__c');
}
```

`member(id:)` distinguishes exactly the two ways this lookup fails, so the sync marks a Contact `Left` only when the member is genuinely gone, and throws — leaving every Contact untouched — when the token itself lost roster access.

The webhook handler checks the secret, syncs the member, and then responds:

```javascript webhooks.js theme={null}
import crypto from 'node:crypto';
import express from 'express';
import { syncMember } from './sync.js';

const expected = Buffer.from(`Bearer ${process.env.MIGHTY_WEBHOOK_SECRET}`);

function fromMighty(req) {
  const received = Buffer.from(req.get('authorization') ?? '');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

export const webhooks = express.Router();

webhooks.post('/mighty/webhooks', express.json(), async (req, res) => {
  if (!fromMighty(req)) return res.sendStatus(401);

  const { payload } = req.body;
  const memberId = payload?.member?.id ?? payload?.member_id;

  try {
    if (memberId) await syncMember(String(memberId));
    res.sendStatus(204);
  } catch (err) {
    console.error('Member sync failed', memberId, err);
    res.sendStatus(500);
  }
});
```

A sync is one or two Mighty API calls and one Salesforce call, which finishes well within the 10-second delivery timeout, so the handler needs no job queue. If the sync fails, the handler returns `500` and Mighty retries the delivery later. A retry, or the same event arriving twice, is harmless, because the sync always writes the member's current state.

## Step 8: Write changes back to Mighty

Two-way sync lets Salesforce drive what happens in your Network. For example, when a sales rep marks a Contact as a VIP, the member gets a `VIP` tag, which can unlock a Space or trigger a Network automation.

First, find the tag's ID:

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

Then add an endpoint to your service that applies the tag with `createTagMemberships`:

```javascript salesforce-hooks.js theme={null}
import crypto from 'node:crypto';
import express from 'express';
import { mighty } from './mighty.js';

const ADD_TAG = `
  mutation AddTag($input: CreateTagMembershipsInput!) {
    createTagMemberships(input: $input) {
      tag { title }
      outcome
      errors
    }
  }
`;

const expected = Buffer.from(process.env.SALESFORCE_CALLOUT_SECRET);

export const salesforceHooks = express.Router();

salesforceHooks.post('/salesforce/vip', express.json(), async (req, res) => {
  const received = Buffer.from(req.get('x-sync-secret') ?? '');
  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return res.sendStatus(401);
  }

  const { mightyMemberId } = req.body;
  const { createTagMemberships } = await mighty(ADD_TAG, {
    input: { tagId: process.env.MIGHTY_VIP_TAG_ID, memberIds: [mightyMemberId] },
  });
  if (createTagMemberships.errors.length) {
    return res.status(422).json({ errors: createTagMemberships.errors });
  }
  if (createTagMemberships.outcome === 'SKIPPED_NO_MEMBERSHIP') {
    // No error, but nothing was granted either: the member holds no
    // membership in the Network or any of its Spaces.
    return res.status(422).json({ errors: ['Member is not eligible for this tag'] });
  }
  res.sendStatus(204);
});
```

`createTagMemberships` can no-op with an empty `errors` array: `outcome` is `GRANTED` when at least one member changed, `ALREADY_GRANTED` when every member already had the tag (the desired state is met, so this is still a success), or `SKIPPED_NO_MEMBERSHIP` when no requested member holds a membership in the Network or any of its Spaces, meaning nothing was granted. Check `outcome` as well as `errors`, or a skipped grant looks like a success. To remove the tag, call `deleteTagMemberships` with the same input and check its `outcome` the same way — `REVOKED` means something changed, `NOT_GRANTED` means no member had the tag to begin with. To write a Salesforce value into a member's profile, such as their account manager's name, call `updateCustomFieldAnswer` with the custom field's ID, the member's ID, and the text or option IDs to set.

In Salesforce, call the endpoint from a record-triggered flow on Contact:

1. Create an external credential that stores the shared secret, and a named credential for your service's URL that sends it as an `X-Sync-Secret` custom header. See [custom headers with named credentials](https://help.salesforce.com/s/articleView?id=sf.nc_custom_headers_and_api_keys.htm\&type=5).
2. Create a record-triggered flow on Contact that runs when the VIP field changes to true.
3. On the flow's **Start** element, add an asynchronous path. Salesforce doesn't allow callouts while a record is saving, so the callout must run on that path.
4. On the asynchronous path, add an **HTTP Callout** action that uses the named credential to `POST` `{ "mightyMemberId": "<Mighty_Member_ID__c>" }` to `/salesforce/vip`.

<Tip>
  Tagging the member fires a `MEMBER_TAG_ADDED` webhook, so the sync from Step 7 then writes the new tag list back to the Contact. That doesn't loop, because the upsert doesn't change the VIP field that triggers the flow. Keep that property as you add write-back rules: trigger flows on fields the Mighty sync never writes.
</Tip>

## Step 9: Run it in production

* **Reconcile nightly.** Webhooks are delivered asynchronously and retried, but a long outage on your side can still lose events. Rerun the backfill on a schedule to catch anything missed among current members. It's safe to repeat because every write is an upsert. The backfill alone can't catch a lost `MEMBER_LEFT` event, because a member who left no longer appears in the roster it pages through. Add a second pass after each run: query Salesforce for Contacts where `Mighty_Status__c = 'Member'` and `Mighty_Member_ID__c` wasn't in this run's roster, then call `syncMember` on each. It looks the member up in the Mighty API and sets `Mighty_Status__c` to `Left` for anyone who's really gone.
* **Monitor webhook health.** Two safety nets protect a failing endpoint, and only one of them self-heals. A [circuit breaker](/admin-api#circuit-breaker) pauses deliveries automatically and resumes them on its own once your endpoint responds with `2xx` again. Separately, 5 consecutive failed deliveries spanning 3 or more days permanently disable the callback: once that happens, deliveries stop for good, and nothing your endpoint does brings them back. Query `network { webhookCallbacks(first: 10) { nodes { url disabled consecutiveFailures } } }` from a monitor, and alert on `disabled: true` so you can call `updateWebhookCallback(input: { id: ... })` — or re-save the webhook in **Network Admin** — to turn it back on.
* **Handle a lost authorization.** If a token refresh returns `invalid_grant`, the refresh token has been revoked or has expired. Alert someone to have the integration host visit `/connect` again.
* **Stay within rate limits.** The Mighty API meters GraphQL operations under the same [quota model](/admin-api#rate-limit-and-quota) as the Admin REST API, tracked separately. Each webhook delivery costs one or two operations, and a backfill page costs one. A burst of events, such as a bulk tag change, triggers that many syncs at once. On a large Network, cap how many syncs run concurrently, for example with a concurrency limiter such as [p-limit](https://github.com/sindresorhus/p-limit). A delivery that fails because of a rate limit returns `500`, and Mighty retries it later.
* **Protect the credentials.** Store the Mighty refresh token, Client Secret, webhook secret, and Salesforce consumer secret encrypted, and never log them. See [Token handling](/api/oauth-client-architectures#token-handling).
* **Follow the changelog.** Check the [Mighty API changelog](/api/changelog) before you upgrade, and watch for deprecations of any field your sync reads.

## Troubleshooting

| Symptom | Likely cause | Fix |
| - | - | - |
| `Email` is empty on every Contact | Your plan doesn't include member-email visibility, or members haven't consented to sharing their email | Match on `Mighty_Member_ID__c` alone, or upgrade your plan. Members without consent always come back without an email. |
| `members` returns no records | The token belongs to a user who isn't a host or moderator | Reauthorize with a host account. |
| A JSON parse error on every request | The request has no `User-Agent` header and was blocked with an HTML `403` page | Send a descriptive `User-Agent` on every call. |
| `Query has complexity of …` error | A query with nested connections exceeded the cost limit | Lower `first`, or split the nested fields into a separate query. |
| Webhooks stopped arriving temporarily | The circuit breaker paused deliveries after repeated failures | Fix your endpoint. Deliveries resume automatically once it responds with `2xx`. |
| Webhooks stopped arriving for good | 5 consecutive failed deliveries spanning 3+ days disabled the callback (`disabled: true`) | Fix your endpoint, then call `updateWebhookCallback(input: { id: ... })` or re-save the webhook in **Network Admin**. Deliveries never resume on their own. |
| `invalid_grant` on token refresh | The host revoked access, changed their password, was signed out network-wide, or the application was deleted | Have a host visit `/connect` again. |
| Duplicate Contacts after the first backfill | Existing Contacts had no `Mighty_Member_ID__c` | Merge the duplicates, then run the one-time email match described in Step 6. |

## Next steps

<CardGroup cols={2}>
  <Card title="Mighty API" icon="diagram-project" href="/api">
    Learn the endpoint, errors, cost limits, and schema tools.
  </Card>

  <Card title="GraphQL Schema Explorer" icon="compass" href="/api/graphql-explorer">
    Find more member data to sync, and try queries against sample data.
  </Card>

  <Card title="Authentication" icon="key" href="/api/authentication">
    Read more about tokens, refresh, and revocation.
  </Card>

  <Card title="Webhooks" icon="bolt" href="/admin-api#webhooks">
    See the payload for every webhook event.
  </Card>
</CardGroup>
