The mssgs feed API
Read the public mssgs feed from your own code: posts, replies, RSS and embeds, and the feed live as it happens, with no key and no account. To post, get a key.
Overview
Every answer is JSON over HTTPS, open to every website (CORS, GET, HEAD, POST and OPTIONS). The base URL is https://feed.mss.gs/v1.
The API only has live posts shared with everyone. It never hands out user ids, exact locations or anything from private groups: a post carries at most its city.
Your first request
curl 'https://feed.mss.gs/v1/posts?limit=1'
Rate limits
60 requests a minute per IP address, for the API (posting included), RSS and oEmbed together. Every answer says where you stand in three headers; past the limit you get 429 with Retry-After in seconds.
Answers are cached: lists for 10 seconds, a post and its replies for 30. Asking more often never gets you newer posts; for posts the moment they go live, use the WebSocket.
| Name | Description |
|---|---|
X-RateLimit-Limit | Requests allowed per minute. |
X-RateLimit-Remaining | Requests left in this minute. |
X-RateLimit-Reset | Seconds until the minute starts over. |
Retry-After | On a 429 or 503: seconds to wait. |
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 23
{"error": "RATE_LIMITED", "retry_after": 23, "limit": "60 requests per minute"}
When the feed cannot be read for a moment, the API answers 503 FEED_UNAVAILABLE with Retry-After: 10.
Endpoints
Every endpoint can also answer 429 RATE_LIMITED and 503 FEED_UNAVAILABLE (see Rate limits).
HTTP/1.1 400 Bad Request
{"error": "INVALID_LIMIT"}
List posts
GET/v1/posts
The newest posts, newest first by when they were posted (a repost does not move a post here). Page down with before = the next_before of the previous page.
Parameters
| Name | Type | Description |
|---|---|---|
limit | integer | 1 to 50; 20 by default. |
before | integer | A time in milliseconds: only posts older than that. Pass next_before. |
hashtag | string | Without the #, e.g. amsterdam. Letters, digits and underscores, any script. |
user | string | A username (an @ in front is fine): one account’s posts. |
near | lat,lon | Posts from cities around that point, e.g. 52.09,6.15. |
radius_km | number | 1 to 100 kilometres around near; 25 by default. Needs near. |
curl 'https://feed.mss.gs/v1/posts?hashtag=amsterdam&limit=1'
{
"posts": [
{
"guid": "4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02",
"url": "https://feed.mss.gs/4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02",
"created": "2026-10-06T08:12:04.715Z",
"created_unix": 1791273124,
"type": "photo",
"text": "Rain again in #amsterdam",
"hashtags": [
"amsterdam"
],
"media": [
{
"type": "image",
"url": "https://mss.gs/static/stories/a1b2/c3d4.jpg",
"thumb_url": "https://mss.gs/static/stories/a1b2/c3d4_540x960.jpg",
"width": 540,
"height": 960
}
],
"author": {
"username": "sofie",
"avatar_url": "https://mss.gs/static/profile-uploads/e5f6.png",
"is_verified": false,
"profile_url": "https://mss.gs/@sofie"
},
"stats": {
"likes": 12,
"replies": 4,
"reposts": 3,
"views": 240
},
"place": {
"city": "Amsterdam",
"country_code": "NL"
}
}
],
"has_more": true,
"next_before": 1791273124715
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | INVALID_LIMIT | limit is not a whole number from 1 to 50. |
| 400 | INVALID_BEFORE | before is not a positive whole number. |
| 400 | INVALID_HASHTAG | hashtag is not a hashtag. |
| 400 | INVALID_USER | user is not a username. |
| 400 | INVALID_NEAR | near is not lat,lon. |
| 400 | INVALID_RADIUS | radius_km is not between 1 and 100. |
| 400 | RADIUS_WITHOUT_NEAR | radius_km without near. |
One post
GET/v1/posts/{guid}
A post by its id: the last part of its link, feed.mss.gs/{guid}.
Parameters
| Name | Type | Description |
|---|---|---|
guid | path | The post’s id. |
curl https://feed.mss.gs/v1/posts/4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02
{
"post": {
"guid": "4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02",
"url": "https://feed.mss.gs/4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02",
"created": "2026-10-06T08:12:04.715Z",
"created_unix": 1791273124,
"type": "text",
"text": "Rain again in #amsterdam",
"hashtags": [
"amsterdam"
],
"media": [],
"author": {
"username": "sofie",
"avatar_url": "https://mss.gs/static/profile-uploads/e5f6.png",
"is_verified": false,
"profile_url": "https://mss.gs/@sofie"
},
"stats": {
"likes": 12,
"replies": 4,
"reposts": 3,
"views": 240
},
"place": null
}
}
Errors
| Status | Code | When |
|---|---|---|
| 404 | POST_NOT_FOUND | No public post with that id: deleted, taken down, private, or never there. |
A post’s replies
GET/v1/posts/{guid}/replies
Its replies, oldest first, at most 200. Replies are one level deep: a reply to a reply has parent_guid.
Parameters
| Name | Type | Description |
|---|---|---|
guid | path | The post’s id. |
curl https://feed.mss.gs/v1/posts/4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02/replies
{
"replies": [
{
"guid": "9a1f0c7e4b2d4e6f8a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2fj02",
"post_guid": "4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02",
"parent_guid": null,
"created": "2026-10-06T08:14:30.000Z",
"created_unix": 1791273270,
"text": "Soaked. Completely soaked. 🌧️",
"author": {
"username": "thomas",
"avatar_url": null,
"is_verified": false,
"profile_url": "https://mss.gs/@thomas"
},
"likes": 3,
"replies": 1
}
]
}
Errors
| Status | Code | When |
|---|---|---|
| 404 | POST_NOT_FOUND | No public post with that id. |
RSS
GET/rss.xml
The same list as /v1/posts as RSS 2.0, with the same parameters: for a reader, a widget or the screen in a town hall. Each item has the author, the hashtags as categories and the first picture as an enclosure.
Parameters
| Name | Type | Description |
|---|---|---|
… | Every parameter of List posts. |
curl 'https://feed.mss.gs/rss.xml?near=52.09,6.15&radius_km=10&hashtag=roadworks'
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>mssgs feed: #roadworks, 10 km around 52.09,6.15</title>
<link>https://feed.mss.gs/tag/roadworks</link>
<item>
<title>@roadworks_demo: Zutphensestraat closed 14 to 18 October</title>
<link>https://feed.mss.gs/4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02</link>
<dc:creator>@roadworks_demo</dc:creator>
<category>roadworks</category>
</item>
</channel>
</rss>
Errors
| Status | Code | When |
|---|---|---|
| 400 | INVALID_… | As for List posts, as JSON. |
oEmbed
GET/oembed
oEmbed 1.0 for a post’s link: tools that speak oEmbed get the embed code themselves. The answer is type rich, an iframe of the card.
Parameters
| Name | Type | Description |
|---|---|---|
url | string | Required. A post’s link, https://feed.mss.gs/{guid}. |
maxwidth | integer | 250 to 550; 550 by default. |
maxheight | integer | The most the frame may be tall. |
format | string | json, the only one. |
curl 'https://feed.mss.gs/oembed?url=https%3A%2F%2Ffeed.mss.gs%2F4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02&maxwidth=550'
{
"version": "1.0",
"type": "rich",
"provider_name": "mssgs",
"provider_url": "https://mss.gs",
"title": "Rain again in #amsterdam",
"author_name": "@sofie",
"author_url": "https://mss.gs/@sofie",
"width": 550,
"height": 760,
"cache_age": 300,
"html": "<iframe src=\"https://feed.mss.gs/4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02/embed\" width=\"550\" height=\"760\" style=\"border:0;max-width:100%;border-radius:16px;overflow:hidden\" title=\"@sofie on mssgs\" loading=\"lazy\" allow=\"clipboard-write\" scrolling=\"no\"></iframe>",
"thumbnail_url": "https://mss.gs/static/stories/a1b2/c3d4_540x960.jpg"
}
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_A_POST_URL | url is not a feed.mss.gs post link. |
| 404 | POST_NOT_FOUND | No public post with that id. |
| 501 | FORMAT_NOT_SUPPORTED | A format other than json. |
Embed a post (embed.js)
https://feed.mss.gs/embed.js
Paste the quote where the post should go and add the script once per page. The script turns it into the post’s card, sized to fit, with its picture, replies, reposts, likes and views; readers without JavaScript still see the quote.
Parameters
| Name | Type | Description |
|---|---|---|
data-guid | attribute | The post’s id (or a link to the post inside the quote). |
data-lang | attribute | The card’s language, e.g. nl; the reader’s browser language by default. |
<blockquote class="mssgs-post" data-guid="4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02" data-lang="en">
<p>Rain again in #amsterdam</p>
— @sofie <a href="https://feed.mss.gs/4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02">6 Oct 2026</a>
</blockquote>
<script async src="https://feed.mss.gs/embed.js"></script>
Quotes added later (a single-page app): call window.mssgsEmbed.load(). Without the script: an iframe of https://feed.mss.gs/{guid}/embed, which tells its parent its height with postMessage.
Post with an API key
With a key, your own code posts to the feed as you: a municipality’s notices, a weather station, a status page. Make one in the developer portal on mss.gs, signed in with your mssgs account. A post from a key follows the same rules as one from the app: public, a text of up to 500 characters, your city at most.
Reading needs no key; only the three calls below do. Keep the key on your server: a key in a web page or an app is a key anyone can post with.
Authentication
Send the key in the Authorization header as Bearer mfk_…, or as X-Api-Key: mfk_…. A key is mfk_ and 46 letters and digits, and works for 30 days.
It comes with a refresh token, mfr_ and 52 letters and digits, that trades itself for a new pair (see Refreshing a key). Both are shown once, when you make or rotate the key: mssgs keeps only a fingerprint of each. In the portal you rename, rotate and revoke keys and see what each one did.
Each of the three can also answer the key errors below (401), 429 RATE_LIMITED and 503 FEED_API_UNAVAILABLE.
HTTP/1.1 401 Unauthorized
{"error": "KEY_EXPIRED"}
Create a post
POST/v1/posts
A text post as the key’s account. The answer has the post’s id and link, with status: "in_review". A post from a key follows the same rules as a post from the app: a text post is usually on the feed within a second or two, and one with a link, an email address, a phone number or an obvious word waits a few seconds for a check first.
Parameters
| Name | Type | Description |
|---|---|---|
text | string | Required. 1 to 500 characters. Its #hashtags become the post’s hashtags; it can be a sticker or a GIF, as in the app (see Text shapes). |
location | object | Optional. { "lat": 52.09, "lon": 6.15 }: the post carries the city around it, never the spot. |
curl https://feed.mss.gs/v1/posts \
-H "Authorization: Bearer $MSSGS_FEED_KEY" \
-H 'Content-Type: application/json' \
-d '{"text": "Zutphensestraat closed 14 to 18 October #roadworks", "location": {"lat": 52.09, "lon": 6.15}}'
{
"post": {
"guid": "7d1c0e5a9b2f4c6d8e0a1b3c5d7e9f1a2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1ej02",
"url": "https://feed.mss.gs/7d1c0e5a9b2f4c6d8e0a1b3c5d7e9f1a2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1ej02",
"status": "in_review"
}
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | INVALID_CONTENT | text is missing, empty or over 500 characters. |
| 403 | SCOPE_MISSING | The key may not post. |
| 403 | POSTING_RESTRICTED | The account may not post in public. |
| 409 | DUPLICATE_POST | The account posted the same text in the last 10 minutes. |
| 429 | RATE_LIMITED | Over the key’s or the account’s posts an hour: wait retry_after seconds. |
Refresh a key
POST/v1/token/refresh
Trades the refresh token for a new key and refresh token, for another 30 and 90 days. The old pair stops working at once. No key needed: the refresh token is the proof.
Parameters
| Name | Type | Description |
|---|---|---|
refresh_token | string | Required. The key’s current refresh token. |
curl https://feed.mss.gs/v1/token/refresh \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\": \"$MSSGS_FEED_REFRESH\"}"
{
"api_key": "mfk_Q7mZp2…",
"refresh_token": "mfr_kT4wHn…",
"expires_ms": 1793865124715
}
Errors
| Status | Code | When |
|---|---|---|
| 401 | INVALID_REFRESH_TOKEN | Not the key’s current refresh token: used already, older than 90 days, or the key was revoked. |
| 409 | BUSY_TRY_AGAIN | This key is being refreshed at the same moment. Try again in a few seconds. |
| 409 | KEY_CHANGED | The key changed while it was refreshed (revoked, rotated or refreshed elsewhere). |
| 429 | RATE_LIMITED | More than 10 refreshes of this key in an hour. |
Who a key is
GET/v1/me
The key’s label, scopes and expiry, its account and its limits: a cheap way to check that a key works, and when it needs refreshing.
Parameters
| Name | Type | Description |
|---|---|---|
Authorization | header | Bearer mfk_… (or X-Api-Key). |
curl https://feed.mss.gs/v1/me -H "Authorization: Bearer $MSSGS_FEED_KEY"
{
"key": {
"label": "Roadworks bot",
"scopes": [
"read",
"post"
],
"expires_ms": 1793865124715
},
"user": {
"username": "roadworks_demo",
"avatar_url": "https://mss.gs/static/profile-uploads/e5f6.png",
"is_verified": true
},
"limits": {
"posts_per_hour": 20,
"posts_per_hour_account": 40,
"requests_per_minute": 120,
"refreshes_per_hour": 10
}
}
Errors
Every error is JSON, {"error": "CODE"}, with retry_after in seconds on a 429 (and the Retry-After header).
| Status | Code | When |
|---|---|---|
| 401 | INVALID_API_KEY | No key, or not one mssgs knows: a revoked key too, and a key’s old value after a refresh or a rotate. |
| 401 | KEY_REVOKED | The key’s account is gone. (A key revoked in the portal, or because a used refresh token came back, answers INVALID_API_KEY: its secret went with it.) |
| 401 | KEY_EXPIRED | The key is more than 30 days old: refresh it. |
| 401 | INVALID_REFRESH_TOKEN | See Refresh a key. |
| 403 | SCOPE_MISSING | The key may not post. |
| 403 | POSTING_RESTRICTED | The account may not post in public. |
| 400 | INVALID_CONTENT | The text is missing, empty or over 500 characters. |
| 400 | INVALID_JSON | The body is not JSON. |
| 409 | DUPLICATE_POST | The same text from the same account within 10 minutes. |
| 409 | BUSY_TRY_AGAIN | Two refreshes of one key at once. |
| 409 | KEY_CHANGED | The key changed during a refresh. |
| 413 | BODY_TOO_LARGE | The body is over 16 KB. |
| 415 | JSON_REQUIRED | The body is sent as something other than JSON. |
| 429 | RATE_LIMITED | Over a limit below; retry_after says how long to wait. |
| 500 | POST_FAILED | The post could not be saved. Try again. |
| 503 | FEED_API_UNAVAILABLE | The post API cannot be reached for a moment. Try again after Retry-After. |
Limits
| Name | Description |
|---|---|
| 120 | Requests a minute per key, every call with it. |
| 20 | Posts an hour per key. |
| 40 | Posts an hour per account, every key together. |
| 10 | Refreshes an hour per key. |
| 60 | Requests a minute per IP address through feed.mss.gs, together with reading (see Rate limits). |
| 10 | Active keys per account. |
| 30 days | A key works, then refresh it. |
| 90 days | A refresh token works, once. |
Refreshing a key
- Keep the key and its refresh token together, on your server.
- Before the 30 days are up, or when a call answers
KEY_EXPIRED, send the refresh token toPOST /v1/token/refresh.GET /v1/metells youexpires_ms. - Save the new pair before you use it: the old key and refresh token stopped working.
- A refresh token works once. If the answer gets lost, that pair is gone: rotate the key in the portal for a new one. A used refresh token that comes back more than 2 minutes later means someone else has a copy, and mssgs revokes the key.
- Missed the 90 days? Rotate the key in the portal: same label and history, a new pair.
Examples
# Your key from mss.gs/en/developers/feed, kept out of your code.
export MSSGS_FEED_KEY='mfk_…'
curl -s https://feed.mss.gs/v1/posts \
-H "Authorization: Bearer $MSSGS_FEED_KEY" \
-H 'Content-Type: application/json' \
-d '{"text": "Waste collection moves to Saturday this week #brummen"}' | jq .
import { readFile, writeFile } from 'node:fs/promises';
const API = 'https://feed.mss.gs/v1';
const FILE = './mssgs-key.json'; // { "api_key": "mfk_…", "refresh_token": "mfr_…" }
async function refresh (keys) {
const res = await fetch(`${API}/token/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: keys.refresh_token })
});
const body = await res.json();
if (!res.ok) throw new Error(`refresh failed: ${body.error}`);
// Save the new pair first: the old one stopped working.
await writeFile(FILE, JSON.stringify({ api_key: body.api_key, refresh_token: body.refresh_token }));
return body;
}
export async function post (text) {
let keys = JSON.parse(await readFile(FILE, 'utf8'));
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`${API}/posts`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${keys.api_key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
const body = await res.json();
if (res.ok) return body.post; // { guid, url, status: 'in_review' }
if (body.error === 'KEY_EXPIRED') {
keys = await refresh(keys);
} else if (body.error === 'RATE_LIMITED') {
await new Promise((resolve) => setTimeout(resolve, (body.retry_after || 60) * 1000));
} else {
throw new Error(body.error); // DUPLICATE_POST, INVALID_CONTENT, KEY_REVOKED, …
}
}
throw new Error('gave up');
}
console.log(await post('Waste collection moves to Saturday this week #brummen'));
import json
import time
import requests
API = "https://feed.mss.gs/v1"
FILE = "mssgs-key.json" # {"api_key": "mfk_…", "refresh_token": "mfr_…"}
def refresh(keys):
res = requests.post(f"{API}/token/refresh", json={"refresh_token": keys["refresh_token"]}, timeout=10)
res.raise_for_status()
body = res.json()
# Save the new pair first: the old one stopped working.
with open(FILE, "w") as f:
json.dump({"api_key": body["api_key"], "refresh_token": body["refresh_token"]}, f)
return body
def post(text):
with open(FILE) as f:
keys = json.load(f)
for _ in range(3):
res = requests.post(f"{API}/posts", json={"text": text},
headers={"Authorization": f"Bearer {keys['api_key']}"}, timeout=10)
body = res.json()
if res.ok:
return body["post"] # {"guid", "url", "status": "in_review"}
if body.get("error") == "KEY_EXPIRED":
keys = refresh(keys)
elif body.get("error") == "RATE_LIMITED":
time.sleep(body.get("retry_after", 60))
else:
raise RuntimeError(body.get("error")) # DUPLICATE_POST, INVALID_CONTENT, …
raise RuntimeError("gave up")
print(post("Waste collection moves to Saturday this week #brummen"))
Post object
Every list and every post answer carries posts in this shape. Media URLs are absolute; counts are numbers, never missing.
| Field | Type | Description |
|---|---|---|
guid | string | The post’s id. |
url | string | Its page, https://feed.mss.gs/{guid}. |
created | string | When it was posted, ISO 8601 in UTC. |
created_unix | integer | The same, in seconds. |
type | string | text, photo, slideshow or video. |
text | string | The words (a photo’s caption). Can be a GIF or carry stickers: see Text shapes. |
hashtags | string[] | Lowercase, without the #. |
media | object[] | Pictures and videos in order: type (image or video), url, thumb_url, width, height. |
author | object | username, avatar_url, is_verified, profile_url. |
stats | object | likes, replies, reposts, views. |
place | object|null | city and country_code when the author attached their city. Never coordinates. |
A reply
| Field | Type | Description |
|---|---|---|
guid | string | The reply’s id. |
post_guid | string | The post it answers. |
parent_guid | string|null | The reply it answers, or null at the top level. |
created / created_unix | string / integer | When it was written. |
text | string | Its words; the same shapes as a post. |
author | object | As on a post. |
likes / replies | integer | Its counts. |
Text shapes
A post’s text (and a reply’s) is always plain text; the apps draw some of it as pictures, and you can too. Anything that is not one of these shapes is words.
| Name | Description |
|---|---|
| GIF | The whole text is one https URL on static.klipy.com/ii/… or mss.gs/static/…. Its size is in the fragment, #w=498&h=280, so you can reserve the box before it loads. A sentence with a GIF link in it is words. |
| Stickers | A [sticker-id] token such as [funny-little-bear-floor-laugh] is a built-in sticker: an animation at https://mssgs-stickers.com/files/sticker-{id}-v{version}.apng (a still: .webp). The ids and versions are in feed-stickers.json. A text of only tokens shows them large, a token in a sentence inline. An id that is not in the list stays text. |
| Emoji | Ordinary Unicode. mssgs draws them as Twemoji. |
[
{ "text": "https://static.klipy.com/ii/71b2873e478b9d8d0482ea3ec777ba7f/85/a0/vFycpRjg.webp#w=498&h=342" },
{ "text": "[funny-little-bear-floor-laugh] [mint-bot-bye]" },
{ "text": "Monday mood [mint-bot-bye] but the coffee is good ☕️ #monday" }
]
// Is this text a GIF? The whole text, one https URL on an allowed host.
function gifOf (text) {
let url;
try { url = new URL(text.trim()); } catch { return null; }
const allowed = (url.hostname === 'static.klipy.com' && url.pathname.startsWith('/ii/')) ||
(url.hostname === 'mss.gs' && url.pathname.startsWith('/static/'));
if (url.protocol !== 'https:' || !allowed || /\s/.test(text.trim())) return null;
const size = new URLSearchParams(url.hash.slice(1));
return { src: url.origin + url.pathname, width: Number(size.get('w')), height: Number(size.get('h')) };
}
Live updates
For posts the moment they go live, connect to the mssgs gateway at wss://gateway.mss.gs (it routes you to the nearest hub) and subscribe. No sign-in needed.
Pushes come in the backend’s own shape: the text is description, media paths are relative to https://mss.gs, the time is _meta.cms in milliseconds. Send a PING every 30 seconds, and subscribe again after a reconnect.
| Push | Payload |
|---|---|
FEED_POST | { item }: a new post. A repost brings a post back the same way, with reposted_by (who) and sort_cms (when); order by sort_cms, else _meta.cms, and keep one entry per post. |
FEED_POST_REMOVED | { guid }: the post was deleted or taken down. |
FEED_POST_STATS | { guid, likes, comments, reposts }: new counts. |
const ws = new WebSocket('wss://gateway.mss.gs');
ws.onopen = () => {
ws.send(JSON.stringify({ method: 'FEED_SUBSCRIBE', payload: {}, request_id: 'live-1' }));
// Keep the socket open: a PING every 30 seconds.
setInterval(() => {
ws.send(JSON.stringify({ method: 'PING', payload: {}, request_id: 'ping' }));
}, 30000);
};
ws.onmessage = (event) => {
const { method, payload } = JSON.parse(event.data);
if (method === 'FEED_POST') {
// A new post, or one a repost brought back: then it has reposted_by
// (who) and sort_cms (when). Order by sort_cms ?? _meta.cms.
const { item } = payload;
console.log(item.reposted_by ? `reposted by ${item.reposted_by.username}` : 'new post', item);
}
if (method === 'FEED_POST_REMOVED') console.log('removed', payload.guid);
if (method === 'FEED_POST_STATS') console.log('new counts', payload); // likes, comments, reposts
};
Examples
curl -s 'https://feed.mss.gs/v1/posts?near=52.09,6.15&radius_km=10&hashtag=roadworks' \
| jq -r '.posts[] | "\(.created) @\(.author.username) \(.text)"'
async function postsWith (hashtag) {
const posts = [];
let before = '';
while (true) {
const res = await fetch(`https://feed.mss.gs/v1/posts?hashtag=${encodeURIComponent(hashtag)}&limit=50${before}`);
if (res.status === 429) {
// Over the limit: wait as long as the API says, then ask again.
await new Promise((resolve) => setTimeout(resolve, Number(res.headers.get('Retry-After') || 5) * 1000));
continue;
}
const { posts: page, has_more, next_before } = await res.json();
posts.push(...page);
if (!has_more) return posts;
before = `&before=${next_before}`;
}
}
// A post's words are text: show them as text, never as HTML.
for (const post of await postsWith('amsterdam')) {
const line = document.createElement('p');
line.textContent = `@${post.author.username}: ${post.text}`;
document.body.append(line);
}
import time
import requests
API = "https://feed.mss.gs/v1/posts"
def posts_with(hashtag):
params = {"hashtag": hashtag, "limit": 50}
while True:
res = requests.get(API, params=params, timeout=10)
if res.status_code == 429:
# Over the limit: wait as long as the API says.
time.sleep(int(res.headers.get("Retry-After", "5")))
continue
res.raise_for_status()
body = res.json()
yield from body["posts"]
if not body["has_more"]:
return
params["before"] = body["next_before"]
for post in posts_with("amsterdam"):
print(post["created"], post["author"]["username"], post["text"])