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.
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
| Scope | Covers |
|---|---|
| site.read | Community profile and settings |
| content.read | Post reads, feeds, search and reply trees |
| content.write | Creating, editing and deleting posts |
| media.read | Signed URLs for stored media |
| media.write | Media upload tokens — also reads signed URLs, for backwards compatibility |
| spaces.read | Space directory and detail |
| members.read | Member 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.
X-API-Key: mfk_xxx
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
{
"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.
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 profile, settings and metadata
One post by id
Required: id
Up to 50 posts at once; ids that miss come back in missing_ids rather than failing the batch
Required: ids
Search posts — the field is query, not keyword
Required: query
Featured posts, optionally scoped to a space
Every reply under a post
Required: post_id
First-level replies only
Required: post_id
The sub-tree below one reply
Required: post_id
The ancestor chain of a reply
Required: post_id
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
Community feed by score (float cursor)
Featured feed (string cursor)
One space's feed, with optional sort, search and Q&A filters (string cursor)
Required: space_id
One space's feed by score (float cursor) — the legacy entry point, kept for compatibility
Required: space_id
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
Edit a post; pass version for optimistic locking
Required: actor_user_id, idempotency_key, post_id
Delete a post
Required: actor_user_id, idempotency_key, post_id
Spaces the viewer can see — omit viewer_user_id for the anonymous, publicly readable set
One space by id
Required: id
One member by id
Required: id
Resolve an exact email address to a member
Required: email
Short-lived token for the media upload service
Required: author_user_id
Signed URLs for private media (they expire — re-sign, do not store)
Required: author_user_id, items, access_level
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_idThe member a new post is published as. Must already belong to this community, otherwise 20303.
actor_user_idWho performs an edit or delete. Permission is evaluated against this member — a non-author who is not an admin gets 10004.
viewer_user_idOptional 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:
- Exchange your API key for a short-lived upload token via /media/get_token.
- 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.
- 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.
# 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.
| Code | HTTP | Meaning |
|---|---|---|
| 0 | 200 | Success |
| 10005 | 400 | Invalid parameters — msg names the offending field |
| 10007 | 401 | Key invalid, revoked or expired, or the caller IP is not on the allowlist |
| 21304 | 403 | The key lacks the scope this endpoint requires; scopes are fixed at creation |
| 22203 | 403 | The plan does not include API access — see data.required_plan |
| 22204 | 403 | Daily quota exhausted — see data.current and data.limit |
| 20303 | 404 | author_user_id / viewer_user_id is not a member of this community |
| 10003 | 404 | The target record does not exist |
| 10004 | 403 | The actor has no permission on this resource |
| 10202 | 429 | Rate limited — back off and honour the Retry-After header |
| 10001 | 500 | Server 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".
// 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.
| Layer | Counts | Over the limit |
|---|---|---|
| Plan daily quota | A whole day, all endpoints, per community | 22204 / HTTP 403 |
| Per-key burst budget | 10-second and 60-second windows, per key, tiered by endpoint | 10202 / HTTP 429 |
| General IP limit | Per source IP, 60 per 10s and 300 per minute | 10202 / 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.
| Tier | Covers | Per 10s | Per 60s |
|---|---|---|---|
| Reads | Feeds, posts, replies, spaces, introspection | 120 | 600 |
| Media | get_token and get_signed_urls | 30 | 120 |
| Writes | Post create, update and delete | 20 | 120 |
| Search | post/search | 20 | 60 |
| Member lookup | member/lookup | 5 | 10 |
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.
| Field | Identifies | Across retries |
|---|---|---|
| event_id | The business event | Stable |
| occurred_at | When the event happened | Stable |
| delivery_id | This delivery attempt | Changes |
| timestamp | When this attempt was sent | Changes |
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.
| Symptom | Usually |
|---|---|
| 10007 invalid API key, but the key was just copied from the console | Whitespace 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_available | The 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 0 | The plan's daily request quota is 0, which means unavailable — not unset and therefore unlimited |
| 21304 on an endpoint you expected to work | The 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 429 | A rate limit. Back off per Retry-After; if it was member/lookup, remember that bucket is ten a minute |
| 10005 with schedule_cancel_unsupported | You 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 working | Since September 2026 a token with no scope is upload-only. Pass a scope of read or delete |
| /member/lookup returns 200 with found false | That is a normal miss, not an error. Hits and misses are both 200 — branch on found |
| 10005 invalid params mentioning query | The search field is query. keyword is not a valid field |
| 20303 user not found | The id is not a member of this community, or you sent a numeric id instead of a hashid |
| Pagination stops advancing | The /feed/top and /space/feed/top cursors are floats. Serialized as strings they stop matching |
| Media URLs start returning 403 after a while | Signed 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