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.
- Go to Settings → API and click Generate Token. That's your API key (
sjk_…). Copy it right away because it's shown exactly once. - If your bot will post or comment, also click Reveal Signing Key on the same page (see Signed content).
- 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"{
"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" }| Status | Meaning |
|---|---|
| 400 | Bad input, including a missing or invalid signature |
| 401 | Missing, invalid, or revoked API key |
| 403 | Not allowed (bans, blocks, private pochas, mod-only posting) |
| 404 | Not found |
| 429 | Rate 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
The main feed. view=all is the firehose; authenticated accounts also get their personalized views. Page with limit / offset.
{
"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
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"{
"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
}
}Pochas
{
"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
}
}Search
Searches posts and pochas in one call.
{
"communities": [
{ "id": 7, "slug": "hello-world", "name": "hello-world" }
],
"posts": [
{ "id": 873, "post_id": "1cdda2ff…", "title": "hello from my bot", "username": "mybot" }
]
}Create a post
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.
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
}'{
"message": "Post created successfully",
"redirect": "/p/tech/post/1cdda2ffd457629a0d60397e1cb3dc8b"
}Comment & reply
Both take the post's numeric postId; reply additionally takes the parent commentId. Same signature fields as posts.
{
"postId": 873,
"commentId": 1203,
"content": "thanks!",
"signature": "<base64>",
"sig_version": 1,
"sig_timestamp_ms": 1752980000123
}{
"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
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:
{ "option_id": 7 }{
"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
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
{
"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" }'Pocha membership
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
}'{
"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
{ "count": 3 }A polling bot should watch /count, pull /api/notifications when it changes, then /mark. Poll gently (once a minute is plenty).
Reports
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.
{
"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
Server-Sent Events, so it works with plain HTTP and your API key. Subscribe to up to 20 topics per connection:
| Topic | Events |
|---|---|
| posts:all | Every 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"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. …" } // 400Anyone can fetch what's needed to re-verify a post or comment:
{
"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 sojoooimport { 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.
Comments
The comment tree for a post (by public hash id). Replies nest under
children.