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

Running news digests for several clients

October 23, 20269 min readFeedmarkdown#newsletter#agency#deduplication#google-news

One tray of news pages sorted into separate envelopes, each going to its own desk

To run a news digest for clients, give each client their own feed inside one account, create each search query once as a source and attach it to every feed that needs it, and deliver each feed to that client's own endpoint. Keep deduplication scoped to the feed, or one client's stories will start disappearing from another's digest.

We tested the data side on 2026-10-23: 17 Google News queries for six made-up clients, then an offline replay of the headline-matching code. We did not run several feeds on a live account. The sections below say which claims come from a run and which come from reading code.

If you are building a single digest first, start with how to build a daily news newsletter from feeds. This post picks up where its "more than one newsletter" paragraph stops.

One feed per client

Three objects matter:

  • A source is one query, such as offshore wind when:7d. It is the unit a plan counts.
  • A feed reads one or more sources and delivers the new stories. It has its own interval, its own delivery target and its own dedup settings.
  • Your account holds all of them.

The mapping that works is one feed per client. Plans count sources and not feeds (each pricing card lists its sources "across unlimited feeds"), so extra clients cost you only the queries they add. A source can sit in several feeds and counts once.

One account with five sources, one shared by two feeds, each feed delivering to its own client endpoint, and an org-wide API key box

Each feed has its own webhook_url and its own signing secret, so every client's endpoint can verify its own signature. For pull delivery, each feed has its own JSON Feed and Atom URL. Those URLs need your API key, which comes up again below.

Name feeds and sources so you can read the list later

After a dozen clients the source list is the first thing that becomes unreadable. Names can be up to 200 characters, so use a fixed pattern:

Object Pattern Example
Source for one client client | topic | news acme-solar | solar tax credit | news
Source shared by clients shared | topic | news shared | renewable energy | news
Feed client daily acme-solar daily

Keep the clients in one config file and generate the requests from it. This script prints the request bodies for two clients that share one query. It does not call anything. We ran it on 2026-10-23.

import json
from collections import defaultdict

# One query per client, listed once. A query used by several clients is a shared source.
clients = {
    "acme-solar": ["rooftop solar", "solar tax credit", "renewable energy"],
    "blue-grid": ["offshore wind", "wind farm", "renewable energy"],
}

users = defaultdict(list)
for client, queries in clients.items():
    for q in queries:
        users[q].append(client)

sources = {}
for q, owners in users.items():
    owner = owners[0] if len(owners) == 1 else "shared"
    sources[q] = {
        "kind": "google_news",
        "name": f"{owner} | {q} | news",
        "config": {"query": f"{q} when:7d", "lang": "en", "country": "US"},
    }

print("POST /v1/source bodies (one per unique query):")
for body in sources.values():
    print(json.dumps(body))

print("\nPOST /v1/feed bodies (one per client; source_ids come from the responses above):")
for client, queries in clients.items():
    print(json.dumps({
        "name": f"{client} daily",
        "source_ids": [f"<id of: {sources[q]['name']}>" for q in queries],
        "delivery": "webhook",
        "webhook_url": f"https://hooks.example.com/{client}",
        "dedup_scope": "feed",
    }, indent=2))
print(f"\nsources to create: {len(sources)} (client-query pairs: {sum(len(v) for v in clients.values())})")

The last line printed sources to create: 5 (client-query pairs: 6). The bodies follow the request shapes in the server's API annotations. We did not send them to the API, so we have not seen the responses.

A duplicate query is not rejected. If two clients each get their own renewable energy source, the account counts two. Create the query once and attach it to both feeds.

What the plan limits mean for a client roster

Sources are what runs out. The plan limits from the pricing page are 10 sources on Starter, 50 on Pro and 200 on Agency. Checks run every 6 hours on Starter, hourly on Pro and every 15 minutes on Agency, and that is per account, not per client.

Clients Sources each Sources if none are shared With one source shared by all Smallest plan
3 3 9 7 Starter (10)
10 3 30 21 Pro (50)
10 4 40 31 Pro (50)
30 5 150 121 Agency (200)
30 7 210 181 Agency, only if you share sources

This is arithmetic, not a measurement. From reading the code, adding a source past the limit is refused with HTTP 402, and paused sources still count. We did not trigger that error.

For picking the queries themselves, see how to pick sources for a niche news digest. For what a plan costs against stitching tools together, see what a newsletter's news stack costs per month.

What we pulled for six fictional clients

We made up six clients with real sectors: acme-solar, northwind-ev, blue-grid (offshore wind), harbor-health, pixel-bank and cyber-shield. Each got three queries with when:7d. acme-solar and blue-grid share renewable energy, so 18 query slots needed 17 requests.

We fetched the public Google News RSS search endpoint on 2026-10-23 at about 13:10 UTC. The script waited 5 seconds between requests, sent a plain feedmarkdown-research/1.0 User-Agent, used no cookies or credentials, and was set to stop on a 403, 429 or redirect. None came. All 17 returned 200.

Terminal output of 17 Google News requests, each with status 200 and an item count, grouped by client

That is 1,521 items. Counted per client, the six feeds fetched 1,621 items (the shared query counts for both). After each feed dropped its own repeated ids and repeated headlines with the same code the server uses, they would deliver 1,475 items.

How much the clients overlap

We then asked, for each pair, how many of one client's items also reach the other, by exact id or by headline match.

A 6 by 6 matrix of items shared between pairs of fictional clients; the acme-solar and blue-grid cell shows 97 and most others show 0 to 5

Almost all the overlap comes from the one query we shared on purpose. Take it away and acme-solar and blue-grid have 0 ids in common. The rest is small: 5 stories between acme-solar and northwind-ev, 2 between northwind-ev and blue-grid, 5 between harbor-health and cyber-shield. pixel-bank shared none. Overall, 212 of the 1,475 delivered items reached at least one other client.

This is one day, six invented clients and queries we chose. Do not read it as a typical overlap rate for your roster.

Keep dedup scope on feed

Every feed has a dedup_scope, and the default is feed. Articles are stored per account and shared by feeds, so the scope decides what counts as "already seen".

  • feed: a story is new unless this feed already has it. If client A got a story, client B still gets it. A story another feed already scraped is reused, so a shared story is not paid for twice.
  • org: a story is skipped if any feed in the account has it, and headlines are compared against every feed's recent stories. Whichever client's feed runs first gets the story and the others do not.

org is right for several feeds of one client, say a daily and a weekly. For separate clients it quietly takes stories away.

We checked this by reading pipeline.go, then replayed it offline. We copied the server's dedup.go (the TitleIndex headline matcher) into a scratch folder and ran the saved items through it. The database lookups were replaced with in-memory sets and the dedup time window was not applied. It is a replay of the headline code, not a run on a real account.

Bars showing items each client receives under feed scope and under org scope in two processing orders; blue-grid drops from 238 to 142 or acme-solar from 241 to 141 depending on which runs first

With feed scope each client got its full list. With org scope, whoever ran second lost the shared query's stories: blue-grid fell from 238 to 142 when acme-solar ran first, and acme-solar fell from 241 to 141 when blue-grid ran first. You don't control the order. The other clients lost 2 to 5 items each. Nothing errors. The client just gets a thinner digest.

Rule: leave clients on feed. Use org only for feeds that belong to the same client. We did not confirm this on a live account.

Delivering to each client

For webhooks, give each client's feed its own URL, such as https://hooks.example.com/acme-solar. Each feed has its own signing secret, returned once when you create or rotate it. The body is signed with HMAC-SHA256 and the signature arrives in X-Feedmarkdown-Signature as sha256=<hex>. The stable payload fields are title, url, source, author, published_at and markdown. Separate URLs mean you do not need to route on anything inside the payload.

A receiver check in Python. We ran it against a signature we computed ourselves with the same scheme, and it accepted the valid body and rejected a tampered body and a wrong secret. We did not run it against a webhook sent by Feedmarkdown.

import hashlib, hmac

def verify(secret: str, body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)

Pull delivery needs more care. The per-feed URLs are /v1/feed/{id}/json (JSON Feed 1.1) and /v1/feed/{id}/rss (Atom). Both sit under /v1, so they need your account's API key:

curl -s -H "Authorization: Bearer $FEEDMARKDOWN_API_KEY" \
  "https://api.feedmarkdown.com/v1/feed/FEED_ID/json"

We did not run this one for the post. The shape comes from the API routes.

What does not work today

Check these before you promise anything to a client:

  • API keys cover the whole account. A key can read every feed, source and article you have. Do not give a client a key to "their" feed. Send it by webhook to the client's endpoint, or pull on your side and forward it.
  • There is no per-client usage or billing. The usage endpoint reports for the account as a whole.
  • No branded output and no client login. Clients see what you forward to them.
  • This setup is one account. We have not described or tested any other arrangement.

Not tested: a live run with several feeds, webhook delivery to several endpoints, the 402 on the source limit, and org scope on a real account.

Skip the plumbing

Feedmarkdown does the fetching side for this setup. It takes Google News and Google Alerts sources, resolves the publisher links, drops duplicates by headline before it scrapes and again by content after, and delivers full-text Markdown by HMAC-signed webhook or as JSON Feed and Atom. Each feed gets its own signing secret, and a story another feed already scraped is reused. You still choose the queries and write the digest, and the client relationship stays yours. The plans and their source counts are on the pricing section, and the home page has the short version.

FAQ

Can two clients share a source?

Yes. Create the query once and attach it to both feeds. It counts once toward your plan.

Will one client's articles show up in another client's feed?

Not from what we read in the code. Each feed lists only its own items. Under org scope the effect goes the other way: later feeds lose stories instead of seeing someone else's.