TL;DR: With an n8n workflow, Ghost webhooks, and a translation API (DeepL or OpenAI), you can automatically translate every new blog article and publish it as a linked DE/EN pair. No more manual copy-paste – publish once, cross-post automatically.

🌍 Introduction: Why Bilingual Blogging Is So Tedious

If you run a blog in two languages, you know the problem: every article needs to be translated, formatted, and published as a separate post. Links, images, tags – everything has to be correct. It's time-consuming and error-prone.

The good news: with n8n and the Ghost Admin API, this process can be fully automated. In this article, I'll show you how to build a workflow that automatically creates a translated version every time you publish.

Ghost: The better WordPress
Why Ghost is the better blogging platform

🎯 The Challenge: Keeping DE/EN Pairs Consistent

A bilingual blog presents several challenges:

  • Synchronization: Every article needs a counterpart in the other language
  • Linking: The language versions must be linked to each other
  • Formatting: HTML structure, images, and code blocks must not break
  • Updates: When the original article is updated, the translation should follow

Manually managing this becomes nearly impossible as the number of articles grows. Automation is the key.

🏗️ Architecture: How the Workflow Works

The architecture is fundamentally simple:

  1. Ghost Webhook fires on post.published
  2. n8n Webhook Node receives the event
  3. Ghost Admin API delivers the complete post content
  4. Translation API (DeepL or OpenAI) translates the HTML content
  5. Ghost Admin API creates the translated post with relation tags

The interplay between Ghost and n8n makes this pipeline particularly elegant – both tools are built API-first and combine beautifully.

n8n Unleashed: Ultimate Workflow Automation

⚡ Setting Up the Ghost Webhook: Trigger on post.published

In Ghost, set up a webhook under Settings → Integrations → Custom Integration:

  • Event: post.published
  • Target URL: The URL of your n8n webhook node (e.g., https://n8n.example.com/webhook/translate-post)

Ghost sends a JSON payload with the Post ID and basic metadata every time you publish. We then fetch the complete content via the Admin API.

{
  "post": {
    "current": {
      "id": "63a1b2c3d4e5f6...",
      "title": "My New Article",
      "slug": "my-new-article",
      "status": "published"
    }
  }
}

🔧 n8n Workflow Design: Step by Step

The workflow in n8n consists of five main nodes:

1. Webhook Trigger Node

Create a Webhook Node as trigger. Select POST as the HTTP method. The node receives the Ghost webhook payload and starts the workflow.

Important: Add a condition that checks whether the post is already a translated post (e.g., by checking the tags). Otherwise, you'll create an infinite loop!

2. HTTP Request: Fetch Post Content

Using the Post ID from the webhook, fetch the complete article via the Ghost Admin API:

GET /ghost/api/admin/posts/{{postId}}/?formats=html,mobiledoc

You need the html format for translation and the metadata for tags, feature image, and excerpt.

3. Translation Node: DeepL or OpenAI

For the actual translation, you have two good options:

Option A – DeepL API:

// n8n Function Node – Prepare DeepL request
const html = $input.item.json.posts[0].html;
const title = $input.item.json.posts[0].title;

return {
  json: {
    text: [html, title],
    source_lang: "DE",
    target_lang: "EN",
    tag_handling: "html"
  }
};

DeepL has the advantage that it natively understands HTML tags and preserves the structure (tag_handling: "html").

Option B – OpenAI API:

// n8n Function Node – OpenAI Prompt
const html = $input.item.json.posts[0].html;

return {
  json: {
    model: "gpt-4o",
    messages: [
      {
        role: "system",
        content: "Translate the following HTML blog post from German to English. Keep ALL HTML tags intact. Do not translate code blocks. Return only the translated HTML."
      },
      {
        role: "user",
        content: html
      }
    ]
  }
};

OpenAI is more flexible with context and style, but needs a precise prompt to avoid altering the HTML structure.

4. HTTP Request: Create Translated Post

The translated content is created as a new post via the Ghost Admin API:

// n8n Function Node – Prepare post data
const original = $input.item.json.posts[0];
const translatedHtml = $input.item.json.translated_html;
const translatedTitle = $input.item.json.translated_title;

return {
  json: {
    posts: [{
      title: translatedTitle,
      html: translatedHtml,
      status: "draft", // Start as draft for review
      feature_image: original.feature_image,
      feature_image_alt: original.feature_image_alt,
      feature_image_caption: original.feature_image_caption,
      custom_excerpt: translatedExcerpt,
      tags: [
        {name: "#en"},
        {name: "#crosspost"},
        {name: `#rel-en-${original.id}`}
      ]
    }]
  }
};

5. Setting Relation Tags

The link between the original and the translation works via internal tags:

  • Original article gets: #rel-de-{id}
  • Translated article gets: #rel-en-{id}

In Ghost, tags with the # prefix are internal and not visible to readers. However, you can use them in templates and the API to find language pairs.

🖼️ Images and Feature Images: Just Reuse the URLs

The beauty of Ghost: images are referenced via URLs. You don't need to copy or re-upload them – the same URLs work in both language versions.

Simply carry over the following fields 1:1 from the original:

  • feature_image – The main image of the article
  • feature_image_alt – Alt text (translate if needed!)
  • feature_image_caption – Image caption (translate!)
  • All <img> tags in the HTML – URLs stay the same

Tip: You should also translate alt texts and captions – it's good for SEO and accessibility.

🔑 Ghost JWT Auth in n8n: Code Snippet for the Function Node

Ghost uses JWT tokens for Admin API authentication. In n8n, you can generate the token in a Function Node:

// n8n Function Node – Generate Ghost JWT token
const crypto = require('crypto');

const adminKey = $env.GHOST_ADMIN_API_KEY; // Format: "id:secret"
const [id, secret] = adminKey.split(':');

// Header
const header = Buffer.from(JSON.stringify({
  alg: 'HS256',
  kid: id,
  typ: 'JWT'
})).toString('base64url');

// Payload
const now = Math.floor(Date.now() / 1000);
const payload = Buffer.from(JSON.stringify({
  iat: now,
  exp: now + 5 * 60, // Valid for 5 minutes
  aud: '/admin/'
})).toString('base64url');

// Signature
const signingInput = `${header}.${payload}`;
const signature = crypto
  .createHmac('sha256', Buffer.from(secret, 'hex'))
  .update(signingInput)
  .digest('base64url');

const token = `${signingInput}.${signature}`;

return {
  json: {
    token,
    ghostUrl: $env.GHOST_API_URL
  }
};

You then set this token in the HTTP Request Node as the Authorization: Ghost {token} header.

⚠️ Edge Cases: Avoiding Updates and Infinite Loops

A few pitfalls to watch out for:

Preventing Infinite Loops

When the translated post is published, the webhook fires again. Without protection, n8n translates the translation back – infinitely.

Solution: Check in the workflow whether the post has a #crosspost tag. If so → abort the workflow.

// n8n IF Node condition
const tags = $input.item.json.post.current.tags || [];
const isCrosspost = tags.some(t => t.name === '#crosspost');

// If isCrosspost === true → Stop
return !isCrosspost;

Updating Existing Translations

When the original article is updated, you don't want a new translated post – you want to update the existing one.

Solution: Before creating, search for a post with the matching relation tag:

// Check if a translation already exists
const originalId = $input.item.json.post.current.id;
const relTag = `#rel-en-${originalId}`;

// Ghost API: Filter posts by internal tag
// GET /ghost/api/admin/posts/?filter=tag:hash-rel-en-{id}
// If result exists: PUT (update), otherwise: POST (create)

Additional Edge Cases

  • Don't translate code blocks: DeepL with tag_handling: "html" doesn't always recognize <pre>/<code>. Exclude them manually if needed.
  • Slug generation: Let Ghost automatically generate the slug from the translated title – don't set it manually.
  • Rate limits: DeepL Free allows 500,000 characters/month. With many posts, that can run out quickly.

🎬 Conclusion

With n8n, Ghost Webhooks, and a Translation API, you can fully automate your bilingual blog. The initial setup effort pays off quickly: instead of manually translating every article, you simply publish in your primary language – the rest happens automatically.

The combination of Ghost and n8n is particularly powerful because both tools think API-first and integrate seamlessly. And with relation tags, you have a clean method to programmatically link language pairs.

Next steps for you:

  • Set up an n8n instance (self-hosted or cloud)
  • Create a Ghost Custom Integration + Webhook
  • Get a DeepL or OpenAI API key
  • Import and test the workflow

Happy automating! 🚀

Artikel teilen:Share article: