feed Get mssgs

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

Shell
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.

NameDescription
X-RateLimit-LimitRequests allowed per minute.
X-RateLimit-RemainingRequests left in this minute.
X-RateLimit-ResetSeconds until the minute starts over.
Retry-AfterOn a 429 or 503: seconds to wait.
HTTP
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
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

NameTypeDescription
limitinteger1 to 50; 20 by default.
beforeintegerA time in milliseconds: only posts older than that. Pass next_before.
hashtagstringWithout the #, e.g. amsterdam. Letters, digits and underscores, any script.
userstringA username (an @ in front is fine): one account’s posts.
nearlat,lonPosts from cities around that point, e.g. 52.09,6.15.
radius_kmnumber1 to 100 kilometres around near; 25 by default. Needs near.
ShellRequest
curl 'https://feed.mss.gs/v1/posts?hashtag=amsterdam&limit=1'
JSONResponse
{
  "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

StatusCodeWhen
400INVALID_LIMITlimit is not a whole number from 1 to 50.
400INVALID_BEFOREbefore is not a positive whole number.
400INVALID_HASHTAGhashtag is not a hashtag.
400INVALID_USERuser is not a username.
400INVALID_NEARnear is not lat,lon.
400INVALID_RADIUSradius_km is not between 1 and 100.
400RADIUS_WITHOUT_NEARradius_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

NameTypeDescription
guidpathThe post’s id.
ShellRequest
curl https://feed.mss.gs/v1/posts/4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02
JSONResponse
{
  "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

StatusCodeWhen
404POST_NOT_FOUNDNo 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

NameTypeDescription
guidpathThe post’s id.
ShellRequest
curl https://feed.mss.gs/v1/posts/4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02/replies
JSONResponse
{
  "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

StatusCodeWhen
404POST_NOT_FOUNDNo 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

NameTypeDescription
…Every parameter of List posts.
ShellRequest
curl 'https://feed.mss.gs/rss.xml?near=52.09,6.15&radius_km=10&hashtag=roadworks'
XMLResponse
<?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

StatusCodeWhen
400INVALID_…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

NameTypeDescription
urlstringRequired. A post’s link, https://feed.mss.gs/{guid}.
maxwidthinteger250 to 550; 550 by default.
maxheightintegerThe most the frame may be tall.
formatstringjson, the only one.
ShellRequest
curl 'https://feed.mss.gs/oembed?url=https%3A%2F%2Ffeed.mss.gs%2F4cf6070aff4c39d671be1898ea5f1a325ff5cc7fd3d9456447069d9987379033j02&maxwidth=550'
JSONResponse
{
  "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

StatusCodeWhen
404NOT_A_POST_URLurl is not a feed.mss.gs post link.
404POST_NOT_FOUNDNo public post with that id.
501FORMAT_NOT_SUPPORTEDA 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

NameTypeDescription
data-guidattributeThe post’s id (or a link to the post inside the quote).
data-langattributeThe card’s language, e.g. nl; the reader’s browser language by default.
HTML
<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.

Get a key

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
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

NameTypeDescription
textstringRequired. 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).
locationobjectOptional. { "lat": 52.09, "lon": 6.15 }: the post carries the city around it, never the spot.
ShellRequest
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}}'
JSONResponse
{
  "post": {
    "guid": "7d1c0e5a9b2f4c6d8e0a1b3c5d7e9f1a2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1ej02",
    "url": "https://feed.mss.gs/7d1c0e5a9b2f4c6d8e0a1b3c5d7e9f1a2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1ej02",
    "status": "in_review"
  }
}

Errors

StatusCodeWhen
400INVALID_CONTENTtext is missing, empty or over 500 characters.
403SCOPE_MISSINGThe key may not post.
403POSTING_RESTRICTEDThe account may not post in public.
409DUPLICATE_POSTThe account posted the same text in the last 10 minutes.
429RATE_LIMITEDOver 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

NameTypeDescription
refresh_tokenstringRequired. The key’s current refresh token.
ShellRequest
curl https://feed.mss.gs/v1/token/refresh \
  -H 'Content-Type: application/json' \
  -d "{\"refresh_token\": \"$MSSGS_FEED_REFRESH\"}"
JSONResponse
{
  "api_key": "mfk_Q7mZp2…",
  "refresh_token": "mfr_kT4wHn…",
  "expires_ms": 1793865124715
}

Errors

StatusCodeWhen
401INVALID_REFRESH_TOKENNot the key’s current refresh token: used already, older than 90 days, or the key was revoked.
409BUSY_TRY_AGAINThis key is being refreshed at the same moment. Try again in a few seconds.
409KEY_CHANGEDThe key changed while it was refreshed (revoked, rotated or refreshed elsewhere).
429RATE_LIMITEDMore 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

NameTypeDescription
AuthorizationheaderBearer mfk_… (or X-Api-Key).
ShellRequest
curl https://feed.mss.gs/v1/me -H "Authorization: Bearer $MSSGS_FEED_KEY"
JSONResponse
{
  "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).

StatusCodeWhen
401INVALID_API_KEYNo key, or not one mssgs knows: a revoked key too, and a key’s old value after a refresh or a rotate.
401KEY_REVOKEDThe 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.)
401KEY_EXPIREDThe key is more than 30 days old: refresh it.
401INVALID_REFRESH_TOKENSee Refresh a key.
403SCOPE_MISSINGThe key may not post.
403POSTING_RESTRICTEDThe account may not post in public.
400INVALID_CONTENTThe text is missing, empty or over 500 characters.
400INVALID_JSONThe body is not JSON.
409DUPLICATE_POSTThe same text from the same account within 10 minutes.
409BUSY_TRY_AGAINTwo refreshes of one key at once.
409KEY_CHANGEDThe key changed during a refresh.
413BODY_TOO_LARGEThe body is over 16 KB.
415JSON_REQUIREDThe body is sent as something other than JSON.
429RATE_LIMITEDOver a limit below; retry_after says how long to wait.
500POST_FAILEDThe post could not be saved. Try again.
503FEED_API_UNAVAILABLEThe post API cannot be reached for a moment. Try again after Retry-After.

Limits

NameDescription
120Requests a minute per key, every call with it.
20Posts an hour per key.
40Posts an hour per account, every key together.
10Refreshes an hour per key.
60Requests a minute per IP address through feed.mss.gs, together with reading (see Rate limits).
10Active keys per account.
30 daysA key works, then refresh it.
90 daysA refresh token works, once.

Refreshing a key

  1. Keep the key and its refresh token together, on your server.
  2. Before the 30 days are up, or when a call answers KEY_EXPIRED, send the refresh token to POST /v1/token/refresh. GET /v1/me tells you expires_ms.
  3. Save the new pair before you use it: the old key and refresh token stopped working.
  4. 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.
  5. Missed the 90 days? Rotate the key in the portal: same label and history, a new pair.

Examples

ShellPost from the command line
# 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 .
JavaScriptPost from Node.js, refreshing the key when it runs out
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'));
PythonThe same in Python
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.

FieldTypeDescription
guidstringThe post’s id.
urlstringIts page, https://feed.mss.gs/{guid}.
createdstringWhen it was posted, ISO 8601 in UTC.
created_unixintegerThe same, in seconds.
typestringtext, photo, slideshow or video.
textstringThe words (a photo’s caption). Can be a GIF or carry stickers: see Text shapes.
hashtagsstring[]Lowercase, without the #.
mediaobject[]Pictures and videos in order: type (image or video), url, thumb_url, width, height.
authorobjectusername, avatar_url, is_verified, profile_url.
statsobjectlikes, replies, reposts, views.
placeobject|nullcity and country_code when the author attached their city. Never coordinates.

A reply

FieldTypeDescription
guidstringThe reply’s id.
post_guidstringThe post it answers.
parent_guidstring|nullThe reply it answers, or null at the top level.
created / created_unixstring / integerWhen it was written.
textstringIts words; the same shapes as a post.
authorobjectAs on a post.
likes / repliesintegerIts 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.

NameDescription
GIFThe 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.
StickersA [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.
EmojiOrdinary Unicode. mssgs draws them as Twemoji.
JSON
[
  { "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" }
]
JavaScript
// 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.

PushPayload
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.
JavaScript
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

ShellRoad works around one town, as JSON, with jq
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)"'
JavaScriptEvery post with a hashtag, page by page
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);
}
PythonThe same in Python, waiting when it is told to
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"])