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.
The REST layer is a thin serialisation layer on top of GraphQL. GraphQL API documents the schema, and these REST endpoints map directly to those operations.
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" (default) | "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": []
}
}
]
}
}REST 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.
POST /api/v1-bonfire/groups
Create a new group or topic.
Authentication: required
Request body (JSON):
| Param | Type | Description |
|---|---|---|
name | string | Display name of the group |
type | "group" | "topic" | Defaults to "group" |
boundary.preset | string | Named boundary preset slug (e.g. "open", "private_club", "on_request"). Discovered via GET /api/v1-bonfire/boundaries?context=group. Omit to use the instance default. |
Response:
200 OK—Accountwithgroupfield401 Unauthorized— not authenticated422 Unprocessable Entity— validation failed (e.g. missingname)
PATCH /api/v1-bonfire/groups/:id
Update a group's name or boundary preset. Only the group admin may call this.
Authentication: required (admin of the group)
Request body (JSON):
| Param | Type | Description |
|---|---|---|
name | string | New display name |
boundary.preset | string | New boundary preset slug. Changes join_mode and related access rules. |
Response:
200 OK— updatedAccountwithgroupfield401 Unauthorized— not authenticated403 Forbidden— authenticated but not an admin of this group404 Not Found— group not found
POST /api/v1-bonfire/groups/:id/members
Add a member directly, or accept a pending join request.
Authentication: required (admin of the group)
Request body (JSON) — one of:
| Param | Type | Description |
|---|---|---|
account_id | string | ULID of the account to add directly as a member |
request_id | string | ULID of a pending join request to accept |
Response:
200 OK— updatedRelationshipfor the affected account (same shape asPOST /join)401 Unauthorized— not authenticated403 Forbidden— not an admin of this group404 Not Found— group, account, or request not found
DELETE /api/v1-bonfire/groups/:id/members/:account_id
Remove a member from the group.
Authentication: required (admin of the group)
Response:
200 OK—{"success": true}401 Unauthorized— not authenticated403 Forbidden— not an admin of this group404 Not Found— group or account not found
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 |
like_approval | QuoteApproval | null | Same shape — who may react |
current_user on each approval object is one of "automatic", "manual", "denied", or "unknown". "manual" means the current user is in the "request" VerbGrant's can list (can send a request) but not in the direct can list.
Boundaries Discovery
GET /api/v1-bonfire/boundaries
Returns the permitted visibility options and interaction policy options for the given context. The response shape is always the same — visibility + policies — filtered to what is valid in that context. Group creation/editing uses the GraphQL boundaries(context: ...) query for the full 3-layer model.
Authentication: required
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
context | keyword or ULID | "post" | "post" (default) — options for composing a post. "user" — user account-level visibility options. A group/topic/post ULID — options scoped to posting within that specific object. "instance" — TODO. |
Examples:
GET /api/v1-bonfire/boundaries— options for the compose UIGET /api/v1-bonfire/boundaries?context=user— user account-level boundary optionsGET /api/v1-bonfire/boundaries?context=01JPXYZ...— options when posting into a specific group, topic, or thread
Response: 200 OK
{
"context": "post",
"visibility": ["public", "local", "mentions", "follows", "private"],
"visibility_labels": [
{"value": "public", "label": "Public", "icon": "ph:globe-duotone", "description": "Visible to everyone."},
{"value": "local", "label": "Local", "icon": "ph:campfire-duotone", "description": "Everyone on this instance."},
{"value": "mentions", "label": "Mentions", "icon": "ph:at-duotone", "description": "Only people you @mention."},
{"value": "follows", "label": "Follows", "icon": "ph:eye-duotone", "description": "Only people you follow."},
{"value": "private", "label": "Private", "icon": "heroicons-solid:eye-off", "description": "Only you."}
],
"policies": [
{"key": "reply_approval_policy", "values": ["public", "followers", "mentioned", "nobody"]},
{"key": "reply_denied_policy", "values": ["public", "followers", "mentioned", "nobody"]},
{"key": "announce_approval_policy", "values": ["public", "followers", "nobody"]},
{"key": "announce_denied_policy", "values": ["public", "followers", "nobody"]},
{"key": "like_approval_policy", "values": ["public", "followers", "nobody"]},
{"key": "like_denied_policy", "values": ["public", "followers", "nobody"]},
{"key": "quote_approval_policy", "values": ["public", "followers", "nobody"]},
{"key": "quote_manual_approval_policy", "values": ["public", "followers", "nobody"]},
{"key": "quote_denied_policy", "values": ["public", "followers", "nobody"]}
],
"policy_labels": [
{"value": "public", "label": "Anyone", "icon": "ph:globe-duotone", "description": "Anyone can interact"},
{"value": "followers", "label": "Followers", "icon": "ph:lock-duotone", "description": "Only your followers"},
{"value": "mentioned", "label": "Mentioned only", "icon": "ph:at-duotone", "description": "Only accounts you mention"},
{"value": "nobody", "label": "Nobody", "icon": "ph:prohibit-duotone", "description": "Disabled"}
]
}When context is a group or topic ULID, visibility and policies[].values are filtered to only what the group permits. policy_labels always contains the full label set for all possible values.
| Field | Type | Description |
|---|---|---|
context | string | Echoed back |
visibility | array of strings | Permitted visibility slugs. Pass as visibility on POST /api/v1/statuses. |
visibility_labels | array of BoundaryLabelledOption | Display metadata per slug, localised by the server. |
policies | array of {key, values[]} | Authoritative list of permitted values per policy param. key is the REST param name for POST /api/v1/statuses. |
policy_labels | array of BoundaryLabelledOption | Display metadata per policy keyword value. |
All labels and icons are localised by the server. Icon values are Iconify slugs.
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.
See BOUNDARIES_MODEL.md for the full 4-dimension model, named presets, and Layer 2 override reference. The authoritative runtime values are in extensions/bonfire_boundaries/lib/runtime_config.ex and extensions/bonfire_classify/lib/runtime_config.ex, and are also served live by GET /api/v1-bonfire/boundaries?context=group.