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

> Grant Mighty Network access when a customer buys a Shopify product, and revoke it on refund or cancellation

This guide walks you through a server-side integration that connects a Shopify store to your Mighty Network. When a customer pays for a Shopify product, your integration grants them the Spaces that product unlocks. When the order is refunded or canceled, it takes that plan access away.

Use this pattern when Shopify is where you take payment but the thing you're selling lives in Mighty: a course, a cohort, a membership, or a members-only Space bundled with a physical product.

<Note>
  Selling merchandise **inside** your Network is a different job. To embed a Shopify storefront in a page, see [How Do I Embed a Shopify Buy Button in My Mighty Network?](/for-hosts/analytics-and-integrations/shopifybuybutton), which needs no code.
</Note>

## How it works

```text theme={null}
Shopify store                 Your integration service                Mighty API
─────────────                 ────────────────────────                ──────────
orders/paid      ──webhook──▶  1. Verify the Shopify signature
                               2. Map line items to Mighty plans
                               3. Find or create the member     ───▶  memberByEmail / createMember
                               4. Grant each plan               ───▶  createPaymentPlanMembership
                               5. Tag the member (optional)     ───▶  createTagMemberships

refunds/create   ──webhook──▶  Revoke the plans for the          ───▶  deletePaymentPlanMembership
orders/cancelled               fully refunded line items
```

Shopify collects the money. Mighty grants access through a **free Payment Plan** that holds the Spaces the customer bought. Your service sits between them. It receives Shopify webhooks and calls the [Mighty API](/api) as a Network Host.

Granting a free plan, rather than adding the member to Spaces one by one, gives you three advantages:

* **One ID per product.** The plan decides which Spaces a customer gets. You can change that list in Mighty without redeploying your integration.
* **Clean revocation.** Removing the plan removes the Spaces that only it grants.
* **Limited Members work.** A customer who should get only the purchased Spaces, not your whole Network, can be created as a Limited Member holding that plan.

## Before you begin

You need:

* A Mighty Network on the **Scale plan or above**, which is required to create OAuth applications
* Host access to that Network
* A Shopify store where you can add webhooks
* A server you control that can receive HTTPS requests and store secrets, such as a small Node.js or Python app, or a cloud function

The code samples use Node.js 18 or later with Express. The same steps apply in any language.

## Step 1: Create a free plan for each product

In your Mighty Network, create one free Payment Plan for each Shopify product that grants access. Add the Spaces that product unlocks.

The plan must be an **active, free, subscription-style plan**. `createPaymentPlanMembership` rejects paid plans and one-time purchase plans, because Shopify, not Mighty, has already taken payment. If you'll grant the plan by creating a Limited Member (Step 4), it must also grant at least one Space and must not include the Network — `createMember` rejects a `planId` that includes the Network.

Keep every plan to **Spaces only**, including plans for `FULL_MEMBER` products. `createMember` already adds a full member to your Network, so the plan doesn't need to. It also keeps refunds predictable: revoking a plan that includes the Network, in a Network people can't join freely, removes the member from the whole Network unless something else still pays for their membership. See [Revoke access on refund or cancellation](#revoke-access-on-refund-or-cancellation).

<Warning>
  A free plan can be joined by anyone who reaches its join page. Treat it as a back-office plan: don't feature it on your landing page or share its link. Your integration is the only intended way in.
</Warning>

Optionally, create a tag such as **Shopify customer** so you can find and segment these members later.

## Step 2: Register an OAuth application

Your integration authenticates with OAuth 2.0 and acts as the Host who authorizes it.

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

2. Choose the **Confidential** client type. Your integration runs on a server, so it can keep a Client Secret.

3. Register a redirect URI on your server, such as `https://integrations.example.com/mighty/callback`.

4. Select these scopes:

   | Scope | Why the integration needs it |
   | - | - |
   | `host:write:network_members` | Look up members by email, create members, and assign tags |
   | `host:write:network_plans` | Grant and revoke Payment Plans, and create Limited Members |

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

This is a first-party integration that you build and run against your own Network, so you can enable **Skip consent screen**. See [OAuth Applications](/oauth-applications) for the full setup reference.

## Step 3: Authorize the integration once

A Host completes the [Authorization Code flow](/api/authentication#authorization-code-flow) one time. Your service stores the resulting refresh token and uses it for every webhook after that. No customer ever signs in to your integration.

<Steps>
  <Step title="Start authorization from your server">
    Add a route, such as `/mighty/connect`, that generates a `state` nonce and a PKCE `code_verifier`, stores both in the server-side session, and redirects to `https://YOUR_SUBDOMAIN.mn.co/oauth/authorize` with the two scopes above.
  </Step>

  <Step title="Sign in as a Host">
    Open `/mighty/connect` and sign in with a Host account. Host scopes only work for Hosts, so a member or Moderator can't complete this step.
  </Step>

  <Step title="Exchange the code and store the tokens">
    On the callback, verify `state` and `iss`, then exchange the code at `/oauth/token`. Store the refresh token encrypted at rest. It is the credential your integration runs on.
  </Step>
</Steps>

Follow the [backend web app architecture](/api/oauth-client-architectures#backend-web-apps): the server generates `state` and the verifier, receives the callback, and exchanges the code itself.

<Tip>
  Authorize with a Host account that will stay a Host. The integration acts as that person. If they stop being a Host, token refreshes still succeed but every Mighty API call returns `401`. If their refresh token is revoked, refreshes fail with `invalid_grant`.
</Tip>

### Keep an access token fresh

Access tokens expire after one hour. Refresh on demand before you call the Mighty API, and persist the new refresh token whenever one is returned:

```javascript mighty-auth.js theme={null}
const SUBDOMAIN = process.env.MIGHTY_SUBDOMAIN;

let cached = { accessToken: null, expiresAt: 0 };
let refreshing = null;

export async function getAccessToken() {
  if (cached.accessToken && Date.now() < cached.expiresAt - 60_000) {
    return cached.accessToken;
  }

  // Refresh tokens rotate: the token you present is revoked as soon as it's
  // used. Share one in-flight refresh so concurrent callers don't each spend it.
  refreshing ??= refreshAccessToken().finally(() => {
    refreshing = null;
  });
  return refreshing;
}

async function refreshAccessToken() {
  const response = 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: await loadRefreshToken(), // from your encrypted store
      client_id: process.env.MIGHTY_CLIENT_ID,
      client_secret: process.env.MIGHTY_CLIENT_SECRET,
    }),
  });

  const token = await response.json();
  if (!response.ok) {
    // invalid_grant means a Host must run /mighty/connect again.
    throw new Error(`Mighty token refresh failed: ${token.error}`);
  }

  // Persist the rotated refresh token before handing out the new access token.
  if (token.refresh_token) await saveRefreshToken(token.refresh_token);
  cached = {
    accessToken: token.access_token,
    expiresAt: Date.now() + token.expires_in * 1000,
  };
  return cached.accessToken;
}
```

Alert someone when a refresh returns `invalid_grant`. Until a Host re-authorizes, the integration can't grant access to new customers.

If multiple workers run this integration, serialize refreshes across processes too — for example, with a lock in the database that holds the refresh token — so only one worker ever exchanges a given refresh token.

## Step 4: Map Shopify products to Mighty plans

Your integration needs to know which Mighty plan each Shopify product grants. Find the plan and tag IDs with the hosted [GraphiQL explorer](/api#graphiql-explorer) or a one-off query:

```graphql theme={null}
query IntegrationIds {
  network {
    paymentPlans(pricingTypes: [FREE], first: 50) {
      nodes { id name status }
    }
    tags(term: "Shopify", first: 10) {
      nodes { id title }
    }
  }
}
```

Keep the mapping in configuration, keyed by Shopify product or variant ID:

```javascript config.js theme={null}
// Shopify product ID → Mighty access to grant
export const PRODUCT_ACCESS = {
  '8123456789012': { planId: 'UGF5bWVudFBsYW4tMTIz', memberType: 'LIMITED_MEMBER' },
  '8123456789013': { planId: 'UGF5bWVudFBsYW4tNDU2', memberType: 'FULL_MEMBER' },
};

export const CUSTOMER_TAG_ID = 'VGFnLTc4OQ=='; // optional
```

Use `LIMITED_MEMBER` when a purchase should unlock only that plan's Spaces, and `FULL_MEMBER` when the customer should also join your Network. A plan mapped to `LIMITED_MEMBER` must grant at least one Space and must not include the Network — confirm that when you create the plan in Step 1. See [What is a Full vs. Limited Membership?](/for-hosts/payments-and-access/what-is-a-full-vs-limited-membership) for the difference.

## Step 5: Receive Shopify webhooks

In Shopify, subscribe your service to these topics:

| Topic | What your integration does |
| - | - |
| `orders/paid` | Grants access for each mapped line item |
| `refunds/create` | Revokes access for each product once every unit of it on the order is refunded |
| `orders/cancelled` | Revokes access for every mapped line item on the order |

You can add webhooks in the Shopify admin under **Settings** > **Notifications** > **Webhooks**, or subscribe from a Shopify app. See Shopify's [webhooks documentation](https://shopify.dev/docs/apps/build/webhooks) for both options.

Every delivery carries an `X-Shopify-Hmac-Sha256` header: a base64-encoded HMAC-SHA256 of the raw request body, signed with your webhook secret. Verify it against the **raw** body before you parse it, and reject anything that doesn't match:

```javascript server.js theme={null}
import crypto from 'node:crypto';
import express from 'express';
import { handleOrderPaid, handleRefund, handleOrderCancelled, NeedsReviewError } from './handlers.js';

const app = express();

function verifyShopify(req) {
  const received = Buffer.from(req.get('X-Shopify-Hmac-Sha256') ?? '', 'base64');
  const expected = crypto
    .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
    .update(req.body) // raw Buffer, not parsed JSON
    .digest();
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

app.post('/shopify/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyShopify(req)) return res.sendStatus(401);

  const topic = req.get('X-Shopify-Topic');
  const deliveryId = req.get('X-Shopify-Webhook-Id');
  const payload = JSON.parse(req.body.toString('utf8'));

  try {
    // Enqueueing is fast, so this still acknowledges well within Shopify's
    // timeout. Wait for it: a 200 you send before the job is queued can be
    // followed by a dropped job that Shopify never retries.
    await enqueue({ topic, deliveryId, payload }); // your job queue
    res.sendStatus(200);
  } catch (err) {
    console.error(err);
    res.sendStatus(500); // Shopify retries deliveries that don't get a 2xx.
  }
});

// Your queue worker
async function processJob({ topic, deliveryId, payload }) {
  if (await alreadyProcessed(deliveryId)) return;

  try {
    if (topic === 'orders/paid') await handleOrderPaid(payload);
    if (topic === 'refunds/create') await handleRefund(payload);
    if (topic === 'orders/cancelled') await handleOrderCancelled(payload);
  } catch (err) {
    if (err instanceof NeedsReviewError) {
      // Route to a person rather than retrying — retrying won't resolve it.
      await flagForReview({ topic, deliveryId, payload, reason: err.message });
    } else {
      throw err; // let your queue's retry policy handle transient failures
    }
  }

  await markProcessed(deliveryId);
}
```

Shopify can deliver the same event more than once, and out of order. Record the `X-Shopify-Webhook-Id` of each processed delivery and skip repeats — that handles duplicates. It doesn't handle reordering: a retried `orders/paid` that arrives after `refunds/create` would re-grant access you already revoked. The handlers below record each refunded or canceled line item and skip it on a later `orders/paid`, which is what handles reordering. The Mighty mutations are idempotent too, so a retried grant or revoke reports the state you asked for instead of failing.

## Step 6: Call the Mighty API

Wrap the GraphQL endpoint in a small helper. Remember that requests to `api.mn.co` must send a `User-Agent`, and that GraphQL reports most errors with HTTP `200`. Check the `errors` array on every response:

```javascript mighty.js theme={null}
import { getAccessToken } from './mighty-auth.js';

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

export async function mighty(query, variables = {}) {
  const response = await fetch(ENDPOINT, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${await getAccessToken()}`,
      'Content-Type': 'application/json',
      'User-Agent': 'shopify-mighty-sync/1.0 (+https://integrations.example.com)',
    },
    body: JSON.stringify({ query, variables }),
  });

  const { data, errors } = await response.json();
  if (errors?.length) {
    const error = new Error(errors.map((e) => e.message).join('; '));
    error.codes = errors.map((e) => e.extensions?.code);
    throw error;
  }
  return data;
}
```

The thrown error's `codes` array carries each error's `extensions.code` — for example `NOT_FOUND` or `THROTTLED` — so callers can branch on the reason instead of matching message text.

### Find or create the member

Resolve each Shopify customer to a Mighty member in this order:

1. **Your own records.** Once you've resolved a customer, save their Mighty member ID against the Shopify customer ID (or, when an order carries no customer object, the normalized email) and reuse it.
2. **`memberByEmail`.** Finds an existing member by the order's email address. A failed lookup (no member found) counts against a per-Network budget of 10 failed lookups every 10 minutes, and every first-time customer is one, so a busy store can hit it. Back off and retry when `memberByEmail` raises `THROTTLED`.
3. **`createMember`.** Creates the account and adds it to your Network. Accounts belong to a single Network: if the address already has an account in your Network, such as a former member's, it's reused. An account in another Mighty Network isn't.

```javascript handlers.js theme={null}
import { mighty } from './mighty.js';
import { PRODUCT_ACCESS, CUSTOMER_TAG_ID } from './config.js';

const MEMBER_BY_EMAIL = `
  query MemberByEmail($email: String!) {
    network { memberByEmail(email: $email) { id memberType } }
  }`;

const MEMBER_BY_ID = `
  query MemberById($id: ID!) {
    network { member(id: $id) { id memberType } }
  }`;

const CREATE_MEMBER = `
  mutation CreateMember($input: CreateMemberInput!) {
    createMember(input: $input) {
      member { id }
      errors
    }
  }`;

// Thrown for an order a person needs to look at instead of a retry fixing —
// for example, a member createMember can't find or create automatically.
export class NeedsReviewError extends Error {
  constructor(email, errors) {
    super(`Needs review (${email}): ${errors.join('; ')}`);
    this.name = 'NeedsReviewError';
  }
}

// Key saved member IDs by the Shopify customer ID when the order has one, or
// by normalized email otherwise. Some orders — draft orders, some POS sales —
// carry no customer object, so falling back to customer.id alone would key
// every one of those orders to the same undefined member.
function customerKey(customer) {
  if (customer?.id) return `shopify-customer:${customer.id}`;
  return `email:${customer?.email?.trim().toLowerCase()}`;
}

// A saved member ID's current member type, or null when they hold no current
// Network or Space membership. `member(id:)` raises NOT_FOUND for that case,
// same as for an ID it doesn't recognize at all.
async function currentMemberType(memberId) {
  try {
    const { network } = await mighty(MEMBER_BY_ID, { id: memberId });
    return network.member?.memberType ?? null;
  } catch (err) {
    if (err.codes?.includes('NOT_FOUND')) return null;
    throw err;
  }
}

// Returns the member's ID, their current member type, and the plan createMember
// granted, if any. `want.planId` is only used when creating a Limited Member.
async function findOrCreateMember(customer, want) {
  const key = customerKey(customer);
  const saved = await loadMemberId(key);
  if (saved) {
    // The member type only matters when the order needs a full member.
    if (want.memberType !== 'FULL_MEMBER') return { memberId: saved };

    const memberType = await currentMemberType(saved);
    if (memberType) return { memberId: saved, memberType };
    // No current Network or Space membership — for example, a removed full
    // member, or a Limited Member left with none after a refund. Fall through
    // to createMember below, which reuses this account and re-adds it.
  }

  if (!saved) {
    const { network } = await mighty(MEMBER_BY_EMAIL, { email: customer.email });
    if (network.memberByEmail) {
      await saveMemberId(key, network.memberByEmail.id);
      return { memberId: network.memberByEmail.id, memberType: network.memberByEmail.memberType };
    }
  }

  const limited = want.memberType === 'LIMITED_MEMBER';
  const { createMember } = await mighty(CREATE_MEMBER, {
    input: {
      email: customer.email,
      firstName: customer.first_name || 'Member',
      lastName: customer.last_name || '-',
      memberType: want.memberType,
      // A Limited Member receives the plan when created.
      planId: limited ? want.planId : undefined,
    },
  });

  if (createMember.errors.length) {
    // For example, "This person is already a member of this Network."
    throw new NeedsReviewError(customer.email, createMember.errors);
  }

  await saveMemberId(key, createMember.member.id);
  return {
    memberId: createMember.member.id,
    memberType: want.memberType,
    grantedPlanId: limited ? want.planId : undefined,
  };
}
```

`createMember` emails the new member a welcome. For a full member, the welcome carries a sign-in link. A Limited Member receives the plan's welcome email instead. Pass `sendWelcomeEmail: false` if you'd rather send your own onboarding email from Shopify.

<Warning>
  `memberByEmail` returns `null` unless your Network's plan includes member email visibility **and** that member has agreed to share their email with Hosts. Networks that use SSO are exempt from the consent requirement. When an existing member stays hidden, `createMember` refuses with **This person is already a member of this Network.** Route those orders to a person for review rather than retrying them. Saving member IDs after the first purchase keeps this case rare.
</Warning>

### Grant the plan and tag the member

```javascript handlers.js theme={null}
const GRANT_PLAN = `
  mutation GrantPlan($input: CreatePaymentPlanMembershipInput!) {
    createPaymentPlanMembership(input: $input) {
      outcome
      errors
    }
  }`;

const TAG_MEMBER = `
  mutation TagMember($input: CreateTagMembershipsInput!) {
    createTagMemberships(input: $input) {
      outcome
      errors
    }
  }`;

export async function handleOrderPaid(order) {
  const customer = { ...order.customer, email: order.customer?.email ?? order.email };

  const grants = [];
  for (const item of order.line_items) {
    const access = PRODUCT_ACCESS[String(item.product_id)];
    if (!access) continue;

    // A retried orders/paid can arrive after refunds/create for the same
    // order. Don't re-grant a line item you've already revoked.
    if (await isLineItemRevoked(order.id, item.product_id)) continue;
    grants.push(access);
  }
  if (grants.length === 0) return;

  // Decide the member type once for the whole order, so the result doesn't
  // depend on line-item order: any FULL_MEMBER product makes a full member.
  const needsFull = grants.some((access) => access.memberType === 'FULL_MEMBER');
  const { memberId, memberType, grantedPlanId } = await findOrCreateMember(customer, {
    memberType: needsFull ? 'FULL_MEMBER' : 'LIMITED_MEMBER',
    planId: grants[0].planId,
  });

  for (const access of grants) {
    if (access.planId === grantedPlanId) continue; // createMember granted it
    const { createPaymentPlanMembership: grant } = await mighty(GRANT_PLAN, {
      input: { memberId, planId: access.planId },
    });
    if (grant.errors.length) throw new Error(grant.errors.join('; '));
    // outcome: SUBSCRIBED, or ALREADY_SUBSCRIBED on a retry
  }

  if (CUSTOMER_TAG_ID) {
    const { createTagMemberships: tag } = await mighty(TAG_MEMBER, {
      input: { tagId: CUSTOMER_TAG_ID, memberIds: [memberId] },
    });
    // SKIPPED_NO_MEMBERSHIP means the member holds no membership in the
    // Network or any of its Spaces — treat it as success, not an error.
    if (tag.errors.length) throw new Error(tag.errors.join('; '));
  }

  // The Mighty API has no mutation that promotes a Limited Member to a full
  // member. The plans are granted, so hand the promotion to a Host.
  if (needsFull && memberType === 'LIMITED_MEMBER') {
    throw new NeedsReviewError(customer.email, [
      'Limited Member bought a FULL_MEMBER product. Promote them to a full member in Network Admin.',
    ]);
  }
}
```

`createPaymentPlanMembership` doesn't email the member. It reports `ALREADY_SUBSCRIBED` when the member already holds the plan, so repeated deliveries are safe.

The handler settles the member type once per order. If any mapped product in the order is `FULL_MEMBER`, a new customer is created as a full member and every plan is granted with `createPaymentPlanMembership`, so a mixed order gives the same result whatever order its line items arrive in.

<Note>
  The Mighty API has no mutation that promotes a Limited Member to a full member. Granting a free plan that includes the Network would, but this guide keeps plans to Spaces only, as recommended in [Step 1](#step-1-create-a-free-plan-for-each-product), because revoking such a plan would remove the member from the Network. When a Limited Member buys a `FULL_MEMBER` product, the handler still grants the plan, then routes the order to review so a Host can promote them in **Network Admin**. If you'd rather avoid manual steps, map every product to the same `memberType`.
</Note>

A member can hold a tag while they belong to your Network or to any of its Spaces, so a Limited Member holding the plan's Spaces is tagged like a full member. `createTagMemberships` reports `SKIPPED_NO_MEMBERSHIP` only for someone with no membership at all, such as a customer whose access you've already revoked.

### Revoke access on refund or cancellation

```javascript handlers.js theme={null}
const REVOKE_PLAN = `
  mutation RevokePlan($input: DeletePaymentPlanMembershipInput!) {
    deletePaymentPlanMembership(input: $input) {
      outcome
      errors
    }
  }`;

async function revoke(orderId, customer, productIds) {
  const memberId = await loadMemberId(customerKey(customer));

  for (const productId of productIds) {
    const access = PRODUCT_ACCESS[String(productId)];
    if (!access) continue;

    // Record the line item as revoked even if there's no member yet, so a
    // late-arriving orders/paid for this order can't re-grant it.
    await markLineItemRevoked(orderId, productId);
    if (!memberId) continue;

    const { deletePaymentPlanMembership: result } = await mighty(REVOKE_PLAN, {
      input: { memberId, planId: access.planId },
    });
    if (result.errors.length) throw new Error(result.errors.join('; '));
    // outcome: REMOVED, or NOT_SUBSCRIBED if they no longer held it
  }
}

export async function handleRefund(refund) {
  const order = await loadOrder(refund.order_id); // from your records or the Shopify Admin API
  const customer = order.customer ?? { email: order.email };

  // Revoke a product only once every unit of it on the order is refunded.
  const fullyRefunded = [];
  for (const refundItem of refund.refund_line_items) {
    const productId = refundItem.line_item.product_id;
    if (!PRODUCT_ACCESS[String(productId)]) continue;

    // Keyed by the refund line item's ID, so a replayed delivery isn't counted twice.
    await recordRefundedQuantity(order.id, productId, refundItem.id, refundItem.quantity);
    const refunded = await totalRefundedQuantity(order.id, productId);
    const purchased = order.line_items
      .filter((item) => item.product_id === productId)
      .reduce((sum, item) => sum + item.quantity, 0);
    if (refunded >= purchased) fullyRefunded.push(productId);
  }

  await revoke(order.id, customer, fullyRefunded);
}

export async function handleOrderCancelled(order) {
  const customer = order.customer ?? { email: order.email };
  await revoke(order.id, customer, order.line_items.map((item) => item.product_id));
}
```

Revoking a free plan ends access immediately. `deletePaymentPlanMembership` is idempotent too: it reports `NOT_SUBSCRIBED` when the member no longer holds the plan.

Revoking a Spaces-only plan removes the member from the plan's Spaces, with three exceptions. They keep:

* A Space that another plan or purchase still grants them.
* A Space a Host added them to directly.
* For a full member, any Space in the plan that's open for Network members to join.

A full member's Network membership stays in place. A Limited Member who held only this plan's Spaces is left with no membership.

<Warning>
  If the plan includes the Network, and your Network isn't open for anyone to join, revoking it also removes the member from the Network and every Space in it, unless another plan or purchase still covers their Network membership. Keep plans to Spaces only, as recommended in [Step 1](#step-1-create-a-free-plan-for-each-product), to avoid removing members on a refund.
</Warning>

<Note>
  To remove a `FULL_MEMBER` customer from the Network on a refund, call `deleteNetworkMembership` (scope `host:write:network_members`) after revoking the plan. The member keeps their content and can rejoin the Network later.
</Note>

A partial refund doesn't revoke access. `handleRefund` adds up each product's refunded quantity across every refund on the order, and revokes the plan only once all the units of that product are refunded. If a customer bought two units and you refund one, they keep access.

The same applies across orders. Before you revoke, check whether the customer holds another paid order for the same product that hasn't been refunded. If they bought it twice, one refund shouldn't remove access they're still paying for.

## Step 7: Test the integration

1. Create a test product in Shopify, map it to a free plan, and place a test order with an email address that isn't in your Network.
2. Confirm the new member appears in your Network with the plan's Spaces, and with the tag if you set one.
3. Place a second order for the same product. The grant should report `ALREADY_SUBSCRIBED`.
4. Refund the order and confirm the member loses the plan's Spaces, apart from any the exceptions in [Revoke access on refund or cancellation](#revoke-access-on-refund-or-cancellation) keep, and that a full member stays in your Network.
5. Refund a `LIMITED_MEMBER`-only order, leaving that customer with no current membership, then place an order for a `FULL_MEMBER` product with the same customer. Confirm they rejoin as a full member instead of the order stalling.
6. Place an order for two units of the product and refund one. Confirm the member keeps access, then refund the second unit and confirm access is revoked.
7. Replay a webhook delivery from your logs and confirm your service skips it.

## Go further

Once the core flow works, you can extend it in either direction:

* **Recurring access.** If you sell subscriptions through a Shopify subscription app, grant on each successful renewal and revoke when the subscription is canceled or payment fails.
* **Mighty to Shopify.** Register a Mighty webhook with `createWebhookCallback` (requires the `host:write:network_integrations` scope) to react to events such as `MEMBER_JOINED` or `MEMBER_COURSE_PROGRESS_COMPLETED`. You could tag a Shopify customer when they finish a course, or issue a discount code to members of a Space.
* **Reconciliation.** Run a nightly job that lists members of each plan with `network { members(planId: ...) }` and compares them to your paid Shopify orders. It catches anything a missed webhook left out of sync.

## Troubleshooting

### The token refresh returns `invalid_grant`

The refresh token is no longer valid. For example, it was revoked, or it was already exchanged by a concurrent refresh. See [Refreshing tokens](/api/authentication#refreshing-tokens) for what invalidates one. Have a Host open `/mighty/connect` again.

### Mighty API calls return HTTP `401` after a successful refresh

Most often, the Host who authorized the integration is no longer a Host. The token refresh doesn't check the role, but every API call does, so have a current Host open `/mighty/connect` again. You also get a `401` if that account was banned, or if your Network's plan no longer includes the Mighty API.

### `createPaymentPlanMembership` returns "Only free plans are supported."

The mapped plan charges a price in Mighty. Point the product at a free plan, since Shopify has already collected payment.

### `createMember` returns "This person is already a member of this Network."

The customer is already a member, but `memberByEmail` couldn't return them. See the warning in [Find or create the member](#find-or-create-the-member). Look up the member in **Network Admin**, save their member ID against the Shopify customer, and replay the order.

### Requests fail with a JSON parse error and HTTP `403`

Your requests to `api.mn.co` are missing a `User-Agent` header, so bot protection returned an HTML page. Set one on every request.

## Related

<CardGroup cols={2}>
  <Card title="Mighty API" icon="diagram-project" href="/api">
    Endpoint, request format, rate limits, and errors.
  </Card>

  <Card title="Authentication" icon="key" href="/api/authentication">
    OAuth flows, token refresh, and revocation.
  </Card>

  <Card title="OAuth Client Architectures" icon="sitemap" href="/api/oauth-client-architectures">
    Where tokens should live for a server-side integration.
  </Card>

  <Card title="GraphQL Schema Explorer" icon="compass" href="/api/graphql-explorer">
    Browse every query and mutation used in this guide.
  </Card>
</CardGroup>
