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

> Use the Mighty API to invite Eventbrite ticket buyers to a Space and mirror your Eventbrite events in your Mighty Network

## Overview

This guide shows you how to connect Eventbrite to your Mighty Network with a small backend service that listens for Eventbrite webhooks and calls the [Mighty API](/api). When you finish, your integration will:

* **Invite ticket buyers to a Space.** Each attendee on a new Eventbrite order gets an invite to a Space you choose, such as a private Space for event attendees.
* **Mirror events.** When you publish or update an event on Eventbrite, a matching event appears in a Space in your Network, linked back to the Eventbrite ticket page.

The integration runs server-to-server. A Network Host authorizes it once, and your service keeps the resulting tokens on the server.

```text theme={null}
Eventbrite ──webhook──▶ Your service ──GraphQL──▶ Mighty API
   ▲                         │
   └────── Eventbrite API ◀──┘  (fetch order or event details)
```

## Before you begin

You need:

* A Mighty Network on the **Scale plan or above**, and a Host account on it. OAuth applications are only available on those plans.
* An Eventbrite account that owns the events, and an Eventbrite **private token** (in Eventbrite, go to **Account Settings** > **Developer Links** > **API Keys**).
* A server that can receive HTTPS requests from Eventbrite and store secrets securely.

## Step 1: Create a Mighty OAuth application

<Steps>
  <Step title="Create the application">
    In your Mighty Network, go to **Network Admin** > **Integrations** > **OAuth Applications** and click **New OAuth Application**. Choose the **Confidential** client type, because the integration runs on your server and can keep a Client Secret. See [OAuth Applications](/oauth-applications) for every setting.
  </Step>

  <Step title="Register a redirect URI">
    Add a redirect URI on your service, for example `https://integrations.example.com/mighty/callback`. Your service handles this URI once, when a Host connects the integration.
  </Step>

  <Step title="Select scopes">
    Select only the scopes the integration needs:

    | Scope | Used for |
    | - | - |
    | `host:write:network_members` | Sending invites |
    | `host:write:network_events` | Creating and updating mirrored events |

    If you only want to invite ticket buyers, skip `host:write:network_events`. Listing Spaces to find your target Space (Step 4) doesn't require its own scope today.
  </Step>
</Steps>

## Step 2: Connect the integration as a Host

Your service needs an access token that acts as a Host of the Network. Run the [Authorization Code flow](/api/authentication#authorization-code-flow) once:

1. A Host visits a "Connect Mighty" page on your service, which redirects them to `https://YOUR-SUBDOMAIN.mn.co/oauth/authorize` with your Client ID, redirect URI, scopes, `state`, and a PKCE challenge.
2. The Host approves the scopes and Mighty redirects back to your redirect URI with a `code`.
3. Your service checks `state`, exchanges the code at `/oauth/token`, and stores the `access_token` and `refresh_token`.

Access tokens expire after one hour. Before each batch of Mighty API calls, refresh the access token if it has expired:

```javascript Node.js theme={null}
async function refreshMightyToken(stored) {
  const response = await fetch(`https://${MIGHTY_SUBDOMAIN}.mn.co/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: stored.refreshToken,
      client_id: MIGHTY_CLIENT_ID,
      client_secret: MIGHTY_CLIENT_SECRET,
    }),
  });
  if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`);

  const token = await response.json();
  // Refresh tokens rotate. Save the new pair before you use the new access token.
  await saveTokens({
    accessToken: token.access_token,
    refreshToken: token.refresh_token,
    expiresAt: Date.now() + token.expires_in * 1000,
  });
  return token.access_token;
}
```

<Warning>
  Refresh tokens rotate. Each refresh revokes the token you presented, so save the new refresh token before you use the new access token, and make sure only one refresh runs at a time. If a refresh returns `invalid_grant`, ask the Host to connect the integration again. See [OAuth Client Architectures](/api/oauth-client-architectures#token-handling).
</Warning>

## Step 3: Add a Mighty API helper

Every Mighty API call is a `POST` to your Network's GraphQL endpoint. Send a `User-Agent` header, because requests without one are blocked. Check the `errors` array on every response, because GraphQL returns HTTP `200` for most errors.

```javascript Node.js theme={null}
async function mighty(query, variables) {
  const accessToken = await getValidMightyAccessToken(); // refreshes when expired

  const response = await fetch(
    `https://api.mn.co/networks/${MIGHTY_SUBDOMAIN}/graphql`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
        'User-Agent': 'eventbrite-sync/1.0 (+https://integrations.example.com)',
      },
      body: JSON.stringify({ query, variables }),
    }
  );

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

## Step 4: Find your target Space

Invites and events both need the ID of a Space. List your Spaces once and save the ID of the one you want in your service's configuration:

```graphql theme={null}
query Spaces {
  network {
    spaces(first: 50) {
      edges {
        node {
          id
          title
        }
      }
    }
  }
}
```

If you want each Eventbrite event to invite buyers to a different Space, store a mapping from Eventbrite event ID to Space ID instead.

## Step 5: Register Eventbrite webhooks

In Eventbrite, go to **Account Settings** > **Developer Links** > **Webhooks** and click **Add Webhook**. Point it at an endpoint on your service and select these actions:

| Eventbrite action | What your service does |
| - | - |
| `order.placed` | Invites each attendee on the order to your Space |
| `event.published` | Creates a matching event in your Network |
| `event.updated` | Updates the matching event in your Network, if one exists |

Eventbrite doesn't sign webhook requests. Protect your endpoint by:

* Including a long random secret in the webhook URL, such as `https://integrations.example.com/eventbrite/webhook/3f9c…`, and rejecting requests that don't match it.
* Treating the request body only as a notification. Fetch the order or event yourself from the `api_url` in the payload, and only after you confirm the URL starts with `https://www.eventbriteapi.com/`.

A webhook request body looks like this:

```json theme={null}
{
  "config": {
    "action": "order.placed",
    "user_id": "123456789",
    "endpoint_url": "https://integrations.example.com/eventbrite/webhook/3f9c…",
    "webhook_id": "987654"
  },
  "api_url": "https://www.eventbriteapi.com/v3/orders/1234567890/"
}
```

Respond with HTTP `200` quickly, and do the slow Mighty and Eventbrite calls in a worker, so they don't hold up the response. Put the webhook on a durable job queue, such as a database-backed queue, and wait for the enqueue to succeed before you respond. Once you return `200`, Eventbrite won't send that webhook again. If "in the background" means an in-process task and your service crashes after responding, the event is lost.

```javascript Node.js theme={null}
app.post('/eventbrite/webhook/:secret', express.json(), async (req, res) => {
  if (!webhookSecretMatches(req.params.secret)) return res.sendStatus(404);

  try {
    // Wait for the job to be stored before acknowledging. A 200 sent before
    // the enqueue finishes can be followed by a lost job.
    await enqueue({ action: req.body.config.action, apiUrl: req.body.api_url }); // your job queue
    res.sendStatus(200);
  } catch (err) {
    console.error(err);
    res.sendStatus(500);
  }
});

// Your queue worker. Throw on failure so your queue's retry policy runs.
async function processJob({ action, apiUrl }) {
  if (action === 'order.placed') await handleOrderPlaced(apiUrl);
  if (action === 'event.published' || action === 'event.updated') {
    await handleEventWebhook(action, apiUrl);
  }
}
```

## Step 6: Invite ticket buyers to a Space

When an `order.placed` webhook arrives, fetch the order with its attendees, then send the attendees to [`createInvites`](/api/graphql-explorer) with your Space ID, in batches of 10 or fewer recipients so each call stays in sync mode (see below):

```javascript Node.js theme={null}
const CREATE_INVITES = `
  mutation InviteAttendees($input: CreateInvitesInput!) {
    createInvites(input: $input) {
      count
      ignoredRecipients
      mode
      errors
    }
  }
`;

// Above this many email recipients in one call, createInvites switches to
// async mode: invalid addresses are dropped silently instead of returned as
// a retryable error. Stay at or under it to keep every call in sync mode.
const MAX_SYNC_RECIPIENTS = 10;

async function handleOrderPlaced(apiUrl) {
  const order = await eventbrite(`${apiUrl}?expand=attendees,event`);

  // Invite each attendee once, even if one buyer bought several tickets for the same person.
  const recipients = [
    ...new Map(
      order.attendees
        .filter((attendee) => attendee.profile?.email)
        .map(({ profile }) => [
          profile.email.toLowerCase(),
          { email: profile.email, firstName: profile.first_name, lastName: profile.last_name },
        ])
    ).values(),
  ];
  if (recipients.length === 0) return;

  // Most orders fit in a single call; this only chunks when one order has an
  // unusually large party.
  for (let i = 0; i < recipients.length; i += MAX_SYNC_RECIPIENTS) {
    await sendInvites(recipients.slice(i, i + MAX_SYNC_RECIPIENTS), order);
  }
}

async function sendInvites(recipients, order, alreadyRetried = false) {
  const { createInvites } = await mighty(CREATE_INVITES, {
    input: {
      spaceId: ATTENDEE_SPACE_ID,
      recipients,
      message: `Thanks for registering for ${order.event?.name?.text ?? 'our event'}! Join the conversation with other attendees.`,
    },
  });

  // A batch of MAX_SYNC_RECIPIENTS or fewer runs synchronously, where one
  // invalid address rejects the whole batch (count: 0). Drop the listed
  // addresses and retry once so the rest of the attendees still get invited.
  const invalidMatch = createInvites.errors.find((error) => error.startsWith('Invalid Emails:'));
  if (invalidMatch && !alreadyRetried) {
    const invalidEmails = invalidMatch
      .slice('Invalid Emails:'.length)
      .split(',')
      .map((email) => email.trim().toLowerCase());
    const retryRecipients = recipients.filter((r) => !invalidEmails.includes(r.email.toLowerCase()));
    console.warn(`Retrying without invalid addresses: ${invalidEmails.join(', ')}`);
    if (retryRecipients.length === 0) return;
    return sendInvites(retryRecipients, order, true);
  }

  if (createInvites.errors.length) {
    throw new Error(createInvites.errors.join('; '));
  }
  console.log(`Invited ${createInvites.count}; skipped ${createInvites.ignoredRecipients.length}`);
}

async function eventbrite(url) {
  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${EVENTBRITE_PRIVATE_TOKEN}` },
  });
  if (!response.ok) throw new Error(`Eventbrite ${response.status} for ${url}`);
  return response.json();
}
```

In sync mode (batches of 10 or fewer recipients, as the sample code sends), `createInvites` treats each attendee like this:

* **New to your Network:** they receive an invite email. Accepting it adds them to the Network and the Space.
* **Already a member of the Network, but not the Space:** they receive an invite to the Space.
* **Already in the Space, or already holding a pending invite:** they're skipped and listed in `ignoredRecipients`. Replaying the same webhook doesn't send duplicate invites.

Keep these rules in mind:

* `createInvites` sends real invite emails. Test against a Space you own before you point the integration at a live event.
* The `message` field can't contain links. If it does, the mutation returns an error.
* **Keep each call at 10 or fewer email recipients.** Above that, `createInvites` switches to async mode: it returns immediately with `mode: "async"` and `count` set to the number of recipients you submitted, not the number actually invited. `ignoredRecipients` is always empty in async mode, and invalid addresses are dropped silently instead of returned as an error, so you can't tell from the response who was skipped. The sample code above chunks each order into batches of `MAX_SYNC_RECIPIENTS` for this reason.
* In sync mode (10 or fewer recipients), if any recipient's email fails Mighty's validation, `createInvites` rejects the whole batch (`count: 0`) and lists the addresses in `errors` as `Invalid Emails: ...`. The sample code retries once with those addresses removed so the rest of the attendees still get invited. This rejection and retry only happen in sync mode.
* Each `createInvites` call creates an invite batch, and a sender is limited to 30 batches an hour and 200 a day; past that, the mutation returns the error `You are sending too many invites`. The sample code's one call per order, chunked to 10 or fewer recipients, comfortably fits both limits for most events. For an unusually high-volume launch, queue attendees from multiple orders and flush them together — still 10 or fewer recipients per call — to use fewer batches.
* To grant access to a paid offering instead of a free Space, pass `planId` and `linkDestination` in place of `spaceId`. See `CreateInvitesInput` in the [GraphQL Schema Explorer](/api/graphql-explorer).

## Step 7: Mirror Eventbrite events in your Network

When an `event.published` or `event.updated` webhook arrives, fetch the event and pass along `config.action` from the webhook payload. Only `event.published` creates a new mirrored event; `event.updated` only updates one that already exists. Link mirrored events to the Eventbrite page so members buy tickets there.

```javascript Node.js theme={null}
const CREATE_EVENT = `
  mutation MirrorEvent($input: CreateEventInput!) {
    createEvent(input: $input) {
      event { id url }
      errors
    }
  }
`;

const UPDATE_EVENT = `
  mutation UpdateMirroredEvent($input: UpdateEventInput!) {
    updateEvent(input: $input) {
      event { id }
      errors
    }
  }
`;

function toMightyEvent(ebEvent) {
  return {
    title: ebEvent.name.text,
    description: `${ebEvent.description?.text ?? ''}\n\nGet tickets: ${ebEvent.url}`.trim(),
    startsAt: ebEvent.start.utc,
    endsAt: ebEvent.end.utc,
    timeZone: ebEvent.start.timezone,
    eventType: ebEvent.online_event ? 'online_meeting' : 'local',
    link: ebEvent.url,
  };
}

async function handleEventWebhook(action, apiUrl) {
  const ebEvent = await eventbrite(apiUrl);
  const mirror = await lookupMirror(ebEvent.id); // from your database

  if (mirror && !mirror.mightyEventId) {
    // Another worker has claimed this event and is still creating it. Fail so
    // your queue retries this job after the Mighty event ID is saved.
    throw new Error(`Mirrored event for ${ebEvent.id} is still being created`);
  }

  if (mirror) {
    const { updateEvent } = await mighty(UPDATE_EVENT, {
      input: { id: mirror.mightyEventId, ...toMightyEvent(ebEvent) },
    });
    if (updateEvent.errors.length) throw new Error(updateEvent.errors.join('; '));
    return;
  }

  // Without a saved mapping, only a published, live event should create a
  // new mirrored event. An event.updated webhook with no mapping means the
  // mirrored event was never created (or its record was lost) — skip it
  // instead of creating a duplicate later.
  if (action !== 'event.published' || ebEvent.status !== 'live') return;

  // Two deliveries of the same webhook can both reach this point before either
  // saves a mapping. Claim the Eventbrite event ID first with an insert guarded
  // by a unique key (for example, INSERT ... ON CONFLICT DO NOTHING). Only the
  // worker whose insert succeeds creates the event.
  const claimed = await claimMirror(ebEvent.id);
  if (!claimed) return;

  let createEvent;
  try {
    ({ createEvent } = await mighty(CREATE_EVENT, {
      input: { spaceId: EVENTS_SPACE_ID, ...toMightyEvent(ebEvent) },
    }));
  } catch (err) {
    await releaseMirrorClaim(ebEvent.id); // nothing was created, so a retry can claim it again
    throw err;
  }
  if (createEvent.errors.length) {
    await releaseMirrorClaim(ebEvent.id);
    throw new Error(createEvent.errors.join('; '));
  }

  await saveMirroredEventId(ebEvent.id, createEvent.event.id);
}
```

Keep these rules in mind:

* Store the mapping from Eventbrite event ID to Mighty event ID. Without it, a repeated `event.published` webhook creates a duplicate event, and an `event.updated` webhook is skipped instead of updating anything.
* Claim the mapping atomically before you call `createEvent`, as the sample code does. Checking for a mapping and saving it afterward isn't enough: two workers handling duplicate deliveries can both find no mapping and each create an event.
* A claim that still has no Mighty event ID after a few minutes means a worker stopped between `createEvent` and saving the ID, or a request timed out after Mighty created the event. Check the Space for the event before you clear the claim, so a retry doesn't create a second one.
* An event must start in the future. Past Eventbrite events return the error `Start cannot be too soon or in the past.`
* Events that use the Mighty Zoom integration can't be created through the Mighty API. Use `online_meeting` with the Eventbrite link, as above.
* By default, a new event is also posted to the Space's feed. Pass `postInFeed: false` to turn that off.

## Test the integration

1. Create a test event on Eventbrite with a free ticket, and point the integration at a test Space.
2. Publish the event. Confirm a matching event appears in the test Space with a link to Eventbrite.
3. Register for the event with an email address you control. Confirm the invite email arrives and that accepting it adds you to the Space.
4. Place a second order with the same email. Confirm your logs show the address in `ignoredRecipients` and no second email arrives.

## Troubleshooting

<AccordionGroup>
  <Accordion title="I get an HTML 403 page instead of JSON">
    Add a non-empty `User-Agent` header to every request to `api.mn.co`. Requests without one are blocked by bot protection.
  </Accordion>

  <Accordion title="Mutations return FORBIDDEN">
    The token must belong to a Host of the Network and include the scopes from [Step 1](#step-1-create-a-mighty-oauth-application). If you added a scope to the application after the Host connected, have the Host connect the integration again so the new scope is granted.
  </Accordion>

  <Accordion title="Token refresh fails with invalid_grant">
    The refresh token was already used, revoked, or expired. This often means two refreshes ran at the same time. Serialize refreshes, and have the Host connect the integration again to get a new token pair.
  </Accordion>

  <Accordion title="An attendee never got an invite">
    Check `ignoredRecipients` in your logs first. The attendee may already be in the Space or have a pending invite — but `ignoredRecipients` is only populated in sync mode (10 or fewer recipients); it's always empty for an async call. If `errors` listed `Invalid Emails`, that address failed Mighty's email validation (not just a formatting check) and, because the call ran synchronously, the whole batch was rejected with `count: 0`. The sample code retries once without the listed addresses, so other attendees on the order still get invited, but the attendee with the invalid address never will until they update it on Eventbrite. In async mode, invalid addresses are dropped silently instead, with no error to catch — another reason to keep calls at 10 or fewer recipients.
  </Accordion>

  <Accordion title="Mirrored events appear twice">
    Your service created a new event instead of updating the existing one. Check that you claim the Eventbrite event ID with a unique key before calling `createEvent`, save the Mighty event ID after it succeeds, and look up the mapping before creating.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Authentication" icon="key" href="/api/authentication">
    Token exchange, refresh, and revocation in detail
  </Card>

  <Card title="GraphQL Schema Explorer" icon="compass" href="/api/graphql-explorer">
    Browse `createInvites`, `createEvent`, and every other mutation
  </Card>

  <Card title="OAuth Client Architectures" icon="sitemap" href="/api/oauth-client-architectures">
    Keep tokens safe in a backend service
  </Card>

  <Card title="Changelog" icon="clock-rotate-left" href="/api/changelog">
    Track changes to the Mighty API
  </Card>
</CardGroup>
