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

> Build a WordPress plugin that connects to your Mighty Network with OAuth and shows upcoming events and recent posts through the Mighty API

In this guide, you build a small WordPress plugin that connects to your Mighty Network through the [Mighty API](/api). When you finish, your WordPress site can:

* Connect to your Network once, from a WordPress settings page, through OAuth 2.0
* Show upcoming events with a `[mighty_events]` shortcode
* Show recent posts from a Space with a `[mighty_posts]` shortcode
* Refresh its cache when a webhook reports new content on the Network

The plugin is a [backend web app](/api/oauth-client-architectures#backend-web-apps). WordPress runs on a server you control, so it registers as a **Confidential** client and keeps every token on the server. Visitors to your site never see a Mighty token.

## 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 these plans.
* A WordPress site served over `https`, where you can edit `wp-config.php` and add plugins.
* PHP 8.0 or later.
* **Pretty permalinks** turned on (**Settings** > **Permalinks**, any option except **Plain**). The OAuth callback is a WordPress REST route, and the [redirect URI](/oauth-applications#redirect-uris) you register must match it exactly.

The examples use `my-community` as the Network subdomain and `https://example.com` as the WordPress site. Replace both with your own values.

## Choose the account that connects

Every Mighty API token acts as a signed-in user and sees exactly what that user sees in the Network. The plugin shows what its token can read to anyone who visits your WordPress site, so the account you connect decides what becomes public.

<Warning>
  If a host connects the plugin, the token can read every Space in the Network, including private and paid Spaces. A shortcode without a `space` attribute then publishes content from all of them on your public site.
</Warning>

For a public website, create a dedicated member account (for example, `website@example.com`), add it only to the Spaces you want to publish, and connect the plugin with that account. The plugin only requests the `read:network` scope, so the token can't use Host scopes, and the plugin itself only ever sends read queries. It can still read everything the connected account can see, which for a host is every Space.

## Step 1: Create an OAuth application

<Steps>
  <Step title="Open OAuth Applications">
    Sign in to your Network as a host. Go to **Network Admin** > **Integrations** > **OAuth Applications**, then click **New OAuth Application**.
  </Step>

  <Step title="Configure the application">
    * **Name**: Something the connecting account recognizes, such as `Example.com website`.
    * **Client type**: **Confidential**.
    * **Redirect URI**: `https://example.com/wp-json/mighty/v1/oauth/callback`
    * **Member scopes**: `read:network`.
    * **Host scopes**: Leave empty.
  </Step>

  <Step title="Copy your credentials">
    Save the application and copy the **Client ID** and **Client Secret**. You add them to WordPress in the next step.
  </Step>
</Steps>

See [OAuth Applications](/oauth-applications) for more on each setting.

## Step 2: Add your credentials to wp-config.php

Keep secrets in `wp-config.php`, not in the WordPress database or in the plugin's code. Generate an encryption key for the stored tokens and a secret for webhook deliveries:

```bash theme={null}
php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'  # MIGHTY_ENCRYPTION_KEY
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'        # MIGHTY_WEBHOOK_SECRET
```

Add these lines to `wp-config.php`, above `/* That's all, stop editing! */`:

```php wp-config.php theme={null}
define( 'MIGHTY_NETWORK_SUBDOMAIN', 'my-community' );
define( 'MIGHTY_CLIENT_ID', 'YOUR_CLIENT_ID' );
define( 'MIGHTY_CLIENT_SECRET', 'YOUR_CLIENT_SECRET' );
define( 'MIGHTY_ENCRYPTION_KEY', 'OUTPUT_OF_THE_FIRST_COMMAND' );
define( 'MIGHTY_WEBHOOK_SECRET', 'OUTPUT_OF_THE_SECOND_COMMAND' );

// Only set this if your Network uses a custom domain and its SSL certificate is
// live. See the Note under "Handle the callback".
// define( 'MIGHTY_NETWORK_CUSTOM_DOMAIN', 'https://community.example.com' );
```

## Step 3: Create the plugin

Create the file `wp-content/plugins/mighty-networks-connect/mighty-networks-connect.php`. Each code block labeled `mighty-networks-connect.php` in this guide goes into this file, in order.

Start with the plugin header and a few helpers:

```php mighty-networks-connect.php theme={null}
<?php
/**
 * Plugin Name: Mighty Networks Connect
 * Description: Shows events and posts from a Mighty Network with the Mighty API.
 * Version:     0.1.0
 * Requires PHP: 8.0
 */

defined( 'ABSPATH' ) || exit;

function mighty_base64url( string $bytes ): string {
	return rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' );
}

// OAuth endpoints live on the Network's community host, not on api.mn.co.
function mighty_oauth_url( string $path = '' ): string {
	return 'https://' . MIGHTY_NETWORK_SUBDOMAIN . '.mn.co' . $path;
}

function mighty_redirect_uri(): string {
	return rest_url( 'mighty/v1/oauth/callback' );
}

// The host that shows the consent screen and sends back `iss`. It's the
// subdomain above, unless your Network has a custom domain with its SSL
// certificate live, in which case /oauth/authorize redirects there first. See
// the Note under "Handle the callback".
function mighty_issuer(): string {
	return defined( 'MIGHTY_NETWORK_CUSTOM_DOMAIN' ) ? MIGHTY_NETWORK_CUSTOM_DOMAIN : mighty_oauth_url();
}

function mighty_user_agent(): string {
	return 'mighty-networks-connect/0.1.0 (+' . home_url() . ')';
}
```

### Store tokens encrypted

The plugin encrypts tokens with libsodium before it writes them to the options table, so a database backup or a leaked export doesn't expose them:

```php mighty-networks-connect.php theme={null}
function mighty_encrypt( string $plaintext ): string {
	$key   = base64_decode( MIGHTY_ENCRYPTION_KEY );
	$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
	return base64_encode( $nonce . sodium_crypto_secretbox( $plaintext, $nonce, $key ) );
}

function mighty_decrypt( string $ciphertext ): ?string {
	$raw   = base64_decode( $ciphertext );
	$key   = base64_decode( MIGHTY_ENCRYPTION_KEY );
	$nonce = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
	$plain = sodium_crypto_secretbox_open( substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), $nonce, $key );
	return false === $plain ? null : $plain;
}

function mighty_save_tokens( array $tokens ): void {
	$record = [
		'access_token'  => $tokens['access_token'],
		'refresh_token' => $tokens['refresh_token'],
		'expires_at'    => time() + (int) $tokens['expires_in'],
	];
	update_option( 'mighty_tokens', mighty_encrypt( wp_json_encode( $record ) ), false );
}

function mighty_load_tokens(): ?array {
	$stored = get_option( 'mighty_tokens' );
	if ( ! $stored ) {
		return null;
	}
	$json = mighty_decrypt( $stored );
	return $json ? json_decode( $json, true ) : null;
}
```

### Add a settings page with a Connect button

The settings page appears under **Settings** > **Mighty Networks** and only WordPress administrators can see it:

```php mighty-networks-connect.php theme={null}
add_action( 'admin_menu', function () {
	add_options_page( 'Mighty Networks', 'Mighty Networks', 'manage_options', 'mighty-networks', 'mighty_render_settings_page' );
} );

function mighty_render_settings_page(): void {
	$connected = null !== mighty_load_tokens();
	?>
	<div class="wrap">
		<h1>Mighty Networks</h1>
		<p><?php echo $connected ? 'Connected to ' . esc_html( MIGHTY_NETWORK_SUBDOMAIN ) . '.' : 'Not connected.'; ?></p>
		<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
			<input type="hidden" name="action" value="mighty_connect">
			<?php wp_nonce_field( 'mighty_connect' ); ?>
			<?php submit_button( $connected ? 'Reconnect' : 'Connect to Mighty Networks', 'primary', 'submit', false ); ?>
		</form>
		<?php if ( $connected ) : ?>
			<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="margin-top: 1em;">
				<input type="hidden" name="action" value="mighty_disconnect">
				<?php wp_nonce_field( 'mighty_disconnect' ); ?>
				<?php submit_button( 'Disconnect', 'secondary', 'submit', false ); ?>
			</form>
		<?php endif; ?>
	</div>
	<?php
}
```

### Start the authorization request

When an administrator clicks **Connect**, the plugin generates a single-use `state` value and a PKCE `code_verifier` on the server. It keeps both in a short-lived transient, binds `state` to the administrator's browser with an `HttpOnly` cookie, and redirects to the Network's authorization endpoint:

```php mighty-networks-connect.php theme={null}
add_action( 'admin_post_mighty_connect', function () {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( 'You do not have permission to do this.', 403 );
	}
	check_admin_referer( 'mighty_connect' );

	$state    = mighty_base64url( random_bytes( 32 ) );
	$verifier = mighty_base64url( random_bytes( 32 ) );

	set_transient( 'mighty_oauth_' . $state, [
		'verifier' => $verifier,
		'user_id'  => get_current_user_id(),
	], 10 * MINUTE_IN_SECONDS );

	setcookie( 'mighty_oauth_state', $state, [
		'expires'  => time() + 10 * MINUTE_IN_SECONDS,
		'path'     => '/',
		'secure'   => true,
		'httponly' => true,
		'samesite' => 'Lax',
	] );

	$params = [
		'response_type'         => 'code',
		'client_id'             => MIGHTY_CLIENT_ID,
		'redirect_uri'          => mighty_redirect_uri(),
		'scope'                 => 'read:network',
		'state'                 => $state,
		'code_challenge'        => mighty_base64url( hash( 'sha256', $verifier, true ) ),
		'code_challenge_method' => 'S256',
	];

	wp_redirect( mighty_oauth_url( '/oauth/authorize' ) . '?' . http_build_query( $params, '', '&', PHP_QUERY_RFC3986 ) );
	exit;
} );
```

The account that approves the request on the Network's consent screen is the account the plugin acts as. Before you click **Connect**, sign in to the Network in that browser with the account you chose in [Choose the account that connects](#choose-the-account-that-connects).

### Handle the callback

The Network redirects back to the REST route you registered as the redirect URI. The callback accepts the request only when the returned `state` matches both the transient and the cookie, and when `iss` matches the Network. Then it exchanges the code for tokens on the server:

```php mighty-networks-connect.php theme={null}
add_action( 'rest_api_init', function () {
	register_rest_route( 'mighty/v1', '/oauth/callback', [
		'methods'             => 'GET',
		'callback'            => 'mighty_oauth_callback',
		'permission_callback' => '__return_true', // Checked against state below.
	] );
} );

function mighty_oauth_callback( WP_REST_Request $request ) {
	$state  = (string) $request->get_param( 'state' );
	$cookie = (string) ( $_COOKIE['mighty_oauth_state'] ?? '' );
	$saved  = '' !== $state ? get_transient( 'mighty_oauth_' . $state ) : false;

	if ( ! $saved || ! hash_equals( $cookie, $state ) || ! user_can( $saved['user_id'], 'manage_options' ) ) {
		return new WP_Error( 'mighty_invalid_state', 'This sign-in link is invalid or expired. Start again from Settings.', [ 'status' => 400 ] );
	}

	// state is single use.
	delete_transient( 'mighty_oauth_' . $state );
	setcookie( 'mighty_oauth_state', '', [ 'expires' => time() - HOUR_IN_SECONDS, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax' ] );

	if ( $request->get_param( 'error' ) ) {
		return new WP_Error( 'mighty_denied', 'The connection was not approved.', [ 'status' => 400 ] );
	}
	if ( $request->get_param( 'iss' ) !== mighty_issuer() ) {
		return new WP_Error( 'mighty_invalid_issuer', 'Unexpected authorization server.', [ 'status' => 400 ] );
	}

	$response = wp_remote_post( mighty_oauth_url( '/oauth/token' ), [
		'headers' => [ 'User-Agent' => mighty_user_agent() ],
		'body'    => [
			'grant_type'    => 'authorization_code',
			'code'          => (string) $request->get_param( 'code' ),
			'redirect_uri'  => mighty_redirect_uri(),
			'client_id'     => MIGHTY_CLIENT_ID,
			'client_secret' => MIGHTY_CLIENT_SECRET,
			'code_verifier' => $saved['verifier'],
		],
	] );

	if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
		return new WP_Error( 'mighty_token_exchange_failed', 'Could not complete the connection.', [ 'status' => 502 ] );
	}

	mighty_save_tokens( json_decode( wp_remote_retrieve_body( $response ), true ) );

	return new WP_REST_Response( null, 302, [ 'Location' => admin_url( 'options-general.php?page=mighty-networks' ) ] );
}
```

<Note>
  `iss` names the host that showed the consent screen, which is `https://my-community.mn.co` only until your Network's custom domain gets a live SSL certificate — from then on, `/oauth/authorize` redirects there first, so `iss` becomes `https://your-custom-domain`. Set `MIGHTY_NETWORK_CUSTOM_DOMAIN` to that URL once it does. `/.well-known/oauth-authorization-server` isn't redirected, so its `issuer` value only matches `iss` when you fetch it from the same host that redirected you — fetching it from the subdomain won't tell you what `iss` becomes once your custom domain is live.
</Note>

### Refresh expired access tokens

Access tokens expire after one hour. The plugin refreshes the token shortly before it expires. The refresh token may rotate on every refresh, so the plugin takes an atomic database lock to allow only one refresh at a time. Two page loads that refresh at the same moment would otherwise race, and the loser would send a refresh token that's no longer valid.

```php mighty-networks-connect.php theme={null}
function mighty_access_token(): ?string {
	$tokens = mighty_load_tokens();
	if ( ! $tokens ) {
		return null;
	}
	if ( $tokens['expires_at'] > time() + 60 ) {
		return $tokens['access_token'];
	}

	global $wpdb;

	// Each attempt gets its own lock value, so releasing the lock later can confirm
	// this process still owns it instead of deleting someone else's live lock.
	$lock = time() . ':' . wp_generate_password( 12, false );

	// INSERT IGNORE only inserts when no row exists yet, so exactly one request wins the lock.
	$acquired = $wpdb->query( $wpdb->prepare(
		"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
		'mighty_refresh_lock',
		$lock
	) );

	if ( 1 !== $acquired ) {
		$current_lock = get_option( 'mighty_refresh_lock' );
		if ( $current_lock && time() - (int) strtok( $current_lock, ':' ) < 30 ) {
			return null; // Another request is refreshing.
		}

		// The lock is stale. Delete it only if it's still the same stale row, then retry once.
		$wpdb->query( $wpdb->prepare(
			"DELETE FROM {$wpdb->options} WHERE option_name = %s AND CAST( SUBSTRING_INDEX( option_value, ':', 1 ) AS UNSIGNED ) < %d",
			'mighty_refresh_lock',
			time() - 30
		) );
		wp_cache_delete( 'mighty_refresh_lock', 'options' );

		$lock     = time() . ':' . wp_generate_password( 12, false );
		$acquired = $wpdb->query( $wpdb->prepare(
			"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
			'mighty_refresh_lock',
			$lock
		) );
		if ( 1 !== $acquired ) {
			return null; // Lost the retry to another request.
		}
	}

	try {
		// Another request may have refreshed while this one waited for the lock.
		wp_cache_delete( 'mighty_tokens', 'options' );
		$tokens = mighty_load_tokens();
		if ( ! $tokens ) {
			return null;
		}
		if ( $tokens['expires_at'] > time() + 60 ) {
			return $tokens['access_token'];
		}

		$response = wp_remote_post( mighty_oauth_url( '/oauth/token' ), [
			'timeout' => 10,
			'headers' => [ 'User-Agent' => mighty_user_agent() ],
			'body'    => [
				'grant_type'    => 'refresh_token',
				'refresh_token' => $tokens['refresh_token'],
				'client_id'     => MIGHTY_CLIENT_ID,
				'client_secret' => MIGHTY_CLIENT_SECRET,
			],
		] );
		if ( is_wp_error( $response ) ) {
			return null;
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
			$body['refresh_token'] = $body['refresh_token'] ?? $tokens['refresh_token'];
			mighty_save_tokens( $body );
			return $body['access_token'];
		}
		if ( 'invalid_grant' === ( $body['error'] ?? '' ) ) {
			// Another request may have already refreshed with this same refresh token.
			wp_cache_delete( 'mighty_tokens', 'options' );
			$current = mighty_load_tokens();
			if ( $current && $current['refresh_token'] === $tokens['refresh_token'] ) {
				delete_option( 'mighty_tokens' ); // An administrator must connect again.
			}
		}
		return null;
	} finally {
		// Match on the exact lock value this process inserted, so a refresh that outlives
		// the stale window releases only its own lock, never one a later request took over.
		if ( $acquired ) {
			$wpdb->query( $wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
				'mighty_refresh_lock',
				$lock
			) );
			wp_cache_delete( 'mighty_refresh_lock', 'options' );
		}
	}
}
```

A refresh returns `invalid_grant` when the connection was revoked. That happens when the connected account disconnects the app or changes its password, or when a host deletes the OAuth application. The settings page then shows **Not connected** until an administrator clicks **Connect** again.

### Disconnect

**Disconnect** revokes the token pair at the Network and deletes it from WordPress:

```php mighty-networks-connect.php theme={null}
add_action( 'admin_post_mighty_disconnect', function () {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( 'You do not have permission to do this.', 403 );
	}
	check_admin_referer( 'mighty_disconnect' );

	$tokens = mighty_load_tokens();
	if ( $tokens ) {
		wp_remote_post( mighty_oauth_url( '/oauth/revoke' ), [
			'headers' => [ 'User-Agent' => mighty_user_agent() ],
			'body'    => [
				'token'         => $tokens['refresh_token'],
				'client_id'     => MIGHTY_CLIENT_ID,
				'client_secret' => MIGHTY_CLIENT_SECRET,
			],
		] );
	}
	delete_option( 'mighty_tokens' );

	wp_redirect( admin_url( 'options-general.php?page=mighty-networks' ) );
	exit;
} );
```

## Step 4: Query the Mighty API

Every GraphQL request is a `POST` to the Network's endpoint on `api.mn.co`. Requests need a non-empty `User-Agent` header. GraphQL returns HTTP `200` for most errors, so the client checks the `errors` array on every response:

```php mighty-networks-connect.php theme={null}
function mighty_graphql( string $query, array $variables = [] ): ?array {
	$token = mighty_access_token();
	if ( ! $token ) {
		return null;
	}

	$response = wp_remote_post( 'https://api.mn.co/networks/' . MIGHTY_NETWORK_SUBDOMAIN . '/graphql', [
		'timeout' => 10,
		'headers' => [
			'Authorization' => 'Bearer ' . $token,
			'Content-Type'  => 'application/json',
			'User-Agent'    => mighty_user_agent(),
		],
		'body'    => wp_json_encode( [ 'query' => $query, 'variables' => (object) $variables ] ),
	] );

	if ( is_wp_error( $response ) ) {
		error_log( 'Mighty API request failed: ' . $response->get_error_message() );
		return null;
	}

	$status = wp_remote_retrieve_response_code( $response );
	if ( 200 !== $status ) {
		error_log( 'Mighty API request failed with status ' . $status );
		return null;
	}

	$payload = json_decode( wp_remote_retrieve_body( $response ), true );
	if ( ! empty( $payload['errors'] ) ) {
		error_log( 'Mighty API errors: ' . wp_json_encode( $payload['errors'] ) );
		return null;
	}
	return $payload['data'] ?? null;
}
```

### Cache responses

Calling the Mighty API on every page view is slow, and it spends your Network's API quota. The plugin caches each result in a transient for 15 minutes. The cache key includes a version number, so a single `update_option` call makes every cached result stale at once. You use that version number in [Step 6](#step-6-refresh-the-cache-with-a-webhook). The plugin also keeps the last successful result in a separate, non-expiring copy, and serves it if the API call that would replace an expired cache fails, so an outage empties nothing that was already showing.

```php mighty-networks-connect.php theme={null}
function mighty_cached_query( string $query, array $variables = [] ): ?array {
	$version    = (int) get_option( 'mighty_cache_version', 1 );
	$query_key  = md5( $query . wp_json_encode( $variables ) );
	$cache_key  = 'mighty_' . $version . '_' . $query_key;
	$last_good  = 'mighty_last_good_' . $query_key;

	$cached = get_transient( $cache_key );
	if ( false !== $cached ) {
		return $cached;
	}

	$data = mighty_graphql( $query, $variables );
	if ( null === $data ) {
		// Serve the last successful result, however old, rather than showing nothing.
		$fallback = get_option( $last_good );
		return false !== $fallback ? $fallback : null;
	}

	set_transient( $cache_key, $data, 15 * MINUTE_IN_SECONDS );
	update_option( $last_good, $data, false );
	return $data;
}
```

## Step 5: Add shortcodes

### Upcoming events

`network.events` returns one row for each occurrence, so a recurring event appears once for each upcoming date. `spaceId` limits the results to one Space.

```php mighty-networks-connect.php theme={null}
const MIGHTY_EVENTS_QUERY = <<<'GRAPHQL'
query UpcomingEvents($spaceId: ID, $perPage: Int) {
  network {
    events(direction: UPCOMING, spaceId: $spaceId, perPage: $perPage) {
      id
      title
      startsAt
      url
    }
  }
}
GRAPHQL;

add_shortcode( 'mighty_events', function ( $atts ) {
	$atts = shortcode_atts( [ 'space' => '', 'limit' => 5 ], $atts );
	$data = mighty_cached_query( MIGHTY_EVENTS_QUERY, [
		'spaceId' => $atts['space'] ?: null,
		'perPage' => min( 50, max( 1, (int) $atts['limit'] ) ),
	] );

	if ( null === $data ) {
		return ''; // The API call failed and no cached copy exists.
	}

	$events = $data['network']['events'] ?? [];
	if ( ! $events ) {
		return '<p>No upcoming events.</p>';
	}

	$format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
	$html   = '<ul class="mighty-events">';
	foreach ( $events as $event ) {
		$when  = $event['startsAt'] ? wp_date( $format, strtotime( $event['startsAt'] ) ) : '';
		$html .= sprintf(
			'<li><a href="%s">%s</a> <time datetime="%s">%s</time></li>',
			esc_url( $event['url'] ),
			esc_html( $event['title'] ?: 'Untitled event' ),
			esc_attr( $event['startsAt'] ?? '' ),
			esc_html( $when )
		);
	}
	return $html . '</ul>';
} );
```

### Recent posts

`network.posts` returns posts, events, and polls as a union. Filter it with `postTypes`, and select fields on the `Post` type with an inline fragment. By default, the connection returns only published posts.

```php mighty-networks-connect.php theme={null}
const MIGHTY_POSTS_QUERY = <<<'GRAPHQL'
query RecentPosts($spaceId: ID, $first: Int) {
  network {
    posts(spaceId: $spaceId, first: $first, postTypes: [POST, ARTICLE]) {
      nodes {
        ... on Post {
          id
          title
          bodyText
          imageUrl
          publishedAt
          url
          creator {
            name
          }
        }
      }
    }
  }
}
GRAPHQL;

add_shortcode( 'mighty_posts', function ( $atts ) {
	$atts = shortcode_atts( [ 'space' => '', 'limit' => 5 ], $atts );
	$data = mighty_cached_query( MIGHTY_POSTS_QUERY, [
		'spaceId' => $atts['space'] ?: null,
		'first'   => min( 50, max( 1, (int) $atts['limit'] ) ),
	] );

	if ( null === $data ) {
		return ''; // The API call failed and no cached copy exists.
	}

	$posts = array_filter( $data['network']['posts']['nodes'] ?? [] );
	if ( ! $posts ) {
		return '<p>No posts yet.</p>';
	}

	$html = '<div class="mighty-posts">';
	foreach ( $posts as $post ) {
		$html .= '<article class="mighty-post">';
		if ( ! empty( $post['imageUrl'] ) ) {
			$html .= sprintf( '<img src="%s" alt="" loading="lazy">', esc_url( $post['imageUrl'] ) );
		}
		$html .= sprintf(
			'<h3><a href="%s">%s</a></h3><p class="mighty-post-meta">%s</p><p>%s</p>',
			esc_url( $post['url'] ),
			esc_html( $post['title'] ?: 'Untitled post' ),
			esc_html( $post['creator']['name'] ?? '' ),
			esc_html( wp_trim_words( $post['bodyText'] ?? '', 40 ) )
		);
		$html .= '</article>';
	}
	return $html . '</div>';
} );
```

<Tip>
  The shortcodes render `bodyText`, the plain-text body, and escape every value. If you switch to `bodyHtml` to keep formatting, pass it through `wp_kses_post()` before you output it.
</Tip>

### Find a Space ID

The `space` attribute takes a Space's GraphQL `id`. To list the Spaces the connected account can see, open the hosted GraphiQL explorer at `https://my-community.mn.co/admin/headless-api/explorer` and run:

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

The explorer runs as your host account, so it lists every Space in the Network. The plugin only sees Spaces that the connected account belongs to.

### Activate and test

1. In WordPress, go to **Plugins** and activate **Mighty Networks Connect**.
2. Go to **Settings** > **Mighty Networks**, click **Connect to Mighty Networks**, sign in with the account you chose, and approve access.
3. Add the shortcodes to any page or post:

   ```text theme={null}
   [mighty_events limit="3"]
   [mighty_posts space="SPACE_ID" limit="6"]
   ```

## Step 6: Refresh the cache with a webhook

Without a webhook, new content shows up on your site within 15 minutes, when the cache expires. With a webhook, the Network notifies WordPress when content changes, and the plugin clears its cache right away.

Add a REST route that accepts deliveries. The Network sends the API key you set on the webhook as a Bearer token in the `Authorization` header, so the route compares that header with `MIGHTY_WEBHOOK_SECRET`:

```php mighty-networks-connect.php theme={null}
add_action( 'rest_api_init', function () {
	register_rest_route( 'mighty/v1', '/webhook', [
		'methods'             => 'POST',
		'callback'            => function () {
			update_option( 'mighty_cache_version', (int) get_option( 'mighty_cache_version', 1 ) + 1, false );
			return new WP_REST_Response( null, 204 );
		},
		'permission_callback' => function ( WP_REST_Request $request ) {
			$header = (string) $request->get_header( 'authorization' );
			return hash_equals( 'Bearer ' . MIGHTY_WEBHOOK_SECRET, $header );
		},
	] );
} );
```

Then register the webhook on your Network:

* **Network Admin**: Go to **Network Admin** > **Integrations** > **Webhooks**. Set the URL to `https://example.com/wp-json/mighty/v1/webhook` and the API key to your `MIGHTY_WEBHOOK_SECRET` value.
* **Mighty API**: A host can run the `createWebhookCallback` mutation in the same GraphiQL explorer used in [Find a Space ID](#find-a-space-id). It requires the `host:write:network_integrations` scope, which the WordPress application doesn't have — but the explorer doesn't run as any OAuth application you create; it always runs with every scope a host can hold, so no separate application is needed.

  ```graphql theme={null}
  mutation RegisterWordPressWebhook {
    createWebhookCallback(
      input: {
        url: "https://example.com/wp-json/mighty/v1/webhook"
        apiKey: "YOUR_MIGHTY_WEBHOOK_SECRET"
        includedEvents: [POST_CREATED, POST_UPDATED, ARTICLE_CREATED, ARTICLE_UPDATED, EVENT_CREATED, EVENT_UPDATED, EVENT_CANCELED, EVENT_INSTANCE_CANCELED]
      }
    ) {
      webhookCallback {
        id
        includedEvents
      }
      errors
    }
  }
  ```

The route returns `204` right away and doesn't depend on the payload, so retried or duplicate deliveries are harmless. If your endpoint fails intermittently, delivery backs off and retries on its own — see the [circuit breaker](/admin-api#circuit-breaker) for details. But if it fails for 5 consecutive attempts spanning at least 3 days, the Network disables the webhook outright, and nothing retries it after that: go to **Network Admin** > **Integrations** > **Webhooks** and save the webhook again (or run `updateWebhookCallback`) to turn it back on.

<Note>
  Webhooks follow your Network's plan. If **Webhooks** doesn't appear under **Integrations**, the 15-minute cache still keeps your site up to date.
</Note>

## Troubleshooting

<AccordionGroup>
  <Accordion title="redirect_uri_mismatch on the consent screen">
    The redirect URI the plugin sends must exactly match the one on the OAuth application. Open `https://example.com/wp-json/mighty/v1/oauth/callback` in a browser. If WordPress redirects to a different URL (for example, `www.` or a trailing slash), or if permalinks are set to **Plain**, register the URL that WordPress actually uses.
  </Accordion>

  <Accordion title="This sign-in link is invalid or expired">
    The `state` check failed. Start again from **Settings** > **Mighty Networks**, and finish within 10 minutes in the same browser. Caching plugins or a CDN that strip cookies from `/wp-json/` requests also cause this error. Exclude the callback route from caching.
  </Accordion>

  <Accordion title="Shortcodes show no content">
    Check your PHP error log for `Mighty API errors` lines. A `NOT_FOUND` or empty result usually means the connected account doesn't belong to the Space. An `UNAUTHENTICATED` error means the connection was revoked, so click **Connect** again.
  </Accordion>

  <Accordion title="A 403 response with an HTML page">
    `api.mn.co` blocks requests without a `User-Agent` header. The plugin sets one on every request. If you changed the HTTP code, restore the header.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Browse the schema" icon="compass" href="/api/graphql-explorer">
    Find more fields to show on your site, such as event images, courses, or member counts.
  </Card>

  <Card title="Authentication" icon="key" href="/api/authentication">
    Read the complete OAuth flow, token lifecycle, and error reference.
  </Card>

  <Card title="OAuth Client Architectures" icon="sitemap" href="/api/oauth-client-architectures">
    Check the plugin's design against the security review checklist.
  </Card>

  <Card title="Changelog" icon="clock-rotate-left" href="/api/changelog">
    Track schema changes before you update the plugin.
  </Card>
</CardGroup>
