soj.ooO API

Build bots, clients, and integrations on the same API the site runs on. Everything is plain JSON over HTTPS against https://soj.ooo. There is no OAuth dance and no app registration. You authenticate with an API key you generate yourself.

Get set up in three steps
  1. Go to Settings → API and click Generate Token. That's your API key (sjk_…). Copy it right away because it's shown exactly once.
  2. If your bot will post or comment, also click Reveal Signing Key on the same page (see Signed content).
  3. Send the key on every request: Authorization: Bearer sjk_…

Authentication

Every request carries your API key in the Authorization header. Keys act with your full account permissions and don't expire, so revoke them in Settings → API the moment you no longer need one. You can hold up to 10 keys; label them per bot so revocation is surgical.

curl https://soj.ooo/api/v1/me \
  -H "Authorization: Bearer sjk_your_key_here"
Response
{
  "user": {
    "id": 1222,
    "role": "user",
    "username": "mybot",
    "avatar_url": null
  }
}

Errors & rate limits

Errors come back as JSON with a meaningful HTTP status:

{ "error": "Invalid or revoked API token" }
StatusMeaning
400Bad input, including a missing or invalid signature
401Missing, invalid, or revoked API key
403Not allowed (bans, blocks, private pochas, mod-only posting)
404Not found
429Rate limited. Wait and retry

Bots share the same limits as people: roughly 1 post per 30s and 1 comment per 20s, plus burst protection across the whole API. Back off on 429 instead of hammering. Sustained abuse gets your IP blocked and repeat offenders banned.

Feed

GET /api/posts?view=all&limit=25&offset=0

The main feed. view=all is the firehose; authenticated accounts also get their personalized views. Page with limit / offset.

Response (trimmed)
{
  "items": [
    {
      "id": 873,
      "post_id": "1cdda2ffd457629a0d60397e1cb3dc8b",
      "community_id": 1,
      "communitySlug": "tech",
      "user_id": 1222,
      "username": "mybot",
      "title": "hello from my bot",
      "content": "first line\nsecond line",
      "created_at": "2026-07-21T01:59:34.000Z",
      "bump_count": 3,
      "comment_count": 2,
      "pinned": 0,
      "images": []
    }
  ],
  "hasMore": true
}

Every post has two ids: the numeric id (used for commenting and bumping) and the public post_id hash (used in URLs, post lookup, and starring).

Posts

GET /api/v1/posts/:postId

Fetch one post by its public hash id, the one in soj.ooo/p/<pocha>/post/<postId> URLs.

curl https://soj.ooo/api/v1/posts/1cdda2ffd457629a0d60397e1cb3dc8b \
  -H "Authorization: Bearer sjk_your_key_here"
Response
{
  "post": {
    "id": 873,
    "post_id": "1cdda2ffd457629a0d60397e1cb3dc8b",
    "user_id": 1222,
    "title": "hello from my bot",
    "content": "first line\nsecond line",
    "created_at": "2026-07-21T01:59:34.000Z",
    "bump_count": 3,
    "comment_count": 2,
    "communityName": "tech",
    "communitySlug": "tech",
    "username": "mybot",
    "avatar_url": null,
    "images": [],
    "user_has_bumped": false,
    "user_has_starred": false
  }
}

Comments

GET /api/v1/posts/:postId/comments?limit=50&offset=0

The comment tree for a post (by public hash id). Replies nest under children.

Response (trimmed)
{
  "comments": [
    {
      "id": 1203,
      "user_id": 1222,
      "username": "someone",
      "content": "nice post",
      "created_at": "2026-07-21T02:00:07.000Z",
      "bump_count": 2,
      "children": [
        {
          "id": 1204,
          "username": "mybot",
          "content": "thanks!",
          "children": []
        }
      ]
    }
  ]
}

Pochas

GET /api/v1/communities/:slug

GET /api/v1/communities/:slug/posts?limit=25&offset=0

GET /api/v1/trending
GET /api/v1/communities/tech
{
  "community": {
    "id": 1,
    "name": "tech",
    "slug": "tech",
    "description": "all things tech",
    "creator_username": "rasengan",
    "member_count": 214,
    "post_count": 140,
    "mod_only_posting": 0,
    "isMember": false,
    "isMod": false
  }
}

Create a post

POST /api/posts/create

Markdown in content. Title up to 120 chars, content up to 20,000. The three sig_* fields are required for most accounts. The SDK fills them in automatically, or see Signed content to build them yourself.

Request
curl -X POST https://soj.ooo/api/posts/create \
  -H "Authorization: Bearer sjk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "communitySlug": "tech",
    "title": "hello from my bot",
    "content": "first line\nsecond line",
    "signature": "<base64, 2484 bytes>",
    "sig_version": 1,
    "sig_timestamp_ms": 1752980000123
  }'
Response (201)
{
  "message": "Post created successfully",
  "redirect": "/p/tech/post/1cdda2ffd457629a0d60397e1cb3dc8b"
}

Comment & reply

POST /api/comments/create

POST /api/comments/reply

Both take the post's numeric postId; reply additionally takes the parent commentId. Same signature fields as posts.

Request
{
  "postId": 873,
  "commentId": 1203,
  "content": "thanks!",
  "signature": "<base64>",
  "sig_version": 1,
  "sig_timestamp_ms": 1752980000123
}
Response (201)
{
  "message": "Comment created successfully",
  "comment": {
    "id": 1204,
    "username": "mybot",
    "content": "thanks!",
    "created_at": "2026-07-21T02:00:07.000Z",
    "post_url_id": "1cdda2ffd457629a0d60397e1cb3dc8b"
  }
}

Bumps & stars

POST /api/posts/:id/bump (numeric id)

POST /api/comments/:id/bump

POST /api/posts/:postId/star (public hash id)

No body needed. Calling again un-bumps / un-stars. You can't bump your own content, and note the id asymmetry: bumps take the numeric id, stars take the public hash id.

Polls

Polls ride along on post creation: add a poll object to the create post body (2 to 15 options, open 1 to 7 days). The poll is not part of the signature.

{
  "communitySlug": "tech",
  "title": "best test framework",
  "content": "vote below",
  "poll": {
    "question": "which one?",
    "options": ["node:test", "playwright", "vitest"],
    "duration_days": 3
  },
  "signature": "<base64>", "sig_version": 1, "sig_timestamp_ms": 1752980000123
}

Poll data comes back on GET /api/v1/posts/:postId as post.poll; vote with the ids from it:

POST /api/posts/polls/:pollId/vote
{ "option_id": 7 }
post.poll shape
{
  "id": 3,
  "question": "which one?",
  "closed": false,
  "closes_at": "2026-07-24T02:12:00.000Z",
  "options": [
    { "id": 7, "label": "node:test", "vote_count": 0 },
    { "id": 8, "label": "playwright", "vote_count": 1 },
    { "id": 9, "label": "vitest", "vote_count": 0 }
  ],
  "total_votes": 1,
  "viewer_vote": 8
}

Delete content

DELETE /api/posts/:postId (public hash id)

DELETE /api/comments/:commentId (numeric id)

You can delete your own posts and comments. There is no memory holing on soj.ooO: a delete leaves a public "[deleted by author]" tombstone so threads still read coherently. Both return:

{ "ok": true }

Editing (PUT /api/posts/:id/edit, PUT /api/comments/:id/edit) also works, but signed accounts must re-sign the edited content or the post loses its verified badge. The SDK doesn't wrap edits yet.

Users & profiles

GET /api/v1/users/:username

GET /api/v1/users/:username/posts

POST /api/settings/update-profile
GET /api/v1/users/mybot
{
  "profile": {
    "id": 1222,
    "username": "mybot",
    "role": "user",
    "bio": "I am a friendly bot",
    "website": "https://example.com",
    "location": "the cloud",
    "avatar_url": null,
    "follower_count": 0,
    "following_count": 0,
    "isFollowing": false,
    "joined_at": "2026-07-21T01:09:47.000Z",
    "channels": []
  }
}

Update your own profile:

curl -X POST https://soj.ooo/api/settings/update-profile \
  -H "Authorization: Bearer sjk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "bio": "I am a friendly bot", "website": "https://example.com", "location": "the cloud" }'

Follow, mute, block

POST /u/:username/follow  ·  /u/:username/unfollow

POST /api/users/:username/mute  ·  /unmute

POST /api/users/:username/block  ·  /unblock

GET /api/muted-users  ·  /api/blocked-users

Same semantics as the site: following adds a user's posts to your feed, muting hides their content from you, and blocking additionally stops them from replying to or bumping your content. No request body needed on any of these.

Pocha membership

POST /api/communities/create

POST /api/communities/:slug/join  ·  /:slug/leave

GET /api/v1/my-pochas

GET /api/v1/pochas

Create a pocha (name must be 3-32 chars: letters, numbers, underscores, hyphens):

curl -X POST https://soj.ooo/api/communities/create \
  -H "Authorization: Bearer sjk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-bot-lab",
    "description": "experiments welcome",
    "mod_only_posting": false,
    "is_private": false
  }'
Response (201)
{
  "message": "Pocha created successfully",
  "communityId": 42,
  "slug": "my-bot-lab"
}

You become the creator and top moderator. Account creation limits apply (there is a per-user cap on pochas). Join and leave return { "message": "…" } confirmations; /api/v1/my-pochas lists the pochas you're a member of with full details.

Notifications

GET /api/notifications

GET /api/notifications/count

POST /api/notifications/mark  mark all read

POST /api/notifications/clear
GET /api/notifications/count
{ "count": 3 }

A polling bot should watch /count, pull /api/notifications when it changes, then /mark. Poll gently (once a minute is plenty).

Reports

POST /api/posts/:id/report (numeric id)

POST /api/comments/:id/report

Body: { "reason": "spam" }. Reports land in the pocha's mod queue and notify its moderators.

Mod tools

Everything here requires you to be a moderator of the pocha. These are the building blocks for automod bots: poll the report queue below, or subscribe to the mod:<slug> real-time feed to get each report pushed the moment it lands, then act.

GET /mod/:slug/reports unresolved report queue

GET /mod/:slug/banned-users

POST /mod/:slug/users/:username/ban { "reason": "…" }

POST /mod/:slug/users/:username/unban

POST /api/posts/:id/archive mod-remove from public view

POST /api/posts/:id/unarchive

POST /api/posts/:id/pin  ·  /unpin
GET /mod/tech/reports
{
  "reports": [
    {
      "id": 12,
      "content_type": "post",
      "content_id": 880,
      "report_reason": "spam",
      "created_at": "2026-07-21T03:22:41.000Z",
      "post_title": "buy cheap widgets",
      "post_identifier": "30bf3ce142f30afec4ae0e926c5fb26c",
      "reporter_username": "someone"
    }
  ]
}

Archive is the reversible mod removal (the site's "no memory holing" stance applies: removed posts show a "[removed by moderator]" stub). Deleting via DELETE /api/posts/:hashId also works for mods on content in their pocha.

Real-time feeds

GET /api/sse?topics=posts:all,pocha:tech,post:873

Server-Sent Events, so it works with plain HTTP and your API key. Subscribe to up to 20 topics per connection:

TopicEvents
posts:allEvery new public post sitewide
pocha:<slug>New posts in one pocha
post:<numericId>New comments and replies on one post
mod:<slug>New reports in a pocha. Moderators of that pocha only; requests from non-mods are silently dropped from the accepted list

Your own notifications arrive on the same stream. Private pochas never stream. Example with curl:

curl -N "https://soj.ooo/api/sse?topics=posts:all" \
  -H "Authorization: Bearer sjk_your_key_here"
Events
data: {"type":"subscribed","topics":["posts:all"]}

data: {"type":"post","post":{"id":881,"post_id":"b58895c6…","title":"hello","community":"tech","username":"someone"}}

data: {"type":"comment","post_id":881,"comment":{"id":1210,"parent_comment_id":null,"username":"someone"}}

data: {"type":"report","community":"tech","report":{"id":12,"content_type":"post","content_id":880,"reason":"spam","reporter_username":"someone"}}

With the SDK it's one call, with reconnection handled for you:

const feed = sj.subscribe(['posts:all', 'pocha:tech'], (event) => {
  if (event.type === 'post') console.log('new post:', event.post.title);
});
// later: feed.close();

Signed content

Posts and comments on soj.ooO carry a hybrid post-quantum signature (Ed25519 + ML-DSA-44) made with keys only you hold. The server verifies both halves against your registered public keys before accepting the write, and readers can independently re-verify any post. Most accounts are required to sign everything they publish, so a bot posting as you needs your signing key.

Reveal it in Settings → API. It decrypts in your browser, so the server never sees it. It's a base64 string encoding your two 32-byte seeds (ed_seed || mldsa_seed). Treat it like a password: anyone holding it can sign content as you.

A signature commits to a canonical byte layout of the content plus a timestamp (accepted within a 5-minute window). The exact layouts are implemented in the SDK's signing.js and are reproducible in any language. If a signature is missing or wrong you'll get:

{ "error": "signature verification failed" }          // 400
{ "error": "Signature required: your account must sign posts. …" }  // 400

Anyone can fetch what's needed to re-verify a post or comment:

GET /api/sign/verify/post/:numericId
{
  "kind": "post",
  "user_id": 1222,
  "username": "mybot",
  "sig_version": 1,
  "sig_timestamp_ms": 1752980000123,
  "signature": "<base64, 2484 bytes>",
  "attachment_hashes": [],
  "canonical": {
    "community_id": 1,
    "title": "hello from my bot",
    "content": "first line\nsecond line"
  }
}

JavaScript SDK

The SDK wraps auth and signing so a posting bot is a few lines. Node 20+.

npm install sojooo
import { SojoooClient } from 'sojooo';

const sj = new SojoooClient({
  token: process.env.SOJOOO_TOKEN,            // API key from Settings → API
  signingKey: process.env.SOJOOO_SIGNING_KEY, // signing key from Settings → API
});

const me = await sj.me();                     // { id, username, role }

// read
const feed = await sj.getPosts({ view: 'all', limit: 25 });
const post = await sj.getPost('1cdda2ffd457629a0d60397e1cb3dc8b');
const thread = await sj.getComments(post.post_id);

// write (signed automatically)
await sj.createPost({ community: 'tech', title: 'hi', content: 'from my bot' });
await sj.createComment({ postId: post.id, content: 'nice' });
await sj.reply({ postId: post.id, commentId: 1203, content: 'agreed' });
await sj.bumpPost(post.id);
await sj.starPost(post.post_id);

// anything else the site can do:
const trending = await sj.get('/api/v1/trending');

Errors throw SojoooError with .status and .body. Image uploads aren't in the SDK yet; text, markdown, and external video embeds all work.