Zum Inhalt springen
Mario Breskic

Mario Breskic

Grafikdesigner

Kategorien

  • Baukasten
  • Blog
  • Hört
  • Liest
  • Macht
  • Notiert
  • Schaut
  • Spielt

Seiten

  • About
  • Bücherregal
  • Datenschutzerklärung
  • Design-Websites
  • Freunde
  • Impressum
  • Kontakt
  • Medienfeed
  • Now
  • Social Media
  • Werkzeuge

Webroll

  • Ariane Konzepterin
  • Alexander Auffermann
  • Andreas Ken Lanig
  • Andreas Maxbauer
  • Andreas Rauth
  • David Sickinger
  • Harald Geisler
  • Jenny Habermehl
  • Kamman Rossi
  • Kyle T. Webster
  • Marco Hayek
  • Martina Wetzel
  • Narrata.io
  • Rémy & moi
  • Rene Stach
  • Stephanie Kowalski
  • Tilo Staudenrausch
Startseite › Blog › How to retire a ghost ActivityPub account left behind by WordPress and move it to Mastodon › 22. Juni 2026, 1:16

How to retire a ghost ActivityPub account left behind by WordPress and move it to Mastodon

Autor: 

Mario Breskic

Publiziert am: 

22. Juni 2026

Zuletzt editiert: 

22. Juni 2026

The ActivityPub plugin for WordPress can turn your website, your WordPress user account, or both into actors in the Fediverse.

If you later uninstall the plugin, those actors do not necessarily disappear from remote Mastodon servers. Other servers may continue to remember them, search for them, display them, or send requests to inbox URLs that no longer exist.

This can leave you with what looks like a ghost account:

@yourname@www.example.com

while your actual Mastodon account is:

@yourname@mastodon.example

Simply uninstalling ActivityPub does not tell the rest of the Fediverse that the old actor has moved. Returning a generic 404 Not Found does not help either.

The proper repair is:

  1. temporarily restore the old WordPress actor;
  2. add it as an alias of the real Mastodon account;
  3. send a signed ActivityPub Move;
  4. preserve the retired actor and its movedTo property;
  5. close its old inbox with 410 Gone;
  6. remove the full ActivityPub plugin without erasing the migration record.

This tutorial walks through that entire procedure on a Plesk server using WP Toolkit and WP-CLI.

Disclosure

This tutorial was made with ChatGPT.

ChatGPT and I successfully implemented and verified this migration on my own WordPress website and Mastodon server. I executed every command, inspected the responses, verified the database records, removed the ActivityPub plugin, and tested the final retired actors and inboxes.

The tested environment was:

Plesk Obsidian
WordPress 7.0
ActivityPub for WordPress 9.0.1
PHP 8.2
Mastodon 4.6.0
Ubuntu Linux

Plugin interfaces and internal paths may change in later versions. Read each command before running it and keep a complete website and database backup.


What this procedure does

The procedure creates a proper ActivityPub migration from an old WordPress actor to a real Mastodon actor.

The old actor will publish:

{
  "movedTo": "https://mastodon.example/users/alice"
}

Its outbox will contain a Move activity resembling:

{
  "type": "Move",
  "object": "https://www.example.com/?author=1",
  "target": "https://mastodon.example/users/alice"
}

The new Mastodon actor must reciprocally list the old actor under:

{
  "alsoKnownAs": [
    "https://www.example.com/?author=1"
  ]
}

This reciprocal relationship prevents one account from falsely claiming that it moved to an unrelated person.

What it does not do

This procedure does not:

  • copy old WordPress posts into Mastodon;
  • guarantee immediate deletion from every remote server;
  • force every Fediverse implementation to refresh its cache immediately;
  • forward old inbox traffic into Mastodon;
  • turn an HTTP redirect into an account migration.

The old actor remains a small, machine-readable retirement notice.


Why forwarding the old inbox is the wrong solution

Suppose remote servers are still sending requests to:

https://www.example.com/wp-json/activitypub/1.0/actors/0/inbox

It may seem reasonable to proxy those requests to:

https://mastodon.example/users/alice/inbox

Do not do this.

ActivityPub deliveries are signed for a particular host, request target and actor identity. Rewriting the destination does not transform an activity addressed to one actor into an activity addressed to another actor.

A reverse proxy relocates an HTTP request. It does not transfer an ActivityPub identity.

The correct mechanism is a Move activity.


Before beginning

You need:

  • SSH root access to the Plesk server;
  • access to the WordPress administration interface;
  • access to the destination Mastodon account;
  • Plesk WP Toolkit;
  • the ActivityPub plugin temporarily installed and active;
  • curl;
  • Python 3;
  • a current website and database backup.

jq is not required. All JSON examples in this tutorial use Python.


Part 1: define your installation values

Begin by listing the WordPress installations known to Plesk:

plesk ext wp-toolkit --list

Example:

ID  Installation Path  Website URL
8   /httpdocs          https://www.example.com

Set the values for your installation:

WP_INSTANCE_ID='8'

VHOST='/var/www/vhosts/example.com'
DOCROOT="$VHOST/httpdocs"

SITE_HOST='www.example.com'

TARGET_HANDLE='alice@mastodon.example'
TARGET_ACTOR='https://mastodon.example/users/alice'

PHP_BIN='/opt/plesk/php/8.2/bin/php'

Replace every example value with the real value for your server.

Do not guess the destination actor URL. Mastodon actor URLs normally resemble:

https://mastodon.example/users/alice

They are not necessarily the same as the human profile URL:

https://mastodon.example/@alice

Part 2: restore the ActivityPub plugin temporarily

If ActivityPub is still installed but inactive:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  plugin activate activitypub

If it was deleted:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  plugin install activitypub --activate

Check the installed version:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  plugin get activitypub \
  --fields=name,status,version \
  --format=table

Example:

Field    Value
name     activitypub
version  9.0.1
status   active

Do not remove the plugin again until the migration, export and retirement handler have all been verified.


Part 3: identify every old WordPress actor

A WordPress installation may expose more than one ActivityPub actor.

This was the crucial detail in my case.

I had:

@mario@www.mariobreskic.de

which resolved to:

https://www.mariobreskic.de/?author=1

and a separate site-wide blog actor:

@mariobreskic.de@www.mariobreskic.de

which resolved to:

https://www.mariobreskic.de/?author=0

They had separate inboxes:

/wp-json/activitypub/1.0/actors/1/inbox
/wp-json/activitypub/1.0/actors/0/inbox

A request to /actors/0/inbox therefore did not belong to the same actor as /actors/1/inbox.

3.1 Check the user actor with WebFinger

Replace the handle:

curl -fsS -G \
  -H 'Accept: application/jrd+json' \
  --data-urlencode \
  'resource=acct:alice@www.example.com' \
  'https://www.example.com/.well-known/webfinger' |
python3 -m json.tool

Look for:

{
  "rel": "self",
  "type": "application/activity+json",
  "href": "https://www.example.com/?author=1"
}

The href value is the canonical ActivityPub actor ID.

It is not the inbox URL.

3.2 Check for a site-wide blog actor

The blog actor may have a handle based on the website domain:

curl -fsS -G \
  -H 'Accept: application/jrd+json' \
  --data-urlencode \
  'resource=acct:example.com@www.example.com' \
  'https://www.example.com/.well-known/webfinger' |
python3 -m json.tool

A blog actor may resolve to:

https://www.example.com/?author=0

and may use the ActivityPub type:

Group

rather than:

Person

That is normal.

3.3 If the blog actor cannot be found

Open the WordPress administration interface and locate the ActivityPub profile settings.

Enable the blog-wide or site-wide profile temporarily.

Its identifier must match the old identifier used when the account was originally federated.

Then save the WordPress permalink settings once:

Settings
→ Permalinks
→ Save Changes

Do not change the permalink structure. Saving it refreshes WordPress rewrite rules.

Repeat the WebFinger request afterwards.


Part 4: inspect each actor

Inspect the user actor:

curl -fsS \
  -H 'Accept: application/activity+json' \
  'https://www.example.com/?author=1' |
python3 -c '
import json
import sys

data = json.load(sys.stdin)

for key in (
    "id",
    "type",
    "preferredUsername",
    "inbox",
    "outbox",
    "followers",
    "alsoKnownAs",
    "movedTo",
):
    print(f"{key}: {data.get(key)}")
'

Inspect the blog actor:

curl -fsS \
  -H 'Accept: application/activity+json' \
  'https://www.example.com/?author=0' |
python3 -c '
import json
import sys

data = json.load(sys.stdin)

for key in (
    "id",
    "type",
    "preferredUsername",
    "inbox",
    "outbox",
    "followers",
    "alsoKnownAs",
    "movedTo",
):
    print(f"{key}: {data.get(key)}")
'

Record the exact id value of every actor.

For the remaining examples, assume:

OLD_ACTOR_0='https://www.example.com/?author=0'
OLD_HANDLE_0='example.com@www.example.com'

OLD_ACTOR_1='https://www.example.com/?author=1'
OLD_HANDLE_1='alice@www.example.com'

Remove actor 0 or actor 1 from the procedure if your site only has one of them.


Part 5: add the old actors as aliases in Mastodon

Log in to the destination Mastodon account.

Open:

Preferences
→ Account
→ Moving from a different account
→ Create account alias

Add the old handle:

alice@www.example.com

If the blog actor exists, add it separately:

example.com@www.example.com

Do not include a leading @ unless the interface specifically requests it.

Adding an alias does not move anything by itself. It tells Mastodon that the destination account consents to being the target of a later migration.

5.1 If Mastodon cannot find the old account

Do not continue.

Confirm that WebFinger works:

curl -fsS -G \
  -H 'Accept: application/jrd+json' \
  --data-urlencode \
  'resource=acct:example.com@www.example.com' \
  'https://www.example.com/.well-known/webfinger' |
python3 -m json.tool

Also try searching inside Mastodon for:

@example.com@www.example.com

or for the direct actor URL:

https://www.example.com/?author=0

Once Mastodon can resolve the actor, retry the alias form.


Part 6: verify the aliases on the destination actor

The destination Mastodon actor must publish every old actor URL under alsoKnownAs.

Run:

curl -fsS \
  -H 'Accept: application/activity+json' \
  "$TARGET_ACTOR" |
python3 -c '
import json
import sys

data = json.load(sys.stdin)

print("id:", data.get("id"))
print("alsoKnownAs:")

for value in data.get("alsoKnownAs", []):
    print("  " + value)
'

Required result:

https://www.example.com/?author=0
https://www.example.com/?author=1

Do not run the Move command for an actor whose URL is absent.


Part 7: check whether WordPress retained followers

For actor 1:

curl -fsS \
  -H 'Accept: application/activity+json' \
  'https://www.example.com/wp-json/activitypub/1.0/actors/1/followers' |
python3 -c '
import json
import sys

data = json.load(sys.stdin)
print("type:", data.get("type"))
print("totalItems:", data.get("totalItems"))
'

For actor 0:

curl -fsS \
  -H 'Accept: application/activity+json' \
  'https://www.example.com/wp-json/activitypub/1.0/actors/0/followers' |
python3 -c '
import json
import sys

data = json.load(sys.stdin)
print("type:", data.get("type"))
print("totalItems:", data.get("totalItems"))
'

A result such as:

totalItems: 0

does not prevent the migration.

It means WordPress has no locally stored follower inboxes to notify directly. The old actor can still publish movedTo, and remote servers can discover it when they refresh the actor.


Part 8: run ActivityPub commands correctly through Plesk

Plesk integrates WP-CLI through WP Toolkit.

Test the ActivityPub Move command:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  activitypub move --help \
  --skip-plugins=false

The final option is important:

--skip-plugins=false

Without it, Plesk may run WP-CLI without loading ordinary WordPress plugins. The result will be:

Error: 'activitypub' is not a registered wp command.

The plugin can be active in WordPress while still being absent from the WP-CLI runtime.


Part 9: move the user actor

Run:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  activitypub move \
  'https://www.example.com/?author=1' \
  "$TARGET_ACTOR" \
  --skip-plugins=false

Expected result:

Success: Move Scheduled.

Do not repeat the command after it succeeds.


Part 10: move the blog actor

Run:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  activitypub move \
  'https://www.example.com/?author=0' \
  "$TARGET_ACTOR" \
  --skip-plugins=false

Expected:

Success: Move Scheduled.

Again, do not repeat it after success.


Troubleshooting: “Invalid target” or “Ungültiges Ziel”

This means WordPress fetched the destination actor but did not find the old actor URL under alsoKnownAs.

First verify the destination actor again:

curl -fsS \
  -H 'Accept: application/activity+json' \
  "$TARGET_ACTOR" |
python3 -m json.tool

If the alias is present publicly, WordPress may have cached an older copy.

Generate the plugin’s cache key:

CACHE_KEY=$(
  python3 - "$TARGET_ACTOR" <<'PY'
import hashlib
import sys

url = sys.argv[1]
print("activitypub_http_" + hashlib.md5(url.encode()).hexdigest())
PY
)

Display it:

echo "$CACHE_KEY"

Delete the transient:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  transient delete "$CACHE_KEY"

Then inspect what WordPress fetches without cache:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- eval '
$url = "https://mastodon.example/users/alice";

$data = \Activitypub\Http::get_remote_object($url, false);

if (is_wp_error($data)) {
    WP_CLI::error(
        $data->get_error_code() . ": " .
        $data->get_error_message()
    );
}

echo "id: " . ($data["id"] ?? "MISSING") . PHP_EOL;
echo "alsoKnownAs:" . PHP_EOL;

foreach (($data["alsoKnownAs"] ?? array()) as $alias) {
    echo "  " . $alias . PHP_EOL;
}
' --skip-plugins=false

Replace the target URL inside the PHP code.

After the correct alias appears, run the Move command once more.

Important warning for ActivityPub 9.0.1

Do not experiment with incorrect destination URLs.

In version 9.0.1, the plugin writes the movedTo setting before it completes target validation. Verify the destination and its aliases before running the command.


Part 11: verify the public actors

Check both:

for NUMBER in 0 1; do
  echo "=== actor $NUMBER ==="

  curl -fsS \
    -H 'Accept: application/activity+json' \
    "https://www.example.com/?author=$NUMBER" |
  python3 -c '
import json
import sys

data = json.load(sys.stdin)

print("id:", data.get("id"))
print("type:", data.get("type"))
print("movedTo:", data.get("movedTo"))
'

  echo
done

Expected:

=== actor 0 ===
id: https://www.example.com/?author=0
type: Group
movedTo: https://mastodon.example/users/alice

=== actor 1 ===
id: https://www.example.com/?author=1
type: Person
movedTo: https://mastodon.example/users/alice

Part 12: verify the Move activities in the database

The public outbox REST page may be paginated or may not show the newest Move where you expect it.

Query the WordPress outbox records directly:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- eval '
$posts = get_posts(
    array(
        "post_type"      => "ap_outbox",
        "post_status"    => "any",
        "posts_per_page" => 100,
        "orderby"        => "ID",
        "order"          => "DESC",
    )
);

foreach ($posts as $post) {
    $data = json_decode($post->post_content, true);

    if (($data["type"] ?? "") !== "Move") {
        continue;
    }

    echo "ID: " . $post->ID . PHP_EOL;
    echo "type: " . ($data["type"] ?? "MISSING") . PHP_EOL;
    echo "object: " . ($data["object"] ?? "MISSING") . PHP_EOL;
    echo "target: " . ($data["target"] ?? "MISSING") . PHP_EOL;
    echo PHP_EOL;
}
' --skip-plugins=false

You need one Move for every retired actor:

object: https://www.example.com/?author=0
target: https://mastodon.example/users/alice

and:

object: https://www.example.com/?author=1
target: https://mastodon.example/users/alice

Part 13: archive the final actor state

Do not uninstall ActivityPub yet.

Create an archive outside the public document root:

ARCHIVE="$VHOST/private/activitypub-retired"

SITE_USER=$(stat -c '%U' "$DOCROOT/wp-config.php")
SITE_GROUP=$(stat -c '%G' "$DOCROOT/wp-config.php")

install -d \
  -o "$SITE_USER" \
  -g "$SITE_GROUP" \
  -m 0750 \
  "$ARCHIVE"

13.1 Save the actor documents

curl -fsS \
  -H 'Accept: application/activity+json' \
  'https://www.example.com/?author=0' \
  -o "$ARCHIVE/actor-0.json"

curl -fsS \
  -H 'Accept: application/activity+json' \
  'https://www.example.com/?author=1' \
  -o "$ARCHIVE/actor-1.json"

13.2 Save the WebFinger documents

curl -fsS -G \
  -H 'Accept: application/jrd+json' \
  --data-urlencode \
  'resource=acct:example.com@www.example.com' \
  'https://www.example.com/.well-known/webfinger' \
  -o "$ARCHIVE/webfinger-0.json"

curl -fsS -G \
  -H 'Accept: application/jrd+json' \
  --data-urlencode \
  'resource=acct:alice@www.example.com' \
  'https://www.example.com/.well-known/webfinger' \
  -o "$ARCHIVE/webfinger-1.json"

13.3 Export the Move activities

Edit the two actor URLs inside this command:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- eval '
$actors = array(
    "0" => "https://www.example.com/?author=0",
    "1" => "https://www.example.com/?author=1",
);

$directory =
    "/var/www/vhosts/example.com/private/activitypub-retired";

$posts = get_posts(
    array(
        "post_type"      => "ap_outbox",
        "post_status"    => "any",
        "posts_per_page" => 100,
        "orderby"        => "ID",
        "order"          => "DESC",
    )
);

$found = array();

foreach ($posts as $post) {
    $data = json_decode($post->post_content, true);

    if (
        !is_array($data) ||
        ($data["type"] ?? "") !== "Move"
    ) {
        continue;
    }

    foreach ($actors as $number => $actor_url) {
        if (
            !isset($found[$number]) &&
            ($data["object"] ?? "") === $actor_url
        ) {
            $filename =
                $directory . "/move-" . $number . ".json";

            $json = wp_json_encode(
                $data,
                JSON_PRETTY_PRINT |
                JSON_UNESCAPED_SLASHES |
                JSON_UNESCAPED_UNICODE
            );

            if (
                false === file_put_contents(
                    $filename,
                    $json . PHP_EOL
                )
            ) {
                WP_CLI::error(
                    "Could not write " . $filename
                );
            }

            $found[$number] = $post->ID;
        }
    }
}

foreach ($actors as $number => $actor_url) {
    if (!isset($found[$number])) {
        WP_CLI::error(
            "No Move activity found for actor " . $number
        );
    }
}

foreach ($found as $number => $post_id) {
    echo "actor " . $number .
        ": outbox post " . $post_id .
        PHP_EOL;
}
' --skip-plugins=false

Correct the archive directory in the PHP code before running it.

13.4 Set permissions

chown "$SITE_USER:$SITE_GROUP" "$ARCHIVE"/*.json
chmod 0640 "$ARCHIVE"/*.json

ls -la "$ARCHIVE"

Expected files:

actor-0.json
actor-1.json
move-0.json
move-1.json
webfinger-0.json
webfinger-1.json

Part 14: validate the archive

python3 - <<'PY'
import json
from pathlib import Path

base = Path(
    "/var/www/vhosts/example.com/private/activitypub-retired"
)

target = "https://mastodon.example/users/alice"

for number in ("0", "1"):
    with (
        base / f"actor-{number}.json"
    ).open(encoding="utf-8") as file:
        actor = json.load(file)

    with (
        base / f"webfinger-{number}.json"
    ).open(encoding="utf-8") as file:
        webfinger = json.load(file)

    with (
        base / f"move-{number}.json"
    ).open(encoding="utf-8") as file:
        move = json.load(file)

    self_links = [
        link.get("href")
        for link in webfinger.get("links", [])
        if link.get("rel") == "self"
    ]

    print(f"=== actor {number} ===")
    print("actor id:", actor.get("id"))
    print("actor type:", actor.get("type"))
    print("movedTo:", actor.get("movedTo"))
    print("WebFinger self:", self_links)
    print("Move type:", move.get("type"))
    print("Move object:", move.get("object"))
    print("Move target:", move.get("target"))

    assert actor.get("movedTo") == target
    assert move.get("type") == "Move"
    assert move.get("object") == actor.get("id")
    assert move.get("target") == target
    assert actor.get("id") in self_links

    print("VALID")
    print()

print("All retirement records are valid.")
PY

Edit the archive path and target actor before running it.

Do not continue unless every actor prints:

VALID

Part 15: install a permanent retirement handler

The full ActivityPub plugin is no longer needed after the migration, but the old identities should remain resolvable.

We will install a small must-use WordPress plugin.

It will:

  • serve the archived WebFinger responses;
  • serve the archived actor documents;
  • serve the archived Move objects;
  • return 410 Gone from the old inboxes;
  • leave ordinary HTML author pages untouched.

Create the file:

cat > /tmp/activitypub-retirement.php <<'PHP'
<?php
/**
 * Plugin Name: Retired ActivityPub Identities
 * Description: Preserves moved ActivityPub actors after removing the ActivityPub plugin.
 * Version: 1.0.0
 */

defined( 'ABSPATH' ) || exit;

/*
 * CHANGE THESE VALUES.
 */
const MB_AP_RETIREMENT_ARCHIVE =
	'/var/www/vhosts/example.com/private/activitypub-retired';

const MB_AP_RETIREMENT_ORIGIN =
	'https://www.example.com';

const MB_AP_RETIREMENT_TARGET =
	'https://mastodon.example/users/alice';


function mb_ap_retirement_send_file(
	string $filename,
	string $content_type
): void {
	$path = MB_AP_RETIREMENT_ARCHIVE . '/' . $filename;

	if ( ! is_readable( $path ) ) {
		status_header( 500 );
		header( 'Content-Type: application/json; charset=utf-8' );
		header( 'Cache-Control: no-store' );

		echo wp_json_encode(
			array(
				'error'   => 'Retirement archive unavailable',
				'missing' => $filename,
			)
		);

		exit;
	}

	status_header( 200 );

	header(
		'Content-Type: ' .
		$content_type .
		'; charset=utf-8'
	);

	header( 'Cache-Control: public, max-age=3600' );
	header( 'X-Content-Type-Options: nosniff' );
	header( 'Vary: Accept' );
	header( 'Content-Length: ' . filesize( $path ) );

	if (
		'HEAD' !== strtoupper(
			$_SERVER['REQUEST_METHOD'] ?? 'GET'
		)
	) {
		readfile( $path );
	}

	exit;
}


function mb_ap_retirement_send_gone(): void {
	$body = wp_json_encode(
		array(
			'error'   => 'Gone',
			'message' => 'This ActivityPub actor has moved.',
			'movedTo' => MB_AP_RETIREMENT_TARGET,
		),
		JSON_UNESCAPED_SLASHES
	);

	status_header( 410 );

	header(
		'Content-Type: application/activity+json; charset=utf-8'
	);

	header( 'Cache-Control: no-store' );
	header( 'X-Content-Type-Options: nosniff' );

	if (
		'HEAD' !== strtoupper(
			$_SERVER['REQUEST_METHOD'] ?? 'GET'
		)
	) {
		echo $body;
	}

	exit;
}


function mb_ap_retirement_accepts_activitypub(): bool {
	$accept = strtolower(
		$_SERVER['HTTP_ACCEPT'] ?? ''
	);

	return str_contains(
		$accept,
		'application/activity+json'
	) || str_contains(
		$accept,
		'application/ld+json'
	);
}


add_action(
	'parse_request',
	static function (): void {
		$request_uri =
			$_SERVER['REQUEST_URI'] ?? '/';

		$path = parse_url(
			$request_uri,
			PHP_URL_PATH
		);

		$method = strtoupper(
			$_SERVER['REQUEST_METHOD'] ?? 'GET'
		);

		/*
		 * CHANGE THE TWO acct: VALUES.
		 */
		if (
			'/.well-known/webfinger' === rtrim(
				(string) $path,
				'/'
			)
			&& in_array(
				$method,
				array( 'GET', 'HEAD' ),
				true
			)
		) {
			$resource = isset( $_GET['resource'] )
				? trim(
					wp_unslash(
						$_GET['resource']
					)
				)
				: '';

			$webfinger_files = array(
				'acct:example.com@www.example.com'
					=> 'webfinger-0.json',

				'acct:alice@www.example.com'
					=> 'webfinger-1.json',
			);

			if (
				isset(
					$webfinger_files[ $resource ]
				)
			) {
				mb_ap_retirement_send_file(
					$webfinger_files[ $resource ],
					'application/jrd+json'
				);
			}

			return;
		}

		/*
		 * Serve the retired actors only when an
		 * ActivityPub representation is requested.
		 */
		if (
			'/' === $path
			&& in_array(
				$method,
				array( 'GET', 'HEAD' ),
				true
			)
			&& mb_ap_retirement_accepts_activitypub()
		) {
			$author = isset( $_GET['author'] )
				? (string) wp_unslash(
					$_GET['author']
				)
				: '';

			$actor_files = array(
				'0' => 'actor-0.json',
				'1' => 'actor-1.json',
			);

			if ( isset( $actor_files[ $author ] ) ) {
				mb_ap_retirement_send_file(
					$actor_files[ $author ],
					'application/activity+json'
				);
			}
		}

		/*
		 * Preserve dereferenceable Move activity IDs.
		 */
		if (
			in_array(
				$method,
				array( 'GET', 'HEAD' ),
				true
			)
			&& mb_ap_retirement_accepts_activitypub()
		) {
			$current_url =
				MB_AP_RETIREMENT_ORIGIN .
				$request_uri;

			foreach (
				array( '0', '1' ) as $number
			) {
				$filename =
					'move-' .
					$number .
					'.json';

				$file =
					MB_AP_RETIREMENT_ARCHIVE .
					'/' .
					$filename;

				if ( ! is_readable( $file ) ) {
					continue;
				}

				$move = json_decode(
					file_get_contents( $file ),
					true
				);

				if (
					is_array( $move )
					&& isset( $move['id'] )
					&& $move['id'] === $current_url
				) {
					mb_ap_retirement_send_file(
						$filename,
						'application/activity+json'
					);
				}
			}
		}

		/*
		 * Retire old inboxes and collections.
		 */
		$normalised_path =
			rtrim( (string) $path, '/' );

		$is_retired_endpoint =
			'/wp-json/activitypub/1.0/inbox'
				=== $normalised_path
			|| preg_match(
				'#^/wp-json/activitypub/1\.0/' .
				'actors/[01]/' .
				'(?:inbox|outbox(?:/stream)?|' .
				'followers|following|liked|' .
				'collections(?:/.*)?)$#',
				$normalised_path
			);

		if ( $is_retired_endpoint ) {
			mb_ap_retirement_send_gone();
		}
	},
	-1000
);
PHP

Edit these values before installing:

MB_AP_RETIREMENT_ARCHIVE
MB_AP_RETIREMENT_ORIGIN
MB_AP_RETIREMENT_TARGET
the two WebFinger acct: identifiers

If you only have one retired actor, remove the unused map entries and files.

15.1 Check the syntax

"$PHP_BIN" -l /tmp/activitypub-retirement.php

Required:

No syntax errors detected

15.2 Install it as a must-use plugin

MUPLUGINS="$DOCROOT/wp-content/mu-plugins"

install -d \
  -o "$SITE_USER" \
  -g "$SITE_GROUP" \
  -m 0755 \
  "$MUPLUGINS"

install \
  -o "$SITE_USER" \
  -g "$SITE_GROUP" \
  -m 0644 \
  /tmp/activitypub-retirement.php \
  "$MUPLUGINS/activitypub-retirement.php"

Confirm:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  plugin list \
  --status=must-use \
  --fields=name,status,version \
  --format=table

Expected:

activitypub-retirement  must-use  1.0.0

Part 16: test the retirement handler before removing ActivityPub

16.1 Test WebFinger

for RESOURCE in \
  'acct:example.com@www.example.com' \
  'acct:alice@www.example.com'
do
  echo "=== $RESOURCE ==="

  curl -fsS -G \
    -H 'Accept: application/jrd+json' \
    --data-urlencode "resource=$RESOURCE" \
    'https://www.example.com/.well-known/webfinger' |
  python3 -c '
import json
import sys

data = json.load(sys.stdin)

print("subject:", data.get("subject"))

for link in data.get("links", []):
    if link.get("rel") == "self":
        print("self:", link.get("href"))
'

  echo
done

16.2 Test both actors

for NUMBER in 0 1; do
  echo "=== actor $NUMBER ==="

  curl -fsS \
    -H 'Accept: application/activity+json' \
    "https://www.example.com/?author=$NUMBER" |
  python3 -c '
import json
import sys

data = json.load(sys.stdin)

print("id:", data.get("id"))
print("type:", data.get("type"))
print("movedTo:", data.get("movedTo"))
'

  echo
done

16.3 Confirm normal HTML remains available

for NUMBER in 0 1; do
  curl -sS \
    -o /dev/null \
    -H 'Accept: text/html' \
    -w "author=$NUMBER: HTTP %{http_code}, %{content_type}\n" \
    "https://www.example.com/?author=$NUMBER"
done

A 200 or normal WordPress 301 redirect is acceptable.

16.4 Test the retired inboxes

for NUMBER in 0 1; do
  curl -sS \
    -o /dev/null \
    -X POST \
    -H 'Content-Type: application/activity+json' \
    -d '{}' \
    -w "actor $NUMBER inbox: HTTP %{http_code}\n" \
    "https://www.example.com/wp-json/activitypub/1.0/actors/$NUMBER/inbox"
done

Required:

HTTP 410

Test the shared inbox:

curl -sS \
  -o /dev/null \
  -X POST \
  -H 'Content-Type: application/activity+json' \
  -d '{}' \
  -w "shared inbox: HTTP %{http_code}\n" \
  'https://www.example.com/wp-json/activitypub/1.0/inbox'

Required:

HTTP 410

Part 17: deactivate ActivityPub

Do not delete it immediately.

Deactivate it first:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  plugin deactivate activitypub \
  --skip-plugins=false

Confirm:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  plugin get activitypub \
  --fields=name,status,version \
  --format=table

Status must be:

inactive

Repeat all tests from Part 16.

The WebFinger documents, actor documents and 410 Gone inboxes must continue working with the main ActivityPub plugin inactive.


Part 18: delete the ActivityPub plugin files

Only after every test succeeds:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  plugin delete activitypub

The retirement MU-plugin is separate and will remain installed.

Confirm:

plesk ext wp-toolkit --wp-cli -instance-id "$WP_INSTANCE_ID" -- \
  plugin list \
  --fields=name,status,version \
  --format=table |
grep -E '^(name|activitypub)'

Only the table header should remain.


Part 19: final verification

Use a saved response body rather than piping directly into Python. This produces clearer diagnostics if a proxy cache briefly returns an empty response.

for NUMBER in 0 1; do
  BODY="/tmp/retired-actor-$NUMBER.json"

  echo "=== actor $NUMBER ==="

  curl --fail-with-body -sS \
    -H 'Accept: application/activity+json' \
    -H 'Cache-Control: no-cache' \
    "https://www.example.com/?author=$NUMBER" \
    -o "$BODY"

  python3 -c '
import json
import sys

with open(sys.argv[1], encoding="utf-8") as file:
    data = json.load(file)

print("id:", data.get("id"))
print("type:", data.get("type"))
print("movedTo:", data.get("movedTo"))
' "$BODY"

  curl -sS \
    -o /dev/null \
    -X POST \
    -H 'Content-Type: application/activity+json' \
    -d '{}' \
    -w "inbox: HTTP %{http_code}\n" \
    "https://www.example.com/wp-json/activitypub/1.0/actors/$NUMBER/inbox"

  echo
done

Final expected state:

actor 0:
type: Group
movedTo: real Mastodon actor
inbox: HTTP 410

actor 1:
type: Person
movedTo: real Mastodon actor
inbox: HTTP 410

Check both WebFinger identities once more:

for RESOURCE in \
  'acct:example.com@www.example.com' \
  'acct:alice@www.example.com'
do
  curl --fail-with-body -sS -G \
    -H 'Accept: application/jrd+json' \
    -H 'Cache-Control: no-cache' \
    --data-urlencode "resource=$RESOURCE" \
    'https://www.example.com/.well-known/webfinger' |
  python3 -c '
import json
import sys

data = json.load(sys.stdin)

print("subject:", data.get("subject"))

for link in data.get("links", []):
    if link.get("rel") == "self":
        print("self:", link.get("href"))
'
done

Common errors

/usr/bin/env: php: No such file or directory

Plesk may not expose a generic php binary in the shell path.

Use the Plesk WP Toolkit wrapper:

plesk ext wp-toolkit --wp-cli -instance-id 8 -- ...

or invoke the configured Plesk PHP binary directly:

/opt/plesk/php/8.2/bin/php

Do not create a global PHP symlink merely to satisfy WP-CLI.

'activitypub' is not a registered wp command

Add:

--skip-plugins=false

to the WP Toolkit command.

Mastodon cannot find the old account

The actor is not currently discoverable through WebFinger.

Temporarily re-enable the relevant ActivityPub profile in WordPress, save the permalink settings and verify:

/.well-known/webfinger?resource=acct:username@domain

before retrying the Mastodon alias.

The follower count is zero

This is not a migration failure.

It means WordPress has no stored followers to notify directly. The actor can still publish movedTo, and remote servers can discover it later.

The REST outbox does not display the Move

Query the ap_outbox records directly with WP-CLI. Pagination and filtering can make the public outbox misleading.

Python reports JSONDecodeError

Save the HTTP body and headers first:

curl -sS \
  -D /tmp/headers.txt \
  -o /tmp/body.json \
  -H 'Accept: application/activity+json' \
  'https://www.example.com/?author=0'

Inspect:

cat /tmp/headers.txt
wc -c /tmp/body.json
python3 -m json.tool /tmp/body.json

A transient empty response during cache turnover does not necessarily mean the retirement handler is broken.

Remote servers continue posting to the old inbox

They may retain stale actor information for some time.

The correct final response is:

410 Gone

Do not proxy those requests into Mastodon.


What must remain permanently

Keep these files:

wp-content/mu-plugins/activitypub-retirement.php

and:

/private/activitypub-retired/

Back them up with the rest of the website.

Also retain the old account aliases on the destination Mastodon account. They are part of the reciprocal evidence that validates the Move.

The final architecture is deliberately small:

Old WordPress actor
    ↓
movedTo
    ↓
Real Mastodon actor

The old actor remains readable, but inert.

Its inbox is closed.

Its identity is not forwarded, impersonated or silently discarded.

The Fediverse receives an explicit statement: this actor existed, and it moved here.

The protocol requirements were checked against Mastodon’s official documentation for Move, alsoKnownAs, account aliases and WebFinger. The WordPress commands and migration behavior were checked against ActivityPub for WordPress 9.0.1 source code, including its external-move implementation, cache key and CLI registration. The Plesk command form and plugin deactivation/deletion steps were checked against the official Plesk and WP-CLI documentation.

activitypub chatgpt mastodon snippets wordpress

Meta

  • Datenschutz­erklärung
  • Impressum
  • Kontakt
  • Humans.txt
  • Llms.txt
  • Sitemap

Projekte

  • Code and Canvas
  • Medienfeed
  • Mastodon Server
  • Social Wall

Socials

  • Are.na
  • Artbreeder
  • Artstation
  • Bēhance
  • Bluesky
  • Cara
  • Das Auge
  • Doměstika
  • Facebook
  • Facebook Page
  • Flickr
  • Github
  • Goodreads
  • Instagram
  • LinkedIn
  • Mastodon
  • Medium
  • Page Online
  • Pinterest
  • Stackoverflow
  • Substack
  • Tumblr
  • Twitter
  • Xing

Twenty Twenty-Five

Gestaltet mit WordPress