---
title: "Slack News Bot Integration Tutorial: 2026 Python Guide"
description: "Slack news bot integration tutorial: build a Python bot, format with Block Kit, schedule on cron / GitHub Actions / Lambda. Working code, ~80 lines."
source: https://apitube.io/blog/post/slack-news-bot-integration-tutorial
---

# Slack News Bot Integration Tutorial: News API to Block Kit Digest

A **Slack news bot** is a service that pulls articles from a news API on a schedule, filters them by topic or sentiment, and posts a formatted digest to a Slack channel via an incoming webhook. It is four moving parts: a Slack app with a webhook URL, a news source, a formatter, and a scheduler.

This tutorial wires those four parts into one Python script. Most existing webhook tutorials stop at "POST hello world to Slack" — you leave knowing the webhook URL exists but not how to connect it to a real news feed, schedule it on real infrastructure, or stop it from re-posting yesterday's headlines tomorrow morning.

Disclosure: I work on APITube, a news API. The pipeline below is provider-agnostic — substitute your own endpoint where shown. APITube ships sentiment and category fields on every article, which makes filtering one HTTP parameter instead of an extra NLP layer. This article is for **DevOps engineers and team leads** wiring a daily news digest into Slack — say, a #market-watch channel, a #pr-monitor, or a dev-ecosystem feed.

## Key Takeaways

- A Slack incoming webhook accepts roughly **1 message per second per channel**. A 50-article digest uses 0.06% of the daily ceiling.
- Use a **webhook** when you only post; upgrade to a **Slack bot user** with the Events API when users need to interact back.
- Without **deduplication**, a 24-hour news window will re-post the same articles every morning. SHA-1 of the article URL is enough.
- **Block Kit** with a sentiment-driven color bar makes a digest readable at a glance — green for positive coverage, red for negative.
- The whole bot is ~80 lines of Python plus one of three schedulers: `cron`, GitHub Actions, or AWS Lambda + EventBridge.

## What You'll Build

A Python script that runs once a day, fetches the last 24 hours of English technology news, filters by sentiment, deduplicates against the previous run, and posts each article as a Block Kit card to a Slack channel.

Build time: ~30 minutes. You'll get:

1. A Slack app with an incoming webhook
2. A news API call that returns structured articles
3. Block Kit formatting with image preview and sentiment-driven color
4. Deduplication using a SHA-1 hash of the article URL
5. Three deployment paths: `cron`, GitHub Actions, AWS Lambda + EventBridge

## Decision: Webhook vs Slack Bot User vs RSS App

Before you write code, pick the right transport. Most tutorials skip this — they jump straight to webhooks. But the wrong transport here costs you a refactor later.

| Approach | When to use | Hard limits |
|----------|-------------|-------------|
| **Incoming webhook** (this tutorial) | One-way posting, one channel per webhook, no interactivity | Cannot read messages, cannot react, cannot DM, cannot delete |
| **Slack bot user (Events API)** | Replies, slash commands, buttons, multiple channels | Requires OAuth + bot token + an event subscription URL |
| **Built-in `/feed` RSS app** | The source already exposes RSS and you need zero filtering | No sentiment filter, no category filter, no dedup beyond the feed |

Unlike the built-in `/feed` app, a custom webhook bot can filter on enriched fields like sentiment polarity or article category, which means a #market-watch channel doesn't drown in unrelated posts. Rule of thumb: post-only and ≤1 channel → webhook. Need buttons, replies, or @-mentions back → upgrade to a bot.

## Prerequisites

- Python 3.9+
- A Slack workspace where you can install apps
- A news API key. This tutorial uses APITube (free tier: 100 requests/day, more than enough for a daily digest). Sign up at [apitube.io](https://apitube.io).
- `requests` library: `pip install requests`

## Step 1 — Create the Slack App and Webhook

1. Go to `https://api.slack.com/apps` → **Create New App** → **From scratch**.
2. Name the app (e.g., `News Digest Bot`), pick the workspace, click **Create**.
3. In the left sidebar, select **Incoming Webhooks** and toggle **Activate Incoming Webhooks** to On.
4. Click **Add New Webhook to Workspace**, choose the channel (e.g., `#news-digest`), and authorize.
5. Copy the URL. It looks like `https://hooks.slack.com/services/T0xx/B0xx/xxxxx`.

Treat that URL like a password. Slack actively revokes leaked webhook URLs, so commit it to env vars, not git.

```bash
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T0.../B0.../..."
export APITUBE_API_KEY="your_apitube_key"
```

## Step 2 — Fetch Structured News from the API

A daily digest needs articles from the last 24 hours, in English, ideally with sentiment and category metadata so you can filter without running your own NLP pipeline.

```python
import os
import requests
from datetime import datetime, timedelta, timezone

API_URL = "https://api.apitube.io/v1/news/everything"

def fetch_news(api_key: str, hours: int = 24, limit: int = 10) -> list[dict]:
    since = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
    params = {
        "language.code": "en",
        "category.id": "medtop:04000000",  # technology
        "published_at.start": since,
        "per_page": limit,
        "sort.by": "published_at",
        "sort.order": "desc",
    }
    headers = {"X-API-Key": api_key}
    r = requests.get(API_URL, params=params, headers=headers, timeout=15)
    r.raise_for_status()
    return r.json().get("results", [])
```

A real response item looks roughly like this — note the sentiment block, that's what powers the colored bar in the Slack card:

```json
{
  "title": "OpenAI ships new agent runtime",
  "href": "https://example.com/openai-agent",
  "description": "The new runtime lowers latency by 30%...",
  "published_at": "2026-05-02T08:14:00Z",
  "source": { "domain": "example.com" },
  "image": { "href": "https://cdn.example.com/cover.jpg" },
  "sentiment": { "overall": { "polarity": "positive", "score": 0.71 } }
}
```

If you're using a different news API, the schema will differ — adjust the field names in Step 3 accordingly.

## Step 3 — Format Each Article as a Block Kit Card

Plain text in Slack reads like a log line. Block Kit gives you a header, source line, image, and a sentiment-colored side bar. The trick to embedding a preview image is to add an `accessory` of type `image` to the section block, then wrap the whole thing in `attachments` so the side color can vary per article.

```python
SENTIMENT_COLOR = {
    "positive": "#36a64f",  # green
    "negative": "#e01e5a",  # red
    "neutral":  "#9aa0a6",  # grey
}

def article_to_blocks(a: dict) -> dict:
    polarity = a.get("sentiment", {}).get("overall", {}).get("polarity", "neutral")
    image_url = a.get("image", {}).get("href")
    blocks = [
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": f"*<{a['href']}|{a['title']}>*\n{a.get('description', '')[:240]}…",
            },
        },
        {
            "type": "context",
            "elements": [
                {"type": "mrkdwn",
                 "text": f"📰 {a['source']['domain']}  ·  "
                         f"sentiment: `{polarity}`  ·  "
                         f"{a['published_at'][:16].replace('T', ' ')} UTC"},
            ],
        },
    ]
    if image_url:
        blocks[0]["accessory"] = {
            "type": "image",
            "image_url": image_url,
            "alt_text": a["title"],
        }
    return {
        "attachments": [{
            "color": SENTIMENT_COLOR.get(polarity, "#9aa0a6"),
            "blocks": blocks,
        }]
    }
```

Validate the JSON in [Block Kit Builder](https://app.slack.com/block-kit-builder) before you hit the webhook — Slack returns a generic `invalid_payload` and won't tell you which field is wrong.

## Step 4 — Deduplicate and Respect Rate Limits

Slack throttles incoming webhooks at approximately **1 message per second per channel**. The math: 86,400 seconds × 1 message/sec = 86,400 daily messages per channel maximum, so a 50-article digest uses 0.06% of budget. Bursting 200 articles in 30 seconds, however, **will** trigger HTTP 429. Pace the loop with `time.sleep(1.1)`.

Deduplication is the second piece nobody covers. Without it, the 09:00 cron tomorrow re-posts every article still inside the rolling 24-hour window. Hash each article's `href` with SHA-1, store the hashes in a local file, and skip on collision.

```python
import hashlib, json, time, pathlib

SEEN_FILE = pathlib.Path(".seen.json")

def load_seen() -> set[str]:
    return set(json.loads(SEEN_FILE.read_text())) if SEEN_FILE.exists() else set()

def save_seen(seen: set[str]) -> None:
    SEEN_FILE.write_text(json.dumps(list(seen)))

def fingerprint(href: str) -> str:
    return hashlib.sha1(href.encode()).hexdigest()

def post_to_slack(webhook: str, payload: dict) -> None:
    r = requests.post(webhook, json=payload, timeout=10)
    if r.status_code == 429:
        time.sleep(int(r.headers.get("Retry-After", 1)))
        r = requests.post(webhook, json=payload, timeout=10)
    r.raise_for_status()
```

For multi-instance deploys (Lambda warm pools, two cron hosts), replace the JSON file with Redis: `SET seen:<sha1> 1 EX 604800` gives you a 7-day TTL and atomic dedup across runners.

## Step 5 — Schedule the Bot

Slack does not run your code. You need an external scheduler — pick one based on where the rest of your stack lives.

### Option A — `cron` on a Linux box

```cron
0 9 * * *  cd /opt/news-bot && /usr/bin/python3 bot.py >> /var/log/newsbot.log 2>&1
```

Simple, free, but state lives on one host and the host must be up.

### Option B — GitHub Actions (zero infrastructure)

Save as `.github/workflows/digest.yml`. Store `SLACK_WEBHOOK_URL` and `APITUBE_API_KEY` as repo secrets.

```yaml
name: Daily News Digest
on:
  schedule:
    - cron: '0 9 * * *'  # 09:00 UTC daily
  workflow_dispatch:
jobs:
  digest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install requests
      - run: python bot.py
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          APITUBE_API_KEY: ${{ secrets.APITUBE_API_KEY }}
      - uses: actions/upload-artifact@v4
        with: { name: seen-cache, path: .seen.json }
```

The `upload-artifact` step persists `.seen.json` between runs — without it, every run starts blind and dedup breaks.

### Option C — AWS Lambda + EventBridge

```bash
aws lambda create-function --function-name news-digest \
  --runtime python3.11 --handler bot.lambda_handler \
  --zip-file fileb://bot.zip \
  --environment "Variables={SLACK_WEBHOOK_URL=...,APITUBE_API_KEY=...}"

aws events put-rule --name daily-9am --schedule-expression "cron(0 9 * * ? *)"
aws events put-targets --rule daily-9am \
  --targets "Id"="1","Arn"="arn:aws:lambda:...:function:news-digest"
```

The Lambda filesystem is ephemeral — swap `.seen.json` for a DynamoDB table or S3 object before you ship.

## Complete Code

```python
import os, time
from datetime import datetime, timezone
# ... fetch_news, article_to_blocks, post_to_slack, dedup helpers above ...

def main():
    webhook = os.environ["SLACK_WEBHOOK_URL"]
    api_key = os.environ["APITUBE_API_KEY"]
    seen = load_seen()

    articles = fetch_news(api_key, hours=24, limit=10)
    fresh = [a for a in articles if fingerprint(a["href"]) not in seen]

    if not fresh:
        return

    header = {"text": f"📰 News digest · {datetime.now(timezone.utc):%Y-%m-%d}"}
    post_to_slack(webhook, header)
    time.sleep(1.1)

    for a in fresh:
        post_to_slack(webhook, article_to_blocks(a))
        seen.add(fingerprint(a["href"]))
        time.sleep(1.1)

    save_seen(seen)

if __name__ == "__main__":
    main()
```

That's ~80 lines for a working production bot.

## Common Errors

- **`invalid_payload`** — Block Kit JSON is malformed. Validate at [Block Kit Builder](https://app.slack.com/block-kit-builder).
- **`channel_is_archived`** — the webhook is bound to a channel that someone archived. Recreate the webhook for a live channel.
- **HTTP 429** — you're posting faster than 1/sec. The retry shown in Step 4 handles transient bursts; reduce concurrency for steady throughput.
- **Empty articles list** — most likely `published_at.start` is in the future, or the category id is wrong.
- **Image not rendering** — Slack only unfurls HTTPS image URLs that respond with a valid `Content-Type: image/*`. CDN redirects sometimes break this.

## FAQ

### How do you send news updates to Slack automatically?

You send news updates to Slack automatically by creating an incoming webhook in a Slack app, writing a script that calls a news API and POSTs each article as JSON to the webhook URL, then running that script on a scheduler — `cron`, GitHub Actions, or an AWS Lambda triggered by EventBridge.

### What is the difference between a Slack webhook and a Slack bot?

A Slack webhook is a one-way URL bound to a single channel: it can post messages but cannot read, react, or receive events. A Slack bot is a full app with a bot token that listens to the Events API, supports interactivity (buttons, slash commands), and can act across multiple channels. Use a webhook when you only post; upgrade to a bot when users need to interact back.

### Can Slack receive RSS feeds without a third-party tool?

Yes. Slack's built-in `/feed` slash command subscribes a channel to any RSS URL. The trade-off: no filtering by sentiment or category, no deduplication beyond what the feed provides, and you cannot enrich the message with extra metadata. For a curated digest, a custom webhook bot wins.

### How often can a Slack incoming webhook be called?

A Slack incoming webhook is rate-limited at approximately 1 message per second per channel, with short bursts tolerated. Sustained bursts return HTTP 429 with a `Retry-After` header. Pace your loop with at least 1.1 seconds between calls.

### How do I post a news article with a preview image to Slack?

Build a Block Kit `section` block with `mrkdwn` text for the headline and add an `accessory` of type `image` whose `image_url` points at the article's cover image. Wrap the section in `attachments` so you can add a colored side bar that signals sentiment.

## Next Steps

1. Add a sentiment filter — only post articles where `sentiment.overall.score > 0.5` to mute noise.
2. Add a per-channel router — different webhooks for `#tech`, `#finance`, `#climate`, each with its own category filter.
3. Replace the JSON dedup file with Redis or DynamoDB if you scale beyond one runner.

Try APITube free → [apitube.io](https://apitube.io). Free tier covers a daily digest comfortably.

## Resources

- **APITube** — [apitube.io](https://apitube.io) — try it free, sentiment and entities included on every article
- **Documentation** — [docs.apitube.io](https://docs.apitube.io) — endpoints, parameters, response structure, integrations
- **Pricing** — [apitube.io/pricing](https://apitube.io/pricing) — all tiers
- **APITube blog** — [apitube.io/blog](https://apitube.io/blog) — more guides and comparisons

**Related guides:**
- [News API Rate Limits Compared](https://apitube.io/blog/news-api-rate-limits-compared)
