strangers-js

A CommonJS client library for building bot accounts on Strangers. Login, post, follow, message, and more, without writing raw HTTP calls yourself.

Install

Copy the strangers-js folder into your own project and require it by relative path. No npm registry needed.

const { StrangersBot } = require('./strangers-js');

const bot = new StrangersBot();

Requires Node 18 or newer, since it uses the built in fetch.

Logging in

Two ways to log in: username and password, or a saved session cookie.

// with a username and password
await bot.login('bot_username', 'bot_password');
console.log(bot.user.username);
// with a saved cookie, skips the password entirely
await bot.loginWithCookie(savedToken);
// save the token after logging in once, reuse it next time
const token = bot.getSessionCookie();
// write token somewhere safe, like a local json file

// or pass a cookie straight into the constructor
const bot2 = new StrangersBot({ cookie: token });

Heartbeat

The bot sends a heartbeat every 30 seconds automatically while logged in, so it shows up as online. You never need to call this yourself.

// turn it off if you don't want the bot showing as online
const bot = new StrangersBot({ autoHeartbeat: false });

// or control it by hand
bot.stopHeartbeat();
bot.startHeartbeat();

// change how often it beats, in milliseconds
const bot2 = new StrangersBot({ heartbeatIntervalMs: 15000 });

Watching things

This library never uses a live stream. Instead, watch functions poll in the background and only call you back for things you have not seen yet. A running watcher is also what keeps a persistent bot process alive, the same way a discord bot stays up.

// stop() ends the polling
const stop = bot.notifications.watch((notif) => {
  console.log(notif.type, notif.actor.username);
});

// call stop() later if you want to stop watching
stop();

Errors

Every failed request throws a StrangersApiError, with a status code and the server's error message.

const { StrangersApiError } = require('./strangers-js');

try {
  await bot.posts.create({ text: '' });
} catch (err) {
  if (err instanceof StrangersApiError) {
    console.log(err.status, err.message);
    console.log(err.data);
  }
}

StrangersBot

The main class. Creating one gives you every module below, all sharing the same login session.

NEWnew StrangersBot(options)

Creates a bot instance. Nothing is sent over the network until you call login, loginWithCookie, or signup.

options.baseUrl
API base url, defaults to https://api.strangersapp.xyz
options.cookie
A session token to use right away, same as calling loginWithCookie
options.autoHeartbeat
Set to false to disable the automatic heartbeat
options.heartbeatIntervalMs
How often to send a heartbeat, defaults to 30000
bot.login(username, password)

Logs in with a username and password. Starts the heartbeat automatically. Returns the logged in user, also stored as bot.user.

const user = await bot.login('bot_testing', '12345678');
console.log('logged in as', user.username);
bot.loginWithCookie(cookie)

Logs in using a saved session token instead of a password. Starts the heartbeat automatically.

await bot.loginWithCookie('the-saved-token');
bot.signup(data)

Creates a brand new account and logs in as it. Starts the heartbeat automatically.

await bot.signup({
  username: 'my_new_bot',
  password: 'a-strong-password',
  inviteKey: 'AB12-CD34-EF56' // only needed if invites are required
});
bot.logout()

Logs out and stops the heartbeat.

await bot.logout();
bot.getSessionCookie()

Gets the current session token, so you can save it and skip the password next time with loginWithCookie.

const token = bot.getSessionCookie();
bot.stopHeartbeat() / bot.startHeartbeat()

Manually stop or start the automatic heartbeat without logging out.

bot.destroy()

Stops all timers. Call this if the process is not exiting cleanly on its own.

auth

Lower level account calls. Most of the time bot.login and bot.signup are all you need, this is here for direct access.

POSTbot.auth.signup(data)

Same as bot.signup, but does not update bot.user or start the heartbeat.

POSTbot.auth.login(username, password)

Same as bot.login, but does not update bot.user or start the heartbeat.

POSTbot.auth.logout()

Logs out on the server. Does not clear the local session or stop the heartbeat, use bot.logout for that.

GETbot.auth.me()

Gets the currently logged in account fresh from the server.

const me = await bot.auth.me();
PATCHbot.auth.updateMe(data)

Updates account settings, like bio or display name.

await bot.auth.updateMe({
  bio: 'a friendly bot',
  displayName: 'Bot Testing'
});
DELETEbot.auth.deleteAccount()

Permanently deletes the logged in account. There is no undo.

users

Profiles, following, blocking, and follow requests for private accounts.

GETbot.users.profile(username)

Gets a user's public profile.

const { user } = await bot.users.profile('someusername');
GETbot.users.posts(username, before)

A user's own posts, newest first. Pass the createdAt of the oldest post you already have as before, to load older ones.

GETbot.users.replies(username, before)

A user's replies to other posts.

GETbot.users.relays(username, before)

Posts a user has relayed.

GETbot.users.followers(username)

Who follows this user.

GETbot.users.following(username)

Who this user follows.

POSTbot.users.follow(username)

Follows a user. If their account is private, this creates a pending follow request instead.

await bot.users.follow('someusername');
DELETEbot.users.unfollow(username)

Unfollows a user.

POSTbot.users.block(username)

Blocks a user. Cuts off following, messaging, and visibility in both directions.

DELETEbot.users.unblock(username)

Unblocks a user.

GETbot.users.blockedUsers()

Everyone the logged in account has blocked.

POSTbot.users.report(username, reason)

Reports a user for moderation. Returns { ok: true }.

GETbot.users.followRequests()

Pending follow requests this account has received, only relevant for private accounts.

POSTbot.users.acceptFollowRequest(username)

Accepts a pending follow request.

DELETEbot.users.rejectFollowRequest(username)

Rejects a pending follow request.

posts

The feed, creating posts, and every reaction you can take on one.

GETbot.posts.feed(before)

The main feed. Pass before to page further back.

const { posts } = await bot.posts.feed();
POSTbot.posts.create(data)

Creates a post.

data.text
The post text
data.imageUrl
Optional, from bot.media.upload()
data.replyToId
Optional, makes this a reply to another post
data.poll
Optional, see the poll example below
await bot.posts.create({ text: 'hello world' });

// a reply
await bot.posts.create({ text: 'nice post', replyToId: postId });

// a poll
await bot.posts.create({
  text: 'cats or dogs?',
  poll: {
    durationHours: 24,
    options: [{ text: 'cats' }, { text: 'dogs' }]
  }
});
GETbot.posts.get(postId)

Gets one post and its reply thread.

DELETEbot.posts.remove(postId)

Deletes a post the bot authored.

POSTbot.posts.pin(postId)

Pins a post to the top of the bot's profile.

DELETEbot.posts.unpin(postId)

Unpins a post.

POSTbot.posts.like(postId)

Likes a post.

DELETEbot.posts.unlike(postId)

Removes a like.

POSTbot.posts.relay(postId, quoteText)

Relays a post. Pass quoteText to make it a quote relay instead of a plain one.

await bot.posts.relay(postId);
await bot.posts.relay(postId, 'this is great');
DELETEbot.posts.unrelay(postId)

Removes a relay.

POSTbot.posts.trackView(postId)

Marks a post as viewed, for its view count.

POSTbot.posts.votePoll(postId, optionId)

Votes on a poll. optionId comes from the post's poll.options list.

POSTbot.posts.report(postId, reason)

Reports a post for moderation. Returns { ok: true }.

bookmarks

Posts the bot has privately saved.

GETbot.bookmarks.list(before)

The bot's own bookmarks, newest saved first.

Returns { posts, hasMore }. Each post has the same shape a regular post has everywhere else in this API, plus two bookmark-specific fields always set on every item here:

post.bookmarked
Always true on this list, by definition
post.bookmarkedAt
ISO timestamp of when the bot bookmarked it
{
  "posts": [
    {
      // same fields as a regular post duh
      "bookmarked": true,
      "bookmarkedAt": "2026-09-07T14:32:10.000Z"
    }
  ],
  "hasMore": false
}
POSTbot.bookmarks.add(postId)

Bookmarks a post. Returns { bookmarked: true }. Safe to call twice on the same post: a repeat call is a no-op, not an error.

DELETEbot.bookmarks.remove(postId)

Removes a bookmark. Returns { bookmarked: false }.

notifications

Likes, follows, replies, mentions, and message activity aimed at the bot.

GETbot.notifications.list()

The bot's notifications, newest first, plus an unread count.

POSTbot.notifications.markRead()

Marks every notification as read.

POLLbot.notifications.watch(onNotification, options)

Polls in the background and calls onNotification once for each new notification. Keeps the process running until you call the returned stop function.

options
A plain number for the interval, or an object
options.intervalMs
How often to poll, defaults to 10000
options.types
Optional array of NotificationType values to filter to
const { NotificationType } = require('./strangers-js');

const stop = bot.notifications.watch((notif) => {
  console.log(notif.actor.username, 'followed you');
}, { types: [NotificationType.Follow] });

The notif object passed to onNotification, same shape returned by list()'s notifications array, and by each event on the SSE stream the frontend uses:

notif.id
The notification's own id
notif.type
One of the NotificationType values below
notif.createdAt
ISO timestamp of when it fired
notif.read
Whether it's been marked read yet
notif.actor.username
Who triggered it
notif.actor.displayName
The actor's display name
notif.actor.avatarUrl
The actor's avatar image url
notif.postId
The related post's id, if this notification is about a post (null otherwise, e.g. follow)
notif.postPreview
First 80 characters of that post's text, or null if there's no related post
// example notif for a like
{
  "id": "a1b2c3d4-...",
  "type": "like",
  "createdAt": "2026-09-07T14:32:10.000Z",
  "read": false,
  "actor": {
    "username": "someusername",
    "displayName": "Some User",
    "avatarUrl": "https://files.shibbystudios.xyz/..."
  },
  "postId": "e5f6...",
  "postPreview": "the first 80 characters of the liked post"
}

postId/postPreview are only populated for post-related types: like, relay, reply, mention_post. For follow, mention_bio, and any message-related notification, both are null.

presence

The heartbeat itself is automatic, this module is just for reading online status.

GETbot.presence.onlineCount()

How many accounts are online right now, site wide. Returns { online }, a plain count, deduped so one account open in several tabs still only counts once.

media

POSTbot.media.upload(buffer, contentType)

Uploads an image and returns its hosted url, ready to use as a post's imageUrl.

const fs = require('fs');
const buffer = fs.readFileSync('./photo.png');
const { url } = await bot.media.upload(buffer, 'image/png');

await bot.posts.create({ text: 'check this out', imageUrl: url });

Returns just { url }, nothing else. That url is what you hand back in as imageUrl for a post, poll option, blog cover, or profile avatar/banner; the server rejects any image field that isn't a URL it issued itself.

GETbot.media.youtubeMeta(videoId)

Looks up title, author, and thumbnail for a YouTube video id.

title
The video's title
author
The channel/uploader name
thumbnailUrl
A thumbnail image url
{
  "title": "Some Video Title",
  "author": "Some Channel",
  "thumbnailUrl": "https://i.ytimg.com/vi/.../hqdefault.jpg"
}

messages

Direct messages between accounts.

GETbot.messages.conversations()

Every conversation the bot is part of, including its own pending sent requests. Returns { conversations }, each shaped:

id
The conversation's id
status
"pending" or "accepted"
otherUser
id, username, displayName, bio, avatarUrl, bannerUrl, isPrivate, isVerified, isPremium, isAdultOnly
lastMessage
{ text, senderId } of the most recent message, or null if none yet
lastMessageAt
ISO timestamp of the last message
unreadCount
Messages from the other person the bot hasn't read yet
isRequester
True if the bot is the one who started this conversation (only present here, not on requests())
{
  "conversations": [
    {
      "id": "9f2e...",
      "status": "accepted",
      "otherUser": {
        "id": "7c1a...",
        "username": "someusername",
        "displayName": "Some User",
        "bio": "...",
        "avatarUrl": "https://files.shibbystudios.xyz/...",
        "bannerUrl": null,
        "isPrivate": false,
        "isVerified": false,
        "isPremium": false,
        "isAdultOnly": false
      },
      "lastMessage": { "text": "hey there", "senderId": "7c1a..." },
      "lastMessageAt": "2026-09-07T14:32:10.000Z",
      "unreadCount": 1,
      "isRequester": false
    }
  ]
}
GETbot.messages.requests()

Message requests other people sent to the bot, waiting to be accepted. Returns { requests }, same shape as a conversation above, minus isRequester (every row here was, by definition, sent by someone else).

GETbot.messages.getConversation(conversationId)

A single conversation's full message history. Returns { conversation, messages }:

conversation
{ id, status, isRequester, otherUser }, a trimmed version of the shape above, no lastMessage/unreadCount since you're about to read the messages directly
messages[]
{ id, text, senderId, createdAt, read }, oldest first. Calling this also marks every message from the other person as read.
{
  "conversation": {
    "id": "9f2e...",
    "status": "accepted",
    "isRequester": false,
    "otherUser": { // same shape as conversations() above }
  },
  "messages": [
    {
      "id": "a4b2...",
      "text": "hey there",
      "senderId": "7c1a...",
      "createdAt": "2026-09-07T14:32:10.000Z",
      "read": true
    }
  ]
}
POSTbot.messages.send(username, text)

Sends a message to a user by username. Creates a new conversation if one does not exist yet. If the recipient is the one replying to the bot's still-pending request, that reply auto-accepts it.

await bot.messages.send('someusername', 'hey there');

Returns { conversation: { id, status }, message: { id, text, senderId, createdAt, read } }. message.read is always false here, since it was just sent.

{
  "conversation": { "id": "9f2e...", "status": "pending" },
  "message": {
    "id": "a4b2...",
    "text": "hey there",
    "senderId": "7c1a...",
    "createdAt": "2026-09-07T14:32:10.000Z",
    "read": false
  }
}
POSTbot.messages.accept(conversationId)

Accepts a pending message request. Returns { ok: true }.

DELETEbot.messages.remove(conversationId)

Deletes or denies a conversation. Returns { ok: true }.

POLLbot.messages.watch(conversationId, onMessage, intervalMs)

Polls one conversation in the background and calls onMessage for each new message.

const stop = bot.messages.watch(conversationId, (message) => {
  console.log(message.text);
});

blog

Reading the official Strangers blog. Read only, no bot ever writes to this.

GETbot.blog.list(before)

Published blog posts, newest first. Returns { posts, hasMore }, each post shaped:

id / title / body
body is raw markdown, unrendered; rendering is left to the reader
imageUrl
Cover image, or null
createdAt / updatedAt
ISO timestamps
author
{ username, displayName, avatarUrl }
{
  "posts": [
    {
      "id": "3d9c...",
      "title": "What's new this month",
      "body": "## Heading\n\nSome **markdown** text.",
      "imageUrl": null,
      "createdAt": "2026-09-01T12:00:00.000Z",
      "updatedAt": "2026-09-01T12:00:00.000Z",
      "author": { "username": "admin", "displayName": "Strangers Team", "avatarUrl": "https://files.shibbystudios.xyz/..." }
    }
  ],
  "hasMore": false
}
GETbot.blog.get(postId)

A single blog post. Returns { post }, same shape as one entry from list() above.

cssLibrary

Browsing and sharing profile CSS templates. Submitting requires the account to be Premium.

GETbot.cssLibrary.list(sort, before)

sort can be "popular" or "recent". Returns { items, hasMore }. hasMore is only ever true for "recent"; "popular" always returns its full ranked window in one response, there's no next page. Each item:

id / title / description
description defaults to "" if the author left it blank
imageUrl
Screenshot, or null
css
Not present on this list endpoint at all (omitted, not blank); only get() below includes it, to keep a page of 30 cards from shipping 30 copies of up-to-5000-char CSS
likes / liked
Total like count, and whether the bot itself has liked it
createdAt
ISO timestamp
author
{ username, displayName, avatarUrl, isVerified }
isOwn
True if the bot submitted this one
{
  "items": [
    {
      "id": "e1f0...",
      "title": "Dark mode retro",
      "description": "",
      "imageUrl": "https://files.shibbystudios.xyz/...",
      "likes": 12,
      "liked": false,
      "createdAt": "2026-08-30T09:00:00.000Z",
      "author": { "username": "someusername", "displayName": "Some User", "avatarUrl": "...", "isVerified": false },
      "isOwn": false
    }
  ],
  "hasMore": true
}
GETbot.cssLibrary.get(itemId)

A single template, including the full CSS. Returns { item }, same shape as list() above, but with a css field included this time (the actual CSS text).

{
  "item": {
    // same fields as list()
    "css": "body { background: #14181a; }"
  }
}
POSTbot.cssLibrary.submit(data)

Shares a template. data can include title, description, css, and imageUrl. Returns { item }, same full shape as get() above (css included).

DELETEbot.cssLibrary.remove(itemId)

Removes a template the bot submitted. Returns { ok: true }.

POSTbot.cssLibrary.like(itemId)

Likes a template. Returns { liked: true }.

DELETEbot.cssLibrary.unlike(itemId)

Removes a like. Returns { liked: false }.

gifs

config

GETbot.config.get()

Public instance config, including whether invite keys are required to sign up. Returns { inviteKeysEnabled, featureFlags }. featureFlags is the same object featureFlags() below returns, folded in here so a caller that fetches this once at startup doesn't need a second request.

{
  "inviteKeysEnabled": true,
  "featureFlags": {
    "messages": true,
    "blog": true,
    "bookmarks": true,
    "css_library": true,
    "gifs": true,
    "polls": true,
    "premium": true,
    "presence": true,
    "search": true
  }
}
GETbot.config.featureFlags()

Which optional features are currently turned on. Returns { featureFlags }, a plain { [key]: boolean } map. Keys currently include messages, blog, bookmarks, css_library, gifs, polls, premium, presence, search. A key that's missing entirely (rather than false) hasn't happened yet in practice, but should be treated as enabled if it ever does.

{ "featureFlags": { "messages": true, "gifs": false, /* ... */ } }

warnings

Moderation warnings issued to this account.

GETbot.warnings.current()

The oldest unacknowledged warning, or null if there is none. Returns { warning }, where warning is { id, reason, createdAt } or null. If the account has more than one outstanding warning, this always returns the oldest first; you have to acknowledge it before the next one shows up here.

{ "warning": { "id": "c8e1...", "reason": "Spam", "createdAt": "2026-09-05T10:00:00.000Z" } }
POSTbot.warnings.acknowledge(warningId)

Acknowledges a warning so it stops being returned by current(). Returns { ok: true }.

premium

POSTbot.premium.claimKofi(data)

Claims Premium from a Ko-fi purchase that was not auto matched. data can include transactionId and or email; transactionId is tried first if given, otherwise the most recent unclaimed order for that email. Returns { ok: true } on success. An order that's already been claimed by any account (including this one) is rejected rather than silently re-granting.

NotificationType

Constants for every notification type, so you never have to type a raw string. Every type shares the same notification object shape, only postId/postPreview vary by type.

const { NotificationType } = require('./strangers-js');
ConstantValueFires when
NotificationType.Followfollowsomeone follows the bot
NotificationType.Likelikesomeone likes the bot's post
NotificationType.Relayrelaysomeone relays the bot's post
NotificationType.Replyreplysomeone replies to the bot's post
NotificationType.MentionPostmention_postsomeone @mentions the bot in a post
NotificationType.MentionBiomention_biosomeone @mentions the bot in their bio
NotificationType.MessageRequestmessage_requestsomeone sends the bot a first message
NotificationType.Messagemessagea new message in an existing conversation
NotificationType.MessageAcceptmessage_acceptsomeone accepts the bot's message request
bot.notifications.watch((notif) => {
  switch (notif.type) {
    case NotificationType.Follow:
      console.log(notif.actor.username + ' followed you');
      break;
    case NotificationType.Like:
      console.log(notif.actor.username + ' liked your post');
      break;
    default:
      console.log('other notification:', notif.type);
  }
});

Full example

A persistent bot that stays running, follows back anyone who follows it, and replies to direct messages.

const { StrangersBot, NotificationType } = require('./strangers-js');

const bot = new StrangersBot();

async function main() {
  await bot.login('bot_testing', '12345678');
  console.log('logged in as', bot.user.username);

  // follow back, keeps the process alive on its own
  bot.notifications.watch((notif) => {
    if (notif.type === NotificationType.Follow) {
      bot.users.follow(notif.actor.username);
    }
  });

  // reply to dms
  const watching = new Set();

  setInterval(async () => {
    const { conversations } = await bot.messages.conversations();
    for (const convo of conversations) {
      if (watching.has(convo.id)) continue;
      watching.add(convo.id);

      bot.messages.watch(convo.id, (message) => {
        if (message.senderId === bot.user.id) return;
        bot.messages.send(convo.id, 'thanks for the message!');
      });
    }
  }, 15000);

  console.log('bot is running, press ctrl+c to stop');
}

process.on('SIGINT', async () => {
  await bot.logout();
  process.exit(0);
});

main().catch((err) => {
  console.error('bot crashed:', err);
});