Skip to content
Developers

Community Open API

A site-scoped API: one key is bound to one community and acts on behalf of the members you name. It is not the creator dashboard API and not the member API — there is no login session here.

Download openapi.jsonContract published September 2026

Before you start

Two things have to be true before any key works, and neither is something you can fix from your own code.

1. The community's plan has to include API access

The community owner controls this, not the integrator. The plan needs both the API access feature — otherwise every call returns 22203 — and a non-zero daily quota, otherwise every call returns 22204.

Communities created a while ago have API access switched off by default, even when the plan already includes other advanced features. If a freshly issued key returns 22203 on every call, ask the owner to check the console rather than re-reading your code.

You do not have to discover this by collecting errors: /capabilities/get answers it directly, and still works in both of those states.

2. Create a key in the community admin console

Community admin → Integrations → API Keys → Create. A key is mfk_ followed by 64 hex characters, and the full value is shown exactly once — afterwards only the prefix is visible.

The scopes you tick at creation decide which endpoints the key can call. They cannot be edited afterwards; to change them, create a new key and revoke the old one.

Optionally restrict a key to an IP allowlist (single addresses or CIDR ranges). Requests from anywhere else fail with 10007 — the same code as an invalid key, which is worth remembering when a key that worked yesterday stops working from a new host.

Scopes

ScopeCovers
site.readCommunity profile and settings
content.readPost reads, feeds, search and reply trees
content.writeCreating, editing and deleting posts
media.readSigned URLs for stored media
media.writeMedia upload tokens — also reads signed URLs, for backwards compatibility
spaces.readSpace directory and detail
members.readMember reads and email lookup

What * means changed in September 2026

It used to mean "everything, including scopes added later", which quietly widened every key ever issued each time the platform shipped a new capability. Now a new key stores the concrete list in force when it was created, and a legacy * covers only the four scopes that existed when it was signed: site.read, content.read, content.write and media.write.

So an older key cannot call the newer endpoints — spaces, members and signed-URL reads. Issue a fresh key with those scopes ticked. This is deliberate: nobody should hand over their member directory because the platform shipped a new feature.

Authentication

Send your key in one of two headers. They are equivalent; X-API-Key wins if you send both. The community is derived from the key, so you never pass a site id.

Option 1: X-API-Key header (recommended)
X-API-Key: mfk_xxx
Option 2: Authorization Bearer header
Authorization: Bearer mfk_xxx

The key is the credential on its own — keep it server-side. It belongs in neither browser code nor a mobile bundle, where anyone can read it out and post as your members.

Conventions

These hold for every endpoint below.

  • Every endpoint is POST. There is no GET, PUT or DELETE — reads are POST too.
  • Send Content-Type: application/json. Endpoints that take no parameters still expect an empty JSON body.
  • Every id is a hashid string such as "kZ3mQ9x" — community, user, post, space and media alike. Numeric ids are rejected.
  • Timestamps come back as RFC3339, for example 2026-08-18T10:00:00Z.
  • Monetary amounts are integers in the smallest currency unit (cents).
  • An optional Accept-Language header (zh, en, ja, ko, es, fr, de, pt) localizes error messages.
  • The community is derived from your API key. Do not send a site id in the path or the body.

One input breaks that rule: scheduled_at on /post/create and /post/update is an i64 Unix timestamp in seconds, not a string. See scheduled posts below.

Success and failure share one envelope

JSON
{
  "code": 0,
  "msg": "success",
  "data": { }
}

Endpoints

All endpoints live under /site_open_api/v1, use POST with a JSON body, and require the listed scope on your key.

Introspection
POST/capabilities/getno scope

Your key's scopes, whether API access is on, and quota used, remaining and reset time. The only endpoint that still answers when the plan gate or the daily quota is blocking everything else — and it consumes no quota itself. A quota_limit of -1 means unlimited; the three quota fields appear and disappear together, so check one exists before subtracting.

Community
POST/site/getsite.read

Community profile, settings and metadata

Reading posts
POST/post/getcontent.read

One post by id

Required: id

POST/post/batch_getcontent.read

Up to 50 posts at once; ids that miss come back in missing_ids rather than failing the batch

Required: ids

POST/post/searchcontent.read

Search posts — the field is query, not keyword

Required: query

POST/post/featured_postscontent.read

Featured posts, optionally scoped to a space

POST/post/replies/listcontent.read

Every reply under a post

Required: post_id

POST/post/direct_replies/listcontent.read

First-level replies only

Required: post_id

POST/post/reply_descendants/listcontent.read

The sub-tree below one reply

Required: post_id

POST/post/thread_chaincontent.read

The ancestor chain of a reply

Required: post_id

Feeds
POST/feed/listcontent.read

Community feed, newest first (string cursor). since_id pulls only newer posts — it cannot see edits or deletions, so it tops up a timeline rather than syncing one

POST/feed/topcontent.read

Community feed by score (float cursor)

POST/feed/featuredcontent.read

Featured feed (string cursor)

POST/space/feed/listcontent.read

One space's feed, with optional sort, search and Q&A filters (string cursor)

Required: space_id

POST/space/feed/topcontent.read

One space's feed by score (float cursor) — the legacy entry point, kept for compatibility

Required: space_id

Writing posts
POST/post/createcontent.write

Publish a post as a member. Body, space, title, reply and quote targets, media, polls, audio and scheduling are all optional

Required: author_user_id, idempotency_key

POST/post/updatecontent.write

Edit a post; pass version for optimistic locking

Required: actor_user_id, idempotency_key, post_id

POST/post/deletecontent.write

Delete a post

Required: actor_user_id, idempotency_key, post_id

Spaces
POST/space/listspaces.read

Spaces the viewer can see — omit viewer_user_id for the anonymous, publicly readable set

POST/space/getspaces.read

One space by id

Required: id

Members
POST/member/getmembers.read

One member by id

Required: id

POST/member/lookupmembers.read

Resolve an exact email address to a member

Required: email

Media
POST/media/get_tokenmedia.write

Short-lived token for the media upload service

Required: author_user_id

POST/media/get_signed_urlsmedia.read | media.write

Signed URLs for private media (they expire — re-sign, do not store)

Required: author_user_id, items, access_level

cURL
curl -X POST https://api.mateflow.com/site_open_api/v1/capabilities/get \
  -H "X-API-Key: mfk_xxx" -H "Content-Type: application/json" -d '{}'

# → data: {
#     "api_version": "v1",
#     "site_id": "kZ3mQ9x",
#     "scopes": ["site.read", "content.read"],
#     "api_access": true,
#     "quota_limit": 5000,     // -1 means unlimited
#     "quota_used": 128,
#     "quota_reset_at": "2026-09-15T00:00:00Z"
#   }
  • Reads that take a list of ids cap at 50, and every paginated endpoint caps limit at 50.
  • On /space/feed/list the cursor encoding follows sort, so switching sort means restarting pagination. Q&A spaces ignore sort and order by qa_sort instead. New integrations should use /space/feed/list with sort=top rather than /space/feed/top.
  • Member responses are a narrow projection: id, username, display_name, avatar_url, status, role and created_at. The email is never returned, even when you looked the member up by it — you already hold that address, and echoing every member's back would turn /member/get into a contact export. Lookup is exact-match only, and a miss is a normal success, not an error, so hits and misses are indistinguishable by status code or by timing. It also has the tightest rate limit on the API.

Acting on behalf of a member

The API has no login session, so who acts — and whose view results are rendered for — is always explicit in the request body. All three take a member hashid.

  • author_user_id

    The member a new post is published as. Must already belong to this community, otherwise 20303.

  • actor_user_id

    Who performs an edit or delete. Permission is evaluated against this member — a non-author who is not an admin gets 10004.

  • viewer_user_id

    Optional on reads. Returns results as that member sees them: private spaces, like and bookmark state. Omit it for the anonymous, public-only view.

Pagination

  • Feeds and lists return their items plus next_cursor. Send next_cursor back untouched to fetch the next page; an empty next_cursor means you reached the end.
  • limit caps out at 50 everywhere.
  • On /space/feed/list the cursor encodes the current sort. Changing sort mid-listing invalidates the cursor — reset to the first page.

Careful: the /feed/top and /space/feed/top cursors are floating-point scores, not strings. Holding both kinds in one string variable breaks paging silently.

Idempotency

Every write takes an idempotency_key in the request body. There is exactly one layer of it here — see the note below if you already know the platform's header-based one.

  • idempotency_key is required on every write and capped at 190 characters. Reusing one never creates a second post.
  • Its scope is the community, the request type and the key together, independent of which API key you used: two different keys on the same community sending the same idempotency_key to the same endpoint means the second call replays the first result.
  • The same key with a different body returns a stable conflict error rather than silently passing as a successful retry.
  • When a request times out, retry with the same key — never generate a fresh one.

The platform's general Idempotency-Key request header — the one with the 24-hour response cache — does not apply to /site_open_api/**. That layer sits in front of API key authentication, so a cache hit would skip verifying the key entirely. Do not send the header expecting it to do anything here.

Scheduled posts

Scheduling is the one place where both the input format and the behaviour differ from everything else.

  • scheduled_at is an i64 Unix timestamp in seconds — not the RFC3339 string every other timestamp uses.
  • On /post/update, omitting scheduled_at leaves the existing schedule alone. It is not a clear.

Passing a value of 0 or less cancels the schedule and returns the post to draft — but only blog posts have a draft state. On an ordinary post the same call returns 10005 with schedule_cancel_unsupported, because the underlying path there is "publish now": accepting it would push the content live early, fire the feed and the notifications, and leave nothing to undo.

Uploading media

Files go to the media service, not to the API host. Three steps:

  1. Exchange your API key for a short-lived upload token via /media/get_token.
  2. POST the file as multipart to the media service, with the token in a header literally named "token" and file_cate set to one of Media, Avatar, Header, Audio or File.
  3. Pass the returned media id in media_ids when you create the post.

Breaking change, September 2026

A token issued without an explicit scope now carries upload permission only; it used to carry upload, read and delete together. If you used that token to read or delete, pass a scope of read or delete explicitly. Separately, a community in its grace period — trial expired, no card on file — is refused an upload token by the plan gate; read and delete tokens are unaffected.

cURL
# 1. Exchange the API key for a short-lived upload token.
#    Since 2026-09 a token with no explicit scope is upload-only.
curl -X POST https://api.mateflow.com/site_open_api/v1/media/get_token \
  -H "X-API-Key: mfk_xxx" -H "Content-Type: application/json" \
  -d '{"author_user_id": "kZ3mQ9x", "scope": "upload"}'

# 2. Upload the file to the media service (token goes in a "token" header)
curl -X POST https://media.mateflow.com/api/v1/media/upload \
  -H "token: <token from step 1>" \
  -F "file_cate=Media" -F "file=@photo.jpg"

# 3. Attach the media id when creating the post
curl -X POST https://api.mateflow.com/site_open_api/v1/post/create \
  -H "X-API-Key: mfk_xxx" -H "Content-Type: application/json" \
  -d '{"author_user_id":"kZ3mQ9x","idempotency_key":"6f1c...","body":"Hi","media_ids":["m8Yq2Lp"]}'

Private media is read through /media/get_signed_urls, which takes the items and an access_level of 1 (public), 2 (semi-private) or 3 (private) and returns URLs with an expiry. Request them on demand instead of storing them, or they will start returning 403.

Attaching media to a post

  • Send either medias — a list of media_id and alt pairs — or media_ids, a plain list of ids. Sending both returns 10005.
  • alt is optional. An empty string clears the existing alt text; omitting the field leaves it as it was.

Error codes

Success and failure share the envelope, and the HTTP status tracks the business code. Branch on code, not on the status.

CodeHTTPMeaning
0200Success
10005400Invalid parameters — msg names the offending field
10007401Key invalid, revoked or expired, or the caller IP is not on the allowlist
21304403The key lacks the scope this endpoint requires; scopes are fixed at creation
22203403The plan does not include API access — see data.required_plan
22204403Daily quota exhausted — see data.current and data.limit
20303404author_user_id / viewer_user_id is not a member of this community
10003404The target record does not exist
10004403The actor has no permission on this resource
10202429Rate limited — back off and honour the Retry-After header
10001500Server error — safe to retry

Note the trap: 22203 and 22204 are 403, not 429. Only 10202 is throttling. Detect it by code, never by HTTP status — this is the single most common misread of this API.

Error responses carry a request_id. Quote it when you report a problem — it is how we find your exact call in the logs.

Plan errors carry structured metadata

Both plan errors come back with enough detail to tell the community owner what to do, so you can surface a real message instead of "something went wrong".

JSON
// The plan does not include API access
{
  "code": 22203,
  "msg": "...",
  "request_id": "00de5640-8ae5-4a71-aef8-2f2cab0ce8a7",
  "data": {
    "error_code": "feature_not_available",
    "feature": "api_access",
    "required_plan": "Growth"
  }
}

// The community is out of daily quota
{
  "code": 22204,
  "data": {
    "error_code": "quota_exceeded",
    "resource": "api_requests_per_day",
    "current": "5001",
    "limit": "5000",
    "required_plan": "Business"
  }
}

Quota and rate limits

Three separate mechanisms. Mixing them up is why an integration is reported as rate limited when it has actually run out of plan quota.

LayerCountsOver the limit
Plan daily quotaA whole day, all endpoints, per community22204 / HTTP 403
Per-key burst budget10-second and 60-second windows, per key, tiered by endpoint10202 / HTTP 429
General IP limitPer source IP, 60 per 10s and 300 per minute10202 / HTTP 429

The daily quota counts requests that pass authentication, resets at 00:00 UTC, and reports the ceiling actually in force in data.limit — including any allowance raised for that community specifically. When the counter's backend misbehaves the request is allowed through rather than rejected, so an occasional undercount does not mean the quota stopped working.

Per-key burst budget (new in September 2026)

Previously the only bucket was per IP, so several customers behind one egress address competed with each other while a caller spread across many addresses was barely constrained. The budget is now tiered per key — the thing a community owner issues, can rotate, and is accountable for.

TierCoversPer 10sPer 60s
ReadsFeeds, posts, replies, spaces, introspection120600
Mediaget_token and get_signed_urls30120
WritesPost create, update and delete20120
Searchpost/search2060
Member lookupmember/lookup510

member/lookup is much tighter for security rather than capacity. Its other constraints limit how much a single answer reveals; this one limits how many times you can ask — and that is the step that turns lookup into directory harvesting. Used as designed, resolving addresses you already hold at human pace, ten a minute is plenty.

  • Going over returns HTTP 429 with code 10202, plus Retry-After and the X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.
  • Treat these numbers as a starting point, not a measurement — they are tuned from observed traffic. Do not hardcode them into retry logic; honour 429 and Retry-After instead. The rate-limiting layer is off by default and runs in observation mode for a while before it is enforced.
  • Implement 429 backoff regardless, and keep read traffic at or below roughly 10 requests per second.

Receiving webhooks

If the community owner has configured a webhook endpoint or connected Slack, Discord or Zapier, deliveries carry two sets of ids that mean different things.

FieldIdentifiesAcross retries
event_idThe business eventStable
occurred_atWhen the event happenedStable
delivery_idThis delivery attemptChanges
timestampWhen this attempt was sentChanges

De-duplicate on event_id. A delivery you processed successfully but answered too slowly arrives again with a new delivery_id and the same event_id. When one event goes to both a custom webhook and a Zap, both sides see the same event_id, so you can line them up.

Delivery is at-least-once and may be out of order. Duplicates and late arrivals of older events are both normal; the receiver has to be idempotent.

event_id can be absent. Jobs queued before the field shipped do not carry it, and the delivery omits the field entirely rather than sending 0 — a 0 would make every old job look like the same event. Accept it missing, and fall back to best-effort de-duplication on delivery_id.

Troubleshooting

The symptoms that actually come in, and what they usually turn out to be.

SymptomUsually
10007 invalid API key, but the key was just copied from the consoleWhitespace or a newline around the key; or the key was revoked; or an IP allowlist is set and your egress address is not on it
22203 feature_not_availableThe plan has API access off. After the owner changes it, allow up to 10 minutes for the plan cache to expire
22204 quota_exceeded with a limit of 0The plan's daily request quota is 0, which means unavailable — not unset and therefore unlimited
21304 on an endpoint you expected to workThe key lacks that scope. Scopes are fixed at creation, so the fix is a new key
21304 on a key that has all permissions (*)A legacy * does not cover the scopes added later — media.read, spaces.read, members.read. Issue a new key with them ticked
10202 with HTTP 429A rate limit. Back off per Retry-After; if it was member/lookup, remember that bucket is ten a minute
10005 with schedule_cancel_unsupportedYou passed a scheduled_at of 0 or less on an ordinary post. Only blog posts have a draft state to return to
A media token that used to read or delete stopped workingSince September 2026 a token with no scope is upload-only. Pass a scope of read or delete
/member/lookup returns 200 with found falseThat is a normal miss, not an error. Hits and misses are both 200 — branch on found
10005 invalid params mentioning queryThe search field is query. keyword is not a valid field
20303 user not foundThe id is not a member of this community, or you sent a numeric id instead of a hashid
Pagination stops advancingThe /feed/top and /space/feed/top cursors are floats. Serialized as strings they stop matching
Media URLs start returning 403 after a whileSigned URLs have a TTL. Re-sign on demand instead of storing them

Notes

  • The community is derived from your API key. Do not send a site id in the path or the body.
  • Scopes are fixed when the key is created. To change them, create a new key and revoke the old one.
  • Keep your API keys server-side. A key in client code lets anyone who reads it post as your members.
  • The OpenAPI contract covers every path and schema. One caveat: the generator it comes from does not emit required on request bodies, so take required fields from this page rather than from the spec.

Start building

Create a scoped key in your community admin console, call /capabilities/get to confirm it, and go.

14-day free trial · No credit card required

Start free trial