Men of Hunger Member API
The Member API lets a verified member use their own account from their own tools: a scheduler, a blog that cross-posts, a bot, a dashboard. The member creates a key in Settings and the tool acts as that member. This is not OAuth for third-party apps (that is the read-only Partner API, by approval); signing in to other people's accounts through the Member API is not supported.
Interactive reference: api.menofhunger.com/member/docs. Machine-readable OpenAPI: member/openapi.json.
Who can use it
| Free, verified | Premium | |
|---|---|---|
| Create and hold keys (up to 5) | Yes | Yes |
| Read your profile, feed, mentions, notifications, followers and following | Yes | Yes |
| Read public profiles, posts, replies, articles and article comments | Yes | Yes |
| RSS feed of your public posts and articles | Yes (no key) | Yes (no key) |
| Create, edit and delete posts and replies | No | Yes |
| Outgoing webhooks | No | Yes |
Only verified members can hold a key. Keys keep working only while the member stays verified and, for write scopes, Premium. A banned, unverified or lapsed member's keys stop working without being deleted; they work again when standing returns. There is no separate charge for keys.
Creating a key
Settings › Developer access. Give the key a public title and choose scopes. Men of Hunger shows a Client ID (mk_...) and a Client Secret (mks_...). The secret is shown once and cannot be retrieved; only a hash is stored. If you lose it, rotate it.
- Title. Shown as "via Scheduler" for a key titled Scheduler, on every post written with the key. The title is stored on the post when it is written, so renaming or deleting the key never rewrites history.
- Scopes. Choose the smallest set the tool needs. Removing a scope takes effect immediately. Adding a write scope needs Premium.
- Rotate. New secret; the old one and every token made with it stop working immediately.
- Suspend and resume. Suspending one key disconnects one tool and leaves the account and every other key alone. A key suspended by the system for abuse cannot be resumed by the member; contact support.
- Delete. Ends the key, its tokens, its webhook and the webhook's delivery history. Posts it wrote stay, with their "via" label.
Management uses the member's own signed-in session (/v1/developer/keys), never a key. A key cannot create or change keys.
Base URL and conventions
https://api.menofhunger.com/v1/member
- Success:
{ "data": ... }for one object,{ "data": [...], "pagination": { "nextCursor": "..." } }for lists. - Errors:
{ "error": { "code": "...", "message": "..." } }(see Errors). - Lists are newest first. Pass
nextCursorback ascursorunchanged;nullends the list.limitis 1 to 100, default 20. - Timestamps are ISO 8601 UTC. IDs are opaque strings.
- Responses are never cached (
Cache-Control: no-store), except the public RSS feed.
Authentication
Two steps, like OAuth client credentials. Exchange the key for tokens, then send the access token.
curl -X POST https://api.menofhunger.com/v1/member/auth/token \
-H "Content-Type: application/json" \
-d '{ "clientId": "mk_...", "clientSecret": "mks_..." }'
{
"data": {
"tokenType": "Bearer",
"accessToken": "mka_...",
"expiresIn": 3600,
"refreshToken": "mkr_...",
"refreshExpiresIn": 2592000
}
}
Send Authorization: Bearer <accessToken> on every call. Access tokens last one hour; refresh tokens last 30 days.
curl -X POST https://api.menofhunger.com/v1/member/auth/refresh \
-H "Content-Type: application/json" \
-d '{ "refreshToken": "mkr_..." }'
You receive a new pair. Refresh tokens rotate:
- Retrying the same refresh token within 30 seconds of its first use returns the same replacement, so a lost response or two workers do no harm.
- If that replacement has already been refreshed, the retry returns
409 conflictand nothing is revoked. Use the latest token. - Reusing a rotated refresh token after 30 seconds revokes the whole session (
401); exchange the Client ID and Secret again.
Rotating the secret, suspending or deleting the key, removing a scope, or the member losing standing ends all tokens. Token and refresh calls are limited to 10 per minute per key and per address. Never put secrets or tokens in client-side code or public repositories.
GET /me needs a valid token and no scope:
{
"data": {
"id": "u_...", "username": "example", "name": "Example", "bio": null, "avatarUrl": null,
"canonicalUrl": "https://menofhunger.com/u/example", "accountKind": "person",
"createdAt": "2026-09-30T12:00:00.000Z", "counts": { "followers": 4, "following": 8 },
"key": { "id": "...", "title": "Scheduler", "scopes": ["posts:read", "posts:write"] }
}
}
Scopes
Each key holds a set of scopes; a request outside them is 403 forbidden. Scopes are named resource:action so the same vocabulary can later back OAuth for third-party apps.
| Scope | Plan | Allows |
|---|---|---|
profile:read |
Free | GET /users/{username} |
posts:read |
Free | Your posts (/me/posts), public posts, replies, articles, article comments, a member's public posts and articles |
feed:read |
Free | GET /me/feed |
mentions:read |
Free | GET /me/mentions; the mention.created webhook event |
notifications:read |
Free | GET /me/notifications; the reply.created and boost.created events |
followers:read |
Free | GET /me/followers; the follow.created and friend.created events |
following:read |
Free | GET /me/following |
posts:write |
Premium | Create posts and replies, edit posts |
posts:delete |
Premium | Delete posts. Not implied by posts:write |
webhooks:manage |
Premium | Configure the webhook and read its deliveries |
Endpoints
Paths are relative to the base URL.
Your account
| Method and path | Scope |
|---|---|
GET /me |
none |
GET /me/feed |
feed:read |
GET /me/mentions |
mentions:read |
GET /me/notifications |
notifications:read |
GET /me/followers |
followers:read |
GET /me/following |
following:read |
Feed. Public top-level posts from accounts you follow, newest first, as post objects (below).
Mentions and notifications. Reading does not mark anything read. Items: { id, type, createdAt, read, actor: { id, username, name } | null, postId, canonicalUrl }. type is reply.created, mention.created, boost.created, follow.created or friend.created.
Followers and following. Profiles as in GET /me, without key. Blocked and banned accounts are left out before pagination. Start each sync without a cursor and deduplicate by id; lists are not snapshots.
Your posts
| Method and path | Scope | Notes |
|---|---|---|
GET /me/posts |
posts:read |
Your published public and verified-only posts and replies |
GET /me/posts/{id} |
posts:read |
|
POST /me/posts |
posts:write |
201. Requires Idempotency-Key |
PATCH /me/posts/{id} |
posts:write |
Requires Idempotency-Key. Only posts written with this same key |
DELETE /me/posts/{id} |
posts:delete |
Requires Idempotency-Key. Only posts written with this same key |
Create:
curl -X POST https://api.menofhunger.com/v1/member/me/posts \
-H "Authorization: Bearer <accessToken>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f8f9a1e-9c9e-4a3b-8f4d-1e2f3a4b5c6d" \
-d '{ "body": "Hello from the API", "visibility": "public" }'
| Field | Notes |
|---|---|
body |
Required, 1 to 1000 characters. Same content rules as the apps (moderation filter, mentions, hashtags, cashtags) |
replyToId |
Optional. Replies to that post; you must be allowed to see and reply to it |
visibility |
public (default) or verifiedOnly |
Unknown fields are rejected. Media, polls, groups, walls, boards, check-ins, drafts and scheduling are not available through keys.
Post object:
{
"id": "p_...", "canonicalUrl": "https://menofhunger.com/p/p_...", "body": "Hello from the API",
"visibility": "public", "replyToId": null,
"source": { "name": "Scheduler", "url": "https://menofhunger.com/p/p_..." },
"createdAt": "2026-10-21T20:00:00.000Z", "editedAt": null,
"counts": { "boosts": 0, "replies": 0 }
}
source.name is the key title captured when the post was written ("via Scheduler"); posts written in the apps show Men of Hunger. Request bodies cannot set it. Edits keep the original source.
Edits follow the app rules: 30 minutes after creation, up to 3 edits, replies cannot be edited. Deletion is a soft delete through the same path as the apps; deleting a post twice with different idempotency keys returns 404 not_found the second time. A post that was written in the app or with another key is 404 not_found for edit and delete.
Posting by key also fans out to the member's other connections exactly like a post from the app does for anything that follows the post (notifications to followers and mentioned members, realtime updates). It does not cross-post to Pickax or X.
Public content
These return what any signed-in member can see: published public posts and articles. Drafts, scheduled content, groups, Board, walls, friends-only and verified-only content, messages, and blocked or banned accounts are excluded and return 404. Single objects, lists and pagination match the Partner API content objects.
| Method and path | Scope |
|---|---|
GET /users/{username} |
profile:read |
GET /users/{username}/posts |
posts:read |
GET /users/{username}/articles |
posts:read |
GET /posts/{id} |
posts:read |
GET /posts/{id}/replies |
posts:read |
GET /articles/{id} |
posts:read |
GET /articles/{id}/comments |
posts:read |
Post objects here carry kind, bodyFormat, author, media and engagement (boosts, comments and recorded distinct signed-in viewers, filtered for blocks; not reach). Post bodies are plain text; article bodies are allowlisted HTML. source.name shows the key title for posts written by key. Usernames are display values; match accounts by id.
RSS
GET https://api.menofhunger.com/v1/public/users/{username}/feed.xml needs no key. It lists the member's last 30 published public posts and articles (RSS 2.0), is cached for 5 minutes, and is limited to 120 requests per minute per address. Replies, reposts, quotes, groups and walls are not included.
Idempotency
Every write (POST, PATCH, DELETE under /me/posts) requires an Idempotency-Key header of 1 to 200 characters after trimming; use a new UUID for each logical request. Retrying with the same key, method and path within 24 hours returns the original status and body with Idempotent-Replayed: true, and creates nothing. Rules:
- The window starts at the first request and retries do not extend it.
- The same key with a different body is
409 conflict. Object property order does not matter; array order does. - An identical request still in progress is
409 conflict; retry shortly. - Failed requests are not stored, so the key is free to retry.
- After 24 hours the key is new again and may create another copy. Save returned ids.
- Authentication, scopes, limits and the spam checks apply to retries too; a replay consumes one write request.
Rate limits
Fixed 60-second windows, separate per key:
| Bucket | Limit |
|---|---|
| Token and refresh exchanges | 10 per minute per key and per address |
Reads (GET) |
300 per minute per key |
Writes (POST, PUT, PATCH, DELETE) |
60 per minute per key |
Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds when the window rolls over). A 429 adds Retry-After in whole seconds. Attempts that fail permission or validation checks still count. Posting has further limits (see Errors).
Spam protection and posting limits
Posting by key is more automated than posting in the app, so it has its own guardrails. Each rejection counts a strike against the key.
| Check | Result |
|---|---|
| Daily cap: 50 posts per key per UTC day (5 for accounts under 7 days old) | 429 daily_post_limit |
| More than two links in a post on an account under 7 days old | 422 too_many_links |
| Same text as anything you posted in the last 24 hours, ignoring case and spacing | 422 duplicate_content |
| An open spam report against your account | 403 account_under_review |
| The app's own content, mention and audience rules | The app's 400 / 403 |
Ten strikes in one hour suspend that key (403 key_suspended, reason abuse). Your account and other keys are untouched. Per-post rules of the apps, including Premium's 1000-character limit and the post rate limits, also apply.
Errors
{ "error": { "code": "premium_required", "message": "Write access to the API needs Premium." } }
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request |
Malformed body or query, unknown field, bad cursor, missing Idempotency-Key |
| 401 | unauthorized |
Missing, invalid, expired or revoked token or credentials |
| 403 | forbidden |
The key lacks the scope, or the action is not allowed |
| 403 | verification_required |
The member is not (or no longer) verified |
| 403 | premium_required |
A write scope or webhook was used without Premium |
| 403 | key_suspended |
The key is suspended |
| 403 | account_under_review |
Posting by API is paused while an account review is open |
| 404 | not_found |
Missing, not visible to you, or not written with this key |
| 409 | conflict |
Idempotency or refresh-token conflict, or an unavailable retry |
| 422 | unprocessable_entity |
Valid shape, unacceptable value (for example a webhook URL) |
| 422 | key_limit, too_many_links, duplicate_content |
See above |
| 429 | daily_post_limit |
Daily post cap reached |
| 429 | rate_limit_exceeded |
Over a per-minute limit; honour Retry-After |
| 500 | internal_error |
Something went wrong on our side |
Always switch on code, not message. Clients should tolerate new codes and new response fields.
Webhooks
Each key can have one HTTPS receiver. It needs webhooks:manage and Premium, plus the read scope for each event it subscribes to, so a webhook never reveals more than the API would.
PUT /webhook (full replace; enabled defaults to true):
{ "url": "https://example.com/moh/webhook", "events": ["reply.created", "mention.created"], "enabled": true }
The first call returns the configuration plus signingSecret (whsec_...) once. It is separate from the client secret. Later calls return the configuration without it. The URL must be public HTTPS on port 443 with no credentials; private, loopback and link-local addresses are refused, DNS is resolved and pinned on every attempt, and redirects are not followed.
| Event | Needs scope | Sent when |
|---|---|---|
reply.created |
notifications:read |
Someone replies to your post |
mention.created |
mentions:read |
Someone mentions you |
boost.created |
notifications:read |
Someone boosts your post |
follow.created |
followers:read |
Someone follows you |
friend.created |
followers:read |
A friendship with you starts |
Events about posts are sent only when the post is public. Friends-only, group and wall activity is never delivered.
{
"id": "evt_...", "type": "reply.created", "version": 1,
"occurredAt": "2026-10-21T20:00:00.000Z", "accountId": "u_...", "apiKeyId": "k_...",
"data": {
"postId": "p_102", "parentPostId": "p_100",
"author": { "id": "u_9", "username": "reader" },
"content": "A reply", "canonicalUrl": "https://menofhunger.com/p/p_102"
}
}
data by type: mention.created has postId, author, content, canonicalUrl. boost.created has postId, booster, canonicalUrl. follow.created has follower. friend.created has friend. Authors are { id, username }. Content is plain text, at most 1000 characters.
Verify the X-MOH-Signature header before parsing: t=UNIX_SECONDS,v1=HEX. Sign the timestamp, a dot and the exact raw body with HMAC-SHA256 using the full signing secret; compare in constant time; reject timestamps more than five minutes from your clock. X-MOH-Event-Id equals the event id.
import { createHmac, timingSafeEqual } from 'node:crypto';
const match = signatureHeader.match(/^t=(\d+),v1=([a-f0-9]{64})$/);
if (!match || Math.abs(Date.now() / 1000 - Number(match[1])) > 300) throw new Error('Invalid webhook');
const expected = createHmac('sha256', signingSecret).update(match[1] + '.').update(rawBody).digest();
if (!timingSafeEqual(expected, Buffer.from(match[2], 'hex'))) throw new Error('Invalid webhook');
const event = JSON.parse(rawBody.toString('utf8'));
Delivery is at least once and may arrive out of order: deduplicate by event id and use occurredAt to order. Return any 2xx within 10 seconds. Failures retry up to eight attempts, waiting 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. Retries keep the original id and body with a fresh signature timestamp. There is no historical backfill; use the read endpoints for initial sync.
| Method and path | Purpose |
|---|---|
GET /webhook |
Configuration, or null. Never includes the secret |
PUT /webhook |
Create or replace. Cancels outstanding deliveries |
DELETE /webhook |
Remove it and its history |
POST /webhook/rotate-secret |
New secret, returned once. Cancels outstanding deliveries |
GET /webhook/deliveries |
{ id, type, status, attempts, lastStatus, lastError, createdAt, nextAttemptAt }, never payloads. Kept seven days after finishing |
POST /webhook/deliveries/{id}/retry |
Restart a failed delivery. 409 unless it failed and the webhook is enabled |
Status is pending, sending, delivered, failed or cancelled. Before every send the key, its scopes, the receiver, the member's standing and Premium are checked again; a delivery that no longer qualifies is cancelled. A request already in flight cannot be recalled. Management calls use the normal read and write limits and need no Idempotency-Key.
What is not here
Sign-in for third-party apps (OAuth), media upload, polls, drafts and scheduling, groups and channels, direct messages, reactions, follows, profile edits, and billing are not available through keys. Admin and moderation actions never are. New capabilities arrive as new scopes; existing keys gain nothing automatically.