Skip to main content
This page records breaking changes to the Membership.io API contract. Non-breaking additions (new endpoints, new optional fields, new SSE event types) are not listed here — consult the endpoint inventory and the generated API reference for additions.

2026-10-13: team media follows team roles

Changing a team’s media now depends on the caller’s team role. Before, any member of the team could upload, edit and delete its media. Reading is unchanged. The owner and full-scope team API keys keep full access. Existing members without a role were given a “Media editor” role holding media.edit, so their editing keeps working; only deleting and replacing became owner-only. Old to new: Writes to the media of a team that was deleted now return 404 for signed-in users. A member whose role is a platform system role can read the team’s media but not change it.

2026-10-12: the comments list says whether the viewer can post

GET /api/v1/comments now returns an optional meta.viewer_can_comment boolean. It is true when the viewer may post a top-level comment on that target right now, and false when a comment from them would be refused: a locked discussion, a muted or banned member, or content the viewer can read but not access, such as a hub owner, admin or moderator reading a segment-gated lesson through read-only oversight. It is omitted on lists with no single viewer and target, such as replies and admin lists. Clients should hide or disable the composer when it is false. POST /api/v1/comments is unchanged and stays the authority. Separately, hub staff outside a lesson’s segment no longer get the bell “comment” notification for comments on that lesson. Old to new:

2026-10-11: team roles accept only team-scoped permissions

A team role now grants authority: a member whose team role holds contacts.read can read the team’s contact directory (GET /api/v1/teams/{team_id}/contact-directory). So a team role may only carry team-scoped permissions. Old to new: A platform permission already on a team role grants nothing and can still be removed with DELETE /api/v1/roles/{role_id}/permissions/{permission_slug}.

2026-10-11: a team API key can’t give a new member a role

POST /api/v1/teams/{team_id}/members now refuses a team API key when the body sets role_id. A team role can carry permissions, and the membership outlives the key, so a leaked key could otherwise grant lasting access. Assigning a role needs the team owner’s signed-in session. A key can still add a member without a role. Old to new:

2026-10-10: restricted spaces of another team are hidden

Discussions spaces now check a restricted space’s segment against the hub’s own team, and a restricted space whose team isn’t the hub’s team is closed to every member, whatever its segment, access grants or allow_moderators setting. Before, several reads checked segments against the team of the first space in the list, or never compared a space’s team with the hub’s, so on a hub with mixed-team space rows a member could see another team’s restricted space, or lose a valid one, depending on the order of the rows. This applies to the space list and detail, discussions and their comments, reactions, poll voters and attachments, the hub-wide feed, search, repost previews, mark-read, member profile cards, drafts and scheduled posts, notification preferences, notification excerpts and the realtime stream. Joining, posting, reacting, voting, commenting, reposting and reporting in such a space are refused, its members get no notifications, broadcast email or realtime events for it, and new hub members are not added to it as a default space. A public space’s segment-only posting rule is also checked against the hub’s team. Restricted spaces on a hub whose team was deleted are closed too. The API always stores the hub’s team on a space, so only rows written outside it, or hubs that later moved team, change behavior. Old to new:

2026-10-10: segment conditions only match the hub’s own team

An in_segment access-rule condition now matches only a segment that belongs to the hub’s own team. A condition that names another team’s segment matches nobody, on every read route and in the access-granted and access-revoked events. The access-rule API already refuses to create or update such a rule (422), so only rules written before that check, or rules on a hub that later moved team, change behavior. Old to new:

2026-10-09: the data.id rule on the last 10 PATCH routes

The Update request rule now covers the routes whose resource id isn’t in the URL; the JSON:API page lists which id each one expects. A different id is a 409 resource_id_mismatch at /data/id with nothing written, and null or a non-string id is a 422. Old to new: Composite ids are compared exactly, so send them as the server returns them.

2026-10-09: the data.id rule reaches 27 more PATCH routes

The Update request rule from 2026-10-08 now covers 27 more PATCH routes: data.id is optional, a different id is a 409 resource_id_mismatch at /data/id with nothing written, and null or a non-string id is a 422 at /data/id. Old to new: On /contact-auth/me and /portal/hubs/{hub_id}/me/profile the id is your own contact id; on transcription settings it is the team id. On some routes your permission on the resource is checked after the id, so a request that isn’t allowed and names a different id can get the 409 instead of a 403 or 404. A rejected request still counts toward the route’s rate limit, the same as a body that fails validation.

2026-10-08: API-key bodies reject unknown fields, and revoked contact tokens lose hub admin access

API-key mint and update reject unknown fields

POST /api/v1/teams/{team_id}/api-keys and PATCH /api/v1/teams/{team_id}/api-keys/{key_id} used to drop any field they didn’t know when the body was flat, so a typo or an unsupported field returned success and did nothing. Both now reject it, in a flat body and in a JSON:API envelope alike. Old to new:

Revoked contact tokens on hub admin, moderation and realtime routes

Some hub routes accepted a contact access token without checking whether it had been revoked, so a member who logged out kept that access until the token expired (up to 15 minutes): hub member administration, the moderation console and moderation actions, hub event authoring, custom domains, pinning, segments, and locking, removing or moving discussions. They now check, like every other contact route. The realtime stream (GET /api/v1/realtime/events) already checked revocation and now also requires an access token. Old to new: A live contact access token, API keys and platform tokens are unchanged. Where a route looks up the hub before checking the token, a hub that doesn’t exist still returns 404 as before.

2026-10-08: API-key scopes are enforced, and role routes are limited to admins and team owners

API-key scopes

scopes on a team API key used to be stored and shown but never checked, so every valid key had full access to its team whatever its list said. Scopes are now enforced, and "*" (full access to the key’s team) is the only scope. Keys stored as ["*"], which is what the admin always creates and the default when you leave scopes out, are unchanged. Old to new: Existing keys don’t land in the last row: when this change deployed, every key that wasn’t revoked was set to ["*"], the access it already had. If you do get api_key_scope_denied, the key still appears in the key list; create a replacement with the default scopes, move your integration to it, and revoke the old key. See Scopes.

Role routes

Every /api/v1/roles route used to accept any signed-in platform user. A global role (no team_id) is now managed by platform admins only, and stays visible to every signed-in user. A team role is visible to platform admins and to the members and owner of that team, and managed by platform admins and that team’s owner. API keys were already refused on these routes and still are. Old to new, for a caller who is not a platform admin: Platform admins keep the access they had. The mio roles create command sends no team_id, so it creates a global role and now needs a platform admin.

2026-10-08: one data.id rule on PATCH, and space attributes you can clear

data.id on PATCH

PATCH used to treat the resource id in data three different ways depending on the resource. Hubs, pages and products rejected it with a 422. Playlists, playlist items, files, folders, attachments and report reasons required it but never compared it with the URL. Spaces ignored it. These routes now share one rule: data.id is optional, and when you send it, it must be the id of the resource you’re updating. A different id is a 409 with code resource_id_mismatch and source.pointer /data/id, and nothing is written. "id": null is a 422 on these routes (email templates still accept it); leave id out instead. See Update request. Old to new: Any other unknown member of data is still a 422 where it was before. The remaining PATCH routes adopt the same rule in a later release.

Clearing a space’s icon, description, color or segment

PATCH /api/v1/admin/teams/{team_id}/hubs/{hub_id}/spaces/{space_id} used to drop every attribute sent as null and answer 200, so an icon, once set, could never be removed. It now applies exactly the attributes you send. A 422 writes nothing, including the other attributes in the same request.

2026-10-05: PATCH null on teams, users and roles, and JSON:API 404 and 405 bodies

Two behavior changes, plus two additions you can ignore if you do not need them.

PATCH null no longer means “ignore”

PATCH /api/v1/teams/{team_id}, PATCH /api/v1/users/{user_id} and PATCH /api/v1/roles/{role_id} used to drop every field sent as null. They now apply exactly the fields you send. An omitted field is still left alone. An explicit null clears a field that can be empty and is a 422 on one that cannot, with nothing written. The 422 carries a source.pointer: /data/attributes/<field> for an envelope body, /<field> for a flat body. Old to new: Team envelope bodies also get envelope pointers on two create requests. A field error on POST /api/v1/teams or POST /api/v1/teams/{team_id}/members used to point at /<field>, such as /slug for a body missing slug. With an envelope body it now points at /data/attributes/<field>, such as /data/attributes/slug. Flat bodies keep /<field>. The same applies to every validation error on an envelope PATCH /api/v1/teams/{team_id} body, not only name: null. Flat bodies keep their old pointers. The OpenAPI schema still shows is_active and both name fields as nullable. The 422 is the real behavior. How to migrate:
  1. Stop sending null for is_active, name, or any field you only meant to leave alone. Leave the key out instead.
  2. If you relied on {"first_name": null} doing nothing, remove it from the body. It now clears the name.
  3. If you read source.pointer on team errors, handle both /<field> and /data/attributes/<field>.
See Updating with PATCH.

Unknown routes and wrong methods return the standard error body

A 404 for a path no endpoint serves, and a 405 for a method the path does not support, used to return {"detail": "..."}. They now return the standard errors array with meta.request_id and an X-Request-ID header. A 405 also lists every method the path supports in Allow. Before, Allow came from the first route that matched the path and could be incomplete. The same goes for any other error the framework raises before an endpoint runs, such as a 400 for a malformed multipart body. How to migrate: read errors[0].detail and the HTTP status instead of a top-level detail. Status codes did not change. See Error handling.

Additive: Location on creates and polling

These need no action. Existing clients keep working.
  • Most create requests that return 201 now send a Location header and data.links.self when the new resource has its own GET URL. Some creates, such as comments, files and nested creates, send neither, so do not assume it is present. See Created resources.
  • Three 202 Accepted requests now send a Location to poll: import from Stripe, file transcode, and custom domain verify while the domain is pending. Other 202 responses send none. See Accepted requests.

2026-10-04 — Achievement create and update validate their input (MIO-3460)

POST /api/teams/{team_id}/achievements and PATCH /api/teams/{team_id}/achievements/{id} now reject values the server cannot store, with a 422 that names the field in source.pointer. Before, most of these returned a bare 500, and a blank title was accepted and stored as a badge with no name. What changed:
  • title must be 1 to 255 characters and not blank. A title of only whitespace is rejected with 422; it used to be stored. Surrounding whitespace is now trimmed before storing, so " Early Bird " is saved as "Early Bird".
  • points and rule_threshold are at most 2147483647. points is still at least 0, and rule_threshold is still at least 1 (unchanged error rule_threshold_invalid).
  • award_mode is manual or rule. The request schemas now publish this as an enum; the response schemas are unchanged and still describe award_mode as a plain string.
  • category is at most 64 characters.
  • title, category and description must not contain control characters. Tab, newline and carriage return are still allowed.
  • Several bad fields in one request return one error per field in the same 422.
Why this is listed here: a request that used to return 500 now returns 422, a blank or untrimmed title that used to be stored is now rejected or trimmed, and a client generated from the new spec sees the new minLength, maxLength, maximum and enum keywords on the request types. Existing achievements are not touched. A PATCH that does not send title leaves the stored title as it is. How to migrate:
  1. Regenerate your types against the new schema. A generator that honours enum types award_mode as "manual" | "rule" on requests.
  2. Check any code that sends a title with surrounding spaces, or that reads the title straight after a write, for the trimmed value.
  3. Handle 422 with source.pointer on these two routes instead of treating a 500 as the signal for a bad value.

2026-09-10 — appearance.icon on achievements publishes a closed enum (MIO-3728)

icon on the achievements appearance object previously published as a bare nullable string (anyOf: [string, null]). It now publishes the same nullable union with the string branch constrained to a closed enum of the 95 asset-backed pictogram names the server has always enforced. The field is $ref’d from five schemas: achievement create, achievement update, achievement read, achievement catalog, and the achievement-earn notification subject. What changed:
  • The published schema now enumerates the exact 95 accepted names, sorted and byte-stable across regenerations. Read them from the generated API reference rather than hard-coding the list.
  • null remains valid. An achievement badge with no icon is still legal, and the null branch of the union is unchanged.
  • Runtime validation is unchanged. The accepted names are the same names the server already enforced, and a name outside the vocabulary is still rejected with 422 (unknown badge icon) exactly as before. No request that was valid before this enum was published becomes invalid because of it.
  • The design-system icon manifest lists 98 names. Three of them have no backing .svg asset and are deliberately excluded from the vocabulary: iconmonstr-chart-5 1, iconmonstr-check-mark-9 1, and iconmonstr-whats-hot-1 1. Sending one of these three is rejected, not silently accepted.
Why this is listed here: unchanged server validation is not unchanged source compatibility, and the cost lands on this change, at your first regeneration — not only on later vocabulary changes. If you generate a client from this spec with a generator that honours enum, icon becomes a closed union of the 95 literals plus null. A caller assigning a broadly-typed string (or string | null) into that field stops compiling, even when the value it carries is a valid icon name at runtime. A generator that ignores enum keeps rendering a nullable string and sees no source change. How to migrate:
  1. Regenerate your types against the new schema and run your typecheck.
  2. Narrow or adapt any caller that feeds a broad string into icon — a literal or union type at the source, a narrowing helper, or an explicit cast at the boundary.
  3. Check a representative valid literal and null at your real client boundary, so the closed union is exercised rather than assumed.
Future additions to this vocabulary are a coordinated change, announced here first. A client generated from an older copy of this schema will not recognise a newly accepted name — a type error if you generate types, a validation failure if you validate responses against the spec. The vocabulary, the published schema, this documentation, and known consumers are therefore updated together, before a new name is accepted in production. Subscribe to this page if you generate from the spec.

2026-08-06 — Replying to a reply is rejected (MIO-2963)

POST /api/v1/comments now enforces two-level comment threading. A request whose parent_comment_id points at a comment that is itself a reply previously succeeded with 201; it now fails with 422 and code nested_reply_not_allowed. What changed:
  • parent_comment_id must reference a root comment (one with no parent of its own). Referencing a reply returns 422 / nested_reply_not_allowed.
  • comment_count on discussions and content nodes is recomputed on deploy to exclude replies nested deeper than one level, which previously inflated it. A discussion’s comment_count may drop with no content deleted.
  • Deleting a root comment now decrements comment_count by that comment plus its replies that had not already been deleted, rather than by 1.
How to migrate: Thread replies against the root comment’s id — when a user replies to a reply, send that reply’s own parent_comment_id (its root), not the reply’s id. Treat 422 / nested_reply_not_allowed as a client-side threading bug rather than surfacing it to the user. Do not treat a one-time comment_count decrease as content loss.

2026-06-18 — GET /api/users is now team-scoped (MIO-1253)

GET /api/users now requires team context. Without it the endpoint returns 400 with code team_context_required. What changed:
  • Requests must supply an X-Team-ID header or use a JWT that carries a team_id claim.
  • Requests made with a plain API key (no team claim) are rejected with 403 — use a contact-scoped or team-scoped JWT instead.
  • Responses are filtered to the team identified by the header/claim; contacts belonging to other teams are not returned.
How to migrate: Add X-Team-ID: <your-team-id> to all GET /api/users requests, or switch to a JWT that includes a team_id claim. See Tenancy for how team context flows through the API.

2026-06-18 — JSON:API type renames (MIO-1257)

Two resource type values were renamed to the snake_case + plural contract that governs the entire API surface: How to migrate: Update any client-side deserialization that matches on the literal type string. The data shape (attributes, relationships) is unchanged — only the type value differs. No deprecation window: this was a clean alpha cut-over. No Sunset or Deprecation headers were emitted — the old type values stopped being served at deploy time. If your integration broke on 2026-06-18, update the type strings above and redeploy.