Draft extension of the standard Mastodon REST API for Bonfire groups and topics.
All new endpoints live under /api/v1-bonfire/ to distinguish them from the standard Mastodon v1/v2 API.
Design Principles
- Groups are Accounts. A Bonfire
Categorywithtype: :grouportype: :topicis an ActivityPub actor and is served as a standard MastodonAccountobject. This means groups can appear as theaccount(creator/subject) on aStatusor any other activity without special-casing on the client.Account.display_namemaps toCategory.name;Account.notemaps toCategory.summary. groupis an extension field. Just asEventextendsStatusby adding aneventfield,GroupextendsAccountby adding agroupfield. Thegroupfield only contains fields that don't already exist onAccount.- IDs are shared. The group's
Account.idis the same ULID as the underlyingCategory. Bonfire resolves IDs through our pointer table, so the same ID routes correctly to the category schema without any prefix. - Depth is opt-in. Full nested
sub_groupsandparent_groupobjects are not included by default. Callers request them via depth params. A flatparent_group_idis always present. - Follow and membership are partially decoupled. Following a group subscribes to its feed in the home timeline. Membership grants access rights (posting, private content). Joining automatically follows, and leaving automatically unfollows, but each can be adjusted independently afterwards.
- Group timeline = account statuses. Since a group is an account with the same ID, use the standard
GET /api/v1/accounts/:id/statusesto fetch its feed. No duplicate endpoint needed.
The group Extension Object
Attached to any Account that represents a group or topic as account.group.
Fields already present on Account are not duplicated here (name → display_name, summary → note, canonical_url → url, username → username).
{
"type": "group",
"join_mode": "free",
"members_count": 0,
"is_disabled": false,
"extra_info": null,
"parent_group_id": null,
"parent_group": null,
"sub_groups": []
}| Field | Type | Always present | Description |
|---|---|---|---|
type | "group" | "topic" | "label" | yes | Maps to Bonfire.Classify.Category.type |
join_mode | "free" | "request" | "invite" | yes | How new members join. Also reflected on Account.locked (locked: true when not "free") |
members_count | integer | yes | Number of members. Distinct from Account.followers_count when follow/membership are decoupled |
is_disabled | boolean | yes | Whether the group has been soft-disabled. Maps to Category.is_disabled |
extra_info | object | null | yes | Freeform JSON metadata. Maps to Category ExtraInfo mixin |
parent_group_id | string | null | yes | ULID of the parent category. Always present without requiring a depth param |
parent_group | Account | null | conditional | Full parent Account with its own group field. Only populated when ?parent_depth >= 1 |
sub_groups | Array\<Account> | conditional | Direct child Accounts, each with their own group field. Only populated when ?sub_depth >= 1. Empty array otherwise |
Example — full Account with group extension
{
"id": "01JPXYZ...",
"username": "cooking",
"acct": "cooking@social.example",
"display_name": "Cooking",
"note": "<p>All things food and drink.</p>",
"url": "https://social.example/@cooking",
"uri": "https://social.example/groups/cooking",
"avatar": "https://social.example/media/cooking-avatar.jpg",
"avatar_static": "https://social.example/media/cooking-avatar.jpg",
"header": "https://social.example/media/cooking-header.jpg",
"header_static": "https://social.example/media/cooking-header.jpg",
"locked": false,
"created_at": "2024-01-15T10:00:00.000Z",
"followers_count": 128,
"following_count": 0,
"statuses_count": 342,
"emojis": [],
"fields": [],
"group": {
"type": "group",
"join_mode": "free",
"is_disabled": false,
"extra_info": null,
"parent_group_id": null,
"parent_group": null,
"sub_groups": [
{
"id": "01JPXYZ...",
"username": "baking",
"acct": "baking@social.example",
"display_name": "Baking",
"note": "<p>Bread, pastry, and everything baked.</p>",
"url": "https://social.example/@baking",
"locked": false,
"group": {
"type": "topic",
"join_mode": "free",
"is_disabled": false,
"extra_info": null,
"parent_group_id": "01JPXYZ...",
"parent_group": null,
"sub_groups": []
}
}
]
}
}Endpoints
GET /api/v1-bonfire/groups
List groups (and optionally topics/labels).
Authentication: optional (public groups visible to all; private groups only to members)
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
type | "group" | "topic" | "label" | "group" | Filter by category type |
top_level | boolean | true | When true, only return root groups (no parent). Mutually exclusive with parent_id |
parent_id | string | — | Only return direct children of this group/topic |
sub_depth | integer | 0 | How many levels of sub_groups to nest (0 = empty array). Use with top_level=true to get a tree: ?top_level=true&sub_depth=2 returns roots with 2 levels of children embedded |
parent_depth | integer | 0 | How many ancestor levels to include on parent_group (0 = null) |
max_id | string | — | Return results older than this ID |
since_id | string | — | Return results newer than this ID |
min_id | string | — | Return results immediately newer than this ID |
limit | integer | 20 | Max results (max: 80) |
Response: 200 OK — Array<Account> (each with group field)
Includes Link header for pagination (same RFC 5988 format as Mastodon timelines).
GET /api/v1-bonfire/groups/:id
Get a single group or topic by ID (ULID) or username.
Authentication: optional
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
sub_depth | integer | 1 | Levels of sub-groups to nest |
parent_depth | integer | 1 | Levels of parent chain to include |
Response:
200 OK—Accountwithgroupfield404 Not Found— not found or not readable by current user
GET /api/v1-bonfire/groups/:id/members
List members of a group.
Authentication: optional (may require membership for private groups)
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
role | "member" | "moderator" | "admin" | — | Filter by role. Omit to return all members. Use ?role=moderator or ?role=admin to list moderators/admins. |
max_id, since_id, min_id, limit | — | — | Standard pagination |
Response: 200 OK — Array<{ account: Account, relationship: Relationship }>. Each entry pairs the standard Account object with its Relationship so relationship.group.role is consistent with every other endpoint that returns Relationships.
[
{
"account": {
"id": "01JPXYZ...",
"username": "alice",
"acct": "alice@social.example",
"display_name": "Alice"
},
"relationship": {
"id": "01JPXYZ...",
"following": true,
"group": {
"member": true,
"role": "admin"
}
}
}
]Includes Link header for pagination.
GET /api/v1-bonfire/accounts/:id/groups
List groups that an account is a member of.
Authentication: optional
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
type | "group" | "topic" | "label" | — | Filter by type (all types returned if omitted) |
sub_depth | integer | 0 | Levels of sub-groups to nest |
parent_depth | integer | 0 | Levels of parent chain to include |
max_id, since_id, min_id, limit | — | — | Standard pagination |
Response: 200 OK — Array<Account> (each with group field)
Includes Link header for pagination.
Extended Relationship Object
When GET /api/v1/accounts/relationships?id[]=:group_id is called for an account that is a group, the standard Mastodon Relationship object gains an extra group field. following retains its standard meaning (subscribed to the group's posts in home feed) and is independent from group.member.
{
"id": "01JPXYZ...",
"following": true,
"showing_reblogs": true,
"notifying": false,
"followed_by": false,
"blocking": false,
"muting": false,
"requested": false,
"note": "",
"group": {
"member": true,
"role": "member"
}
}| Field | Type | Description |
|---|---|---|
requested | boolean | Standard Mastodon field. For groups with join_mode: "request", true means a join request is pending approval — same semantics as a follow request on a locked account |
group.member | boolean | Whether the current user is a member, regardless of follow state |
group.role | "member" | "moderator" | "admin" | null | Current user's role within the group. null if not a member |
No new endpoint needed — this is purely an extension of the existing relationships response.
POST /api/v1-bonfire/groups/:id/join
Join a group, or request to join if join_mode is "request".
Authentication: required
Response: 200 OK — updated Relationship
If join_mode is "free":
{
"id": "01JPXYZ...",
"following": true,
"requested": false,
...
"group": {
"member": true,
"role": "member"
}
}If join_mode is "request" (pending approval):
{
"id": "01JPXYZ...",
"following": false,
"requested": true,
...
"group": {
"member": false,
"role": null
}
}Note: Joining with join_mode: "free" also automatically sets following: true. The user can later unfollow the group feed without leaving by calling POST /api/v1/accounts/:id/unfollow.
Error responses:
404 Not Found— group does not exist or is not visible to current user
POST /api/v1-bonfire/groups/:id/leave
Leave a group, or cancel a pending join request.
Authentication: required
Response: 200 OK — updated Relationship
{
"id": "01JPXYZ...",
"following": false,
"requested": false,
...
"group": {
"member": false,
"role": null
}
}Note: Leaving also automatically sets following: false. The user can later re-follow the group feed without rejoining (if posts are visible to non-members) by calling POST /api/v1/accounts/:id/follow.
Error responses:
404 Not Found— group does not exist or is not visible to current user
Posting a Status — Extension Parameters
The standard POST /api/v1/statuses accepts the following additional parameters.
Context
| Param | Type | Description |
|---|---|---|
context_id | string | ULID of the context this post belongs to — a group/topic Account, a thread root Status, or any other container. Bonfire routes it accordingly (e.g. boosts the post into a group's feed). Complements in_reply_to_id: use in_reply_to_id to reply to a specific post, context_id to post within a context. |
Visibility
visibility works exactly as Mastodon defines it (i.e. sets the visibility of the posted status to public, unlisted, private, or direct), but Bonfire provides extra visibility options, and the defaults can also be extended by each user or group, so you can use GET /api/v1-bonfire/boundaries to get options available for the authenticated user (eg. for a new post).
In a later version: You can also use
GET /api/v1-bonfire/boundaries?context=<post|user|instance|group>to get possible options for different contexts.
When posting a status with context_id being a group, it is recommended to omit visibility so it will use the group's configured default.
In a later version: If provided along with
context_id, it must be a value permitted by the group — useGET /api/v1-bonfire/boundaries?context=<group_id>to discover what is allowed for that group before composing.
Interaction policies
Modeled after Mastodon's quote_approval_policy field and QuoteApproval entity. Each interaction has policy fields controlling automatic approval, manual approval (request where supported), and explicit denial. When context_id is a group, omit all policy fields to use the group's configured defaults. Ignored when visibility is direct.
The available policies and their permitted values depend on the context — use GET /api/v1-bonfire/boundaries?context=... to discover what applies before composing. The params listed below are the known set; not all are supported in every context:
| Param | Type | Description |
|---|---|---|
reply_approval_policy | String (Enumerable, oneOf) | Who may reply without requiring approval. members restricts to members of the context_id group. |
reply_denied_policy | Same format as _approval_policy (keywords or account IDs) | Who is explicitly denied from replying regardless of reply_approval_policy. |
announce_approval_policy | String (Enumerable, oneOf) | Who may boost without requiring approval. |
announce_denied_policy | Same format as _approval_policy (keywords or account IDs) | Who is explicitly denied from boosting. |
like_approval_policy | String (Enumerable, oneOf) | Who may react. |
like_denied_policy | Same format as _approval_policy (keywords or account IDs) | Who is explicitly denied from reacting. |
quote_approval_policy | String (Enumerable, oneOf) | Standard Mastodon field — who may quote without requiring approval. |
quote_manual_approval_policy | String (Enumerable, oneOf) | Who may quote subject to author's manual approval. |
quote_denied_policy | Same format as _approval_policy (keywords or account IDs) | Who is explicitly denied from quoting. |
All field types accept the same value format: one or more keywords or account IDs. Available keywords are provided by GET /api/v1-bonfire/boundaries as they can be extended by the server or group. In a future version, circle IDs will also be supported.
The Status object — extension fields
Extension fields present on Status responses when applicable. The *_approval objects follow the same shape as Mastodon's QuoteApproval entity, extended to all interaction types.
{
"context_id": "01JPXYZ...",
"context_type": "group",
"quote_approval": {
"automatic": ["followers"],
"manual": ["public"],
"current_user": "automatic"
},
"reply_approval": {
"automatic": ["followers", "mentioned"],
"manual": ["public"],
"current_user": "denied"
},
"announce_approval": {
"automatic": ["followers"],
"manual": [],
"current_user": "manual"
},
"like_approval": {
"automatic": ["public"],
"current_user": "automatic"
}
}| Field | Type | Description |
|---|---|---|
context_id | string | null | ULID of the context this status was posted into |
context_type | "group" | "topic" | "thread" | null | Type of the context object, so clients don't need to resolve context_id to know what kind of thing it is |
quote_approval | QuoteApproval | null | Standard Mastodon QuoteApproval entity — who may quote and how it applies to the requesting user |
reply_approval | QuoteApproval | null | Same shape as QuoteApproval — who may reply and the requesting user's effective permission |
announce_approval | QuoteApproval | null | Same shape — who may boost. No manual field as request-based approval is not supported for boosts |
like_approval | QuoteApproval | null | Same shape — who may react. No manual field |
current_user on each approval object is one of "automatic", "manual", "denied", or "unknown".
Boundaries Discovery
GET /api/v1-bonfire/boundaries
Returns the visibility presets and available interaction policy options for the given context. Clients use this to build a compose UI with the correct visibility picker and policy selectors.
Authentication: required
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
context | "instance" | "user" or a ULID | "user" | The context to return valid options for. Pass "user" for a regular post, "instance" for server-wide policies, or a group/topic ULID to get the options permitted within that specific group. |
Examples:
GET /api/v1-bonfire/boundaries— options for a regular postGET /api/v1-bonfire/boundaries?context=instance— instance-level policies (TODO in a later version)GET /api/v1-bonfire/boundaries?context=01JPXYZ...— options permitted when posting into that group or topic (TODO in a later version)
Response: 200 OK
{
"context": "01JPXYZ...",
"visibility": ["public", "unlisted", "private"],
"visibility_labels": {
"public": {"label": "Public", "icon": "globe", "description": "Visible to everyone"},
"unlisted": {"label": "Local only", "icon": "home", "description": "Visible to users on this instance"},
"private": {"label": "Followers", "icon": "lock", "description": "Visible to your followers only"},
"direct": {"label": "Direct", "icon": "envelope", "description": "Visible to mentioned users only"}
},
"policies": {
"reply_approval_policy": ["public", "followers", "members", "mentioned", "nobody"],
"reply_denied_policy": ["public", "followers", "members", "mentioned", "nobody"],
"announce_approval_policy": ["public", "followers", "members", "nobody"],
"announce_denied_policy": ["public", "followers", "members", "nobody"],
"like_approval_policy": ["public", "followers", "members", "nobody"],
"like_denied_policy": ["public", "followers", "members", "nobody"],
"quote_approval_policy": ["public", "followers", "nobody"],
"quote_manual_approval_policy": ["public", "followers", "members", "nobody"],
"quote_denied_policy": ["public", "followers", "members", "nobody"]
},
"policy_labels": {
"public": {"label": "Anyone", "icon": "globe", "description": "Anyone can interact"},
"followers": {"label": "Followers", "icon": "lock", "description": "Only your followers"},
"members": {"label": "Group members", "icon": "people", "description": "Only members of this group"},
"mentioned": {"label": "Mentioned only", "icon": "at", "description": "Only accounts you mention"},
"nobody": {"label": "Nobody", "icon": "block", "description": "Disabled"}
}
}| Field | Type | Description |
|---|---|---|
context | string | Echoes back the resolved context |
visibility | array of strings | Visibility values permitted in this context. Pass the chosen value as the standard visibility param on POST /api/v1/statuses. |
visibility_labels | object | Display metadata for each visibility value — label, icon (semantic name, map to your own icon set), and description, all localised by the server. |
policies | object | Keyed by the param name to use on POST /api/v1/statuses. Each value is an array of permitted option keys. Only policies applicable to the context are included — treat this as the authoritative list. "members" only appears when context is a group or topic ULID. |
policy_labels | object | Display metadata for policy keyword values — label, icon (semantic name), and description, all localised by the server. Clients should look up display strings here rather than hardcoding them. |
Dedicate endpoints NOT needed (use standard Mastodon API)
| Need | Standard endpoint |
|---|---|
| Get a group's posts/feed | GET /api/v1/accounts/:id/statuses |
| Check membership / role | GET /api/v1/accounts/relationships?id[]=:group_id — see group.member and group.role |
| Follow group feed (without joining, only works if allowed by group boundaries) | POST /api/v1/accounts/:id/follow — sets following: true, does not affect group.member |
| Unfollow group feed (without leaving) | POST /api/v1/accounts/:id/unfollow — sets following: false, does not affect group.member |
Notes on Depth Parameters
sub_depth=N and parent_depth=N control recursive nesting. parent_group_id is always present regardless.
0— full object omitted (sub_groups: [],parent_group: null)1— one level: direct children / immediate parent2— two levels: children of children / parent and grandparent- etc.
List endpoints (GET /groups) default both to 0 to keep responses lean.
Single-item endpoints (GET /groups/:id) default both to 1.