Cross-Posting WordPress to Bluesky and Threads: Mapping Custom AT Protocol Schema and ActivityPub Webhooks in n8n

Publishing identical post content across multiple open social networks sounds simple until custom metadata breaks the API payload. While standard RSS syndication can distribute basic post titles and links, it drops critical meta fields—such as custom canonical URLs, primary category taxonomies, guest author attribution, and dedicated social image overrides.

When relying on automated webhooks or REST API pipelines, destination platforms enforce strict formatting rules. Bluesky’s AT Protocol requires rich text entities to be split into precise byte-offset facets and binary blob uploads (Medium). Meanwhile, ActivityPub engines (like those serving Threads or Mastodon federation) demand strict JSON-LD @context formatting (Medium).

Here is a step-by-step workflow for configuring self-hosted or cloud-hosted n8n instances to parse custom WordPress REST API fields, handle remote image uploads, and route valid payloads to both protocols simultaneously.


1. Capturing WordPress Meta Fields in the Webhook Payload

Default WordPress REST API endpoints (/wp-json/wp/v2/posts) omit custom fields created via Advanced Custom Fields (ACF) or custom code blocks unless they are explicitly registered.

To ensure n8n receives image attachments, canonical links, and custom metadata, register those fields within your WordPress theme’s functions.php file or via a custom plugin:

add_action('rest_api_init', function () {
    register_rest_field('post', 'social_custom_meta', [
        'get_callback' => function ($post_arr) {
            return [
                'canonical_url' => get_post_meta($post_arr['id'], 'canonical_url', true) ?: get_permalink($post_arr['id']),
                'social_image'  => get_the_post_thumbnail_url($post_arr['id'], 'full'),
                'author_handle' => get_the_author_meta('display_name', $post_arr['author']),
            ];
        },
        'schema' => null,
    ]);
});

Using a WordPress webhook plugin (such as WP Webhooks or DirectRelay (eesel AI)), dispatch an HTTP POST request to your n8n Webhook Trigger node on post publication (YouTube). Ensure the JSON payload includes the extended social_custom_meta object along with standard title and content attributes.


2. AT Protocol Architecture: Binary Uploads and Facet Mapping

Bluesky does not automatically fetch image links passed in post bodies. To attach media, you must first upload the binary image payload to the Personal Data Server (PDS) to obtain a blob reference, then pass that reference inside the record creation payload (Medium).

Step A: Authenticate and Upload Media in n8n

  1. Session Authentication: Send an HTTP POST request to https://bsky.social/xrpc/com.atproto.server.createSession containing your account handle and an App Password (AT Protocol). Save the returned accessJwt token and account did.
  2. Download Image: Attach an HTTP Request node in n8n using the social_image URL captured from WordPress. Set the Response Format to File (Binary).
  3. Upload Blob: Dispatch a POST request to https://bsky.social/xrpc/com.atproto.repo.uploadBlob (Medium):
  4. Header: Authorization: Bearer {{ $json.accessJwt }}
  5. Header: Content-Type: image/png (or dynamic mime-type)
  6. Body: Select your downloaded binary property (data).

Extract the returned JSON blob reference structure ($link, mimeType, size) (Medium).

Step B: Constructing app.bsky.feed.post Records

Bluesky links and mentions do not convert automatically from plain markdown; they require explicit facets specifying exact UTF-8 byte index start and end positions (AT Protocol).

In an n8n Code Node, compute the string byte lengths for embedded URLs:

const text = `${items[0].json.title}\n\nRead more: ${items[0].json.social_custom_meta.canonical_url}`;
const url = items[0].json.social_custom_meta.canonical_url;

// Calculate byte indices for AT Protocol facets
const encoder = new TextEncoder();
const textBeforeUrl = `${items[0].json.title}\n\nRead more: `;
const byteStart = encoder.encode(textBeforeUrl).length;
const byteEnd = byteStart + encoder.encode(url).length;

return [{
  json: {
    text: text,
    facets: [{
      index: { byteStart: byteStart, byteEnd: byteEnd },
      features: [{
        "$type": "app.bsky.richtext.facet#link",
        "uri": url
      }]
    }]
  }
}];

Finally, dispatch an HTTP POST request to https://bsky.social/xrpc/com.atproto.repo.createRecord (Medium):

{
  "repo": "{{ $node['Auth'].json.did }}",
  "collection": "app.bsky.feed.post",
  "record": {
    "$type": "app.bsky.feed.post",
    "text": "{{ $json.text }}",
    "createdAt": "{{ new Date().toISOString() }}",
    "facets": {{ JSON.stringify($json.facets) }},
    "embed": {
      "$type": "app.bsky.embed.images",
      "images": [{
        "alt": "{{ $json.title }}",
        "image": {{ JSON.stringify($node['Upload Blob'].json.blob) }}
      }]
    }
  }
}

3. ActivityPub Architecture: Constructing W3C-Compliant Notes

While AT Protocol relies on byte offsets and dedicated binary uploads (AT Protocol), ActivityPub handles federated posts using structured JSON-LD objects.

When publishing to ActivityPub-compatible targets, the payload takes the form of a wrapped Create activity containing a Note object (Medium).

Constructing the ActivityPub JSON Payload in n8n

Add an n8n Code Node to build the JSON-LD object:

const post = items[0].json;
const publishedDate = new Date(post.date).toISOString();

const activityPayload = {
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": `${post.social_custom_meta.canonical_url}#create`,
  "type": "Create",
  "actor": "https://yourdomain.com/authors/profile",
  "published": publishedDate,
  "to": ["https://www.w3.org/ns/activitystreams#Public"],
  "object": {
    "id": post.social_custom_meta.canonical_url,
    "type": "Note",
    "published": publishedDate,
    "attributedTo": "https://yourdomain.com/authors/profile",
    "content": `<p>${post.title}</p><p><a href="${post.social_custom_meta.canonical_url}">${post.social_custom_meta.canonical_url}</a></p>`,
    "to": ["https://www.w3.org/ns/activitystreams#Public"],
    "attachment": post.social_custom_meta.social_image ? [{
      "type": "Document",
      "mediaType": "image/jpeg",
      "url": post.social_custom_meta.social_image,
      "name": post.title
    }] : []
  }
};

return [{ json: activityPayload }];

Outbox Routing vs. Target Endpoint Webhooks

Depending on your server configuration, you can route this ActivityPub payload in one of two ways:

  1. Direct Webhook Relay: Deliver the POST request directly to a self-hosted ActivityPub microservice or relay agent that handles HTTP Signature signing before dispatching to fediverse inbox servers.
  2. WordPress Native Plugin Outbox: Use n8n to send an authenticated HTTP POST back to your WordPress instance running the official ActivityPub plugin (Epiphyt). This registers the update in your site’s native /wp-json/activitypub/v1/users/1/outbox collection, letting WordPress manage federation signatures automatically.

Technical Trade-Offs and Schema Differences

Feature AT Protocol (Bluesky) ActivityPub (Threads/Fediverse)
Data Format Lexicon / Typed JSON Records (npm) W3C JSON-LD standard (Medium)
Hyperlinks Byte-offset facets (AT Protocol) Native HTML <a href="..."> tags (Medium)
Media Attachments Requires prior blob upload for reference handle (Medium) Direct external image URL attachment array (Medium)
Post Length Strict character limit (300 graphemes) (Reddit) Varies by server platform (typically 500+ chars)

By centralizing transformation logic within n8n, publishers can map WordPress custom fields directly to both schemas without losing canonical formatting, media payloads, or author attributions across decentralized platforms.


This article was generated with the help of AI.

This post was generated by Omniposter AI. Start your free trial.