Founding customers get 50% off the first year. Claim it →
← all posts

n8n RSS feed to Slack with full article text

October 22, 20268 min readFeedmarkdown#n8n#slack#google-news#webhook

Tangled news pages combed through a small gate into one tidy chat message

You can post one clean message per Google News story to Slack from n8n, but the RSS Read node can't do it alone. We built the workflow and ran it on 2026-10-22, on n8n 1.100.1 as recorded in our notes (not re-checked that day). Slack was mocked: the last node posts Slack-shaped JSON to a receiver we built in a second workflow. We never tested real Slack.

The longer build, with an LLM and email, is in how to build a daily news newsletter from feeds. Why article text matters is covered in feeding news to an LLM.

What the n8n RSS feed node gives you from Google News

We fetched q=offshore+wind+when:1d at 18:27 UTC and got 91 items. We ran the same URL through an n8n RSS Read node and compared the first item with what Feedmarkdown sent for that story.

Left: a Google News RSS item and the n8n RSS Read output, both with a news.google.com redirect link and no article text. Right: a Feedmarkdown webhook body with the publisher URL and a markdown field

The RSS Read node returns a news.google.com/rss/articles/... redirect link, a headline, and a snippet that just repeats the headline. There's no publisher URL and no text. Google News links redirect explains why, and the Google News RSS feed guide covers the URL formats. The Feedmarkdown body has the publisher URL and 13,276 characters of Markdown for that story. Zapier's RSS trigger hands you the same redirect link and snippet; we measured it in what the Zapier RSS trigger can't do with news feeds.

The workflow

It uses core n8n nodes only. Download the workflow JSON, then change the webhook path, the secret and the Slack URL.

n8n canvas: Webhook, Read raw body, Compute HMAC-SHA256, Verify signature, Signature valid, then Seen before, New story, Format for Slack, Post to Slack, Remember URL and Done, with Reject and Skip repeat branches

In order:

  1. Webhook, POST, Raw Body on, responding from a Respond to Webhook node.
  2. Read raw body, Compute HMAC-SHA256 (Crypto node), Verify signature (Code), Signature valid? (IF). Bad signatures get a 401. This is the same pattern as the webhook workflow in the newsletter post.
  3. Seen before? (Code) and New story? (IF). Repeats get a 200 and stop.
  4. Format for Slack (Code), Post to Slack (HTTP Request), Remember URL (Code), Done (200).

We created the source and feed through the API. The key comes from an environment variable. The webhook URL is your n8n production URL:

curl -s -X POST https://api.feedmarkdown.com/v1/source \
  -H "Authorization: Bearer $FEEDMARKDOWN_N8N_API_KEY" -H "Content-Type: application/json" \
  -d '{"kind":"google_news","name":"offshore wind","config":{"query":"offshore wind when:1d","lang":"en","country":"US"}}'

curl -s -X POST https://api.feedmarkdown.com/v1/feed \
  -H "Authorization: Bearer $FEEDMARKDOWN_N8N_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"offshore wind to Slack","source_ids":["SOURCE_ID"],"delivery":"webhook",
       "webhook_url":"https://YOUR_N8N_HOST/webhook/YOUR_RANDOM_PATH",
       "interval_minutes":360,"items_limit":3,"dedup_scope":"feed","dedup_window_hours":72}'

The create response includes webhook_secret once. The first run found 3 items, 3 new, 0 deduped, 3 scraped, 0 errors, and finished in about 9.5 seconds. The webhook body has title, url, source, author, published_at and markdown. It also carried id, feed_id and source_id, which we ignore.

Feedmarkdown signs the raw body with HMAC-SHA256 and sends sha256=<hex> in X-Feedmarkdown-Signature. This Code node compares the Crypto node's digest and parses the body only if it matches:

// The Crypto node put HMAC-SHA256(secret, raw body) in $json.hmac, as hex.
const want = 'sha256=' + $json.hmac;
const got = String($('Webhook').first().json.headers['x-feedmarkdown-signature'] || '');

// Constant-time compare, so response time does not leak how much matched.
let diff = want.length ^ got.length;
for (let i = 0; i < want.length; i++) diff |= want.charCodeAt(i) ^ (got.charCodeAt(i) || 0);
const valid = diff === 0;

// Parse only after the signature checks out.
const raw = $('Read raw body').first().json.rawBody;
return [{ json: { valid, payload: valid ? JSON.parse(raw) : null } }];

Markdown to Slack text

Slack's mrkdwn is not CommonMark. On Slack's formatting page, read on 2026-10-22: bold is *bold*, italic is _italic_, links are <url|label>, and the page documents no heading syntax. Lists are plain characters. &, < and > must be escaped. So the Code node turns headings into bold lines, bullets into •, **bold** into *bold*, *italic* into _italic_ and links into <url|label>. Relative links, which Slack can't open, keep only their label. Images and rules are dropped.

Markdown from a real article on the left, and the Slack mrkdwn text the Code node produced on the right, with the excerpt cut after 700 characters

The excerpt is about 700 characters. It takes whole paragraphs, falls back to a sentence or word boundary, then backs off while a < is unclosed or a * or backtick count is odd. The node:

const MAX = 700; // excerpt length, characters

function toSlack(md) {
  let t = md.replace(/\r/g, '');
  t = t.replace(/^#\s.*\n+/, '');                       // the first H1 repeats the title
  t = t.replace(/!\[[^\]]*\]\([^)]*\)/g, '');           // images
  t = t.replace(/^\s*([-*_])(\s*\1){2,}\s*$/gm, '');    // horizontal rules
  t = t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  t = t.replace(/^&gt; ?/gm, '> ');                     // block quotes keep a real >
  t = t.replace(/^#{1,6}\s+(.+)$/gm, '\u0001$1\u0001'); // headings -> bold line
  t = t.replace(/^\s*[-*+]\s+/gm, '• ');                // bullets
  t = t.replace(/\*\*([^*\n]+)\*\*/g, '\u0001$1\u0001');   // bold, parked
  t = t.replace(/(^|[^*\w])\*([^*\n]+)\*(?![*\w])/g, '$1_$2_'); // *italic* -> _italic_
  t = t.replace(/\u0001/g, '*');
  t = t.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (m, text, url) =>
    /^https?:\/\//.test(url) ? `<${url}|${text.replace(/[|]/g, '')}>` : text); // relative links: keep the text
  return t.replace(/\n{3,}/g, '\n\n').trim();
}

// Menus and link rails are short paragraphs; article paragraphs are not.
function bodyOnly(text) {
  const visible = (s) => s.replace(/<[^|>]*\|([^>]*)>/g, '$1').trim();
  const kept = text.split(/\n{2,}/).filter((p) => visible(p).length >= 80);
  return kept.length ? kept.join('\n\n') : text;
}

function excerpt(text, max) {
  if (text.length <= max) return text;
  let cut = '';
  for (const para of text.split(/\n{2,}/)) {            // whole paragraphs first
    const next = cut ? cut + '\n\n' + para : para;
    if (next.length > max) break;
    cut = next;
  }
  if (!cut) {                                            // first paragraph is too long
    const head = text.slice(0, max);
    const s = Math.max(head.lastIndexOf('. '), head.lastIndexOf('! '), head.lastIndexOf('? '));
    cut = s > max * 0.4 ? head.slice(0, s + 1) : head.slice(0, head.lastIndexOf(' '));
  }
  for (;;) {                                             // never leave a half-open link or marker
    const open = cut.lastIndexOf('<');
    if (open > cut.lastIndexOf('>')) { cut = cut.slice(0, open).trimEnd(); continue; }
    if ((cut.match(/\*/g) || []).length % 2) { cut = cut.slice(0, cut.lastIndexOf('*')).trimEnd(); continue; }
    if ((cut.match(/`/g) || []).length % 2) { cut = cut.slice(0, cut.lastIndexOf('`')).trimEnd(); continue; }
    break;
  }
  return cut + ' …';
}

const p = $input.first().json.payload;
const esc = (s) => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const when = p.published_at ? new Date(p.published_at).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : '';
const byline = [p.source, p.author, when].filter(Boolean).map(esc).join(' · ');
const text = `*<${esc(p.url)}|${esc(p.title).replace(/\|/g, '')}>*\n${byline}\n\n${excerpt(bodyOnly(toSlack(p.markdown || '')), MAX)}`;

return [{ json: { url: p.url, text, chars: text.length } }];

Slack's chat.postMessage docs recommend keeping text to 4,000 characters and say Slack truncates above 40,000. We found no documented limit for incoming webhooks, so 700 is our choice, not Slack's.

We tested the cut on 47 generated paragraphs with a link and a bold span placed around character 700. A plain 700-character slice left an open < or an odd * in 43. The node left none. The three real messages were 836, 842 and 850 characters.

What broke

  • require('crypto') is blocked in the Code node. The run failed with Module 'crypto' is disallowed. That is why a Crypto node does the HMAC.
  • A crashed node answered HTTP 200 with an empty body. Feedmarkdown counts any 2xx as delivered, so an error before the Respond node can lose a message silently. We did not test a failing Slack call, but expect the same and set an Error Workflow.
  • We created the feed before activating the workflow. The first run's three deliveries got 404 and were not retried. Resend fixed it.
  • Headings turned italic. The italic rule caught our own *Heading* rewrite, so bold markers are now parked first.
  • Menus became the excerpt. The Utility Dive Markdown starts with a site menu. The node now drops paragraphs under 80 visible characters. It does not remove cookie-consent text: the Your Halifax Now excerpt is still consent boilerplate.
  • & in the title link. The URL needs &amp; too. Fixed.

Testing without a real Slack

For Google Alerts specifically, how to send Google Alerts to Slack ran a relay against a real Slack webhook. This workflow was tested against a mock only.

The mock receiver is a second workflow that returns 401 without a shared header, 400 without text, and ok otherwise. It shows our JSON has a text field and that unsigned input is rejected. It doesn't show that Slack accepts the message, that the mrkdwn renders as expected, or how Slack's rate limits behave.

A log of six requests to the mock receiver and the JSON body of the first accepted one

The first three rows in the log are our direct probes of the mock: two without the header (401) and one with the header but no text (400). On 2026-10-22 at about 18:31 UTC we also sent three bad requests to the n8n workflow itself. A modified body with the original signature, no signature, and a signature made with the wrong secret each got a 401, and none reached the mock. Then we resent the run.

Resending and duplicates

POST /v1/feed/FEED_ID/webhook/resend redelivers the latest finished run. The first resend gave three 200s (450, 713 and 678 ms in Feedmarkdown's delivery log) and three messages in the mock. The second resend gave three more 200s, and the workflow skipped all three. The mock still held three messages.

Remember URL stores each posted url in workflow static data after the Slack call, so a failed post is not marked as seen. Static data worked on production executions. Cross-source dedup happens before the POST, but this feed had one source, so we saw 0 deduped.

What this costs and where Feedmarkdown fits

This demo used 1 source. On the pricing section, read 2026-10-22, Starter is $19 (10 sources, every 6 hours), Pro $49 (50, hourly) and Agency $149 (200, every 15 minutes). We did not measure Slack or n8n costs.

Feedmarkdown resolves Google News redirects, drops duplicates before scraping (how headline matching works), returns full-text Markdown, and sends it by signed webhook or as JSON Feed or Atom. Google News and Google Alerts are the sources today. See the home page.