`, ``, `` and ``. Anything else is **stripped, not escaped**: the platform rejects an entire message containing an unknown tag, so removing it is what keeps the message deliverable.
The 4,096-character limit is the platform's own. A longer body is refused here rather than being truncated per recipient.
### Refusals
Every `422` is refused **before** anything is queued. The underlying job re-checks the same conditions and aborts silently, which is correct for a background job and wrong for an API caller, so the endpoint rejects them up front rather than returning `202` for a send that could never leave. A segment that resolves to zero recipients is the send action's own refusal, shared with the dashboard: it is not queued.
- Requires ability: `broadcast:send`
- Fires events: `broadcast.queued`, `broadcast.completed`
- MCP tools: `broadcast_message`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A message and the segment of the project's members it goes to.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `message` | string | yes | The message body, 1 to 4,096 characters, in the HTML subset the delivering connector accepts. Unknown tags are stripped rather than escaped, because the platform rejects a whole message that holds one. |
| `audience` | `all`, `customer`, `trialing`, `lead`, `churned`, `expiring_soon`, `cancelled_still_active`, `paused`, `trialing_cardless`, `all_pass_holders`, `all_pass_holders_not_in_queue`, `pass_holders`, `pass_holders_not_in_queue` | no | The segment to address: `all` (the default), `customer`, `trialing`, `lead`, `churned`, `expiring_soon`, `cancelled_still_active`, `paused`, `trialing_cardless`, `all_pass_holders`, `all_pass_holders_not_in_queue`, `pass_holders` or `pass_holders_not_in_queue`. |
| `pass_window_id` | string \| null (uuid) | no | The access window for `pass_holders` and `pass_holders_not_in_queue`; required by those two, ignored by every other segment. |
| `plan_id` | string \| null (uuid) | no | Narrow the segment to members whose subscription is on this plan; it composes with `audience` rather than replacing it. Refused, not ignored, for `lead` and the pass segments. |
| `expiring_within_days` | integer \| null | no | The horizon `expiring_soon` counts within, 1 to 90; defaults to 7. Read by no other segment. |
### Responses
- **202**: 202 with the segment, the verified filters, the recipient count and the ETA.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.project_id` | string | yes | The project the broadcast goes out from. |
| `data.audience` | string | yes | The segment as verified. |
| `data.pass_window_id` | string \| null | yes | The window a pass segment addresses; null for every other segment. |
| `data.plan_id` | string \| null | yes | The plan filter as verified; null when none was sent. |
| `data.expiring_within_days` | integer \| null | yes | The horizon `expiring_soon` used; null for every other segment. |
| `data.recipient_estimate` | integer | yes | How many members the segment resolved to when the send was queued. |
| `data.estimated_seconds` | integer | yes | Roughly how long delivery will take at the platform's rate. |
| `data.status` | string | yes | Always `queued`. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: with `error.context.reason` of `no_connected_bot` (the project has no connector connected, so it has no way to deliver), `missing_capability` (a pass segment on a project whose plan no longer includes passes), `pass_window_required` (a single-window segment with no `pass_window_id`), `unknown_audience` (the context lists the `supported` values), `plan_filter_unsupported` (a `plan_id` with `lead` or a pass segment) or `unknown_plan` (the `plan_id` is not this project's); or with no reason when the segment holds nobody right now, in which case `error.message` says which.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [broadcast.* events](https://docs.subscriby.net/webhooks/v1/events/broadcast): React to sends and their outcomes.
- [Broadcasts](https://docs.subscriby.net/creators/broadcasts): The same feature in the dashboard.
- [Zapier](https://docs.subscriby.net/integrations/zapier): And [n8n](https://docs.subscriby.net/integrations/n8n): send broadcasts without writing code.
---
# Connectors API
Source: https://docs.subscriby.net/api/v1/reference/connectors
A Subscriby project runs on **connectors**: the platforms it gates access on, sends messages through and takes payments from. The directory these endpoints serve is the same one the dashboard and the marketing site render, so a client reads one document and knows which connectors exist, which a creator may install today, what each can do, and exactly which fields connecting one asks for. The manifest is served whole because the creator apps render the connect form from it: no client has to know what a "bot token" is before the connector says so.
The catalogue is the same for every creator and never carries a credential. A project's **installations** are what the Connectors tab lists: every connector set up on the project, live and standby, with its state and health.
## Endpoints
- `GET /v1/connectors` — [List the connector directory](#list-the-connector-directory)
- `GET /v1/connectors/{key}` — [Get one connector](#get-one-connector)
- `GET /v1/projects/{project}/connectors` — [List a project's installations](#list-a-projects-installations)
- `GET /v1/projects/{project}/connectors/{key}/installation` — [Get a project's installation of a connector](#get-a-projects-installation-of-a-connector)
- `DELETE /v1/projects/{project}/connectors/{key}/installation` — [Disconnect an installation](#disconnect-an-installation)
- `GET /v1/projects/{project}/connectors/{key}/uninstall-preview` — [Preview an uninstall](#preview-an-uninstall)
- `POST /v1/projects/{project}/connectors/{key}` — [Install a connector](#install-a-connector)
- `DELETE /v1/projects/{project}/connectors/{key}` — [Uninstall a connector](#uninstall-a-connector)
- `POST /v1/projects/{project}/connectors/{key}/installation/verify` — [Verify an installation](#verify-an-installation)
- `POST /v1/projects/{project}/connectors/{key}/installation/doctor` — [Run the connector doctor](#run-the-connector-doctor)
- `POST /v1/projects/{project}/connectors/{key}/installation/restore-access` — [Restore access after a reinstall](#restore-access-after-a-reinstall)
- `PATCH /v1/projects/{project}/connectors/{key}/installation/settings` — [Change an installation's settings](#change-an-installations-settings)
## List the connector directory
`GET /v1/connectors`
Every connector Subscriby knows, lane by lane, installable connectors first, each with its badges and, for a registered one, its whole manifest and the connect form. `?status=` narrows to one lane.
```bash
curl https://api.subscriby.net/v1/connectors \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Cards come lane by lane, installable connectors first; within a lane the most installed connector comes first (`installations`, the number of projects running it today, uninstalled rows excluded) and the name breaks ties. The public and in-app directories offer the same order plus Newest (`added_at`) and Name, and filter on `official`, `installable` and `category`; a client sorts and filters on the same fields.
### Lanes and badges
| `status` | `status_label` | Meaning |
| ---------------- | ----------------- | ---------------------------------------------------------------------------------------------- |
| `available` | Available Now | A package is present and configuration offers it; `installable` is `true`. |
| `beta` | Experimental | Available, and flagged so; a badge on the card, never a gate. `installable` is `true`. |
| `paused` | Paused | Switched off during an incident. Nothing new is installed and sends queue until it is back. |
| `in_development` | Under Development | A package exists but configuration does not offer it yet (built, on staging, dark). |
| `coming_soon` | Coming Soon | A roadmap entry with no package behind it: the card, the category and an `eta` sentence only. |
`badges` are facts the platform stamps, never a package's own claim: `official` or `community` (who publishes it), `new` while the listing was added within the last sixty days, `trending` for the connector(s) creators installed most in the last thirty days. A `coming_soon` card carries none.
- Requires ability: `project-connector:view-any`
- MCP tools: `list_connectors`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | string | no | Narrow the directory to one lane: `available`, `beta`, `paused`, `in_development` or `coming_soon`. Omit for every lane. An unknown lane is `422 VALIDATION_FAILED` naming the accepted values. |
### Responses
- **200**: Array of `ConnectorListingResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].key` | string | yes | The connector's key, the value the installation routes take as `{key}`. |
| `data[].name` | string | yes | The connector's display name. |
| `data[].vendor` | string \| null | yes | Who publishes the connector; null on a roadmap card. |
| `data[].version` | string \| null | yes | The connector package's version; null on a roadmap card. |
| `data[].status` | string | yes | The lane: `available`, `beta`, `paused`, `in_development` or `coming_soon`. |
| `data[].status_label` | `Available Now`, `Experimental`, `Paused`, `Under Development`, `Coming Soon` | yes | The lane heading the dashboard shows, in the token holder's language. |
| `data[].installable` | boolean | yes | Whether a creator may install the connector today: true for `available` and `beta` only. |
| `data[].official` | boolean | yes | Whether Subscriby publishes the connector itself. |
| `data[].badges` | array of any | yes | Facts the platform stamps, never the package's own claim: each `{key, label}` is `official` or `community` (who publishes it), `new` while the listing was added within the last sixty days, or `trending` for the connectors creators installed most in the last thirty days. Empty on a roadmap card. |
| `data[].installations` | integer | yes | How many projects run the connector today, uninstalled rows excluded. |
| `data[].category` | string | yes | The connector's family, such as `messaging`. |
| `data[].tagline` | string | yes | The one-line pitch on the card. |
| `data[].overview` | string \| null | yes | The longer description; null on a roadmap card. |
| `data[].icon` | string | yes | The icon slug the directory draws. |
| `data[].eta` | string \| null | yes | When a roadmap connector is expected, as a sentence; null once it is registered. |
| `data[].links` | object \| null | yes | Where to read more; null on a roadmap card. |
| `data[].links.documentation` | string \| null | yes | The connector's guide on the docs site. |
| `data[].links.support` | string \| null | yes | Where to ask for help, a URL or a `mailto:`. |
| `data[].links.privacy` | string \| null | yes | The platform's privacy policy. |
| `data[].links.terms` | string \| null | yes | The platform's terms of service. |
| `data[].links.homepage` | string \| null | yes | The platform's home page. |
| `data[].added_at` | string \| null | yes | The day the listing was added, `YYYY-MM-DD`; null on a roadmap card. |
| `data[].changelog_url` | string \| null | yes | Where the connector's changes are announced; null on a roadmap card. |
| `data[].sign_in_required` | boolean | yes | Whether connecting asks the creator to sign in on the platform rather than paste a credential. |
| `data[].portal_cta` | object \| null | yes | The button the member portal shows to open the connector, or null when the connector has nowhere to open; the portal addresses it through the installation's start link. |
| `data[].portal_cta.label` | string | yes | The button's text, translated. |
| `data[].portal_cta.icon` | string | yes | The button's icon slug. |
| `data[].install_mode` | string \| null | yes | How a creator connects it: `paste_credential` (the creator pastes a token the platform issued), `oauth` (the creator authorises an app) or `shared_platform` (Subscriby runs one shared presence; nothing to connect). Null on a roadmap card. |
| `data[].scopes` | array of any | yes | `project` for an installation a creator sets up on a project, `platform` for a presence Subscriby itself runs on the connector. |
| `data[].resource_kinds` | array of any | yes | The kinds of place the connector gates. Each carries `kind`, the creator-facing `label`, the `portal_label` members see, an `icon` slug, the `grant_mode` (`bearer_link`: a single-use link; `membership`: the connector admits the member itself; `role`: a role is granted; `creator_task`: the creator hands access over by hand), `supports_early_admission_hold` (whether a grant can be held ahead of a pass window), `upgrades_from` (the kind of the same connector a place of this kind can be an in-place upgrade of, or null) and `plan_kinds` (the shapes of plan a place can be sold under: `one_time`, `recurring`, `pass`, `pass_series`). A resource's `kind` on the Resources endpoints is `:`. |
| `data[].capabilities` | array of any | yes | The port families the connector implements: `messaging`, `broadcasts`, `access_control`, `early_admission_hold`, `support_relay`, `native_payments`, `portal_login`, `creator_registration`, `management_surface`, `recovery_probes`, `recovery_standby_installations`, `recovery_resource_standby`, `recovery_mirror`, `recovery_identity_relink`. |
| `data[].messaging` | object \| null | yes | The limits every message is checked against; null on a roadmap card. |
| `data[].messaging.max_length` | integer | yes | The longest message body the platform delivers. |
| `data[].messaging.buttons_per_row` | integer | yes | How many buttons a keyboard row may hold. |
| `data[].messaging.max_buttons` | integer | yes | How many buttons a message may carry in total. |
| `data[].messaging.callback_data_bytes` | integer | yes | How many bytes a button's callback payload may hold. |
| `data[].messaging.supports_underline` | boolean | yes | Whether underlined text renders. |
| `data[].messaging.supports_spoiler` | boolean | yes | Whether spoiler text renders. |
| `data[].messaging.supports_files` | boolean | yes | Whether files can be attached. |
| `data[].pacing` | object \| null | yes | The rate every bulk send is paced at; null on a roadmap card. |
| `data[].pacing.min_interval_microseconds` | integer | yes | The shortest gap between two sends. |
| `data[].pacing.burst` | integer | yes | How many sends may go out before the gap applies. |
| `data[].pacing.per_recipient_interval_microseconds` | integer | yes | The shortest gap between two sends to the same recipient. |
| `data[].management_commands` | array of any | yes | The core commands the connector's in-chat surface renders, such as `plan_create` or `broadcast`. |
| `data[].missing_commands` | array of any | yes | The core commands the in-chat surface does not render; the Connectors tab shows the gaps from these. |
| `data[].relay_modes` | array of string | yes | How support conversations can be relayed on the connector, such as `owner_dm` or `forum_group`. |
| `data[].recovery` | object \| null | yes | Which Disaster Recovery facets the connector implements; null on a roadmap card. |
| `data[].recovery.probes` | boolean | yes | Whether the connector answers health probes. |
| `data[].recovery.standby_installations` | boolean | yes | Whether a spare installation can be kept ready. |
| `data[].recovery.resource_standby` | boolean | yes | Whether a spare place can be kept ready for a resource. |
| `data[].recovery.mirror` | boolean | yes | Whether a standby can be mirrored from the live place. |
| `data[].recovery.identity_relink` | boolean | yes | Whether a creator's account can be relinked after a loss. |
| `data[].install_fields` | array of any | yes | The declarative connect form, in order. Each field carries `name`, `type` (`text`, `secret`, `select`, `toggle`, `instructions`, `link`), a translated `label` and `help`, `required`, the validation `rules`, `options` for a select, a default `value`, the numbered walkthrough `steps` (with `:app` and `:button` placeholders for the app's name and the submit button's label) and `links` naming the text a renderer turns into a link. |
| `data[].settings_fields` | array of any | yes | The declarative settings form, in the same shape as `install_fields`; its field names are the keys the settings endpoint accepts. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **422**: `VALIDATION_FAILED`: when `status` names no lane; `error.context.accepted` lists the lanes.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get one connector
`GET /v1/connectors/{key}`
One card by key, registered or on the roadmap, in the shape the directory lists.
```bash
curl https://api.subscriby.net/v1/connectors/example \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
### The manifest
Every key from `install_mode` onward describes what a registered connector can do; a roadmap card has them `null` or empty.
- `install_mode`: `paste_credential` (the creator pastes a token the platform issued), `oauth` (the creator authorises an app), `shared_platform` (Subscriby runs one shared presence; nothing to connect).
- `scopes`: `project` for an installation a creator sets up on a project; `platform` for a presence Subscriby itself runs on the connector.
- `resource_kinds`: the kinds of place the connector gates, with the creator-facing `label`, the `portal_label` members see, an `icon` slug, the `grant_mode` (`bearer_link`: a single-use link; `membership`: the connector admits the member itself; `role`: a role is granted; `creator_task`: the creator hands access over by hand), whether a grant can be held ahead of a pass window (`supports_early_admission_hold`), the kind of the same connector a place of this kind can be an in-place upgrade of (`upgrades_from`, or `null`), and the shapes of plan a place of the kind can be sold under (`plan_kinds`: `one_time`, `recurring`, `pass`, `pass_series`, in that order). A resource's `kind` on the [Resources endpoints](https://docs.subscriby.net/api/v1/reference/resources) is `:`, and the same spelling is what a link request takes. Labels are translated into the token holder's language.
- `capabilities`: the port families the connector implements: `messaging`, `broadcasts`, `access_control`, `early_admission_hold`, `support_relay`, `native_payments`, `portal_login`, `creator_registration`, `management_surface`, `recovery_probes`, `recovery_standby_installations`, `recovery_resource_standby`, `recovery_mirror`, `recovery_identity_relink`.
- `messaging` and `pacing`: the limits every message and every bulk send is checked against.
- `management_commands` and `missing_commands`: which core commands the connector's in-chat surface renders and which it does not; the docs' capability matrix and the Connectors tab show the gaps from these.
- `recovery`: which Disaster Recovery facets the connector implements.
- `portal_cta`: the button the member portal shows to open the connector (`label`, translated, and `icon`), or `null` when the connector has nowhere to open; the portal addresses it through the installation's start link.
- `install_fields` and `settings_fields`: the declarative forms. Each field carries `type` (`text`, `secret`, `select`, `toggle`, `instructions`, `link`), a translated `label` and `help`, `required`, the validation `rules`, `options` for a select, a default `value`, the numbered walkthrough `steps` (with `:app` and `:button` placeholders for the app's name and the submit button's label) and `links` naming the text a renderer turns into a link. Render them in order and you have the connect form the dashboard shows.
- Requires ability: `project-connector:view-any`
- MCP tools: `get_connector`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `key` | path | string | yes | The connector key from the route. |
### Responses
- **200**: 200 with the card.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.key` | string | yes | The connector's key, the value the installation routes take as `{key}`. |
| `data.name` | string | yes | The connector's display name. |
| `data.vendor` | string \| null | yes | Who publishes the connector; null on a roadmap card. |
| `data.version` | string \| null | yes | The connector package's version; null on a roadmap card. |
| `data.status` | string | yes | The lane: `available`, `beta`, `paused`, `in_development` or `coming_soon`. |
| `data.status_label` | `Available Now`, `Experimental`, `Paused`, `Under Development`, `Coming Soon` | yes | The lane heading the dashboard shows, in the token holder's language. |
| `data.installable` | boolean | yes | Whether a creator may install the connector today: true for `available` and `beta` only. |
| `data.official` | boolean | yes | Whether Subscriby publishes the connector itself. |
| `data.badges` | array of any | yes | Facts the platform stamps, never the package's own claim: each `{key, label}` is `official` or `community` (who publishes it), `new` while the listing was added within the last sixty days, or `trending` for the connectors creators installed most in the last thirty days. Empty on a roadmap card. |
| `data.installations` | integer | yes | How many projects run the connector today, uninstalled rows excluded. |
| `data.category` | string | yes | The connector's family, such as `messaging`. |
| `data.tagline` | string | yes | The one-line pitch on the card. |
| `data.overview` | string \| null | yes | The longer description; null on a roadmap card. |
| `data.icon` | string | yes | The icon slug the directory draws. |
| `data.eta` | string \| null | yes | When a roadmap connector is expected, as a sentence; null once it is registered. |
| `data.links` | object \| null | yes | Where to read more; null on a roadmap card. |
| `data.links.documentation` | string \| null | yes | The connector's guide on the docs site. |
| `data.links.support` | string \| null | yes | Where to ask for help, a URL or a `mailto:`. |
| `data.links.privacy` | string \| null | yes | The platform's privacy policy. |
| `data.links.terms` | string \| null | yes | The platform's terms of service. |
| `data.links.homepage` | string \| null | yes | The platform's home page. |
| `data.added_at` | string \| null | yes | The day the listing was added, `YYYY-MM-DD`; null on a roadmap card. |
| `data.changelog_url` | string \| null | yes | Where the connector's changes are announced; null on a roadmap card. |
| `data.sign_in_required` | boolean | yes | Whether connecting asks the creator to sign in on the platform rather than paste a credential. |
| `data.portal_cta` | object \| null | yes | The button the member portal shows to open the connector, or null when the connector has nowhere to open; the portal addresses it through the installation's start link. |
| `data.portal_cta.label` | string | yes | The button's text, translated. |
| `data.portal_cta.icon` | string | yes | The button's icon slug. |
| `data.install_mode` | string \| null | yes | How a creator connects it: `paste_credential` (the creator pastes a token the platform issued), `oauth` (the creator authorises an app) or `shared_platform` (Subscriby runs one shared presence; nothing to connect). Null on a roadmap card. |
| `data.scopes` | array of any | yes | `project` for an installation a creator sets up on a project, `platform` for a presence Subscriby itself runs on the connector. |
| `data.resource_kinds` | array of any | yes | The kinds of place the connector gates. Each carries `kind`, the creator-facing `label`, the `portal_label` members see, an `icon` slug, the `grant_mode` (`bearer_link`: a single-use link; `membership`: the connector admits the member itself; `role`: a role is granted; `creator_task`: the creator hands access over by hand), `supports_early_admission_hold` (whether a grant can be held ahead of a pass window), `upgrades_from` (the kind of the same connector a place of this kind can be an in-place upgrade of, or null) and `plan_kinds` (the shapes of plan a place can be sold under: `one_time`, `recurring`, `pass`, `pass_series`). A resource's `kind` on the Resources endpoints is `:`. |
| `data.capabilities` | array of any | yes | The port families the connector implements: `messaging`, `broadcasts`, `access_control`, `early_admission_hold`, `support_relay`, `native_payments`, `portal_login`, `creator_registration`, `management_surface`, `recovery_probes`, `recovery_standby_installations`, `recovery_resource_standby`, `recovery_mirror`, `recovery_identity_relink`. |
| `data.messaging` | object \| null | yes | The limits every message is checked against; null on a roadmap card. |
| `data.messaging.max_length` | integer | yes | The longest message body the platform delivers. |
| `data.messaging.buttons_per_row` | integer | yes | How many buttons a keyboard row may hold. |
| `data.messaging.max_buttons` | integer | yes | How many buttons a message may carry in total. |
| `data.messaging.callback_data_bytes` | integer | yes | How many bytes a button's callback payload may hold. |
| `data.messaging.supports_underline` | boolean | yes | Whether underlined text renders. |
| `data.messaging.supports_spoiler` | boolean | yes | Whether spoiler text renders. |
| `data.messaging.supports_files` | boolean | yes | Whether files can be attached. |
| `data.pacing` | object \| null | yes | The rate every bulk send is paced at; null on a roadmap card. |
| `data.pacing.min_interval_microseconds` | integer | yes | The shortest gap between two sends. |
| `data.pacing.burst` | integer | yes | How many sends may go out before the gap applies. |
| `data.pacing.per_recipient_interval_microseconds` | integer | yes | The shortest gap between two sends to the same recipient. |
| `data.management_commands` | array of any | yes | The core commands the connector's in-chat surface renders, such as `plan_create` or `broadcast`. |
| `data.missing_commands` | array of any | yes | The core commands the in-chat surface does not render; the Connectors tab shows the gaps from these. |
| `data.relay_modes` | array of string | yes | How support conversations can be relayed on the connector, such as `owner_dm` or `forum_group`. |
| `data.recovery` | object \| null | yes | Which Disaster Recovery facets the connector implements; null on a roadmap card. |
| `data.recovery.probes` | boolean | yes | Whether the connector answers health probes. |
| `data.recovery.standby_installations` | boolean | yes | Whether a spare installation can be kept ready. |
| `data.recovery.resource_standby` | boolean | yes | Whether a spare place can be kept ready for a resource. |
| `data.recovery.mirror` | boolean | yes | Whether a standby can be mirrored from the live place. |
| `data.recovery.identity_relink` | boolean | yes | Whether a creator's account can be relinked after a loss. |
| `data.install_fields` | array of any | yes | The declarative connect form, in order. Each field carries `name`, `type` (`text`, `secret`, `select`, `toggle`, `instructions`, `link`), a translated `label` and `help`, `required`, the validation `rules`, `options` for a select, a default `value`, the numbered walkthrough `steps` (with `:app` and `:button` placeholders for the app's name and the submit button's label) and `links` naming the text a renderer turns into a link. |
| `data.settings_fields` | array of any | yes | The declarative settings form, in the same shape as `install_fields`; its field names are the keys the settings endpoint accepts. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: when nothing is known by that key; `error.context.connector` echoes it.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## List a project's installations
`GET /v1/projects/{project}/connectors`
Every installation the project holds, live and standby, oldest first.
```bash
curl https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/connectors \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
`role` is `live` for the one that acts and `standby` for the spare the Disaster Recovery Program keeps; `state` is `pending`, `connected`, `degraded` (the health probe found something wrong, `state_reason` and `state_detail` say what, `creator_actionable` whether the creator can fix it), `revoked` (the platform withdrew it) or `disconnected` (the creator did); `operational` is the one-word answer to "can it act right now?". `external_id`, `display_name`, `handle` and `avatar_url` are the platform's own account for the installation. Credentials and settings are never serialised.
`sales_paused` and `outage` describe a connector outage: the platform refused this live installation outright (token revoked or regenerated, bot deleted). While `sales_paused` is `true`, `outage` carries the outage's `id`, the connector's `reason` code and `started_at`, and every plan that unlocks a place on this connector is refused at checkout with `422 VALIDATION_FAILED`; both go back to `false` and `null` when the installation answers again, is replaced or is uninstalled. [Connector outages](https://docs.subscriby.net/disaster-recovery/connector-outages) describes what members see and how they are compensated; [`connector.outage_opened`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-outage-opened) and [`connector.outage_closed`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-outage-closed) announce both ends.
- Requires ability: `project-connector:view-any`
- MCP tools: `list_connector_installations`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
### Responses
- **200**: Array of `ConnectorInstallationResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].id` | string | yes | The installation's id. |
| `data[].connector` | string | yes | The connector's key. |
| `data[].project_id` | string \| null | yes | The project the installation belongs to; null for a platform-scope presence. |
| `data[].scope` | string | yes | `project` for an installation a creator set up, `platform` for a presence Subscriby runs. |
| `data[].role` | string | yes | `live` for the installation that acts, `standby` for the spare the Disaster Recovery Program keeps. |
| `data[].state` | string | yes | `pending` (installed, nothing connected yet), `connected`, `degraded` (the health probe found something wrong), `revoked` (the platform withdrew it) or `disconnected` (the creator did). |
| `data[].state_reason` | string \| null | yes | The connector's reason code for a `degraded` or `revoked` state; null otherwise. |
| `data[].state_detail` | string \| null | yes | The connector's sentence about the state, in the creator's language; null when there is nothing to say. |
| `data[].creator_actionable` | boolean | yes | Whether the creator can fix the current state themselves. |
| `data[].operational` | boolean | yes | The one-word answer to "can it act right now?". |
| `data[].external_id` | string \| null | yes | The platform's own id for the installation's account, such as a bot id; null while pending. |
| `data[].display_name` | string \| null | yes | The platform's display name for the account; null while pending. |
| `data[].handle` | string \| null | yes | The platform's handle for the account, without a prefix; null while pending or where the platform has none. |
| `data[].avatar_url` | string \| null | yes | The account's avatar; null where none is known. |
| `data[].health_checked_at` | string \| null | yes | When the connector was last asked whether the installation still answers, ISO 8601. |
| `data[].doctor_ran_at` | string \| null | yes | When the doctor last ran on the installation, ISO 8601; null before the first run. |
| `data[].doctor_healthy` | boolean \| null | yes | Whether the last doctor report found every finding `ok`; null before the first run. |
| `data[].connected_at` | string \| null | yes | When the creator's credentials were bound, ISO 8601; null while pending. |
| `data[].verified_at` | string \| null | yes | When the installation last answered a probe, ISO 8601. |
| `data[].revoked_at` | string \| null | yes | When the platform withdrew the installation, ISO 8601; null unless revoked. |
| `data[].disconnected_at` | string \| null | yes | When the creator disconnected it, ISO 8601; null unless disconnected. |
| `data[].uninstalled_at` | string \| null | yes | When the connector was uninstalled from the project, ISO 8601; null while installed. |
| `data[].created_at` | string \| null | yes | When the installation was opened, ISO 8601. |
| `data[].capabilities` | object | yes | Every capability the connector declares, keyed by capability, each with `enabled` (the project's switch for this installation) and `toggleable` (whether the switch can be turned off at all; `access_control`, `early_admission_hold`, `management_surface`, `portal_login` and `creator_registration` cannot). |
| `data[].sales_paused` | boolean | yes | Whether a connector outage is open on this installation, refusing every checkout of a plan that unlocks a place on it. |
| `data[].outage` | object \| null | yes | The open outage while `sales_paused` is true, null otherwise. |
| `data[].outage.id` | string | yes | The outage's id. |
| `data[].outage.reason` | string | yes | The connector's reason code for the outage, such as a revoked or regenerated token. |
| `data[].outage.started_at` | string | yes | When the outage opened, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a project's installation of a connector
`GET /v1/projects/{project}/connectors/{key}/installation`
The project's installation of that connector in the live role, in whatever state, in the shape the installations list describes.
```bash
curl https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/connectors/example/installation \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
- Requires ability: `project-connector:view`
- MCP tools: `get_connector_installation`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `key` | path | string | yes | The connector key from the route. |
### Responses
- **200**: 200 with the installation.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The installation's id. |
| `data.connector` | string | yes | The connector's key. |
| `data.project_id` | string \| null | yes | The project the installation belongs to; null for a platform-scope presence. |
| `data.scope` | string | yes | `project` for an installation a creator set up, `platform` for a presence Subscriby runs. |
| `data.role` | string | yes | `live` for the installation that acts, `standby` for the spare the Disaster Recovery Program keeps. |
| `data.state` | string | yes | `pending` (installed, nothing connected yet), `connected`, `degraded` (the health probe found something wrong), `revoked` (the platform withdrew it) or `disconnected` (the creator did). |
| `data.state_reason` | string \| null | yes | The connector's reason code for a `degraded` or `revoked` state; null otherwise. |
| `data.state_detail` | string \| null | yes | The connector's sentence about the state, in the creator's language; null when there is nothing to say. |
| `data.creator_actionable` | boolean | yes | Whether the creator can fix the current state themselves. |
| `data.operational` | boolean | yes | The one-word answer to "can it act right now?". |
| `data.external_id` | string \| null | yes | The platform's own id for the installation's account, such as a bot id; null while pending. |
| `data.display_name` | string \| null | yes | The platform's display name for the account; null while pending. |
| `data.handle` | string \| null | yes | The platform's handle for the account, without a prefix; null while pending or where the platform has none. |
| `data.avatar_url` | string \| null | yes | The account's avatar; null where none is known. |
| `data.health_checked_at` | string \| null | yes | When the connector was last asked whether the installation still answers, ISO 8601. |
| `data.doctor_ran_at` | string \| null | yes | When the doctor last ran on the installation, ISO 8601; null before the first run. |
| `data.doctor_healthy` | boolean \| null | yes | Whether the last doctor report found every finding `ok`; null before the first run. |
| `data.connected_at` | string \| null | yes | When the creator's credentials were bound, ISO 8601; null while pending. |
| `data.verified_at` | string \| null | yes | When the installation last answered a probe, ISO 8601. |
| `data.revoked_at` | string \| null | yes | When the platform withdrew the installation, ISO 8601; null unless revoked. |
| `data.disconnected_at` | string \| null | yes | When the creator disconnected it, ISO 8601; null unless disconnected. |
| `data.uninstalled_at` | string \| null | yes | When the connector was uninstalled from the project, ISO 8601; null while installed. |
| `data.created_at` | string \| null | yes | When the installation was opened, ISO 8601. |
| `data.capabilities` | object | yes | Every capability the connector declares, keyed by capability, each with `enabled` (the project's switch for this installation) and `toggleable` (whether the switch can be turned off at all; `access_control`, `early_admission_hold`, `management_surface`, `portal_login` and `creator_registration` cannot). |
| `data.sales_paused` | boolean | yes | Whether a connector outage is open on this installation, refusing every checkout of a plan that unlocks a place on it. |
| `data.outage` | object \| null | yes | The open outage while `sales_paused` is true, null otherwise. |
| `data.outage.id` | string | yes | The outage's id. |
| `data.outage.reason` | string | yes | The connector's reason code for the outage, such as a revoked or regenerated token. |
| `data.outage.started_at` | string | yes | When the outage opened, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: with `reason: unknown_connector` when the key names no connector at all, so a client can tell a typo from a connector that is not set up here. `CONNECTOR_NOT_INSTALLED`: when the connector exists but the project does not run it.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Disconnect an installation
`DELETE /v1/projects/{project}/connectors/{key}/installation`
```bash
curl -X DELETE https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/connectors/example/installation \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
The connector withdraws the installation on its platform, the credentials are wiped and the installation turns `disconnected`; the row stays and can be connected again from the dashboard. Grants, resources, plans and identities are untouched, and members keep the access they hold. Answers `204`; an installation already disconnected is left alone. Disconnecting is not uninstalling: uninstalling, with its impact preview over the plans it would empty, is the separate act below.
- Requires ability: `project-connector:delete`
- Fires events: `connector.disconnected`
- MCP tools: `disconnect_connector`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `key` | path | string | yes | The connector key from the route. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: with `reason: unknown_connector`, or `404 CONNECTOR_NOT_INSTALLED` when the project does not run the connector.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Preview an uninstall
`GET /v1/projects/{project}/connectors/{key}/uninstall-preview`
Uninstall deletes nothing and touches no money by itself, and it is always previewed first:
```bash
curl https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/connectors/example/uninstall-preview \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
- `resources` are the connector's on the project (manual perks are never among them); they will be deactivated and marked `detached`, their external ids kept.
- `live_grants` are the pending, held and granted grants on those resources; each is revoked with reason `connector_uninstalled` and fires `member.resource_removed`.
- `emptied_plans` are the plans that would keep no active grantable resource; `recurring_subscriptions` and `one_time_subscriptions` are the live purchases on them, `affected_subscription_ids` names them.
- `upcoming_windows`, `open_support_threads` and `member_identities` are informational: pass windows are never cancelled for you (cancel them from the pass window endpoints, which handle refunds), support threads lose their reach, and member accounts are never removed.
- `options` are the two opt-ins with their defaults, exactly the names the `DELETE` takes.
- Requires ability: `project-connector:view`
- MCP tools: `get_connector_uninstall_preview`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `key` | path | string | yes | The connector key from the route. |
### Responses
- **200**: 200 with the preview.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.installation` | array of any | yes | The installation that would be uninstalled, in the installation object's shape. |
| `data.connector` | string | yes | The connector's key. |
| `data.resources` | array of object | yes | The connector's places on the project; manual perks are never among them. They would be deactivated and marked detached, their external ids kept. |
| `data.resources[].id` | string | yes | The resource's id. |
| `data.resources[].title` | string | yes | The resource's title. |
| `data.resources[].kind` | string | yes | The resource's kind, `:`. |
| `data.resources[].active` | boolean | yes | Whether the resource is active today. |
| `data.live_grants` | integer | yes | The pending, held and granted grants on those places; each would be revoked with reason `connector_uninstalled`, firing `member.resource_removed`. |
| `data.emptied_plans` | array of object | yes | The plans that would keep no active grantable resource. |
| `data.emptied_plans[].id` | string | yes | The plan's id. |
| `data.emptied_plans[].name` | string | yes | The plan's name. |
| `data.emptied_plans[].active` | boolean | yes | Whether the plan is on sale today. |
| `data.emptied_plans[].recurring` | boolean | yes | Whether the plan renews, so its live subscriptions could be cancelled by the opt-in. |
| `data.recurring_subscriptions` | integer | yes | How many live recurring purchases sit on the emptied plans. |
| `data.one_time_subscriptions` | integer | yes | How many live one-time or lifetime purchases sit on them; these are never cancelled. |
| `data.affected_subscription_ids` | array of string | yes | The ids of every live purchase on the emptied plans. |
| `data.upcoming_windows` | integer | yes | Informational: pass windows on the connector's places that have not run yet. They are never cancelled for you; cancel them from the pass window endpoints, which handle refunds. |
| `data.open_support_threads` | integer | yes | Informational: open support threads that would lose their reach. |
| `data.member_identities` | integer | yes | Informational: member accounts linked on the connector. They are never removed. |
| `data.empties_plans` | boolean | yes | Whether any plan would be left with nothing to grant. |
| `data.options` | object | yes | The two opt-ins with their defaults, exactly the names the `DELETE` body takes. |
| `data.options.unpublish_emptied_plans` | boolean | yes | Whether the emptied plans would be taken off sale; on by default. |
| `data.options.cancel_recurring_subscriptions` | boolean | yes | Whether their live recurring subscriptions would be cancelled at the end of the paid period; on by default. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: with `reason: unknown_connector`, or `404 CONNECTOR_NOT_INSTALLED` when the project does not run the connector.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Install a connector
`POST /v1/projects/{project}/connectors/{key}`
```bash
curl -X POST https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/connectors/example \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Installing and connecting are two acts. Installing says "this project runs this connector" and opens a **pending** installation with no credentials (`201` with the installation; `200` with the existing one when it was already installed, nothing changed). Connecting, which hands the connector the creator's credentials, stays on the dashboard, where the connector's declared `install_fields` are rendered and the platform is asked to describe the installation before anything is bound; a REST client cannot prove a credential is its own to bind. An uninstalled connector installed again comes back as pending with its settings kept.
- Requires ability: `project-connector:create`
- Fires events: `connector.installed`
- MCP tools: `install_connector`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `key` | path | string | yes | The connector key from the route. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 201 with the installation; 200 when it was already installed.
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `CONNECTOR_TIER_REQUIRED`: for a second distinct connector on the project when the **project owner's** plan lacks the `multi_connector` capability (Growth). The owner's plan is read, never the token holder's, because Teams lets a member run someone else's project.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: with `reason: unknown_connector` when the key names no connector.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `CONNECTOR_UNAVAILABLE`: when the key names no connector creators may install today: only a card whose `installable` is `true` qualifies; `error.context.connector` echoes the key.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Uninstall a connector
`DELETE /v1/projects/{project}/connectors/{key}`
```bash
curl -X DELETE https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/connectors/example \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"unpublish_emptied_plans": true, "cancel_recurring_subscriptions": false}'
```
In order: the connector puts every member outside while its credentials still work, the rows are retired in one transaction (grants revoked, resources detached, the installation kept with `uninstalled_at`), the connector withdraws the installation, and the two opt-ins run. `unpublish_emptied_plans` (default `true`) takes the emptied plans off sale, firing `plan.deactivated` per plan; `cancel_recurring_subscriptions` (default `true`) cancels their live recurring subscriptions at the end of the paid period, firing `subscription.cancelled` per subscription and emailing each member that their subscription will not renew. One-time and lifetime purchases are never cancelled; a refund stays the creator's call. Identities are never removed. `connector.uninstalled` fires with the counts. Installing the connector again brings the same installation back as pending with its settings kept.
The two opt-ins travel in the JSON body or as query parameters; on by default, they only need sending to switch one off.
- Requires ability: `project-connector:delete`
- Fires events: `connector.uninstalled`, `member.resource_removed`, `plan.deactivated`, `subscription.cancelled`
- MCP tools: `uninstall_connector`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `key` | path | string | yes | The connector key from the route. |
| `unpublish_emptied_plans` | query | boolean | no | Take the plans the uninstall leaves with nothing to grant off sale, firing `plan.deactivated` per plan. Defaults to true; send `false` to leave them on sale. |
| `cancel_recurring_subscriptions` | query | boolean | no | Cancel the live recurring subscriptions on those plans at the end of their paid period, firing `subscription.cancelled` per subscription and emailing each member. Defaults to true; one-time and lifetime purchases are never cancelled. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with the report.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.installation` | array of any | yes | The installation as it stands afterwards, `disconnected` with `uninstalled_at` set, in the installation object's shape. |
| `data.connector` | string | yes | The connector's key. |
| `data.grants_revoked` | integer | yes | How many grants were revoked. |
| `data.resources_detached` | integer | yes | How many places were detached. |
| `data.plans_unpublished` | integer | yes | How many emptied plans were taken off sale by the opt-in. |
| `data.subscriptions_cancelled` | integer | yes | How many recurring subscriptions were cancelled at the end of their paid period by the opt-in. |
| `data.members_notified` | integer | yes | How many members were emailed that their subscription will not renew. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: with `reason: unknown_connector`, or `404 CONNECTOR_NOT_INSTALLED` when the project does not run the connector.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Verify an installation
`POST /v1/projects/{project}/connectors/{key}/installation/verify`
```bash
curl -X POST https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/connectors/example/installation/verify \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
A fresh probe rather than the hourly one: the connector is asked whether the installation still answers and the verdict is recorded, so `state` becomes `connected`, `degraded` (with `state_reason`, `state_detail` and `creator_actionable`) or `revoked`, and `health_checked_at` is stamped. Answers `200` with the installation. A `pending` installation has nothing to verify and is returned as it is. `connector.status_changed` fires only when the state moved.
- Requires ability: `project-connector:update`
- Fires events: `connector.status_changed`
- MCP tools: `verify_connector_installation`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `key` | path | string | yes | The connector key from the route. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with the installation as recorded.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The installation's id. |
| `data.connector` | string | yes | The connector's key. |
| `data.project_id` | string \| null | yes | The project the installation belongs to; null for a platform-scope presence. |
| `data.scope` | string | yes | `project` for an installation a creator set up, `platform` for a presence Subscriby runs. |
| `data.role` | string | yes | `live` for the installation that acts, `standby` for the spare the Disaster Recovery Program keeps. |
| `data.state` | string | yes | `pending` (installed, nothing connected yet), `connected`, `degraded` (the health probe found something wrong), `revoked` (the platform withdrew it) or `disconnected` (the creator did). |
| `data.state_reason` | string \| null | yes | The connector's reason code for a `degraded` or `revoked` state; null otherwise. |
| `data.state_detail` | string \| null | yes | The connector's sentence about the state, in the creator's language; null when there is nothing to say. |
| `data.creator_actionable` | boolean | yes | Whether the creator can fix the current state themselves. |
| `data.operational` | boolean | yes | The one-word answer to "can it act right now?". |
| `data.external_id` | string \| null | yes | The platform's own id for the installation's account, such as a bot id; null while pending. |
| `data.display_name` | string \| null | yes | The platform's display name for the account; null while pending. |
| `data.handle` | string \| null | yes | The platform's handle for the account, without a prefix; null while pending or where the platform has none. |
| `data.avatar_url` | string \| null | yes | The account's avatar; null where none is known. |
| `data.health_checked_at` | string \| null | yes | When the connector was last asked whether the installation still answers, ISO 8601. |
| `data.doctor_ran_at` | string \| null | yes | When the doctor last ran on the installation, ISO 8601; null before the first run. |
| `data.doctor_healthy` | boolean \| null | yes | Whether the last doctor report found every finding `ok`; null before the first run. |
| `data.connected_at` | string \| null | yes | When the creator's credentials were bound, ISO 8601; null while pending. |
| `data.verified_at` | string \| null | yes | When the installation last answered a probe, ISO 8601. |
| `data.revoked_at` | string \| null | yes | When the platform withdrew the installation, ISO 8601; null unless revoked. |
| `data.disconnected_at` | string \| null | yes | When the creator disconnected it, ISO 8601; null unless disconnected. |
| `data.uninstalled_at` | string \| null | yes | When the connector was uninstalled from the project, ISO 8601; null while installed. |
| `data.created_at` | string \| null | yes | When the installation was opened, ISO 8601. |
| `data.capabilities` | object | yes | Every capability the connector declares, keyed by capability, each with `enabled` (the project's switch for this installation) and `toggleable` (whether the switch can be turned off at all; `access_control`, `early_admission_hold`, `management_surface`, `portal_login` and `creator_registration` cannot). |
| `data.sales_paused` | boolean | yes | Whether a connector outage is open on this installation, refusing every checkout of a plan that unlocks a place on it. |
| `data.outage` | object \| null | yes | The open outage while `sales_paused` is true, null otherwise. |
| `data.outage.id` | string | yes | The outage's id. |
| `data.outage.reason` | string | yes | The connector's reason code for the outage, such as a revoked or regenerated token. |
| `data.outage.started_at` | string | yes | When the outage opened, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: with `reason: unknown_connector`, or `404 CONNECTOR_NOT_INSTALLED` when the project does not run the connector.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Run the connector doctor
`POST /v1/projects/{project}/connectors/{key}/installation/doctor`
```bash
curl -X POST https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/connectors/example/installation/doctor \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
The **Run Doctor** button of the dashboard. The installation is verified exactly as the verify endpoint does it, then the connector is asked about every resource the installation gates, and the answer is one report: a finding for the installation, then one per resource, each with a `severity` (`ok`, `warning`, `critical`), the connector's own `state` word, a sentence in the creator's language, whether the creator can fix it (`creator_actionable`) and where (`fix_url`). A resource the connector has no place for is a `warning`, never silence. `healthy` is `true` when every finding is `ok`.
The report is kept on the installation (`doctor_ran_at` and `doctor_healthy` on the installation object) and `connector.doctor_completed` fires only when the findings differ from the previous run's.
- Requires ability: `project-connector:update`
- Fires events: `connector.doctor_completed`
- MCP tools: `run_connector_doctor`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `key` | path | string | yes | The connector key. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with the report.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.installation_id` | string | yes | The installation the doctor ran on. |
| `data.connector` | string | yes | The connector's key. |
| `data.project_id` | string | yes | The project the installation belongs to. |
| `data.ran_at` | string | yes | When the report was produced, ISO 8601. |
| `data.healthy` | boolean | yes | True when every finding is `ok`. |
| `data.critical` | integer | yes | How many findings are `critical`. |
| `data.warnings` | integer | yes | How many findings are `warning`. |
| `data.findings` | array of any | yes | The findings, the installation's first. Each carries `key` (`installation`, or `resource:`), `kind` (`installation` or `resource`), `severity` (`ok`, `warning`, `critical`) with its `severity_label`, the `subject` (the account or the place), the connector's own `state` word, a `message` in the creator's language, `creator_actionable` (whether the creator can fix it), `fix_url` (where, or null) and `resource_id` (null for the installation finding). A resource the connector has no place for is a `warning`, never silence. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: with `reason: unknown_connector`, or `404 CONNECTOR_NOT_INSTALLED` when the project does not run the connector.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Restore access after a reinstall
`POST /v1/projects/{project}/connectors/{key}/installation/restore-access`
```bash
curl -X POST https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/connectors/example/installation/restore-access \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
The **Restore Access** item of the dashboard's Connectors tab, offered while an uninstall has left the project detached places on the connector. Once the connector is installed and connected again, every detached place is asked about through the connector; the ones it still controls are made active and healthy again, and every live purchase of a plan that grants them is handed fresh access: only the grants the purchase lacks are issued, which are exactly the ones the uninstall took away, and `member.resource_added` fires per grant. A place the connector no longer controls stays detached for the doctor to explain. Plans the uninstall took off sale stay off sale until you publish them again.
- Requires ability: `project-connector:update`
- Fires events: `member.resource_added`
- MCP tools: `restore_connector_access`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `key` | path | string | yes | The connector key. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with the report.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.installation` | array of any | yes | The installation the connector was asked through, in the installation object's shape. |
| `data.connector` | string | yes | The connector's key. |
| `data.resources` | array of object | yes | Every place the uninstall had detached, with whether the connector still controls it. |
| `data.resources[].resource_id` | string | yes | The resource's id. |
| `data.resources[].title` | string | yes | The resource's title. |
| `data.resources[].restored` | boolean | yes | True when the connector still controls the place and it was made active and healthy again; false when it stays detached for the doctor to explain. |
| `data.resources_reactivated` | integer | yes | How many places came back. |
| `data.resources_still_detached` | integer | yes | How many places the connector no longer controls. |
| `data.subscriptions_reissued` | integer | yes | How many live purchases were handed the grants the uninstall took away. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: with `reason: unknown_connector`, or `404 CONNECTOR_NOT_INSTALLED` when the project has no installation of the connector.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `CONNECTOR_NOT_CONNECTED`: while the installation is pending or disconnected.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Change an installation's settings
`PATCH /v1/projects/{project}/connectors/{key}/installation/settings`
```bash
curl -X PATCH https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/connectors/example/installation/settings \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"settings": {"greeting": "Welcome aboard"}, "capabilities": {"broadcasts": false}}'
```
`settings` is an object keyed by the field names the connector declares in its `settings_fields` (read them from the card). Every declared rule runs; a key the connector never declared is `422 VALIDATION_FAILED` naming it; fields left out keep their value.
`capabilities` is an object keyed by capability to `true` or `false`: the project's switches for what the connector may do on this installation. A project keeps a connector for access alone by switching off `messaging`, `broadcasts`, `support_relay`, `native_payments` or any `recovery_*` facet; every core surface asks the switch before it invokes the connector, so a switched-off broadcast is refused before it is queued and a switched-off health check is skipped. A capability the connector does not declare, or one that cannot be switched off (`access_control`, `early_admission_hold`, `management_surface`, `portal_login`, `creator_registration`), is `422 VALIDATION_FAILED` naming it; switches left out keep their value. At least one of `settings` and `capabilities` is required.
Answers `200` with the installation, which never carries the settings themselves because a settings field may be a secret, but does carry `capabilities`: every capability the connector declares, each with `enabled` and `toggleable`. `connector.settings_updated` fires naming the fields and the switches (`capabilities.`) that changed, only when something did.
- Requires ability: `project-connector:update`
- Fires events: `connector.settings_updated`
- MCP tools: `update_connector_installation_settings`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `key` | path | string | yes | The connector key from the route. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
The settings and capability switches to change on an installation. At least one of the two is required; keys left out keep their value.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `settings` | array of string | no | An object keyed by the field names the connector declares in its `settings_fields` (read them from the connector's card). Every declared rule runs; a key the connector never declared is `422 VALIDATION_FAILED` naming it. |
| `capabilities` | array of boolean | no | An object keyed by capability to `true` or `false`: the project's switches for what the connector may do on this installation. A capability the connector does not declare, or one that cannot be switched off (`access_control`, `early_admission_hold`, `management_surface`, `portal_login`, `creator_registration`), is `422 VALIDATION_FAILED` naming it. |
### Responses
- **200**: 200 with the installation.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The installation's id. |
| `data.connector` | string | yes | The connector's key. |
| `data.project_id` | string \| null | yes | The project the installation belongs to; null for a platform-scope presence. |
| `data.scope` | string | yes | `project` for an installation a creator set up, `platform` for a presence Subscriby runs. |
| `data.role` | string | yes | `live` for the installation that acts, `standby` for the spare the Disaster Recovery Program keeps. |
| `data.state` | string | yes | `pending` (installed, nothing connected yet), `connected`, `degraded` (the health probe found something wrong), `revoked` (the platform withdrew it) or `disconnected` (the creator did). |
| `data.state_reason` | string \| null | yes | The connector's reason code for a `degraded` or `revoked` state; null otherwise. |
| `data.state_detail` | string \| null | yes | The connector's sentence about the state, in the creator's language; null when there is nothing to say. |
| `data.creator_actionable` | boolean | yes | Whether the creator can fix the current state themselves. |
| `data.operational` | boolean | yes | The one-word answer to "can it act right now?". |
| `data.external_id` | string \| null | yes | The platform's own id for the installation's account, such as a bot id; null while pending. |
| `data.display_name` | string \| null | yes | The platform's display name for the account; null while pending. |
| `data.handle` | string \| null | yes | The platform's handle for the account, without a prefix; null while pending or where the platform has none. |
| `data.avatar_url` | string \| null | yes | The account's avatar; null where none is known. |
| `data.health_checked_at` | string \| null | yes | When the connector was last asked whether the installation still answers, ISO 8601. |
| `data.doctor_ran_at` | string \| null | yes | When the doctor last ran on the installation, ISO 8601; null before the first run. |
| `data.doctor_healthy` | boolean \| null | yes | Whether the last doctor report found every finding `ok`; null before the first run. |
| `data.connected_at` | string \| null | yes | When the creator's credentials were bound, ISO 8601; null while pending. |
| `data.verified_at` | string \| null | yes | When the installation last answered a probe, ISO 8601. |
| `data.revoked_at` | string \| null | yes | When the platform withdrew the installation, ISO 8601; null unless revoked. |
| `data.disconnected_at` | string \| null | yes | When the creator disconnected it, ISO 8601; null unless disconnected. |
| `data.uninstalled_at` | string \| null | yes | When the connector was uninstalled from the project, ISO 8601; null while installed. |
| `data.created_at` | string \| null | yes | When the installation was opened, ISO 8601. |
| `data.capabilities` | object | yes | Every capability the connector declares, keyed by capability, each with `enabled` (the project's switch for this installation) and `toggleable` (whether the switch can be turned off at all; `access_control`, `early_admission_hold`, `management_surface`, `portal_login` and `creator_registration` cannot). |
| `data.sales_paused` | boolean | yes | Whether a connector outage is open on this installation, refusing every checkout of a plan that unlocks a place on it. |
| `data.outage` | object \| null | yes | The open outage while `sales_paused` is true, null otherwise. |
| `data.outage.id` | string | yes | The outage's id. |
| `data.outage.reason` | string | yes | The connector's reason code for the outage, such as a revoked or regenerated token. |
| `data.outage.started_at` | string | yes | When the outage opened, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: with `reason: unknown_connector`, or `404 CONNECTOR_NOT_INSTALLED` when the project does not run the connector.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when both `settings` and `capabilities` are missing, a settings key the connector never declared is sent, a declared rule fails, or a capability is undeclared or cannot be switched off; `error.fields` names the key.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources): The places a project gates on its connectors.
- [connector.* events](https://docs.subscriby.net/webhooks/v1/events/connector): connector.*.
- [Ability Catalog](https://docs.subscriby.net/api/v1/abilities): project-connector:*.
- [Error Codes](https://docs.subscriby.net/api/v1/errors#connectors): Every write here answers 404 RESOURCE_NOT_FOUND with reason: unknown_connector for a key that names no connector, and 404 CONNECTOR_NOT_INSTALLED for one the project does not run.
- [Resources Reference](https://docs.subscriby.net/mcp/v1/resources-reference): subscriby://connectors/catalog carries the whole directory for an agent to read at session start.
---
# Coupons API
Source: https://docs.subscriby.net/api/v1/reference/coupons
A coupon is one code many subscribers can redeem for money off at checkout: the multi-redemption counterpart to an [access code](https://docs.subscriby.net/api/v1/reference/access-codes), which is one code for one person and grants access outright without a payment. Coupons live under a project at `/v1/projects/{project}/coupons`.
Coupons require the **Coupons Addon**, or a Growth plan. Every write is refused with `403 TEAM_TIER_REQUIRED` if the creator is not entitled, and entitlement is re-checked again when a subscriber actually redeems.
## Endpoints
- `GET /v1/projects/{project}/coupons` — [List a project's coupons](#list-a-projects-coupons)
- `POST /v1/projects/{project}/coupons` — [Create a coupon](#create-a-coupon)
- `GET /v1/projects/{project}/coupons/{coupon}` — [Get a coupon](#get-a-coupon)
- `PATCH /v1/projects/{project}/coupons/{coupon}` — [Update a coupon](#update-a-coupon)
- `DELETE /v1/projects/{project}/coupons/{coupon}` — [Delete a coupon](#delete-a-coupon)
- `POST /v1/projects/{project}/coupons/{coupon}/activate` — [Activate a coupon](#activate-a-coupon)
- `POST /v1/projects/{project}/coupons/{coupon}/deactivate` — [Deactivate a coupon](#deactivate-a-coupon)
## List a project's coupons
`GET /v1/projects/{project}/coupons`
```bash
curl "https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/coupons?active=true&per_page=25&page=1" \
-H "Authorization: Bearer sbt_..."
```
Pages the project's coupons, newest first. `active` filters on the creator's on/off switch only; `code` finds one exact code.
> `active=true` is not the same as redeemable. A coupon can be switched on and still not apply: outside its window, fully claimed, or capped for that subscriber. Read `redeemable` on each row for the combined answer.
- Requires ability: `project-coupon:view-any`
- MCP tools: `list_coupons`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `active` | query | boolean | no | Keep only coupons whose on/off switch is in this position. Not the same as redeemable: read `redeemable` on each row for the combined answer. |
| `code` | query | string | no | Find one coupon by its exact code, compared in upper case. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: The page.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The coupon's id. |
| `data[].project_id` | string | yes | The project the coupon belongs to; codes are unique within a project, not across projects. |
| `data[].code` | string | yes | The code subscribers type at checkout, stored and matched in upper case. |
| `data[].name` | string | yes | A creator-facing label, never shown to subscribers. |
| `data[].discount` | object | yes | What comes off the price. |
| `data[].discount.type` | string | yes | `percentage` or `fixed`. |
| `data[].discount.value` | string | yes | A decimal string with four places: `"25.0000"` for 25% off, `"10.0000"` for ten of the coupon's currency. Parse it as a decimal, never a float. |
| `data[].discount.currency_id` | string \| null | yes | The currency a `fixed` discount is denominated in; null on a `percentage` coupon, which applies to a plan in any currency. |
| `data[].discount.currency` | string \| null | no | That currency's ISO 4217 code, present only when the relation is loaded: on a single read, a create and an update, not on every list row. |
| `data[].duration` | string | yes | Always `once`: only first-payment discounts exist today. |
| `data[].limits` | object | yes | How often and on what the code may be used. |
| `data[].limits.max_redemptions` | integer \| null | yes | The total cap across everyone; null for unlimited. |
| `data[].limits.max_redemptions_per_user` | integer | yes | How many times one member may redeem the code. |
| `data[].limits.minimum_amount` | string \| null | yes | The order floor, in the plan's own currency; null for none. |
| `data[].redemptions` | object | yes | How far the cap has been used. |
| `data[].redemptions.count` | integer | yes | Settled redemptions so far. |
| `data[].redemptions.remaining` | integer \| null | yes | Derived, not stored: the cap minus settled redemptions and reservations whose hold has not expired, so an in-flight checkout is already counted and an abandoned one gives its slot back. Prefer it over your own subtraction, which will disagree. Null when uncapped. |
| `data[].redemptions.exhausted` | boolean | yes | Whether nothing remains under the cap. |
| `data[].window` | object | yes | When the code can be redeemed. |
| `data[].window.starts_at` | string \| null | yes | When redemption opens, ISO 8601; null for immediately. |
| `data[].window.expires_at` | string \| null | yes | When redemption closes, ISO 8601; null for never. |
| `data[].plan_ids` | object | yes | The plans the code is restricted to. An empty array means **every** plan in the project, including plans added later, not no plans. |
| `data[].active` | boolean | yes | The creator's on/off switch. Not the same as redeemable. |
| `data[].redeemable` | boolean | yes | The combined answer: switched on, inside its window, not exhausted. Read this rather than `active` to know whether the code applies right now. |
| `data[].created_at` | string \| null | yes | When the coupon was created, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When it last changed, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Create a coupon
`POST /v1/projects/{project}/coupons`
```bash
curl -X POST https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/coupons \
-H "Authorization: Bearer sbt_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"code": "BLACKFRIDAY",
"name": "Black Friday 2026",
"discount_type": "percentage",
"discount_value": 25,
"max_redemptions": 500,
"max_redemptions_per_user": 1,
"starts_at": "2026-11-27T00:00:00Z",
"expires_at": "2026-12-01T00:00:00Z",
"active": true
}'
```
Answers `201` with the coupon. `duration` is not yours to set: only first-payment discounts exist today, so it is always `once` on the way out.
> **`code` is uppercased before the uniqueness check.** Sending `blackfriday` when `BLACKFRIDAY` exists in the project is a `422`, not a second coupon. Uniqueness is per project, so two different projects may both have `BLACKFRIDAY`.
- Requires ability: `project-coupon:create`
- Fires events: `coupon.created`
- MCP tools: `create_coupon`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A coupon to create: the code, what comes off, how often it may be used and when.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `code` | string | yes | The code subscribers type, 3 to 64 letters, numbers and dashes. Stored and matched in upper case, and uppercased before the uniqueness check: `blackfriday` when `BLACKFRIDAY` exists in the project is refused, not a second coupon. Two projects may both hold the same code. |
| `name` | string | yes | A creator-facing label, 3 to 255 characters, never shown to subscribers. |
| `discount_type` | `percentage`, `fixed` | yes | `percentage` or `fixed`. |
| `discount_value` | number | yes | Greater than 0; at most 100 when `discount_type` is `percentage`. |
| `currency_id` | string \| null | no | Required when `discount_type` is `fixed`, and it must match the currency of the plans the coupon applies to. Refused as meaningless on a percentage coupon. |
| `duration` | `once` | no | Accepted for forward compatibility but only `once` is honoured: first-payment discounts are the only kind that exist today. |
| `max_redemptions` | integer \| null | no | The total cap across everyone, 1 to 1,000,000. Omit or send null for unlimited. |
| `max_redemptions_per_user` | integer | no | How many times one member may redeem the code, 1 to 1,000. Defaults to 1. |
| `minimum_amount` | number \| null | no | The order floor, in the plan's own currency; greater than 0. |
| `starts_at` | string \| null (date-time) | no | When redemption opens, ISO 8601. Omit for immediately. |
| `expires_at` | string \| null (date-time) | no | When redemption closes, ISO 8601; must be after `starts_at`. Omit for never. |
| `active` | boolean | no | Whether the code can be redeemed from the start. Defaults to true. |
| `plan_ids` | array of string | no | Restrict the code to these plans of the project, at most 200. Omit or send `[]` for every plan, including plans added later. |
### Responses
- **201**: The new coupon.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The coupon's id. |
| `data.project_id` | string | yes | The project the coupon belongs to; codes are unique within a project, not across projects. |
| `data.code` | string | yes | The code subscribers type at checkout, stored and matched in upper case. |
| `data.name` | string | yes | A creator-facing label, never shown to subscribers. |
| `data.discount` | object | yes | What comes off the price. |
| `data.discount.type` | string | yes | `percentage` or `fixed`. |
| `data.discount.value` | string | yes | A decimal string with four places: `"25.0000"` for 25% off, `"10.0000"` for ten of the coupon's currency. Parse it as a decimal, never a float. |
| `data.discount.currency_id` | string \| null | yes | The currency a `fixed` discount is denominated in; null on a `percentage` coupon, which applies to a plan in any currency. |
| `data.discount.currency` | string \| null | no | That currency's ISO 4217 code, present only when the relation is loaded: on a single read, a create and an update, not on every list row. |
| `data.duration` | string | yes | Always `once`: only first-payment discounts exist today. |
| `data.limits` | object | yes | How often and on what the code may be used. |
| `data.limits.max_redemptions` | integer \| null | yes | The total cap across everyone; null for unlimited. |
| `data.limits.max_redemptions_per_user` | integer | yes | How many times one member may redeem the code. |
| `data.limits.minimum_amount` | string \| null | yes | The order floor, in the plan's own currency; null for none. |
| `data.redemptions` | object | yes | How far the cap has been used. |
| `data.redemptions.count` | integer | yes | Settled redemptions so far. |
| `data.redemptions.remaining` | integer \| null | yes | Derived, not stored: the cap minus settled redemptions and reservations whose hold has not expired, so an in-flight checkout is already counted and an abandoned one gives its slot back. Prefer it over your own subtraction, which will disagree. Null when uncapped. |
| `data.redemptions.exhausted` | boolean | yes | Whether nothing remains under the cap. |
| `data.window` | object | yes | When the code can be redeemed. |
| `data.window.starts_at` | string \| null | yes | When redemption opens, ISO 8601; null for immediately. |
| `data.window.expires_at` | string \| null | yes | When redemption closes, ISO 8601; null for never. |
| `data.plan_ids` | object | yes | The plans the code is restricted to. An empty array means **every** plan in the project, including plans added later, not no plans. |
| `data.active` | boolean | yes | The creator's on/off switch. Not the same as redeemable. |
| `data.redeemable` | boolean | yes | The combined answer: switched on, inside its window, not exhausted. Read this rather than `active` to know whether the code applies right now. |
| `data.created_at` | string \| null | yes | When the coupon was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when the creator has neither the Coupons Addon nor a Growth plan.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when a field is invalid, the code is taken in the project, a fixed discount has no currency, or a percentage is above 100; `error.fields` names the key.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a coupon
`GET /v1/projects/{project}/coupons/{coupon}`
One coupon with its currency ISO code loaded. A coupon of another project, or a deleted one, is `404 RESOURCE_NOT_FOUND`.
### Three fields that are easy to misread
> **`plan_ids: []` means every plan, not no plans.** An empty array is how the coupon itself reads an empty plan relation: the code applies to **every** plan in the project, including plans added later. Treating it as "restricted to nothing" inverts the meaning.
**`redemptions.remaining` is derived, not stored.** Prefer it over computing `max_redemptions - count` yourself. The quota counts settled redemptions *plus* reservations whose hold has not expired, so an in-flight checkout is already accounted for, and an abandoned one returns its slot without any sweeper job. Your own subtraction will disagree.
**`discount.value` is a decimal string with four places**: `"25.0000"` for 25% off, `"10.0000"` for ten of the coupon's currency. Parse it as a decimal, never a float. `currency_id` is `null` on a percentage coupon and set on a fixed-amount one, because a percentage applies to a plan in any currency and a fixed amount only to plans priced in its own.
`currency` (the ISO code) is present only when the relation is loaded: on a single read, a create and an update, not on every list row.
- Requires ability: `project-coupon:view`
- MCP tools: `get_coupon`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `coupon` | path | string (uuid) | yes | The coupon, resolved within the project by the route binder. |
### Responses
- **200**: The coupon.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The coupon's id. |
| `data.project_id` | string | yes | The project the coupon belongs to; codes are unique within a project, not across projects. |
| `data.code` | string | yes | The code subscribers type at checkout, stored and matched in upper case. |
| `data.name` | string | yes | A creator-facing label, never shown to subscribers. |
| `data.discount` | object | yes | What comes off the price. |
| `data.discount.type` | string | yes | `percentage` or `fixed`. |
| `data.discount.value` | string | yes | A decimal string with four places: `"25.0000"` for 25% off, `"10.0000"` for ten of the coupon's currency. Parse it as a decimal, never a float. |
| `data.discount.currency_id` | string \| null | yes | The currency a `fixed` discount is denominated in; null on a `percentage` coupon, which applies to a plan in any currency. |
| `data.discount.currency` | string \| null | no | That currency's ISO 4217 code, present only when the relation is loaded: on a single read, a create and an update, not on every list row. |
| `data.duration` | string | yes | Always `once`: only first-payment discounts exist today. |
| `data.limits` | object | yes | How often and on what the code may be used. |
| `data.limits.max_redemptions` | integer \| null | yes | The total cap across everyone; null for unlimited. |
| `data.limits.max_redemptions_per_user` | integer | yes | How many times one member may redeem the code. |
| `data.limits.minimum_amount` | string \| null | yes | The order floor, in the plan's own currency; null for none. |
| `data.redemptions` | object | yes | How far the cap has been used. |
| `data.redemptions.count` | integer | yes | Settled redemptions so far. |
| `data.redemptions.remaining` | integer \| null | yes | Derived, not stored: the cap minus settled redemptions and reservations whose hold has not expired, so an in-flight checkout is already counted and an abandoned one gives its slot back. Prefer it over your own subtraction, which will disagree. Null when uncapped. |
| `data.redemptions.exhausted` | boolean | yes | Whether nothing remains under the cap. |
| `data.window` | object | yes | When the code can be redeemed. |
| `data.window.starts_at` | string \| null | yes | When redemption opens, ISO 8601; null for immediately. |
| `data.window.expires_at` | string \| null | yes | When redemption closes, ISO 8601; null for never. |
| `data.plan_ids` | object | yes | The plans the code is restricted to. An empty array means **every** plan in the project, including plans added later, not no plans. |
| `data.active` | boolean | yes | The creator's on/off switch. Not the same as redeemable. |
| `data.redeemable` | boolean | yes | The combined answer: switched on, inside its window, not exhausted. Read this rather than `active` to know whether the code applies right now. |
| `data.created_at` | string \| null | yes | When the coupon was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Update a coupon
`PATCH /v1/projects/{project}/coupons/{coupon}`
Accepts the same fields as create, all optional. Changing terms is **not retroactive**: subscribers who already redeemed keep exactly what they paid, and the change applies to redemptions from that point on.
Raising `max_redemptions` on an exhausted coupon makes it redeemable again. Lowering it below the current count does not claw anything back; the coupon simply reports `remaining: 0`. Answers `200` with the coupon.
- Requires ability: `project-coupon:update`
- Fires events: `coupon.updated`
- MCP tools: `update_coupon`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `coupon` | path | string (uuid) | yes | The coupon, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
The changes to a coupon: any subset of the create shape. A change is never retroactive; subscribers who already redeemed keep exactly what they paid.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `code` | string | no | A new code, 3 to 64 letters, numbers and dashes, uppercased before the uniqueness check against the project's other live coupons. |
| `name` | string | no | A new creator-facing label, 3 to 255 characters. |
| `discount_type` | `percentage`, `fixed` | no | `percentage` or `fixed`. |
| `discount_value` | number | no | Greater than 0; at most 100 when the coupon is, or becomes, a percentage. |
| `currency_id` | string \| null | no | Required when the coupon is, or becomes, `fixed` and it has no currency yet; refused as meaningless on a percentage coupon. |
| `duration` | `once` | no | Accepted for forward compatibility but only `once` is honoured. |
| `max_redemptions` | integer \| null | no | The new total cap, 1 to 1,000,000, or null for unlimited. Raising it on an exhausted coupon makes it redeemable again; lowering it below the current count claws nothing back, the coupon simply reports `remaining: 0`. |
| `max_redemptions_per_user` | integer | no | How many times one member may redeem the code, 1 to 1,000. |
| `minimum_amount` | number \| null | no | The order floor, in the plan's own currency; greater than 0, or null for none. |
| `starts_at` | string \| null (date-time) | no | When redemption opens, ISO 8601, or null for immediately. |
| `expires_at` | string \| null (date-time) | no | When redemption closes, ISO 8601, or null for never. |
| `active` | boolean | no | The on/off switch; prefer the activate and deactivate endpoints, which fire their own events. |
| `plan_ids` | array of string | no | The plans to restrict the code to, at most 200; `[]` opens it to every plan. |
### Responses
- **200**: The coupon after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The coupon's id. |
| `data.project_id` | string | yes | The project the coupon belongs to; codes are unique within a project, not across projects. |
| `data.code` | string | yes | The code subscribers type at checkout, stored and matched in upper case. |
| `data.name` | string | yes | A creator-facing label, never shown to subscribers. |
| `data.discount` | object | yes | What comes off the price. |
| `data.discount.type` | string | yes | `percentage` or `fixed`. |
| `data.discount.value` | string | yes | A decimal string with four places: `"25.0000"` for 25% off, `"10.0000"` for ten of the coupon's currency. Parse it as a decimal, never a float. |
| `data.discount.currency_id` | string \| null | yes | The currency a `fixed` discount is denominated in; null on a `percentage` coupon, which applies to a plan in any currency. |
| `data.discount.currency` | string \| null | no | That currency's ISO 4217 code, present only when the relation is loaded: on a single read, a create and an update, not on every list row. |
| `data.duration` | string | yes | Always `once`: only first-payment discounts exist today. |
| `data.limits` | object | yes | How often and on what the code may be used. |
| `data.limits.max_redemptions` | integer \| null | yes | The total cap across everyone; null for unlimited. |
| `data.limits.max_redemptions_per_user` | integer | yes | How many times one member may redeem the code. |
| `data.limits.minimum_amount` | string \| null | yes | The order floor, in the plan's own currency; null for none. |
| `data.redemptions` | object | yes | How far the cap has been used. |
| `data.redemptions.count` | integer | yes | Settled redemptions so far. |
| `data.redemptions.remaining` | integer \| null | yes | Derived, not stored: the cap minus settled redemptions and reservations whose hold has not expired, so an in-flight checkout is already counted and an abandoned one gives its slot back. Prefer it over your own subtraction, which will disagree. Null when uncapped. |
| `data.redemptions.exhausted` | boolean | yes | Whether nothing remains under the cap. |
| `data.window` | object | yes | When the code can be redeemed. |
| `data.window.starts_at` | string \| null | yes | When redemption opens, ISO 8601; null for immediately. |
| `data.window.expires_at` | string \| null | yes | When redemption closes, ISO 8601; null for never. |
| `data.plan_ids` | object | yes | The plans the code is restricted to. An empty array means **every** plan in the project, including plans added later, not no plans. |
| `data.active` | boolean | yes | The creator's on/off switch. Not the same as redeemable. |
| `data.redeemable` | boolean | yes | The combined answer: switched on, inside its window, not exhausted. Read this rather than `active` to know whether the code applies right now. |
| `data.created_at` | string \| null | yes | When the coupon was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when the creator has neither the Coupons Addon nor a Growth plan.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when a field is invalid or the new code is taken by another live coupon of the project.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Delete a coupon
`DELETE /v1/projects/{project}/coupons/{coupon}`
```bash
curl -X DELETE https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/coupons/4b9d3e08-a1f6-4275-9c83-7e01d6a2f594 \
-H "Authorization: Bearer sbt_..." \
-H "Idempotency-Key: $(uuidgen)"
```
Removes the code and its redemption history; deactivating keeps both. Answers `204` with no body.
> **A delete can be refused.** If any subscriber holds a live reservation, a checkout in flight with this discount already quoted to a payment provider, the delete answers `422` and asks you to deactivate instead.
- Requires ability: `project-coupon:delete`
- Fires events: `coupon.deleted`
- MCP tools: `delete_coupon`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `coupon` | path | string (uuid) | yes | The coupon, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when the creator has neither the Coupons Addon nor a Growth plan.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when a checkout with the code is still in flight; `error.context.coupon` carries the reason.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Activate a coupon
`POST /v1/projects/{project}/coupons/{coupon}/activate`
Switches the code on so new redemptions are accepted again, within its window and cap. Emits `coupon.activated` rather than `coupon.updated`, so an automation watching availability has one event to subscribe to. Answers `200` with the coupon.
- Requires ability: `project-coupon:update`
- Fires events: `coupon.activated`
- MCP tools: `activate_coupon`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `coupon` | path | string (uuid) | yes | The coupon, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The coupon after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The coupon's id. |
| `data.project_id` | string | yes | The project the coupon belongs to; codes are unique within a project, not across projects. |
| `data.code` | string | yes | The code subscribers type at checkout, stored and matched in upper case. |
| `data.name` | string | yes | A creator-facing label, never shown to subscribers. |
| `data.discount` | object | yes | What comes off the price. |
| `data.discount.type` | string | yes | `percentage` or `fixed`. |
| `data.discount.value` | string | yes | A decimal string with four places: `"25.0000"` for 25% off, `"10.0000"` for ten of the coupon's currency. Parse it as a decimal, never a float. |
| `data.discount.currency_id` | string \| null | yes | The currency a `fixed` discount is denominated in; null on a `percentage` coupon, which applies to a plan in any currency. |
| `data.discount.currency` | string \| null | no | That currency's ISO 4217 code, present only when the relation is loaded: on a single read, a create and an update, not on every list row. |
| `data.duration` | string | yes | Always `once`: only first-payment discounts exist today. |
| `data.limits` | object | yes | How often and on what the code may be used. |
| `data.limits.max_redemptions` | integer \| null | yes | The total cap across everyone; null for unlimited. |
| `data.limits.max_redemptions_per_user` | integer | yes | How many times one member may redeem the code. |
| `data.limits.minimum_amount` | string \| null | yes | The order floor, in the plan's own currency; null for none. |
| `data.redemptions` | object | yes | How far the cap has been used. |
| `data.redemptions.count` | integer | yes | Settled redemptions so far. |
| `data.redemptions.remaining` | integer \| null | yes | Derived, not stored: the cap minus settled redemptions and reservations whose hold has not expired, so an in-flight checkout is already counted and an abandoned one gives its slot back. Prefer it over your own subtraction, which will disagree. Null when uncapped. |
| `data.redemptions.exhausted` | boolean | yes | Whether nothing remains under the cap. |
| `data.window` | object | yes | When the code can be redeemed. |
| `data.window.starts_at` | string \| null | yes | When redemption opens, ISO 8601; null for immediately. |
| `data.window.expires_at` | string \| null | yes | When redemption closes, ISO 8601; null for never. |
| `data.plan_ids` | object | yes | The plans the code is restricted to. An empty array means **every** plan in the project, including plans added later, not no plans. |
| `data.active` | boolean | yes | The creator's on/off switch. Not the same as redeemable. |
| `data.redeemable` | boolean | yes | The combined answer: switched on, inside its window, not exhausted. Read this rather than `active` to know whether the code applies right now. |
| `data.created_at` | string \| null | yes | When the coupon was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when the creator has neither the Coupons Addon nor a Growth plan.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Deactivate a coupon
`POST /v1/projects/{project}/coupons/{coupon}/deactivate`
```bash
curl -X POST https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/coupons/4b9d3e08-a1f6-4275-9c83-7e01d6a2f594/deactivate \
-H "Authorization: Bearer sbt_..." \
-H "Idempotency-Key: $(uuidgen)"
```
The safe way to retire a code: new redemptions stop immediately and every redemption already recorded is kept. A checkout **already in flight** with the code still completes, so expect a late `coupon.redeemed` shortly after. Emits `coupon.deactivated`. Answers `200` with the coupon.
- Requires ability: `project-coupon:update`
- Fires events: `coupon.deactivated`
- MCP tools: `deactivate_coupon`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `coupon` | path | string (uuid) | yes | The coupon, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The coupon after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The coupon's id. |
| `data.project_id` | string | yes | The project the coupon belongs to; codes are unique within a project, not across projects. |
| `data.code` | string | yes | The code subscribers type at checkout, stored and matched in upper case. |
| `data.name` | string | yes | A creator-facing label, never shown to subscribers. |
| `data.discount` | object | yes | What comes off the price. |
| `data.discount.type` | string | yes | `percentage` or `fixed`. |
| `data.discount.value` | string | yes | A decimal string with four places: `"25.0000"` for 25% off, `"10.0000"` for ten of the coupon's currency. Parse it as a decimal, never a float. |
| `data.discount.currency_id` | string \| null | yes | The currency a `fixed` discount is denominated in; null on a `percentage` coupon, which applies to a plan in any currency. |
| `data.discount.currency` | string \| null | no | That currency's ISO 4217 code, present only when the relation is loaded: on a single read, a create and an update, not on every list row. |
| `data.duration` | string | yes | Always `once`: only first-payment discounts exist today. |
| `data.limits` | object | yes | How often and on what the code may be used. |
| `data.limits.max_redemptions` | integer \| null | yes | The total cap across everyone; null for unlimited. |
| `data.limits.max_redemptions_per_user` | integer | yes | How many times one member may redeem the code. |
| `data.limits.minimum_amount` | string \| null | yes | The order floor, in the plan's own currency; null for none. |
| `data.redemptions` | object | yes | How far the cap has been used. |
| `data.redemptions.count` | integer | yes | Settled redemptions so far. |
| `data.redemptions.remaining` | integer \| null | yes | Derived, not stored: the cap minus settled redemptions and reservations whose hold has not expired, so an in-flight checkout is already counted and an abandoned one gives its slot back. Prefer it over your own subtraction, which will disagree. Null when uncapped. |
| `data.redemptions.exhausted` | boolean | yes | Whether nothing remains under the cap. |
| `data.window` | object | yes | When the code can be redeemed. |
| `data.window.starts_at` | string \| null | yes | When redemption opens, ISO 8601; null for immediately. |
| `data.window.expires_at` | string \| null | yes | When redemption closes, ISO 8601; null for never. |
| `data.plan_ids` | object | yes | The plans the code is restricted to. An empty array means **every** plan in the project, including plans added later, not no plans. |
| `data.active` | boolean | yes | The creator's on/off switch. Not the same as redeemable. |
| `data.redeemable` | boolean | yes | The combined answer: switched on, inside its window, not exhausted. Read this rather than `active` to know whether the code applies right now. |
| `data.created_at` | string \| null | yes | When the coupon was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when the creator has neither the Coupons Addon nor a Growth plan.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Coupon Codes](https://docs.subscriby.net/creators/coupon-codes): The feature walkthrough.
- [Coupons Addon](https://docs.subscriby.net/addons/coupons): Pricing, and which payment methods can discount.
- [coupon.* events](https://docs.subscriby.net/webhooks/v1/events/coupon): The seven events; coupon.redeemed fires per checkout that settles with the code and coupon.exhausted when the last slot under the cap is taken, neither from a call here.
- [Access Codes API](https://docs.subscriby.net/api/v1/reference/access-codes): One code, one person, grants access.
- [Ability Reference](https://docs.subscriby.net/teams/abilities#project-coupon): How project-coupon:* maps onto team roles.
---
# Creator Tasks API
Source: https://docs.subscriby.net/api/v1/reference/creator-tasks
A **creator task** is a grant no connector can give. When a purchase entitles a subscriber to a **manual** resource, a perk the creator hands over themselves, the access ledger records a `creator_task` grant in state `pending` and opens a task naming the member and the perk. The dashboard lists the open tasks on the members page; these endpoints do the same for integrations, and completing one issues the grant.
No new ability gates tasks: a task is a fact about a subscription's access, so the subscription abilities apply.
## Endpoints
- `GET /v1/projects/{project}/creator-tasks` — [List a project's creator tasks](#list-a-projects-creator-tasks)
- `POST /v1/projects/{project}/creator-tasks/{task}/complete` — [Complete a creator task](#complete-a-creator-task)
## List a project's creator tasks
`GET /v1/projects/{project}/creator-tasks`
```bash
curl "https://api.subscriby.net/v1/projects/$PROJECT_ID/creator-tasks?status=open" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
`status` is `open` (the default: tasks not yet done whose grant still stands), `completed`, or `all`. Paged like every list but **oldest first** by default, so the longest-waiting member is at the top.
- `grant_id` is the access ledger row the task belongs to; read it with the [subscription's grants](https://docs.subscriby.net/api/v1/reference/subscriptions#list-a-subscriptions-grants), where its `mode` is `creator_task`.
- `instructions` is the sentence the dashboard shows the creator, in the creator's locale.
- `due_at` is null for continuous access and the window's start for a dated pass.
- `completed_at` and `completed_by_user_id` are set once the task is done.
- Requires ability: `project-subscription:view`
- MCP tools: `list_creator_tasks`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `status` | query | `open`, `completed`, `all` \| null | no | The tab: `open` (the default) lists tasks not yet done whose grant still stands, `completed` the ones marked done, `all` both. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `asc`. |
### Responses
- **200**: The page, oldest first by default.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The task's id, the value the complete endpoint takes and `creator_task.opened` carries as `task_id`. |
| `data[].grant_id` | string | yes | The access-ledger row the task belongs to; read it among the subscription's grants, where its `mode` is `creator_task`. |
| `data[].project_id` | string | yes | The project the task belongs to. |
| `data[].subscription_id` | string | yes | The purchase that entitles the member to the perk. |
| `data[].subscriber_id` | string \| null | yes | The member who is owed the perk. |
| `data[].resource_id` | string | yes | The manual resource to hand over. |
| `data[].resource_title` | string | yes | That resource's title, so an integration need not fetch it to say which perk. |
| `data[].instructions` | string \| null | yes | The sentence the dashboard shows the creator, in the creator's language. |
| `data[].due_at` | string \| null | yes | The window's start for a dated pass, ISO 8601; null for continuous access. |
| `data[].completed_at` | string \| null | yes | When the task was marked done, ISO 8601; null while open. |
| `data[].completed_by_user_id` | string \| null | yes | The creator who marked it done; null while open. |
| `data[].created_at` | string \| null | yes | When the task opened, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Complete a creator task
`POST /v1/projects/{project}/creator-tasks/{task}/complete`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/creator-tasks/$TASK_ID/complete \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Marks the task done by the token's user, moves the grant to `granted`, and raises `creator_task.completed` followed by `member.resource_added` for the same grant. Answers `200` with the completed task.
> **Nobody messages the member for you.** A manual perk is yours to hand over. Completing the task records that you did and tells your integrations; it does not send the member anything.
- Requires ability: `project-subscription:update`
- Fires events: `creator_task.completed`, `member.resource_added`
- MCP tools: `complete_creator_task`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `task` | path | string (uuid) | yes | The task, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with the completed task.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The task's id, the value the complete endpoint takes and `creator_task.opened` carries as `task_id`. |
| `data.grant_id` | string | yes | The access-ledger row the task belongs to; read it among the subscription's grants, where its `mode` is `creator_task`. |
| `data.project_id` | string | yes | The project the task belongs to. |
| `data.subscription_id` | string | yes | The purchase that entitles the member to the perk. |
| `data.subscriber_id` | string \| null | yes | The member who is owed the perk. |
| `data.resource_id` | string | yes | The manual resource to hand over. |
| `data.resource_title` | string | yes | That resource's title, so an integration need not fetch it to say which perk. |
| `data.instructions` | string \| null | yes | The sentence the dashboard shows the creator, in the creator's language. |
| `data.due_at` | string \| null | yes | The window's start for a dated pass, ISO 8601; null for continuous access. |
| `data.completed_at` | string \| null | yes | When the task was marked done, ISO 8601; null while open. |
| `data.completed_by_user_id` | string \| null | yes | The creator who marked it done; null while open. |
| `data.created_at` | string \| null | yes | When the task opened, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when the task is already done, or its purchase has since ended so there is nothing left to grant; `error.context.task_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions): The grants a purchase holds.
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources): Manual resources are created there.
- [Webhook Events API](https://docs.subscriby.net/api/v1/reference/webhook-events): A read-only polling endpoint that surfaces the most recent webhook deliveries for a given event type.
- [creator_task.* events](https://docs.subscriby.net/webhooks/v1/events/creator-task): creator_task.opened fires per manual resource when a purchase activates; completing a task raises creator_task.completed, then member.resource_added.
---
# Disaster Recovery API
Source: https://docs.subscriby.net/api/v1/reference/disaster-recovery
Everything the **Disaster Recovery** pages show a creator is readable here: what the health probes found broken, what was done about it and whether it can still be undone, how many members a channel recovery has re-admitted so far, how many self-service recoveries the rolling window still allows, and how ready the account is for the next ban. And everything a creator does about it afterwards is here too: the per-project settings, the standbys kept ready, the two requests that start a chat picker on the connector, the undo, the reminder to stragglers, the handover mail after a bot replacement, and the member list to keep before one.
The ledger endpoints live under `/v1/recovery` with no project in the path because the ledger belongs to the **account holder**, not to a project: a ban lands on the creator's sign-in account and on every project at once. Settings and standbys hang off the project and the resource they concern. The creator's own account lives under `/v1/me/recovery`.
## Tenancy
The ledger a token reads is the one of the creator who owns the team the token is scoped to. A teammate holding `project-recovery:view-any` therefore reads the same rows the owner sees on the dashboard; a token scoped to another team sees none of them, and any id from another creator's ledger is `404 RESOURCE_NOT_FOUND`.
> **Reads are the team's, writes are the owner's.** The writes run the same actions the dashboard runs, and those refuse anyone but the project owner (or, for the account, the account holder) with `422 VALIDATION_FAILED` and the reason, the same sentence the dashboard would show. Registering a standby installation and replacing the project bot take a credential and stay off tokens altogether.
Every enum value comes with its label beside it (`kind_label`, `status_label`, `reason_label`), the same words the dashboard shows, so a client can render the ledger without a translation table of its own.
## Endpoints
- `GET /v1/projects/{project}/resources/{resource}/standby` — [Get a resource's standby](#get-a-resources-standby)
- `PATCH /v1/projects/{project}/resources/{resource}/standby` — [Switch a standby's live mirror](#switch-a-standbys-live-mirror)
- `DELETE /v1/projects/{project}/resources/{resource}/standby` — [Remove a resource's standby](#remove-a-resources-standby)
- `GET /v1/projects/{project}/recovery/settings` — [Get a project's recovery settings](#get-a-projects-recovery-settings)
- `PATCH /v1/projects/{project}/recovery/settings` — [Update a project's recovery settings](#update-a-projects-recovery-settings)
- `GET /v1/projects/{project}/recovery/members/export` — [Export a project's member list](#export-a-projects-member-list)
- `GET /v1/recovery/readiness` — [Get the readiness checklist](#get-the-readiness-checklist)
- `GET /v1/recovery/incidents` — [List incidents](#list-incidents)
- `GET /v1/recovery/incidents/{incident}` — [Get an incident](#get-an-incident)
- `GET /v1/recovery/operations` — [List recovery operations](#list-recovery-operations)
- `GET /v1/recovery/operations/{operation}` — [Get a recovery operation](#get-a-recovery-operation)
- `GET /v1/recovery/operations/{operation}/roll-call` — [Get a recovery's roll call](#get-a-recoverys-roll-call)
- `GET /v1/recovery/allowances` — [Get the recovery allowances](#get-the-recovery-allowances)
- `GET /v1/me/recovery` — [Get the caller's recovery account](#get-the-callers-recovery-account)
- `GET /v1/me/recovery/handshakes/{handshake}` — [Poll a recovery handshake](#poll-a-recovery-handshake)
- `DELETE /v1/me/recovery/handshakes/{handshake}` — [Cancel a recovery handshake](#cancel-a-recovery-handshake)
- `POST /v1/projects/{project}/resources/{resource}/standby/request` — [Request a standby for a resource](#request-a-standby-for-a-resource)
- `DELETE /v1/projects/{project}/resources/{resource}/standby/request` — [Withdraw a standby request](#withdraw-a-standby-request)
- `POST /v1/projects/{project}/resources/{resource}/standby/use` — [Use a resource's standby now](#use-a-resources-standby-now)
- `POST /v1/projects/{project}/resources/{resource}/replacement/request` — [Request a replacement for a resource](#request-a-replacement-for-a-resource)
- `DELETE /v1/projects/{project}/resources/{resource}/replacement/request` — [Withdraw a replacement request](#withdraw-a-replacement-request)
- `DELETE /v1/projects/{project}/recovery/standby-installation` — [Remove a project's standby installation](#remove-a-projects-standby-installation)
- `POST /v1/recovery/operations/{operation}/revert` — [Undo a recovery](#undo-a-recovery)
- `POST /v1/recovery/operations/{operation}/nudge` — [Remind a recovery's stragglers](#remind-a-recoverys-stragglers)
- `POST /v1/recovery/operations/{operation}/notify-members` — [Tell members about a bot replacement](#tell-members-about-a-bot-replacement)
- `POST /v1/me/recovery/handshakes` — [Open a recovery handshake](#open-a-recovery-handshake)
- `POST /v1/me/recovery/handshakes/{handshake}/confirm` — [Confirm a relink handshake](#confirm-a-relink-handshake)
- `POST /v1/me/recovery/backup-identity` — [Register a backup account](#register-a-backup-account)
- `DELETE /v1/me/recovery/backup-identity` — [Remove the backup account](#remove-the-backup-account)
- `POST /v1/me/recovery/backup-identity/switch` — [Switch to the backup account](#switch-to-the-backup-account)
## Get a resource's standby
`GET /v1/projects/{project}/resources/{resource}/standby`
```bash
curl https://api.subscriby.net/v1/projects/$PROJECT_ID/resources/$RESOURCE_ID/standby \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The standby's own identifier on the platform is deliberately absent, as every connector identifier is on the API.
- Requires ability: `project-recovery:view`
- MCP tools: `get_resource_standby`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
### Responses
- **200**: 200 with the standby.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.resource_id` | string | yes | The resource the standby is kept for. |
| `data.mirror_enabled` | boolean | yes | Whether every post made in the channel is copied into the standby as it is made. |
| `data.health_status` | string | yes | `healthy`, `degraded` or `detached`, as the last probe found it; null before the first probe. |
| `data.health_status_label` | `Healthy`, `Degraded`, `Detached` | yes | The status in the creator's language. |
| `data.health_reason` | string \| null | yes | The connector's code for a degraded or detached standby, such as `chat_not_found`, `bot_removed`, `bot_not_administrator`, `bot_missing_rights` or `connector_api_error`; null while healthy. |
| `data.health_checked_at` | string \| null | yes | When the standby was last probed, ISO 8601; null before the first probe. |
| `data.last_mirrored_at` | string \| null | yes | When a post was last copied into the standby, ISO 8601; null if never. |
| `data.linked_at` | string | yes | When the standby was linked, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: when the resource keeps no standby; `error.context.resource_id` names it.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Switch a standby's live mirror
`PATCH /v1/projects/{project}/resources/{resource}/standby`
```bash
curl -X PATCH https://api.subscriby.net/v1/projects/$PROJECT_ID/resources/$RESOURCE_ID/standby \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"mirror": true}'
```
Switches the live mirror on or off: when on, every post made in the channel is copied into the standby as it is made. Only a channel can be mirrored, and switching on needs the Growth plan. Answers `200` with the standby.
- Requires ability: `project-recovery:update`
- MCP tools: `set_resource_standby_mirror`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
The one switch a resource's standby carries.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mirror` | boolean | yes | Whether every post made in the channel is copied into the standby as it is made. Only a channel can be mirrored, and switching on needs the Growth plan. |
### Responses
- **200**: 200 with the standby, updated.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.resource_id` | string | yes | The resource the standby is kept for. |
| `data.mirror_enabled` | boolean | yes | Whether every post made in the channel is copied into the standby as it is made. |
| `data.health_status` | string | yes | `healthy`, `degraded` or `detached`, as the last probe found it; null before the first probe. |
| `data.health_status_label` | `Healthy`, `Degraded`, `Detached` | yes | The status in the creator's language. |
| `data.health_reason` | string \| null | yes | The connector's code for a degraded or detached standby, such as `chat_not_found`, `bot_removed`, `bot_not_administrator`, `bot_missing_rights` or `connector_api_error`; null while healthy. |
| `data.health_checked_at` | string \| null | yes | When the standby was last probed, ISO 8601; null before the first probe. |
| `data.last_mirrored_at` | string \| null | yes | When a post was last copied into the standby, ISO 8601; null if never. |
| `data.linked_at` | string | yes | When the standby was linked, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: for a group, a resource without a standby, a plan without the feature, or a caller who is not the project owner.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Remove a resource's standby
`DELETE /v1/projects/{project}/resources/{resource}/standby`
Stops keeping the standby; the chat itself is untouched. Answers `204` and raises `recovery.standby_removed`.
- Requires ability: `project-recovery:delete`
- Fires events: `recovery.standby_removed`
- MCP tools: `remove_resource_standby`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the caller is not the project owner.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a project's recovery settings
`GET /v1/projects/{project}/recovery/settings`
```bash
curl https://api.subscriby.net/v1/projects/$PROJECT_ID/recovery/settings \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
- `auto_failover_enabled` says whether the platform may swap a banned channel for its standby on its own; `auto_failover_email_consented_at` when the creator accepted the per-email fee a failover may charge for the members the bot cannot reach.
- `email_delivery` says how members are told after a channel swap the creator ran: `self` (the creator tells them) or `platform` (Subscriby emails them at the fee).
- `standby_installation_registered` says whether a spare bot is kept; its credentials never appear.
- Requires ability: `project-recovery:view`
- MCP tools: `get_recovery_settings`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
### Responses
- **200**: 200 with the settings.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.project_id` | string | yes | The project the settings belong to. |
| `data.auto_failover_enabled` | boolean | yes | Whether the platform may swap a banned channel for its standby on its own. |
| `data.auto_failover_email_consented_at` | string \| null | yes | When the creator accepted the per-email fee a failover may charge for the members the bot cannot reach, ISO 8601; null until accepted. |
| `data.email_delivery` | string | yes | How members are told after a channel swap the creator ran: `self` (the creator tells them) or `platform` (Subscriby emails them at the fee). |
| `data.email_delivery_label` | `Email them for me`, `I will email them myself` | yes | That choice in the creator's language. |
| `data.standby_installation_registered` | boolean | yes | Whether a spare bot is kept for the project. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Update a project's recovery settings
`PATCH /v1/projects/{project}/recovery/settings`
```bash
curl -X PATCH https://api.subscriby.net/v1/projects/$PROJECT_ID/recovery/settings \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"auto_failover": true, "accepts_email_fee": true, "email_delivery": "platform"}'
```
Every field is optional and omitted ones keep their value. Switching `auto_failover` on needs `accepts_email_fee: true` in the same call and the Growth plan. Answers `200` with the settings as they now stand.
- Requires ability: `project-recovery:update`
- MCP tools: `update_recovery_settings`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
Which Disaster Recovery settings to change on a project. Every field is optional and an omitted one keeps its value.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `auto_failover` | boolean | no | Whether the platform may swap a banned channel for its standby on its own. Switching it on needs `accepts_email_fee: true` in the same call and the Growth plan. |
| `accepts_email_fee` | boolean | no | Consent to the per-email fee a failover may charge for the members the bot cannot reach; consent is not implied by the switch. |
| `email_delivery` | `platform`, `self` | no | How members are told after a channel swap the creator ran: `self` (the creator tells them) or `platform` (Subscriby emails them at the fee). |
### Responses
- **200**: 200 with the settings, updated.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.project_id` | string | yes | The project the settings belong to. |
| `data.auto_failover_enabled` | boolean | yes | Whether the platform may swap a banned channel for its standby on its own. |
| `data.auto_failover_email_consented_at` | string \| null | yes | When the creator accepted the per-email fee a failover may charge for the members the bot cannot reach, ISO 8601; null until accepted. |
| `data.email_delivery` | string | yes | How members are told after a channel swap the creator ran: `self` (the creator tells them) or `platform` (Subscriby emails them at the fee). |
| `data.email_delivery_label` | `Email them for me`, `I will email them myself` | yes | That choice in the creator's language. |
| `data.standby_installation_registered` | boolean | yes | Whether a spare bot is kept for the project. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `auto_failover` is switched on without `accepts_email_fee: true` or without the Growth plan, when the caller is not the project owner, or when `email_delivery` is not `self` or `platform`.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Export a project's member list
`GET /v1/projects/{project}/recovery/members/export`
```bash
curl -L https://api.subscriby.net/v1/projects/$PROJECT_ID/recovery/members/export \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" -o members.csv
```
The member list a creator keeps before replacing a bot, as a CSV download (`text/csv`, one member per row: name, email, connector id, active plans, when access ends, resources, whether the bot can still reach them). REST only; there is no MCP tool for a file.
- Requires ability: `project-recovery:view-any`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
### Responses
- **200**: The CSV, one member per row.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **422**: `VALIDATION_FAILED`: when the caller is not the project owner.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get the readiness checklist
`GET /v1/recovery/readiness`
```bash
curl https://api.subscriby.net/v1/recovery/readiness \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The whole checklist the **Readiness** page shows, with its totals.
- `done` is three-valued: `true` or `false` when the platform can verify the line, `null` when it can only remind (`reminder: true`). Reminders never count toward `total`.
- `locked` says the plan lacks the prevention feature the line needs, so a client shows the upgrade rather than the fix; `prevention` at the top says whether the plan bundles those features at all.
- `url` is where the line is fixed, or `null` for a reminder. `detail` is the state in words, such as "2 of 3 projects".
- `connector` names the connector a line belongs to (`connector_name` is its display name); both are `null` for the account's own lines.
The lines are the ones the dashboard lists, in the same order. The account's come first: `second_factor` and `backup_identity`. Then each installed connector's own lines, worded by that connector and keyed within it; Telegram's are `standby_installation`, `standby_spaces`, `auto_failover` and `live_mirror`, one line per key however many projects run it (the detail counts the projects when they differ). Last the two reminders, `off_platform_copy` and `second_admin`. A creator with no connector installed gets the account lines and the reminders alone. Every change to a verifiable line's state raises `recovery.readiness_changed`.
- Requires ability: `project-recovery:view-any`
- MCP tools: `get_recovery_readiness`
### Responses
- **200**: 200 with the checklist.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.prevention` | boolean | yes | Whether the plan bundles the prevention features the checklist checks. |
| `data.completed` | integer | yes | How many verifiable lines are done. |
| `data.total` | integer | yes | How many lines are verifiable; reminders never count. |
| `data.complete` | boolean | yes | True when every verifiable line is done. |
| `data.items` | array of any | yes | The lines, in the order the dashboard lists them: the account's first, then each installed connector's, then the two reminders. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## List incidents
`GET /v1/recovery/incidents`
```bash
curl "https://api.subscriby.net/v1/recovery/incidents?status=open" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
An incident is one problem a probe detected and has not yet seen fixed: the creator's sign-in account unreachable (`kind: account`), a project's bot refused by the platform (`bot`), or a channel or group the project sells gone or no longer administered (`resources`). It resolves itself when a later probe finds the thing healthy, or is resolved by the recovery that replaced it, which `resolved_by_operation_id` then names.
`status` is `open` (the default: what still needs attention), `resolved`, or `all`. Paginated like every list, newest first by `detected_at`; `sort_by` also accepts `kind`, `status` and `resolved_at`.
- Requires ability: `project-recovery:view-any`
- MCP tools: `list_recovery_incidents`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | `open`, `resolved`, `all` \| null | no | The tab: `open` (the default, what still needs attention), `resolved`, or `all`. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `detected_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: The page, newest first by default.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The incident's id. |
| `data[].kind` | string | yes | `account` (the creator's sign-in account unreachable), `bot` (a project's bot refused by the platform) or `resources` (a channel or group the project sells gone or no longer administered). |
| `data[].kind_label` | string | yes | The kind in the connector's own words, in the creator's language. |
| `data[].status` | string | yes | `open` while the problem stands, `resolved` once a later probe found the thing healthy or a recovery replaced it. |
| `data[].status_label` | `Open`, `Resolved` | yes | The status in the creator's language. |
| `data[].connector` | string \| null | yes | The connector the incident is on, by key; null when it concerns no connector. |
| `data[].project_id` | string \| null | yes | The project concerned; null on an account incident. |
| `data[].project_name` | string \| null | yes | That project's name, so an integration can say which project without another call; null on an account incident. |
| `data[].resource_id` | string \| null | yes | The resource concerned; set on `resources` incidents only. |
| `data[].resource_title` | string \| null | yes | That resource's title; set on `resources` incidents only. |
| `data[].reason` | string \| null | yes | The connector's own code for what it saw, such as `chat_not_found`, `bot_removed`, `bot_not_administrator`, `bot_missing_rights` or `connector_api_error`; null when the probe recorded no reason, as on account incidents. |
| `data[].reason_label` | string \| null | yes | That code in the connector's words; null when no reason was recorded. |
| `data[].reason_explanation` | string \| null | yes | The connector's explanation of the code, the same sentence the alert email carried, in Markdown; null when no reason was recorded. |
| `data[].detected_at` | string | yes | When the probe first saw the problem, ISO 8601. |
| `data[].resolved_at` | string \| null | yes | When the problem was seen fixed, ISO 8601; null while open. |
| `data[].resolved_by_operation_id` | string \| null | yes | The recovery that fixed it, when one did; null otherwise. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `status` is not `open`, `resolved` or `all`.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get an incident
`GET /v1/recovery/incidents/{incident}`
```bash
curl https://api.subscriby.net/v1/recovery/incidents/$INCIDENT_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
One incident.
- `reason` is the connector's own code for what it saw; `reason_label` and `reason_explanation` are that code in the connector's words, the same sentence the alert email carried. Both are `null` when the probe recorded no reason, which is the case for account incidents.
- `resource_id` and `resource_title` are set on `resources` incidents only; `project_id` is `null` on an account incident.
- Requires ability: `project-recovery:view`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `incident` | path | string (uuid) | yes | The incident, resolved within the ledger by the route binder. |
### Responses
- **200**: 200 with the incident.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The incident's id. |
| `data.kind` | string | yes | `account` (the creator's sign-in account unreachable), `bot` (a project's bot refused by the platform) or `resources` (a channel or group the project sells gone or no longer administered). |
| `data.kind_label` | string | yes | The kind in the connector's own words, in the creator's language. |
| `data.status` | string | yes | `open` while the problem stands, `resolved` once a later probe found the thing healthy or a recovery replaced it. |
| `data.status_label` | `Open`, `Resolved` | yes | The status in the creator's language. |
| `data.connector` | string \| null | yes | The connector the incident is on, by key; null when it concerns no connector. |
| `data.project_id` | string \| null | yes | The project concerned; null on an account incident. |
| `data.project_name` | string \| null | yes | That project's name, so an integration can say which project without another call; null on an account incident. |
| `data.resource_id` | string \| null | yes | The resource concerned; set on `resources` incidents only. |
| `data.resource_title` | string \| null | yes | That resource's title; set on `resources` incidents only. |
| `data.reason` | string \| null | yes | The connector's own code for what it saw, such as `chat_not_found`, `bot_removed`, `bot_not_administrator`, `bot_missing_rights` or `connector_api_error`; null when the probe recorded no reason, as on account incidents. |
| `data.reason_label` | string \| null | yes | That code in the connector's words; null when no reason was recorded. |
| `data.reason_explanation` | string \| null | yes | The connector's explanation of the code, the same sentence the alert email carried, in Markdown; null when no reason was recorded. |
| `data.detected_at` | string | yes | When the probe first saw the problem, ISO 8601. |
| `data.resolved_at` | string \| null | yes | When the problem was seen fixed, ISO 8601; null while open. |
| `data.resolved_by_operation_id` | string \| null | yes | The recovery that fixed it, when one did; null otherwise. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## List recovery operations
`GET /v1/recovery/operations`
```bash
curl "https://api.subscriby.net/v1/recovery/operations?kind=resources&status=completed" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
An operation is one recovery: run by the creator, by the platform on their behalf (`automatic: true`, the failover you sleep through), or on demand (`on_demand: true`, a Swap & Grant of a healthy resource, which spends no allowance). The row exists from the moment the recovery starts, because it is the quota ledger and the audit trail at once.
Both filters are optional: `kind` is `account`, `bot` or `resources`; `status` is `started`, `completed`, `failed` or `reverted`. Newest first by `started_at`; `sort_by` also accepts `kind`, `status` and `completed_at`.
- Requires ability: `project-recovery:view-any`
- MCP tools: `list_recovery_operations`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `kind` | query | `account`, `bot`, `resources` \| null | no | Narrow to one kind: `account`, `bot` or `resources`. An unknown value is refused. |
| `status` | query | `started`, `completed`, `failed`, `reverted` \| null | no | Narrow to one status: `started`, `completed`, `failed` or `reverted`. An unknown value is refused. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `started_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: The page, newest first by default.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The operation's id, the value the revert, nudge and notify endpoints take. |
| `data[].kind` | string | yes | `account` (a sign-in relink), `bot` (a bot replacement) or `resources` (a channel swap). |
| `data[].kind_label` | string | yes | The kind in the connector's own words, in the creator's language. |
| `data[].status` | string | yes | `started` while the recovery runs, then `completed`, `failed` (with `failure_reason`) or `reverted`. |
| `data[].status_label` | `In progress`, `Completed`, `Failed`, `Reverted` | yes | The status in the creator's language. |
| `data[].connector` | string \| null | yes | The connector the recovery ran on, by key; null when it concerns no connector. |
| `data[].project_id` | string \| null | yes | The project concerned; null for an account relink. |
| `data[].project_name` | string \| null | yes | That project's name; null for an account relink. |
| `data[].grant_id` | string \| null | yes | The support grant that paid for the recovery when the self-service allowance was already spent; null otherwise. |
| `data[].automatic` | boolean | yes | True when the platform ran the recovery on the creator's behalf, the failover they sleep through. |
| `data[].on_demand` | boolean | yes | True for a Swap & Grant of a healthy resource, which spends no allowance. |
| `data[].failure_reason` | any | yes | Why a `failed` recovery failed; null otherwise. |
| `data[].started_at` | string | yes | When the recovery started, ISO 8601. The row exists from that moment, because it is the quota ledger and the audit trail at once. |
| `data[].completed_at` | string \| null | yes | When it finished, ISO 8601; null while running or after failing. |
| `data[].reverted_at` | string \| null | yes | When it was undone, ISO 8601; null otherwise. |
| `data[].revertible` | boolean | yes | Whether the creator may still undo it **right now**: completed, of a kind that can be undone (an account relink or a channel swap, never a bot replacement), and inside the undo window. Show the undo exactly when this is true. |
| `data[].revert_window_ends_at` | string \| null | yes | When the undo window closes, ISO 8601; null for a recovery that cannot be undone. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `kind` or `status` is not one of its values.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a recovery operation
`GET /v1/recovery/operations/{operation}`
```bash
curl https://api.subscriby.net/v1/recovery/operations/$OPERATION_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
One operation, with its undo state.
- `status` is `started` while the recovery runs, then `completed`, `failed` (with `failure_reason`) or `reverted`.
- `revertible` says whether the creator may still undo it **right now**: it is completed, of a kind that can be undone (an account relink or a channel swap, never a bot replacement), and inside the undo window that `revert_window_ends_at` closes. A client should show the undo exactly when this is `true`.
- `grant_id` names the support grant that paid for the recovery when the self-service allowance was already spent; `null` otherwise.
- Requires ability: `project-recovery:view`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `operation` | path | string (uuid) | yes | The operation, resolved within the ledger by the route binder. |
### Responses
- **200**: 200 with the operation.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The operation's id, the value the revert, nudge and notify endpoints take. |
| `data.kind` | string | yes | `account` (a sign-in relink), `bot` (a bot replacement) or `resources` (a channel swap). |
| `data.kind_label` | string | yes | The kind in the connector's own words, in the creator's language. |
| `data.status` | string | yes | `started` while the recovery runs, then `completed`, `failed` (with `failure_reason`) or `reverted`. |
| `data.status_label` | `In progress`, `Completed`, `Failed`, `Reverted` | yes | The status in the creator's language. |
| `data.connector` | string \| null | yes | The connector the recovery ran on, by key; null when it concerns no connector. |
| `data.project_id` | string \| null | yes | The project concerned; null for an account relink. |
| `data.project_name` | string \| null | yes | That project's name; null for an account relink. |
| `data.grant_id` | string \| null | yes | The support grant that paid for the recovery when the self-service allowance was already spent; null otherwise. |
| `data.automatic` | boolean | yes | True when the platform ran the recovery on the creator's behalf, the failover they sleep through. |
| `data.on_demand` | boolean | yes | True for a Swap & Grant of a healthy resource, which spends no allowance. |
| `data.failure_reason` | any | yes | Why a `failed` recovery failed; null otherwise. |
| `data.started_at` | string | yes | When the recovery started, ISO 8601. The row exists from that moment, because it is the quota ledger and the audit trail at once. |
| `data.completed_at` | string \| null | yes | When it finished, ISO 8601; null while running or after failing. |
| `data.reverted_at` | string \| null | yes | When it was undone, ISO 8601; null otherwise. |
| `data.revertible` | boolean | yes | Whether the creator may still undo it **right now**: completed, of a kind that can be undone (an account relink or a channel swap, never a bot replacement), and inside the undo window. Show the undo exactly when this is true. |
| `data.revert_window_ends_at` | string \| null | yes | When the undo window closes, ISO 8601; null for a recovery that cannot be undone. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a recovery's roll call
`GET /v1/recovery/operations/{operation}/roll-call`
```bash
curl https://api.subscriby.net/v1/recovery/operations/$OPERATION_ID/roll-call \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Where the re-admission after a channel recovery stands, read live.
- `total` is how many members the swap set out to re-admit; `regranted` how many hold a fresh link into the new chat; `failed` how many could not be given one.
- `unreachable` is how many the bot could not message, and `emailed` how many of those were told by email instead (at the per-email fee, when the project opted in).
- `joined` is stamped the moment the platform approves a member's join request, so `pending` (`regranted − joined`) is who is still outside.
- `settled` means the queue has handled everyone, so the counters will not move on their own. `can_nudge` is true when somebody is still outside and the reminder cooldown has passed; `cooling_down` and `nudge_available_at` explain a `false`.
A recovery of a kind that re-admits nobody (an account relink, a bot replacement) answers the same shape with every counter at zero rather than an error.
- Requires ability: `project-recovery:view`
- MCP tools: `get_recovery_roll_call`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `operation` | path | string (uuid) | yes | The operation, resolved within the ledger by the route binder. |
### Responses
- **200**: 200 with the counters.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.total` | integer | yes | How many members the swap set out to re-admit. |
| `data.regranted` | integer | yes | How many hold a fresh link into the new chat. |
| `data.failed` | integer | yes | How many could not be given one. |
| `data.unreachable` | integer | yes | How many the bot could not message. |
| `data.emailed` | integer | yes | How many of the unreachable were told by email instead, at the per-email fee, when the project opted in. |
| `data.joined` | integer | yes | How many the platform has approved into the new chat; stamped the moment a join request is approved. |
| `data.pending` | integer | yes | Who is still outside: `regranted` minus `joined`. |
| `data.nudged` | integer | yes | How many reminders have gone out so far. |
| `data.settled` | boolean | yes | True once the queue has handled everyone, so the counters will not move on their own. |
| `data.cooling_down` | boolean | yes | True while the reminder cooldown has not passed since the last nudge. |
| `data.can_nudge` | boolean | yes | True when somebody is still outside and the cooldown has passed; `cooling_down` and `nudge_available_at` explain a false. |
| `data.last_nudged_at` | string \| null | yes | When the last reminder went out, ISO 8601; null if none has. |
| `data.nudge_available_at` | string \| null | yes | When the next reminder may go out, ISO 8601; null when one may go out now or nobody is waiting. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get the recovery allowances
`GET /v1/recovery/allowances`
```bash
curl https://api.subscriby.net/v1/recovery/allowances \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
One row per kind, plus the window they are counted in.
- `self_service_remaining` is what the rolling window still allows on its own; `grant_remaining` is what support has released on top and not yet spent. `remaining` is their sum and `allowed` whether a recovery of that kind would start right now.
- `uses_grant` is true when the next recovery would spend a support grant rather than the self-service allowance.
- `next_self_service_at` is when the self-service allowance returns, `null` while it is available. `last_used_at` is when the kind was last recovered inside the window.
See [Allowances and support review](https://docs.subscriby.net/disaster-recovery/allowances-and-support-review) for the rule and what support audits before releasing a grant.
- Requires ability: `project-recovery:view-any`
- MCP tools: `get_recovery_allowances`
### Responses
- **200**: Array of `RecoveryAllowanceResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].kind` | string | yes | The kind of recovery: `account` (the creator's sign-in account), `bot` (a project's bot) or `resources` (the channels and groups a project sells). |
| `data[].kind_label` | string | yes | The kind in the connector's own words, such as "Project Bot", in the creator's language. |
| `data[].self_service_remaining` | integer | yes | What the rolling window still allows on its own. |
| `data[].grant_remaining` | integer | yes | What support has released on top and not yet spent. |
| `data[].remaining` | integer | yes | The sum of the two. |
| `data[].allowed` | boolean | yes | Whether a recovery of that kind would start right now. |
| `data[].uses_grant` | boolean | yes | True when the next recovery would spend a support grant rather than the self-service allowance. |
| `data[].last_used_at` | string \| null | yes | When the kind was last recovered inside the window, ISO 8601; null if never. |
| `data[].next_self_service_at` | string \| null | yes | When the self-service allowance returns, ISO 8601; null while it is available. |
| `meta` | object | yes | The rule the allowances are counted under. |
| `meta.window_days` | integer | yes | The rolling window the allowances are counted in, in days. |
| `meta.self_service_uses` | integer | yes | How many self-service recoveries of each kind the window allows. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get the caller's recovery account
`GET /v1/me/recovery`
```bash
curl https://api.subscriby.net/v1/me/recovery \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Recovering the creator's sign-in account, and keeping a backup account ahead of a ban, are two-sided **handshakes**: the API mints a code, the creator taps the link (or types the code) from the account to detect, the connector records which account that was, and the API confirms. Both sides have to agree, which is what keeps a leaked link from binding a stranger. These endpoints live under `/v1/me` because they are about the caller, never a project, and the actions behind them refuse anyone but the account holder.
These flows are deliberately not offered as MCP tools: moving a sign-in is a human act, and [`get_me`](https://docs.subscriby.net/mcp/v1/tools/account#get-me) already shows which accounts are linked.
- `primary_display_name` and `backup_display_name` name the accounts as the connector reported them; their identifiers on the connector never appear.
- `relink_handshake` and `backup_handshake` are the newest handshake of each kind, in whatever state, so a client can pick up a flow the creator started on the dashboard or in the bot.
- Requires ability: `project-recovery:view`
### Responses
- **200**: 200 with the summary.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.primary_display_name` | string \| null | yes | The account that signs the creator in, as the connector reported it (display name, else handle, else the platform's id); null when none is linked. Connector identifiers never appear. |
| `data.backup_identity_registered` | boolean | yes | Whether a backup account is kept ahead of a ban. |
| `data.backup_display_name` | string \| null | yes | The backup account as the connector reported it; null when none is kept. |
| `data.relink_handshake` | array \| null | yes | The newest handshake that moves the sign-in, in whatever state, so a client can pick up a flow the creator started on the dashboard or in the bot; null when none was ever opened. |
| `data.backup_handshake` | array \| null | yes | The newest handshake that registers a backup, in whatever state; null when none was ever opened. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Poll a recovery handshake
`GET /v1/me/recovery/handshakes/{handshake}`
`status` moves from `pending` to `completed` when an account taps, with `detected_display_name` and `detected_identity_id` filled; `expired` when the code ran out or was cancelled; `consumed` once confirmed.
- Requires ability: `project-recovery:view`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `handshake` | path | string (uuid) | yes | The handshake, resolved within the caller's own by the route binder. |
### Responses
- **200**: 200 with the handshake.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The handshake's id, the value the poll, cancel and confirm endpoints take. |
| `data.kind` | string | yes | `relink` (move the sign-in to a new account) or `backup` (register a backup account). |
| `data.kind_label` | `Replace my connected account`, `Register a backup account` | yes | The kind in the creator's language. |
| `data.status` | string | yes | `pending` until an account taps, `completed` when one has, `expired` when the code ran out or was cancelled, `consumed` once confirmed. |
| `data.status_label` | `Waiting on the connector`, `Account detected`, `Linked`, `Expired` | yes | The status in the creator's language. |
| `data.code` | string | yes | The eight-character code to type into the bot. |
| `data.start_link` | string \| null | yes | The link that opens the bot on the code; null when the creator cannot be reached on the connector. |
| `data.connector` | string \| null | yes | The connector the handshake runs on, by key. |
| `data.detected_display_name` | string \| null | yes | The account that tapped, as the connector reported it; null while pending. |
| `data.detected_identity_id` | string \| null | yes | The neutral identity id of the account that tapped, never the connector's own account identifier; null while pending. |
| `data.expires_at` | string | yes | When the code stops working, fifteen minutes after minting, ISO 8601. |
| `data.completed_at` | string \| null | yes | When an account tapped, ISO 8601; null while pending. |
| `data.created_at` | string \| null | yes | When the handshake was opened, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Cancel a recovery handshake
`DELETE /v1/me/recovery/handshakes/{handshake}`
Cancels a pending handshake so its code stops working. Answers `204`.
- Requires ability: `project-recovery:delete`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `handshake` | path | string (uuid) | yes | The handshake, resolved within the caller's own by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the handshake is no longer pending; `error.context.handshake_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Request a standby for a resource
`POST /v1/projects/{project}/resources/{resource}/standby/request`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/resources/$RESOURCE_ID/standby/request \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Asks the creator, on the connector, to pick the chat that becomes the resource's standby (Growth plan). Answers `202` once the request is sent; the standby appears, announced by `recovery.standby_registered`, the moment they choose.
Linking a standby or a replacement is a conversation with the creator on the connector: the bot messages them with a picker, and the link or the swap happens the moment they choose. The API cannot name the chat directly, because that would take a platform identifier, so it starts the conversation and takes it back. A creator holds one request of each kind at a time, whichever resource it was for.
- Requires ability: `project-recovery:create`
- MCP tools: `request_resource_standby`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **202**: 202 with `resource_id` and `status: request_sent`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.resource_id` | string | yes | The resource the standby will be kept for. |
| `data.status` | string | yes | Always `request_sent`: the creator has been asked on the connector. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the resource is a perk rather than a place, the creator cannot be reached on the connector, the plan lacks the feature, or the caller is not the project owner; `error.context.resource_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Withdraw a standby request
`DELETE /v1/projects/{project}/resources/{resource}/standby/request`
Takes the open standby request back. A creator holds one request of each kind at a time, whichever resource it was for, so the resource in the path only keys the route. Answers `204`, also when nothing was open.
- Requires ability: `project-recovery:delete`
- MCP tools: `withdraw_resource_standby_request`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Use a resource's standby now
`POST /v1/projects/{project}/resources/{resource}/standby/use`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/resources/$RESOURCE_ID/standby/use \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Swaps the resource onto its standby right now: the resource points at the standby chat, the old invite links are revoked, every active member is re-admitted, and the standby is consumed. Answers `200` with the recovery operation the swap ran under (`on_demand: true` when the resource was healthy with no open incident, spending no allowance). Undo is offered for 24 hours through the revert endpoint. Raises `recovery.resource_replaced` and `recovery.standby_removed` with `reason: used`, beside the operation's `recovery.operation_started` and `recovery.operation_completed`.
- Requires ability: `project-recovery:create`
- Fires events: `recovery.operation_started`, `recovery.operation_completed`, `recovery.resource_replaced`, `recovery.standby_removed`, `recovery.incident_resolved`, `project.resource.updated`
- MCP tools: `use_resource_standby`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with the recovery operation the swap ran under.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The operation's id, the value the revert, nudge and notify endpoints take. |
| `data.kind` | string | yes | `account` (a sign-in relink), `bot` (a bot replacement) or `resources` (a channel swap). |
| `data.kind_label` | string | yes | The kind in the connector's own words, in the creator's language. |
| `data.status` | string | yes | `started` while the recovery runs, then `completed`, `failed` (with `failure_reason`) or `reverted`. |
| `data.status_label` | `In progress`, `Completed`, `Failed`, `Reverted` | yes | The status in the creator's language. |
| `data.connector` | string \| null | yes | The connector the recovery ran on, by key; null when it concerns no connector. |
| `data.project_id` | string \| null | yes | The project concerned; null for an account relink. |
| `data.project_name` | string \| null | yes | That project's name; null for an account relink. |
| `data.grant_id` | string \| null | yes | The support grant that paid for the recovery when the self-service allowance was already spent; null otherwise. |
| `data.automatic` | boolean | yes | True when the platform ran the recovery on the creator's behalf, the failover they sleep through. |
| `data.on_demand` | boolean | yes | True for a Swap & Grant of a healthy resource, which spends no allowance. |
| `data.failure_reason` | any | yes | Why a `failed` recovery failed; null otherwise. |
| `data.started_at` | string | yes | When the recovery started, ISO 8601. The row exists from that moment, because it is the quota ledger and the audit trail at once. |
| `data.completed_at` | string \| null | yes | When it finished, ISO 8601; null while running or after failing. |
| `data.reverted_at` | string \| null | yes | When it was undone, ISO 8601; null otherwise. |
| `data.revertible` | boolean | yes | Whether the creator may still undo it **right now**: completed, of a kind that can be undone (an account relink or a channel swap, never a bot replacement), and inside the undo window. Show the undo exactly when this is true. |
| `data.revert_window_ends_at` | string \| null | yes | When the undo window closes, ISO 8601; null for a recovery that cannot be undone. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: for a resource without a standby, an allowance already spent for a degraded resource, or a caller who is not the project owner.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Request a replacement for a resource
`POST /v1/projects/{project}/resources/{resource}/replacement/request`
Asks the creator, on the connector, to pick the chat that replaces the resource's: the same conversation a standby request starts, ending in a swap instead of a spare. For a degraded resource the swap will spend the channel recovery allowance or join the recovery already absorbing swaps; for a healthy one it is on demand and free. Answers `202` once the request is sent.
- Requires ability: `project-recovery:create`
- MCP tools: `request_resource_replacement`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **202**: 202 with `resource_id` and `status: request_sent`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.resource_id` | string | yes | The resource whose place will be replaced. |
| `data.status` | string | yes | Always `request_sent`: the creator has been asked on the connector. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the resource is a perk with nothing to replace, the creator cannot be reached on the connector, or the caller is not the project owner; `error.context.resource_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Withdraw a replacement request
`DELETE /v1/projects/{project}/resources/{resource}/replacement/request`
Takes the open replacement request back. Answers `204`, also when nothing was open.
- Requires ability: `project-recovery:delete`
- MCP tools: `withdraw_resource_replacement_request`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Remove a project's standby installation
`DELETE /v1/projects/{project}/recovery/standby-installation`
```bash
curl -X DELETE https://api.subscriby.net/v1/projects/$PROJECT_ID/recovery/standby-installation \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Stops keeping the spare bot registered for the project and withdraws it from the connector; the live installation, members and access stay as they are. Answers `204`. Registering one is not offered to tokens, because it takes a credential; the dashboard's Prevention page does that. Raises `recovery.standby_removed` with `standby: installation`.
- Requires ability: `project-recovery:delete`
- Fires events: `recovery.standby_removed`
- MCP tools: `remove_standby_installation`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the caller is not the project owner.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Undo a recovery
`POST /v1/recovery/operations/{operation}/revert`
```bash
curl -X POST https://api.subscriby.net/v1/recovery/operations/$OPERATION_ID/revert \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6"}'
```
Inside the undo window (`revert_window_ends_at` on the operation) a completed recovery can be undone. A **channel recovery** is undone one swap at a time: `resource_id` names the resource to put back on its old chat, and every active member is re-admitted there again; the operation reads `reverted` once its last standing swap is undone. An **account relink** is undone whole and takes no `resource_id`: sign-in moves back to the previous account, every browser session is signed out, and a `relink_disputed` incident opens for support. A **bot replacement** cannot be undone.
Answers `200` with the operation, its `revertible` now `false`. Raises `recovery.operation_reverted`; a channel undo also moves the resource's place (`project.resource.updated` with `changes.space_id`) and an account undo opens the dispute incident (`recovery.incident_opened`).
- Requires ability: `project-recovery:update`
- Fires events: `recovery.operation_reverted`, `recovery.incident_opened`, `project.resource.updated`
- MCP tools: `revert_recovery_operation`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `operation` | path | string (uuid) | yes | The operation, resolved within the ledger by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
Which part of a recovery to undo. A channel recovery is undone one swap at a time and names the resource; an account relink is undone whole and takes an empty body.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string \| null (uuid) | no | For a channel recovery, the resource to put back on its old chat; every active member is re-admitted there again. Omit it for an account relink. |
### Responses
- **200**: 200 with the operation, its undo state updated.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The operation's id, the value the revert, nudge and notify endpoints take. |
| `data.kind` | string | yes | `account` (a sign-in relink), `bot` (a bot replacement) or `resources` (a channel swap). |
| `data.kind_label` | string | yes | The kind in the connector's own words, in the creator's language. |
| `data.status` | string | yes | `started` while the recovery runs, then `completed`, `failed` (with `failure_reason`) or `reverted`. |
| `data.status_label` | `In progress`, `Completed`, `Failed`, `Reverted` | yes | The status in the creator's language. |
| `data.connector` | string \| null | yes | The connector the recovery ran on, by key; null when it concerns no connector. |
| `data.project_id` | string \| null | yes | The project concerned; null for an account relink. |
| `data.project_name` | string \| null | yes | That project's name; null for an account relink. |
| `data.grant_id` | string \| null | yes | The support grant that paid for the recovery when the self-service allowance was already spent; null otherwise. |
| `data.automatic` | boolean | yes | True when the platform ran the recovery on the creator's behalf, the failover they sleep through. |
| `data.on_demand` | boolean | yes | True for a Swap & Grant of a healthy resource, which spends no allowance. |
| `data.failure_reason` | any | yes | Why a `failed` recovery failed; null otherwise. |
| `data.started_at` | string | yes | When the recovery started, ISO 8601. The row exists from that moment, because it is the quota ledger and the audit trail at once. |
| `data.completed_at` | string \| null | yes | When it finished, ISO 8601; null while running or after failing. |
| `data.reverted_at` | string \| null | yes | When it was undone, ISO 8601; null otherwise. |
| `data.revertible` | boolean | yes | Whether the creator may still undo it **right now**: completed, of a kind that can be undone (an account relink or a channel swap, never a bot replacement), and inside the undo window. Show the undo exactly when this is true. |
| `data.revert_window_ends_at` | string \| null | yes | When the undo window closes, ISO 8601; null for a recovery that cannot be undone. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: when `resource_id` is not a resource the token manages.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: for a bot replacement, a channel recovery with no `resource_id`, a closed window, a swap already put back, or a recovery that is not the token owner's; `error.context.operation_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Remind a recovery's stragglers
`POST /v1/recovery/operations/{operation}/nudge`
```bash
curl -X POST https://api.subscriby.net/v1/recovery/operations/$OPERATION_ID/nudge \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Sends one reminder, with a fresh link, to every member a channel recovery re-admitted who has not joined the new chat yet, and records the nudge on the operation. Read the roll call first: `can_nudge` says whether anyone is still outside and the cooldown has passed. Answers `200` with the count queued.
- Requires ability: `project-recovery:update`
- MCP tools: `nudge_pending_readmissions`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `operation` | path | string (uuid) | yes | The operation, resolved within the ledger by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with `operation_id` and how many reminders were `nudged`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.operation_id` | string | yes | The recovery whose stragglers were reminded. |
| `data.nudged` | integer | yes | How many reminders were queued. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when nobody is waiting, the nudge is too soon, or the recovery is of another kind.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Tell members about a bot replacement
`POST /v1/recovery/operations/{operation}/notify-members`
```bash
curl -X POST https://api.subscriby.net/v1/recovery/operations/$OPERATION_ID/notify-members \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
After a **bot replacement**, emails every member the project can reach by email that its bot changed, with the new bot's link and the portal, at the per-email fee added to the creator's transaction fees. Sent once per recovery: a second call answers with the count already sent and sends nothing. Answers `200` with the count.
- Requires ability: `project-recovery:update`
- MCP tools: `notify_members_of_recovery`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `operation` | path | string (uuid) | yes | The operation, resolved within the ledger by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with `operation_id` and how many members were `notified`, now or earlier.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.operation_id` | string | yes | The recovery the mail concerns. |
| `data.notified` | integer | yes | How many members were emailed, now or by the earlier call. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the recovery belongs to no project, so there is nobody to tell; `error.context.operation_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Open a recovery handshake
`POST /v1/me/recovery/handshakes`
```bash
curl -X POST https://api.subscriby.net/v1/me/recovery/handshakes \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"kind": "relink"}'
```
`kind` is `relink` (move the sign-in to a new account; needs the account recovery allowance) or `backup` (register a backup account; needs the Growth plan). Answers `201` with the pending handshake: `code` is the eight-character code to type into the bot, `start_link` the link that opens the bot on it (`null` when the creator cannot be reached on the connector), and the handshake expires fifteen minutes after it was minted.
- Requires ability: `project-recovery:create`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
Which handshake to open.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | `relink`, `backup` | yes | `relink` moves the sign-in to a new account and needs the account recovery allowance; `backup` registers a backup account and needs the Growth plan. |
### Responses
- **201**: 201 with the pending handshake, its code and link.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The handshake's id, the value the poll, cancel and confirm endpoints take. |
| `data.kind` | string | yes | `relink` (move the sign-in to a new account) or `backup` (register a backup account). |
| `data.kind_label` | `Replace my connected account`, `Register a backup account` | yes | The kind in the creator's language. |
| `data.status` | string | yes | `pending` until an account taps, `completed` when one has, `expired` when the code ran out or was cancelled, `consumed` once confirmed. |
| `data.status_label` | `Waiting on the connector`, `Account detected`, `Linked`, `Expired` | yes | The status in the creator's language. |
| `data.code` | string | yes | The eight-character code to type into the bot. |
| `data.start_link` | string \| null | yes | The link that opens the bot on the code; null when the creator cannot be reached on the connector. |
| `data.connector` | string \| null | yes | The connector the handshake runs on, by key. |
| `data.detected_display_name` | string \| null | yes | The account that tapped, as the connector reported it; null while pending. |
| `data.detected_identity_id` | string \| null | yes | The neutral identity id of the account that tapped, never the connector's own account identifier; null while pending. |
| `data.expires_at` | string | yes | When the code stops working, fifteen minutes after minting, ISO 8601. |
| `data.completed_at` | string \| null | yes | When an account tapped, ISO 8601; null while pending. |
| `data.created_at` | string \| null | yes | When the handshake was opened, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when the account allowance is spent (`relink`), the plan lacks the backup feature (`backup`), `kind` is neither, or the caller is not the account holder.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Confirm a relink handshake
`POST /v1/me/recovery/handshakes/{handshake}/confirm`
Confirms a **completed relink**: the sign-in moves to the account that tapped, every browser session is signed out, the account allowance is spent, and the undo mail goes out. Answers `200` with the recovery operation (`kind: account`, `revertible: true`) and raises `recovery.identity_relinked` beside the operation's `recovery.operation_started` and `recovery.operation_completed`.
- Requires ability: `project-recovery:create`
- Fires events: `recovery.operation_started`, `recovery.operation_completed`, `recovery.identity_relinked`, `recovery.incident_resolved`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `handshake` | path | string (uuid) | yes | The handshake, resolved within the caller's own by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with the recovery operation.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The operation's id, the value the revert, nudge and notify endpoints take. |
| `data.kind` | string | yes | `account` (a sign-in relink), `bot` (a bot replacement) or `resources` (a channel swap). |
| `data.kind_label` | string | yes | The kind in the connector's own words, in the creator's language. |
| `data.status` | string | yes | `started` while the recovery runs, then `completed`, `failed` (with `failure_reason`) or `reverted`. |
| `data.status_label` | `In progress`, `Completed`, `Failed`, `Reverted` | yes | The status in the creator's language. |
| `data.connector` | string \| null | yes | The connector the recovery ran on, by key; null when it concerns no connector. |
| `data.project_id` | string \| null | yes | The project concerned; null for an account relink. |
| `data.project_name` | string \| null | yes | That project's name; null for an account relink. |
| `data.grant_id` | string \| null | yes | The support grant that paid for the recovery when the self-service allowance was already spent; null otherwise. |
| `data.automatic` | boolean | yes | True when the platform ran the recovery on the creator's behalf, the failover they sleep through. |
| `data.on_demand` | boolean | yes | True for a Swap & Grant of a healthy resource, which spends no allowance. |
| `data.failure_reason` | any | yes | Why a `failed` recovery failed; null otherwise. |
| `data.started_at` | string | yes | When the recovery started, ISO 8601. The row exists from that moment, because it is the quota ledger and the audit trail at once. |
| `data.completed_at` | string \| null | yes | When it finished, ISO 8601; null while running or after failing. |
| `data.reverted_at` | string \| null | yes | When it was undone, ISO 8601; null otherwise. |
| `data.revertible` | boolean | yes | Whether the creator may still undo it **right now**: completed, of a kind that can be undone (an account relink or a channel swap, never a bot replacement), and inside the undo window. Show the undo exactly when this is true. |
| `data.revert_window_ends_at` | string \| null | yes | When the undo window closes, ISO 8601; null for a recovery that cannot be undone. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: for a pending or expired handshake, a `backup` handshake (which is confirmed by registering the backup identity), or a caller who is not the account holder; `error.context.handshake_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Register a backup account
`POST /v1/me/recovery/backup-identity`
```bash
curl -X POST https://api.subscriby.net/v1/me/recovery/backup-identity \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"handshake_id": "0c4f9e2a-7b31-4d68-a5e2-9f1c3b7d6e80"}'
```
Registers the account a **completed backup** handshake detected as the backup (Growth plan). Answers `200` with the account summary.
- Requires ability: `project-recovery:create`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
Which completed backup handshake names the account to keep as the backup.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `handshake_id` | string (uuid) | yes | A `backup` handshake of the caller's in state `completed`; the account that tapped it becomes the backup. |
### Responses
- **200**: 200 with the account summary.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.primary_display_name` | string \| null | yes | The account that signs the creator in, as the connector reported it (display name, else handle, else the platform's id); null when none is linked. Connector identifiers never appear. |
| `data.backup_identity_registered` | boolean | yes | Whether a backup account is kept ahead of a ban. |
| `data.backup_display_name` | string \| null | yes | The backup account as the connector reported it; null when none is kept. |
| `data.relink_handshake` | array \| null | yes | The newest handshake that moves the sign-in, in whatever state, so a client can pick up a flow the creator started on the dashboard or in the bot; null when none was ever opened. |
| `data.backup_handshake` | array \| null | yes | The newest handshake that registers a backup, in whatever state; null when none was ever opened. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: when the handshake is not one of the caller's; `error.context.handshake_id` names it.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when the handshake is not a completed `backup` handshake, the plan lacks the feature, or the caller is not the account holder.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Remove the backup account
`DELETE /v1/me/recovery/backup-identity`
Stops keeping a backup. Answers `204`.
- Requires ability: `project-recovery:delete`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the caller is not the account holder.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Switch to the backup account
`POST /v1/me/recovery/backup-identity/switch`
Moves the sign-in to the backup right now, keeping the previous account as the new backup; spends the account allowance and offers the same undo as a relink. Answers `200` with the recovery operation and raises `recovery.identity_relinked` beside the operation's `recovery.operation_started` and `recovery.operation_completed`.
- Requires ability: `project-recovery:create`
- Fires events: `recovery.operation_started`, `recovery.operation_completed`, `recovery.identity_relinked`, `recovery.incident_resolved`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with the recovery operation.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The operation's id, the value the revert, nudge and notify endpoints take. |
| `data.kind` | string | yes | `account` (a sign-in relink), `bot` (a bot replacement) or `resources` (a channel swap). |
| `data.kind_label` | string | yes | The kind in the connector's own words, in the creator's language. |
| `data.status` | string | yes | `started` while the recovery runs, then `completed`, `failed` (with `failure_reason`) or `reverted`. |
| `data.status_label` | `In progress`, `Completed`, `Failed`, `Reverted` | yes | The status in the creator's language. |
| `data.connector` | string \| null | yes | The connector the recovery ran on, by key; null when it concerns no connector. |
| `data.project_id` | string \| null | yes | The project concerned; null for an account relink. |
| `data.project_name` | string \| null | yes | That project's name; null for an account relink. |
| `data.grant_id` | string \| null | yes | The support grant that paid for the recovery when the self-service allowance was already spent; null otherwise. |
| `data.automatic` | boolean | yes | True when the platform ran the recovery on the creator's behalf, the failover they sleep through. |
| `data.on_demand` | boolean | yes | True for a Swap & Grant of a healthy resource, which spends no allowance. |
| `data.failure_reason` | any | yes | Why a `failed` recovery failed; null otherwise. |
| `data.started_at` | string | yes | When the recovery started, ISO 8601. The row exists from that moment, because it is the quota ledger and the audit trail at once. |
| `data.completed_at` | string \| null | yes | When it finished, ISO 8601; null while running or after failing. |
| `data.reverted_at` | string \| null | yes | When it was undone, ISO 8601; null otherwise. |
| `data.revertible` | boolean | yes | Whether the creator may still undo it **right now**: completed, of a kind that can be undone (an account relink or a channel swap, never a bot replacement), and inside the undo window. Show the undo exactly when this is true. |
| `data.revert_window_ends_at` | string \| null | yes | When the undo window closes, ISO 8601; null for a recovery that cannot be undone. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when no backup is registered, the account allowance is spent, or the caller is not the account holder.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Disaster Recovery Program](https://docs.subscriby.net/disaster-recovery): What the program does and why.
- [History and Undo](https://docs.subscriby.net/disaster-recovery/history-and-undo): The same ledger on the dashboard.
- [Active Disaster Prevention](https://docs.subscriby.net/disaster-recovery/active-disaster-prevention): The features the readiness lines check.
- [Allowances and Support Review](https://docs.subscriby.net/disaster-recovery/allowances-and-support-review): The quota rule and what support audits before releasing a grant.
- [recovery.* events](https://docs.subscriby.net/webhooks/v1/events/recovery): The Disaster Recovery ledger as it moves: incidents opening and resolving, recoveries starting, finishing, failing and being undone, standbys kept and consumed, channels swapped and accounts relinked.
- [Ability Catalog](https://docs.subscriby.net/api/v1/abilities): Every ability string a token can carry, what enforces it on the API and the MCP server, and how to pick the right ones when minting.
---
# Distribution API
Source: https://docs.subscriby.net/api/v1/reference/distribution
Every project exposes two public URLs: the subscriber portal and the deep link into the project's connector installation, with a start payload that decides what opens. These endpoints return the canonical strings so clients do not reconstruct them from project, installation and plan fields themselves; if the URL shapes ever change, REST callers get the update for free. The connector shapes its own links; nothing here spells a platform's URL.
## Endpoints
- `GET /v1/projects/{project}/distribution/deep-link` — [Build a deep link](#build-a-deep-link)
- `GET /v1/projects/{project}/distribution/portal-url` — [Get the portal URL](#get-the-portal-url)
## Build a deep link
`GET /v1/projects/{project}/distribution/deep-link`
Builds a start link against the project's connected installation. The payload depends on which query parameter you pass (at most one; an access code wins over a plan, and a plan over a custom parameter):
| Query parameter | Resulting payload | Acted on by the bot? |
| --------------- | --------------------------- | ------------------------------------------------------------ |
| `access_code` | the code verbatim | Yes: redeemed on open. |
| `plan_id` | the plan's bare UUID | Yes: opens that plan's checkout flow. |
| `custom` | the caller-supplied string | No, see below. |
| *(none)* | `project_` | No: falls through to the project home, which is the intent. |
> **Only a bare UUID is acted on.** The bot acts on a start payload only when it is a bare UUID: it is matched against the project's plans first, then tried as an access code. `custom` payloads and the default `project_` do not match that shape, so the bot ignores them and opens its home screen. `custom` is therefore a passthrough for **your own** tracking: the platform delivers it and you can read it off the update in a webhook consumer, but Subscriby neither stores it nor attaches it to the member.
```bash
curl "https://api.subscriby.net/v1/projects/$PROJECT_ID/distribution/deep-link?plan_id=$PLAN_ID" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
- Requires ability: `distribution:read`
- MCP tools: `get_deep_link`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `plan_id` | query | string \| null (uuid) | no | A plan of this project; the link then opens that plan's checkout. Answers 404 for a plan outside the project. Ignored when `access_code` is also sent. |
| `access_code` | query | string \| null | no | An access code, carried verbatim and redeemed when the link is opened. Wins over `plan_id` and `custom`. |
| `custom` | query | string \| null | no | Your own tracking string, 1 to 64 characters of letters, digits, underscore or hyphen. Delivered by the platform but not acted on or stored by Subscriby. Used only when neither `access_code` nor `plan_id` is sent. |
### Responses
- **200**: The bot URL, the start payload and the assembled deep link.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.project_id` | string | yes | The project. |
| `data.bot_url` | string \| null | yes | The public URL of the project's live installation. |
| `data.start_payload` | string | yes | The payload the link carries, as the table above resolves it. |
| `data.deep_link` | string \| null | yes | The assembled start link. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: when `plan_id` does not belong to this project.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: with `reason: no_bot_attached` when the project has no connected installation to link to. `VALIDATION_FAILED`: on `plan_id` when it is not a UUID, or on `custom` when it is not 1 to 64 characters of letters, digits, underscore or hyphen.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get the portal URL
`GET /v1/projects/{project}/distribution/portal-url`
The portal URL always uses the project's current `handle`. If you change the handle the portal URL updates in lock-step; any old link answers 404 on the portal host.
- Requires ability: `distribution:read`
- MCP tools: `get_portal_url`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
### Responses
- **200**: The portal URL for the project's handle.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.project_id` | string | yes | The project. |
| `data.handle` | string \| null | yes | The project's current handle, the last segment of the portal URL. |
| `data.portal_url` | string | yes | The subscriber portal for the project. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors): Read the installation's state before building deep links; the installation object carries its handle.
- [Projects API](https://docs.subscriby.net/api/v1/reference/projects): handle on the project resource.
- [Share Your Project](https://docs.subscriby.net/creators/share): Put your project in front of subscribers through your bot link, the hosted portal page, QR codes and embedded buttons.
---
# Groups API
Source: https://docs.subscriby.net/api/v1/reference/groups
A **group** is a named bundle of permissions on a team that several collaborators hold at once: put the three people who run the inbox into a `Support` group and they share its permissions, take one out and they keep only what their own role gives them. Groups sit beside [roles](https://docs.subscriby.net/api/v1/reference/roles), which are assigned one per collaborator; a group is the way to grant the same extra permissions to a set of people without editing each role.
Each group carries `permissions`, the exact ability strings a token can hold, and `member_ids`, the collaborators in it. Both are returned when a group is read individually or after a write and omitted from the list so a listing stays one query. Membership is replaced as a whole with `PUT …/members`, so a sync from your own directory sends the full list each time rather than adding and removing one by one. Deleting a group removes what it granted from everyone in it; nobody leaves the team.
Visibility is the union of every team the caller owns or belongs to, and a group on a team outside that set answers `404 RESOURCE_NOT_FOUND` rather than `403`, so the API never confirms that a team exists. Every write announces itself as a `group.*` event.
## Endpoints
- `GET /v1/groups` — [List groups](#list-groups)
- `POST /v1/groups` — [Create a group](#create-a-group)
- `GET /v1/groups/{group}` — [Get a group](#get-a-group)
- `PATCH /v1/groups/{group}` — [Update a group](#update-a-group)
- `DELETE /v1/groups/{group}` — [Delete a group](#delete-a-group)
- `PUT /v1/groups/{group}/members` — [Replace a group's members](#replace-a-groups-members)
## List groups
`GET /v1/groups`
```bash
curl https://api.subscriby.net/v1/groups \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Every group across the teams the caller sees, newest first. Rows carry neither `permissions` nor `member_ids`; read a group individually for those. Visibility is the union of every team the caller owns or belongs to; a group from a team the caller cannot see answers `404 RESOURCE_NOT_FOUND` rather than `403`, so the API cannot be used to discover that a group id exists.
- Requires ability: `group:view-any`
- MCP tools: `list_groups`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: Groups across every team the caller belongs to.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The group's id. |
| `data[].team_id` | string \| null | yes | The team the group belongs to. |
| `data[].code` | string | yes | The machine name, unique within the team and immutable after creation, because permissions are addressed by it. |
| `data[].name` | string | yes | The display name. |
| `data[].permissions` | array of string | no | The permission codes the group grants. Returned when the group is read individually or after a write, omitted from list responses so a listing stays one query. |
| `data[].member_ids` | array of string | no | The users in the group. Returned after a member sync, omitted elsewhere. |
| `data[].created_at` | string \| null | yes | When the group was created, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Create a group
`POST /v1/groups`
```bash
curl -X POST https://api.subscriby.net/v1/groups \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"code": "billing-team",
"name": "Billing Team",
"permissions": ["project-subscription:view-any"]
}'
```
> **Choose the code deliberately.** `code` is unique within the team and **cannot be changed afterwards**: permissions are addressed by it. Only `name` and the permission set are mutable.
A group starts empty. Add people with the members endpoint; membership is deliberately not a field here, because replacing who is in a group and naming it are different operations with different consequences.
Answers `201` with the group and its permissions. Emits `group.created`.
- Requires ability: `group:create`
- Fires events: `group.created`
- MCP tools: `create_group`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A group's team, code, name and permissions. On create `team_id`, `code` and `name` are required; on update `team_id` and `code` are prohibited (a group is neither moved nor renamed by code) and `name` and `permissions` are optional. Membership is not set here but with the members endpoint.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string (uuid) | yes | The team to create the group in; the caller must belong to it, and a team outside their account answers 404. Prohibited on update: a group cannot be moved between teams. |
| `code` | string | yes | The machine name, up to 255 letters, numbers, dashes and underscores, unique within the team and immutable afterwards because permissions are addressed by it. Prohibited on update. |
| `name` | string | yes | The display name, up to 255 characters. |
| `permissions` | array of `project:view-any`, `project:view`, `project:create`, `project:update`, `project:delete`, `project-payment-method:view-any`, `project-payment-method:view`, `project-payment-method:create`, `project-payment-method:update`, `project-payment-method:delete`, `project-resource:view-any`, `project-resource:view`, `project-resource:create`, `project-resource:update`, `project-resource:delete`, `project-access-code:view-any`, `project-access-code:view`, `project-access-code:create`, `project-access-code:update`, `project-access-code:delete`, `project-coupon:view-any`, `project-coupon:view`, `project-coupon:create`, `project-coupon:update`, `project-coupon:delete`, `project-subscription:view-any`, `project-subscription:view`, `project-subscription:create`, `project-subscription:update`, `project-subscription:delete`, `project-subscription-plan:view-any`, `project-subscription-plan:view`, `project-subscription-plan:create`, `project-subscription-plan:update`, `project-subscription-plan:delete`, `project-user:view-any`, `project-user:view`, `project-user:create`, `project-user:update`, `project-user:delete`, `project-recovery:view-any`, `project-recovery:view`, `project-recovery:create`, `project-recovery:update`, `project-recovery:delete`, `project-connector:view-any`, `project-connector:view`, `project-connector:create`, `project-connector:update`, `project-connector:delete`, `support-conversation:view-any`, `support-conversation:view`, `support-conversation:create`, `support-conversation:update`, `support-conversation:delete`, `team-member:view-any`, `team-member:view`, `team-member:invite`, `team-member:remove`, `team-member:update-role` | no | The permission codes the group grants. Sending it sets the group's permissions to exactly this list; omit the key to leave the existing set untouched. Defaults to none on create. |
### Responses
- **201**: The group resource as a 201.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | any | yes | The response's payload. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: below the Growth tier; nothing changes.
- **404**: `RESOURCE_NOT_FOUND`: when `team_id` is not one of the caller's teams.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when a group with that code already exists in the team; `error.context.code` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a group
`GET /v1/groups/{group}`
One group with its `permissions` and `member_ids`, which are returned when the group is read individually or after a write and omitted from list responses so a listing stays one query.
- Requires ability: `group:view`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `group` | path | string (uuid) | yes | The group, resolved by the route binder. |
### Responses
- **200**: The group resource.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The group's id. |
| `data.team_id` | string \| null | yes | The team the group belongs to. |
| `data.code` | string | yes | The machine name, unique within the team and immutable after creation, because permissions are addressed by it. |
| `data.name` | string | yes | The display name. |
| `data.permissions` | array of string | no | The permission codes the group grants. Returned when the group is read individually or after a write, omitted from list responses so a listing stays one query. |
| `data.member_ids` | array of string | no | The users in the group. Returned after a member sync, omitted elsewhere. |
| `data.created_at` | string \| null | yes | When the group was created, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Update a group
`PATCH /v1/groups/{group}`
```bash
curl -X PATCH https://api.subscriby.net/v1/groups/$GROUP_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"name": "Billing & Finance"}'
```
> **Permissions replace, they do not merge.** Sending `permissions` sets the group's permissions to exactly that list. **Omit the key entirely** to leave the existing set untouched while changing only the name.
`code` and `team_id` are prohibited: a group is neither renamed by code nor moved between teams. Answers `200` with the group and its permissions. Emits `group.updated`.
- Requires ability: `group:update`
- Fires events: `group.updated`
- MCP tools: `update_group`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `group` | path | string (uuid) | yes | The group, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A group's team, code, name and permissions. On create `team_id`, `code` and `name` are required; on update `team_id` and `code` are prohibited (a group is neither moved nor renamed by code) and `name` and `permissions` are optional. Membership is not set here but with the members endpoint.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string (uuid) | yes | The team to create the group in; the caller must belong to it, and a team outside their account answers 404. Prohibited on update: a group cannot be moved between teams. |
| `code` | string | yes | The machine name, up to 255 letters, numbers, dashes and underscores, unique within the team and immutable afterwards because permissions are addressed by it. Prohibited on update. |
| `name` | string | yes | The display name, up to 255 characters. |
| `permissions` | array of `project:view-any`, `project:view`, `project:create`, `project:update`, `project:delete`, `project-payment-method:view-any`, `project-payment-method:view`, `project-payment-method:create`, `project-payment-method:update`, `project-payment-method:delete`, `project-resource:view-any`, `project-resource:view`, `project-resource:create`, `project-resource:update`, `project-resource:delete`, `project-access-code:view-any`, `project-access-code:view`, `project-access-code:create`, `project-access-code:update`, `project-access-code:delete`, `project-coupon:view-any`, `project-coupon:view`, `project-coupon:create`, `project-coupon:update`, `project-coupon:delete`, `project-subscription:view-any`, `project-subscription:view`, `project-subscription:create`, `project-subscription:update`, `project-subscription:delete`, `project-subscription-plan:view-any`, `project-subscription-plan:view`, `project-subscription-plan:create`, `project-subscription-plan:update`, `project-subscription-plan:delete`, `project-user:view-any`, `project-user:view`, `project-user:create`, `project-user:update`, `project-user:delete`, `project-recovery:view-any`, `project-recovery:view`, `project-recovery:create`, `project-recovery:update`, `project-recovery:delete`, `project-connector:view-any`, `project-connector:view`, `project-connector:create`, `project-connector:update`, `project-connector:delete`, `support-conversation:view-any`, `support-conversation:view`, `support-conversation:create`, `support-conversation:update`, `support-conversation:delete`, `team-member:view-any`, `team-member:view`, `team-member:invite`, `team-member:remove`, `team-member:update-role` | no | The permission codes the group grants. Sending it sets the group's permissions to exactly this list; omit the key to leave the existing set untouched. Defaults to none on create. |
### Responses
- **200**: The updated group resource.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | any | yes | The response's payload. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: below the Growth tier; nothing changes.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `code` or `team_id` is sent, or a permission code is unknown.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Delete a group
`DELETE /v1/groups/{group}`
```bash
curl -X DELETE https://api.subscriby.net/v1/groups/$GROUP_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Returns `204 No Content`. Everyone in the group loses whatever it granted them, but stays in the team. Not gated by tier. Emits `group.deleted`.
- Requires ability: `group:delete`
- Fires events: `group.deleted`
- MCP tools: `delete_group`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `group` | path | string (uuid) | yes | The group, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Replace a group's members
`PUT /v1/groups/{group}/members`
```bash
curl -X PUT https://api.subscriby.net/v1/groups/$GROUP_ID/members \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"user_ids": ["2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80", "4b8e2f6a-1c3d-4e5f-9a7b-8c9d0e1f2a3b"]}'
```
This is a **sync**, not an add. The array replaces the membership entirely: anyone omitted is removed, and `[]` empties the group. That is deliberate and differs from the permission field above: clearing a group is a legitimate thing to ask for, whereas silently stripping every permission because a field was blank is not.
This is the one write in the identity surface whose tier gate depends on its argument: a sync that only removes people succeeds on any tier, and one that adds anybody needs Growth. Deciding from the route alone would leave a lapsed creator unable to take one person out of a group without deleting the whole group, and deleting is not gated, so the gate would only be pushing them toward the more destructive option.
Every id must already belong to the group's team. A group grants permissions inside one tenant, so an outsider is refused naming the offending ids; the whole call fails rather than partly applying, so you never end up with a membership you did not ask for.
Answers `200` with the group, its `permissions` and its `member_ids`. Emits `group.members_synced`, which carries `added_ids` and `removed_ids` as well as the final list, so an access-control mirror does not have to diff two snapshots to work out what moved. A sync that changes nothing emits nothing.
- Requires ability: `group:update`
- Fires events: `group.members_synced`
- MCP tools: `sync_group_members`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `group` | path | string (uuid) | yes | The group, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
The group's complete membership after the call. A sync, not an add: anyone omitted is removed, and `[]` empties the group.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `user_ids` | array of string (uuid) | yes | Every user the group should have afterwards. Must be present; `[]` empties the group. Each id must already belong to the group's team, or the whole call is refused naming the outsiders. |
### Responses
- **200**: The group with its permissions and members.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | any | yes | The response's payload. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: below the Growth tier when the sync would add somebody; a sync that only removes people succeeds on any tier.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when any id is not a member of the group's team; `error.context.user_ids` lists the outsiders.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Teams API](https://docs.subscriby.net/api/v1/reference/teams): Teams group creators, roles, and projects.
- [Roles API](https://docs.subscriby.net/api/v1/reference/roles): Per-collaborator permission sets
- [Team Members API](https://docs.subscriby.net/api/v1/reference/team-members): Team members are the humans who collaborate inside a team: the creator who owns it and the people they invited to help run its projects.
- [group.* events](https://docs.subscriby.net/webhooks/v1/events/group): Groups bundle collaborators for access control: a named permission set that several people in a team share, and someone can be in more than one.
---
# Identities API
Source: https://docs.subscriby.net/api/v1/reference/identities
A **linked identity** is an account you hold on a connector, tied to your Subscriby creator account. It is what lets the platform bot reach you, lets you manage projects from it, and lets the connector's sign-in button sign you in. The dashboard shows the same list under **Settings → Security → Linked accounts**.
These routes are about the **caller's own** account, so they live under `/v1/me` and need no project or team in the path. A member's connected accounts are a different thing, read under [Members](https://docs.subscriby.net/api/v1/reference/members).
## Endpoints
- `GET /v1/me/identities` — [List the caller's linked accounts](#list-the-callers-linked-accounts)
- `DELETE /v1/me/identities/{identity}` — [Unlink an account](#unlink-an-account)
## List the caller's linked accounts
`GET /v1/me/identities`
```bash
curl https://api.subscriby.net/v1/me/identities \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Every account linked to the caller's creator account, by connector then purpose. Never paginated.
- `purpose` is `primary` (the account that signs you in and receives alerts) or `backup` (the account registered for [Disaster Recovery](https://docs.subscriby.net/account/account-recovery)).
- `source` says how the link was proven: `handshake` (you completed a link from Settings), `bot` (the account came in through the connector's own flow), `backfill` (migrated from before connectors), `portal` or `adopted`.
- `external_id` is the platform's own id for the account, `username` its handle when the platform has one. Secrets and chat rows are never returned.
> **Linking has no endpoint.** Linking is a two-sided proof: you open it from **Settings → Security → Linked accounts** and finish it from the account itself, by opening the link or sending the code to the connector's bot. A token cannot stand in for a person on the connector, so there is nothing to POST here.
- Requires ability: `account:read`
- MCP tools: `get_me`
### Responses
- **200**: Array of `UserIdentityResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].id` | string | yes | The link's id, the value the unlink endpoint takes. |
| `data[].connector` | string | yes | The connector the account is on, by key. |
| `data[].purpose` | string | yes | `primary` for the account that signs the creator in and receives alerts; `backup` for the account registered for Disaster Recovery. |
| `data[].source` | string | yes | How the link was proven: `handshake` (completed from Settings), `bot` (the account came in through the connector's own flow), `backfill` (migrated from before connectors), `portal` or `adopted`. |
| `data[].external_id` | string | yes | The platform's own id for the account, so an integration can match the creator against the connector's records. |
| `data[].display_name` | string \| null | yes | What the platform calls the person. |
| `data[].username` | string \| null | yes | The account's handle when the platform has one; null otherwise. |
| `data[].linked_at` | string | yes | When the account was linked, ISO 8601. |
| `data[].verified_at` | string \| null | yes | When the link was proven, ISO 8601; null while unverified. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Unlink an account
`DELETE /v1/me/identities/{identity}`
```bash
curl -X DELETE https://api.subscriby.net/v1/me/identities/$IDENTITY_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Drops a **primary** link at once and answers an empty `204`. You keep your password and passkeys, so nothing locks you out; what you lose until you link again is the connector side: its bot can no longer reach you or manage your projects, and its sign-in button no longer signs you in.
- Requires ability: `account:write`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `identity` | path | string (uuid) | yes | One of the caller's links, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: for an id that is not one of the caller's own links, so the API cannot be used to probe which ids exist.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: for a **backup** account: it is removed from the Disaster Recovery page, where the change is recorded, not from here.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Signing In with a Connected Platform](https://docs.subscriby.net/account/social-login): Linking and unlinking from the dashboard.
- [Me API](https://docs.subscriby.net/api/v1/reference/me): The same list embedded as identities.
- [Tokens API](https://docs.subscriby.net/api/v1/reference/tokens): Minting a token with the account:* abilities.
---
# API Reference
Source: https://docs.subscriby.net/api/v1/reference
Every operation of the REST API, generated from the OpenAPI document Subscriby publishes and grouped by resource: the request fields with their types and constraints, the response fields, every refusal, the ability the token must carry, the webhook events the call fires, the MCP tools that wrap it, code samples in seven languages, and a playground that sends the request to `api.subscriby.net` with your token. Each group is one page; a row below opens it at that operation.
## Access Codes API
Access codes are pre-generated single-use subscription activations a creator hands out. [Read the overview](https://docs.subscriby.net/api/v1/reference/access-codes).
- `GET /v1/projects/{project}/plans/{plan}/access-codes` — [List a plan's access codes](https://docs.subscriby.net/api/v1/reference/access-codes#list-a-plans-access-codes)
- `GET /v1/projects/{project}/plans/{plan}/access-codes/preview` — [Price a batch](https://docs.subscriby.net/api/v1/reference/access-codes#price-a-batch)
- `POST /v1/projects/{project}/plans/{plan}/access-codes/bulk-generate` — [Generate a batch of codes](https://docs.subscriby.net/api/v1/reference/access-codes#generate-a-batch-of-codes)
- `DELETE /v1/projects/{project}/plans/{plan}/access-codes/{accessCode}` — [Revoke an access code](https://docs.subscriby.net/api/v1/reference/access-codes#revoke-an-access-code)
## Activity API
Every mutation Subscriby performs, whether from the dashboard, the REST API, an MCP tool call, a Zapier step or a scheduled system task, is recorded against its subject in the activity log. [Read the overview](https://docs.subscriby.net/api/v1/reference/activity).
- `GET /v1/activity` — [Read a subject's activity](https://docs.subscriby.net/api/v1/reference/activity#read-a-subjects-activity)
## Alert Destinations API
An alert destination is a place your creator alerts go: one of the accounts linked to your creator account on a connector, or your email address. [Read the overview](https://docs.subscriby.net/api/v1/reference/alert-destinations).
- `GET /v1/me/alert-destinations` — [List your alert destinations](https://docs.subscriby.net/api/v1/reference/alert-destinations#list-your-alert-destinations)
- `PUT /v1/me/alert-destinations` — [Replace your alert destinations](https://docs.subscriby.net/api/v1/reference/alert-destinations#replace-your-alert-destinations)
## Analytics API
Read-only analytics under /v1/analytics/ surface the same figures the creator dashboard renders. [Read the overview](https://docs.subscriby.net/api/v1/reference/analytics).
- `GET /v1/analytics/dashboard` — [Headline figures](https://docs.subscriby.net/api/v1/reference/analytics#headline-figures)
- `GET /v1/analytics/earnings` — [Earnings report](https://docs.subscriby.net/api/v1/reference/analytics#earnings-report)
- `GET /v1/analytics/subscribers` — [Subscriber analytics](https://docs.subscriby.net/api/v1/reference/analytics#subscriber-analytics)
- `GET /v1/analytics/transactions` — [List transactions](https://docs.subscriby.net/api/v1/reference/analytics#list-transactions)
- `GET /v1/analytics/transactions/breakdown` — [Transaction breakdown](https://docs.subscriby.net/api/v1/reference/analytics#transaction-breakdown)
- `GET /v1/analytics/plan-performance` — [Plan performance](https://docs.subscriby.net/api/v1/reference/analytics#plan-performance)
- `GET /v1/analytics/connectors` — [By connector](https://docs.subscriby.net/api/v1/reference/analytics#by-connector)
- `GET /v1/analytics/composition` — [Revenue composition](https://docs.subscriby.net/api/v1/reference/analytics#revenue-composition)
## Broadcasts API
A broadcast sends one message, through the project's connector, to a segment of the project's members. [Read the overview](https://docs.subscriby.net/api/v1/reference/broadcasts).
- `GET /v1/projects/{project}/broadcasts/audiences` — [List every audience](https://docs.subscriby.net/api/v1/reference/broadcasts#list-every-audience)
- `GET /v1/projects/{project}/broadcasts/preview` — [Preview an audience](https://docs.subscriby.net/api/v1/reference/broadcasts#preview-an-audience)
- `POST /v1/projects/{project}/broadcasts` — [Send a broadcast](https://docs.subscriby.net/api/v1/reference/broadcasts#send-a-broadcast)
## Connectors API
A Subscriby project runs on connectors: the platforms it gates access on, sends messages through and takes payments from. [Read the overview](https://docs.subscriby.net/api/v1/reference/connectors).
- `GET /v1/connectors` — [List the connector directory](https://docs.subscriby.net/api/v1/reference/connectors#list-the-connector-directory)
- `GET /v1/connectors/{key}` — [Get one connector](https://docs.subscriby.net/api/v1/reference/connectors#get-one-connector)
- `GET /v1/projects/{project}/connectors` — [List a project's installations](https://docs.subscriby.net/api/v1/reference/connectors#list-a-projects-installations)
- `GET /v1/projects/{project}/connectors/{key}/installation` — [Get a project's installation of a connector](https://docs.subscriby.net/api/v1/reference/connectors#get-a-projects-installation-of-a-connector)
- `DELETE /v1/projects/{project}/connectors/{key}/installation` — [Disconnect an installation](https://docs.subscriby.net/api/v1/reference/connectors#disconnect-an-installation)
- `GET /v1/projects/{project}/connectors/{key}/uninstall-preview` — [Preview an uninstall](https://docs.subscriby.net/api/v1/reference/connectors#preview-an-uninstall)
- `POST /v1/projects/{project}/connectors/{key}` — [Install a connector](https://docs.subscriby.net/api/v1/reference/connectors#install-a-connector)
- `DELETE /v1/projects/{project}/connectors/{key}` — [Uninstall a connector](https://docs.subscriby.net/api/v1/reference/connectors#uninstall-a-connector)
- `POST /v1/projects/{project}/connectors/{key}/installation/verify` — [Verify an installation](https://docs.subscriby.net/api/v1/reference/connectors#verify-an-installation)
- `POST /v1/projects/{project}/connectors/{key}/installation/doctor` — [Run the connector doctor](https://docs.subscriby.net/api/v1/reference/connectors#run-the-connector-doctor)
- `POST /v1/projects/{project}/connectors/{key}/installation/restore-access` — [Restore access after a reinstall](https://docs.subscriby.net/api/v1/reference/connectors#restore-access-after-a-reinstall)
- `PATCH /v1/projects/{project}/connectors/{key}/installation/settings` — [Change an installation's settings](https://docs.subscriby.net/api/v1/reference/connectors#change-an-installations-settings)
## Coupons API
A coupon is one code many subscribers can redeem for money off at checkout: the multi-redemption counterpart to an access code, which is one code for one person and grants access outright without a payment. [Read the overview](https://docs.subscriby.net/api/v1/reference/coupons).
- `GET /v1/projects/{project}/coupons` — [List a project's coupons](https://docs.subscriby.net/api/v1/reference/coupons#list-a-projects-coupons)
- `POST /v1/projects/{project}/coupons` — [Create a coupon](https://docs.subscriby.net/api/v1/reference/coupons#create-a-coupon)
- `GET /v1/projects/{project}/coupons/{coupon}` — [Get a coupon](https://docs.subscriby.net/api/v1/reference/coupons#get-a-coupon)
- `PATCH /v1/projects/{project}/coupons/{coupon}` — [Update a coupon](https://docs.subscriby.net/api/v1/reference/coupons#update-a-coupon)
- `DELETE /v1/projects/{project}/coupons/{coupon}` — [Delete a coupon](https://docs.subscriby.net/api/v1/reference/coupons#delete-a-coupon)
- `POST /v1/projects/{project}/coupons/{coupon}/activate` — [Activate a coupon](https://docs.subscriby.net/api/v1/reference/coupons#activate-a-coupon)
- `POST /v1/projects/{project}/coupons/{coupon}/deactivate` — [Deactivate a coupon](https://docs.subscriby.net/api/v1/reference/coupons#deactivate-a-coupon)
## Creator Tasks API
A creator task is a grant no connector can give. [Read the overview](https://docs.subscriby.net/api/v1/reference/creator-tasks).
- `GET /v1/projects/{project}/creator-tasks` — [List a project's creator tasks](https://docs.subscriby.net/api/v1/reference/creator-tasks#list-a-projects-creator-tasks)
- `POST /v1/projects/{project}/creator-tasks/{task}/complete` — [Complete a creator task](https://docs.subscriby.net/api/v1/reference/creator-tasks#complete-a-creator-task)
## Disaster Recovery API
Everything the Disaster Recovery pages show a creator is readable here: what the health probes found broken, what was done about it and whether it can still be undone, how many members a channel recovery has re-admitted so far, how many self-service recoveries the rolling window still allows, and how ready the account is for the next ban. [Read the overview](https://docs.subscriby.net/api/v1/reference/disaster-recovery).
- `GET /v1/projects/{project}/resources/{resource}/standby` — [Get a resource's standby](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-resources-standby)
- `PATCH /v1/projects/{project}/resources/{resource}/standby` — [Switch a standby's live mirror](https://docs.subscriby.net/api/v1/reference/disaster-recovery#switch-a-standbys-live-mirror)
- `DELETE /v1/projects/{project}/resources/{resource}/standby` — [Remove a resource's standby](https://docs.subscriby.net/api/v1/reference/disaster-recovery#remove-a-resources-standby)
- `GET /v1/projects/{project}/recovery/settings` — [Get a project's recovery settings](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-projects-recovery-settings)
- `PATCH /v1/projects/{project}/recovery/settings` — [Update a project's recovery settings](https://docs.subscriby.net/api/v1/reference/disaster-recovery#update-a-projects-recovery-settings)
- `GET /v1/projects/{project}/recovery/members/export` — [Export a project's member list](https://docs.subscriby.net/api/v1/reference/disaster-recovery#export-a-projects-member-list)
- `GET /v1/recovery/readiness` — [Get the readiness checklist](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-the-readiness-checklist)
- `GET /v1/recovery/incidents` — [List incidents](https://docs.subscriby.net/api/v1/reference/disaster-recovery#list-incidents)
- `GET /v1/recovery/incidents/{incident}` — [Get an incident](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-an-incident)
- `GET /v1/recovery/operations` — [List recovery operations](https://docs.subscriby.net/api/v1/reference/disaster-recovery#list-recovery-operations)
- `GET /v1/recovery/operations/{operation}` — [Get a recovery operation](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-recovery-operation)
- `GET /v1/recovery/operations/{operation}/roll-call` — [Get a recovery's roll call](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-recoverys-roll-call)
- `GET /v1/recovery/allowances` — [Get the recovery allowances](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-the-recovery-allowances)
- `GET /v1/me/recovery` — [Get the caller's recovery account](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-the-callers-recovery-account)
- `GET /v1/me/recovery/handshakes/{handshake}` — [Poll a recovery handshake](https://docs.subscriby.net/api/v1/reference/disaster-recovery#poll-a-recovery-handshake)
- `DELETE /v1/me/recovery/handshakes/{handshake}` — [Cancel a recovery handshake](https://docs.subscriby.net/api/v1/reference/disaster-recovery#cancel-a-recovery-handshake)
- `POST /v1/projects/{project}/resources/{resource}/standby/request` — [Request a standby for a resource](https://docs.subscriby.net/api/v1/reference/disaster-recovery#request-a-standby-for-a-resource)
- `DELETE /v1/projects/{project}/resources/{resource}/standby/request` — [Withdraw a standby request](https://docs.subscriby.net/api/v1/reference/disaster-recovery#withdraw-a-standby-request)
- `POST /v1/projects/{project}/resources/{resource}/standby/use` — [Use a resource's standby now](https://docs.subscriby.net/api/v1/reference/disaster-recovery#use-a-resources-standby-now)
- `POST /v1/projects/{project}/resources/{resource}/replacement/request` — [Request a replacement for a resource](https://docs.subscriby.net/api/v1/reference/disaster-recovery#request-a-replacement-for-a-resource)
- `DELETE /v1/projects/{project}/resources/{resource}/replacement/request` — [Withdraw a replacement request](https://docs.subscriby.net/api/v1/reference/disaster-recovery#withdraw-a-replacement-request)
- `DELETE /v1/projects/{project}/recovery/standby-installation` — [Remove a project's standby installation](https://docs.subscriby.net/api/v1/reference/disaster-recovery#remove-a-projects-standby-installation)
- `POST /v1/recovery/operations/{operation}/revert` — [Undo a recovery](https://docs.subscriby.net/api/v1/reference/disaster-recovery#undo-a-recovery)
- `POST /v1/recovery/operations/{operation}/nudge` — [Remind a recovery's stragglers](https://docs.subscriby.net/api/v1/reference/disaster-recovery#remind-a-recoverys-stragglers)
- `POST /v1/recovery/operations/{operation}/notify-members` — [Tell members about a bot replacement](https://docs.subscriby.net/api/v1/reference/disaster-recovery#tell-members-about-a-bot-replacement)
- `POST /v1/me/recovery/handshakes` — [Open a recovery handshake](https://docs.subscriby.net/api/v1/reference/disaster-recovery#open-a-recovery-handshake)
- `POST /v1/me/recovery/handshakes/{handshake}/confirm` — [Confirm a relink handshake](https://docs.subscriby.net/api/v1/reference/disaster-recovery#confirm-a-relink-handshake)
- `POST /v1/me/recovery/backup-identity` — [Register a backup account](https://docs.subscriby.net/api/v1/reference/disaster-recovery#register-a-backup-account)
- `DELETE /v1/me/recovery/backup-identity` — [Remove the backup account](https://docs.subscriby.net/api/v1/reference/disaster-recovery#remove-the-backup-account)
- `POST /v1/me/recovery/backup-identity/switch` — [Switch to the backup account](https://docs.subscriby.net/api/v1/reference/disaster-recovery#switch-to-the-backup-account)
## Distribution API
Every project exposes two public URLs: the subscriber portal and the deep link into the project's connector installation, with a start payload that decides what opens. [Read the overview](https://docs.subscriby.net/api/v1/reference/distribution).
- `GET /v1/projects/{project}/distribution/deep-link` — [Build a deep link](https://docs.subscriby.net/api/v1/reference/distribution#build-a-deep-link)
- `GET /v1/projects/{project}/distribution/portal-url` — [Get the portal URL](https://docs.subscriby.net/api/v1/reference/distribution#get-the-portal-url)
## Groups API
A group is a named bundle of permissions on a team that several collaborators hold at once: put the three people who run the inbox into a Support group and they share its permissions, take one out and they keep only what their own role gives them. [Read the overview](https://docs.subscriby.net/api/v1/reference/groups).
- `GET /v1/groups` — [List groups](https://docs.subscriby.net/api/v1/reference/groups#list-groups)
- `POST /v1/groups` — [Create a group](https://docs.subscriby.net/api/v1/reference/groups#create-a-group)
- `GET /v1/groups/{group}` — [Get a group](https://docs.subscriby.net/api/v1/reference/groups#get-a-group)
- `PATCH /v1/groups/{group}` — [Update a group](https://docs.subscriby.net/api/v1/reference/groups#update-a-group)
- `DELETE /v1/groups/{group}` — [Delete a group](https://docs.subscriby.net/api/v1/reference/groups#delete-a-group)
- `PUT /v1/groups/{group}/members` — [Replace a group's members](https://docs.subscriby.net/api/v1/reference/groups#replace-a-groups-members)
## Identities API
A linked identity is an account you hold on a connector, tied to your Subscriby creator account. [Read the overview](https://docs.subscriby.net/api/v1/reference/identities).
- `GET /v1/me/identities` — [List the caller's linked accounts](https://docs.subscriby.net/api/v1/reference/identities#list-the-callers-linked-accounts)
- `DELETE /v1/me/identities/{identity}` — [Unlink an account](https://docs.subscriby.net/api/v1/reference/identities#unlink-an-account)
## Me API
GET /v1/me is the first call a client makes. [Read the overview](https://docs.subscriby.net/api/v1/reference/me).
- `GET /v1/me` — [Get the caller's account](https://docs.subscriby.net/api/v1/reference/me#get-the-callers-account)
## Members API
Members are the project-scoped subscribers. [Read the overview](https://docs.subscriby.net/api/v1/reference/members).
- `GET /v1/projects/{project}/members` — [List a project's members](https://docs.subscriby.net/api/v1/reference/members#list-a-projects-members)
- `GET /v1/projects/{project}/members/{member}` — [Get a member](https://docs.subscriby.net/api/v1/reference/members#get-a-member)
- `GET /v1/projects/{project}/members/{member}/identities` — [List a member's connected accounts](https://docs.subscriby.net/api/v1/reference/members#list-a-members-connected-accounts)
- `POST /v1/projects/{project}/members/{member}/ban` — [Ban a member](https://docs.subscriby.net/api/v1/reference/members#ban-a-member)
- `POST /v1/projects/{project}/members/{member}/kick` — [Kick a member](https://docs.subscriby.net/api/v1/reference/members#kick-a-member)
- `POST /v1/projects/{project}/members/{member}/unban` — [Unban a member](https://docs.subscriby.net/api/v1/reference/members#unban-a-member)
- `DELETE /v1/projects/{project}/members/{member}/identities/{link}` — [Disconnect a member's account](https://docs.subscriby.net/api/v1/reference/members#disconnect-a-members-account)
## Notifications API
Every alert Subscriby sends a creator (a sale, a support backlog, a bot or channel that went silent, a billing or security notice, a pass window, an onboarding nudge) also lands in the Notifications Center: the sidebar entry with its unread count and popover, and the page at /notifications. [Read the overview](https://docs.subscriby.net/api/v1/reference/notifications).
- `GET /v1/me/notifications` — [List the caller's notifications](https://docs.subscriby.net/api/v1/reference/notifications#list-the-callers-notifications)
- `POST /v1/me/notifications/read-all` — [Mark every notification read](https://docs.subscriby.net/api/v1/reference/notifications#mark-every-notification-read)
- `POST /v1/me/notifications/{notification}/read` — [Mark a notification read](https://docs.subscriby.net/api/v1/reference/notifications#mark-a-notification-read)
## Pass Windows API
A pass window is one dated stretch of access on a kind: pass plan: the Saturday 09:00–12:00 a buyer of a match-day pass actually gets. [Read the overview](https://docs.subscriby.net/api/v1/reference/pass-windows).
- `GET /v1/projects/{project}/pass-windows` — [List a project's pass windows](https://docs.subscriby.net/api/v1/reference/pass-windows#list-a-projects-pass-windows)
- `GET /v1/projects/{project}/pass-windows/{window}` — [Get a pass window](https://docs.subscriby.net/api/v1/reference/pass-windows#get-a-pass-window)
- `POST /v1/projects/{project}/plans/{plan}/pass-windows` — [Place a pass window by hand](https://docs.subscriby.net/api/v1/reference/pass-windows#place-a-pass-window-by-hand)
- `POST /v1/projects/{project}/pass-windows/{window}/cancel` — [Cancel a pass window](https://docs.subscriby.net/api/v1/reference/pass-windows#cancel-a-pass-window)
- `POST /v1/projects/{project}/pass-windows/{window}/remind` — [Remind a window's holders](https://docs.subscriby.net/api/v1/reference/pass-windows#remind-a-windows-holders)
## Payment Methods API
Every project configures one or more payment providers so subscribers can buy plans. [Read the overview](https://docs.subscriby.net/api/v1/reference/payment-methods).
- `GET /v1/projects/{project}/payment-methods` — [List a project's payment methods](https://docs.subscriby.net/api/v1/reference/payment-methods#list-a-projects-payment-methods)
- `GET /v1/projects/{project}/payment-methods/{method}` — [Get a payment method](https://docs.subscriby.net/api/v1/reference/payment-methods#get-a-payment-method)
- `DELETE /v1/projects/{project}/payment-methods/{method}` — [Delete a payment method](https://docs.subscriby.net/api/v1/reference/payment-methods#delete-a-payment-method)
- `POST /v1/projects/{project}/payment-methods/{method}/activate` — [Activate a payment method](https://docs.subscriby.net/api/v1/reference/payment-methods#activate-a-payment-method)
- `POST /v1/projects/{project}/payment-methods/{method}/deactivate` — [Deactivate a payment method](https://docs.subscriby.net/api/v1/reference/payment-methods#deactivate-a-payment-method)
- `POST /v1/projects/{project}/payment-methods/{method}/sync` — [Sync a payment method's plans](https://docs.subscriby.net/api/v1/reference/payment-methods#sync-a-payment-methods-plans)
## Ping API
The one endpoint that needs no token. [Read the overview](https://docs.subscriby.net/api/v1/reference/ping).
- `GET /v1/ping` — [Check the API is up](https://docs.subscriby.net/api/v1/reference/ping#check-the-api-is-up)
## Plans API
A plan is what a project sells: the price, the currency, the billing cycle or the dated windows, the eligibility rules and the resources a purchase unlocks. [Read the overview](https://docs.subscriby.net/api/v1/reference/plans).
- `GET /v1/projects/{project}/plans` — [List a project's plans](https://docs.subscriby.net/api/v1/reference/plans#list-a-projects-plans)
- `POST /v1/projects/{project}/plans` — [Create a plan](https://docs.subscriby.net/api/v1/reference/plans#create-a-plan)
- `GET /v1/projects/{project}/plans/{plan}` — [Get a plan](https://docs.subscriby.net/api/v1/reference/plans#get-a-plan)
- `PATCH /v1/projects/{project}/plans/{plan}` — [Update a plan](https://docs.subscriby.net/api/v1/reference/plans#update-a-plan)
- `DELETE /v1/projects/{project}/plans/{plan}` — [Delete a plan](https://docs.subscriby.net/api/v1/reference/plans#delete-a-plan)
- `POST /v1/projects/{project}/plans/{plan}/publish` — [Publish a plan](https://docs.subscriby.net/api/v1/reference/plans#publish-a-plan)
- `POST /v1/projects/{project}/plans/{plan}/unpublish` — [Unpublish a plan](https://docs.subscriby.net/api/v1/reference/plans#unpublish-a-plan)
- `POST /v1/projects/{project}/plans/order` — [Arrange the storefront order](https://docs.subscriby.net/api/v1/reference/plans#arrange-the-storefront-order)
- `POST /v1/projects/{project}/plans/{plan}/successor` — [Start the next season](https://docs.subscriby.net/api/v1/reference/plans#start-the-next-season)
## Projects API
A project is the container for one membership business: its plans, its members and their subscriptions, its payment methods, the resources a purchase unlocks and the connectors it runs on. [Read the overview](https://docs.subscriby.net/api/v1/reference/projects).
- `GET /v1/projects` — [List projects](https://docs.subscriby.net/api/v1/reference/projects#list-projects)
- `POST /v1/projects` — [Create a project](https://docs.subscriby.net/api/v1/reference/projects#create-a-project)
- `GET /v1/projects/{project}` — [Get a project](https://docs.subscriby.net/api/v1/reference/projects#get-a-project)
- `PATCH /v1/projects/{project}` — [Update a project](https://docs.subscriby.net/api/v1/reference/projects#update-a-project)
- `DELETE /v1/projects/{project}` — [Delete a project](https://docs.subscriby.net/api/v1/reference/projects#delete-a-project)
- `POST /v1/projects/{project}/archive` — [Archive a project](https://docs.subscriby.net/api/v1/reference/projects#archive-a-project)
- `POST /v1/projects/{project}/restore` — [Restore a project](https://docs.subscriby.net/api/v1/reference/projects#restore-a-project)
## Resources API
Resources are what a plan unlocks: a place a connector gates (a channel, group or supergroup on Telegram today; each connector's own kinds as it launches) or a manually-tracked perk such as a PDF, a token or a URL. [Read the overview](https://docs.subscriby.net/api/v1/reference/resources).
- `GET /v1/projects/{project}/resources` — [List a project's resources](https://docs.subscriby.net/api/v1/reference/resources#list-a-projects-resources)
- `POST /v1/projects/{project}/resources` — [Create a manual perk](https://docs.subscriby.net/api/v1/reference/resources#create-a-manual-perk)
- `GET /v1/projects/{project}/resources/{resource}` — [Get a resource](https://docs.subscriby.net/api/v1/reference/resources#get-a-resource)
- `PATCH /v1/projects/{project}/resources/{resource}` — [Update a resource](https://docs.subscriby.net/api/v1/reference/resources#update-a-resource)
- `DELETE /v1/projects/{project}/resources/{resource}` — [Delete a resource](https://docs.subscriby.net/api/v1/reference/resources#delete-a-resource)
- `POST /v1/projects/{project}/resources/link-requests` — [Request a place link](https://docs.subscriby.net/api/v1/reference/resources#request-a-place-link)
- `POST /v1/projects/{project}/resources/{resource}/unlink` — [Unlink a resource's place](https://docs.subscriby.net/api/v1/reference/resources#unlink-a-resources-place)
- `POST /v1/projects/{project}/resources/{resource}/activate` — [Activate a resource](https://docs.subscriby.net/api/v1/reference/resources#activate-a-resource)
- `POST /v1/projects/{project}/resources/{resource}/deactivate` — [Deactivate a resource](https://docs.subscriby.net/api/v1/reference/resources#deactivate-a-resource)
## Roles API
A role is the permission set a collaborator holds on a team. [Read the overview](https://docs.subscriby.net/api/v1/reference/roles).
- `GET /v1/roles` — [List roles](https://docs.subscriby.net/api/v1/reference/roles#list-roles)
- `POST /v1/roles` — [Create a role](https://docs.subscriby.net/api/v1/reference/roles#create-a-role)
- `GET /v1/roles/{role}` — [Get a role](https://docs.subscriby.net/api/v1/reference/roles#get-a-role)
- `PATCH /v1/roles/{role}` — [Update a role](https://docs.subscriby.net/api/v1/reference/roles#update-a-role)
- `DELETE /v1/roles/{role}` — [Delete a role](https://docs.subscriby.net/api/v1/reference/roles#delete-a-role)
## Subscriptions API
A subscription is one member's purchase of one plan: the row that says who bought what, on which payment method, for how much, and until when. [Read the overview](https://docs.subscriby.net/api/v1/reference/subscriptions).
- `GET /v1/subscriptions` — [List subscriptions](https://docs.subscriby.net/api/v1/reference/subscriptions#list-subscriptions)
- `GET /v1/subscriptions/{subscription}` — [Get a subscription](https://docs.subscriby.net/api/v1/reference/subscriptions#get-a-subscription)
- `GET /v1/subscriptions/{subscription}/grants` — [List a subscription's grants](https://docs.subscriby.net/api/v1/reference/subscriptions#list-a-subscriptions-grants)
- `POST /v1/subscriptions/{subscription}/cancel` — [Cancel a subscription](https://docs.subscriby.net/api/v1/reference/subscriptions#cancel-a-subscription)
- `POST /v1/subscriptions/{subscription}/pause` — [Pause a subscription](https://docs.subscriby.net/api/v1/reference/subscriptions#pause-a-subscription)
- `POST /v1/subscriptions/{subscription}/unpause` — [Unpause a subscription](https://docs.subscriby.net/api/v1/reference/subscriptions#unpause-a-subscription)
- `POST /v1/subscriptions/{subscription}/reactivate` — [Reactivate a subscription](https://docs.subscriby.net/api/v1/reference/subscriptions#reactivate-a-subscription)
- `POST /v1/subscriptions/{subscription}/remind` — [Remind a pass holder](https://docs.subscriby.net/api/v1/reference/subscriptions#remind-a-pass-holder)
- `POST /v1/subscriptions/{subscription}/grants/reissue` — [Reissue a subscription's access](https://docs.subscriby.net/api/v1/reference/subscriptions#reissue-a-subscriptions-access)
## Support Inbox API
The support inbox is where a member who writes to your project bot something it does not recognise ends up: one durable support conversation per (project, member, channel), the saved replies your team answers with, and the project's support settings that decide whether the inbox is open, how you hear about a new thread and how replies are signed. [Read the overview](https://docs.subscriby.net/api/v1/reference/support-inbox).
- `GET /v1/support/conversations` — [List support conversations](https://docs.subscriby.net/api/v1/reference/support-inbox#list-support-conversations)
- `GET /v1/support/conversations/{conversation}` — [Get a support conversation](https://docs.subscriby.net/api/v1/reference/support-inbox#get-a-support-conversation)
- `GET /v1/support/conversations/{conversation}/messages` — [List a conversation's messages](https://docs.subscriby.net/api/v1/reference/support-inbox#list-a-conversations-messages)
- `POST /v1/support/conversations/{conversation}/messages` — [Reply to a member](https://docs.subscriby.net/api/v1/reference/support-inbox#reply-to-a-member)
- `GET /v1/projects/{project}/support/canned-replies` — [List a project's saved replies](https://docs.subscriby.net/api/v1/reference/support-inbox#list-a-projects-saved-replies)
- `POST /v1/projects/{project}/support/canned-replies` — [Create a saved reply](https://docs.subscriby.net/api/v1/reference/support-inbox#create-a-saved-reply)
- `GET /v1/projects/{project}/support/canned-replies/{reply}` — [Get a saved reply](https://docs.subscriby.net/api/v1/reference/support-inbox#get-a-saved-reply)
- `PATCH /v1/projects/{project}/support/canned-replies/{reply}` — [Update a saved reply](https://docs.subscriby.net/api/v1/reference/support-inbox#update-a-saved-reply)
- `DELETE /v1/projects/{project}/support/canned-replies/{reply}` — [Delete a saved reply](https://docs.subscriby.net/api/v1/reference/support-inbox#delete-a-saved-reply)
- `GET /v1/projects/{project}/support/settings` — [Get a project's support settings](https://docs.subscriby.net/api/v1/reference/support-inbox#get-a-projects-support-settings)
- `PATCH /v1/projects/{project}/support/settings` — [Update a project's support settings](https://docs.subscriby.net/api/v1/reference/support-inbox#update-a-projects-support-settings)
- `POST /v1/support/conversations/{conversation}/resolve` — [Resolve a support conversation](https://docs.subscriby.net/api/v1/reference/support-inbox#resolve-a-support-conversation)
- `POST /v1/support/conversations/{conversation}/assign` — [Assign a support conversation](https://docs.subscriby.net/api/v1/reference/support-inbox#assign-a-support-conversation)
- `POST /v1/support/conversations/{conversation}/reopen` — [Reopen a support conversation](https://docs.subscriby.net/api/v1/reference/support-inbox#reopen-a-support-conversation)
- `POST /v1/support/conversations/{conversation}/block` — [Block a support contact](https://docs.subscriby.net/api/v1/reference/support-inbox#block-a-support-contact)
- `POST /v1/support/conversations/{conversation}/unblock` — [Unblock a support contact](https://docs.subscriby.net/api/v1/reference/support-inbox#unblock-a-support-contact)
## Team Members API
Team members are the humans who collaborate inside a team: the creator who owns it and the people they invited to help run its projects. [Read the overview](https://docs.subscriby.net/api/v1/reference/team-members).
- `GET /v1/teams/{team}/members` — [List a team's members](https://docs.subscriby.net/api/v1/reference/team-members#list-a-teams-members)
- `POST /v1/teams/{team}/members` — [Invite a team member](https://docs.subscriby.net/api/v1/reference/team-members#invite-a-team-member)
- `GET /v1/teams/{team}/members/{member}` — [Get a team member](https://docs.subscriby.net/api/v1/reference/team-members#get-a-team-member)
- `DELETE /v1/teams/{team}/members/{member}` — [Remove a team member](https://docs.subscriby.net/api/v1/reference/team-members#remove-a-team-member)
- `PATCH /v1/teams/{team}/members/{member}/role` — [Change a team member's role](https://docs.subscriby.net/api/v1/reference/team-members#change-a-team-members-role)
- `DELETE /v1/teams/{team}/invitations/{invitation}` — [Withdraw an invitation](https://docs.subscriby.net/api/v1/reference/team-members#withdraw-an-invitation)
## Teams API
Teams group creators, roles, and projects. [Read the overview](https://docs.subscriby.net/api/v1/reference/teams).
- `GET /v1/teams` — [List teams](https://docs.subscriby.net/api/v1/reference/teams#list-teams)
- `POST /v1/teams` — [Create a team](https://docs.subscriby.net/api/v1/reference/teams#create-a-team)
- `GET /v1/teams/current` — [Get the current team](https://docs.subscriby.net/api/v1/reference/teams#get-the-current-team)
- `GET /v1/teams/{team}` — [Get a team](https://docs.subscriby.net/api/v1/reference/teams#get-a-team)
- `PATCH /v1/teams/{team}` — [Rename a team](https://docs.subscriby.net/api/v1/reference/teams#rename-a-team)
- `DELETE /v1/teams/{team}` — [Delete a team](https://docs.subscriby.net/api/v1/reference/teams#delete-a-team)
## Tokens API
Personal access tokens are what authorize every REST and MCP call. [Read the overview](https://docs.subscriby.net/api/v1/reference/tokens).
- `GET /v1/tokens` — [List the caller's tokens](https://docs.subscriby.net/api/v1/reference/tokens#list-the-callers-tokens)
- `POST /v1/tokens` — [Mint a token](https://docs.subscriby.net/api/v1/reference/tokens#mint-a-token)
- `GET /v1/tokens/{token}` — [Get a token](https://docs.subscriby.net/api/v1/reference/tokens#get-a-token)
- `DELETE /v1/tokens/{token}` — [Revoke a token](https://docs.subscriby.net/api/v1/reference/tokens#revoke-a-token)
## Webhook Deliveries API
Every event Subscriby posts to one of your webhook endpoints is a delivery: one row per endpoint per event, carrying the signed envelope that was sent, the target's response and where the row is on the retry ladder. [Read the overview](https://docs.subscriby.net/api/v1/reference/webhook-deliveries).
- `GET /v1/webhook-deliveries` — [List webhook deliveries](https://docs.subscriby.net/api/v1/reference/webhook-deliveries#list-webhook-deliveries)
- `GET /v1/webhook-deliveries/{delivery}` — [Get a webhook delivery](https://docs.subscriby.net/api/v1/reference/webhook-deliveries#get-a-webhook-delivery)
- `POST /v1/webhook-deliveries/retry-dead` — [Retry every recent dead delivery](https://docs.subscriby.net/api/v1/reference/webhook-deliveries#retry-every-recent-dead-delivery)
- `POST /v1/webhook-deliveries/{delivery}/retry` — [Retry a webhook delivery](https://docs.subscriby.net/api/v1/reference/webhook-deliveries#retry-a-webhook-delivery)
## Webhook Endpoints API
A webhook endpoint is a URL of yours that Subscriby posts events to, with the event families it subscribes to and a signing secret you verify each delivery against. [Read the overview](https://docs.subscriby.net/api/v1/reference/webhook-endpoints).
- `GET /v1/webhook-endpoints` — [List webhook endpoints](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#list-webhook-endpoints)
- `POST /v1/webhook-endpoints` — [Register a webhook endpoint](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#register-a-webhook-endpoint)
- `GET /v1/webhook-endpoints/{endpoint}` — [Get a webhook endpoint](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#get-a-webhook-endpoint)
- `DELETE /v1/webhook-endpoints/{endpoint}` — [Delete a webhook endpoint](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#delete-a-webhook-endpoint)
- `POST /v1/webhook-endpoints/{endpoint}/rotate-secret` — [Rotate a webhook endpoint's secret](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#rotate-a-webhook-endpoints-secret)
- `POST /v1/webhook-endpoints/{endpoint}/test` — [Test-fire a webhook endpoint](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#test-fire-a-webhook-endpoint)
- `POST /v1/webhook-endpoints/{endpoint}/pause` — [Pause a webhook endpoint](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#pause-a-webhook-endpoint)
- `POST /v1/webhook-endpoints/{endpoint}/resume` — [Resume a webhook endpoint](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#resume-a-webhook-endpoint)
## Webhook Events API
A read-only polling endpoint that surfaces the most recent webhook deliveries for a given event type. [Read the overview](https://docs.subscriby.net/api/v1/reference/webhook-events).
- `GET /v1/webhook-events` — [Poll recent webhook events](https://docs.subscriby.net/api/v1/reference/webhook-events#poll-recent-webhook-events)
---
# Me API
Source: https://docs.subscriby.net/api/v1/reference/me
`GET /v1/me` is the first call a client makes. It answers "who am I acting as?" in one round trip: the creator behind the token, the **team the token is scoped to** (the id every project call takes), every team they belong to, the platform plan and the capabilities it grants, the accounts linked on the connectors and where alerts go. The dashboard shows the same facts across **Settings → Profile**, **Security** and **Notifications**; a mobile or desktop client renders its home screen from this payload.
It lives under `/v1/me` with no project or team in the path because it is about the caller, never a project.
## Endpoints
- `GET /v1/me` — [Get the caller's account](#get-the-callers-account)
## Get the caller's account
`GET /v1/me`
```bash
curl https://api.subscriby.net/v1/me \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The caller's account; never paginated.
> **Read it once per session.** Nothing here changes under a running client except by an action the client itself takes (linking an account, changing alert destinations, switching plan). Read it on sign-in and after those actions; there is no need to poll it.
- `plan` is the platform tier's type (`free`, `starter`, `growth`, …) while the subscription is active, or `null` without one. It is the plan that gates what you can do, not the one still being billed while past due.
- `capabilities` lists every capability the tiers can grant, each `true` when your plan or an active add-on grants it. The set is fixed by the platform, so a client can render toggles from the keys without knowing them in advance.
- `current_team` is the team the token is scoped to (`scope:team:`), when you still belong to it; `null` when the token carries no team scope. Its `id` is the value every project call resolves against.
- `teams` is every team you own or are a member of, the same list as [`GET /v1/teams`](https://docs.subscriby.net/api/v1/reference/teams#list-teams).
- `identities` is the same list as [`GET /v1/me/identities`](https://docs.subscriby.net/api/v1/reference/identities).
- `alert_destinations` is the same list as [`GET /v1/me/alert-destinations`](https://docs.subscriby.net/api/v1/reference/alert-destinations); `alert_destinations_default` is `true` while no destination has been written and the default routing applies (every class to your primary connected account, critical classes by email).
- `unread_notifications` is how many entries of your [Notifications Center](https://docs.subscriby.net/api/v1/reference/notifications) you have not opened, the number on the sidebar entry.
- Requires ability: `account:read`
- MCP tools: `get_me`
### Responses
- **200**: The caller's account.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The creator's user id. |
| `data.name` | string | yes | The creator's name. |
| `data.email` | string | yes | The creator's email address. |
| `data.locale` | string | yes | The creator's language, as a locale code. |
| `data.email_verified` | boolean | yes | Whether the email address has been verified. |
| `data.plan` | string \| null | yes | The platform tier's type (`free`, `starter`, `growth`, …) while the subscription is active, or null without one. It is the plan that gates what the creator can do, not the one still being billed while past due. |
| `data.capabilities` | object | yes | Every capability the tiers can grant, each true when the plan or an active add-on grants it: `no_branding`, `custom_handle`, `time_limited_passes`, `coupons`, `free_plans`, `teams`, `disaster_recovery_prevention`, `multi_connector`. The set is fixed by the platform, so a client can render toggles from the keys. |
| `data.current_team` | array \| null | yes | The team the token is scoped to, when the creator still belongs to it; null when the token carries no team scope. Its id is the value every project call resolves against. |
| `data.teams` | array of any | yes | Every team the creator owns or is a member of, the same list as the teams endpoint. |
| `data.identities` | array of any | yes | The accounts linked on the connectors, the same list as the identities endpoint. |
| `data.alert_destinations` | array of any | yes | Where each alert class goes, the same list as the alert destinations endpoint. |
| `data.alert_destinations_default` | boolean | yes | True while no destination has been written and the default routing applies: every class to the primary connected account, critical classes by email. |
| `data.unread_notifications` | integer | yes | How many Notifications Center entries the creator has not opened, the number on the sidebar entry. |
| `data.created_at` | string \| null | yes | When the account was created, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Teams API](https://docs.subscriby.net/api/v1/reference/teams): GET /v1/teams/current returns current_team alone.
- [Identities API](https://docs.subscriby.net/api/v1/reference/identities): The accounts behind identities.
- [Alert Destinations API](https://docs.subscriby.net/api/v1/reference/alert-destinations): Change where alerts go.
- [get_me](https://docs.subscriby.net/mcp/v1/tools/account#get-me): The same payload as an MCP tool.
---
# Members API
Source: https://docs.subscriby.net/api/v1/reference/members
Members are the project-scoped subscribers. Every payment is made on behalf of a member; every access grant a connector issues resolves to one. These endpoints list and read them, flip the three moderation states (ban, unban, kick) and manage the accounts a member has connected on the connectors.
## Endpoints
- `GET /v1/projects/{project}/members` — [List a project's members](#list-a-projects-members)
- `GET /v1/projects/{project}/members/{member}` — [Get a member](#get-a-member)
- `GET /v1/projects/{project}/members/{member}/identities` — [List a member's connected accounts](#list-a-members-connected-accounts)
- `POST /v1/projects/{project}/members/{member}/ban` — [Ban a member](#ban-a-member)
- `POST /v1/projects/{project}/members/{member}/kick` — [Kick a member](#kick-a-member)
- `POST /v1/projects/{project}/members/{member}/unban` — [Unban a member](#unban-a-member)
- `DELETE /v1/projects/{project}/members/{member}/identities/{link}` — [Disconnect a member's account](#disconnect-a-members-account)
## List a project's members
`GET /v1/projects/{project}/members`
```bash
curl "https://api.subscriby.net/v1/projects/$PROJECT_ID/members?status=customer" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Newest first, paged with `page` and `per_page` (default 25; `limit` is accepted as an alias). `status` narrows to one lifecycle state. `identity` looks one member up by the account they connected, `connector:external_id`, for a workflow that starts from a platform event and needs the member behind an account id; the page then holds that one member with their `identities` embedded, or no rows.
- Requires ability: `project-user:view-any`
- MCP tools: `find_member_by_identity`, `list_subscribers`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `status` | query | `lead`, `trialing`, `customer`, `churned`, `banned` \| null | no | Narrow the page to one lifecycle state: `lead`, `trialing`, `customer`, `churned` or `banned`. Any other value is refused rather than answered with an empty page, so a typo in an automation is noticed. |
| `identity` | query | string \| null | no | Find the member who connected a given account: the connector key and the platform's own id for the account joined by a colon, `connector:123456789`. The page then holds that one member with their `identities` embedded, or no rows when nobody in the project connected that account. A value with no connector before the colon is refused. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
| `limit` | query | integer | no | Legacy alias of `per_page`, kept for clients that predate it. `per_page` wins when both are sent. |
### Responses
- **200**: The page, newest first; one row or none when narrowed to an account, with its connected accounts embedded.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The member's id within the project; the same person in another project is another member. |
| `data[].project_id` | string | yes | The project the member belongs to. |
| `data[].username` | string | yes | The member's handle on the connector they joined through, when the platform has one. |
| `data[].first_name` | string | yes | The member's first name as the connector reported it. |
| `data[].last_name` | string | yes | The member's last name as the connector reported it; null when the platform gave none. |
| `data[].email` | string \| null | no | The address the member chose and verified themselves, their portal sign-in credential; null for most members, who join through a bot and are never asked. Present only for a token holding `project-user:view`; a roster-only token never sees the key. |
| `data[].billing_email` | string \| null | no | Whatever address the member gave a payment provider at checkout, captured automatically on every new payment. Never verified and never used for authentication: treat it as identification only. Null for members who last paid before the capture existed or who joined by access code. Present only for a token holding `project-user:view`. |
| `data[].identities` | array of object | no | The member's connected connector accounts, embedded on the detail read and on a list narrowed with `identity`; absent elsewhere. |
| `data[].identities[].id` | string | yes | The link's id, the value the disconnect endpoint takes. |
| `data[].identities[].connector` | string | yes | The connector the account is on, by key. |
| `data[].identities[].source` | string | yes | How the account was connected: `bot` (through the project's bot), `portal` (from the portal's Account & Recovery screen), `handshake`, `adopted` or `backfill`. |
| `data[].identities[].preferred` | boolean | yes | Whether the project reaches the member through this account first. |
| `data[].identities[].notify` | boolean | yes | Whether the member receives the project's messages on this account. |
| `data[].identities[].external_id` | string | yes | The platform's own id for the account. |
| `data[].identities[].display_name` | string \| null | yes | What the platform calls the person. |
| `data[].identities[].username` | string \| null | yes | The account's handle when the platform has one; null otherwise. |
| `data[].identities[].linked_at` | string | yes | When the account was connected, ISO 8601. |
| `data[].status` | string \| `lead`, `trialing`, `customer`, `churned`, `banned` | yes | Where the member stands: `lead` (met the bot, never paid), `trialing`, `customer`, `churned` (no active access) or `banned`. |
| `data[].joined_at` | string \| null | yes | When the member first joined the project, ISO 8601. |
| `data[].created_at` | string \| null | yes | When the member row was created, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When the member row last changed, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `status` is not one of the five states, or `identity` has no connector before the colon.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a member
`GET /v1/projects/{project}/members/{member}`
One member with their connected accounts embedded as `identities`.
`email` and `billing_email` are present only when the calling token carries `project-user:view`, which this route requires; a roster-only token (`project-user:view-any`) never sees them on the list, where the keys are absent, not null. The two are not interchangeable:
| Field | Where it comes from | Safe to contact? |
| --------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------- |
| `email` | The member chose it themselves and verified it. It is their portal sign-in credential. | Yes |
| `billing_email` | Whatever address they gave a payment provider at checkout. Never verified, never used for authentication. | Treat as identification only |
Members join through a bot and are never asked for an email, so for most of them `email` is `null` and `billing_email` is the only address on record, which is what makes it useful for matching a refund request to a subscription.
`billing_email` is populated automatically from the payment provider on every new payment (Stripe, PayPal, Paystack, Razorpay and Skrill). Members who last paid before that capture existed may have `null` until their next payment, and members who joined by redeeming an access code never had a payment at all, so they have no billing address to record.
- Requires ability: `project-user:view`
- MCP tools: `get_subscriber`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `member` | path | string (uuid) | yes | The member, resolved within the project by the route binder. |
### Responses
- **200**: The member.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | any | yes | The response's payload. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## List a member's connected accounts
`GET /v1/projects/{project}/members/{member}/identities`
```bash
curl https://api.subscriby.net/v1/projects/$PROJECT_ID/members/$MEMBER_ID/identities \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The member's connected accounts, preferred first. Never paginated. A member can connect one account per platform the project is on, from the portal's **Account & Recovery** screen or by talking to the project's bot; the member read embeds the same rows as `identities`. Connecting has no endpoint: it is a two-sided proof the member completes on the connector.
- `id` is the link, which is what the disconnect endpoint takes; `external_id` is the platform's own id for the account.
- `source` says how the link was proven: `handshake` (connected from the portal and confirmed by the bot, or a portal sign-in), `adopted` (taken over from another of the creator's projects), `portal`, `bot` (the bot met the account first) or `backfill` (migrated from before connectors).
- `preferred` marks the account the project reaches first; a member's first account is, and the member can move it on the portal.
- `notify` marks a further account the member switched the project's notices on for, from the portal. The preferred account always hears them, so it reads `false` there too; a grant notice stays on the connector of the grant and a support reply on the connector the member wrote from, whatever the switches say.
- Requires ability: `project-user:view`
- MCP tools: `list_member_identities`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the binder. |
| `member` | path | string (uuid) | yes | The member, resolved within the project. |
### Responses
- **200**: Array of `MemberIdentityResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].id` | string | yes | The link's id, the value the disconnect endpoint takes. |
| `data[].connector` | string | yes | The connector the account is on, by key. |
| `data[].source` | string | yes | How the account was connected: `bot` (through the project's bot), `portal` (from the portal's Account & Recovery screen), `handshake`, `adopted` or `backfill`. |
| `data[].preferred` | boolean | yes | Whether the project reaches the member through this account first. |
| `data[].notify` | boolean | yes | Whether the member receives the project's messages on this account. |
| `data[].external_id` | string | yes | The platform's own id for the account. |
| `data[].display_name` | string \| null | yes | What the platform calls the person. |
| `data[].username` | string \| null | yes | The account's handle when the platform has one; null otherwise. |
| `data[].linked_at` | string | yes | When the account was connected, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Ban a member
`POST /v1/projects/{project}/members/{member}/ban`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/members/$MEMBER_ID/ban \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"reason": "Repeated spam in the research channel."}'
```
Moves a `lead`, `customer`, `trialing` or `churned` member to `banned`, removes their access on every connector and emits `member.banned` with the optional `reason`. Answers `200` with the member; a member already banned is answered unchanged and emits nothing.
Connector-side eviction (kicks from the gated spaces, revoked invite links) runs asynchronously on the connector pipeline after the flip. The call returns as soon as Subscriby's state has been updated and the webhook event has been fired; the platform actions complete in the background.
- Requires ability: `project-user:update`
- Fires events: `member.banned`
- MCP tools: `ban_member`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `member` | path | string (uuid) | yes | The member, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | string | no | Why the member is banned; carried on the `member.banned` event and shown in the dashboard, never sent to the member. |
### Responses
- **200**: The member after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The member's id within the project; the same person in another project is another member. |
| `data.project_id` | string | yes | The project the member belongs to. |
| `data.username` | string | yes | The member's handle on the connector they joined through, when the platform has one. |
| `data.first_name` | string | yes | The member's first name as the connector reported it. |
| `data.last_name` | string | yes | The member's last name as the connector reported it; null when the platform gave none. |
| `data.email` | string \| null | no | The address the member chose and verified themselves, their portal sign-in credential; null for most members, who join through a bot and are never asked. Present only for a token holding `project-user:view`; a roster-only token never sees the key. |
| `data.billing_email` | string \| null | no | Whatever address the member gave a payment provider at checkout, captured automatically on every new payment. Never verified and never used for authentication: treat it as identification only. Null for members who last paid before the capture existed or who joined by access code. Present only for a token holding `project-user:view`. |
| `data.identities` | array of object | no | The member's connected connector accounts, embedded on the detail read and on a list narrowed with `identity`; absent elsewhere. |
| `data.identities[].id` | string | yes | The link's id, the value the disconnect endpoint takes. |
| `data.identities[].connector` | string | yes | The connector the account is on, by key. |
| `data.identities[].source` | string | yes | How the account was connected: `bot` (through the project's bot), `portal` (from the portal's Account & Recovery screen), `handshake`, `adopted` or `backfill`. |
| `data.identities[].preferred` | boolean | yes | Whether the project reaches the member through this account first. |
| `data.identities[].notify` | boolean | yes | Whether the member receives the project's messages on this account. |
| `data.identities[].external_id` | string | yes | The platform's own id for the account. |
| `data.identities[].display_name` | string \| null | yes | What the platform calls the person. |
| `data.identities[].username` | string \| null | yes | The account's handle when the platform has one; null otherwise. |
| `data.identities[].linked_at` | string | yes | When the account was connected, ISO 8601. |
| `data.status` | string \| `lead`, `trialing`, `customer`, `churned`, `banned` | yes | Where the member stands: `lead` (met the bot, never paid), `trialing`, `customer`, `churned` (no active access) or `banned`. |
| `data.joined_at` | string \| null | yes | When the member first joined the project, ISO 8601. |
| `data.created_at` | string \| null | yes | When the member row was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the member row last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Kick a member
`POST /v1/projects/{project}/members/{member}/kick`
Moves the member to `churned`, removes them from every resource and emits `member.kicked` with the optional `reason`. Unlike a ban, a kicked member may come back by subscribing again. Answers `200` with the member; a `banned` member is answered unchanged, since a ban already says more. As with a ban, the connector-side eviction completes in the background after the call returns.
- Requires ability: `project-user:update`
- Fires events: `member.kicked`
- MCP tools: `kick_member`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `member` | path | string (uuid) | yes | The member, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | string | no | Why the member is removed; carried on the `member.kicked` event and shown in the dashboard, never sent to the member. |
### Responses
- **200**: The member after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The member's id within the project; the same person in another project is another member. |
| `data.project_id` | string | yes | The project the member belongs to. |
| `data.username` | string | yes | The member's handle on the connector they joined through, when the platform has one. |
| `data.first_name` | string | yes | The member's first name as the connector reported it. |
| `data.last_name` | string | yes | The member's last name as the connector reported it; null when the platform gave none. |
| `data.email` | string \| null | no | The address the member chose and verified themselves, their portal sign-in credential; null for most members, who join through a bot and are never asked. Present only for a token holding `project-user:view`; a roster-only token never sees the key. |
| `data.billing_email` | string \| null | no | Whatever address the member gave a payment provider at checkout, captured automatically on every new payment. Never verified and never used for authentication: treat it as identification only. Null for members who last paid before the capture existed or who joined by access code. Present only for a token holding `project-user:view`. |
| `data.identities` | array of object | no | The member's connected connector accounts, embedded on the detail read and on a list narrowed with `identity`; absent elsewhere. |
| `data.identities[].id` | string | yes | The link's id, the value the disconnect endpoint takes. |
| `data.identities[].connector` | string | yes | The connector the account is on, by key. |
| `data.identities[].source` | string | yes | How the account was connected: `bot` (through the project's bot), `portal` (from the portal's Account & Recovery screen), `handshake`, `adopted` or `backfill`. |
| `data.identities[].preferred` | boolean | yes | Whether the project reaches the member through this account first. |
| `data.identities[].notify` | boolean | yes | Whether the member receives the project's messages on this account. |
| `data.identities[].external_id` | string | yes | The platform's own id for the account. |
| `data.identities[].display_name` | string \| null | yes | What the platform calls the person. |
| `data.identities[].username` | string \| null | yes | The account's handle when the platform has one; null otherwise. |
| `data.identities[].linked_at` | string | yes | When the account was connected, ISO 8601. |
| `data.status` | string \| `lead`, `trialing`, `customer`, `churned`, `banned` | yes | Where the member stands: `lead` (met the bot, never paid), `trialing`, `customer`, `churned` (no active access) or `banned`. |
| `data.joined_at` | string \| null | yes | When the member first joined the project, ISO 8601. |
| `data.created_at` | string \| null | yes | When the member row was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the member row last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Unban a member
`POST /v1/projects/{project}/members/{member}/unban`
Moves a `banned` member to `churned` and emits `member.unbanned`. Access is not restored: the member subscribes again from the portal or the bot. Answers `200` with the member; a member who is not banned is answered unchanged and emits nothing.
- Requires ability: `project-user:update`
- Fires events: `member.unbanned`
- MCP tools: `unban_member`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `member` | path | string (uuid) | yes | The member, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The member after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The member's id within the project; the same person in another project is another member. |
| `data.project_id` | string | yes | The project the member belongs to. |
| `data.username` | string | yes | The member's handle on the connector they joined through, when the platform has one. |
| `data.first_name` | string | yes | The member's first name as the connector reported it. |
| `data.last_name` | string | yes | The member's last name as the connector reported it; null when the platform gave none. |
| `data.email` | string \| null | no | The address the member chose and verified themselves, their portal sign-in credential; null for most members, who join through a bot and are never asked. Present only for a token holding `project-user:view`; a roster-only token never sees the key. |
| `data.billing_email` | string \| null | no | Whatever address the member gave a payment provider at checkout, captured automatically on every new payment. Never verified and never used for authentication: treat it as identification only. Null for members who last paid before the capture existed or who joined by access code. Present only for a token holding `project-user:view`. |
| `data.identities` | array of object | no | The member's connected connector accounts, embedded on the detail read and on a list narrowed with `identity`; absent elsewhere. |
| `data.identities[].id` | string | yes | The link's id, the value the disconnect endpoint takes. |
| `data.identities[].connector` | string | yes | The connector the account is on, by key. |
| `data.identities[].source` | string | yes | How the account was connected: `bot` (through the project's bot), `portal` (from the portal's Account & Recovery screen), `handshake`, `adopted` or `backfill`. |
| `data.identities[].preferred` | boolean | yes | Whether the project reaches the member through this account first. |
| `data.identities[].notify` | boolean | yes | Whether the member receives the project's messages on this account. |
| `data.identities[].external_id` | string | yes | The platform's own id for the account. |
| `data.identities[].display_name` | string \| null | yes | What the platform calls the person. |
| `data.identities[].username` | string \| null | yes | The account's handle when the platform has one; null otherwise. |
| `data.identities[].linked_at` | string | yes | When the account was connected, ISO 8601. |
| `data.status` | string \| `lead`, `trialing`, `customer`, `churned`, `banned` | yes | Where the member stands: `lead` (met the bot, never paid), `trialing`, `customer`, `churned` (no active access) or `banned`. |
| `data.joined_at` | string \| null | yes | When the member first joined the project, ISO 8601. |
| `data.created_at` | string \| null | yes | When the member row was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the member row last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Disconnect a member's account
`DELETE /v1/projects/{project}/members/{member}/identities/{link}`
```bash
curl -X DELETE https://api.subscriby.net/v1/projects/$PROJECT_ID/members/$MEMBER_ID/identities/$LINK_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Disconnects an account on the creator's behalf, the counterpart of the member's own Disconnect on the portal: it no longer signs the member in and the bot no longer knows them by it. Answers an empty `204` and emits `member.identity_unlinked`.
- Requires ability: `project-user:update`
- Fires events: `member.identity_unlinked`
- MCP tools: `unlink_member_identity`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the binder. |
| `member` | path | string (uuid) | yes | The member, resolved within the project. |
| `link` | path | string (uuid) | yes | The member's link, resolved within the member. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: for a link that is not that member's.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the account is the member's last way to sign in (no verified email, linked Google account or other connected account remains).
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions): The purchases a member holds.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox): A block there is an inbox decision, not a moderation one.
- [member.* events](https://docs.subscriby.net/webhooks/v1/events/member): The endpoints below announce banned, unbanned, kicked and identity_unlinked; identity_linked fires when a member connects an account, and the rest of the family (joined, trial_joined, converted, churned, removed, resource_added, resource_pending, resource_removed) comes from upstream flows.
---
# Notifications API
Source: https://docs.subscriby.net/api/v1/reference/notifications
Every alert Subscriby sends a creator (a sale, a support backlog, a bot or channel that went silent, a billing or security notice, a pass window, an onboarding nudge) also lands in the **Notifications Center**: the sidebar entry with its unread count and popover, and the page at `/notifications`. These endpoints read the same rows and make the same two marks, so a client, a Zap or an agent can show the inbox and clear it.
The rows belong to the **creator behind the token**, never to a team or a project, so everything lives under `/v1/me`. `account:read` lists them; `account:write` marks them.
## Endpoints
- `GET /v1/me/notifications` — [List the caller's notifications](#list-the-callers-notifications)
- `POST /v1/me/notifications/read-all` — [Mark every notification read](#mark-every-notification-read)
- `POST /v1/me/notifications/{notification}/read` — [Mark a notification read](#mark-a-notification-read)
## List the caller's notifications
`GET /v1/me/notifications`
```bash
curl "https://api.subscriby.net/v1/me/notifications?unread=1&class=support" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Newest first, paginated with `page` and `per_page` like every list. `unread=1` narrows to entries not yet read (`unread=0` to the ones already read); `class` narrows to one class; `q` to entries whose title or body contains the words.
- `class` is the alert class the creator routes on **Settings → Notifications**: `sales`, `support`, `recovery`, `billing`, `security`, `passes` or `onboarding`; `class_label` is its name in the creator's language.
- `title` and `body` are the alert's heading and one sentence of context, the same words the email used, in the creator's language at the time it was sent.
- `route` and `params` name the dashboard screen the entry opens, as a named route and its parameters, for a client that maps routes to screens; `url` is the same place resolved, for a client that does not. All three are `null` when the alert points nowhere.
- `read` is whether the creator opened it; `read_at` says when.
Every creator alert is written to the notification centre beside whatever else it sends (the email, the message on a connected account the creator routed it to on **Settings → Notifications**). The entry is shaped from the alert's email: its subject becomes the title, its first line the body, its call to action the route and URL. There is no webhook for a new entry; the alerts that matter to an integration already fire their own events (`recovery.*`, `connector.*`, `support.*`), and a Zap that wants the inbox itself polls the `notification_received` trigger.
- Requires ability: `account:read`
- MCP tools: `list_notifications`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `unread` | query | boolean \| null | no | `1` narrows to entries not yet read, `0` to the ones already read; omit it for both. |
| `class` | query | `sales`, `support`, `recovery`, `billing`, `security`, `passes`, `onboarding` \| null | no | Narrow to one alert class: `sales`, `support`, `recovery`, `billing`, `security`, `passes` or `onboarding`. An unknown class is refused. |
| `q` | query | string \| null | no | Words the title or body must contain, up to 120 characters. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 15. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: The page, newest first.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The entry's id, the value the mark-read endpoint takes. |
| `data[].class` | string | yes | The alert class the creator routes on Settings → Notifications: `sales`, `support`, `recovery`, `billing`, `security`, `passes` or `onboarding`. |
| `data[].class_label` | `Sales`, `Support`, `Recovery`, `Billing`, `Security`, `Passes`, `Onboarding` | yes | The class's name in the creator's language. |
| `data[].title` | string | yes | The alert's heading, the same words the email used, in the creator's language at the time it was sent. |
| `data[].body` | string | yes | One sentence of context, the email's first line. |
| `data[].route` | string \| null | yes | The dashboard screen the entry opens, as a named route, for a client that maps routes to screens; null when the alert points nowhere. |
| `data[].params` | object | yes | The named route's parameters; null when the alert points nowhere. |
| `data[].url` | string \| null | yes | The same place resolved to a URL, for a client that does not map routes; null when the alert points nowhere. |
| `data[].read` | boolean | yes | Whether the creator opened the entry. |
| `data[].read_at` | string \| null | yes | When it was opened, ISO 8601; null while unread. |
| `data[].created_at` | string \| null | yes | When the alert arrived, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `class` is not one of the seven classes.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Mark every notification read
`POST /v1/me/notifications/read-all`
```bash
curl -X POST https://api.subscriby.net/v1/me/notifications/read-all \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Answers `200` with how many entries were unread and are read now. A second call marks nothing and answers `0`.
- Requires ability: `account:write`
- MCP tools: `mark_all_notifications_read`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with how many rows were marked.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.marked` | integer | yes | How many entries were unread and are read now. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Mark a notification read
`POST /v1/me/notifications/{notification}/read`
```bash
curl -X POST https://api.subscriby.net/v1/me/notifications/$NOTIFICATION_ID/read \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Answers `200` with the entry, `read` now `true`. Idempotent: an entry already read keeps its first `read_at`.
- Requires ability: `account:write`
- MCP tools: `mark_notification_read`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `notification` | path | string | yes | The row, resolved by the route binder as the caller's. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with the row, read.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The entry's id, the value the mark-read endpoint takes. |
| `data.class` | string | yes | The alert class the creator routes on Settings → Notifications: `sales`, `support`, `recovery`, `billing`, `security`, `passes` or `onboarding`. |
| `data.class_label` | `Sales`, `Support`, `Recovery`, `Billing`, `Security`, `Passes`, `Onboarding` | yes | The class's name in the creator's language. |
| `data.title` | string | yes | The alert's heading, the same words the email used, in the creator's language at the time it was sent. |
| `data.body` | string | yes | One sentence of context, the email's first line. |
| `data.route` | string \| null | yes | The dashboard screen the entry opens, as a named route, for a client that maps routes to screens; null when the alert points nowhere. |
| `data.params` | object | yes | The named route's parameters; null when the alert points nowhere. |
| `data.url` | string \| null | yes | The same place resolved to a URL, for a client that does not map routes; null when the alert points nowhere. |
| `data.read` | boolean | yes | Whether the creator opened the entry. |
| `data.read_at` | string \| null | yes | When it was opened, ISO 8601; null while unread. |
| `data.created_at` | string \| null | yes | When the alert arrived, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: for an id that is not one of the caller's entries.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Me API](https://docs.subscriby.net/api/v1/reference/me): The caller, with unread_notifications, so a client can draw the sidebar badge without listing the inbox.
- [Alert Destinations API](https://docs.subscriby.net/api/v1/reference/alert-destinations): Where each class of alert is delivered.
- [Email & Notifications](https://docs.subscriby.net/account/notifications): The Notifications Center on the dashboard.
---
# Pass Windows API
Source: https://docs.subscriby.net/api/v1/reference/pass-windows
A **pass window** is one dated stretch of access on a [`kind: pass`](https://docs.subscriby.net/api/v1/reference/plans) plan: the Saturday 09:00–12:00 a buyer of a match-day pass actually gets. Most windows are generated from the plan's slots; a creator can also place one by hand. A pass series is a slate of these windows, so their ids are what a series points at.
Windows are their own resource rather than an include on the plan: an integration authoring a series needs the ids without pulling a whole plan per source plan. The writes are the three the dashboard's **Manage Access Windows** panel offers (place a date, cancel one, nudge its holders) and they run the same actions the panel does.
## Endpoints
- `GET /v1/projects/{project}/pass-windows` — [List a project's pass windows](#list-a-projects-pass-windows)
- `GET /v1/projects/{project}/pass-windows/{window}` — [Get a pass window](#get-a-pass-window)
- `POST /v1/projects/{project}/plans/{plan}/pass-windows` — [Place a pass window by hand](#place-a-pass-window-by-hand)
- `POST /v1/projects/{project}/pass-windows/{window}/cancel` — [Cancel a pass window](#cancel-a-pass-window)
- `POST /v1/projects/{project}/pass-windows/{window}/remind` — [Remind a window's holders](#remind-a-windows-holders)
## List a project's pass windows
`GET /v1/projects/{project}/pass-windows`
```bash
curl "https://api.subscriby.net/v1/projects/$PROJECT_ID/pass-windows?status=scheduled&sellable_only=true" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The project's windows across every pass plan, **soonest first**, 50 per page (`per_page` 1 to 100; `limit` is accepted as an alias). Filters are validated before they reach the query: an unknown status or a malformed date is a `422 VALIDATION_FAILED` naming the field, not an empty page.
> **The list also opens to `project-subscription-plan:view-any`.** The list shipped under the plan ability before the `pass-window:*` family had any surface, and it is what the n8n node and the MCP tool docs named. A token holding only `project-subscription-plan:view-any` therefore still satisfies the `pass-window:view-any` gate. Mint new tokens with the precise ability; the alias exists so old ones keep working.
- Requires ability: `pass-window:view-any`
- MCP tools: `list_pass_windows`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `plan_id` | query | string \| null (uuid) | no | One pass plan of the project only. |
| `status` | query | `scheduled`, `open`, `closed`, `canceled` \| null | no | One state only: `scheduled`, `open`, `closed` or `canceled`. Anything else is refused. |
| `from` | query | string \| null (date-time) | no | Only windows starting at or after this moment, ISO 8601. |
| `to` | query | string \| null (date-time) | no | Only windows starting at or before this moment, ISO 8601. |
| `sellable_only` | query | boolean | no | Keep only windows a buyer could still purchase. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 50. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
| `limit` | query | integer | no | Legacy alias of `per_page`, kept for clients that predate it. `per_page` wins when both are sent. |
### Responses
- **200**: The page, soonest first.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The window's id, what a pass series points at. |
| `data[].plan_id` | string | yes | The pass plan the window belongs to. |
| `data[].plan_name` | string | yes | That plan's name, so a list reads without a plan lookup. |
| `data[].starts_at` | string | yes | When access opens, ISO 8601 in UTC. Store and compare this. |
| `data[].ends_at` | string | yes | When access closes, ISO 8601 in UTC. |
| `data[].timezone` | string \| null | yes | The IANA zone the plan's schedule was authored in. Render window times in this, never in UTC. |
| `data[].local_range` | string | yes | The same window as a person reads it, already in `timezone`; what the dashboard, the portal and the bot all print. |
| `data[].duration_minutes` | integer | yes | The length in minutes, derived from the two instants. |
| `data[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. Set by the scheduler as the window's start and end pass; `canceled` only by an explicit cancel. |
| `data[].source` | string | yes | `generated` from the plan's slots, or `manual` when placed by hand. Manual windows survive a schedule rebuild; generated ones are regenerated. |
| `data[].sellable` | boolean | yes | Whether a buyer could purchase this window right now: the plan's sales cutoff, resolved for the whole page in one query rather than per row. |
| `data[].holders` | integer | yes | How many purchases currently hold the window. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `status` is not one of the four states, `plan_id` is not a UUID, or `from` or `to` is not a date.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a pass window
`GET /v1/projects/{project}/pass-windows/{window}`
One window, with `sellable` and `holders` resolved for it. A window is resolved **within the project**: a window id that belongs to another project, another team, or to nothing at all is a `404 RESOURCE_NOT_FOUND`, so an id's existence never leaks.
| Field | Type | Notes |
| ------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `starts_at` | string | ISO 8601, **UTC**. Store this. |
| `ends_at` | string | ISO 8601, UTC. |
| `timezone` | string | The IANA zone the plan's schedule was authored in. Render window times in this, never in UTC. |
| `local_range` | string | The same window as a person reads it, already in `timezone`: what the dashboard, the portal and the bot all print. |
| `duration_minutes` | integer | Derived from the two instants. |
| `status` | string | `scheduled`, `open`, `closed` or `canceled`. Set by the scheduler as the window's start and end pass; `canceled` only by an explicit cancel. |
| `source` | string | `generated` from the plan's slots, or `manual` when placed by hand. Manual windows survive a schedule rebuild; generated ones are regenerated. |
| `sellable` | boolean | Whether a buyer could purchase this window right now: the plan's sales cutoff, resolved for the whole page in one query rather than per row. |
| `holders` | integer | How many purchases currently hold the window. |
> **Why both UTC and a local range.** A pass schedule is authored in the creator's zone and rendered in it everywhere else in the product. Returning only UTC would leave every consumer converting, and getting it wrong across a daylight-saving boundary. `starts_at` is for storing and comparing; `local_range` is for showing.
- Requires ability: `pass-window:view`
- MCP tools: `get_pass_window`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `window` | path | string (uuid) | yes | The window, resolved within the project with its holder count and sale flag. |
### Responses
- **200**: The window.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The window's id, what a pass series points at. |
| `data.plan_id` | string | yes | The pass plan the window belongs to. |
| `data.plan_name` | string | yes | That plan's name, so a list reads without a plan lookup. |
| `data.starts_at` | string | yes | When access opens, ISO 8601 in UTC. Store and compare this. |
| `data.ends_at` | string | yes | When access closes, ISO 8601 in UTC. |
| `data.timezone` | string \| null | yes | The IANA zone the plan's schedule was authored in. Render window times in this, never in UTC. |
| `data.local_range` | string | yes | The same window as a person reads it, already in `timezone`; what the dashboard, the portal and the bot all print. |
| `data.duration_minutes` | integer | yes | The length in minutes, derived from the two instants. |
| `data.status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. Set by the scheduler as the window's start and end pass; `canceled` only by an explicit cancel. |
| `data.source` | string | yes | `generated` from the plan's slots, or `manual` when placed by hand. Manual windows survive a schedule rebuild; generated ones are regenerated. |
| `data.sellable` | boolean | yes | Whether a buyer could purchase this window right now: the plan's sales cutoff, resolved for the whole page in one query rather than per row. |
| `data.holders` | integer | yes | How many purchases currently hold the window. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Place a pass window by hand
`POST /v1/projects/{project}/plans/{plan}/pass-windows`
Places a window by hand on a `kind: pass` plan. This is how `schedule_mode: fixed` gets its dates, and how a repeating plan gets the one-off that does not fit its pattern.
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/plans/$PLAN_ID/pass-windows \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"starts_at": "2026-09-20 09:00",
"duration_minutes": 180
}'
```
Answers `201` with the new window: `status: scheduled`, `source: manual`, `holders: 0`. The request above and `"starts_at": "2026-09-20T09:00:00-04:00"` describe the same instant for a plan in `America/New_York`, and both come back as `"2026-09-20T13:00:00+00:00"`. Raises `pass.window_scheduled` with `source: manual`.
A manual window is never touched by regeneration: editing `pass.slots` on the plan rebuilds the generated windows and leaves this one where you put it. Every pass series with a rule is asked to look at the new date afterwards, so a matching series absorbs it as described on the [Plans](https://docs.subscriby.net/api/v1/reference/plans) page.
Each refusal below is a `422 VALIDATION_FAILED` naming the field, the same wording the dashboard's panel shows.
- Requires ability: `pass-window:create`
- Fires events: `pass.window_scheduled`
- MCP tools: `create_pass_window`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `plan` | path | string (uuid) | yes | The plan, resolved within the project. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A window to place by hand on a time-limited pass plan: when it starts and how long it lasts.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `starts_at` | string (date-time) | yes | When access opens, ISO 8601. **A value with no offset is read as wall-clock time in the plan's `timezone`**, exactly as the dashboard authors it; a value with an offset or `Z` is the instant it names. Must be in the future, judged in the plan's own zone. |
| `duration_minutes` | integer | yes | How long the window lasts, a whole number of minutes, at least 1. The plan's configured floor and ceiling apply: 5 minutes and 30 days by default. |
### Responses
- **201**: The window.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The window's id, what a pass series points at. |
| `data.plan_id` | string | yes | The pass plan the window belongs to. |
| `data.plan_name` | string | yes | That plan's name, so a list reads without a plan lookup. |
| `data.starts_at` | string | yes | When access opens, ISO 8601 in UTC. Store and compare this. |
| `data.ends_at` | string | yes | When access closes, ISO 8601 in UTC. |
| `data.timezone` | string \| null | yes | The IANA zone the plan's schedule was authored in. Render window times in this, never in UTC. |
| `data.local_range` | string | yes | The same window as a person reads it, already in `timezone`; what the dashboard, the portal and the bot all print. |
| `data.duration_minutes` | integer | yes | The length in minutes, derived from the two instants. |
| `data.status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. Set by the scheduler as the window's start and end pass; `canceled` only by an explicit cancel. |
| `data.source` | string | yes | `generated` from the plan's slots, or `manual` when placed by hand. Manual windows survive a schedule rebuild; generated ones are regenerated. |
| `data.sellable` | boolean | yes | Whether a buyer could purchase this window right now: the plan's sales cutoff, resolved for the whole page in one query rather than per row. |
| `data.holders` | integer | yes | How many purchases currently hold the window. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when the plan is not a time-limited pass: a subscription or a series has no windows of its own. `VALIDATION_FAILED`: when the start is in the past, judged in the plan's own zone. `VALIDATION_FAILED`: when the length is under the plan's floor or over its ceiling (5 minutes and 30 days by default). `VALIDATION_FAILED`: when the plan already has a window starting at that instant. `VALIDATION_FAILED`: when the plan stops selling a fixed number of minutes before a window **ends** and the window is not longer than that cutoff, so it would never be on sale while it runs.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Cancel a pass window
`POST /v1/projects/{project}/pass-windows/{window}/cancel`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/pass-windows/$WINDOW_ID/cancel \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
By the time the response arrives every holder has been resettled, and `meta` says how:
| Tally | What happened to those holders |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `rebound` | Moved to the plan's next window on sale, and told so. Their invite links are re-issued when the new window opens. |
| `refund_due` | The schedule had nothing left to offer, so their pass was ended and they were told to ask the creator for a refund. |
| `legs_dropped` | Season-ticket holders who lost this one date and keep the rest of their series. Their message states what the date was worth of what they paid. |
> **Subscriby never moves the refunds.** `refund_due` and `legs_dropped` are statements, not actions. The money stays where it is until the creator refunds it at the gateway. An integration that wants to act on them should subscribe to `pass.holder_stranded` and `pass_series.leg_dropped`, which carry the amount per holder.
Cancelling is a `POST` verb and not a `DELETE` on purpose: it resettles every holder, moving them on or ending their pass, which is far too consequential to read as removing a row. It also cannot be undone: the window keeps its start permanently, so the same instant can never be re-created on that plan.
Raises `pass.window_cancelled` carrying the tallies, plus one event per holder: `pass.holder_moved` or `pass.holder_stranded` for ordinary passes, `pass_series.leg_substituted` or `pass_series.leg_dropped` for season-ticket holders. A window that is already cancelled is answered `200` with every tally at zero, so a retry after a lost response changes nothing.
- Requires ability: `pass-window:delete`
- Fires events: `pass.window_cancelled`, `pass.holder_moved`, `pass.holder_stranded`, `pass_series.leg_substituted`, `pass_series.leg_dropped`
- MCP tools: `cancel_pass_window`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `window` | path | string (uuid) | yes | The window, resolved within the project. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The window, cancelled, with `meta.rebound`, `meta.refund_due` and `meta.legs_dropped`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The window's id, what a pass series points at. |
| `data.plan_id` | string | yes | The pass plan the window belongs to. |
| `data.plan_name` | string | yes | That plan's name, so a list reads without a plan lookup. |
| `data.starts_at` | string | yes | When access opens, ISO 8601 in UTC. Store and compare this. |
| `data.ends_at` | string | yes | When access closes, ISO 8601 in UTC. |
| `data.timezone` | string \| null | yes | The IANA zone the plan's schedule was authored in. Render window times in this, never in UTC. |
| `data.local_range` | string | yes | The same window as a person reads it, already in `timezone`; what the dashboard, the portal and the bot all print. |
| `data.duration_minutes` | integer | yes | The length in minutes, derived from the two instants. |
| `data.status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. Set by the scheduler as the window's start and end pass; `canceled` only by an explicit cancel. |
| `data.source` | string | yes | `generated` from the plan's slots, or `manual` when placed by hand. Manual windows survive a schedule rebuild; generated ones are regenerated. |
| `data.sellable` | boolean | yes | Whether a buyer could purchase this window right now: the plan's sales cutoff, resolved for the whole page in one query rather than per row. |
| `data.holders` | integer | yes | How many purchases currently hold the window. |
| `meta` | object | yes | How every holder was resettled, as three tallies; all zero when the window was already cancelled. |
| `meta.rebound` | integer | yes | Holders moved to the plan's next window on sale and told so. |
| `meta.refund_due` | integer | yes | Holders whose pass was ended because the schedule had nothing left, and who were told to ask for a refund. |
| `meta.legs_dropped` | integer | yes | Season-ticket holders who lost this one date and keep the rest of their series. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Remind a window's holders
`POST /v1/projects/{project}/pass-windows/{window}/remind`
Nudges everyone who bought the window but has not yet joined, re-attaching their invite links. It is the panel's **Send Reminder** button; the automatic reminders a day and an hour before a window opens still run regardless.
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/pass-windows/$WINDOW_ID/remind \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
`reminded` is how many holders the **connector accepted** a message for, not how many were attempted: a holder who has blocked the bot is skipped without costing the rest their nudge. A window that has ended or been cancelled reminds nobody and answers `0` rather than refusing. No event fires: the reminder is a message on the connector, not a state change.
This messages real people. Do not repeat it within the same window, and to nudge one holder rather than a whole window use the [subscription remind endpoint](https://docs.subscriby.net/api/v1/reference/subscriptions#remind-a-pass-holder).
- Requires ability: `pass-window:update`
- MCP tools: `remind_pass_window_queue`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `window` | path | string (uuid) | yes | The window, resolved within the project. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: `window_id` and how many holders were `reminded`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.window_id` | string | yes | The window that was reminded. |
| `data.reminded` | integer | yes | How many holders the connector accepted a reminder for. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans): The pass and pass_series kinds these windows belong to, and the successor endpoint.
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions): Nudge one holder instead of a whole window.
- [Time-Limited Passes](https://docs.subscriby.net/creators/time-limited-passes): Adding a date by hand, cancelling a window, and what holders see.
- [pass.* events](https://docs.subscriby.net/webhooks/v1/events/pass): pass.window_opened and pass.window_closed come from the scheduler as each window's start and end pass, not from any call here.
---
# Payment Methods API
Source: https://docs.subscriby.net/api/v1/reference/payment-methods
Every project configures one or more payment providers so subscribers can buy plans. These endpoints let you read which providers a project has connected and in what mode, switch each one on or off, push the plan catalogue to its gateway again, and remove one. Entering or rotating credentials remains dashboard-only, because each provider needs its own OAuth or secret-entry flow that a generic REST layer cannot safely reproduce.
## Endpoints
- `GET /v1/projects/{project}/payment-methods` — [List a project's payment methods](#list-a-projects-payment-methods)
- `GET /v1/projects/{project}/payment-methods/{method}` — [Get a payment method](#get-a-payment-method)
- `DELETE /v1/projects/{project}/payment-methods/{method}` — [Delete a payment method](#delete-a-payment-method)
- `POST /v1/projects/{project}/payment-methods/{method}/activate` — [Activate a payment method](#activate-a-payment-method)
- `POST /v1/projects/{project}/payment-methods/{method}/deactivate` — [Deactivate a payment method](#deactivate-a-payment-method)
- `POST /v1/projects/{project}/payment-methods/{method}/sync` — [Sync a payment method's plans](#sync-a-payment-methods-plans)
## List a project's payment methods
`GET /v1/projects/{project}/payment-methods`
```bash
curl https://api.subscriby.net/v1/projects/$PROJECT_ID/payment-methods \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Every payment method attached to the project, newest first.
- Requires ability: `project-payment-method:view-any`
- MCP tools: `list_payment_methods`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: The page, newest first.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The payment method's id. |
| `data[].project_id` | string | yes | The project the method is configured on. |
| `data[].provider` | string \| null | yes | The stored provider key: a gateway slug (`stripe`, `paypal`, `razorpay`, `paystack`, `ceypay`, `skrill`, `coinpayments`, `accesscode`) or a `connector:provider` key for a currency a connector brings, as the connector directory lists it. A project carries one method per provider key and mode. |
| `data[].connector` | string | yes | The connector behind a native payment method: the key before the colon of its `connector:provider` row; null for every gateway and for access codes. |
| `data[].mode` | string | yes | `test` or `live`. Drives which set of provider credentials is used. |
| `data[].linked` | boolean | yes | True once the provider handshake (OAuth callback, API-key verify, Connect onboarding) has completed successfully. |
| `data[].active` | boolean | yes | The creator's switch. A linked-but-inactive method is not offered at checkout and cannot be used to charge subscribers. |
| `data[].created_at` | string \| null | yes | When the method was configured, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When the method last changed, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a payment method
`GET /v1/projects/{project}/payment-methods/{method}`
The method without its credentials. A method is resolved within the project: a method id that does not belong to the project, or a project outside the caller's tenant, is `404 RESOURCE_NOT_FOUND`, so foreign rows never leak.
- `provider` is the stored provider key: a gateway slug (`stripe`, `paypal`, `razorpay`, `paystack`, `ceypay`, `skrill`, `coinpayments`, `accesscode`) or a `connector:provider` key for a currency a connector brings (`telegram:stars` for Telegram Stars). A project carries one method per provider key and mode, so two connectors can each bring their own currency to the same project. Rows created before provider keys existed carried `platformcurrency`; the connector's data migration rewrites them, and until it has run that word still reads as the native method of the project's connector.
- `connector` is the connector behind a native payment method (`telegram` for a `telegram:stars` row), `null` for every gateway and for access codes. A connector declares its native providers through its manifest's `native_payments` capability, and the dashboard offers one option per provider of every connector that is connected on the project.
- `mode` is `test` or `live`. Drives which set of provider credentials is used.
- `linked` is true once the provider handshake (OAuth callback, API-key verify, etc.) has completed successfully.
- `active` is the creator's toggle. A linked-but-inactive method cannot be used to charge subscribers.
> **Credentials never leave.** Provider credentials (API keys, webhook secrets, connected account ids, Stripe Connect onboarding state) live in the encrypted configuration and are **never** serialised through REST or MCP, even for tokens with `view` ability. The dashboard is the only surface that reveals them, and only to the creator.
- Requires ability: `project-payment-method:view`
- MCP tools: `get_payment_method`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `method` | path | string (uuid) | yes | The method, resolved within the project by the route binder. |
### Responses
- **200**: The method without its credentials.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The payment method's id. |
| `data.project_id` | string | yes | The project the method is configured on. |
| `data.provider` | string \| null | yes | The stored provider key: a gateway slug (`stripe`, `paypal`, `razorpay`, `paystack`, `ceypay`, `skrill`, `coinpayments`, `accesscode`) or a `connector:provider` key for a currency a connector brings, as the connector directory lists it. A project carries one method per provider key and mode. |
| `data.connector` | string | yes | The connector behind a native payment method: the key before the colon of its `connector:provider` row; null for every gateway and for access codes. |
| `data.mode` | string | yes | `test` or `live`. Drives which set of provider credentials is used. |
| `data.linked` | boolean | yes | True once the provider handshake (OAuth callback, API-key verify, Connect onboarding) has completed successfully. |
| `data.active` | boolean | yes | The creator's switch. A linked-but-inactive method is not offered at checkout and cannot be used to charge subscribers. |
| `data.created_at` | string \| null | yes | When the method was configured, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the method last changed, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Delete a payment method
`DELETE /v1/projects/{project}/payment-methods/{method}`
```bash
curl -X DELETE https://api.subscriby.net/v1/projects/$PROJECT_ID/payment-methods/$METHOD_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Returns `204 No Content`. The row is soft-deleted: subscriptions sold through it keep their gateway for refunds and history, and configuring the same provider and mode again later revives the row instead of creating a duplicate. Buyers lose that way to pay at once. Emits `project.payment_method.deleted` with a credential-free snapshot; an already-deleted id is a `404`.
- Requires ability: `project-payment-method:delete`
- Fires events: `project.payment_method.deleted`
- MCP tools: `delete_payment_method`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `method` | path | string (uuid) | yes | The method, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Activate a payment method
`POST /v1/projects/{project}/payment-methods/{method}/activate`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/payment-methods/$METHOD_ID/activate \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Offers the method at checkout again and re-queues the plan sync for that gateway. Answers `200` with the method. Idempotent: switching a method to the state it is already in writes nothing and emits nothing; when the switch flips, `project.payment_method.updated` fires once with `changes.active`.
> **Stripe needs its Connect handshake first.** A Stripe method whose Connect onboarding never finished is refused: the row exists only so the creator can resume onboarding, and switching it on would offer buyers a gateway that fails at checkout. Finish onboarding from the dashboard, the only place the Connect handshake can complete.
- Requires ability: `project-payment-method:update`
- Fires events: `project.payment_method.updated`
- MCP tools: `activate_payment_method`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `method` | path | string (uuid) | yes | The method, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The method, switched on.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The payment method's id. |
| `data.project_id` | string | yes | The project the method is configured on. |
| `data.provider` | string \| null | yes | The stored provider key: a gateway slug (`stripe`, `paypal`, `razorpay`, `paystack`, `ceypay`, `skrill`, `coinpayments`, `accesscode`) or a `connector:provider` key for a currency a connector brings, as the connector directory lists it. A project carries one method per provider key and mode. |
| `data.connector` | string | yes | The connector behind a native payment method: the key before the colon of its `connector:provider` row; null for every gateway and for access codes. |
| `data.mode` | string | yes | `test` or `live`. Drives which set of provider credentials is used. |
| `data.linked` | boolean | yes | True once the provider handshake (OAuth callback, API-key verify, Connect onboarding) has completed successfully. |
| `data.active` | boolean | yes | The creator's switch. A linked-but-inactive method is not offered at checkout and cannot be used to charge subscribers. |
| `data.created_at` | string \| null | yes | When the method was configured, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the method last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: for a Stripe method awaiting Connect onboarding.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Deactivate a payment method
`POST /v1/projects/{project}/payment-methods/{method}/deactivate`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/payment-methods/$METHOD_ID/deactivate \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
The creator's switch. A deactivated method is no longer offered at checkout; the subscriptions already sold through it keep renewing with their gateway. Re-queues the plan sync like activating does. Answers `200` with the method; idempotent, and `project.payment_method.updated` fires once with `changes.active` when the switch flips.
- Requires ability: `project-payment-method:update`
- Fires events: `project.payment_method.updated`
- MCP tools: `deactivate_payment_method`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `method` | path | string (uuid) | yes | The method, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The method, switched off.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The payment method's id. |
| `data.project_id` | string | yes | The project the method is configured on. |
| `data.provider` | string \| null | yes | The stored provider key: a gateway slug (`stripe`, `paypal`, `razorpay`, `paystack`, `ceypay`, `skrill`, `coinpayments`, `accesscode`) or a `connector:provider` key for a currency a connector brings, as the connector directory lists it. A project carries one method per provider key and mode. |
| `data.connector` | string | yes | The connector behind a native payment method: the key before the colon of its `connector:provider` row; null for every gateway and for access codes. |
| `data.mode` | string | yes | `test` or `live`. Drives which set of provider credentials is used. |
| `data.linked` | boolean | yes | True once the provider handshake (OAuth callback, API-key verify, Connect onboarding) has completed successfully. |
| `data.active` | boolean | yes | The creator's switch. A linked-but-inactive method is not offered at checkout and cannot be used to charge subscribers. |
| `data.created_at` | string \| null | yes | When the method was configured, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the method last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Sync a payment method's plans
`POST /v1/projects/{project}/payment-methods/{method}/sync`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/payment-methods/$METHOD_ID/sync \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Queues a push of the project owner's plans into the gateway's catalogue (Stripe products and prices, PayPal, Razorpay or CoinPayments plans) and answers `202 Accepted`. The work runs in the background; each plan reports through `plan.sync_completed` as it lands. Queuing twice pushes the same catalogue twice, which is harmless.
- Requires ability: `project-payment-method:update`
- MCP tools: `sync_payment_method_plans`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `method` | path | string (uuid) | yes | The method, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **202**: The method id and `sync_queued`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.payment_method_id` | string | yes | The method whose catalogue is being pushed. |
| `data.status` | string | yes | Always `sync_queued`: the push runs in the background. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: for a gateway that keeps no catalogue (a connector's native currency, access codes, the redirect gateways).
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Payment Methods](https://docs.subscriby.net/payments): Stripe, PayPal, Skrill, CoinPayments, Paystack, Razorpay, CeyPay, native payments and access codes compared: regions, currencies, recurring, crypto.
- [project.* events](https://docs.subscriby.net/webhooks/v1/events/project): Connecting a gateway raises no event, so the first thing an endpoint hears about a new method is its first update or its deletion; credentials never appear in a payload, and when keys are rotated from the dashboard changes.config.keys names which keys changed and nothing else.
---
# Ping API
Source: https://docs.subscriby.net/api/v1/reference/ping
The one endpoint that needs no token. It answers from the API host itself, so a `200` proves the host resolves, TLS terminates and the application is up; it says nothing about your token, which `GET /v1/teams/current` is the probe for.
## Endpoints
- `GET /v1/ping` — [Check the API is up](#check-the-api-is-up)
## Check the API is up
`GET /v1/ping`
```bash
curl https://api.subscriby.net/v1/ping
```
Answers `200` with `data.status` of `ok`, the API version and the current server time in ISO 8601. No `Authorization` header is read and no rate limit applies, so a monitor may call it on a schedule.
### Responses
- **200**: The status, the API version and the current time.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.status` | string | yes | Always `ok`: the application answered. |
| `data.version` | string | yes | The API version the host serves, `v1`. |
| `data.timestamp` | string | yes | The server's current time, ISO 8601 with offset. |
---
# Plans API
Source: https://docs.subscriby.net/api/v1/reference/plans
A **plan** is what a project sells: the price, the currency, the billing cycle or the dated windows, the eligibility rules and the resources a purchase unlocks. Every plan has a **`kind`** that decides its whole shape. A `subscription` renews on a cycle and carries a `billing` block; a `pass` sells one dated access window per purchase and carries a `pass` block; a `pass_series` sells a slate of other pass plans' windows in one go and carries a `pass_series` block. The kind names exactly one block, and the other two are absent.
A plan exists and, separately, is on sale. **Publish** and **unpublish** flip `active` and are how intake is paused while the plan stays on the books and editable; **delete** removes the row, and is refused while a customer still holds an unfinished window. A `sales_cap` pauses the plan by itself when it fills, `eligibility` narrows who may buy, `resources` are the places the plan grants, and the storefront **order** and a series' **next season** are actions of their own. Plans live under a project at `/v1/projects/{project}/plans`, and `POST` takes the same discriminated shape the reads return.
Every change announces itself as a `plan.*` event; a `kind: pass` plan also emits `pass.*` events as its windows are scheduled, open and close, and a series emits `pass_series.*` alongside them.
## The shape is a discriminated union
`kind` is always present, and it names **exactly one** nested object that accompanies it:
| `kind` | Carries | Sells |
| -------------- | ------------- | --------------------------------------------------------------------------------------- |
| `subscription` | `billing` | Access that begins at payment and renews on a cycle. |
| `pass` | `pass` | One dated access window per purchase. |
| `pass_series` | `pass_series` | A slate of *other* pass plans' windows, sold once. |
The other two blocks are **absent**, not null. Presence is a consequence of the tag, never a signal in its own right: read `kind` and you know what you are holding.
> **This replaced a nullable `pass` object.** The previous shape emitted `pass: null` on ordinary plans and told consumers to branch on its presence. That is a type tag smuggled in as a presence check: it cannot express a third kind, and it left every integrator inferring the rule. It also emitted `billing_cycle`, `trial_days` and `recurring` on **every** plan including passes, where they mean nothing. A field that is present and lying is worse than one that is absent, so the cycle fields now appear only on the kind that has a cycle.
**Writes mirror reads exactly**, so a payload you read back is a payload you can send. The read endpoint shows one worked plan per kind, field by field.
## Two axes: existence and availability
`DELETE` removes the row; publish and unpublish flip `active`. Prefer unpublish for "pause intake while keeping the plan on the books": members who already joined keep their access either way, and an unpublished plan stays fully editable.
## Endpoints
- `GET /v1/projects/{project}/plans` — [List a project's plans](#list-a-projects-plans)
- `POST /v1/projects/{project}/plans` — [Create a plan](#create-a-plan)
- `GET /v1/projects/{project}/plans/{plan}` — [Get a plan](#get-a-plan)
- `PATCH /v1/projects/{project}/plans/{plan}` — [Update a plan](#update-a-plan)
- `DELETE /v1/projects/{project}/plans/{plan}` — [Delete a plan](#delete-a-plan)
- `POST /v1/projects/{project}/plans/{plan}/publish` — [Publish a plan](#publish-a-plan)
- `POST /v1/projects/{project}/plans/{plan}/unpublish` — [Unpublish a plan](#unpublish-a-plan)
- `POST /v1/projects/{project}/plans/order` — [Arrange the storefront order](#arrange-the-storefront-order)
- `POST /v1/projects/{project}/plans/{plan}/successor` — [Start the next season](#start-the-next-season)
## List a project's plans
`GET /v1/projects/{project}/plans`
Pages the project's plans, every kind together, newest first. Read `kind` on each row to know which of `billing`, `pass` or `pass_series` it carries; `resources` is loaded on every row. `pass.upcoming_windows` is not served here: the pass windows endpoints list a plan's dates.
- Requires ability: `project-subscription-plan:view-any`
- MCP tools: `list_plans`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: The page, newest first.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The plan's id. |
| `data[].project_id` | string | yes | The project the plan belongs to. |
| `data[].kind` | string | yes | `subscription`, `pass` or `pass_series`. Names the one block that accompanies it; the other two are absent, not null. |
| `data[].name` | string | yes | The name buyers see, unique within the project without regard to case or surrounding spaces. |
| `data[].description` | string \| array of any \| null | yes | The pitch under the name, HTML filtered to a safe subset; null when none was written. |
| `data[].price` | string | yes | What one purchase costs, as a decimal string in `currency`. On a pass that buys one window; on a series it buys the whole slate, once. `"0.00"` is a free plan. |
| `data[].currency` | object | yes | The currency the price is charged in. |
| `data[].currency.id` | string | yes | The currency's id, the value `currency_id` takes on a write. |
| `data[].currency.iso` | string | yes | The ISO 4217 code, such as `USD`. |
| `data[].currency.symbol` | string | yes | The symbol to print before the amount. |
| `data[].active` | boolean | yes | Whether the plan is on sale. Flip it with the publish and unpublish endpoints, not with an update. |
| `data[].sales_cap` | integer \| null | yes | How many purchases the plan takes before pausing itself; null for no limit. |
| `data[].sales_cap_sold` | integer | yes | Purchases counted against the cap since it was last changed or the plan was last published. |
| `data[].position` | integer \| null | yes | The plan's pinned place in the storefront order; null while it follows the built-in order (passes, then seasons, then subscriptions, cheapest first). |
| `data[].paused` | object \| null | yes | Why the plan took itself off sale; null while it is not paused. |
| `data[].paused.reason` | string | yes | `sales_cap_reached` when the cap filled, `seat_cap_reached` when a season filled its last seat. |
| `data[].paused.at` | string \| null | yes | When it paused, ISO 8601. |
| `data[].cadence` | string | yes | The duration in words the portal and the bot print, such as `Per Month`, `Per 3 Hours` or `For all 10 passes`. Use it rather than deriving one from the cycle fields. |
| `data[].eligibility` | object | yes | Who may buy. The first three are mutually exclusive. |
| `data[].eligibility.newcomers_only` | boolean | yes | Only members who never held a subscription on the project. |
| `data[].eligibility.customers_only` | boolean | yes | Only members who currently hold one. |
| `data[].eligibility.churned_only` | boolean | yes | Only members who held one and let it lapse. |
| `data[].eligibility.single_use` | boolean | yes | Each member may buy the plan once. |
| `data[].eligibility.access_codes_only` | boolean | yes | The plan cannot be bought; it is granted by redeeming an access code. |
| `data[].resources` | array of object | no | The places the plan grants, present when the endpoint loaded them. On a series these are the lounge, open for the whole span, not the thing being sold. |
| `data[].resources[].id` | string | yes | The resource's id. |
| `data[].resources[].name` | string | yes | The resource's title. |
| `data[].resources[].kind` | string | yes | `manual` for a perk the creator hands over themselves, or `:` for a place a connector hosts. |
| `data[].resources[].connector` | string \| null | yes | The connector that hosts the place; null for a manual resource. |
| `data[].billing` | object | no | Cycle, trial and renewal. Present only on `kind: subscription`. |
| `data[].billing.billing_cycle` | string \| `day`, `week`, `month`, `year`, `lifetime` | yes | `day`, `week`, `month`, `year` or `lifetime`. |
| `data[].billing.billing_cycle_count` | integer | yes | How many cycles one period spans, 1 to 99; always 1 when the cycle is `lifetime`. |
| `data[].billing.recurring` | boolean | yes | Whether the subscription renews itself. Never true for a crypto or platform currency. |
| `data[].billing.disabled_renewal` | boolean | yes | Charges once, then lapses at the end of the period instead of renewing. |
| `data[].billing.trial_days` | integer | yes | Free days before the first charge, 0 to 365. |
| `data[].billing.trial_cardless` | boolean | yes | Whether the trial starts without a payment method on file. |
| `data[].billing.trial_type` | string \| `project`, `plan` | yes | Whose trial rule applies: `project` for the project's trial settings, `plan` for this plan's own. |
| `data[].pass` | object | no | The plan's own dated schedule. Present only on `kind: pass`. |
| `data[].pass.timezone` | string \| null | yes | The IANA zone the schedule was authored in. Render window times in it, never in UTC. |
| `data[].pass.schedule_mode` | string \| null | yes | `repeating` for windows generated from `slots`, `fixed` for explicitly dated windows. |
| `data[].pass.recurrence` | string \| null | yes | `daily`, `weekly` or `monthly`; null in `fixed` mode. |
| `data[].pass.recurrence_ends_at` | string \| null | yes | When window generation stops, ISO 8601; null for no end. |
| `data[].pass.sales_cutoff_minutes` | integer \| null | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names; null to sell until the window begins. |
| `data[].pass.sales_cutoff_anchor` | string | yes | `before_start` or `before_end`. Never null: a plan that never set one reads as `before_start`. |
| `data[].pass.slots` | array of object | no | The repeating schedule, present when the endpoint loaded it. Each slot carries its own length, so one plan can mix a 3-hour and a 14-hour window. |
| `data[].pass.slots[].weekday` | integer \| null | yes | Day of the week, 0 (Sunday) to 6, on a weekly recurrence; null otherwise. |
| `data[].pass.slots[].day_of_month` | integer \| null | yes | Day of the month, 1 to 31, on a monthly recurrence; null otherwise. 29 to 31 skip the months that lack the day. |
| `data[].pass.slots[].start_time` | string | yes | Local wall-clock start in `timezone`, `HH:MM`. |
| `data[].pass.slots[].duration_minutes` | integer | yes | How long the window stays open. |
| `data[].pass.upcoming_windows` | array of object | no | The next windows, present only where the endpoint loaded them. Timestamps are UTC. |
| `data[].pass.upcoming_windows[].id` | string | yes | The window's id, the value a series names in `pass_series.window_ids`. |
| `data[].pass.upcoming_windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data[].pass.upcoming_windows[].ends_at` | string | yes | When it closes, UTC. |
| `data[].pass.upcoming_windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data[].pass_series` | object | no | The season ticket's slate, the rules that grow it and its seats. Present only on `kind: pass_series`. |
| `data[].pass_series.timezone` | string | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data[].pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data[].pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data[].pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data[].pass_series.seat_cap` | integer \| null | yes | How many holders may hold the season at once; null for unlimited. |
| `data[].pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data[].pass_series.seats_remaining` | integer \| null | yes | Seats still open; null when uncapped. |
| `data[].pass_series.starts_at` | string \| null | yes | The first window's start, UTC; null while the slate is empty. |
| `data[].pass_series.ends_at` | string \| null | yes | The last window's end, UTC; null while the slate is empty. |
| `data[].pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data[].pass_series.successor_plan_id` | string \| null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data[].pass_series.presale_hours` | integer \| null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data[].pass_series.windows` | array of object | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data[].pass_series.windows[].id` | string | yes | The window's id. |
| `data[].pass_series.windows[].plan_id` | string | yes | The pass plan the window belongs to. |
| `data[].pass_series.windows[].plan_name` | string | yes | That plan's name. |
| `data[].pass_series.windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data[].pass_series.windows[].ends_at` | string | yes | When it closes, UTC. |
| `data[].pass_series.windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data[].pass_series.windows[].added_by_rule` | boolean | yes | True when a rule absorbed it rather than the creator picking it by hand. |
| `data[].pass_series.rules` | array of object | yes | The rules that keep absorbing matching windows into the slate. |
| `data[].pass_series.rules[].source_plan_id` | string | yes | The pass plan the rule draws windows from. |
| `data[].pass_series.rules[].kind` | string | yes | `date_range` takes every window between `from_at` and `to_at`; `next_n` takes the next `take` windows. |
| `data[].pass_series.rules[].from_at` | string \| null | yes | The earliest window start the rule takes, UTC; null for no bound. |
| `data[].pass_series.rules[].to_at` | string \| null | yes | The latest window start the rule takes, UTC; null for no bound. |
| `data[].pass_series.rules[].take` | integer \| null | yes | How many windows a `next_n` rule takes; null on a `date_range` rule. |
| `data[].pass_series.blackout_window_ids` | object | yes | Windows a rule matches but the creator has permanently excluded. |
| `data[].pass_series.timezone` | null | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data[].pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data[].pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data[].pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data[].pass_series.seat_cap` | null | yes | How many holders may hold the season at once; null for unlimited. |
| `data[].pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data[].pass_series.seats_remaining` | null | yes | Seats still open; null when uncapped. |
| `data[].pass_series.starts_at` | null | yes | The first window's start, UTC; null while the slate is empty. |
| `data[].pass_series.ends_at` | null | yes | The last window's end, UTC; null while the slate is empty. |
| `data[].pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data[].pass_series.successor_plan_id` | null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data[].pass_series.presale_hours` | null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data[].pass_series.windows` | array of string | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data[].pass_series.rules` | array of string | yes | The rules that keep absorbing matching windows into the slate. |
| `data[].pass_series.blackout_window_ids` | array of string | yes | Windows a rule matches but the creator has permanently excluded. |
| `data[].created_at` | string \| null | yes | When the plan was created, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When it last changed, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Create a plan
`POST /v1/projects/{project}/plans`
`POST` takes the **same shape it returns**: `kind` plus the one matching block. Sending a block that does not match the kind is refused with `VALIDATION_FAILED` naming the offending key; silently ignoring it would let a caller believe they had set a billing cycle on a pass. Answers `201` with the plan in the same discriminated shape the read endpoint shows for each kind. A plan created on sale (`active: true`, the default) is queued for the push to the payment methods that keep a product catalogue (Stripe, PayPal, CoinPayments, Razorpay), so it is sellable through them within a minute; a draft is pushed when it is published.
### Create a subscription
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/plans \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"kind": "subscription",
"name": "Premium Monthly",
"currency_id": "8f27a0d4-63be-4915-8c07-1a5d9e34b628",
"price": 29.00,
"resources": ["b73c5f21-9d80-4a6e-8215-4f70ce13a9d6"],
"billing": { "billing_cycle": "month", "billing_cycle_count": 1, "trial_days": 7 }
}'
```
### Create a pass
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/plans \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"kind": "pass",
"name": "Sunday Slate Pass",
"currency_id": "8f27a0d4-63be-4915-8c07-1a5d9e34b628",
"price": 15.00,
"resources": ["b73c5f21-9d80-4a6e-8215-4f70ce13a9d6"],
"pass": {
"timezone": "America/New_York",
"schedule_mode": "repeating",
"recurrence": "weekly",
"sales_cutoff_minutes": 60,
"sales_cutoff_anchor": "before_start",
"slots": [
{ "weekday": 4, "start_time": "19:00", "duration_minutes": 180 },
{ "weekday": 0, "start_time": "09:00", "duration_minutes": 840 }
]
}
}'
```
Sending `pass.slots` **replaces the whole schedule** and rebuilds future windows. Windows a customer has already bought keep their original times and are never moved or deleted; only unsold future windows are regenerated. `pass.windows` is different: it *adds* explicitly dated windows for `schedule_mode: fixed` and never replaces anything.
### Create a pass series
A series needs window ids, and those come from windows that already exist. Read them from `pass.upcoming_windows` on the source plan, from the pass windows endpoints, or from the [`list_pass_windows`](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) MCP tool.
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/plans \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"kind": "pass_series",
"name": "Autumn Season Ticket",
"currency_id": "8f27a0d4-63be-4915-8c07-1a5d9e34b628",
"price": 99.00,
"pass_series": {
"prevent_overlaps": true,
"seat_cap": 50,
"presale_hours": 48,
"window_ids": ["3d5a8c72-b016-4e94-8fa7-61c209d4e738", "9c1f4e27-5a8b-4d63-b2e0-8f7a6c5d4e31"],
"rules": [
{ "source_plan_id": "0b8e6a2f-4c1d-4e3a-9f52-7d6c1b2a3e45", "kind": "date_range", "from_at": "2026-09-01T00:00:00Z", "to_at": "2026-12-01T00:00:00Z" }
]
}
}'
```
> **`resources` is optional on a series only.** Every other kind must link at least one resource, or a purchase buys nothing. A series is the exception: each of its windows grants **that window's own plan's** resources, so a series with none still delivers exactly what was sold. Anything you do link here is a **lounge**, open for the whole span.
> **Never put a slate window's resource in a series `resources`.** A lounge is granted **at purchase with no window** and kept until the season ends. Send a resource that one of your slate windows already opens and every holder is handed it permanently the moment they pay: the dates it was scheduled for stop gating anything, and a season ticket becomes a permanent key to that channel. The API accepts it, because a genuinely permanent room is a legitimate thing to sell. It is simply almost never what was meant. Keep `resources` for a holders-only room nothing on the slate opens; the slate's own channels are already granted by their own pass plans on their own dates. The dashboard warns when the two overlap. Over the API, checking is yours to do.
Rules keep working after the save: a matching window scheduled later is absorbed into the slate **and granted to everyone already holding the series**, at no charge. That emits [`pass_series.leg_added`](https://docs.subscriby.net/webhooks/v1/events/pass-series#pass-series-leg-added). Handpicked `window_ids` never grow on their own. The two compose; most real seasons use both.
### Validation
The field-by-field rules are on each request field below. The rules that cross fields:
- `name` must be unique within the project, compared without regard to case or surrounding spaces; a duplicate is refused on `name`.
- `price` must be at least the **$1.00 USD equivalent** in `currency_id`, converted at the current rate. Payment providers reject dust amounts, so anything below that is unbuyable and is refused; the error names the minimum in both the plan's currency and USD.
- `price` of exactly `0` publishes a **free plan**. Plans priced at zero can only be sold by a **Starter** or **Growth** account, never on Free: the platform fee is a share of what you charge, so a zero-priced plan earns nothing to share. On Free the call is refused on `price`. The rule enforced is that a plan may not be **simultaneously active and priced at `0`** on a creator whose plan cannot sell one, so an update that leaves a zero-priced plan off sale is allowed.
- `currency_id` must be supported by at least one active payment method on the project.
- At most one of `eligibility.newcomers_only`, `eligibility.customers_only` and `eligibility.churned_only` may be `true`.
- `resources` is required with at least one id, **except on `kind: pass_series`**, where it is optional and means a lounge.
- `billing.billing_cycle_count` must be `1` when `billing.billing_cycle` is `lifetime`; `billing.recurring` is refused as `true` when the currency is a crypto or platform currency.
- `pass.sales_cutoff_anchor: before_end` requires `pass.sales_cutoff_minutes` of at least `5` and under the shortest slot's `duration_minutes`, and is refused outright when that shortest slot is `5` minutes or less. `before_first_end` and `before_last_start` are **series-only** anchors and are rejected on a pass plan, where a lone window is both the first and the last.
- `pass_series.window_ids` needs **at least two** windows unless a rule will supply them; every id must belong to a pass plan on this project; with `prevent_overlaps` on, two windows that run at the same time are refused. `pass_series.rules[].take` is **required when `kind` is `next_n`**, because a count rule with no count is incomplete rather than "all of them". `pass_series.successor_plan_id` must be **another** `kind: pass_series` plan on the same project.
- Requires ability: `project-subscription-plan:create`
- Fires events: `plan.created`
- MCP tools: `create_plan`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A plan to create: `kind` plus the one block it names, in the shape a plan is read back in.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | `subscription`, `pass`, `pass_series` | yes | `subscription`, `pass` or `pass_series`; names the one block the payload carries. Required on create. Omit on an update to keep the plan's current kind. \| \| \|---\| \| `subscription`
Access starts at payment and runs on a billing cycle. \| \| `pass`
One purchase buys one of the plan's own scheduled access windows. \| \| `pass_series`
One purchase buys a curated slate of *other* plans' access windows. \| |
| `name` | string | yes | The name buyers see, 5 to 255 characters, unique within the project without regard to case or surrounding spaces. Required on create. |
| `description` | string \| null | no | The pitch under the name, up to 1,000 characters. HTML is filtered to a safe subset before it is stored. |
| `currency_id` | string | yes | The id of a currency that at least one of the project's active payment methods can charge. Required on create. |
| `price` | number | yes | What one purchase costs: at least the $1.00 USD equivalent in `currency_id` at the current rate, or exactly `0` for a free plan, which only a Starter or Growth account may put on sale. Required on create. |
| `active` | boolean | no | Whether the plan is on sale. Defaults to true on create; prefer the publish and unpublish endpoints to change it later. |
| `sales_cap` | integer \| null | no | Pause the plan after this many purchases, 1 to 100,000; null for no limit. Changing it restarts `sales_cap_sold` at 0. |
| `eligibility` | object | no | Who may buy. At most one of the first three may be true, counting what the plan already holds on an update. |
| `eligibility.newcomers_only` | boolean | no | Only members who never held a subscription on the project. |
| `eligibility.customers_only` | boolean | no | Only members who currently hold one. |
| `eligibility.churned_only` | boolean | no | Only members who held one and let it lapse. |
| `eligibility.single_use` | boolean | no | Each member may buy the plan once. |
| `eligibility.access_codes_only` | boolean | no | The plan cannot be bought, only granted by redeeming an access code. |
| `resources` | array of string | yes | Ids of the project's resources the plan grants: at least one, required on create, except on `kind: pass_series`, where it is optional and names a lounge open for the whole span. Omit on an update to keep the existing links. |
| `billing` | object | yes | Cycle, trial and renewal. Send it on `kind: subscription` only; on any other kind it is refused. |
| `billing.billing_cycle` | `day`, `week`, `month`, `year`, `lifetime` | yes | `day`, `week`, `month`, `year` or `lifetime`. Required on create. |
| `billing.billing_cycle_count` | integer | yes | How many cycles one period spans, 1 to 99; must be 1 when the cycle is `lifetime`. Required on create. |
| `billing.recurring` | boolean | no | Whether the subscription renews itself. Refused as true when the currency is a crypto or platform currency. |
| `billing.disabled_renewal` | boolean | no | Charge once, then lapse at the end of the period instead of renewing. |
| `billing.trial_days` | integer | no | Free days before the first charge, 0 to 365. |
| `billing.trial_cardless` | boolean | no | Whether the trial starts without a payment method on file. |
| `billing.trial_type` | `project`, `plan` | no | Whose trial rule applies: `project` uses the project's trial settings, `plan` this plan's own. |
| `pass` | object | no | The plan's own dated schedule. Send it on `kind: pass` only; on any other kind it is refused. |
| `pass.timezone` | `GMT`, `UTC`, `Africa/Abidjan`, `Africa/Accra`, `Africa/Bamako`, `Africa/Banjul`, `Africa/Bissau`, `Africa/Casablanca`, `Africa/Conakry`, `Africa/Dakar`, `Africa/El_Aaiun`, `Africa/Freetown`, `Africa/Lome`, `Africa/Monrovia`, `Africa/Nouakchott`, `Africa/Ouagadougou`, `Africa/Sao_Tome`, `Africa/Algiers`, `Africa/Bangui`, `Africa/Brazzaville`, `Africa/Douala`, `Africa/Kinshasa`, `Africa/Lagos`, `Africa/Libreville`, `Africa/Luanda`, `Africa/Malabo`, `Africa/Ndjamena`, `Africa/Niamey`, `Africa/Porto-Novo`, `Africa/Tunis`, `Africa/Blantyre`, `Africa/Bujumbura`, `Africa/Ceuta`, `Africa/Gaborone`, `Africa/Harare`, `Africa/Johannesburg`, `Africa/Juba`, `Africa/Khartoum`, `Africa/Kigali`, `Africa/Lubumbashi`, `Africa/Lusaka`, `Africa/Maputo`, `Africa/Maseru`, `Africa/Mbabane`, `Africa/Tripoli`, `Africa/Windhoek`, `Africa/Addis_Ababa`, `Africa/Asmara`, `Africa/Cairo`, `Africa/Dar_es_Salaam`, `Africa/Djibouti`, `Africa/Kampala`, `Africa/Mogadishu`, `Africa/Nairobi`, `America/Adak`, `America/Anchorage`, `America/Juneau`, `America/Metlakatla`, `America/Nome`, `America/Sitka`, `America/Yakutat`, `America/Creston`, `America/Dawson`, `America/Dawson_Creek`, `America/Fort_Nelson`, `America/Hermosillo`, `America/Los_Angeles`, `America/Mazatlan`, `America/Phoenix`, `America/Tijuana`, `America/Vancouver`, `America/Whitehorse`, `America/Bahia_Banderas`, `America/Belize`, `America/Boise`, `America/Cambridge_Bay`, `America/Chihuahua`, `America/Ciudad_Juarez`, `America/Costa_Rica`, `America/Denver`, `America/Edmonton`, `America/El_Salvador`, `America/Guatemala`, `America/Inuvik`, `America/Managua`, `America/Merida`, `America/Mexico_City`, `America/Monterrey`, `America/Regina`, `America/Swift_Current`, `America/Tegucigalpa`, `America/Atikokan`, `America/Bogota`, `America/Cancun`, `America/Cayman`, `America/Chicago`, `America/Eirunepe`, `America/Guayaquil`, `America/Indiana/Knox`, `America/Indiana/Tell_City`, `America/Jamaica`, `America/Lima`, `America/Matamoros`, `America/Menominee`, `America/North_Dakota/Beulah`, `America/North_Dakota/Center`, `America/North_Dakota/New_Salem`, `America/Ojinaga`, `America/Panama`, `America/Rankin_Inlet`, `America/Resolute`, `America/Rio_Branco`, `America/Winnipeg`, `America/Anguilla`, `America/Antigua`, `America/Aruba`, `America/Barbados`, `America/Blanc-Sablon`, `America/Boa_Vista`, `America/Campo_Grande`, `America/Caracas`, `America/Cuiaba`, `America/Curacao`, `America/Detroit`, `America/Dominica`, `America/Grand_Turk`, `America/Grenada`, `America/Guadeloupe`, `America/Guyana`, `America/Havana`, `America/Indiana/Indianapolis`, `America/Indiana/Marengo`, `America/Indiana/Petersburg`, `America/Indiana/Vevay`, `America/Indiana/Vincennes`, `America/Indiana/Winamac`, `America/Iqaluit`, `America/Kentucky/Louisville`, `America/Kentucky/Monticello`, `America/Kralendijk`, `America/La_Paz`, `America/Lower_Princes`, `America/Manaus`, `America/Marigot`, `America/Martinique`, `America/Montserrat`, `America/Nassau`, `America/New_York`, `America/Port_of_Spain`, `America/Port-au-Prince`, `America/Porto_Velho`, `America/Puerto_Rico`, `America/Santo_Domingo`, `America/St_Barthelemy`, `America/St_Kitts`, `America/St_Lucia`, `America/St_Thomas`, `America/St_Vincent`, `America/Toronto`, `America/Tortola`, `America/Araguaina`, `America/Argentina/Buenos_Aires`, `America/Argentina/Catamarca`, `America/Argentina/Cordoba`, `America/Argentina/Jujuy`, `America/Argentina/La_Rioja`, `America/Argentina/Mendoza`, `America/Argentina/Rio_Gallegos`, `America/Argentina/Salta`, `America/Argentina/San_Juan`, `America/Argentina/San_Luis`, `America/Argentina/Tucuman`, `America/Argentina/Ushuaia`, `America/Asuncion`, `America/Bahia`, `America/Belem`, `America/Cayenne`, `America/Coyhaique`, `America/Fortaleza`, `America/Glace_Bay`, `America/Goose_Bay`, `America/Halifax`, `America/Maceio`, `America/Moncton`, `America/Montevideo`, `America/Paramaribo`, `America/Punta_Arenas`, `America/Recife`, `America/Santarem`, `America/Santiago`, `America/Sao_Paulo`, `America/Thule`, `America/St_Johns`, `America/Miquelon`, `America/Noronha`, `America/Nuuk`, `America/Scoresbysund`, `America/Danmarkshavn`, `Antarctica/Palmer`, `Antarctica/Rothera`, `Antarctica/Troll`, `Antarctica/Syowa`, `Antarctica/Mawson`, `Antarctica/Vostok`, `Antarctica/Davis`, `Antarctica/Casey`, `Antarctica/DumontDUrville`, `Antarctica/Macquarie`, `Antarctica/McMurdo`, `Arctic/Longyearbyen`, `Asia/Aden`, `Asia/Amman`, `Asia/Baghdad`, `Asia/Bahrain`, `Asia/Beirut`, `Asia/Damascus`, `Asia/Famagusta`, `Asia/Gaza`, `Asia/Hebron`, `Asia/Jerusalem`, `Asia/Kuwait`, `Asia/Nicosia`, `Asia/Qatar`, `Asia/Riyadh`, `Asia/Tehran`, `Asia/Baku`, `Asia/Dubai`, `Asia/Muscat`, `Asia/Tbilisi`, `Asia/Yerevan`, `Asia/Kabul`, `Asia/Almaty`, `Asia/Aqtau`, `Asia/Aqtobe`, `Asia/Ashgabat`, `Asia/Atyrau`, `Asia/Dushanbe`, `Asia/Karachi`, `Asia/Oral`, `Asia/Qostanay`, `Asia/Qyzylorda`, `Asia/Samarkand`, `Asia/Tashkent`, `Asia/Yekaterinburg`, `Asia/Colombo`, `Asia/Kolkata`, `Asia/Kathmandu`, `Asia/Bishkek`, `Asia/Dhaka`, `Asia/Omsk`, `Asia/Thimphu`, `Asia/Urumqi`, `Asia/Yangon`, `Asia/Bangkok`, `Asia/Barnaul`, `Asia/Ho_Chi_Minh`, `Asia/Hovd`, `Asia/Jakarta`, `Asia/Krasnoyarsk`, `Asia/Novokuznetsk`, `Asia/Novosibirsk`, `Asia/Phnom_Penh`, `Asia/Pontianak`, `Asia/Tomsk`, `Asia/Vientiane`, `Asia/Brunei`, `Asia/Hong_Kong`, `Asia/Irkutsk`, `Asia/Kuala_Lumpur`, `Asia/Kuching`, `Asia/Macau`, `Asia/Makassar`, `Asia/Manila`, `Asia/Shanghai`, `Asia/Singapore`, `Asia/Taipei`, `Asia/Ulaanbaatar`, `Asia/Chita`, `Asia/Dili`, `Asia/Jayapura`, `Asia/Khandyga`, `Asia/Pyongyang`, `Asia/Seoul`, `Asia/Tokyo`, `Asia/Yakutsk`, `Asia/Ust-Nera`, `Asia/Vladivostok`, `Asia/Magadan`, `Asia/Sakhalin`, `Asia/Srednekolymsk`, `Asia/Anadyr`, `Asia/Kamchatka`, `Atlantic/Bermuda`, `Atlantic/Stanley`, `Atlantic/South_Georgia`, `Atlantic/Cape_Verde`, `Atlantic/Azores`, `Atlantic/Reykjavik`, `Atlantic/St_Helena`, `Atlantic/Canary`, `Atlantic/Faroe`, `Atlantic/Madeira`, `Australia/Perth`, `Australia/Eucla`, `Australia/Adelaide`, `Australia/Broken_Hill`, `Australia/Darwin`, `Australia/Brisbane`, `Australia/Hobart`, `Australia/Lindeman`, `Australia/Melbourne`, `Australia/Sydney`, `Australia/Lord_Howe`, `Europe/Dublin`, `Europe/Guernsey`, `Europe/Isle_of_Man`, `Europe/Jersey`, `Europe/Lisbon`, `Europe/London`, `Europe/Amsterdam`, `Europe/Andorra`, `Europe/Belgrade`, `Europe/Berlin`, `Europe/Bratislava`, `Europe/Brussels`, `Europe/Budapest`, `Europe/Busingen`, `Europe/Copenhagen`, `Europe/Gibraltar`, `Europe/Kaliningrad`, `Europe/Ljubljana`, `Europe/Luxembourg`, `Europe/Madrid`, `Europe/Malta`, `Europe/Monaco`, `Europe/Oslo`, `Europe/Paris`, `Europe/Podgorica`, `Europe/Prague`, `Europe/Rome`, `Europe/San_Marino`, `Europe/Sarajevo`, `Europe/Skopje`, `Europe/Stockholm`, `Europe/Tirane`, `Europe/Vaduz`, `Europe/Vatican`, `Europe/Vienna`, `Europe/Warsaw`, `Europe/Zagreb`, `Europe/Zurich`, `Europe/Athens`, `Europe/Bucharest`, `Europe/Chisinau`, `Europe/Helsinki`, `Europe/Istanbul`, `Europe/Kirov`, `Europe/Kyiv`, `Europe/Mariehamn`, `Europe/Minsk`, `Europe/Moscow`, `Europe/Riga`, `Europe/Simferopol`, `Europe/Sofia`, `Europe/Tallinn`, `Europe/Vilnius`, `Europe/Volgograd`, `Europe/Astrakhan`, `Europe/Samara`, `Europe/Saratov`, `Europe/Ulyanovsk`, `Indian/Antananarivo`, `Indian/Comoro`, `Indian/Mayotte`, `Indian/Mahe`, `Indian/Mauritius`, `Indian/Reunion`, `Indian/Kerguelen`, `Indian/Maldives`, `Indian/Chagos`, `Indian/Cocos`, `Indian/Christmas`, `Pacific/Midway`, `Pacific/Niue`, `Pacific/Pago_Pago`, `Pacific/Honolulu`, `Pacific/Rarotonga`, `Pacific/Tahiti`, `Pacific/Marquesas`, `Pacific/Gambier`, `Pacific/Pitcairn`, `Pacific/Galapagos`, `Pacific/Easter`, `Pacific/Palau`, `Pacific/Chuuk`, `Pacific/Guam`, `Pacific/Port_Moresby`, `Pacific/Saipan`, `Pacific/Bougainville`, `Pacific/Efate`, `Pacific/Guadalcanal`, `Pacific/Kosrae`, `Pacific/Norfolk`, `Pacific/Noumea`, `Pacific/Pohnpei`, `Pacific/Auckland`, `Pacific/Fiji`, `Pacific/Funafuti`, `Pacific/Kwajalein`, `Pacific/Majuro`, `Pacific/Nauru`, `Pacific/Tarawa`, `Pacific/Wake`, `Pacific/Wallis`, `Pacific/Chatham`, `Pacific/Apia`, `Pacific/Fakaofo`, `Pacific/Kanton`, `Pacific/Tongatapu`, `Pacific/Kiritimati`, null | no | The IANA zone the schedule is authored in. Legacy names are resolved, so `Asia/Calcutta` is stored as `Asia/Kolkata`. Required on create. |
| `pass.schedule_mode` | `fixed`, `repeating` \| null | no | `repeating` (the default) generates windows from `slots`; `fixed` takes explicitly dated `windows`. |
| `pass.recurrence` | `daily`, `weekly`, `monthly` \| null | no | `daily`, `weekly` or `monthly`. Decides which slot fields apply. |
| `pass.recurrence_ends_at` | string \| null (date-time) | no | When window generation stops; omit to keep generating. |
| `pass.sales_cutoff_minutes` | integer \| null | no | Stop sales this many minutes before the moment `sales_cutoff_anchor` names; omit to sell until the window begins. With `before_end` it must be at least 5 and under the shortest slot's `duration_minutes`. |
| `pass.sales_cutoff_anchor` | `before_start`, `before_end`, null | no | `before_start` (the default) or `before_end`. `before_end` is refused when the shortest slot lasts 5 minutes or less. The series anchors `before_first_end` and `before_last_start` are refused on a pass. |
| `pass.slots` | array of object | no | The repeating schedule, at most 60 slots. Sending it replaces the whole schedule and rebuilds unsold future windows; windows a customer has bought keep their times. |
| `pass.slots[].weekday` | integer \| null | no | Day of the week, 0 (Sunday) to 6, on a weekly recurrence. |
| `pass.slots[].day_of_month` | integer \| null | no | Day of the month, 1 to 31, on a monthly recurrence; 29 to 31 skip the months that lack the day. |
| `pass.slots[].start_time` | string | no | Local wall-clock start in `pass.timezone`, `HH:MM`. |
| `pass.slots[].duration_minutes` | integer | no | How long the window stays open. |
| `pass.windows` | array of object | no | Explicitly dated windows for `schedule_mode: fixed`, at most 200 per call. Added to the schedule, never replacing it. |
| `pass.windows[].starts_at` | string (date-time) | no | Local wall-clock start in `pass.timezone`. |
| `pass.windows[].duration_minutes` | integer | no | How long the window stays open. |
| `pass_series` | object | no | The season ticket's slate, rules and seats. Send it on `kind: pass_series` only; on any other kind it is refused. |
| `pass_series.prevent_overlaps` | boolean | no | Refuse a window that clashes with one already on the slate. Defaults to true. |
| `pass_series.sales_cutoff_minutes` | integer \| null | no | Stop sales this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `pass_series.sales_cutoff_anchor` | `before_start`, `before_first_end`, `before_last_start`, `before_end` \| null | no | `before_start` (the default), `before_first_end`, `before_last_start` or `before_end`. `before_first_end` needs `sales_cutoff_minutes` of at least 5, so a buyer arriving late in the opening window is still admitted to it. |
| `pass_series.seat_cap` | integer \| null | no | How many holders may hold the season at once, at least 1; null for unlimited. Zero is refused: take the plan off sale with unpublish instead. |
| `pass_series.presale_hours` | integer \| null | no | How long the next season is offered to current holders only, 1 to 8760. |
| `pass_series.successor_plan_id` | string \| null | no | Another `kind: pass_series` plan on the same project to offer holders when this one finishes. A series cannot lead to itself. |
| `pass_series.window_ids` | array of string | no | Ids of pass windows on this project to put on the slate, at most 120. A slate needs at least two unless a rule will supply them; with `prevent_overlaps` on, two that run at the same time are refused. |
| `pass_series.blackout_window_ids` | array of string | no | Windows a rule matches but you want permanently excluded. |
| `pass_series.rules` | array of object | no | Rules that keep absorbing matching windows into the slate, at most 20. A matching window scheduled later is added and granted to every current holder at no charge. |
| `pass_series.rules[].source_plan_id` | string | no | A `kind: pass` plan on this project to draw windows from. |
| `pass_series.rules[].kind` | `date_range`, `next_n` | no | `date_range` takes every window between `from_at` and `to_at`; `next_n` takes the next `take` windows. \| \| \|---\| \| `date_range`
Every window of the source plan that starts between two dates. \| \| `next_n`
The next N windows of the source plan, whenever they fall. \| |
| `pass_series.rules[].from_at` | string \| null (date-time) | no | The earliest window start a `date_range` rule takes. |
| `pass_series.rules[].to_at` | string \| null (date-time) | no | The latest window start a `date_range` rule takes. |
| `pass_series.rules[].take` | integer \| null | no | How many windows a `next_n` rule takes, 1 to 120; required when `kind` is `next_n`. |
### Responses
- **201**: The new plan.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The plan's id. |
| `data.project_id` | string | yes | The project the plan belongs to. |
| `data.kind` | string | yes | `subscription`, `pass` or `pass_series`. Names the one block that accompanies it; the other two are absent, not null. |
| `data.name` | string | yes | The name buyers see, unique within the project without regard to case or surrounding spaces. |
| `data.description` | string \| array of any \| null | yes | The pitch under the name, HTML filtered to a safe subset; null when none was written. |
| `data.price` | string | yes | What one purchase costs, as a decimal string in `currency`. On a pass that buys one window; on a series it buys the whole slate, once. `"0.00"` is a free plan. |
| `data.currency` | object | yes | The currency the price is charged in. |
| `data.currency.id` | string | yes | The currency's id, the value `currency_id` takes on a write. |
| `data.currency.iso` | string | yes | The ISO 4217 code, such as `USD`. |
| `data.currency.symbol` | string | yes | The symbol to print before the amount. |
| `data.active` | boolean | yes | Whether the plan is on sale. Flip it with the publish and unpublish endpoints, not with an update. |
| `data.sales_cap` | integer \| null | yes | How many purchases the plan takes before pausing itself; null for no limit. |
| `data.sales_cap_sold` | integer | yes | Purchases counted against the cap since it was last changed or the plan was last published. |
| `data.position` | integer \| null | yes | The plan's pinned place in the storefront order; null while it follows the built-in order (passes, then seasons, then subscriptions, cheapest first). |
| `data.paused` | object \| null | yes | Why the plan took itself off sale; null while it is not paused. |
| `data.paused.reason` | string | yes | `sales_cap_reached` when the cap filled, `seat_cap_reached` when a season filled its last seat. |
| `data.paused.at` | string \| null | yes | When it paused, ISO 8601. |
| `data.cadence` | string | yes | The duration in words the portal and the bot print, such as `Per Month`, `Per 3 Hours` or `For all 10 passes`. Use it rather than deriving one from the cycle fields. |
| `data.eligibility` | object | yes | Who may buy. The first three are mutually exclusive. |
| `data.eligibility.newcomers_only` | boolean | yes | Only members who never held a subscription on the project. |
| `data.eligibility.customers_only` | boolean | yes | Only members who currently hold one. |
| `data.eligibility.churned_only` | boolean | yes | Only members who held one and let it lapse. |
| `data.eligibility.single_use` | boolean | yes | Each member may buy the plan once. |
| `data.eligibility.access_codes_only` | boolean | yes | The plan cannot be bought; it is granted by redeeming an access code. |
| `data.resources` | array of object | no | The places the plan grants, present when the endpoint loaded them. On a series these are the lounge, open for the whole span, not the thing being sold. |
| `data.resources[].id` | string | yes | The resource's id. |
| `data.resources[].name` | string | yes | The resource's title. |
| `data.resources[].kind` | string | yes | `manual` for a perk the creator hands over themselves, or `:` for a place a connector hosts. |
| `data.resources[].connector` | string \| null | yes | The connector that hosts the place; null for a manual resource. |
| `data.billing` | object | no | Cycle, trial and renewal. Present only on `kind: subscription`. |
| `data.billing.billing_cycle` | string \| `day`, `week`, `month`, `year`, `lifetime` | yes | `day`, `week`, `month`, `year` or `lifetime`. |
| `data.billing.billing_cycle_count` | integer | yes | How many cycles one period spans, 1 to 99; always 1 when the cycle is `lifetime`. |
| `data.billing.recurring` | boolean | yes | Whether the subscription renews itself. Never true for a crypto or platform currency. |
| `data.billing.disabled_renewal` | boolean | yes | Charges once, then lapses at the end of the period instead of renewing. |
| `data.billing.trial_days` | integer | yes | Free days before the first charge, 0 to 365. |
| `data.billing.trial_cardless` | boolean | yes | Whether the trial starts without a payment method on file. |
| `data.billing.trial_type` | string \| `project`, `plan` | yes | Whose trial rule applies: `project` for the project's trial settings, `plan` for this plan's own. |
| `data.pass` | object | no | The plan's own dated schedule. Present only on `kind: pass`. |
| `data.pass.timezone` | string \| null | yes | The IANA zone the schedule was authored in. Render window times in it, never in UTC. |
| `data.pass.schedule_mode` | string \| null | yes | `repeating` for windows generated from `slots`, `fixed` for explicitly dated windows. |
| `data.pass.recurrence` | string \| null | yes | `daily`, `weekly` or `monthly`; null in `fixed` mode. |
| `data.pass.recurrence_ends_at` | string \| null | yes | When window generation stops, ISO 8601; null for no end. |
| `data.pass.sales_cutoff_minutes` | integer \| null | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names; null to sell until the window begins. |
| `data.pass.sales_cutoff_anchor` | string | yes | `before_start` or `before_end`. Never null: a plan that never set one reads as `before_start`. |
| `data.pass.slots` | array of object | no | The repeating schedule, present when the endpoint loaded it. Each slot carries its own length, so one plan can mix a 3-hour and a 14-hour window. |
| `data.pass.slots[].weekday` | integer \| null | yes | Day of the week, 0 (Sunday) to 6, on a weekly recurrence; null otherwise. |
| `data.pass.slots[].day_of_month` | integer \| null | yes | Day of the month, 1 to 31, on a monthly recurrence; null otherwise. 29 to 31 skip the months that lack the day. |
| `data.pass.slots[].start_time` | string | yes | Local wall-clock start in `timezone`, `HH:MM`. |
| `data.pass.slots[].duration_minutes` | integer | yes | How long the window stays open. |
| `data.pass.upcoming_windows` | array of object | no | The next windows, present only where the endpoint loaded them. Timestamps are UTC. |
| `data.pass.upcoming_windows[].id` | string | yes | The window's id, the value a series names in `pass_series.window_ids`. |
| `data.pass.upcoming_windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass.upcoming_windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass.upcoming_windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series` | object | no | The season ticket's slate, the rules that grow it and its seats. Present only on `kind: pass_series`. |
| `data.pass_series.timezone` | string | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | integer \| null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | integer \| null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | string \| null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | string \| null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | string \| null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | integer \| null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of object | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.windows[].id` | string | yes | The window's id. |
| `data.pass_series.windows[].plan_id` | string | yes | The pass plan the window belongs to. |
| `data.pass_series.windows[].plan_name` | string | yes | That plan's name. |
| `data.pass_series.windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass_series.windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass_series.windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series.windows[].added_by_rule` | boolean | yes | True when a rule absorbed it rather than the creator picking it by hand. |
| `data.pass_series.rules` | array of object | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.rules[].source_plan_id` | string | yes | The pass plan the rule draws windows from. |
| `data.pass_series.rules[].kind` | string | yes | `date_range` takes every window between `from_at` and `to_at`; `next_n` takes the next `take` windows. |
| `data.pass_series.rules[].from_at` | string \| null | yes | The earliest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].to_at` | string \| null | yes | The latest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].take` | integer \| null | yes | How many windows a `next_n` rule takes; null on a `date_range` rule. |
| `data.pass_series.blackout_window_ids` | object | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.pass_series.timezone` | null | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of string | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.rules` | array of string | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.blackout_window_ids` | array of string | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.created_at` | string \| null | yes | When the plan was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when `kind` is `pass` or `pass_series` and the project owner's account lacks Time-Limited Passes, bundled with Growth or available as the Passes add-on.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when a block does not match `kind`, `name` is taken, `price` is below the minimum or is `0` on a Free account, `currency_id` has no active payment method, two eligibility flags are set, or a per-kind rule above is broken; `error.fields` names the key.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a plan
`GET /v1/projects/{project}/plans/{plan}`
One plan in the same discriminated shape as the list. `kind` names the one block the plan carries, `billing`, `pass` or `pass_series`; the other two are absent. A plan of another project is `404 RESOURCE_NOT_FOUND`, because `{plan}` is resolved within `{project}`.
### Common fields
Present on every kind.
| Field | Type | Notes |
| ------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | string | `subscription`, `pass` or `pass_series`. Names the one block below it. |
| `price` | string | What **one purchase** costs. On a pass that buys one window; on a series it buys the whole slate, once. |
| `currency` | object | `{ id, iso, symbol }`. |
| `cadence` | string | The human-readable duration string: `"Per Month"`, `"Per 3 Hours"`, `"For all 10 passes"`. |
| `eligibility` | object | Audience restrictions. The first three are mutually exclusive. |
| `resources` | array | Present when the relation is loaded: each resource's `id`, `name`, `kind` (`manual` or `connector:kind`) and `connector`. On a series these are the **lounge**, not the thing being sold. |
> **Why `cadence` exists.** Both the portal and the bot already compute exactly this string, and every integrator without it reinvents it, wrongly for a dated plan, printing "1 Month" beside a three-hour window. Use it rather than deriving a duration from the cycle fields.
### kind: subscription
| Field | Type | Notes |
| ----------------------------- | ------- | ----------------------------------------------------- |
| `billing.billing_cycle` | string | `day`, `week`, `month`, `year`, `lifetime`. |
| `billing.billing_cycle_count` | integer | 1–99. Must be `1` when `billing_cycle` is `lifetime`. |
| `billing.recurring` | boolean | Rejected as `true` for crypto or platform currencies. |
| `billing.disabled_renewal` | boolean | Charges once, then lapses rather than renewing. |
| `billing.trial_days` | integer | 0–365. |
| `billing.trial_cardless` | boolean | Whether the trial starts without a payment method. |
| `billing.trial_type` | string | `project` or `plan`: whose trial rule applies. |
### kind: pass
A plan that owns its own dated windows and sells one per purchase.
| Field | Type | Notes |
| ------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pass.timezone` | string | IANA zone the schedule was authored in. Render window times in this, never UTC. |
| `pass.schedule_mode` | string | `repeating` or `fixed`. |
| `pass.recurrence` | string \| null | `daily`, `weekly` or `monthly`. Null in `fixed` mode. |
| `pass.recurrence_ends_at` | timestamp \| null | When window generation stops. **Renamed**, see the note below. |
| `pass.sales_cutoff_minutes` | integer \| null | Stops sales this many minutes before the moment `sales_cutoff_anchor` names. |
| `pass.sales_cutoff_anchor` | string | `before_start` or `before_end`. Never null: a plan that never set it reads as `before_start`. `before_first_end` and `before_last_start` are series-only and refused here. |
| `pass.slots[].start_time` | string `HH:MM` | Local wall-clock time in `timezone`. |
| `pass.slots[].duration_minutes` | integer | Each slot carries its own length, so one plan can mix a 3-hour and a 14-hour window. |
| `pass.upcoming_windows[]` | array | Present only when the relation is loaded. Timestamps are **UTC**. The pass windows endpoints list and manage a plan's dates. |
> **`series_ends_at` was renamed to `recurrence_ends_at`.** Same field, same meaning: how long this plan keeps generating windows from its recurrence. It never had anything to do with a Pass Series, but now that a series is a real plan kind, a field called `series_ends_at` sitting on a pass read as "when this plan's series ends", which is a different thing and one that does not exist here. Update any reader to the new key; there is no alias.
### kind: pass_series
A season ticket. It **owns no windows**: it points at windows that already exist on your pass plans, which is the whole distinction from `kind: pass`.
| Field | Type | Notes |
| ------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pass_series.timezone` | string | Borrowed from the source plans; a series has no zone of its own. A series whose sources disagree is refused at authoring time. |
| `pass_series.prevent_overlaps` | boolean | Refuses to absorb a window clashing with one already on the slate. |
| `pass_series.sales_cutoff_minutes` | integer | Measured against the **whole season**, not one window. |
| `pass_series.sales_cutoff_anchor` | string | Four anchors, earliest deadline first. `before_start` closes before the **first** window opens. `before_first_end` closes during that opening window. `before_last_start` closes as the **last** window opens, so a buyer always gets one whole window. `before_end` closes as the last window ends. |
| `pass_series.seat_cap` | integer \| null | Concurrent holder limit. `null` is unlimited. |
| `pass_series.seats_taken` | integer | Holders currently counted against the cap. |
| `pass_series.seats_remaining` | integer \| null | `null` when uncapped. |
| `pass_series.starts_at` | timestamp \| null | First window's start, **UTC**. |
| `pass_series.ends_at` | timestamp \| null | Last window's end, UTC. |
| `pass_series.window_count` | integer | Slate length. Capped at **120**. |
| `pass_series.successor_plan_id` | string \| null | The next season, offered to holders first when this one finishes. |
| `pass_series.presale_hours` | integer \| null | How long that offer is held for holders only. 1–8760. |
| `pass_series.windows[]` | array | The slate. Each entry names the **plan the window belongs to**; a series can mix several. |
| `pass_series.windows[].added_by_rule` | boolean | `true` when a rule absorbed it rather than the creator picking it by hand. |
| `pass_series.rules[]` | array | Automatic inclusion rules. `kind` is `date_range` or `next_n`; `take` is required on `next_n`. |
| `pass_series.blackout_window_ids` | array | Windows a rule matches but the creator has permanently excluded. |
- Requires ability: `project-subscription-plan:view`
- MCP tools: `get_plan`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `plan` | path | string (uuid) | yes | The plan, resolved within the project by the route binder. |
### Responses
- **200**: The plan.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The plan's id. |
| `data.project_id` | string | yes | The project the plan belongs to. |
| `data.kind` | string | yes | `subscription`, `pass` or `pass_series`. Names the one block that accompanies it; the other two are absent, not null. |
| `data.name` | string | yes | The name buyers see, unique within the project without regard to case or surrounding spaces. |
| `data.description` | string \| array of any \| null | yes | The pitch under the name, HTML filtered to a safe subset; null when none was written. |
| `data.price` | string | yes | What one purchase costs, as a decimal string in `currency`. On a pass that buys one window; on a series it buys the whole slate, once. `"0.00"` is a free plan. |
| `data.currency` | object | yes | The currency the price is charged in. |
| `data.currency.id` | string | yes | The currency's id, the value `currency_id` takes on a write. |
| `data.currency.iso` | string | yes | The ISO 4217 code, such as `USD`. |
| `data.currency.symbol` | string | yes | The symbol to print before the amount. |
| `data.active` | boolean | yes | Whether the plan is on sale. Flip it with the publish and unpublish endpoints, not with an update. |
| `data.sales_cap` | integer \| null | yes | How many purchases the plan takes before pausing itself; null for no limit. |
| `data.sales_cap_sold` | integer | yes | Purchases counted against the cap since it was last changed or the plan was last published. |
| `data.position` | integer \| null | yes | The plan's pinned place in the storefront order; null while it follows the built-in order (passes, then seasons, then subscriptions, cheapest first). |
| `data.paused` | object \| null | yes | Why the plan took itself off sale; null while it is not paused. |
| `data.paused.reason` | string | yes | `sales_cap_reached` when the cap filled, `seat_cap_reached` when a season filled its last seat. |
| `data.paused.at` | string \| null | yes | When it paused, ISO 8601. |
| `data.cadence` | string | yes | The duration in words the portal and the bot print, such as `Per Month`, `Per 3 Hours` or `For all 10 passes`. Use it rather than deriving one from the cycle fields. |
| `data.eligibility` | object | yes | Who may buy. The first three are mutually exclusive. |
| `data.eligibility.newcomers_only` | boolean | yes | Only members who never held a subscription on the project. |
| `data.eligibility.customers_only` | boolean | yes | Only members who currently hold one. |
| `data.eligibility.churned_only` | boolean | yes | Only members who held one and let it lapse. |
| `data.eligibility.single_use` | boolean | yes | Each member may buy the plan once. |
| `data.eligibility.access_codes_only` | boolean | yes | The plan cannot be bought; it is granted by redeeming an access code. |
| `data.resources` | array of object | no | The places the plan grants, present when the endpoint loaded them. On a series these are the lounge, open for the whole span, not the thing being sold. |
| `data.resources[].id` | string | yes | The resource's id. |
| `data.resources[].name` | string | yes | The resource's title. |
| `data.resources[].kind` | string | yes | `manual` for a perk the creator hands over themselves, or `:` for a place a connector hosts. |
| `data.resources[].connector` | string \| null | yes | The connector that hosts the place; null for a manual resource. |
| `data.billing` | object | no | Cycle, trial and renewal. Present only on `kind: subscription`. |
| `data.billing.billing_cycle` | string \| `day`, `week`, `month`, `year`, `lifetime` | yes | `day`, `week`, `month`, `year` or `lifetime`. |
| `data.billing.billing_cycle_count` | integer | yes | How many cycles one period spans, 1 to 99; always 1 when the cycle is `lifetime`. |
| `data.billing.recurring` | boolean | yes | Whether the subscription renews itself. Never true for a crypto or platform currency. |
| `data.billing.disabled_renewal` | boolean | yes | Charges once, then lapses at the end of the period instead of renewing. |
| `data.billing.trial_days` | integer | yes | Free days before the first charge, 0 to 365. |
| `data.billing.trial_cardless` | boolean | yes | Whether the trial starts without a payment method on file. |
| `data.billing.trial_type` | string \| `project`, `plan` | yes | Whose trial rule applies: `project` for the project's trial settings, `plan` for this plan's own. |
| `data.pass` | object | no | The plan's own dated schedule. Present only on `kind: pass`. |
| `data.pass.timezone` | string \| null | yes | The IANA zone the schedule was authored in. Render window times in it, never in UTC. |
| `data.pass.schedule_mode` | string \| null | yes | `repeating` for windows generated from `slots`, `fixed` for explicitly dated windows. |
| `data.pass.recurrence` | string \| null | yes | `daily`, `weekly` or `monthly`; null in `fixed` mode. |
| `data.pass.recurrence_ends_at` | string \| null | yes | When window generation stops, ISO 8601; null for no end. |
| `data.pass.sales_cutoff_minutes` | integer \| null | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names; null to sell until the window begins. |
| `data.pass.sales_cutoff_anchor` | string | yes | `before_start` or `before_end`. Never null: a plan that never set one reads as `before_start`. |
| `data.pass.slots` | array of object | no | The repeating schedule, present when the endpoint loaded it. Each slot carries its own length, so one plan can mix a 3-hour and a 14-hour window. |
| `data.pass.slots[].weekday` | integer \| null | yes | Day of the week, 0 (Sunday) to 6, on a weekly recurrence; null otherwise. |
| `data.pass.slots[].day_of_month` | integer \| null | yes | Day of the month, 1 to 31, on a monthly recurrence; null otherwise. 29 to 31 skip the months that lack the day. |
| `data.pass.slots[].start_time` | string | yes | Local wall-clock start in `timezone`, `HH:MM`. |
| `data.pass.slots[].duration_minutes` | integer | yes | How long the window stays open. |
| `data.pass.upcoming_windows` | array of object | no | The next windows, present only where the endpoint loaded them. Timestamps are UTC. |
| `data.pass.upcoming_windows[].id` | string | yes | The window's id, the value a series names in `pass_series.window_ids`. |
| `data.pass.upcoming_windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass.upcoming_windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass.upcoming_windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series` | object | no | The season ticket's slate, the rules that grow it and its seats. Present only on `kind: pass_series`. |
| `data.pass_series.timezone` | string | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | integer \| null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | integer \| null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | string \| null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | string \| null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | string \| null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | integer \| null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of object | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.windows[].id` | string | yes | The window's id. |
| `data.pass_series.windows[].plan_id` | string | yes | The pass plan the window belongs to. |
| `data.pass_series.windows[].plan_name` | string | yes | That plan's name. |
| `data.pass_series.windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass_series.windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass_series.windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series.windows[].added_by_rule` | boolean | yes | True when a rule absorbed it rather than the creator picking it by hand. |
| `data.pass_series.rules` | array of object | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.rules[].source_plan_id` | string | yes | The pass plan the rule draws windows from. |
| `data.pass_series.rules[].kind` | string | yes | `date_range` takes every window between `from_at` and `to_at`; `next_n` takes the next `take` windows. |
| `data.pass_series.rules[].from_at` | string \| null | yes | The earliest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].to_at` | string \| null | yes | The latest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].take` | integer \| null | yes | How many windows a `next_n` rule takes; null on a `date_range` rule. |
| `data.pass_series.blackout_window_ids` | object | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.pass_series.timezone` | null | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of string | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.rules` | array of string | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.blackout_window_ids` | array of string | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.created_at` | string \| null | yes | When the plan was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Update a plan
`PATCH /v1/projects/{project}/plans/{plan}`
The same discriminated shape as create, with every field optional. Omit `kind` to keep the plan's current kind; a block you send must match it. Where a cross-field rule needs a value you did not send, the billing cycle behind a lifetime count check, the currency behind the price floor, the eligibility flags behind the exclusivity check, the plan's stored value stands in, so a partial update is validated against the plan it will actually produce.
- `resources` is optional; omitting it preserves the existing links, sending it replaces them.
- Every update of a plan on sale queues its push to the payment methods that keep a product catalogue, so a changed price or name reaches Stripe, PayPal, CoinPayments and Razorpay within a minute.
- `sales_cap` may be set, raised, lowered or cleared with `null`; any change restarts `sales_cap_sold` at `0`.
- `pass.slots` **replaces** the whole schedule and rebuilds unsold future windows; `pass.windows` **adds** dated windows and never replaces.
- Setting an active plan's `price` to `0` on a Free account is refused; editing a zero-priced plan that is **off sale** is allowed on any account.
- `sales_cap_sold`, `position` and `paused` are read-only.
Answers `200` with the whole plan after the change.
- Requires ability: `project-subscription-plan:update`
- Fires events: `plan.updated`
- MCP tools: `update_plan`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `plan` | path | string (uuid) | yes | The plan, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
The changes to a plan: any subset of the create shape. A block must match the plan's kind, and a field left out keeps its stored value.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | `subscription`, `pass`, `pass_series` | no | `subscription`, `pass` or `pass_series`; names the one block the payload carries. Required on create. Omit on an update to keep the plan's current kind. \| \| \|---\| \| `subscription`
Access starts at payment and runs on a billing cycle. \| \| `pass`
One purchase buys one of the plan's own scheduled access windows. \| \| `pass_series`
One purchase buys a curated slate of *other* plans' access windows. \| |
| `name` | string | no | The name buyers see, 5 to 255 characters, unique within the project without regard to case or surrounding spaces. Required on create. |
| `description` | string \| null | no | The pitch under the name, up to 1,000 characters. HTML is filtered to a safe subset before it is stored. |
| `currency_id` | string | no | The id of a currency that at least one of the project's active payment methods can charge. Required on create. |
| `price` | number | no | What one purchase costs: at least the $1.00 USD equivalent in `currency_id` at the current rate, or exactly `0` for a free plan, which only a Starter or Growth account may put on sale. Required on create. |
| `active` | boolean | no | Whether the plan is on sale. Defaults to true on create; prefer the publish and unpublish endpoints to change it later. |
| `sales_cap` | integer \| null | no | Pause the plan after this many purchases, 1 to 100,000; null for no limit. Changing it restarts `sales_cap_sold` at 0. |
| `eligibility` | object | no | Who may buy. At most one of the first three may be true, counting what the plan already holds on an update. |
| `eligibility.newcomers_only` | boolean | no | Only members who never held a subscription on the project. |
| `eligibility.customers_only` | boolean | no | Only members who currently hold one. |
| `eligibility.churned_only` | boolean | no | Only members who held one and let it lapse. |
| `eligibility.single_use` | boolean | no | Each member may buy the plan once. |
| `eligibility.access_codes_only` | boolean | no | The plan cannot be bought, only granted by redeeming an access code. |
| `resources` | array of string | no | Ids of the project's resources the plan grants: at least one, required on create, except on `kind: pass_series`, where it is optional and names a lounge open for the whole span. Omit on an update to keep the existing links. |
| `billing` | object | no | Cycle, trial and renewal. Send it on `kind: subscription` only; on any other kind it is refused. |
| `billing.billing_cycle` | `day`, `week`, `month`, `year`, `lifetime` | no | `day`, `week`, `month`, `year` or `lifetime`. Required on create. |
| `billing.billing_cycle_count` | integer | no | How many cycles one period spans, 1 to 99; must be 1 when the cycle is `lifetime`. Required on create. |
| `billing.recurring` | boolean | no | Whether the subscription renews itself. Refused as true when the currency is a crypto or platform currency. |
| `billing.disabled_renewal` | boolean | no | Charge once, then lapse at the end of the period instead of renewing. |
| `billing.trial_days` | integer | no | Free days before the first charge, 0 to 365. |
| `billing.trial_cardless` | boolean | no | Whether the trial starts without a payment method on file. |
| `billing.trial_type` | `project`, `plan` | no | Whose trial rule applies: `project` uses the project's trial settings, `plan` this plan's own. |
| `pass` | object | no | The plan's own dated schedule. Send it on `kind: pass` only; on any other kind it is refused. |
| `pass.timezone` | `GMT`, `UTC`, `Africa/Abidjan`, `Africa/Accra`, `Africa/Bamako`, `Africa/Banjul`, `Africa/Bissau`, `Africa/Casablanca`, `Africa/Conakry`, `Africa/Dakar`, `Africa/El_Aaiun`, `Africa/Freetown`, `Africa/Lome`, `Africa/Monrovia`, `Africa/Nouakchott`, `Africa/Ouagadougou`, `Africa/Sao_Tome`, `Africa/Algiers`, `Africa/Bangui`, `Africa/Brazzaville`, `Africa/Douala`, `Africa/Kinshasa`, `Africa/Lagos`, `Africa/Libreville`, `Africa/Luanda`, `Africa/Malabo`, `Africa/Ndjamena`, `Africa/Niamey`, `Africa/Porto-Novo`, `Africa/Tunis`, `Africa/Blantyre`, `Africa/Bujumbura`, `Africa/Ceuta`, `Africa/Gaborone`, `Africa/Harare`, `Africa/Johannesburg`, `Africa/Juba`, `Africa/Khartoum`, `Africa/Kigali`, `Africa/Lubumbashi`, `Africa/Lusaka`, `Africa/Maputo`, `Africa/Maseru`, `Africa/Mbabane`, `Africa/Tripoli`, `Africa/Windhoek`, `Africa/Addis_Ababa`, `Africa/Asmara`, `Africa/Cairo`, `Africa/Dar_es_Salaam`, `Africa/Djibouti`, `Africa/Kampala`, `Africa/Mogadishu`, `Africa/Nairobi`, `America/Adak`, `America/Anchorage`, `America/Juneau`, `America/Metlakatla`, `America/Nome`, `America/Sitka`, `America/Yakutat`, `America/Creston`, `America/Dawson`, `America/Dawson_Creek`, `America/Fort_Nelson`, `America/Hermosillo`, `America/Los_Angeles`, `America/Mazatlan`, `America/Phoenix`, `America/Tijuana`, `America/Vancouver`, `America/Whitehorse`, `America/Bahia_Banderas`, `America/Belize`, `America/Boise`, `America/Cambridge_Bay`, `America/Chihuahua`, `America/Ciudad_Juarez`, `America/Costa_Rica`, `America/Denver`, `America/Edmonton`, `America/El_Salvador`, `America/Guatemala`, `America/Inuvik`, `America/Managua`, `America/Merida`, `America/Mexico_City`, `America/Monterrey`, `America/Regina`, `America/Swift_Current`, `America/Tegucigalpa`, `America/Atikokan`, `America/Bogota`, `America/Cancun`, `America/Cayman`, `America/Chicago`, `America/Eirunepe`, `America/Guayaquil`, `America/Indiana/Knox`, `America/Indiana/Tell_City`, `America/Jamaica`, `America/Lima`, `America/Matamoros`, `America/Menominee`, `America/North_Dakota/Beulah`, `America/North_Dakota/Center`, `America/North_Dakota/New_Salem`, `America/Ojinaga`, `America/Panama`, `America/Rankin_Inlet`, `America/Resolute`, `America/Rio_Branco`, `America/Winnipeg`, `America/Anguilla`, `America/Antigua`, `America/Aruba`, `America/Barbados`, `America/Blanc-Sablon`, `America/Boa_Vista`, `America/Campo_Grande`, `America/Caracas`, `America/Cuiaba`, `America/Curacao`, `America/Detroit`, `America/Dominica`, `America/Grand_Turk`, `America/Grenada`, `America/Guadeloupe`, `America/Guyana`, `America/Havana`, `America/Indiana/Indianapolis`, `America/Indiana/Marengo`, `America/Indiana/Petersburg`, `America/Indiana/Vevay`, `America/Indiana/Vincennes`, `America/Indiana/Winamac`, `America/Iqaluit`, `America/Kentucky/Louisville`, `America/Kentucky/Monticello`, `America/Kralendijk`, `America/La_Paz`, `America/Lower_Princes`, `America/Manaus`, `America/Marigot`, `America/Martinique`, `America/Montserrat`, `America/Nassau`, `America/New_York`, `America/Port_of_Spain`, `America/Port-au-Prince`, `America/Porto_Velho`, `America/Puerto_Rico`, `America/Santo_Domingo`, `America/St_Barthelemy`, `America/St_Kitts`, `America/St_Lucia`, `America/St_Thomas`, `America/St_Vincent`, `America/Toronto`, `America/Tortola`, `America/Araguaina`, `America/Argentina/Buenos_Aires`, `America/Argentina/Catamarca`, `America/Argentina/Cordoba`, `America/Argentina/Jujuy`, `America/Argentina/La_Rioja`, `America/Argentina/Mendoza`, `America/Argentina/Rio_Gallegos`, `America/Argentina/Salta`, `America/Argentina/San_Juan`, `America/Argentina/San_Luis`, `America/Argentina/Tucuman`, `America/Argentina/Ushuaia`, `America/Asuncion`, `America/Bahia`, `America/Belem`, `America/Cayenne`, `America/Coyhaique`, `America/Fortaleza`, `America/Glace_Bay`, `America/Goose_Bay`, `America/Halifax`, `America/Maceio`, `America/Moncton`, `America/Montevideo`, `America/Paramaribo`, `America/Punta_Arenas`, `America/Recife`, `America/Santarem`, `America/Santiago`, `America/Sao_Paulo`, `America/Thule`, `America/St_Johns`, `America/Miquelon`, `America/Noronha`, `America/Nuuk`, `America/Scoresbysund`, `America/Danmarkshavn`, `Antarctica/Palmer`, `Antarctica/Rothera`, `Antarctica/Troll`, `Antarctica/Syowa`, `Antarctica/Mawson`, `Antarctica/Vostok`, `Antarctica/Davis`, `Antarctica/Casey`, `Antarctica/DumontDUrville`, `Antarctica/Macquarie`, `Antarctica/McMurdo`, `Arctic/Longyearbyen`, `Asia/Aden`, `Asia/Amman`, `Asia/Baghdad`, `Asia/Bahrain`, `Asia/Beirut`, `Asia/Damascus`, `Asia/Famagusta`, `Asia/Gaza`, `Asia/Hebron`, `Asia/Jerusalem`, `Asia/Kuwait`, `Asia/Nicosia`, `Asia/Qatar`, `Asia/Riyadh`, `Asia/Tehran`, `Asia/Baku`, `Asia/Dubai`, `Asia/Muscat`, `Asia/Tbilisi`, `Asia/Yerevan`, `Asia/Kabul`, `Asia/Almaty`, `Asia/Aqtau`, `Asia/Aqtobe`, `Asia/Ashgabat`, `Asia/Atyrau`, `Asia/Dushanbe`, `Asia/Karachi`, `Asia/Oral`, `Asia/Qostanay`, `Asia/Qyzylorda`, `Asia/Samarkand`, `Asia/Tashkent`, `Asia/Yekaterinburg`, `Asia/Colombo`, `Asia/Kolkata`, `Asia/Kathmandu`, `Asia/Bishkek`, `Asia/Dhaka`, `Asia/Omsk`, `Asia/Thimphu`, `Asia/Urumqi`, `Asia/Yangon`, `Asia/Bangkok`, `Asia/Barnaul`, `Asia/Ho_Chi_Minh`, `Asia/Hovd`, `Asia/Jakarta`, `Asia/Krasnoyarsk`, `Asia/Novokuznetsk`, `Asia/Novosibirsk`, `Asia/Phnom_Penh`, `Asia/Pontianak`, `Asia/Tomsk`, `Asia/Vientiane`, `Asia/Brunei`, `Asia/Hong_Kong`, `Asia/Irkutsk`, `Asia/Kuala_Lumpur`, `Asia/Kuching`, `Asia/Macau`, `Asia/Makassar`, `Asia/Manila`, `Asia/Shanghai`, `Asia/Singapore`, `Asia/Taipei`, `Asia/Ulaanbaatar`, `Asia/Chita`, `Asia/Dili`, `Asia/Jayapura`, `Asia/Khandyga`, `Asia/Pyongyang`, `Asia/Seoul`, `Asia/Tokyo`, `Asia/Yakutsk`, `Asia/Ust-Nera`, `Asia/Vladivostok`, `Asia/Magadan`, `Asia/Sakhalin`, `Asia/Srednekolymsk`, `Asia/Anadyr`, `Asia/Kamchatka`, `Atlantic/Bermuda`, `Atlantic/Stanley`, `Atlantic/South_Georgia`, `Atlantic/Cape_Verde`, `Atlantic/Azores`, `Atlantic/Reykjavik`, `Atlantic/St_Helena`, `Atlantic/Canary`, `Atlantic/Faroe`, `Atlantic/Madeira`, `Australia/Perth`, `Australia/Eucla`, `Australia/Adelaide`, `Australia/Broken_Hill`, `Australia/Darwin`, `Australia/Brisbane`, `Australia/Hobart`, `Australia/Lindeman`, `Australia/Melbourne`, `Australia/Sydney`, `Australia/Lord_Howe`, `Europe/Dublin`, `Europe/Guernsey`, `Europe/Isle_of_Man`, `Europe/Jersey`, `Europe/Lisbon`, `Europe/London`, `Europe/Amsterdam`, `Europe/Andorra`, `Europe/Belgrade`, `Europe/Berlin`, `Europe/Bratislava`, `Europe/Brussels`, `Europe/Budapest`, `Europe/Busingen`, `Europe/Copenhagen`, `Europe/Gibraltar`, `Europe/Kaliningrad`, `Europe/Ljubljana`, `Europe/Luxembourg`, `Europe/Madrid`, `Europe/Malta`, `Europe/Monaco`, `Europe/Oslo`, `Europe/Paris`, `Europe/Podgorica`, `Europe/Prague`, `Europe/Rome`, `Europe/San_Marino`, `Europe/Sarajevo`, `Europe/Skopje`, `Europe/Stockholm`, `Europe/Tirane`, `Europe/Vaduz`, `Europe/Vatican`, `Europe/Vienna`, `Europe/Warsaw`, `Europe/Zagreb`, `Europe/Zurich`, `Europe/Athens`, `Europe/Bucharest`, `Europe/Chisinau`, `Europe/Helsinki`, `Europe/Istanbul`, `Europe/Kirov`, `Europe/Kyiv`, `Europe/Mariehamn`, `Europe/Minsk`, `Europe/Moscow`, `Europe/Riga`, `Europe/Simferopol`, `Europe/Sofia`, `Europe/Tallinn`, `Europe/Vilnius`, `Europe/Volgograd`, `Europe/Astrakhan`, `Europe/Samara`, `Europe/Saratov`, `Europe/Ulyanovsk`, `Indian/Antananarivo`, `Indian/Comoro`, `Indian/Mayotte`, `Indian/Mahe`, `Indian/Mauritius`, `Indian/Reunion`, `Indian/Kerguelen`, `Indian/Maldives`, `Indian/Chagos`, `Indian/Cocos`, `Indian/Christmas`, `Pacific/Midway`, `Pacific/Niue`, `Pacific/Pago_Pago`, `Pacific/Honolulu`, `Pacific/Rarotonga`, `Pacific/Tahiti`, `Pacific/Marquesas`, `Pacific/Gambier`, `Pacific/Pitcairn`, `Pacific/Galapagos`, `Pacific/Easter`, `Pacific/Palau`, `Pacific/Chuuk`, `Pacific/Guam`, `Pacific/Port_Moresby`, `Pacific/Saipan`, `Pacific/Bougainville`, `Pacific/Efate`, `Pacific/Guadalcanal`, `Pacific/Kosrae`, `Pacific/Norfolk`, `Pacific/Noumea`, `Pacific/Pohnpei`, `Pacific/Auckland`, `Pacific/Fiji`, `Pacific/Funafuti`, `Pacific/Kwajalein`, `Pacific/Majuro`, `Pacific/Nauru`, `Pacific/Tarawa`, `Pacific/Wake`, `Pacific/Wallis`, `Pacific/Chatham`, `Pacific/Apia`, `Pacific/Fakaofo`, `Pacific/Kanton`, `Pacific/Tongatapu`, `Pacific/Kiritimati`, null | no | The IANA zone the schedule is authored in. Legacy names are resolved, so `Asia/Calcutta` is stored as `Asia/Kolkata`. Required on create. |
| `pass.schedule_mode` | `fixed`, `repeating` \| null | no | `repeating` (the default) generates windows from `slots`; `fixed` takes explicitly dated `windows`. |
| `pass.recurrence` | `daily`, `weekly`, `monthly` \| null | no | `daily`, `weekly` or `monthly`. Decides which slot fields apply. |
| `pass.recurrence_ends_at` | string \| null (date-time) | no | When window generation stops; omit to keep generating. |
| `pass.sales_cutoff_minutes` | integer \| null | no | Stop sales this many minutes before the moment `sales_cutoff_anchor` names; omit to sell until the window begins. With `before_end` it must be at least 5 and under the shortest slot's `duration_minutes`. |
| `pass.sales_cutoff_anchor` | `before_start`, `before_end`, null | no | `before_start` (the default) or `before_end`. `before_end` is refused when the shortest slot lasts 5 minutes or less. The series anchors `before_first_end` and `before_last_start` are refused on a pass. |
| `pass.slots` | array of object | no | The repeating schedule, at most 60 slots. Sending it replaces the whole schedule and rebuilds unsold future windows; windows a customer has bought keep their times. |
| `pass.slots[].weekday` | integer \| null | no | Day of the week, 0 (Sunday) to 6, on a weekly recurrence. |
| `pass.slots[].day_of_month` | integer \| null | no | Day of the month, 1 to 31, on a monthly recurrence; 29 to 31 skip the months that lack the day. |
| `pass.slots[].start_time` | string | no | Local wall-clock start in `pass.timezone`, `HH:MM`. |
| `pass.slots[].duration_minutes` | integer | no | How long the window stays open. |
| `pass.windows` | array of object | no | Explicitly dated windows for `schedule_mode: fixed`, at most 200 per call. Added to the schedule, never replacing it. |
| `pass.windows[].starts_at` | string (date-time) | no | Local wall-clock start in `pass.timezone`. |
| `pass.windows[].duration_minutes` | integer | no | How long the window stays open. |
| `pass_series` | object | no | The season ticket's slate, rules and seats. Send it on `kind: pass_series` only; on any other kind it is refused. |
| `pass_series.prevent_overlaps` | boolean | no | Refuse a window that clashes with one already on the slate. Defaults to true. |
| `pass_series.sales_cutoff_minutes` | integer \| null | no | Stop sales this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `pass_series.sales_cutoff_anchor` | `before_start`, `before_first_end`, `before_last_start`, `before_end` \| null | no | `before_start` (the default), `before_first_end`, `before_last_start` or `before_end`. `before_first_end` needs `sales_cutoff_minutes` of at least 5, so a buyer arriving late in the opening window is still admitted to it. |
| `pass_series.seat_cap` | integer \| null | no | How many holders may hold the season at once, at least 1; null for unlimited. Zero is refused: take the plan off sale with unpublish instead. |
| `pass_series.presale_hours` | integer \| null | no | How long the next season is offered to current holders only, 1 to 8760. |
| `pass_series.successor_plan_id` | string \| null | no | Another `kind: pass_series` plan on the same project to offer holders when this one finishes. A series cannot lead to itself. |
| `pass_series.window_ids` | array of string | no | Ids of pass windows on this project to put on the slate, at most 120. A slate needs at least two unless a rule will supply them; with `prevent_overlaps` on, two that run at the same time are refused. |
| `pass_series.blackout_window_ids` | array of string | no | Windows a rule matches but you want permanently excluded. |
| `pass_series.rules` | array of object | no | Rules that keep absorbing matching windows into the slate, at most 20. A matching window scheduled later is added and granted to every current holder at no charge. |
| `pass_series.rules[].source_plan_id` | string | no | A `kind: pass` plan on this project to draw windows from. |
| `pass_series.rules[].kind` | `date_range`, `next_n` | no | `date_range` takes every window between `from_at` and `to_at`; `next_n` takes the next `take` windows. \| \| \|---\| \| `date_range`
Every window of the source plan that starts between two dates. \| \| `next_n`
The next N windows of the source plan, whenever they fall. \| |
| `pass_series.rules[].from_at` | string \| null (date-time) | no | The earliest window start a `date_range` rule takes. |
| `pass_series.rules[].to_at` | string \| null (date-time) | no | The latest window start a `date_range` rule takes. |
| `pass_series.rules[].take` | integer \| null | no | How many windows a `next_n` rule takes, 1 to 120; required when `kind` is `next_n`. |
### Responses
- **200**: The plan after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The plan's id. |
| `data.project_id` | string | yes | The project the plan belongs to. |
| `data.kind` | string | yes | `subscription`, `pass` or `pass_series`. Names the one block that accompanies it; the other two are absent, not null. |
| `data.name` | string | yes | The name buyers see, unique within the project without regard to case or surrounding spaces. |
| `data.description` | string \| array of any \| null | yes | The pitch under the name, HTML filtered to a safe subset; null when none was written. |
| `data.price` | string | yes | What one purchase costs, as a decimal string in `currency`. On a pass that buys one window; on a series it buys the whole slate, once. `"0.00"` is a free plan. |
| `data.currency` | object | yes | The currency the price is charged in. |
| `data.currency.id` | string | yes | The currency's id, the value `currency_id` takes on a write. |
| `data.currency.iso` | string | yes | The ISO 4217 code, such as `USD`. |
| `data.currency.symbol` | string | yes | The symbol to print before the amount. |
| `data.active` | boolean | yes | Whether the plan is on sale. Flip it with the publish and unpublish endpoints, not with an update. |
| `data.sales_cap` | integer \| null | yes | How many purchases the plan takes before pausing itself; null for no limit. |
| `data.sales_cap_sold` | integer | yes | Purchases counted against the cap since it was last changed or the plan was last published. |
| `data.position` | integer \| null | yes | The plan's pinned place in the storefront order; null while it follows the built-in order (passes, then seasons, then subscriptions, cheapest first). |
| `data.paused` | object \| null | yes | Why the plan took itself off sale; null while it is not paused. |
| `data.paused.reason` | string | yes | `sales_cap_reached` when the cap filled, `seat_cap_reached` when a season filled its last seat. |
| `data.paused.at` | string \| null | yes | When it paused, ISO 8601. |
| `data.cadence` | string | yes | The duration in words the portal and the bot print, such as `Per Month`, `Per 3 Hours` or `For all 10 passes`. Use it rather than deriving one from the cycle fields. |
| `data.eligibility` | object | yes | Who may buy. The first three are mutually exclusive. |
| `data.eligibility.newcomers_only` | boolean | yes | Only members who never held a subscription on the project. |
| `data.eligibility.customers_only` | boolean | yes | Only members who currently hold one. |
| `data.eligibility.churned_only` | boolean | yes | Only members who held one and let it lapse. |
| `data.eligibility.single_use` | boolean | yes | Each member may buy the plan once. |
| `data.eligibility.access_codes_only` | boolean | yes | The plan cannot be bought; it is granted by redeeming an access code. |
| `data.resources` | array of object | no | The places the plan grants, present when the endpoint loaded them. On a series these are the lounge, open for the whole span, not the thing being sold. |
| `data.resources[].id` | string | yes | The resource's id. |
| `data.resources[].name` | string | yes | The resource's title. |
| `data.resources[].kind` | string | yes | `manual` for a perk the creator hands over themselves, or `:` for a place a connector hosts. |
| `data.resources[].connector` | string \| null | yes | The connector that hosts the place; null for a manual resource. |
| `data.billing` | object | no | Cycle, trial and renewal. Present only on `kind: subscription`. |
| `data.billing.billing_cycle` | string \| `day`, `week`, `month`, `year`, `lifetime` | yes | `day`, `week`, `month`, `year` or `lifetime`. |
| `data.billing.billing_cycle_count` | integer | yes | How many cycles one period spans, 1 to 99; always 1 when the cycle is `lifetime`. |
| `data.billing.recurring` | boolean | yes | Whether the subscription renews itself. Never true for a crypto or platform currency. |
| `data.billing.disabled_renewal` | boolean | yes | Charges once, then lapses at the end of the period instead of renewing. |
| `data.billing.trial_days` | integer | yes | Free days before the first charge, 0 to 365. |
| `data.billing.trial_cardless` | boolean | yes | Whether the trial starts without a payment method on file. |
| `data.billing.trial_type` | string \| `project`, `plan` | yes | Whose trial rule applies: `project` for the project's trial settings, `plan` for this plan's own. |
| `data.pass` | object | no | The plan's own dated schedule. Present only on `kind: pass`. |
| `data.pass.timezone` | string \| null | yes | The IANA zone the schedule was authored in. Render window times in it, never in UTC. |
| `data.pass.schedule_mode` | string \| null | yes | `repeating` for windows generated from `slots`, `fixed` for explicitly dated windows. |
| `data.pass.recurrence` | string \| null | yes | `daily`, `weekly` or `monthly`; null in `fixed` mode. |
| `data.pass.recurrence_ends_at` | string \| null | yes | When window generation stops, ISO 8601; null for no end. |
| `data.pass.sales_cutoff_minutes` | integer \| null | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names; null to sell until the window begins. |
| `data.pass.sales_cutoff_anchor` | string | yes | `before_start` or `before_end`. Never null: a plan that never set one reads as `before_start`. |
| `data.pass.slots` | array of object | no | The repeating schedule, present when the endpoint loaded it. Each slot carries its own length, so one plan can mix a 3-hour and a 14-hour window. |
| `data.pass.slots[].weekday` | integer \| null | yes | Day of the week, 0 (Sunday) to 6, on a weekly recurrence; null otherwise. |
| `data.pass.slots[].day_of_month` | integer \| null | yes | Day of the month, 1 to 31, on a monthly recurrence; null otherwise. 29 to 31 skip the months that lack the day. |
| `data.pass.slots[].start_time` | string | yes | Local wall-clock start in `timezone`, `HH:MM`. |
| `data.pass.slots[].duration_minutes` | integer | yes | How long the window stays open. |
| `data.pass.upcoming_windows` | array of object | no | The next windows, present only where the endpoint loaded them. Timestamps are UTC. |
| `data.pass.upcoming_windows[].id` | string | yes | The window's id, the value a series names in `pass_series.window_ids`. |
| `data.pass.upcoming_windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass.upcoming_windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass.upcoming_windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series` | object | no | The season ticket's slate, the rules that grow it and its seats. Present only on `kind: pass_series`. |
| `data.pass_series.timezone` | string | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | integer \| null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | integer \| null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | string \| null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | string \| null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | string \| null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | integer \| null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of object | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.windows[].id` | string | yes | The window's id. |
| `data.pass_series.windows[].plan_id` | string | yes | The pass plan the window belongs to. |
| `data.pass_series.windows[].plan_name` | string | yes | That plan's name. |
| `data.pass_series.windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass_series.windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass_series.windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series.windows[].added_by_rule` | boolean | yes | True when a rule absorbed it rather than the creator picking it by hand. |
| `data.pass_series.rules` | array of object | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.rules[].source_plan_id` | string | yes | The pass plan the rule draws windows from. |
| `data.pass_series.rules[].kind` | string | yes | `date_range` takes every window between `from_at` and `to_at`; `next_n` takes the next `take` windows. |
| `data.pass_series.rules[].from_at` | string \| null | yes | The earliest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].to_at` | string \| null | yes | The latest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].take` | integer \| null | yes | How many windows a `next_n` rule takes; null on a `date_range` rule. |
| `data.pass_series.blackout_window_ids` | object | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.pass_series.timezone` | null | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of string | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.rules` | array of string | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.blackout_window_ids` | array of string | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.created_at` | string \| null | yes | When the plan was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when the change needs an entitlement the project owner's account lacks, such as passes on a plan without them.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when a block does not match the plan's kind, `name` is taken by another live plan, `price` is below the minimum or is `0` on an active plan of a Free account, two eligibility flags end up set, or a per-kind rule is broken; `error.fields` names the key.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Delete a plan
`DELETE /v1/projects/{project}/plans/{plan}`
Removes the plan and emits `plan.deleted` with a snapshot taken before the row disappears. Members who already hold it keep their access until it runs out; to stop selling while keeping the plan on the books, unpublish it instead. A pass or series plan is refused while a customer still holds an unfinished window on it, because deleting it would cascade away access that was paid for.
- Requires ability: `project-subscription-plan:delete`
- Fires events: `plan.deleted`
- MCP tools: `delete_plan`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `plan` | path | string (uuid) | yes | The plan, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
- **500**: `INTERNAL_SERVER_ERROR`: when a customer still holds an unfinished window on the plan; `error.message` says so. Cancel or wait out the windows first.
## Publish a plan
`POST /v1/projects/{project}/plans/{plan}/publish`
Puts the plan on sale: `active` becomes `true`, `sales_cap_sold` restarts at `0`, and `plan.activated` fires. Publishing also queues the push of the plan to the payment methods that keep a product catalogue (Stripe, PayPal, CoinPayments, Razorpay), the same push a create or update of a plan on sale queues, so a plan created as a draft is sellable through them within a minute of going on sale. A plan that is already on sale is answered as it is and emits nothing. Answers `200` with the plan.
- Requires ability: `project-subscription-plan:update`
- Fires events: `plan.activated`
- MCP tools: `publish_plan`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `plan` | path | string (uuid) | yes | The plan, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The plan, active.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The plan's id. |
| `data.project_id` | string | yes | The project the plan belongs to. |
| `data.kind` | string | yes | `subscription`, `pass` or `pass_series`. Names the one block that accompanies it; the other two are absent, not null. |
| `data.name` | string | yes | The name buyers see, unique within the project without regard to case or surrounding spaces. |
| `data.description` | string \| array of any \| null | yes | The pitch under the name, HTML filtered to a safe subset; null when none was written. |
| `data.price` | string | yes | What one purchase costs, as a decimal string in `currency`. On a pass that buys one window; on a series it buys the whole slate, once. `"0.00"` is a free plan. |
| `data.currency` | object | yes | The currency the price is charged in. |
| `data.currency.id` | string | yes | The currency's id, the value `currency_id` takes on a write. |
| `data.currency.iso` | string | yes | The ISO 4217 code, such as `USD`. |
| `data.currency.symbol` | string | yes | The symbol to print before the amount. |
| `data.active` | boolean | yes | Whether the plan is on sale. Flip it with the publish and unpublish endpoints, not with an update. |
| `data.sales_cap` | integer \| null | yes | How many purchases the plan takes before pausing itself; null for no limit. |
| `data.sales_cap_sold` | integer | yes | Purchases counted against the cap since it was last changed or the plan was last published. |
| `data.position` | integer \| null | yes | The plan's pinned place in the storefront order; null while it follows the built-in order (passes, then seasons, then subscriptions, cheapest first). |
| `data.paused` | object \| null | yes | Why the plan took itself off sale; null while it is not paused. |
| `data.paused.reason` | string | yes | `sales_cap_reached` when the cap filled, `seat_cap_reached` when a season filled its last seat. |
| `data.paused.at` | string \| null | yes | When it paused, ISO 8601. |
| `data.cadence` | string | yes | The duration in words the portal and the bot print, such as `Per Month`, `Per 3 Hours` or `For all 10 passes`. Use it rather than deriving one from the cycle fields. |
| `data.eligibility` | object | yes | Who may buy. The first three are mutually exclusive. |
| `data.eligibility.newcomers_only` | boolean | yes | Only members who never held a subscription on the project. |
| `data.eligibility.customers_only` | boolean | yes | Only members who currently hold one. |
| `data.eligibility.churned_only` | boolean | yes | Only members who held one and let it lapse. |
| `data.eligibility.single_use` | boolean | yes | Each member may buy the plan once. |
| `data.eligibility.access_codes_only` | boolean | yes | The plan cannot be bought; it is granted by redeeming an access code. |
| `data.resources` | array of object | no | The places the plan grants, present when the endpoint loaded them. On a series these are the lounge, open for the whole span, not the thing being sold. |
| `data.resources[].id` | string | yes | The resource's id. |
| `data.resources[].name` | string | yes | The resource's title. |
| `data.resources[].kind` | string | yes | `manual` for a perk the creator hands over themselves, or `:` for a place a connector hosts. |
| `data.resources[].connector` | string \| null | yes | The connector that hosts the place; null for a manual resource. |
| `data.billing` | object | no | Cycle, trial and renewal. Present only on `kind: subscription`. |
| `data.billing.billing_cycle` | string \| `day`, `week`, `month`, `year`, `lifetime` | yes | `day`, `week`, `month`, `year` or `lifetime`. |
| `data.billing.billing_cycle_count` | integer | yes | How many cycles one period spans, 1 to 99; always 1 when the cycle is `lifetime`. |
| `data.billing.recurring` | boolean | yes | Whether the subscription renews itself. Never true for a crypto or platform currency. |
| `data.billing.disabled_renewal` | boolean | yes | Charges once, then lapses at the end of the period instead of renewing. |
| `data.billing.trial_days` | integer | yes | Free days before the first charge, 0 to 365. |
| `data.billing.trial_cardless` | boolean | yes | Whether the trial starts without a payment method on file. |
| `data.billing.trial_type` | string \| `project`, `plan` | yes | Whose trial rule applies: `project` for the project's trial settings, `plan` for this plan's own. |
| `data.pass` | object | no | The plan's own dated schedule. Present only on `kind: pass`. |
| `data.pass.timezone` | string \| null | yes | The IANA zone the schedule was authored in. Render window times in it, never in UTC. |
| `data.pass.schedule_mode` | string \| null | yes | `repeating` for windows generated from `slots`, `fixed` for explicitly dated windows. |
| `data.pass.recurrence` | string \| null | yes | `daily`, `weekly` or `monthly`; null in `fixed` mode. |
| `data.pass.recurrence_ends_at` | string \| null | yes | When window generation stops, ISO 8601; null for no end. |
| `data.pass.sales_cutoff_minutes` | integer \| null | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names; null to sell until the window begins. |
| `data.pass.sales_cutoff_anchor` | string | yes | `before_start` or `before_end`. Never null: a plan that never set one reads as `before_start`. |
| `data.pass.slots` | array of object | no | The repeating schedule, present when the endpoint loaded it. Each slot carries its own length, so one plan can mix a 3-hour and a 14-hour window. |
| `data.pass.slots[].weekday` | integer \| null | yes | Day of the week, 0 (Sunday) to 6, on a weekly recurrence; null otherwise. |
| `data.pass.slots[].day_of_month` | integer \| null | yes | Day of the month, 1 to 31, on a monthly recurrence; null otherwise. 29 to 31 skip the months that lack the day. |
| `data.pass.slots[].start_time` | string | yes | Local wall-clock start in `timezone`, `HH:MM`. |
| `data.pass.slots[].duration_minutes` | integer | yes | How long the window stays open. |
| `data.pass.upcoming_windows` | array of object | no | The next windows, present only where the endpoint loaded them. Timestamps are UTC. |
| `data.pass.upcoming_windows[].id` | string | yes | The window's id, the value a series names in `pass_series.window_ids`. |
| `data.pass.upcoming_windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass.upcoming_windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass.upcoming_windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series` | object | no | The season ticket's slate, the rules that grow it and its seats. Present only on `kind: pass_series`. |
| `data.pass_series.timezone` | string | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | integer \| null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | integer \| null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | string \| null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | string \| null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | string \| null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | integer \| null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of object | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.windows[].id` | string | yes | The window's id. |
| `data.pass_series.windows[].plan_id` | string | yes | The pass plan the window belongs to. |
| `data.pass_series.windows[].plan_name` | string | yes | That plan's name. |
| `data.pass_series.windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass_series.windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass_series.windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series.windows[].added_by_rule` | boolean | yes | True when a rule absorbed it rather than the creator picking it by hand. |
| `data.pass_series.rules` | array of object | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.rules[].source_plan_id` | string | yes | The pass plan the rule draws windows from. |
| `data.pass_series.rules[].kind` | string | yes | `date_range` takes every window between `from_at` and `to_at`; `next_n` takes the next `take` windows. |
| `data.pass_series.rules[].from_at` | string \| null | yes | The earliest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].to_at` | string \| null | yes | The latest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].take` | integer \| null | yes | How many windows a `next_n` rule takes; null on a `date_range` rule. |
| `data.pass_series.blackout_window_ids` | object | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.pass_series.timezone` | null | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of string | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.rules` | array of string | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.blackout_window_ids` | array of string | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.created_at` | string \| null | yes | When the plan was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when the plan is priced at `0` and the project owner's account cannot sell a free plan, or when it is a pass or series and the account lacks Time-Limited Passes.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Unpublish a plan
`POST /v1/projects/{project}/plans/{plan}/unpublish`
Takes the plan off sale: `active` becomes `false` and `plan.deactivated` fires. Members who already joined keep their access; the plan stays fully editable and can be published again. A plan that is already off sale is answered as it is and emits nothing. Answers `200` with the plan.
- Requires ability: `project-subscription-plan:update`
- Fires events: `plan.deactivated`
- MCP tools: `publish_plan`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `plan` | path | string (uuid) | yes | The plan, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The plan, inactive.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The plan's id. |
| `data.project_id` | string | yes | The project the plan belongs to. |
| `data.kind` | string | yes | `subscription`, `pass` or `pass_series`. Names the one block that accompanies it; the other two are absent, not null. |
| `data.name` | string | yes | The name buyers see, unique within the project without regard to case or surrounding spaces. |
| `data.description` | string \| array of any \| null | yes | The pitch under the name, HTML filtered to a safe subset; null when none was written. |
| `data.price` | string | yes | What one purchase costs, as a decimal string in `currency`. On a pass that buys one window; on a series it buys the whole slate, once. `"0.00"` is a free plan. |
| `data.currency` | object | yes | The currency the price is charged in. |
| `data.currency.id` | string | yes | The currency's id, the value `currency_id` takes on a write. |
| `data.currency.iso` | string | yes | The ISO 4217 code, such as `USD`. |
| `data.currency.symbol` | string | yes | The symbol to print before the amount. |
| `data.active` | boolean | yes | Whether the plan is on sale. Flip it with the publish and unpublish endpoints, not with an update. |
| `data.sales_cap` | integer \| null | yes | How many purchases the plan takes before pausing itself; null for no limit. |
| `data.sales_cap_sold` | integer | yes | Purchases counted against the cap since it was last changed or the plan was last published. |
| `data.position` | integer \| null | yes | The plan's pinned place in the storefront order; null while it follows the built-in order (passes, then seasons, then subscriptions, cheapest first). |
| `data.paused` | object \| null | yes | Why the plan took itself off sale; null while it is not paused. |
| `data.paused.reason` | string | yes | `sales_cap_reached` when the cap filled, `seat_cap_reached` when a season filled its last seat. |
| `data.paused.at` | string \| null | yes | When it paused, ISO 8601. |
| `data.cadence` | string | yes | The duration in words the portal and the bot print, such as `Per Month`, `Per 3 Hours` or `For all 10 passes`. Use it rather than deriving one from the cycle fields. |
| `data.eligibility` | object | yes | Who may buy. The first three are mutually exclusive. |
| `data.eligibility.newcomers_only` | boolean | yes | Only members who never held a subscription on the project. |
| `data.eligibility.customers_only` | boolean | yes | Only members who currently hold one. |
| `data.eligibility.churned_only` | boolean | yes | Only members who held one and let it lapse. |
| `data.eligibility.single_use` | boolean | yes | Each member may buy the plan once. |
| `data.eligibility.access_codes_only` | boolean | yes | The plan cannot be bought; it is granted by redeeming an access code. |
| `data.resources` | array of object | no | The places the plan grants, present when the endpoint loaded them. On a series these are the lounge, open for the whole span, not the thing being sold. |
| `data.resources[].id` | string | yes | The resource's id. |
| `data.resources[].name` | string | yes | The resource's title. |
| `data.resources[].kind` | string | yes | `manual` for a perk the creator hands over themselves, or `:` for a place a connector hosts. |
| `data.resources[].connector` | string \| null | yes | The connector that hosts the place; null for a manual resource. |
| `data.billing` | object | no | Cycle, trial and renewal. Present only on `kind: subscription`. |
| `data.billing.billing_cycle` | string \| `day`, `week`, `month`, `year`, `lifetime` | yes | `day`, `week`, `month`, `year` or `lifetime`. |
| `data.billing.billing_cycle_count` | integer | yes | How many cycles one period spans, 1 to 99; always 1 when the cycle is `lifetime`. |
| `data.billing.recurring` | boolean | yes | Whether the subscription renews itself. Never true for a crypto or platform currency. |
| `data.billing.disabled_renewal` | boolean | yes | Charges once, then lapses at the end of the period instead of renewing. |
| `data.billing.trial_days` | integer | yes | Free days before the first charge, 0 to 365. |
| `data.billing.trial_cardless` | boolean | yes | Whether the trial starts without a payment method on file. |
| `data.billing.trial_type` | string \| `project`, `plan` | yes | Whose trial rule applies: `project` for the project's trial settings, `plan` for this plan's own. |
| `data.pass` | object | no | The plan's own dated schedule. Present only on `kind: pass`. |
| `data.pass.timezone` | string \| null | yes | The IANA zone the schedule was authored in. Render window times in it, never in UTC. |
| `data.pass.schedule_mode` | string \| null | yes | `repeating` for windows generated from `slots`, `fixed` for explicitly dated windows. |
| `data.pass.recurrence` | string \| null | yes | `daily`, `weekly` or `monthly`; null in `fixed` mode. |
| `data.pass.recurrence_ends_at` | string \| null | yes | When window generation stops, ISO 8601; null for no end. |
| `data.pass.sales_cutoff_minutes` | integer \| null | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names; null to sell until the window begins. |
| `data.pass.sales_cutoff_anchor` | string | yes | `before_start` or `before_end`. Never null: a plan that never set one reads as `before_start`. |
| `data.pass.slots` | array of object | no | The repeating schedule, present when the endpoint loaded it. Each slot carries its own length, so one plan can mix a 3-hour and a 14-hour window. |
| `data.pass.slots[].weekday` | integer \| null | yes | Day of the week, 0 (Sunday) to 6, on a weekly recurrence; null otherwise. |
| `data.pass.slots[].day_of_month` | integer \| null | yes | Day of the month, 1 to 31, on a monthly recurrence; null otherwise. 29 to 31 skip the months that lack the day. |
| `data.pass.slots[].start_time` | string | yes | Local wall-clock start in `timezone`, `HH:MM`. |
| `data.pass.slots[].duration_minutes` | integer | yes | How long the window stays open. |
| `data.pass.upcoming_windows` | array of object | no | The next windows, present only where the endpoint loaded them. Timestamps are UTC. |
| `data.pass.upcoming_windows[].id` | string | yes | The window's id, the value a series names in `pass_series.window_ids`. |
| `data.pass.upcoming_windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass.upcoming_windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass.upcoming_windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series` | object | no | The season ticket's slate, the rules that grow it and its seats. Present only on `kind: pass_series`. |
| `data.pass_series.timezone` | string | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | integer \| null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | integer \| null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | string \| null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | string \| null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | string \| null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | integer \| null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of object | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.windows[].id` | string | yes | The window's id. |
| `data.pass_series.windows[].plan_id` | string | yes | The pass plan the window belongs to. |
| `data.pass_series.windows[].plan_name` | string | yes | That plan's name. |
| `data.pass_series.windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass_series.windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass_series.windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series.windows[].added_by_rule` | boolean | yes | True when a rule absorbed it rather than the creator picking it by hand. |
| `data.pass_series.rules` | array of object | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.rules[].source_plan_id` | string | yes | The pass plan the rule draws windows from. |
| `data.pass_series.rules[].kind` | string | yes | `date_range` takes every window between `from_at` and `to_at`; `next_n` takes the next `take` windows. |
| `data.pass_series.rules[].from_at` | string \| null | yes | The earliest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].to_at` | string \| null | yes | The latest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].take` | integer \| null | yes | How many windows a `next_n` rule takes; null on a `date_range` rule. |
| `data.pass_series.blackout_window_ids` | object | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.pass_series.timezone` | null | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of string | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.rules` | array of string | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.blackout_window_ids` | array of string | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.created_at` | string \| null | yes | When the plan was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Arrange the storefront order
`POST /v1/projects/{project}/plans/order`
```bash
curl -X POST https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/plans/order \
-H "Authorization: Bearer sbt_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"plan_ids": ["c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b", "b1a7c3d5-2e48-4f60-9a1b-7c5d3e820f94"]}'
```
Pins the order plans appear in on the public portal and in the bot; `plan_ids` is every plan you want pinned, first to last. Plans left out keep the built-in order (passes, then seasons, then subscriptions, cheapest first) after the pinned ones. Send an empty `plan_ids` to clear every pin. Answers `200` with the project's active plans in their new order, each in the same shape as a read, with `position` set on the pinned ones. The same list produces the same order and one `plan.order_changed` event per call.
- Requires ability: `project-subscription-plan:update`
- Fires events: `plan.order_changed`
- MCP tools: `reorder_plans`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
The storefront order: every plan to pin, first to last.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_ids` | array of string | yes | Plan ids in storefront order, at most 200, each a plan of this project and none repeated. An empty list clears every pin. |
### Responses
- **200**: Array of `PlanResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].id` | string | yes | The plan's id. |
| `data[].project_id` | string | yes | The project the plan belongs to. |
| `data[].kind` | string | yes | `subscription`, `pass` or `pass_series`. Names the one block that accompanies it; the other two are absent, not null. |
| `data[].name` | string | yes | The name buyers see, unique within the project without regard to case or surrounding spaces. |
| `data[].description` | string \| array of any \| null | yes | The pitch under the name, HTML filtered to a safe subset; null when none was written. |
| `data[].price` | string | yes | What one purchase costs, as a decimal string in `currency`. On a pass that buys one window; on a series it buys the whole slate, once. `"0.00"` is a free plan. |
| `data[].currency` | object | yes | The currency the price is charged in. |
| `data[].currency.id` | string | yes | The currency's id, the value `currency_id` takes on a write. |
| `data[].currency.iso` | string | yes | The ISO 4217 code, such as `USD`. |
| `data[].currency.symbol` | string | yes | The symbol to print before the amount. |
| `data[].active` | boolean | yes | Whether the plan is on sale. Flip it with the publish and unpublish endpoints, not with an update. |
| `data[].sales_cap` | integer \| null | yes | How many purchases the plan takes before pausing itself; null for no limit. |
| `data[].sales_cap_sold` | integer | yes | Purchases counted against the cap since it was last changed or the plan was last published. |
| `data[].position` | integer \| null | yes | The plan's pinned place in the storefront order; null while it follows the built-in order (passes, then seasons, then subscriptions, cheapest first). |
| `data[].paused` | object \| null | yes | Why the plan took itself off sale; null while it is not paused. |
| `data[].paused.reason` | string | yes | `sales_cap_reached` when the cap filled, `seat_cap_reached` when a season filled its last seat. |
| `data[].paused.at` | string \| null | yes | When it paused, ISO 8601. |
| `data[].cadence` | string | yes | The duration in words the portal and the bot print, such as `Per Month`, `Per 3 Hours` or `For all 10 passes`. Use it rather than deriving one from the cycle fields. |
| `data[].eligibility` | object | yes | Who may buy. The first three are mutually exclusive. |
| `data[].eligibility.newcomers_only` | boolean | yes | Only members who never held a subscription on the project. |
| `data[].eligibility.customers_only` | boolean | yes | Only members who currently hold one. |
| `data[].eligibility.churned_only` | boolean | yes | Only members who held one and let it lapse. |
| `data[].eligibility.single_use` | boolean | yes | Each member may buy the plan once. |
| `data[].eligibility.access_codes_only` | boolean | yes | The plan cannot be bought; it is granted by redeeming an access code. |
| `data[].resources` | array of object | no | The places the plan grants, present when the endpoint loaded them. On a series these are the lounge, open for the whole span, not the thing being sold. |
| `data[].resources[].id` | string | yes | The resource's id. |
| `data[].resources[].name` | string | yes | The resource's title. |
| `data[].resources[].kind` | string | yes | `manual` for a perk the creator hands over themselves, or `:` for a place a connector hosts. |
| `data[].resources[].connector` | string \| null | yes | The connector that hosts the place; null for a manual resource. |
| `data[].billing` | object | no | Cycle, trial and renewal. Present only on `kind: subscription`. |
| `data[].billing.billing_cycle` | string \| `day`, `week`, `month`, `year`, `lifetime` | yes | `day`, `week`, `month`, `year` or `lifetime`. |
| `data[].billing.billing_cycle_count` | integer | yes | How many cycles one period spans, 1 to 99; always 1 when the cycle is `lifetime`. |
| `data[].billing.recurring` | boolean | yes | Whether the subscription renews itself. Never true for a crypto or platform currency. |
| `data[].billing.disabled_renewal` | boolean | yes | Charges once, then lapses at the end of the period instead of renewing. |
| `data[].billing.trial_days` | integer | yes | Free days before the first charge, 0 to 365. |
| `data[].billing.trial_cardless` | boolean | yes | Whether the trial starts without a payment method on file. |
| `data[].billing.trial_type` | string \| `project`, `plan` | yes | Whose trial rule applies: `project` for the project's trial settings, `plan` for this plan's own. |
| `data[].pass` | object | no | The plan's own dated schedule. Present only on `kind: pass`. |
| `data[].pass.timezone` | string \| null | yes | The IANA zone the schedule was authored in. Render window times in it, never in UTC. |
| `data[].pass.schedule_mode` | string \| null | yes | `repeating` for windows generated from `slots`, `fixed` for explicitly dated windows. |
| `data[].pass.recurrence` | string \| null | yes | `daily`, `weekly` or `monthly`; null in `fixed` mode. |
| `data[].pass.recurrence_ends_at` | string \| null | yes | When window generation stops, ISO 8601; null for no end. |
| `data[].pass.sales_cutoff_minutes` | integer \| null | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names; null to sell until the window begins. |
| `data[].pass.sales_cutoff_anchor` | string | yes | `before_start` or `before_end`. Never null: a plan that never set one reads as `before_start`. |
| `data[].pass.slots` | array of object | no | The repeating schedule, present when the endpoint loaded it. Each slot carries its own length, so one plan can mix a 3-hour and a 14-hour window. |
| `data[].pass.slots[].weekday` | integer \| null | yes | Day of the week, 0 (Sunday) to 6, on a weekly recurrence; null otherwise. |
| `data[].pass.slots[].day_of_month` | integer \| null | yes | Day of the month, 1 to 31, on a monthly recurrence; null otherwise. 29 to 31 skip the months that lack the day. |
| `data[].pass.slots[].start_time` | string | yes | Local wall-clock start in `timezone`, `HH:MM`. |
| `data[].pass.slots[].duration_minutes` | integer | yes | How long the window stays open. |
| `data[].pass.upcoming_windows` | array of object | no | The next windows, present only where the endpoint loaded them. Timestamps are UTC. |
| `data[].pass.upcoming_windows[].id` | string | yes | The window's id, the value a series names in `pass_series.window_ids`. |
| `data[].pass.upcoming_windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data[].pass.upcoming_windows[].ends_at` | string | yes | When it closes, UTC. |
| `data[].pass.upcoming_windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data[].pass_series` | object | no | The season ticket's slate, the rules that grow it and its seats. Present only on `kind: pass_series`. |
| `data[].pass_series.timezone` | string | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data[].pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data[].pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data[].pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data[].pass_series.seat_cap` | integer \| null | yes | How many holders may hold the season at once; null for unlimited. |
| `data[].pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data[].pass_series.seats_remaining` | integer \| null | yes | Seats still open; null when uncapped. |
| `data[].pass_series.starts_at` | string \| null | yes | The first window's start, UTC; null while the slate is empty. |
| `data[].pass_series.ends_at` | string \| null | yes | The last window's end, UTC; null while the slate is empty. |
| `data[].pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data[].pass_series.successor_plan_id` | string \| null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data[].pass_series.presale_hours` | integer \| null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data[].pass_series.windows` | array of object | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data[].pass_series.windows[].id` | string | yes | The window's id. |
| `data[].pass_series.windows[].plan_id` | string | yes | The pass plan the window belongs to. |
| `data[].pass_series.windows[].plan_name` | string | yes | That plan's name. |
| `data[].pass_series.windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data[].pass_series.windows[].ends_at` | string | yes | When it closes, UTC. |
| `data[].pass_series.windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data[].pass_series.windows[].added_by_rule` | boolean | yes | True when a rule absorbed it rather than the creator picking it by hand. |
| `data[].pass_series.rules` | array of object | yes | The rules that keep absorbing matching windows into the slate. |
| `data[].pass_series.rules[].source_plan_id` | string | yes | The pass plan the rule draws windows from. |
| `data[].pass_series.rules[].kind` | string | yes | `date_range` takes every window between `from_at` and `to_at`; `next_n` takes the next `take` windows. |
| `data[].pass_series.rules[].from_at` | string \| null | yes | The earliest window start the rule takes, UTC; null for no bound. |
| `data[].pass_series.rules[].to_at` | string \| null | yes | The latest window start the rule takes, UTC; null for no bound. |
| `data[].pass_series.rules[].take` | integer \| null | yes | How many windows a `next_n` rule takes; null on a `date_range` rule. |
| `data[].pass_series.blackout_window_ids` | object | yes | Windows a rule matches but the creator has permanently excluded. |
| `data[].pass_series.timezone` | null | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data[].pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data[].pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data[].pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data[].pass_series.seat_cap` | null | yes | How many holders may hold the season at once; null for unlimited. |
| `data[].pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data[].pass_series.seats_remaining` | null | yes | Seats still open; null when uncapped. |
| `data[].pass_series.starts_at` | null | yes | The first window's start, UTC; null while the slate is empty. |
| `data[].pass_series.ends_at` | null | yes | The last window's end, UTC; null while the slate is empty. |
| `data[].pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data[].pass_series.successor_plan_id` | null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data[].pass_series.presale_hours` | null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data[].pass_series.windows` | array of string | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data[].pass_series.rules` | array of string | yes | The rules that keep absorbing matching windows into the slate. |
| `data[].pass_series.blackout_window_ids` | array of string | yes | Windows a rule matches but the creator has permanently excluded. |
| `data[].created_at` | string \| null | yes | When the plan was created, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when a plan id belongs to another project, is repeated, or the list exceeds 200 entries; `error.fields` names the entry.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Start the next season
`POST /v1/projects/{project}/plans/{plan}/successor`
```bash
curl -X POST https://api.subscriby.net/v1/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/plans/$SERIES_PLAN/successor \
-H "Authorization: Bearer sbt_..." \
-H "Idempotency-Key: $(uuidgen)"
```
The plan list's "start next season" button. For a `kind: pass_series` plan only: creates a **new** `pass_series` plan copied from the one named, its price, currency, description, eligibility, linked resources, overlap rule, sales cutoff and seat cap, with two deliberate differences: it is created **inactive**, and its slate is **empty**, because a season that went straight on sale with last year's dates would be selling something that has already happened. Rules are carried forward with their date bounds shifted by the length of the finished season; the slate itself and any blackouts are not, since both name windows that have run.
The old season's `successor_plan_id` is pointed at the new plan (and a presale window is set if the old season had none), which is what lets current holders be offered the next season first. Answers `201` with the new plan in the same discriminated shape as any other read and emits `plan.created` for it. Compose it with a `PATCH` (`pass_series.window_ids` or `pass_series.rules`) and put it on sale with publish.
> **Not idempotent across calls. Call it once per season.** Every call creates another plan, named after the source with a season number appended, and re-points the old season's successor at the newest one. Read the source plan first: if `pass_series.successor_plan_id` is already set, the next season exists. The `Idempotency-Key` protects a retry of the same call, not a second call with a new key.
- Requires ability: `project-subscription-plan:create`
- Fires events: `plan.created`
- MCP tools: `start_next_season`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `plan` | path | string (uuid) | yes | The finished season, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **201**: The new season, 201, inactive with an empty slate.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The plan's id. |
| `data.project_id` | string | yes | The project the plan belongs to. |
| `data.kind` | string | yes | `subscription`, `pass` or `pass_series`. Names the one block that accompanies it; the other two are absent, not null. |
| `data.name` | string | yes | The name buyers see, unique within the project without regard to case or surrounding spaces. |
| `data.description` | string \| array of any \| null | yes | The pitch under the name, HTML filtered to a safe subset; null when none was written. |
| `data.price` | string | yes | What one purchase costs, as a decimal string in `currency`. On a pass that buys one window; on a series it buys the whole slate, once. `"0.00"` is a free plan. |
| `data.currency` | object | yes | The currency the price is charged in. |
| `data.currency.id` | string | yes | The currency's id, the value `currency_id` takes on a write. |
| `data.currency.iso` | string | yes | The ISO 4217 code, such as `USD`. |
| `data.currency.symbol` | string | yes | The symbol to print before the amount. |
| `data.active` | boolean | yes | Whether the plan is on sale. Flip it with the publish and unpublish endpoints, not with an update. |
| `data.sales_cap` | integer \| null | yes | How many purchases the plan takes before pausing itself; null for no limit. |
| `data.sales_cap_sold` | integer | yes | Purchases counted against the cap since it was last changed or the plan was last published. |
| `data.position` | integer \| null | yes | The plan's pinned place in the storefront order; null while it follows the built-in order (passes, then seasons, then subscriptions, cheapest first). |
| `data.paused` | object \| null | yes | Why the plan took itself off sale; null while it is not paused. |
| `data.paused.reason` | string | yes | `sales_cap_reached` when the cap filled, `seat_cap_reached` when a season filled its last seat. |
| `data.paused.at` | string \| null | yes | When it paused, ISO 8601. |
| `data.cadence` | string | yes | The duration in words the portal and the bot print, such as `Per Month`, `Per 3 Hours` or `For all 10 passes`. Use it rather than deriving one from the cycle fields. |
| `data.eligibility` | object | yes | Who may buy. The first three are mutually exclusive. |
| `data.eligibility.newcomers_only` | boolean | yes | Only members who never held a subscription on the project. |
| `data.eligibility.customers_only` | boolean | yes | Only members who currently hold one. |
| `data.eligibility.churned_only` | boolean | yes | Only members who held one and let it lapse. |
| `data.eligibility.single_use` | boolean | yes | Each member may buy the plan once. |
| `data.eligibility.access_codes_only` | boolean | yes | The plan cannot be bought; it is granted by redeeming an access code. |
| `data.resources` | array of object | no | The places the plan grants, present when the endpoint loaded them. On a series these are the lounge, open for the whole span, not the thing being sold. |
| `data.resources[].id` | string | yes | The resource's id. |
| `data.resources[].name` | string | yes | The resource's title. |
| `data.resources[].kind` | string | yes | `manual` for a perk the creator hands over themselves, or `:` for a place a connector hosts. |
| `data.resources[].connector` | string \| null | yes | The connector that hosts the place; null for a manual resource. |
| `data.billing` | object | no | Cycle, trial and renewal. Present only on `kind: subscription`. |
| `data.billing.billing_cycle` | string \| `day`, `week`, `month`, `year`, `lifetime` | yes | `day`, `week`, `month`, `year` or `lifetime`. |
| `data.billing.billing_cycle_count` | integer | yes | How many cycles one period spans, 1 to 99; always 1 when the cycle is `lifetime`. |
| `data.billing.recurring` | boolean | yes | Whether the subscription renews itself. Never true for a crypto or platform currency. |
| `data.billing.disabled_renewal` | boolean | yes | Charges once, then lapses at the end of the period instead of renewing. |
| `data.billing.trial_days` | integer | yes | Free days before the first charge, 0 to 365. |
| `data.billing.trial_cardless` | boolean | yes | Whether the trial starts without a payment method on file. |
| `data.billing.trial_type` | string \| `project`, `plan` | yes | Whose trial rule applies: `project` for the project's trial settings, `plan` for this plan's own. |
| `data.pass` | object | no | The plan's own dated schedule. Present only on `kind: pass`. |
| `data.pass.timezone` | string \| null | yes | The IANA zone the schedule was authored in. Render window times in it, never in UTC. |
| `data.pass.schedule_mode` | string \| null | yes | `repeating` for windows generated from `slots`, `fixed` for explicitly dated windows. |
| `data.pass.recurrence` | string \| null | yes | `daily`, `weekly` or `monthly`; null in `fixed` mode. |
| `data.pass.recurrence_ends_at` | string \| null | yes | When window generation stops, ISO 8601; null for no end. |
| `data.pass.sales_cutoff_minutes` | integer \| null | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names; null to sell until the window begins. |
| `data.pass.sales_cutoff_anchor` | string | yes | `before_start` or `before_end`. Never null: a plan that never set one reads as `before_start`. |
| `data.pass.slots` | array of object | no | The repeating schedule, present when the endpoint loaded it. Each slot carries its own length, so one plan can mix a 3-hour and a 14-hour window. |
| `data.pass.slots[].weekday` | integer \| null | yes | Day of the week, 0 (Sunday) to 6, on a weekly recurrence; null otherwise. |
| `data.pass.slots[].day_of_month` | integer \| null | yes | Day of the month, 1 to 31, on a monthly recurrence; null otherwise. 29 to 31 skip the months that lack the day. |
| `data.pass.slots[].start_time` | string | yes | Local wall-clock start in `timezone`, `HH:MM`. |
| `data.pass.slots[].duration_minutes` | integer | yes | How long the window stays open. |
| `data.pass.upcoming_windows` | array of object | no | The next windows, present only where the endpoint loaded them. Timestamps are UTC. |
| `data.pass.upcoming_windows[].id` | string | yes | The window's id, the value a series names in `pass_series.window_ids`. |
| `data.pass.upcoming_windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass.upcoming_windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass.upcoming_windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series` | object | no | The season ticket's slate, the rules that grow it and its seats. Present only on `kind: pass_series`. |
| `data.pass_series.timezone` | string | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | integer \| null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | integer \| null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | string \| null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | string \| null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | string \| null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | integer \| null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of object | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.windows[].id` | string | yes | The window's id. |
| `data.pass_series.windows[].plan_id` | string | yes | The pass plan the window belongs to. |
| `data.pass_series.windows[].plan_name` | string | yes | That plan's name. |
| `data.pass_series.windows[].starts_at` | string | yes | When the window opens, UTC. |
| `data.pass_series.windows[].ends_at` | string | yes | When it closes, UTC. |
| `data.pass_series.windows[].status` | string | yes | `scheduled`, `open`, `closed` or `canceled`. |
| `data.pass_series.windows[].added_by_rule` | boolean | yes | True when a rule absorbed it rather than the creator picking it by hand. |
| `data.pass_series.rules` | array of object | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.rules[].source_plan_id` | string | yes | The pass plan the rule draws windows from. |
| `data.pass_series.rules[].kind` | string | yes | `date_range` takes every window between `from_at` and `to_at`; `next_n` takes the next `take` windows. |
| `data.pass_series.rules[].from_at` | string \| null | yes | The earliest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].to_at` | string \| null | yes | The latest window start the rule takes, UTC; null for no bound. |
| `data.pass_series.rules[].take` | integer \| null | yes | How many windows a `next_n` rule takes; null on a `date_range` rule. |
| `data.pass_series.blackout_window_ids` | object | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.pass_series.timezone` | null | yes | Borrowed from the source plans; a series has no zone of its own. Null while the slate is empty. |
| `data.pass_series.prevent_overlaps` | boolean | yes | Whether a window clashing with one already on the slate is refused. |
| `data.pass_series.sales_cutoff_minutes` | integer | yes | Sales stop this many minutes before the moment `sales_cutoff_anchor` names, measured against the whole season. |
| `data.pass_series.sales_cutoff_anchor` | string | yes | Earliest deadline first: `before_start` closes before the first window opens, `before_first_end` during that opening window, `before_last_start` as the last window opens, `before_end` as it ends. |
| `data.pass_series.seat_cap` | null | yes | How many holders may hold the season at once; null for unlimited. |
| `data.pass_series.seats_taken` | integer | yes | Holders currently counted against the cap. |
| `data.pass_series.seats_remaining` | null | yes | Seats still open; null when uncapped. |
| `data.pass_series.starts_at` | null | yes | The first window's start, UTC; null while the slate is empty. |
| `data.pass_series.ends_at` | null | yes | The last window's end, UTC; null while the slate is empty. |
| `data.pass_series.window_count` | integer | yes | How many windows the slate holds, at most 120. |
| `data.pass_series.successor_plan_id` | null | yes | The next season, offered to holders first when this one finishes; null when none is set. |
| `data.pass_series.presale_hours` | null | yes | How long that offer is held for holders only, 1 to 8760; null when none is set. |
| `data.pass_series.windows` | array of string | yes | The slate. Each entry names the plan the window belongs to, because a series can mix several. |
| `data.pass_series.rules` | array of string | yes | The rules that keep absorbing matching windows into the slate. |
| `data.pass_series.blackout_window_ids` | array of string | yes | Windows a rule matches but the creator has permanently excluded. |
| `data.created_at` | string \| null | yes | When the plan was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When it last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the plan is not a pass series; `error.context.plan_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Projects API](https://docs.subscriby.net/api/v1/reference/projects): A project is the container for one membership business: its plans, its members and their subscriptions, its payment methods, the resources a purchase unlocks and the connectors it runs on.
- [Pass Windows API](https://docs.subscriby.net/api/v1/reference/pass-windows): The dated windows a pass plan generates, and how to add, cancel or nudge one.
- [Payment Methods API](https://docs.subscriby.net/api/v1/reference/payment-methods): Every project configures one or more payment providers so subscribers can buy plans.
- [Webhook Events API](https://docs.subscriby.net/api/v1/reference/webhook-events): A read-only polling endpoint that surfaces the most recent webhook deliveries for a given event type.
- [plan.* events](https://docs.subscriby.net/webhooks/v1/events/plan): What these endpoints announce, plus plan.sold_out and plan.sync_completed, which no call emits directly.
- [pass.* events](https://docs.subscriby.net/webhooks/v1/events/pass): A kind: pass plan also emits these as its windows are scheduled, open and close.
- [pass_series.* events](https://docs.subscriby.net/webhooks/v1/events/pass-series): A kind: pass_series plan emits those and this family describing the slate.
---
# Projects API
Source: https://docs.subscriby.net/api/v1/reference/projects
A **project** is the container for one membership business: its plans, its members and their subscriptions, its payment methods, the resources a purchase unlocks and the connectors it runs on. A team can hold several, each with its own portal at a `handle` of its choosing, its own terms and privacy links, artwork and public-metrics switch. A project has no platform of its own: it installs [connectors](https://docs.subscriby.net/api/v1/reference/connectors), and the places those connectors gate become its resources.
The endpoints create, read, update and delete projects, and offer **archive** and **restore** as actions separate from update. Archiving flips `active` off so the portal stops selling while every plan, member and payment method is kept; restoring switches it back on. Delete removes the project and everything under it, so prefer archiving to pause intake without losing history. A custom `handle` needs the account tier that unlocks it, and changing it moves the portal URL at once.
A token minted with `scope:project` entries sees only those projects, and any project outside the token's team or scope is a `404 RESOURCE_NOT_FOUND`. Every change announces itself as a `project.*` event; connecting and disconnecting a connector emits `connector.*` events from their own flows rather than from here.
## Endpoints
- `GET /v1/projects` — [List projects](#list-projects)
- `POST /v1/projects` — [Create a project](#create-a-project)
- `GET /v1/projects/{project}` — [Get a project](#get-a-project)
- `PATCH /v1/projects/{project}` — [Update a project](#update-a-project)
- `DELETE /v1/projects/{project}` — [Delete a project](#delete-a-project)
- `POST /v1/projects/{project}/archive` — [Archive a project](#archive-a-project)
- `POST /v1/projects/{project}/restore` — [Restore a project](#restore-a-project)
## List projects
`GET /v1/projects`
```bash
curl https://api.subscriby.net/v1/projects \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Every project visible to the token, newest first, paged with `page` and `per_page` (default 25). A token minted with `scope:project:` entries lists only those projects.
- Requires ability: `project:view-any`
- MCP tools: `list_projects`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: The page, newest first.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The project's id, the value every project-scoped endpoint takes in its path. |
| `data[].team_id` | string \| null | yes | The team the project belongs to. |
| `data[].user_id` | string | yes | The creator who owns the project. |
| `data[].name` | string | yes | The project's name, 5 to 255 characters. |
| `data[].handle` | string \| null | yes | The project's handle: lowercase letters, digits and dashes, unique across Subscriby, the last segment of the portal URL. Null until one is chosen; choosing one needs a tier with the custom-handle capability. |
| `data[].description` | string \| array of any \| null | yes | What the project offers, up to 1,000 characters of HTML filtered to a safe subset; null when none. |
| `data[].terms` | string \| null | yes | The URL of the terms members are asked to accept; null when none. |
| `data[].privacy` | string \| null | yes | The URL of the privacy policy members are asked to accept; null when none. |
| `data[].banner_url` | string \| null | yes | The hosted banner artwork; null until uploaded and processed. |
| `data[].photo_url` | string \| null | yes | The hosted photo artwork; null until uploaded and processed. |
| `data[].metrics` | boolean | yes | The **Enable Public Metrics** switch: whether the portal shows the project's member counts. |
| `data[].outage_compensations` | boolean | yes | The **Outage Compensation** switch: when a platform refuses the project's connector for an hour or more and later answers again, every member whose paid access overlapped the gap gets the lost time added to the end of their access automatically. True on every new project. |
| `data[].active` | boolean | yes | Whether the project is live. Archiving flips it to false and keeps everything; restoring flips it back. |
| `data[].created_at` | string \| null | yes | When the project was created, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When the project last changed, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Create a project
`POST /v1/projects`
```bash
curl -X POST https://api.subscriby.net/v1/projects \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"name": "Research Premium",
"description": "Weekly deep-dive research notes.",
"handle": "research-premium",
"metrics": true
}'
```
Banner and photo are optional image uploads. Send `multipart/form-data` when you want the API to accept the raw image:
```bash
curl -X POST https://api.subscriby.net/v1/projects \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-F "name=Research Premium" \
-F "description=Weekly deep-dive research notes." \
-F "banner=@./banner.jpg" \
-F "photo=@./avatar.jpg"
```
Answers `201` with the project and emits `project.created`. Creating a project enforces the creator's tier project limit; exceeding it is `403 TEAM_TIER_REQUIRED` with a `required_capability` hint in `error.context`.
### Validation rules
Enforced on both create and update (every field is optional on update):
- `name`: required on create. String, 5–255 characters.
- `description`: optional. String, max 1,000 characters. HTML is filtered to a safe subset before storage.
- `terms`, `privacy`: optional. URLs, max 255 characters each.
- `banner`, `photo`: optional. Image uploads, max **5 MB** each, sent as `multipart/form-data`.
- `handle`: optional. Lowercase alpha-dash ASCII, 5–255 characters, unique. Requires a Subscriby tier that includes the custom-handle capability; Free-tier tokens receive `TEAM_TIER_REQUIRED` when a handle is supplied.
- `metrics`, `active`: optional booleans.
- `outage_compensations`: optional boolean, `true` on every new project. **Outage Compensation**: when a platform refuses the project's connector for an hour or more and later answers again, every member whose paid access overlapped the gap gets the lost time added to the end of their access automatically, so nobody pays for days they could not use. Set it to `false` when the creator would rather settle outages themselves, for example with refunds or coupons. See [Connector outages](https://docs.subscriby.net/disaster-recovery/connector-outages).
- `team_id`: optional UUID; must be a team the token's owner belongs to. Defaults to the token's scoped team.
> **Artwork is processed after the request.** The response body returns the project's metadata only, not the hosted image URLs. Image processing (resize, re-encode, upload to storage) runs on the server after a successful create or update; clients that need the hosted URL should fetch the project once upload is complete.
- Requires ability: `project:create`
- Fires events: `project.created`
- MCP tools: `create_project`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (multipart/form-data)
A new project. Send JSON, or `multipart/form-data` when uploading the banner or photo; the response carries the project's metadata only, and the hosted image URLs appear on a later read once processing has finished.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | The project's name, 5 to 255 characters. |
| `description` | string \| null | no | What the project offers, up to 1,000 characters. HTML is filtered to a safe subset before storage. |
| `terms` | string \| null (uri) | no | The URL of the terms members are asked to accept, `https://` or `http://`, up to 255 characters. |
| `privacy` | string \| null (uri) | no | The URL of the privacy policy members are asked to accept, `https://` or `http://`, up to 255 characters. |
| `banner` | string \| null (binary) | no | The banner image (jpg, jpeg, png, gif, bmp, webp or avif), at most 5 MB, as a `multipart/form-data` upload. Resized, re-encoded and uploaded after the request; read the project again for its URL. |
| `photo` | string \| null (binary) | no | The photo image (jpg, jpeg, png, gif, bmp, webp or avif), at most 5 MB, as a `multipart/form-data` upload. Processed after the request like the banner. |
| `metrics` | boolean | no | The **Enable Public Metrics** switch. |
| `active` | boolean | no | Whether the project starts live. |
| `team_id` | string \| null | no | The team to create the project in; must be one the token's owner belongs to. Defaults to the token's scoped team. |
| `handle` | string \| null | no | The portal handle: lowercase letters, digits, dashes and underscores, 5 to 255 characters, unique across Subscriby. Needs a tier with the custom-handle capability; on the Free tier a handle is refused with `TEAM_TIER_REQUIRED`. |
### Responses
- **201**: The new project.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The project's id, the value every project-scoped endpoint takes in its path. |
| `data.team_id` | string \| null | yes | The team the project belongs to. |
| `data.user_id` | string | yes | The creator who owns the project. |
| `data.name` | string | yes | The project's name, 5 to 255 characters. |
| `data.handle` | string \| null | yes | The project's handle: lowercase letters, digits and dashes, unique across Subscriby, the last segment of the portal URL. Null until one is chosen; choosing one needs a tier with the custom-handle capability. |
| `data.description` | string \| array of any \| null | yes | What the project offers, up to 1,000 characters of HTML filtered to a safe subset; null when none. |
| `data.terms` | string \| null | yes | The URL of the terms members are asked to accept; null when none. |
| `data.privacy` | string \| null | yes | The URL of the privacy policy members are asked to accept; null when none. |
| `data.banner_url` | string \| null | yes | The hosted banner artwork; null until uploaded and processed. |
| `data.photo_url` | string \| null | yes | The hosted photo artwork; null until uploaded and processed. |
| `data.metrics` | boolean | yes | The **Enable Public Metrics** switch: whether the portal shows the project's member counts. |
| `data.outage_compensations` | boolean | yes | The **Outage Compensation** switch: when a platform refuses the project's connector for an hour or more and later answers again, every member whose paid access overlapped the gap gets the lost time added to the end of their access automatically. True on every new project. |
| `data.active` | boolean | yes | Whether the project is live. Archiving flips it to false and keeps everything; restoring flips it back. |
| `data.created_at` | string \| null | yes | When the project was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the project last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when the creator's tier project limit is reached, or a `handle` is sent without the custom-handle capability; `error.context.required_capability` names it.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: with per-field messages under `error.fields`.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a project
`GET /v1/projects/{project}`
One project. A project outside the token's team or its project scope is `404 RESOURCE_NOT_FOUND`.
`metrics` is the project's **Enable Public Metrics** switch, `outage_compensations` its **Outage Compensation** switch, `terms` and `privacy` the URLs members are asked to accept, and `banner_url` / `photo_url` the hosted artwork (null until uploaded). A project has no platform field: it installs [connectors](https://docs.subscriby.net/api/v1/reference/connectors), and the project's connectors list names them. Clients that read a `type` on projects before 5.0 must switch to the connectors list; the field is gone.
- Requires ability: `project:view`
- MCP tools: `get_project`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
### Responses
- **200**: The project.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The project's id, the value every project-scoped endpoint takes in its path. |
| `data.team_id` | string \| null | yes | The team the project belongs to. |
| `data.user_id` | string | yes | The creator who owns the project. |
| `data.name` | string | yes | The project's name, 5 to 255 characters. |
| `data.handle` | string \| null | yes | The project's handle: lowercase letters, digits and dashes, unique across Subscriby, the last segment of the portal URL. Null until one is chosen; choosing one needs a tier with the custom-handle capability. |
| `data.description` | string \| array of any \| null | yes | What the project offers, up to 1,000 characters of HTML filtered to a safe subset; null when none. |
| `data.terms` | string \| null | yes | The URL of the terms members are asked to accept; null when none. |
| `data.privacy` | string \| null | yes | The URL of the privacy policy members are asked to accept; null when none. |
| `data.banner_url` | string \| null | yes | The hosted banner artwork; null until uploaded and processed. |
| `data.photo_url` | string \| null | yes | The hosted photo artwork; null until uploaded and processed. |
| `data.metrics` | boolean | yes | The **Enable Public Metrics** switch: whether the portal shows the project's member counts. |
| `data.outage_compensations` | boolean | yes | The **Outage Compensation** switch: when a platform refuses the project's connector for an hour or more and later answers again, every member whose paid access overlapped the gap gets the lost time added to the end of their access automatically. True on every new project. |
| `data.active` | boolean | yes | Whether the project is live. Archiving flips it to false and keeps everything; restoring flips it back. |
| `data.created_at` | string \| null | yes | When the project was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the project last changed, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Update a project
`PATCH /v1/projects/{project}`
Every field is optional; only the fields present in the body change, under the same validation rules as create. Sending a new `handle` moves the portal URL at once. Answers `200` with the project and emits `project.updated`.
- Requires ability: `project:update`
- Fires events: `project.updated`
- MCP tools: `update_project`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (multipart/form-data)
The changes to a project. Every field is optional and an omitted one keeps its value. Send JSON, or `multipart/form-data` when uploading the banner or photo.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | The project's name, 5 to 255 characters. |
| `description` | string \| null | no | What the project offers, up to 1,000 characters. HTML is filtered to a safe subset before storage. |
| `terms` | string \| null (uri) | no | The URL of the terms members are asked to accept, `https://` or `http://`, up to 255 characters. |
| `privacy` | string \| null (uri) | no | The URL of the privacy policy members are asked to accept, `https://` or `http://`, up to 255 characters. |
| `banner` | string \| null (binary) | no | A new banner image (jpg, jpeg, png, gif, bmp, webp or avif), at most 5 MB, as a `multipart/form-data` upload. Processed after the request; read the project again for its URL. |
| `photo` | string \| null (binary) | no | A new photo image (jpg, jpeg, png, gif, bmp, webp or avif), at most 5 MB, as a `multipart/form-data` upload. Processed after the request like the banner. |
| `metrics` | boolean | no | The **Enable Public Metrics** switch. |
| `active` | boolean | no | Whether the project is live; the archive and restore endpoints flip the same switch with their own events. |
| `outage_compensations` | boolean | no | The **Outage Compensation** switch. Set it to false when the creator would rather settle outages themselves, for example with refunds or coupons. |
| `team_id` | string \| null | no | A team the token's owner belongs to. |
| `handle` | string \| null | no | The portal handle: lowercase letters, digits, dashes and underscores, 5 to 255 characters, unique across Subscriby. Needs a tier with the custom-handle capability; on the Free tier a handle is refused with `TEAM_TIER_REQUIRED`. Changing it moves the portal URL at once and the old link answers 404. |
### Responses
- **200**: The project after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The project's id, the value every project-scoped endpoint takes in its path. |
| `data.team_id` | string \| null | yes | The team the project belongs to. |
| `data.user_id` | string | yes | The creator who owns the project. |
| `data.name` | string | yes | The project's name, 5 to 255 characters. |
| `data.handle` | string \| null | yes | The project's handle: lowercase letters, digits and dashes, unique across Subscriby, the last segment of the portal URL. Null until one is chosen; choosing one needs a tier with the custom-handle capability. |
| `data.description` | string \| array of any \| null | yes | What the project offers, up to 1,000 characters of HTML filtered to a safe subset; null when none. |
| `data.terms` | string \| null | yes | The URL of the terms members are asked to accept; null when none. |
| `data.privacy` | string \| null | yes | The URL of the privacy policy members are asked to accept; null when none. |
| `data.banner_url` | string \| null | yes | The hosted banner artwork; null until uploaded and processed. |
| `data.photo_url` | string \| null | yes | The hosted photo artwork; null until uploaded and processed. |
| `data.metrics` | boolean | yes | The **Enable Public Metrics** switch: whether the portal shows the project's member counts. |
| `data.outage_compensations` | boolean | yes | The **Outage Compensation** switch: when a platform refuses the project's connector for an hour or more and later answers again, every member whose paid access overlapped the gap gets the lost time added to the end of their access automatically. True on every new project. |
| `data.active` | boolean | yes | Whether the project is live. Archiving flips it to false and keeps everything; restoring flips it back. |
| `data.created_at` | string \| null | yes | When the project was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the project last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when a `handle` is sent without the custom-handle capability.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: with per-field messages under `error.fields`.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Delete a project
`DELETE /v1/projects/{project}`
Deletes the project and everything under it. Returns `204 No Content` and emits `project.deleted`. Prefer archiving when you want to pause intake without losing history.
- Requires ability: `project:delete`
- Fires events: `project.deleted`
- MCP tools: `delete_project`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Archive a project
`POST /v1/projects/{project}/archive`
Switches the project off: `active` becomes `false`, the portal stops selling, and every plan, member and payment method is kept. Archive and restore are RPC-style actions separate from the update: they preserve the project's data while flipping `active`, so prefer them over delete when you want to pause intake without losing history. Answers `200` with the project and emits `project.archived`. Gated on `project:update`, not `project:delete`, because it flips one column of the project.
- Requires ability: `project:update`
- Fires events: `project.archived`
- MCP tools: `archive_project`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The project, inactive.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The project's id, the value every project-scoped endpoint takes in its path. |
| `data.team_id` | string \| null | yes | The team the project belongs to. |
| `data.user_id` | string | yes | The creator who owns the project. |
| `data.name` | string | yes | The project's name, 5 to 255 characters. |
| `data.handle` | string \| null | yes | The project's handle: lowercase letters, digits and dashes, unique across Subscriby, the last segment of the portal URL. Null until one is chosen; choosing one needs a tier with the custom-handle capability. |
| `data.description` | string \| array of any \| null | yes | What the project offers, up to 1,000 characters of HTML filtered to a safe subset; null when none. |
| `data.terms` | string \| null | yes | The URL of the terms members are asked to accept; null when none. |
| `data.privacy` | string \| null | yes | The URL of the privacy policy members are asked to accept; null when none. |
| `data.banner_url` | string \| null | yes | The hosted banner artwork; null until uploaded and processed. |
| `data.photo_url` | string \| null | yes | The hosted photo artwork; null until uploaded and processed. |
| `data.metrics` | boolean | yes | The **Enable Public Metrics** switch: whether the portal shows the project's member counts. |
| `data.outage_compensations` | boolean | yes | The **Outage Compensation** switch: when a platform refuses the project's connector for an hour or more and later answers again, every member whose paid access overlapped the gap gets the lost time added to the end of their access automatically. True on every new project. |
| `data.active` | boolean | yes | Whether the project is live. Archiving flips it to false and keeps everything; restoring flips it back. |
| `data.created_at` | string \| null | yes | When the project was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the project last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Restore a project
`POST /v1/projects/{project}/restore`
Switches an archived project back on. Answers `200` with the project and emits `project.restored`.
- Requires ability: `project:update`
- Fires events: `project.restored`
- MCP tools: `restore_project`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The project, active.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The project's id, the value every project-scoped endpoint takes in its path. |
| `data.team_id` | string \| null | yes | The team the project belongs to. |
| `data.user_id` | string | yes | The creator who owns the project. |
| `data.name` | string | yes | The project's name, 5 to 255 characters. |
| `data.handle` | string \| null | yes | The project's handle: lowercase letters, digits and dashes, unique across Subscriby, the last segment of the portal URL. Null until one is chosen; choosing one needs a tier with the custom-handle capability. |
| `data.description` | string \| array of any \| null | yes | What the project offers, up to 1,000 characters of HTML filtered to a safe subset; null when none. |
| `data.terms` | string \| null | yes | The URL of the terms members are asked to accept; null when none. |
| `data.privacy` | string \| null | yes | The URL of the privacy policy members are asked to accept; null when none. |
| `data.banner_url` | string \| null | yes | The hosted banner artwork; null until uploaded and processed. |
| `data.photo_url` | string \| null | yes | The hosted photo artwork; null until uploaded and processed. |
| `data.metrics` | boolean | yes | The **Enable Public Metrics** switch: whether the portal shows the project's member counts. |
| `data.outage_compensations` | boolean | yes | The **Outage Compensation** switch: when a platform refuses the project's connector for an hour or more and later answers again, every member whose paid access overlapped the gap gets the lost time added to the end of their access automatically. True on every new project. |
| `data.active` | boolean | yes | Whether the project is live. Archiving flips it to false and keeps everything; restoring flips it back. |
| `data.created_at` | string \| null | yes | When the project was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the project last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans): A plan is what a project sells: the price, the currency, the billing cycle or the dated windows, the eligibility rules and the resources a purchase unlocks.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors): The platforms a project runs on.
- [Ability Catalog](https://docs.subscriby.net/api/v1/abilities): Every ability string a token can carry, what enforces it on the API and the MCP server, and how to pick the right ones when minting.
- [Webhook Events API](https://docs.subscriby.net/api/v1/reference/webhook-events): A read-only polling endpoint that surfaces the most recent webhook deliveries for a given event type.
- [project.* events](https://docs.subscriby.net/webhooks/v1/events/project): What the endpoints below announce; connector.connected and connector.disconnected come from the connect and disconnect flows, not from here.
---
# Resources API
Source: https://docs.subscriby.net/api/v1/reference/resources
Resources are what a plan unlocks: a place a connector gates (a channel, group or supergroup on Telegram today; each connector's own kinds as it launches) or a manually-tracked perk such as a PDF, a token or a URL. The REST API reads every resource, creates manual perks, and asks the connector to link a place; it never attaches a place itself.
> **A place is linked only through its connector**, which proves the installation administers it before the resource exists. The link-request endpoint starts that conversation; the REST and MCP surfaces accept no place identifier, so a token can never attach an arbitrary place.
## Endpoints
- `GET /v1/projects/{project}/resources` — [List a project's resources](#list-a-projects-resources)
- `POST /v1/projects/{project}/resources` — [Create a manual perk](#create-a-manual-perk)
- `GET /v1/projects/{project}/resources/{resource}` — [Get a resource](#get-a-resource)
- `PATCH /v1/projects/{project}/resources/{resource}` — [Update a resource](#update-a-resource)
- `DELETE /v1/projects/{project}/resources/{resource}` — [Delete a resource](#delete-a-resource)
- `POST /v1/projects/{project}/resources/link-requests` — [Request a place link](#request-a-place-link)
- `POST /v1/projects/{project}/resources/{resource}/unlink` — [Unlink a resource's place](#unlink-a-resources-place)
- `POST /v1/projects/{project}/resources/{resource}/activate` — [Activate a resource](#activate-a-resource)
- `POST /v1/projects/{project}/resources/{resource}/deactivate` — [Deactivate a resource](#deactivate-a-resource)
## List a project's resources
`GET /v1/projects/{project}/resources`
```bash
curl "https://api.subscriby.net/v1/projects/$PROJECT_ID/resources?kind=telegram:channel" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Newest first, paged with `page` and `per_page`, sortable with `sort_by` (`created_at`, `kind`, `title`, `active`) and `sort_direction`. `kind` narrows to one kind, `connector` to every resource one connector gates.
- Requires ability: `project-resource:view-any`
- MCP tools: `list_resources`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `kind` | query | string \| null | no | One kind of resource, spelled as the objects spell it: `manual`, or a connector's `connector:kind` as the connector directory lists each connector's `resource_kinds`. Any other shape is refused rather than answered with an empty page. |
| `connector` | query | string \| null | no | Every resource one connector gates, by the connector's key as the connector directory lists it. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
| `limit` | query | integer | no | Legacy alias of `per_page`, kept for clients that predate it. `per_page` wins when both are sent. |
### Responses
- **200**: The page, newest first.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The resource's id. |
| `data[].project_id` | string | yes | The project the resource belongs to. |
| `data[].kind` | string | yes | `manual` for a perk the creator hands over by hand, otherwise `connector:kind` as the connector directory lists each connector's `resource_kinds`. |
| `data[].connector` | string \| null | yes | The connector key the place is on; null for a manual perk. |
| `data[].space` | object \| null | yes | The place the resource is bound to. Null for a manual perk and for a place-kind resource that was unlinked. |
| `data[].space.id` | string | yes | The space's id. |
| `data[].space.external_id` | string | yes | The platform's own id for the place. |
| `data[].space.title` | string \| null | yes | The place's title as the connector last read it. |
| `data[].title` | string | yes | The resource's name on plan cards; for a linked place, the place's title at link time. |
| `data[].description` | string \| array of any \| null | yes | Up to 1,000 characters of well-formed HTML, shown as a perk beside the title; null when none. |
| `data[].active` | boolean | yes | Whether plans grant it right now. An inactive resource keeps its link and its plans, but new members do not receive it. |
| `data[].created_at` | string \| null | yes | When the resource was created, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When the resource last changed, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `kind` is neither `manual` nor `connector:kind`, or `connector` is not a connector key; a typo in an automation is noticed instead of read as "no resources".
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Create a manual perk
`POST /v1/projects/{project}/resources`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/resources \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"title": "Onboarding PDF",
"description": "https://example.com/onboarding.pdf"
}'
```
Answers `201` with the new resource, `kind: "manual"`, and emits `project.resource.created`. The payload names no kind: a manual perk is the only resource this endpoint creates, and a place a connector gates is asked for with the link-request endpoint. A resource is what a plan unlocks, and nothing is sold before a connector can deliver it, so a project running no connected connector is refused, a manual perk included: install a connector on the project and connect it first.
- Requires ability: `project-resource:create`
- Fires events: `project.resource.created`
- MCP tools: `create_resource`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A new manual perk. The payload names no kind and no place: a place a connector gates is asked for with the link-request endpoint.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | yes | The perk's name on plan cards, 5 to 255 characters. |
| `description` | string \| null | no | What the perk is, up to 1,000 characters. HTML is filtered to a safe subset before storage. |
| `active` | boolean | no | Whether plans grant it right away. Defaults to true. |
### Responses
- **201**: The new resource.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The resource's id. |
| `data.project_id` | string | yes | The project the resource belongs to. |
| `data.kind` | string | yes | `manual` for a perk the creator hands over by hand, otherwise `connector:kind` as the connector directory lists each connector's `resource_kinds`. |
| `data.connector` | string \| null | yes | The connector key the place is on; null for a manual perk. |
| `data.space` | object \| null | yes | The place the resource is bound to. Null for a manual perk and for a place-kind resource that was unlinked. |
| `data.space.id` | string | yes | The space's id. |
| `data.space.external_id` | string | yes | The platform's own id for the place. |
| `data.space.title` | string \| null | yes | The place's title as the connector last read it. |
| `data.title` | string | yes | The resource's name on plan cards; for a linked place, the place's title at link time. |
| `data.description` | string \| array of any \| null | yes | Up to 1,000 characters of well-formed HTML, shown as a perk beside the title; null when none. |
| `data.active` | boolean | yes | Whether plans grant it right now. An inactive resource keeps its link and its plans, but new members do not receive it. |
| `data.created_at` | string \| null | yes | When the resource was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the resource last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `CONNECTOR_NOT_INSTALLED`: when no connector on the project is connected.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `title` is missing or outside 5 to 255 characters, or `description` exceeds 1,000 characters.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a resource
`GET /v1/projects/{project}/resources/{resource}`
One resource with its place, when it has one.
| Field | Type | Notes |
| ------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | string | `manual` for a perk the creator hands over by hand, otherwise `connector:kind` as the [connector directory](https://docs.subscriby.net/api/v1/reference/connectors) lists each connector's `resource_kinds` (`telegram:channel`, `telegram:group`, `telegram:supergroup`). |
| `connector` | string or null | The connector key the place is on; `null` for a manual perk. |
| `space` | object or null | The place the resource is bound to: its `id`, the platform's own `external_id` and its `title` as the connector last read it. `null` for a manual perk and for a place-kind resource that was unlinked. |
| `title` | string | The resource's name on plan cards; for a linked place, the place's title at link time. |
| `description` | string or null | Up to 1,000 characters of well-formed HTML, shown as a perk beside the title. |
| `active` | boolean | Whether plans grant it right now. An inactive resource keeps its link and its plans, but new members do not receive it. |
A manual perk reads `{ "kind": "manual", "connector": null, "space": null, … }`.
- Requires ability: `project-resource:view`
- MCP tools: `get_resource`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
### Responses
- **200**: The resource.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The resource's id. |
| `data.project_id` | string | yes | The project the resource belongs to. |
| `data.kind` | string | yes | `manual` for a perk the creator hands over by hand, otherwise `connector:kind` as the connector directory lists each connector's `resource_kinds`. |
| `data.connector` | string \| null | yes | The connector key the place is on; null for a manual perk. |
| `data.space` | object \| null | yes | The place the resource is bound to. Null for a manual perk and for a place-kind resource that was unlinked. |
| `data.space.id` | string | yes | The space's id. |
| `data.space.external_id` | string | yes | The platform's own id for the place. |
| `data.space.title` | string \| null | yes | The place's title as the connector last read it. |
| `data.title` | string | yes | The resource's name on plan cards; for a linked place, the place's title at link time. |
| `data.description` | string \| array of any \| null | yes | Up to 1,000 characters of well-formed HTML, shown as a perk beside the title; null when none. |
| `data.active` | boolean | yes | Whether plans grant it right now. An inactive resource keeps its link and its plans, but new members do not receive it. |
| `data.created_at` | string \| null | yes | When the resource was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the resource last changed, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Update a resource
`PATCH /v1/projects/{project}/resources/{resource}`
```bash
curl -X PATCH https://api.subscriby.net/v1/projects/$PROJECT_ID/resources/$RESOURCE_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "title": "Members lounge (VIP)" }'
```
Partial by design: only the fields present in the body change, so a title-only update leaves the description exactly as stored, filtered HTML included, and logs no description edit. `kind` and `space` are not writable here: a resource keeps its kind for life, and its place moves only through the connector.
Answers `200` with the resource. When at least one field actually changed, `project.resource.updated` fires once with a `changes` map of `{field: {from, to}}`; a body that restates the stored values writes nothing and emits nothing.
- Requires ability: `project-resource:update`
- Fires events: `project.resource.updated`
- MCP tools: `update_resource`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
The changes to a resource. Every field is optional and an omitted one keeps its stored value. `kind` and `space` are not writable: a resource keeps its kind for life, and its place moves only through the connector.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | no | The resource's name on plan cards, 5 to 255 characters. |
| `description` | string \| null | no | What the resource is, up to 1,000 characters, the same HTML filter as create; null clears it. |
| `active` | boolean | no | Whether plans grant it; the activate and deactivate endpoints flip the same switch. |
### Responses
- **200**: The resource after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The resource's id. |
| `data.project_id` | string | yes | The project the resource belongs to. |
| `data.kind` | string | yes | `manual` for a perk the creator hands over by hand, otherwise `connector:kind` as the connector directory lists each connector's `resource_kinds`. |
| `data.connector` | string \| null | yes | The connector key the place is on; null for a manual perk. |
| `data.space` | object \| null | yes | The place the resource is bound to. Null for a manual perk and for a place-kind resource that was unlinked. |
| `data.space.id` | string | yes | The space's id. |
| `data.space.external_id` | string | yes | The platform's own id for the place. |
| `data.space.title` | string \| null | yes | The place's title as the connector last read it. |
| `data.title` | string | yes | The resource's name on plan cards; for a linked place, the place's title at link time. |
| `data.description` | string \| array of any \| null | yes | Up to 1,000 characters of well-formed HTML, shown as a perk beside the title; null when none. |
| `data.active` | boolean | yes | Whether plans grant it right now. An inactive resource keeps its link and its plans, but new members do not receive it. |
| `data.created_at` | string \| null | yes | When the resource was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the resource last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `title` is outside 5 to 255 characters or `description` exceeds 1,000 characters.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Delete a resource
`DELETE /v1/projects/{project}/resources/{resource}`
Deletes the row. Plans that linked it stop granting it. Returns `204 No Content` and emits `project.resource.deleted`.
- Requires ability: `project-resource:delete`
- Fires events: `project.resource.deleted`
- MCP tools: `delete_resource`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Request a place link
`POST /v1/projects/{project}/resources/link-requests`
A place cannot be created from the API: only the connector can prove it administers the place. This endpoint asks the creator, through the connector, to pick the place; the connector messages them with a picker and the resource appears the moment they choose, announced by `project.resource.linked`. Nothing is created by the call itself.
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/resources/link-requests \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "kind": "telegram:channel" }'
```
Answers `202 Accepted` once the creator has been asked. The dashboard's **Send Request** button on the resource editor and the `request_resource_link` tool run the same action.
- Requires ability: `project-resource:create`
- MCP tools: `request_resource_link`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
Which kind of place to ask the creator for. Only a connector's kind can be requested; a manual perk is created outright with the create endpoint.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | string | yes | A connector's kind of place spelled `connector:kind`, as the connector directory lists each connector's `resource_kinds`. `manual` and a bare kind word are refused. |
### Responses
- **202**: 202 with `project_id`, `kind` and `status: request_sent`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.project_id` | string | yes | The project the place will belong to. |
| `data.kind` | string | yes | The kind asked for, as sent. |
| `data.status` | string | yes | Always `request_sent`: the creator has been asked on the connector. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `CONNECTOR_NOT_INSTALLED`: when the project runs no connected connector.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `kind` is `manual` or a bare kind word, when no connected connector on the project gates the kind, or when the creator cannot be reached on the connector; `error.context.kind` names the kind.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Unlink a resource's place
`POST /v1/projects/{project}/resources/{resource}/unlink`
Detaches the place from a resource, so `space` reads `null` and the row becomes a placeholder. The resource stays in the project's list and keeps its `kind`, but no longer grants access through the connector. Re-linking goes through the connector again. Answers `200` with the resource and emits `project.resource.unlinked`.
- Requires ability: `project-resource:update`
- Fires events: `project.resource.unlinked`
- MCP tools: `unlink_resource`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The resource after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The resource's id. |
| `data.project_id` | string | yes | The project the resource belongs to. |
| `data.kind` | string | yes | `manual` for a perk the creator hands over by hand, otherwise `connector:kind` as the connector directory lists each connector's `resource_kinds`. |
| `data.connector` | string \| null | yes | The connector key the place is on; null for a manual perk. |
| `data.space` | object \| null | yes | The place the resource is bound to. Null for a manual perk and for a place-kind resource that was unlinked. |
| `data.space.id` | string | yes | The space's id. |
| `data.space.external_id` | string | yes | The platform's own id for the place. |
| `data.space.title` | string \| null | yes | The place's title as the connector last read it. |
| `data.title` | string | yes | The resource's name on plan cards; for a linked place, the place's title at link time. |
| `data.description` | string \| array of any \| null | yes | Up to 1,000 characters of well-formed HTML, shown as a perk beside the title; null when none. |
| `data.active` | boolean | yes | Whether plans grant it right now. An inactive resource keeps its link and its plans, but new members do not receive it. |
| `data.created_at` | string \| null | yes | When the resource was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the resource last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Activate a resource
`POST /v1/projects/{project}/resources/{resource}/activate`
The switch on the dashboard's resource row, on. A plan grants only its active resources, so the resource returns to what new members receive. Idempotent and answers `200` with the resource; only a flip emits `project.resource.updated`, with `changes.active`.
- Requires ability: `project-resource:update`
- Fires events: `project.resource.updated`
- MCP tools: `activate_resource`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The resource after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The resource's id. |
| `data.project_id` | string | yes | The project the resource belongs to. |
| `data.kind` | string | yes | `manual` for a perk the creator hands over by hand, otherwise `connector:kind` as the connector directory lists each connector's `resource_kinds`. |
| `data.connector` | string \| null | yes | The connector key the place is on; null for a manual perk. |
| `data.space` | object \| null | yes | The place the resource is bound to. Null for a manual perk and for a place-kind resource that was unlinked. |
| `data.space.id` | string | yes | The space's id. |
| `data.space.external_id` | string | yes | The platform's own id for the place. |
| `data.space.title` | string \| null | yes | The place's title as the connector last read it. |
| `data.title` | string | yes | The resource's name on plan cards; for a linked place, the place's title at link time. |
| `data.description` | string \| array of any \| null | yes | Up to 1,000 characters of well-formed HTML, shown as a perk beside the title; null when none. |
| `data.active` | boolean | yes | Whether plans grant it right now. An inactive resource keeps its link and its plans, but new members do not receive it. |
| `data.created_at` | string \| null | yes | When the resource was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the resource last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Deactivate a resource
`POST /v1/projects/{project}/resources/{resource}/deactivate`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/resources/$RESOURCE_ID/deactivate \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
The switch on the dashboard's resource row, off. An inactive resource stays in the project and keeps its place, but a plan grants only its active resources, so it drops out of what new members receive until it is switched back on. Idempotent and answers `200` with the resource; only a flip emits `project.resource.updated`, with `changes.active`.
- Requires ability: `project-resource:update`
- Fires events: `project.resource.updated`
- MCP tools: `deactivate_resource`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `resource` | path | string (uuid) | yes | The resource, resolved within the project by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The resource after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The resource's id. |
| `data.project_id` | string | yes | The project the resource belongs to. |
| `data.kind` | string | yes | `manual` for a perk the creator hands over by hand, otherwise `connector:kind` as the connector directory lists each connector's `resource_kinds`. |
| `data.connector` | string \| null | yes | The connector key the place is on; null for a manual perk. |
| `data.space` | object \| null | yes | The place the resource is bound to. Null for a manual perk and for a place-kind resource that was unlinked. |
| `data.space.id` | string | yes | The space's id. |
| `data.space.external_id` | string | yes | The platform's own id for the place. |
| `data.space.title` | string \| null | yes | The place's title as the connector last read it. |
| `data.title` | string | yes | The resource's name on plan cards; for a linked place, the place's title at link time. |
| `data.description` | string \| array of any \| null | yes | Up to 1,000 characters of well-formed HTML, shown as a perk beside the title; null when none. |
| `data.active` | boolean | yes | Whether plans grant it right now. An inactive resource keeps its link and its plans, but new members do not receive it. |
| `data.created_at` | string \| null | yes | When the resource was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the resource last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors): Each connector's resource_kinds, with the labels, grant mode and plan shapes of every kind.
- [Resources](https://docs.subscriby.net/creators/resources): Add the places your connectors gate and the manual perks a plan grants, and decide what a subscriber actually gets when they pay.
- [Webhook Events API](https://docs.subscriby.net/api/v1/reference/webhook-events): A read-only polling endpoint that surfaces the most recent webhook deliveries for a given event type.
- [project.* events](https://docs.subscriby.net/webhooks/v1/events/project): linked fires when the connector finishes a link request, and updated also fires when a recovery swap moves the place (changes.space_id); the endpoints below announce their own changes.
---
# Roles API
Source: https://docs.subscriby.net/api/v1/reference/roles
A **role** is the permission set a collaborator holds on a team. Subscriby seeds three on every new team, `admin`, `manager` and `viewer`, and a team can add its own: a `support-agent` who may read and answer conversations but touch nothing else, a `finance` role that reads payouts and analytics. Each collaborator holds exactly one role, assigned when they are invited and changed through the [team members](https://docs.subscriby.net/api/v1/reference/team-members) endpoints; [groups](https://docs.subscriby.net/api/v1/reference/groups) layer extra permissions on top of it for several people at once.
A role's `permissions` is a flat array of the same ability strings a personal access token carries, so a role can be matched against a token without a second round-trip, and the [ability catalog](https://docs.subscriby.net/api/v1/abilities) is the list to pick from. Changing a role changes what every holder may do from the next request; deleting one detaches its permissions and leaves its holders in the team with nothing beyond what their groups grant.
Roles are read across every team the caller owns or belongs to, and a role on any other team is a `404 RESOURCE_NOT_FOUND`, never a `403`, so existence never leaks. Every write announces itself as a `role.*` event.
## Endpoints
- `GET /v1/roles` — [List roles](#list-roles)
- `POST /v1/roles` — [Create a role](#create-a-role)
- `GET /v1/roles/{role}` — [Get a role](#get-a-role)
- `PATCH /v1/roles/{role}` — [Update a role](#update-a-role)
- `DELETE /v1/roles/{role}` — [Delete a role](#delete-a-role)
## List roles
`GET /v1/roles`
```bash
curl https://api.subscriby.net/v1/roles \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Every role across the teams the caller can see, with its permission codes. Visibility is the union of every team the caller owns or belongs to; a role from a team the caller cannot see answers `404 RESOURCE_NOT_FOUND` rather than `403`, so existence never leaks.
- Requires ability: `role:view-any`
- MCP tools: `list_roles`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: Roles across every team the caller belongs to.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The role's id. |
| `data[].team_id` | string | yes | The team the role belongs to. |
| `data[].code` | string | yes | The stable identifier permissions are addressed by, unique within the team and immutable after creation. |
| `data[].name` | string \| null | yes | The display name. |
| `data[].description` | string \| null | yes | What the role is for; null when none was given. |
| `data[].owner_user_id` | string \| null | yes | The user who created the role, the only one besides the team owner who may delete it. |
| `data[].permissions` | array of any | yes | The exact ability strings the role grants, the same strings used as token abilities, so roles can be matched against tokens without a second round-trip. |
| `data[].created_at` | string \| null | yes | When the role was created, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Create a role
`POST /v1/roles`
```bash
curl -X POST https://api.subscriby.net/v1/roles \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"code": "support-agent",
"name": "Support Agent",
"description": "Answers tickets, cannot touch billing",
"permissions": ["support-conversation:view-any", "support-conversation:update"]
}'
```
> **Choose the code deliberately.** `code` is the stable identifier permissions are addressed by. It must be unique within the team and **cannot be changed afterwards**: renaming one would detach every assignment that referenced it. Only `name`, `description` and the permission set are mutable.
`permissions` accepts codes from the permission catalog in `entity:action` form. An unknown code is refused rather than silently dropped, so a typo fails loudly instead of creating a role that grants less than you think. Omit the field for a role that grants nothing yet.
Answers `201` with the role and emits `role.created`.
- Requires ability: `role:create`
- Fires events: `role.created`
- MCP tools: `create_role`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A role's team, code, name, description and permissions. On create `team_id`, `code` and `name` are required; on update `team_id` and `code` are prohibited (a role is neither moved nor renamed by code) and every other field is optional.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string (uuid) | yes | The team to create the role in; the caller must belong to it, and a team outside their account answers 404. Prohibited on update: a role cannot be moved between teams. |
| `code` | string | yes | The stable identifier permissions are addressed by, up to 255 letters, numbers, dashes and underscores, unique within the team and immutable afterwards: renaming one would detach every assignment that referenced it. Prohibited on update. |
| `name` | string | yes | The display name, up to 255 characters. |
| `description` | string \| null | no | What the role is for, up to 255 characters; null clears it. |
| `permissions` | array of `project:view-any`, `project:view`, `project:create`, `project:update`, `project:delete`, `project-payment-method:view-any`, `project-payment-method:view`, `project-payment-method:create`, `project-payment-method:update`, `project-payment-method:delete`, `project-resource:view-any`, `project-resource:view`, `project-resource:create`, `project-resource:update`, `project-resource:delete`, `project-access-code:view-any`, `project-access-code:view`, `project-access-code:create`, `project-access-code:update`, `project-access-code:delete`, `project-coupon:view-any`, `project-coupon:view`, `project-coupon:create`, `project-coupon:update`, `project-coupon:delete`, `project-subscription:view-any`, `project-subscription:view`, `project-subscription:create`, `project-subscription:update`, `project-subscription:delete`, `project-subscription-plan:view-any`, `project-subscription-plan:view`, `project-subscription-plan:create`, `project-subscription-plan:update`, `project-subscription-plan:delete`, `project-user:view-any`, `project-user:view`, `project-user:create`, `project-user:update`, `project-user:delete`, `project-recovery:view-any`, `project-recovery:view`, `project-recovery:create`, `project-recovery:update`, `project-recovery:delete`, `project-connector:view-any`, `project-connector:view`, `project-connector:create`, `project-connector:update`, `project-connector:delete`, `support-conversation:view-any`, `support-conversation:view`, `support-conversation:create`, `support-conversation:update`, `support-conversation:delete`, `team-member:view-any`, `team-member:view`, `team-member:invite`, `team-member:remove`, `team-member:update-role` | no | The permission codes the role grants, in `entity:action` form. Sending it sets the role's permissions to exactly this list; omit the key to leave the existing set untouched. Omit it on create for a role that grants nothing yet. |
### Responses
- **201**: The role resource as a 201.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The role's id. |
| `data.team_id` | string | yes | The team the role belongs to. |
| `data.code` | string | yes | The stable identifier permissions are addressed by, unique within the team and immutable after creation. |
| `data.name` | string \| null | yes | The display name. |
| `data.description` | string \| null | yes | What the role is for; null when none was given. |
| `data.owner_user_id` | string \| null | yes | The user who created the role, the only one besides the team owner who may delete it. |
| `data.permissions` | array of any | yes | The exact ability strings the role grants, the same strings used as token abilities, so roles can be matched against tokens without a second round-trip. |
| `data.created_at` | string \| null | yes | When the role was created, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: below the Growth tier; nothing changes.
- **404**: `RESOURCE_NOT_FOUND`: when `team_id` is not one of the caller's teams.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when a role with that code already exists in the team (`error.context.code` names it), or a permission code is unknown.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a role
`GET /v1/roles/{role}`
One role with its permission codes. `permissions` is a flat array of the exact ability strings granted by this role, the same strings used as token abilities, so you can match roles against tokens without a second round-trip.
- Requires ability: `role:view`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `role` | path | string (uuid) | yes | The role, resolved by the route binder. |
### Responses
- **200**: The role resource with its permissions.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The role's id. |
| `data.team_id` | string | yes | The team the role belongs to. |
| `data.code` | string | yes | The stable identifier permissions are addressed by, unique within the team and immutable after creation. |
| `data.name` | string \| null | yes | The display name. |
| `data.description` | string \| null | yes | What the role is for; null when none was given. |
| `data.owner_user_id` | string \| null | yes | The user who created the role, the only one besides the team owner who may delete it. |
| `data.permissions` | array of any | yes | The exact ability strings the role grants, the same strings used as token abilities, so roles can be matched against tokens without a second round-trip. |
| `data.created_at` | string \| null | yes | When the role was created, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Update a role
`PATCH /v1/roles/{role}`
```bash
curl -X PATCH https://api.subscriby.net/v1/roles/$ROLE_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"name": "Senior Support Agent", "permissions": ["support-conversation:view-any"]}'
```
> **Permissions replace, they do not merge.** Sending `permissions` sets the role's permissions to exactly that list; anything omitted is revoked. **Omit the key entirely** to leave the existing set untouched while changing only the name or description.
Answers `200` with the role and emits `role.updated`.
- Requires ability: `role:update`
- Fires events: `role.updated`
- MCP tools: `update_role`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `role` | path | string (uuid) | yes | The role, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A role's team, code, name, description and permissions. On create `team_id`, `code` and `name` are required; on update `team_id` and `code` are prohibited (a role is neither moved nor renamed by code) and every other field is optional.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string (uuid) | yes | The team to create the role in; the caller must belong to it, and a team outside their account answers 404. Prohibited on update: a role cannot be moved between teams. |
| `code` | string | yes | The stable identifier permissions are addressed by, up to 255 letters, numbers, dashes and underscores, unique within the team and immutable afterwards: renaming one would detach every assignment that referenced it. Prohibited on update. |
| `name` | string | yes | The display name, up to 255 characters. |
| `description` | string \| null | no | What the role is for, up to 255 characters; null clears it. |
| `permissions` | array of `project:view-any`, `project:view`, `project:create`, `project:update`, `project:delete`, `project-payment-method:view-any`, `project-payment-method:view`, `project-payment-method:create`, `project-payment-method:update`, `project-payment-method:delete`, `project-resource:view-any`, `project-resource:view`, `project-resource:create`, `project-resource:update`, `project-resource:delete`, `project-access-code:view-any`, `project-access-code:view`, `project-access-code:create`, `project-access-code:update`, `project-access-code:delete`, `project-coupon:view-any`, `project-coupon:view`, `project-coupon:create`, `project-coupon:update`, `project-coupon:delete`, `project-subscription:view-any`, `project-subscription:view`, `project-subscription:create`, `project-subscription:update`, `project-subscription:delete`, `project-subscription-plan:view-any`, `project-subscription-plan:view`, `project-subscription-plan:create`, `project-subscription-plan:update`, `project-subscription-plan:delete`, `project-user:view-any`, `project-user:view`, `project-user:create`, `project-user:update`, `project-user:delete`, `project-recovery:view-any`, `project-recovery:view`, `project-recovery:create`, `project-recovery:update`, `project-recovery:delete`, `project-connector:view-any`, `project-connector:view`, `project-connector:create`, `project-connector:update`, `project-connector:delete`, `support-conversation:view-any`, `support-conversation:view`, `support-conversation:create`, `support-conversation:update`, `support-conversation:delete`, `team-member:view-any`, `team-member:view`, `team-member:invite`, `team-member:remove`, `team-member:update-role` | no | The permission codes the role grants, in `entity:action` form. Sending it sets the role's permissions to exactly this list; omit the key to leave the existing set untouched. Omit it on create for a role that grants nothing yet. |
### Responses
- **200**: The updated role resource.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The role's id. |
| `data.team_id` | string | yes | The team the role belongs to. |
| `data.code` | string | yes | The stable identifier permissions are addressed by, unique within the team and immutable after creation. |
| `data.name` | string \| null | yes | The display name. |
| `data.description` | string \| null | yes | What the role is for; null when none was given. |
| `data.owner_user_id` | string \| null | yes | The user who created the role, the only one besides the team owner who may delete it. |
| `data.permissions` | array of any | yes | The exact ability strings the role grants, the same strings used as token abilities, so roles can be matched against tokens without a second round-trip. |
| `data.created_at` | string \| null | yes | When the role was created, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: below the Growth tier; nothing changes.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `code` or `team_id` is sent (a role cannot be renamed at the code level or moved between teams), or a permission code is unknown.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Delete a role
`DELETE /v1/roles/{role}`
```bash
curl -X DELETE https://api.subscriby.net/v1/roles/$ROLE_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Returns `204 No Content`, and detaches the role's permissions on the way out. Anyone currently holding the role loses whatever it granted them; they stay in the team. Not gated by tier. Emits `role.deleted`.
Restricted to whoever **created** the role, unless the caller **owns** the team. Belonging to a team is not licence to dismantle how everyone else in it is permissioned.
- Requires ability: `role:delete`
- Fires events: `role.deleted`
- MCP tools: `delete_role`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `role` | path | string (uuid) | yes | The role, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the caller neither created the role nor owns the team; `error.context.role_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Teams API](https://docs.subscriby.net/api/v1/reference/teams): Teams group creators, roles, and projects.
- [Team Members API](https://docs.subscriby.net/api/v1/reference/team-members): Team members are the humans who collaborate inside a team: the creator who owns it and the people they invited to help run its projects.
- [Groups API](https://docs.subscriby.net/api/v1/reference/groups): Named permission bundles several people share
- [Ability Catalog](https://docs.subscriby.net/api/v1/abilities): Every ability string a token can carry, what enforces it on the API and the MCP server, and how to pick the right ones when minting.
- [role.* events](https://docs.subscriby.net/webhooks/v1/events/role): Role create, update and delete.
---
# Subscriptions API
Source: https://docs.subscriby.net/api/v1/reference/subscriptions
A **subscription** is one member's purchase of one plan: the row that says who bought what, on which payment method, for how much, and until when. Every checkout on the portal or in the bot, every redeemed access code and every cardless trial creates one, so there is no `POST` here: a subscription comes into being when a member pays or redeems, and these endpoints let you read it, follow the access it granted and drive the rest of its life.
The row carries a `payment_status` that tracks the provider's view of the money (`trialing`, `active`, `past_due`, `paused`, `canceled`, `expired` and the rest), an `ends_at` that already includes any outage compensation the member was owed, and, when read individually, its **grants**: the access ledger with one row per resource the plan unlocks, and per dated window for a pass. A grant says which connector admitted the member, how (`bearer_link`, `membership`, `role` or `creator_task`), where it stands (`pending_identity`, `pending`, `held`, `granted`, `revoked` or `failed`) and why when it failed. Read the grants, not the payment status, to know whether a member is actually inside the channel.
The lifecycle endpoints are RPC-style actions rather than field edits, because each one talks to a payment provider or a connector on the way: **cancel** queues the provider-side cancellation and answers `202`; **pause** and **unpause** suspend and restore access while billing continues; **reactivate** calls off a scheduled cancellation, on Stripe only, since the other providers end the agreement outright; **reissue** revokes the member's invite links and mints fresh ones; and **remind** re-sends a pass holder the links they have not used yet. Every transition announces itself as a `subscription.*` event. Subscriptions are read across every project the token reaches, a `scope:project` token sees only its projects, and a subscription of any other project is a `404` on every endpoint here.
## Endpoints
- `GET /v1/subscriptions` — [List subscriptions](#list-subscriptions)
- `GET /v1/subscriptions/{subscription}` — [Get a subscription](#get-a-subscription)
- `GET /v1/subscriptions/{subscription}/grants` — [List a subscription's grants](#list-a-subscriptions-grants)
- `POST /v1/subscriptions/{subscription}/cancel` — [Cancel a subscription](#cancel-a-subscription)
- `POST /v1/subscriptions/{subscription}/pause` — [Pause a subscription](#pause-a-subscription)
- `POST /v1/subscriptions/{subscription}/unpause` — [Unpause a subscription](#unpause-a-subscription)
- `POST /v1/subscriptions/{subscription}/reactivate` — [Reactivate a subscription](#reactivate-a-subscription)
- `POST /v1/subscriptions/{subscription}/remind` — [Remind a pass holder](#remind-a-pass-holder)
- `POST /v1/subscriptions/{subscription}/grants/reissue` — [Reissue a subscription's access](#reissue-a-subscriptions-access)
## List subscriptions
`GET /v1/subscriptions`
```bash
curl "https://api.subscriby.net/v1/subscriptions?status=active" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The held subscriptions across every project the token reaches, newest first, paged with `page` and `per_page` (default 25; `limit` is accepted as an alias). `status` matches a single payment status and `plan_id` scopes to one plan. Rows carry no `grants`.
A token minted with `scope:project:` entries lists only the subscriptions of those projects, and a subscription of any other project is `404 RESOURCE_NOT_FOUND` on every endpoint of this resource.
- Requires ability: `project-subscription:view-any`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | `trialing`, `active`, `incomplete`, `expired`, `past_due`, `canceled`, `unpaid`, `paused`, `processing`, `succeeded`, `capturable`, `failed`, `pending` \| null | no | Match a single payment status: `trialing`, `active`, `incomplete`, `expired`, `past_due`, `canceled`, `unpaid`, `paused`, `processing`, `succeeded`, `capturable` or `failed`. An unknown value is refused. |
| `plan_id` | query | string \| null (uuid) | no | Scope to one plan. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
| `limit` | query | integer | no | Legacy alias of `per_page`, kept for clients that predate it. `per_page` wins when both are sent. |
### Responses
- **200**: The page of held subscriptions, newest first.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The subscription's id. |
| `data[].plan_id` | string | yes | The plan that was bought. |
| `data[].subscriber_id` | string \| null | yes | The member who holds it. |
| `data[].method_id` | string | yes | The payment method it was bought through; null for an access code. |
| `data[].payment_status` | string \| `trialing`, `active`, `incomplete`, `expired`, `past_due`, `canceled`, `unpaid`, `paused`, `processing`, `succeeded`, `capturable`, `failed`, `pending` | yes | Where the purchase stands: `trialing`, `active`, `incomplete`, `expired`, `past_due`, `canceled`, `unpaid`, `paused`, `processing`, `succeeded`, `capturable` or `failed`. |
| `data[].payment_price` | string | yes | What the member pays per period, as a decimal string in the plan's currency. |
| `data[].currency_id` | string | yes | The currency the price is in. |
| `data[].trial_ends_at` | string \| null | yes | When the trial ends, ISO 8601; null without a trial. |
| `data[].ends_at` | string \| null | yes | When the current access ends, ISO 8601, `compensation_seconds` already included: a one-time purchase simply ends later, while a recurring plan's `ends_at` runs that many seconds past the period the member last paid for. Null while open-ended. |
| `data[].canceled` | boolean | yes | Whether a cancellation is scheduled or done; the access runs to `ends_at`. |
| `data[].compensation_seconds` | integer | yes | The outage time Outage Compensation has banked on the purchase, 0 for almost every subscription; the renewal the gateway bills is `ends_at` minus this. |
| `data[].redeemed_at` | string \| null | yes | When an access code was redeemed, ISO 8601; null for a paid purchase or an unredeemed code. |
| `data[].created_at` | string \| null | yes | When the purchase was made, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When the row last changed, ISO 8601. |
| `data[].grants` | array of object | no | The access ledger rows, one per resource and dated window; embedded on the detail read only, never on the list. |
| `data[].grants[].id` | string | yes | The grant's id. |
| `data[].grants[].subscription_id` | string | yes | The purchase the grant belongs to. |
| `data[].grants[].resource_id` | string | yes | The resource the grant admits the holder to. |
| `data[].grants[].window_id` | string \| null | yes | The dated pass window the grant is for; null for continuous access. |
| `data[].grants[].identity_id` | string \| null | yes | The connector account admitted, the same id the member's identities endpoint returns; null while none is linked or for a hand-arranged perk. |
| `data[].grants[].connector` | string \| null | yes | The connector that gave the access, by key; null for a manual perk. |
| `data[].grants[].mode` | string | yes | How the access is given: `bearer_link` (a personal invite link the member comes through), `membership` (the connector added the member itself), `role` (a role was assigned) or `creator_task` (the creator has to do something by hand). |
| `data[].grants[].state` | string | yes | `pending_identity` (the member has not linked an account on that connector yet; issued the moment they connect one), `pending` (issued, not yet used), `held` (issued ahead of a pass window and released when it opens), `granted`, `revoked` or `failed`. |
| `data[].grants[].reference` | string \| null | yes | The connector's handle on the grant (the invite link on a connector that grants by link); null before anything was issued. Treat it as a secret: whoever holds a bearer link can use it. |
| `data[].grants[].granted_at` | string \| null | yes | When the access was granted, ISO 8601; null until then. |
| `data[].grants[].revoked_at` | string \| null | yes | When the access was revoked, ISO 8601; null while standing. |
| `data[].grants[].failure_kind` | string \| null | yes | Why a `failed` grant failed: `unreachable`, `not_permitted`, `target_missing`, `rate_limited`, `configuration`, `transient` or `other`; null otherwise. |
| `data[].grants[].failure_detail` | string \| null | yes | The sentence the creator sees in the dashboard for a failed grant; null otherwise. |
| `data[].grants[].created_at` | string \| null | yes | When the ledger row was written, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `status` is not a payment status or `plan_id` is not a UUID.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a subscription
`GET /v1/subscriptions/{subscription}`
One subscription with its `grants` embedded.
`payment_status` is one of `trialing`, `active`, `incomplete`, `expired`, `past_due`, `canceled`, `unpaid`, `paused`, `processing`, `succeeded`, `capturable` or `failed` (the `subscriby://enums/subscription-status` MCP resource lists them).
`compensation_seconds` is the outage time [Outage Compensation](https://docs.subscriby.net/disaster-recovery/connector-outages#outage-compensation) has banked on the purchase, `0` for almost every subscription. `ends_at` already includes it: a one-time purchase simply ends later, while a recurring plan's `ends_at` runs that many seconds past the period the member last paid for, so the renewal the gateway bills is `ends_at` minus `compensation_seconds`.
- Requires ability: `project-subscription:view`
- MCP tools: `get_subscription`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `subscription` | path | string (uuid) | yes | The subscription, resolved by the route binder. |
### Responses
- **200**: The subscription, its access grants embedded.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | any | yes | The response's payload. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## List a subscription's grants
`GET /v1/subscriptions/{subscription}/grants`
```bash
curl https://api.subscriby.net/v1/subscriptions/$SUBSCRIPTION_ID/grants \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Every grant the purchase holds, one per resource and dated window, oldest first. Never paginated.
A **grant** is the access ledger's record that a purchase admits its holder to one resource: one row per resource, and per dated window for a pass. It says which connector gave the access, how (`mode`), where it stands (`state`) and, when it failed, why. Read it to know whether a member is actually in the channel rather than inferring it from the payment status.
- `mode` is `bearer_link` (a personal invite link the member comes through), `membership` (the connector added the member itself), `role` (a role was assigned) or `creator_task` (the creator has to do something by hand).
- `state` is `pending_identity` (the member has not linked an account on that connector yet; `member.resource_pending` was raised, and the grant is issued the moment they connect one), `pending` (issued, not yet used), `held` (issued ahead of a pass window and released when it opens), `granted`, `revoked` or `failed`.
- `identity_id` is the connector account admitted, the same id the [member's identities endpoint](https://docs.subscriby.net/api/v1/reference/members) returns, or `null` while none is linked or for a hand-arranged perk.
- `reference` is the connector's handle on the grant (the invite link on a connector that grants by link), `null` before anything was issued. Treat it as a secret: whoever holds a bearer link can use it.
- A `failed` grant carries `failure_kind` (`unreachable`, `not_permitted`, `target_missing`, `rate_limited`, `configuration`, `transient`, `other`) and `failure_detail`, the sentence the creator sees in the dashboard.
The ledger is written by the same job that mints invite links, so a purchase's grants appear moments after `member.resource_added` fires; a revoked link stays as a `revoked` row rather than disappearing, so history is never lost.
- Requires ability: `project-subscription:view`
- MCP tools: `list_subscription_grants`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `subscription` | path | string (uuid) | yes | The subscription, resolved by the route binder. |
### Responses
- **200**: Array of `AccessGrantResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].id` | string | yes | The grant's id. |
| `data[].subscription_id` | string | yes | The purchase the grant belongs to. |
| `data[].resource_id` | string | yes | The resource the grant admits the holder to. |
| `data[].window_id` | string \| null | yes | The dated pass window the grant is for; null for continuous access. |
| `data[].identity_id` | string \| null | yes | The connector account admitted, the same id the member's identities endpoint returns; null while none is linked or for a hand-arranged perk. |
| `data[].connector` | string \| null | yes | The connector that gave the access, by key; null for a manual perk. |
| `data[].mode` | string | yes | How the access is given: `bearer_link` (a personal invite link the member comes through), `membership` (the connector added the member itself), `role` (a role was assigned) or `creator_task` (the creator has to do something by hand). |
| `data[].state` | string | yes | `pending_identity` (the member has not linked an account on that connector yet; issued the moment they connect one), `pending` (issued, not yet used), `held` (issued ahead of a pass window and released when it opens), `granted`, `revoked` or `failed`. |
| `data[].reference` | string \| null | yes | The connector's handle on the grant (the invite link on a connector that grants by link); null before anything was issued. Treat it as a secret: whoever holds a bearer link can use it. |
| `data[].granted_at` | string \| null | yes | When the access was granted, ISO 8601; null until then. |
| `data[].revoked_at` | string \| null | yes | When the access was revoked, ISO 8601; null while standing. |
| `data[].failure_kind` | string \| null | yes | Why a `failed` grant failed: `unreachable`, `not_permitted`, `target_missing`, `rate_limited`, `configuration`, `transient` or `other`; null otherwise. |
| `data[].failure_detail` | string \| null | yes | The sentence the creator sees in the dashboard for a failed grant; null otherwise. |
| `data[].created_at` | string \| null | yes | When the ledger row was written, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Cancel a subscription
`POST /v1/subscriptions/{subscription}/cancel`
```bash
curl -X POST https://api.subscriby.net/v1/subscriptions/$SUBSCRIPTION_ID/cancel \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Cancel is asynchronous: the endpoint answers `202 Accepted` once cancellation is queued. Provider-side cancellation (Stripe, PayPal, Razorpay, Paystack and the others) and the accompanying `subscription.cancelled` event happen on the background worker.
- Requires ability: `project-subscription:update`
- Fires events: `subscription.cancelled`
- MCP tools: `cancel_subscription`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `subscription` | path | string (uuid) | yes | The subscription, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **202**: 202 with `cancellation_queued`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.subscription_id` | string | yes | The purchase being cancelled. |
| `data.status` | string | yes | Always `cancellation_queued`: the provider is told by the worker. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation. On this endpoint: `SUBSCRIPTION_ALREADY_ACTIVE`: when the subscription is already cancelled, rather than silently succeeding; `error.context.subscription_id` names it.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Pause a subscription
`POST /v1/subscriptions/{subscription}/pause`
Suspends access: the member is removed from every linked resource, `payment_status` becomes `paused` and `paused_at` is stamped. Billing continues. Answers `202` with the outcome and emits `subscription.paused`.
> **Pause suspends access, not billing.** The member's payment provider keeps charging on schedule. Subscriby settles through seven providers and only some can hold a recurring charge at all, so a pause meaning "stop billing" would work on some and silently not on others; access is what Subscriby controls directly, so it behaves identically everywhere. If billing must stop, cancel.
Only `active` and `trialing` subscriptions can be paused; only a `paused` one can be unpaused.
- Requires ability: `project-subscription:update`
- Fires events: `subscription.paused`
- MCP tools: `pause_subscription`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `subscription` | path | string (uuid) | yes | The subscription, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **202**: 202 with the outcome.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.subscription_id` | string | yes | The purchase that was changed. |
| `data.plan_id` | string | yes | Its plan. |
| `data.payment_status` | string | yes | The payment status after the change. |
| `data.outcome` | string | yes | Always `paused`. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the subscription is neither `active` nor `trialing`.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Unpause a subscription
`POST /v1/subscriptions/{subscription}/unpause`
Restores access with **fresh** invite links: the ones revoked at pause never come back. Answers `202` with the outcome (`outcome: active`) and emits `subscription.unpaused`.
- Requires ability: `project-subscription:update`
- Fires events: `subscription.unpaused`
- MCP tools: `unpause_subscription`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `subscription` | path | string (uuid) | yes | The subscription, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **202**: 202 with the outcome.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.subscription_id` | string | yes | The purchase that was changed. |
| `data.plan_id` | string | yes | Its plan. |
| `data.payment_status` | string | yes | The payment status after the change. |
| `data.outcome` | string | yes | Always `active`. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the subscription is not `paused`.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Reactivate a subscription
`POST /v1/subscriptions/{subscription}/reactivate`
Calls off a scheduled cancellation at the gateway and locally: the counterpart to cancel, and the win-back path. Answers `202` with the outcome (`outcome: reactivated`) and emits `subscription.reactivated`.
> **Stripe only.** On every other provider, cancelling ends the agreement outright, so there is nothing left to resume; the endpoint refuses and the member has to subscribe again. Flipping the record back to active would otherwise claim a recurring subscription the provider will never charge, and the member would keep access indefinitely for free.
An already-expired subscription cannot be reactivated regardless of provider.
- Requires ability: `project-subscription:update`
- Fires events: `subscription.reactivated`
- MCP tools: `reactivate_subscription`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `subscription` | path | string (uuid) | yes | The subscription, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **202**: 202 with the outcome.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.subscription_id` | string | yes | The purchase that was changed. |
| `data.plan_id` | string | yes | Its plan. |
| `data.payment_status` | string | yes | The payment status after the change. |
| `data.outcome` | string | yes | Always `reactivated`. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the subscription is not cancelled, has already expired, is not on Stripe, or Stripe refuses to resume it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Remind a pass holder
`POST /v1/subscriptions/{subscription}/remind`
```bash
curl -X POST https://api.subscriby.net/v1/subscriptions/$SUBSCRIPTION_ID/remind \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
The members page's nudge for a pass holder who bought a window but has not joined yet: their invite links are re-sent. Answers `200` with `reminded: true` when the connector accepted the message, and `reminded: false` when there was nothing to send: the subscription holds no window, the window has ended or been cancelled, the holder has already queued, or they cannot be reached on their connector. `false` is the dashboard's "Nothing sent" notice, not a fault, so it is never a `422`.
This messages a real person: read the subscription back first and do not repeat the nudge within the same window. To nudge everyone still missing from one window at once, use the [pass window remind endpoint](https://docs.subscriby.net/api/v1/reference/pass-windows).
- Requires ability: `project-subscription:update`
- MCP tools: `remind_pass_holder`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `subscription` | path | string (uuid) | yes | The subscription, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: 200 with `subscription_id` and `reminded`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.subscription_id` | string | yes | The purchase whose holder was nudged. |
| `data.reminded` | boolean | yes | Whether the connector accepted a message; false when there was nothing to send. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Reissue a subscription's access
`POST /v1/subscriptions/{subscription}/grants/reissue`
```bash
curl -X POST https://api.subscriby.net/v1/subscriptions/$SUBSCRIPTION_ID/grants/reissue \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "resource_id": "c9e21f37-8a4b-4d56-b1e0-2f7c9d3a6e15" }'
```
The members page's **Refresh invite links**, for integrations. The grants the member holds are revoked (their personal invite links die) and the job that grants at purchase runs again, issuing fresh grants the bot sends to the member. `resource_id` is optional: name one resource of the plan to reissue it alone, leave it out to reissue every resource the subscription grants.
Answers `202` once the reissue is queued, where `reissued` counts the grants revoked; the fresh grants appear on the grants endpoint seconds later. Each revoked grant raises `member.resource_reissued` and each fresh one `member.resource_added`.
- Requires ability: `project-subscription:update`
- Fires events: `member.resource_reissued`, `member.resource_added`
- MCP tools: `reissue_subscription_grants`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `subscription` | path | string (uuid) | yes | The subscription, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
Which access to reissue: one resource of the plan, or every resource the purchase grants when the body is empty.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string \| null (uuid) | no | A resource the plan grants, to reissue it alone; omit it to reissue every resource. A resource the plan does not grant answers 404. |
### Responses
- **202**: 202 with `subscription_id`, `reissued` and `reissue_queued`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.subscription_id` | string | yes | The purchase whose access is being reissued. |
| `data.reissued` | integer | yes | How many grants were revoked; as many fresh ones follow from the queue. |
| `data.status` | string | yes | Always `reissue_queued`: the fresh grants come from the background dispatcher. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: when `resource_id` is not one the plan grants.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when the subscription is not active; `error.context.subscription_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Members API](https://docs.subscriby.net/api/v1/reference/members): Members are the project-scoped subscribers.
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans): A plan is what a project sells: the price, the currency, the billing cycle or the dated windows, the eligibility rules and the resources a purchase unlocks.
- [Pass Windows API](https://docs.subscriby.net/api/v1/reference/pass-windows): Nudge every holder of one window at once.
- [Webhook Events API](https://docs.subscriby.net/api/v1/reference/webhook-events): A read-only polling endpoint that surfaces the most recent webhook deliveries for a given event type.
- [subscription.* events](https://docs.subscriby.net/webhooks/v1/events/subscription): Every transition these endpoints and the payment providers announce.
---
# Support Inbox API
Source: https://docs.subscriby.net/api/v1/reference/support-inbox
The support inbox is where a member who writes to your project bot something it does not recognise ends up: one durable **support conversation** per `(project, member, channel)`, the **saved replies** your team answers with, and the project's **support settings** that decide whether the inbox is open, how you hear about a new thread and how replies are signed. Replies sent through this API reach the member on their channel, prefixed with the project's support name so they read as coming from a person rather than the bot.
## Conversations
The conversation routes are **not** nested under a project. One call gives you the queue across every project the token can reach; pass `project_id` to narrow it. A token confined to other projects is refused with `403 FORBIDDEN`.
## Saved replies
A **saved reply** (a canned reply, in the route names) is a reusable snippet a creator keeps for the [support inbox](https://docs.subscriby.net/creators/support-inbox#saved-replies): an opening-hours line, a refund policy, the "your link expired, here is how to get a new one" answer. Each has a `shortcut` the creator types in the thread composer to pull it in, and a `sort_order` that is its position in the picker.
The saved-reply endpoints let an integration mirror that picker: read the creator's wording before answering with the [reply endpoint](https://docs.subscriby.net/api/v1/reference/support-inbox#reply-to-a-member), or keep the set in sync from a knowledge base. They run the same actions the inbox uses, so the permission checks and the order the creator arranged are the dashboard's. A token whose holder's team role lacks the support permission on the project is refused with `403 TOKEN_MISSING_ABILITY`.
## Settings
Every project carries a handful of settings for its [support inbox](https://docs.subscriby.net/creators/support-inbox#settings): whether members can open a conversation at all, how the creator hears about a new one, the name replies are signed with, the acknowledgement a member gets on their first message, and whether the creator is emailed. They are columns on the project, so reading them takes only `project:view` and changing them runs the same action as the inbox's settings modal.
The settings abilities are the project abilities, not `support-conversation:*`: the settings decide how a project behaves, so the token that may read or change the project may read or change them. A token whose holder's team role may not update the project is refused with `403 TOKEN_MISSING_ABILITY`.
## Endpoints
- `GET /v1/support/conversations` — [List support conversations](#list-support-conversations)
- `GET /v1/support/conversations/{conversation}` — [Get a support conversation](#get-a-support-conversation)
- `GET /v1/support/conversations/{conversation}/messages` — [List a conversation's messages](#list-a-conversations-messages)
- `POST /v1/support/conversations/{conversation}/messages` — [Reply to a member](#reply-to-a-member)
- `GET /v1/projects/{project}/support/canned-replies` — [List a project's saved replies](#list-a-projects-saved-replies)
- `POST /v1/projects/{project}/support/canned-replies` — [Create a saved reply](#create-a-saved-reply)
- `GET /v1/projects/{project}/support/canned-replies/{reply}` — [Get a saved reply](#get-a-saved-reply)
- `PATCH /v1/projects/{project}/support/canned-replies/{reply}` — [Update a saved reply](#update-a-saved-reply)
- `DELETE /v1/projects/{project}/support/canned-replies/{reply}` — [Delete a saved reply](#delete-a-saved-reply)
- `GET /v1/projects/{project}/support/settings` — [Get a project's support settings](#get-a-projects-support-settings)
- `PATCH /v1/projects/{project}/support/settings` — [Update a project's support settings](#update-a-projects-support-settings)
- `POST /v1/support/conversations/{conversation}/resolve` — [Resolve a support conversation](#resolve-a-support-conversation)
- `POST /v1/support/conversations/{conversation}/assign` — [Assign a support conversation](#assign-a-support-conversation)
- `POST /v1/support/conversations/{conversation}/reopen` — [Reopen a support conversation](#reopen-a-support-conversation)
- `POST /v1/support/conversations/{conversation}/block` — [Block a support contact](#block-a-support-contact)
- `POST /v1/support/conversations/{conversation}/unblock` — [Unblock a support contact](#unblock-a-support-contact)
## List support conversations
`GET /v1/support/conversations`
```bash
curl "https://api.subscriby.net/v1/support/conversations?status=open" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The queue across every project the token can reach, newest activity first, 25 per page. `project_id`, `status` and `assigned_to` narrow it.
- Requires ability: `support-conversation:view-any`
- MCP tools: `list_support_conversations`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project_id` | query | string (uuid) | no | Narrow the queue to a single project. |
| `status` | query | string | no | Narrow to one status: `open`, `pending`, `snoozed` or `resolved`. An unknown value is refused. |
| `assigned_to` | query | string (uuid) | no | Narrow to the conversations assigned to one team member, by user id. |
| `page` | query | integer | no | The page to return, 1-indexed. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. |
| `limit` | query | integer | no | An alias of `per_page`, kept for older integrations. |
### Responses
- **200**: The page.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The conversation's id. |
| `data[].project_id` | string | yes | The project whose bot the member wrote to. |
| `data[].subscriber_id` | string | yes | The member behind the thread; may be a `lead` who never paid, since anyone who can reach the bot can open a conversation. |
| `data[].member_name` | string | yes | The member's display name, or "Unknown member" when they have not shared one, so a picker has something human to show. |
| `data[].channel` | string | yes | The key of the connector the member wrote through. A member who writes through two connectors has two conversations, and each reply goes back on the connector its conversation came in on. |
| `data[].status` | string | yes | `open`, `pending`, `snoozed` or `resolved`. |
| `data[].assigned_to_user_id` | string \| null | yes | The team member the thread is assigned to; null when unassigned. |
| `data[].subject` | string \| null | yes | A subject line, when the inbox set one; null otherwise. |
| `data[].unread_count` | integer | yes | How many inbound messages the team has not read. |
| `data[].blocked` | boolean | yes | True when inbound messages from the member are being dropped: the thread is history only and cannot reopen on inbound. |
| `data[].last_message_at` | string \| null | yes | When the newest message, either way, arrived, ISO 8601. |
| `data[].last_inbound_at` | string \| null | yes | When the member last wrote, ISO 8601; null if never. |
| `data[].last_outbound_at` | string \| null | yes | When the team last replied, ISO 8601; null if never. |
| `data[].first_response_at` | string \| null | yes | Stamped **once**, on the first human reply ever in the thread: an SLA measure of how long the member waited for a person, not of the most recent reply. A reopen does not reset it. |
| `data[].resolved_at` | string \| null | yes | When the thread was last resolved, ISO 8601; null while open. |
| `data[].created_at` | string \| null | yes | When the thread opened, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When the thread last changed, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `FORBIDDEN`: when the token is confined to other projects.
- **422**: `VALIDATION_FAILED`: when `status` is not one of the four statuses; `error.context.allowed` lists them.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a support conversation
`GET /v1/support/conversations/{conversation}`
One thread.
`member_name` is the member's display name, or "Unknown member" when they have not shared one. It is included so a picker built on this endpoint has something human to show; the Zapier and n8n conversation dropdowns both rely on it. Anyone who can reach the bot can open a conversation, including someone who has never paid, so `subscriber_id` may resolve to a member whose status is `lead`.
`channel` is the key of the connector the member wrote through. A member who writes through two connectors has two conversations, and each reply goes back on the connector its conversation came in on.
`status` is one of `open`, `pending`, `snoozed`, `resolved`. `first_response_at` is stamped **once**, on the first human reply ever in the thread: it is an SLA measure of how long the member waited for a person, not of the most recent reply, and a reopen does not reset it.
`blocked: true` means inbound messages from that member are being dropped. The thread is history only, and a blocked member cannot reopen it by writing again.
- Requires ability: `support-conversation:view`
- MCP tools: `get_support_conversation`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `conversation` | path | string (uuid) | yes | The thread, resolved by the route binder. |
### Responses
- **200**: The thread.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The conversation's id. |
| `data.project_id` | string | yes | The project whose bot the member wrote to. |
| `data.subscriber_id` | string | yes | The member behind the thread; may be a `lead` who never paid, since anyone who can reach the bot can open a conversation. |
| `data.member_name` | string | yes | The member's display name, or "Unknown member" when they have not shared one, so a picker has something human to show. |
| `data.channel` | string | yes | The key of the connector the member wrote through. A member who writes through two connectors has two conversations, and each reply goes back on the connector its conversation came in on. |
| `data.status` | string | yes | `open`, `pending`, `snoozed` or `resolved`. |
| `data.assigned_to_user_id` | string \| null | yes | The team member the thread is assigned to; null when unassigned. |
| `data.subject` | string \| null | yes | A subject line, when the inbox set one; null otherwise. |
| `data.unread_count` | integer | yes | How many inbound messages the team has not read. |
| `data.blocked` | boolean | yes | True when inbound messages from the member are being dropped: the thread is history only and cannot reopen on inbound. |
| `data.last_message_at` | string \| null | yes | When the newest message, either way, arrived, ISO 8601. |
| `data.last_inbound_at` | string \| null | yes | When the member last wrote, ISO 8601; null if never. |
| `data.last_outbound_at` | string \| null | yes | When the team last replied, ISO 8601; null if never. |
| `data.first_response_at` | string \| null | yes | Stamped **once**, on the first human reply ever in the thread: an SLA measure of how long the member waited for a person, not of the most recent reply. A reopen does not reset it. |
| `data.resolved_at` | string \| null | yes | When the thread was last resolved, ISO 8601; null while open. |
| `data.created_at` | string \| null | yes | When the thread opened, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the thread last changed, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## List a conversation's messages
`GET /v1/support/conversations/{conversation}/messages`
The thread's messages **oldest first**, 50 per page. `internal: true` notes are withheld unless the token also carries `support-conversation:update`: reading a private remark about a member takes the same permission as writing one.
`direction` is `inbound` (from the member) or `outbound` (from your team). `author_kind` is `contact`, `creator` or `system`; `author_user_id` names the team member on an outbound reply and is `null` for inbound and system messages.
`source` is the surface the message came through: `connector_chat` for a member writing to the connector's installation, `dashboard`, `api` or `mcp` for a reply from your team, and `connector_relay_dm` or `connector_relay_group` for a reply your team typed on the platform itself (into the relay's ping, or inside the thread the relay opened in a group). Messages recorded before the connector SDK carry the same three meanings under the Telegram connector's older words, `telegram_bot`, `telegram_relay_dm` and `telegram_relay_group`.
`delivery_status` on an outbound message is `pending` → `sent`, or `failed` / `unreachable`. **`unreachable` means the member has blocked the bot**: the reply is stored but will never arrive, and retrying will not help. `failure_reason` carries the detail.
`body` is `null` for media sent without a caption. A captioned photo puts the caption in `body` and does not create a second message. A member editing an earlier message updates the stored message in place and stamps `edited_at`; no new message is created and no event fires. Inbound messages are rate-limited per member: excess messages are dropped silently rather than queued, so a flooding member produces no burst of events.
Each entry of `attachments` carries the file's `kind`, `mime`, `file_name`, `size`, `width`, `height`, `duration` and `url`.
> **File ids are never returned.** A platform file id plus the bot token is enough to download the file straight from the platform, so it is treated as a credential. `url` is populated only after the file has been fetched at least once; it is `null` until then.
- Requires ability: `support-conversation:update`, `support-conversation:view`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `conversation` | path | string (uuid) | yes | The thread, resolved by the route binder. |
| `page` | query | integer | no | The page to return, 1-indexed. |
| `per_page` | query | integer | no | Messages per page, 1 to 100. |
| `limit` | query | integer | no | An alias of `per_page`, kept for older integrations. |
### Responses
- **200**: The page.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The message's id, the value `reply_to_message_id` quotes. |
| `data[].conversation_id` | string | yes | The thread the message belongs to. |
| `data[].project_id` | string | yes | The project the thread belongs to. |
| `data[].direction` | string | yes | `inbound` (from the member) or `outbound` (from the team). |
| `data[].author_kind` | string | yes | `contact` (the member), `creator` (a team member) or `system`. |
| `data[].author_user_id` | string \| null | yes | The team member who wrote an outbound reply; null for inbound and system messages. |
| `data[].type` | string | yes | What the message carries: `text`, `photo`, `video`, `audio`, `voice`, `document`, `sticker`, `animation`, `location` or `contact`. |
| `data[].source` | string | yes | The surface the message came through: `connector_chat` for a member writing to the connector's installation, `dashboard`, `api` or `mcp` for a reply from the team, `connector_relay_dm` or `connector_relay_group` for a reply the team typed on the platform itself, `ai_assistant` for a drafted reply. Messages recorded before the connector SDK carry the same meanings under the first connector's older words, its key followed by `_bot`, `_relay_dm` and `_relay_group`. |
| `data[].delivery_status` | string | yes | On an outbound message, `pending` then `sent`, or `failed` / `unreachable`. **`unreachable` means the member has blocked the bot**: the reply is stored but will never arrive, and retrying will not help. |
| `data[].body` | string \| null | yes | The text, or the caption of a media message; null for media sent without a caption. |
| `data[].internal` | boolean | yes | True for a private note the team wrote for itself, never delivered to the member. Withheld from tokens that cannot update the conversation. |
| `data[].reply_to_message_id` | string \| null | yes | The earlier message this one quotes; null when it quotes none. |
| `data[].attachments` | array of object | yes | The files on the message, described without the connector's file ids, which are credentials. |
| `data[].attachments[].kind` | string \| null | yes | What the file is: `photo`, `video`, `audio`, `voice`, `document`, `sticker` or `animation`. |
| `data[].attachments[].mime` | string \| null | yes | The MIME type, when the platform gave one. |
| `data[].attachments[].file_name` | string \| null | yes | The original file name, when the platform gave one. |
| `data[].attachments[].size` | integer \| null | yes | The size in bytes, when known. |
| `data[].attachments[].width` | integer \| null | yes | The width in pixels for an image or video; null otherwise. |
| `data[].attachments[].height` | integer \| null | yes | The height in pixels for an image or video; null otherwise. |
| `data[].attachments[].duration` | integer \| null | yes | The length in seconds for audio and video; null otherwise. |
| `data[].attachments[].url` | string \| null | yes | The stored copy's URL, populated only after the file has been fetched at least once; null until then. |
| `data[].failure_reason` | string \| null | yes | Why an outbound message `failed` or is `unreachable`; null otherwise. |
| `data[].edited_at` | string \| null | yes | When the member edited the message, ISO 8601; the stored message is updated in place and no event fires. Null when never edited. |
| `data[].read_at` | string \| null | yes | When the team read an inbound message, ISO 8601; null while unread. |
| `data[].created_at` | string \| null | yes | When the message was recorded, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Reply to a member
`POST /v1/support/conversations/{conversation}/messages`
```bash
curl -X POST https://api.subscriby.net/v1/support/conversations/$CONVERSATION_ID/messages \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"body": "Sorry about that. Here is a fresh invite link, valid for 24 hours."}'
```
Answers `201` with the created message. `delivery_status` is `pending`: the reply has been recorded and queued, not yet delivered. Poll the message back or subscribe to `support.message.sent` rather than treating `201` as proof of receipt.
**Text only.** Sending media as a reply is a dashboard-inbox feature; this API accepts a `body` of up to 4000 characters, the message ceiling on the connectors.
Send `{"body": "...", "internal": true}` to record a private note for your team instead. Private notes are never delivered to the member and raise no webhook event.
Add `reply_to_message_id` to quote an earlier message, the way a chat client shows a threaded reply. It must name a message in **this** conversation; quoting across threads is refused, because it would put one member's words in front of another.
- Requires ability: `support-conversation:update`
- Fires events: `support.message.sent`
- MCP tools: `reply_support_conversation`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `conversation` | path | string (uuid) | yes | The thread, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `body` | string | yes | The reply text, up to 4,000 characters, the message ceiling on the connectors. Text only; media replies are a dashboard feature. |
| `internal` | boolean | no | True records a private note for the team instead of a reply: never delivered to the member and raising no event. Defaults to false. |
| `reply_to_message_id` | string (uuid) | no | An earlier message of **this** conversation to quote, the way a chat client shows a threaded reply. A message from another thread is refused. |
### Responses
- **201**: The recorded message.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The message's id, the value `reply_to_message_id` quotes. |
| `data.conversation_id` | string | yes | The thread the message belongs to. |
| `data.project_id` | string | yes | The project the thread belongs to. |
| `data.direction` | string | yes | `inbound` (from the member) or `outbound` (from the team). |
| `data.author_kind` | string | yes | `contact` (the member), `creator` (a team member) or `system`. |
| `data.author_user_id` | string \| null | yes | The team member who wrote an outbound reply; null for inbound and system messages. |
| `data.type` | string | yes | What the message carries: `text`, `photo`, `video`, `audio`, `voice`, `document`, `sticker`, `animation`, `location` or `contact`. |
| `data.source` | string | yes | The surface the message came through: `connector_chat` for a member writing to the connector's installation, `dashboard`, `api` or `mcp` for a reply from the team, `connector_relay_dm` or `connector_relay_group` for a reply the team typed on the platform itself, `ai_assistant` for a drafted reply. Messages recorded before the connector SDK carry the same meanings under the first connector's older words, its key followed by `_bot`, `_relay_dm` and `_relay_group`. |
| `data.delivery_status` | string | yes | On an outbound message, `pending` then `sent`, or `failed` / `unreachable`. **`unreachable` means the member has blocked the bot**: the reply is stored but will never arrive, and retrying will not help. |
| `data.body` | string \| null | yes | The text, or the caption of a media message; null for media sent without a caption. |
| `data.internal` | boolean | yes | True for a private note the team wrote for itself, never delivered to the member. Withheld from tokens that cannot update the conversation. |
| `data.reply_to_message_id` | string \| null | yes | The earlier message this one quotes; null when it quotes none. |
| `data.attachments` | array of object | yes | The files on the message, described without the connector's file ids, which are credentials. |
| `data.attachments[].kind` | string \| null | yes | What the file is: `photo`, `video`, `audio`, `voice`, `document`, `sticker` or `animation`. |
| `data.attachments[].mime` | string \| null | yes | The MIME type, when the platform gave one. |
| `data.attachments[].file_name` | string \| null | yes | The original file name, when the platform gave one. |
| `data.attachments[].size` | integer \| null | yes | The size in bytes, when known. |
| `data.attachments[].width` | integer \| null | yes | The width in pixels for an image or video; null otherwise. |
| `data.attachments[].height` | integer \| null | yes | The height in pixels for an image or video; null otherwise. |
| `data.attachments[].duration` | integer \| null | yes | The length in seconds for audio and video; null otherwise. |
| `data.attachments[].url` | string \| null | yes | The stored copy's URL, populated only after the file has been fetched at least once; null until then. |
| `data.failure_reason` | string \| null | yes | Why an outbound message `failed` or is `unreachable`; null otherwise. |
| `data.edited_at` | string \| null | yes | When the member edited the message, ISO 8601; the stored message is updated in place and no event fires. Null when never edited. |
| `data.read_at` | string \| null | yes | When the team read an inbound message, ISO 8601; null while unread. |
| `data.created_at` | string \| null | yes | When the message was recorded, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `body` is missing or over 4,000 characters, or `reply_to_message_id` is not a message of this conversation.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## List a project's saved replies
`GET /v1/projects/{project}/support/canned-replies`
```bash
curl https://api.subscriby.net/v1/projects/$PROJECT_ID/support/canned-replies \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Every snippet on the project in the creator's order (`sort_order` ascending). The set is small by design, so the list is not paginated.
- Requires ability: `support-canned-reply:view-any`
- MCP tools: `list_canned_replies`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
### Responses
- **200**: Array of `SupportCannedReplyResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].id` | string | yes | The snippet's id. |
| `data[].project_id` | string | yes | The project the snippet belongs to. |
| `data[].title` | string | yes | The label shown in the picker, 2 to 80 characters; never sent to the member. |
| `data[].body` | string | yes | The text sent to the member when the snippet is used, 2 to 4,000 characters. |
| `data[].shortcut` | string \| null | yes | What the creator types in the composer to pull the snippet in, up to 30 letters, numbers, dashes and underscores, unique within the project; null when the snippet is picked from the list instead. |
| `data[].sort_order` | integer | yes | The snippet's position in the picker, 0 to 999. |
| `data[].created_at` | string \| null | yes | When the snippet was created, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When the snippet last changed, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Create a saved reply
`POST /v1/projects/{project}/support/canned-replies`
```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/support/canned-replies \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"title": "Opening hours",
"body": "We answer between 9 and 5, Monday to Friday.",
"shortcut": "hours"
}'
```
Answers `201` with the snippet and emits `support.canned_reply.created`.
> **A duplicate shortcut is a 422, not a second snippet.** Shortcuts are unique per project. Sending a shortcut another saved reply on the same project already uses is refused on `shortcut`. Two different projects may both have `hours`.
- Requires ability: `support-canned-reply:create`
- Fires events: `support.canned_reply.created`
- MCP tools: `create_canned_reply`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A new saved reply: the words, the label the picker shows and, optionally, the shortcut and position.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | yes | The label shown in the picker, 2 to 80 characters; never sent to the member. |
| `body` | string | yes | The text sent to the member, 2 to 4,000 characters, the message ceiling the reply has to fit once it is sent. |
| `shortcut` | string \| null | no | Up to 30 letters, numbers, dashes and underscores, unique within the project (two projects may both have `hours`). Omit it, or send null or an empty string, for none. The character set means a shortcut can never be confused with a bot command or need escaping when echoed back. |
| `sort_order` | integer | no | The position in the picker, 0 to 999. Defaults to 0. |
### Responses
- **201**: The snippet.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The snippet's id. |
| `data.project_id` | string | yes | The project the snippet belongs to. |
| `data.title` | string | yes | The label shown in the picker, 2 to 80 characters; never sent to the member. |
| `data.body` | string | yes | The text sent to the member when the snippet is used, 2 to 4,000 characters. |
| `data.shortcut` | string \| null | yes | What the creator types in the composer to pull the snippet in, up to 30 letters, numbers, dashes and underscores, unique within the project; null when the snippet is picked from the list instead. |
| `data.sort_order` | integer | yes | The snippet's position in the picker, 0 to 999. |
| `data.created_at` | string \| null | yes | When the snippet was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the snippet last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `title` or `body` is missing or out of range, `shortcut` is malformed or already used on the project, or `sort_order` is outside 0 to 999.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a saved reply
`GET /v1/projects/{project}/support/canned-replies/{reply}`
One snippet. There is no `support-canned-reply:view`: a snippet is only ever read as part of the picker, so this route gates on `view-any` too. A reply is resolved **within the project**: another project's reply id is a `404 RESOURCE_NOT_FOUND`.
`shortcut` is `null` when the creator never set one; the snippet is then picked from the list rather than typed.
- Requires ability: `support-canned-reply:view-any`
- MCP tools: `get_canned_reply`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `reply` | path | string (uuid) | yes | The snippet, resolved within the project. |
### Responses
- **200**: The snippet.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The snippet's id. |
| `data.project_id` | string | yes | The project the snippet belongs to. |
| `data.title` | string | yes | The label shown in the picker, 2 to 80 characters; never sent to the member. |
| `data.body` | string | yes | The text sent to the member when the snippet is used, 2 to 4,000 characters. |
| `data.shortcut` | string \| null | yes | What the creator types in the composer to pull the snippet in, up to 30 letters, numbers, dashes and underscores, unique within the project; null when the snippet is picked from the list instead. |
| `data.sort_order` | integer | yes | The snippet's position in the picker, 0 to 999. |
| `data.created_at` | string \| null | yes | When the snippet was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the snippet last changed, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Update a saved reply
`PATCH /v1/projects/{project}/support/canned-replies/{reply}`
```bash
curl -X PATCH https://api.subscriby.net/v1/projects/$PROJECT_ID/support/canned-replies/$REPLY_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"title": "Support hours"}'
```
Accepts the same fields as create, all optional. **An omitted field keeps its stored value**, so a snippet can be renamed without resending its body. Send `"shortcut": null` to clear the shortcut. The uniqueness check ignores the snippet being edited, so resending its own shortcut is fine.
Answers `200` with the updated snippet. Replies already sent with the old wording are untouched: a saved reply is copied into a message when it is used, not referenced. Emits `support.canned_reply.updated` only when something actually changed, with a `changes` map.
- Requires ability: `support-canned-reply:update`
- Fires events: `support.canned_reply.updated`
- MCP tools: `update_canned_reply`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `reply` | path | string (uuid) | yes | The snippet, resolved within the project. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
The changes to a saved reply. Every field is optional and an omitted one keeps its stored value, so a snippet can be renamed without resending its body.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | no | The label shown in the picker, 2 to 80 characters. |
| `body` | string | no | The text sent to the member, 2 to 4,000 characters. Replies already sent with the old wording are untouched: a saved reply is copied into a message when it is used, not referenced. |
| `shortcut` | string \| null | no | Up to 30 letters, numbers, dashes and underscores, unique within the project. Send null to clear it. The uniqueness check ignores the snippet being edited, so resending its own shortcut is fine. |
| `sort_order` | integer | no | The position in the picker, 0 to 999. |
### Responses
- **200**: The snippet, updated.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The snippet's id. |
| `data.project_id` | string | yes | The project the snippet belongs to. |
| `data.title` | string | yes | The label shown in the picker, 2 to 80 characters; never sent to the member. |
| `data.body` | string | yes | The text sent to the member when the snippet is used, 2 to 4,000 characters. |
| `data.shortcut` | string \| null | yes | What the creator types in the composer to pull the snippet in, up to 30 letters, numbers, dashes and underscores, unique within the project; null when the snippet is picked from the list instead. |
| `data.sort_order` | integer | yes | The snippet's position in the picker, 0 to 999. |
| `data.created_at` | string \| null | yes | When the snippet was created, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the snippet last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when a field is out of range or the shortcut is already used on the project.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Delete a saved reply
`DELETE /v1/projects/{project}/support/canned-replies/{reply}`
```bash
curl -X DELETE https://api.subscriby.net/v1/projects/$PROJECT_ID/support/canned-replies/$REPLY_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Returns `204 No Content`. The snippet leaves the picker; messages sent with it stay in their threads. Emits `support.canned_reply.deleted`.
- Requires ability: `support-canned-reply:delete`
- Fires events: `support.canned_reply.deleted`
- MCP tools: `delete_canned_reply`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `reply` | path | string (uuid) | yes | The snippet, resolved within the project. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a project's support settings
`GET /v1/projects/{project}/support/settings`
```bash
curl https://api.subscriby.net/v1/projects/$PROJECT_ID/support/settings \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
| Field | Type | Notes |
| -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | Whether a member's unrecognised message to the bot opens a conversation. Off stops **new** threads reaching the inbox; existing ones are kept. |
| `relay_mode` | string | `owner_dm`: the platform bot messages the project owner with an inline Reply button. `forum_group`: each conversation is mirrored into its own topic of a group the bot administers. `none`: the dashboard inbox is the only surface. |
| `agent_name` | string or null | The name outbound replies are prefixed with so they read as coming from a person. `null` falls back to the project's default. |
| `auto_reply` | string or null | Sent to a member on their first message in a thread. `null` sends nothing. |
| `notify_email` | boolean | Whether the creator is emailed about new support activity. |
The keys drop the `support_` prefix the columns carry: on this endpoint everything is about support.
> **The relay chat id is never exposed.** The chat the `owner_dm` relay posts to, and the group `forum_group` mirrors into, are identifiers the bot handshake writes, not settings. They do not appear here and cannot be set from here.
- Requires ability: `project:view`
- MCP tools: `get_support_settings`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
### Responses
- **200**: The settings.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.project_id` | string | yes | The project the settings belong to. |
| `data.enabled` | boolean | yes | Whether a member's unrecognised message to the bot opens a conversation. Off stops **new** threads reaching the inbox; existing ones are kept. |
| `data.relay_mode` | string | yes | `owner_dm` (the platform bot messages the project owner with an inline Reply button), `forum_group` (each conversation mirrored into its own topic of a group the bot administers) or `none` (the dashboard inbox is the only surface). |
| `data.agent_name` | string \| null | yes | The name outbound replies are prefixed with so they read as coming from a person; null falls back to the project's default. |
| `data.auto_reply` | string \| null | yes | Sent to a member on their first message in a thread; null sends nothing. |
| `data.notify_email` | boolean | yes | Whether the creator is emailed about new support activity. |
| `data.updated_at` | string \| null | yes | When the project last changed, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Update a project's support settings
`PATCH /v1/projects/{project}/support/settings`
```bash
curl -X PATCH https://api.subscriby.net/v1/projects/$PROJECT_ID/support/settings \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"relay_mode": "owner_dm",
"auto_reply": "Thanks, a person will reply within a day."
}'
```
Answers `200` with the complete settings after the change. **Every field is optional and an omitted one keeps its stored value**, so the request above changes the relay and the auto-reply and leaves `enabled`, `agent_name` and `notify_email` alone. `support.settings.updated` fires only when at least one value actually changed, with the full settings and a `changes` map; a `PATCH` that resends the current values changes nothing and raises nothing.
> **`forum_group` needs a group the bot can post into.** `forum_group` mirrors each member conversation into its own topic of a group with topics enabled. Switching the mode here does not link a group: the creator adds the project bot to such a group as an administrator (with permission to manage topics), the bot records the group itself and confirms by direct message. Until that happens, new member messages reach only the dashboard inbox. The group's id is never exposed by this endpoint.
- Requires ability: `project:update`
- Fires events: `support.settings.updated`
- MCP tools: `update_support_settings`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project` | path | string (uuid) | yes | The project, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
The changes to a project's support inbox settings. Every field is optional and an omitted one keeps its stored value.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `enabled` | boolean | no | Whether a member's unrecognised message to the bot opens a conversation. |
| `relay_mode` | `owner_dm`, `forum_group`, `none` | no | `owner_dm`, `forum_group` or `none`. Switching to `forum_group` does not link a group: the creator adds the project bot to a group with topics enabled as an administrator, the bot records the group itself and confirms; until then new member messages reach only the dashboard inbox. |
| `agent_name` | string \| null | no | The name outbound replies are prefixed with, up to 60 characters; null or an empty string clears it. Whitespace is trimmed. |
| `auto_reply` | string \| null | no | The acknowledgement sent on a member's first message in a thread, up to 1,000 characters, well under the platform limit because it is sent with an identity header; null clears it. |
| `notify_email` | boolean | no | Whether the creator is emailed about new support activity. |
### Responses
- **200**: The settings, updated.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.project_id` | string | yes | The project the settings belong to. |
| `data.enabled` | boolean | yes | Whether a member's unrecognised message to the bot opens a conversation. Off stops **new** threads reaching the inbox; existing ones are kept. |
| `data.relay_mode` | string | yes | `owner_dm` (the platform bot messages the project owner with an inline Reply button), `forum_group` (each conversation mirrored into its own topic of a group the bot administers) or `none` (the dashboard inbox is the only surface). |
| `data.agent_name` | string \| null | yes | The name outbound replies are prefixed with so they read as coming from a person; null falls back to the project's default. |
| `data.auto_reply` | string \| null | yes | Sent to a member on their first message in a thread; null sends nothing. |
| `data.notify_email` | boolean | yes | Whether the creator is emailed about new support activity. |
| `data.updated_at` | string \| null | yes | When the project last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `relay_mode` is not `owner_dm`, `forum_group` or `none`, `agent_name` exceeds 60 characters, or `auto_reply` exceeds 1,000 characters.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Resolve a support conversation
`POST /v1/support/conversations/{conversation}/resolve`
```bash
curl -X POST https://api.subscriby.net/v1/support/conversations/$CONVERSATION_ID/resolve \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Resolving is not closing: the thread and its history are kept, and the member's next message reopens it automatically, raising `support.conversation.reopened`. Answers `200` with the thread.
- Requires ability: `support-conversation:update`
- Fires events: `support.conversation.resolved`
- MCP tools: `resolve_support_conversation`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `conversation` | path | string (uuid) | yes | The thread, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The thread, resolved.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The conversation's id. |
| `data.project_id` | string | yes | The project whose bot the member wrote to. |
| `data.subscriber_id` | string | yes | The member behind the thread; may be a `lead` who never paid, since anyone who can reach the bot can open a conversation. |
| `data.member_name` | string | yes | The member's display name, or "Unknown member" when they have not shared one, so a picker has something human to show. |
| `data.channel` | string | yes | The key of the connector the member wrote through. A member who writes through two connectors has two conversations, and each reply goes back on the connector its conversation came in on. |
| `data.status` | string | yes | `open`, `pending`, `snoozed` or `resolved`. |
| `data.assigned_to_user_id` | string \| null | yes | The team member the thread is assigned to; null when unassigned. |
| `data.subject` | string \| null | yes | A subject line, when the inbox set one; null otherwise. |
| `data.unread_count` | integer | yes | How many inbound messages the team has not read. |
| `data.blocked` | boolean | yes | True when inbound messages from the member are being dropped: the thread is history only and cannot reopen on inbound. |
| `data.last_message_at` | string \| null | yes | When the newest message, either way, arrived, ISO 8601. |
| `data.last_inbound_at` | string \| null | yes | When the member last wrote, ISO 8601; null if never. |
| `data.last_outbound_at` | string \| null | yes | When the team last replied, ISO 8601; null if never. |
| `data.first_response_at` | string \| null | yes | Stamped **once**, on the first human reply ever in the thread: an SLA measure of how long the member waited for a person, not of the most recent reply. A reopen does not reset it. |
| `data.resolved_at` | string \| null | yes | When the thread was last resolved, ISO 8601; null while open. |
| `data.created_at` | string \| null | yes | When the thread opened, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the thread last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Assign a support conversation
`POST /v1/support/conversations/{conversation}/assign`
```bash
curl -X POST https://api.subscriby.net/v1/support/conversations/$CONVERSATION_ID/assign \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"assigned_to_user_id": "'"$TEAM_USER_ID"'"}'
```
Hands the thread to a team member. `assigned_to_user_id` must be **present**: omitting the key is refused, and sending `null` is how you clear an assignment. Answers `200` with the thread.
- Requires ability: `support-conversation:update`
- Fires events: `support.conversation.assigned`
- MCP tools: `assign_support_conversation`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `conversation` | path | string (uuid) | yes | The thread, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `assigned_to_user_id` | string (uuid) | yes | The team member to hand the thread to, by user id. The key must be **present**: omitting it is refused, while sending null clears the assignment. |
### Responses
- **200**: The thread, reassigned.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The conversation's id. |
| `data.project_id` | string | yes | The project whose bot the member wrote to. |
| `data.subscriber_id` | string | yes | The member behind the thread; may be a `lead` who never paid, since anyone who can reach the bot can open a conversation. |
| `data.member_name` | string | yes | The member's display name, or "Unknown member" when they have not shared one, so a picker has something human to show. |
| `data.channel` | string | yes | The key of the connector the member wrote through. A member who writes through two connectors has two conversations, and each reply goes back on the connector its conversation came in on. |
| `data.status` | string | yes | `open`, `pending`, `snoozed` or `resolved`. |
| `data.assigned_to_user_id` | string \| null | yes | The team member the thread is assigned to; null when unassigned. |
| `data.subject` | string \| null | yes | A subject line, when the inbox set one; null otherwise. |
| `data.unread_count` | integer | yes | How many inbound messages the team has not read. |
| `data.blocked` | boolean | yes | True when inbound messages from the member are being dropped: the thread is history only and cannot reopen on inbound. |
| `data.last_message_at` | string \| null | yes | When the newest message, either way, arrived, ISO 8601. |
| `data.last_inbound_at` | string \| null | yes | When the member last wrote, ISO 8601; null if never. |
| `data.last_outbound_at` | string \| null | yes | When the team last replied, ISO 8601; null if never. |
| `data.first_response_at` | string \| null | yes | Stamped **once**, on the first human reply ever in the thread: an SLA measure of how long the member waited for a person, not of the most recent reply. A reopen does not reset it. |
| `data.resolved_at` | string \| null | yes | When the thread was last resolved, ISO 8601; null while open. |
| `data.created_at` | string \| null | yes | When the thread opened, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the thread last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when the key is omitted, is not a UUID, or names no user.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Reopen a support conversation
`POST /v1/support/conversations/{conversation}/reopen`
```bash
curl -X POST https://api.subscriby.net/v1/support/conversations/$CONVERSATION_ID/reopen \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Puts the conversation back to `open` and emits `support.conversation.reopened`, whatever its previous status was. A member writing back does the same thing on its own, so this is for the creator who changes their mind, or owes a follow-up before the member speaks again. Answers `200` with the thread.
- Requires ability: `support-conversation:update`
- Fires events: `support.conversation.reopened`
- MCP tools: `reopen_support_conversation`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `conversation` | path | string (uuid) | yes | The thread, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The thread, open again.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The conversation's id. |
| `data.project_id` | string | yes | The project whose bot the member wrote to. |
| `data.subscriber_id` | string | yes | The member behind the thread; may be a `lead` who never paid, since anyone who can reach the bot can open a conversation. |
| `data.member_name` | string | yes | The member's display name, or "Unknown member" when they have not shared one, so a picker has something human to show. |
| `data.channel` | string | yes | The key of the connector the member wrote through. A member who writes through two connectors has two conversations, and each reply goes back on the connector its conversation came in on. |
| `data.status` | string | yes | `open`, `pending`, `snoozed` or `resolved`. |
| `data.assigned_to_user_id` | string \| null | yes | The team member the thread is assigned to; null when unassigned. |
| `data.subject` | string \| null | yes | A subject line, when the inbox set one; null otherwise. |
| `data.unread_count` | integer | yes | How many inbound messages the team has not read. |
| `data.blocked` | boolean | yes | True when inbound messages from the member are being dropped: the thread is history only and cannot reopen on inbound. |
| `data.last_message_at` | string \| null | yes | When the newest message, either way, arrived, ISO 8601. |
| `data.last_inbound_at` | string \| null | yes | When the member last wrote, ISO 8601; null if never. |
| `data.last_outbound_at` | string \| null | yes | When the team last replied, ISO 8601; null if never. |
| `data.first_response_at` | string \| null | yes | Stamped **once**, on the first human reply ever in the thread: an SLA measure of how long the member waited for a person, not of the most recent reply. A reopen does not reset it. |
| `data.resolved_at` | string \| null | yes | When the thread was last resolved, ISO 8601; null while open. |
| `data.created_at` | string \| null | yes | When the thread opened, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the thread last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Block a support contact
`POST /v1/support/conversations/{conversation}/block`
```bash
curl -X POST https://api.subscriby.net/v1/support/conversations/$CONVERSATION_ID/block \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Silences the member behind the thread: their messages to this project's bot are dropped without any notice to them, and the thread cannot reopen on inbound. Their paid access is untouched, because a block is an inbox decision and not a moderation one; use the [Members](https://docs.subscriby.net/api/v1/reference/members) endpoints to ban or kick. Idempotent and answers `200` with the thread; blocking an already-blocked contact changes nothing and emits nothing, and when the block actually flips `support.conversation.blocked` fires once.
- Requires ability: `support-conversation:update`
- Fires events: `support.conversation.blocked`
- MCP tools: `block_support_contact`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `conversation` | path | string (uuid) | yes | The thread, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The thread, blocked.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The conversation's id. |
| `data.project_id` | string | yes | The project whose bot the member wrote to. |
| `data.subscriber_id` | string | yes | The member behind the thread; may be a `lead` who never paid, since anyone who can reach the bot can open a conversation. |
| `data.member_name` | string | yes | The member's display name, or "Unknown member" when they have not shared one, so a picker has something human to show. |
| `data.channel` | string | yes | The key of the connector the member wrote through. A member who writes through two connectors has two conversations, and each reply goes back on the connector its conversation came in on. |
| `data.status` | string | yes | `open`, `pending`, `snoozed` or `resolved`. |
| `data.assigned_to_user_id` | string \| null | yes | The team member the thread is assigned to; null when unassigned. |
| `data.subject` | string \| null | yes | A subject line, when the inbox set one; null otherwise. |
| `data.unread_count` | integer | yes | How many inbound messages the team has not read. |
| `data.blocked` | boolean | yes | True when inbound messages from the member are being dropped: the thread is history only and cannot reopen on inbound. |
| `data.last_message_at` | string \| null | yes | When the newest message, either way, arrived, ISO 8601. |
| `data.last_inbound_at` | string \| null | yes | When the member last wrote, ISO 8601; null if never. |
| `data.last_outbound_at` | string \| null | yes | When the team last replied, ISO 8601; null if never. |
| `data.first_response_at` | string \| null | yes | Stamped **once**, on the first human reply ever in the thread: an SLA measure of how long the member waited for a person, not of the most recent reply. A reopen does not reset it. |
| `data.resolved_at` | string \| null | yes | When the thread was last resolved, ISO 8601; null while open. |
| `data.created_at` | string \| null | yes | When the thread opened, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the thread last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Unblock a support contact
`POST /v1/support/conversations/{conversation}/unblock`
Reverses a block. Idempotent and answers `200` with the thread; `support.conversation.unblocked` fires once when the block actually flips.
- Requires ability: `support-conversation:update`
- Fires events: `support.conversation.unblocked`
- MCP tools: `unblock_support_contact`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `conversation` | path | string (uuid) | yes | The thread, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The thread, unblocked.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The conversation's id. |
| `data.project_id` | string | yes | The project whose bot the member wrote to. |
| `data.subscriber_id` | string | yes | The member behind the thread; may be a `lead` who never paid, since anyone who can reach the bot can open a conversation. |
| `data.member_name` | string | yes | The member's display name, or "Unknown member" when they have not shared one, so a picker has something human to show. |
| `data.channel` | string | yes | The key of the connector the member wrote through. A member who writes through two connectors has two conversations, and each reply goes back on the connector its conversation came in on. |
| `data.status` | string | yes | `open`, `pending`, `snoozed` or `resolved`. |
| `data.assigned_to_user_id` | string \| null | yes | The team member the thread is assigned to; null when unassigned. |
| `data.subject` | string \| null | yes | A subject line, when the inbox set one; null otherwise. |
| `data.unread_count` | integer | yes | How many inbound messages the team has not read. |
| `data.blocked` | boolean | yes | True when inbound messages from the member are being dropped: the thread is history only and cannot reopen on inbound. |
| `data.last_message_at` | string \| null | yes | When the newest message, either way, arrived, ISO 8601. |
| `data.last_inbound_at` | string \| null | yes | When the member last wrote, ISO 8601; null if never. |
| `data.last_outbound_at` | string \| null | yes | When the team last replied, ISO 8601; null if never. |
| `data.first_response_at` | string \| null | yes | Stamped **once**, on the first human reply ever in the thread: an SLA measure of how long the member waited for a person, not of the most recent reply. A reopen does not reset it. |
| `data.resolved_at` | string \| null | yes | When the thread was last resolved, ISO 8601; null while open. |
| `data.created_at` | string \| null | yes | When the thread opened, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the thread last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Support Inbox](https://docs.subscriby.net/creators/support-inbox): Members who write to your project bot land in one inbox; answer from the dashboard or straight from the platform's chat.
- [Members API](https://docs.subscriby.net/api/v1/reference/members): The people behind the threads, and where to ban or kick.
- [Projects API](https://docs.subscriby.net/api/v1/reference/projects): The project the settings live on.
- [support.* events](https://docs.subscriby.net/webhooks/v1/events/support): Inbound traffic emits support.conversation.opened, support.message.received and, for a resolved thread, support.conversation.reopened from the bot pipeline; the endpoints below announce their own changes, and the support.canned_reply.* payloads carry a snippet's id, title, shortcut and sort_order, never its body.
---
# Team Members API
Source: https://docs.subscriby.net/api/v1/reference/team-members
**Team members** are the humans who collaborate inside a team: the creator who owns it and the people they invited to help run its projects. They are distinct from a project's *members*, the subscribers who buy plans, who are modelled separately and reached through the [Members](https://docs.subscriby.net/api/v1/reference/members) endpoints.
A collaborator joins by invitation to an email address with a role code, holds one [role](https://docs.subscriby.net/api/v1/reference/roles) at a time, and leaves either by removal or by having an unaccepted invitation withdrawn; both take the same ability, because both revoke the same prospective access. The team's owner appears as the first row of the roster but is not a membership: ownership cannot be re-roled or removed here. Reading who is on a team and reading how to reach them are separate asks, so `email` is present only when the token carries `team-member:view` and is otherwise absent from the row altogether.
The list is not paginated, since a team's collaborator list is small by construction. Every change announces itself as a `team.*` event.
## What the API refuses
The teams package enforces these itself; the API restates them rather than letting a plainly-invalid request surface as a `500`.
| Attempt | Result |
| ----------------------------------------- | ----------------------------------------------- |
| Re-role or remove the **owner** | `404`: ownership is not a membership row |
| Invite someone already in the team | `422` |
| Name a `role` code the team does not have | `422` |
| Address a user who is not in the team | `404` |
| Address a team the caller cannot see | `404` |
A team is visible only when the caller owns it or is a member. Tokens scoped to a different team get `404 RESOURCE_NOT_FOUND` on every route here; existence never leaks.
## Endpoints
- `GET /v1/teams/{team}/members` — [List a team's members](#list-a-teams-members)
- `POST /v1/teams/{team}/members` — [Invite a team member](#invite-a-team-member)
- `GET /v1/teams/{team}/members/{member}` — [Get a team member](#get-a-team-member)
- `DELETE /v1/teams/{team}/members/{member}` — [Remove a team member](#remove-a-team-member)
- `PATCH /v1/teams/{team}/members/{member}/role` — [Change a team member's role](#change-a-team-members-role)
- `DELETE /v1/teams/{team}/invitations/{invitation}` — [Withdraw an invitation](#withdraw-an-invitation)
## List a team's members
`GET /v1/teams/{team}/members`
```bash
curl https://api.subscriby.net/v1/teams/$TEAM_ID/members \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The team's owner as the first row followed by every member. Not paginated: a team's collaborator list is small by construction.
`email` is **omitted entirely** unless the token carries `team-member:view`. Listing who is on a team and reading how to reach them are different asks, and the fine-grained ability is what separates them. A roster-only token gets every other field and no `email` key at all: not `null`, absent.
The same `team-member:view-any` permission gates the dashboard's **Settings → Teams → Manage Members** page: only team owners and members holding the permission via their role can load it. Free-tier teams auto-assign everyone as owner, so this only matters on Growth-tier teams with multiple collaborators.
- Requires ability: `team-member:view-any`
- MCP tools: `list_team_members`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `team` | path | string (uuid) | yes | The team, resolved by the route binder. |
### Responses
- **200**: Array of `TeamMemberResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].id` | string | yes | The collaborator's user id, the value the member routes take. |
| `data[].team_id` | string | yes | The team the membership is on. |
| `data[].name` | string | yes | The collaborator's name. |
| `data[].email` | string | no | The collaborator's email address. Present only for a token holding `team-member:view`; a roster-only token gets no `email` key at all, not null. The show route requires that ability, so it always includes it. |
| `data[].role_id` | string \| null | yes | The role the collaborator holds on this team, or null for the owner pseudo-row. |
| `data[].is_owner` | boolean | yes | True only for the single team creator, who is returned alongside the members but bypasses the membership table entirely. |
| `data[].joined_at` | string \| null | yes | When the collaborator joined, ISO 8601; the team's creation for the owner. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Invite a team member
`POST /v1/teams/{team}/members`
```bash
curl -X POST https://api.subscriby.net/v1/teams/$TEAM_ID/members \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"email": "newcomer@example.com", "role": "support-agent"}'
```
Addressed by **email**, not user id: the invitee may not have a Subscriby account yet, which is what separates inviting from adding. They receive an invitation message with a link; the membership row appears when they accept.
`role` is a role **code** on that team, such as `support-agent`, not a role id. Create it first with [Roles](https://docs.subscriby.net/api/v1/reference/roles) if it does not exist.
Answers `201` with the invitation summary. Emits `team.member.invited`, then `team.member.joined` when they accept.
- Requires ability: `team-member:invite`
- Fires events: `team.member.invited`
- MCP tools: `invite_team_member`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `team` | path | string (uuid) | yes | The team, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
Who to invite and as what. An invitation names an email rather than a user, because the invitee may not have an account yet.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string (email) | yes | The invitee's email address, up to 255 characters. They receive an invitation message with a link; the membership appears when they accept. |
| `role` | string | yes | A role **code** on that team, such as `support-agent`, not a role id. Create it first if it does not exist; an unknown code is refused. |
### Responses
- **201**: 201 with the invitation summary.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.team_id` | string | yes | The team the invitation is for. |
| `data.email` | any | yes | The address the invitation was sent to. |
| `data.role` | any | yes | The role code the invitee will hold on accepting. |
| `data.status` | string | yes | Always `invited`: the membership appears on acceptance. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: below the Growth tier; nothing changes.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when the role code is not one of the team's, or the invitee is already in the team.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a team member
`GET /v1/teams/{team}/members/{member}`
```bash
curl https://api.subscriby.net/v1/teams/$TEAM_ID/members/$USER_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
One collaborator, with their `email`, which this route always includes because it requires `team-member:view` by definition. `404 RESOURCE_NOT_FOUND` if the user is not on the team or the team is not in the caller's visibility set.
- `role_id` points at a [Role](https://docs.subscriby.net/api/v1/reference/roles) row on the same team, or `null` for the owner pseudo-row.
- `is_owner` is `true` only for the single team creator; the owner is returned alongside the members but bypasses the membership table entirely.
- Requires ability: `team-member:view`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `team` | path | string (uuid) | yes | The team, resolved by the route binder. |
| `member` | path | string | yes | The person's place on the team, resolved by the route binder. |
### Responses
- **200**: The membership.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The collaborator's user id, the value the member routes take. |
| `data.team_id` | string | yes | The team the membership is on. |
| `data.name` | string | yes | The collaborator's name. |
| `data.email` | string | no | The collaborator's email address. Present only for a token holding `team-member:view`; a roster-only token gets no `email` key at all, not null. The show route requires that ability, so it always includes it. |
| `data.role_id` | string \| null | yes | The role the collaborator holds on this team, or null for the owner pseudo-row. |
| `data.is_owner` | boolean | yes | True only for the single team creator, who is returned alongside the members but bypasses the membership table entirely. |
| `data.joined_at` | string \| null | yes | When the collaborator joined, ISO 8601; the team's creation for the owner. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Remove a team member
`DELETE /v1/teams/{team}/members/{member}`
```bash
curl -X DELETE https://api.subscriby.net/v1/teams/$TEAM_ID/members/$USER_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Returns `204 No Content`. They lose access to every project and setting scoped to the team immediately; their own Subscriby account is untouched. Not gated by tier. Emits `team.member.removed`.
- Requires ability: `team-member:remove`
- Fires events: `team.member.removed`
- MCP tools: `remove_team_member`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `team` | path | string (uuid) | yes | The team, resolved by the route binder. |
| `member` | path | string | yes | The membership, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: for the owner, who cannot be removed.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Change a team member's role
`PATCH /v1/teams/{team}/members/{member}/role`
```bash
curl -X PATCH https://api.subscriby.net/v1/teams/$TEAM_ID/members/$USER_ID/role \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"role": "manager"}'
```
Moves a collaborator onto another of the team's roles. The team **owner** cannot be re-roled: ownership is not a membership row and carries everything unconditionally. Answers `200` with the membership and emits `team.member.role_changed`.
Changing a member's role has **no dashboard equivalent**: the settings page offers invite, remove and cancel-invitation only, so this endpoint is the only way to move someone between roles without removing and re-inviting them.
- Requires ability: `team-member:update-role`
- Fires events: `team.member.role_changed`
- MCP tools: `update_team_member_role`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `team` | path | string (uuid) | yes | The team, resolved by the route binder. |
| `member` | path | string | yes | The membership, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
The role to move a collaborator onto.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `role` | string | yes | A role **code** on the team, such as `manager`, not a role id. An unknown code is refused. |
### Responses
- **200**: The membership after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The collaborator's user id, the value the member routes take. |
| `data.team_id` | string | yes | The team the membership is on. |
| `data.name` | string | yes | The collaborator's name. |
| `data.email` | string | no | The collaborator's email address. Present only for a token holding `team-member:view`; a roster-only token gets no `email` key at all, not null. The show route requires that ability, so it always includes it. |
| `data.role_id` | string \| null | yes | The role the collaborator holds on this team, or null for the owner pseudo-row. |
| `data.is_owner` | boolean | yes | True only for the single team creator, who is returned alongside the members but bypasses the membership table entirely. |
| `data.joined_at` | string \| null | yes | When the collaborator joined, ISO 8601; the team's creation for the owner. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: below the Growth tier; nothing changes.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: for the owner, who has no role to change, or for someone who is not in the team.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when the role code is not one of the team's.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Withdraw an invitation
`DELETE /v1/teams/{team}/invitations/{invitation}`
Requires `team-member:remove`, the same ability as removing a member, because withdrawing an invitation and removing a member revoke the same prospective access.
```bash
curl -X DELETE https://api.subscriby.net/v1/teams/$TEAM_ID/invitations/$INVITATION_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
> **An invitee is not a member.** Someone who has not accepted has no membership row to remove, only a pending invitation. That is why this is a separate route: removing a member and withdrawing an invitation fail on different things, and using the wrong one returns `404` rather than doing something surprising.
Returns `204 No Content` and the invitation link stops working.
- Requires ability: `team-member:remove`
- MCP tools: `cancel_team_invitation`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `team` | path | string (uuid) | yes | The team, resolved by the route binder. |
| `invitation` | path | string (uuid) | yes | The pending invitation, resolved within the team by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Roles API](https://docs.subscriby.net/api/v1/reference/roles): A role is the permission set a collaborator holds on a team.
- [Teams API](https://docs.subscriby.net/api/v1/reference/teams): Teams group creators, roles, and projects.
- [Ability Catalog](https://docs.subscriby.net/api/v1/abilities): Every ability string a token can carry, what enforces it on the API and the MCP server, and how to pick the right ones when minting.
- [team.* events](https://docs.subscriby.net/webhooks/v1/events/team): Team creation, rename, deletion, and membership changes.
---
# Teams API
Source: https://docs.subscriby.net/api/v1/reference/teams
Teams group creators, roles, and projects. Tokens always resolve a single team via the mandatory `scope:team:` tuple in their abilities array, and these endpoints let you introspect and manage the teams your account can act on.
> **Creating a team is a Growth capability; deleting one is not.** Create and update require a platform tier that includes Teams; on a lower tier they return `403 TEAM_TIER_REQUIRED` and change nothing, the same gate the dashboard applies at **Settings → Teams**. Delete is not gated. See what the tier gates below.
## What the tier gates
Teams, roles and groups sell on the Growth tier, so one rule runs through every write in this part of the API:
> **Granting access is a paid capability. Withdrawing it is not.**
| | Gated on Growth |
| ------------------------------------------------------------------------------------------ | ------------------------------ |
| Create a team, role or group | Yes |
| Rename or re-permission one | Yes |
| Invite a collaborator, or change their role | Yes |
| Delete a team, role or group | No |
| Remove a collaborator, or withdraw an invitation | No |
| [Replace a group's members](https://docs.subscriby.net/api/v1/reference/groups#replace-a-groups-members) | Only if the sync adds somebody |
The asymmetry is deliberate. A creator who drops below Growth still has collaborators attached, and gating removal would leave them unable to revoke access they no longer want to be paying for; a downgrade would become a permanent grant. So the tier decides what you can build, never what you can take apart.
The same rule holds on the [MCP tools](https://docs.subscriby.net/mcp/v1/tools-reference) and in the dashboard.
## Endpoints
- `GET /v1/teams` — [List teams](#list-teams)
- `POST /v1/teams` — [Create a team](#create-a-team)
- `GET /v1/teams/current` — [Get the current team](#get-the-current-team)
- `GET /v1/teams/{team}` — [Get a team](#get-a-team)
- `PATCH /v1/teams/{team}` — [Rename a team](#rename-a-team)
- `DELETE /v1/teams/{team}` — [Delete a team](#delete-a-team)
## List teams
`GET /v1/teams`
```bash
curl https://api.subscriby.net/v1/teams \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Every team the caller owns or is a member of; teams belonging to other creators are invisible. Never paginated. Useful for a re-auth flow that wants to surface a team picker before the user mints a token scoped to a specific team.
- Requires ability: `team:view-any`
- MCP tools: `list_teams`
### Responses
- **200**: Array of `TeamResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].id` | string | yes | The team's id, the value a token's team scope names. |
| `data[].name` | string | yes | The team's name. |
| `data[].owner_user_id` | string | yes | The user who owns the team; deleting it is theirs alone. |
| `data[].personal` | boolean | yes | Whether this is the creator's personal team, the one created with the account, which cannot be deleted. |
| `data[].created_at` | string \| null | yes | When the team was created, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Create a team
`POST /v1/teams`
```bash
curl -X POST https://api.subscriby.net/v1/teams \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"name": "Research Collective"}'
```
The caller becomes the team **owner**. Ownership is not a membership row: the owner cannot be removed from the team, cannot be re-roled, and is the only account permitted to rename or delete it.
A new team starts empty. Add people with [Team Members](https://docs.subscriby.net/api/v1/reference/team-members), and give them something to hold with [Roles](https://docs.subscriby.net/api/v1/reference/roles). Answers `201` with the team and emits `team.created`.
- Requires ability: `team:create`
- Fires events: `team.created`
- MCP tools: `create_team`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A team's name: required on create, optional on update. Ownership does not transfer through this body, and membership moves through its own routes.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | The team's name, up to 255 characters; the only mutable field. |
### Responses
- **201**: The new team.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The team's id, the value a token's team scope names. |
| `data.name` | string | yes | The team's name. |
| `data.owner_user_id` | string | yes | The user who owns the team; deleting it is theirs alone. |
| `data.personal` | boolean | yes | Whether this is the creator's personal team, the one created with the account, which cannot be deleted. |
| `data.created_at` | string \| null | yes | When the team was created, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: below the Growth tier; nothing changes.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get the current team
`GET /v1/teams/current`
```bash
curl https://api.subscriby.net/v1/teams/current \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Exactly the team the token is scoped to via `scope:team:`. Zapier's connection test calls this on save to validate the bearer token.
- Requires ability: `team:view`
- MCP tools: `get_me`
### Responses
- **200**: The team named by the token's `scope:team:` ability.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The team's id, the value a token's team scope names. |
| `data.name` | string | yes | The team's name. |
| `data.owner_user_id` | string | yes | The user who owns the team; deleting it is theirs alone. |
| `data.personal` | boolean | yes | Whether this is the creator's personal team, the one created with the account, which cannot be deleted. |
| `data.created_at` | string \| null | yes | When the team was created, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `TENANT_MISMATCH`: with `reason: missing_team_scope` when the token carries no team scope. `RESOURCE_NOT_FOUND`: when the scoped team no longer exists.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a team
`GET /v1/teams/{team}`
```bash
curl https://api.subscriby.net/v1/teams/$TEAM_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The team if the caller belongs to it; `404 RESOURCE_NOT_FOUND` otherwise, never `403`, so foreign-team existence does not leak.
- Requires ability: `team:view`
- MCP tools: `get_team`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `team` | path | string (uuid) | yes | The team, resolved by the route binder. |
### Responses
- **200**: The team.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The team's id, the value a token's team scope names. |
| `data.name` | string | yes | The team's name. |
| `data.owner_user_id` | string | yes | The user who owns the team; deleting it is theirs alone. |
| `data.personal` | boolean | yes | Whether this is the creator's personal team, the one created with the account, which cannot be deleted. |
| `data.created_at` | string \| null | yes | When the team was created, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Rename a team
`PATCH /v1/teams/{team}`
```bash
curl -X PATCH https://api.subscriby.net/v1/teams/$TEAM_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"name": "Research Collective (EU)"}'
```
`name` is the only mutable field. Ownership does not transfer through this endpoint, and membership moves through its own routes. Owner only. Answers `200` with the team and emits `team.updated`.
- Requires ability: `team:update`
- Fires events: `team.updated`
- MCP tools: `update_team`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `team` | path | string (uuid) | yes | The team, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A team's name: required on create, optional on update. Ownership does not transfer through this body, and membership moves through its own routes.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | The team's name, up to 255 characters; the only mutable field. |
### Responses
- **200**: The team after the change.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The team's id, the value a token's team scope names. |
| `data.name` | string | yes | The team's name. |
| `data.owner_user_id` | string | yes | The user who owns the team; deleting it is theirs alone. |
| `data.personal` | boolean | yes | Whether this is the creator's personal team, the one created with the account, which cannot be deleted. |
| `data.created_at` | string \| null | yes | When the team was created, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: below the Growth tier; nothing changes.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when the caller belongs to the team but does not own it; `error.context.team_id` names it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Delete a team
`DELETE /v1/teams/{team}`
```bash
curl -X DELETE https://api.subscriby.net/v1/teams/$TEAM_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Returns `204 No Content` and emits `team.deleted`.
> **This takes everything with it.** Deleting a team deletes every project, plan, subscription and member scoped to it. There is no restore and no soft-delete. If you only want to stop selling, archive the projects instead.
- Requires ability: `team:delete`
- Fires events: `team.deleted`
- MCP tools: `delete_team`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `team` | path | string (uuid) | yes | The team, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: when the team is the caller's only one: an account with no tenant is a state nothing else in the product can produce, and an unscoped token would have nowhere to resolve to. `VALIDATION_FAILED`: when the caller is a member, not the owner: belonging to a team is not licence to dismantle it for everyone else in it.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Team Members API](https://docs.subscriby.net/api/v1/reference/team-members): Invite, re-role and remove collaborators
- [Roles API](https://docs.subscriby.net/api/v1/reference/roles): The permission sets a member can hold
- [Groups API](https://docs.subscriby.net/api/v1/reference/groups): Named permission bundles several people share
- [team.* events](https://docs.subscriby.net/webhooks/v1/events/team): Team creation, rename, deletion, and membership changes.
---
# Tokens API
Source: https://docs.subscriby.net/api/v1/reference/tokens
Personal access tokens are what authorize every REST and MCP call. They look like `sbt_live__` in production and `sbt_test__` everywhere else, and the plaintext value exists exactly once: in the response that created it.
You can mint one in the dashboard under **Settings → API Tokens**, or through the mint endpoint below. No `token.*` webhook events exist: mint and revoke are deliberately silent, because deliveries would let an observer fingerprint credential churn on an account they cannot otherwise see.
## Authorization
Ownership is necessary but not sufficient.
> **Changed in 3.0.0.** These routes previously required **no ability at all**, on the reasoning that owning a token is the permission. It is not: a token scoped to nothing but `dashboard:read` could enumerate and revoke every other token its owner held, including the ones running their automation. Each route now demands its matching `token:*` ability as well. If you have a token that reads analytics and also manages tokens, add `token:view-any` / `token:delete` to it, or better, mint a separate one.
Tokens belonging to another user are invisible: every route returns `404 RESOURCE_NOT_FOUND` for an id outside the caller's own set, so the API cannot be used to probe which ids exist.
## Endpoints
- `GET /v1/tokens` — [List the caller's tokens](#list-the-callers-tokens)
- `POST /v1/tokens` — [Mint a token](#mint-a-token)
- `GET /v1/tokens/{token}` — [Get a token](#get-a-token)
- `DELETE /v1/tokens/{token}` — [Revoke a token](#revoke-a-token)
## List the caller's tokens
`GET /v1/tokens`
```bash
curl https://api.subscriby.net/v1/tokens \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
Paginated, newest first. `per_page` accepts 1–100 and defaults to 25.
- Requires ability: `token:view-any`
- MCP tools: `list_tokens`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 25. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: The caller's tokens, newest first.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The token's id: an **integer** serialised as a string, the one identifier in the platform that is not a UUID. |
| `data[].name` | string | yes | The label the token was minted with. |
| `data[].abilities` | array of any | yes | The gate abilities only; the scope tuples are split out into `scopes`. |
| `data[].scopes` | object | yes | The token's confinement, read out of its `scope:team:` and `scope:project:` abilities as structure rather than opaque strings. |
| `data[].scopes.team_id` | string | yes | The team the token is scoped to; null for a token minted without a team scope. |
| `data[].scopes.project_ids` | array of string | yes | The projects the token is confined to; empty when it reaches every project of the team. |
| `data[].abilities_count` | integer | yes | How many gate abilities the token holds, bookkeeping excluded. |
| `data[].last_used_at` | string \| null | yes | When the token last authenticated a request, ISO 8601; null until it has. |
| `data[].expires_at` | string \| null | yes | When the token stops working, ISO 8601; null for a token that does not expire. |
| `data[].created_at` | string \| null | yes | When the token was minted, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Mint a token
`POST /v1/tokens`
```bash
curl -X POST https://api.subscriby.net/v1/tokens \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"name": "Nightly reconciliation",
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"abilities": ["project-subscription:view-any", "payment:view-any"]
}'
```
`token` sits alongside `data`, not inside it, the same shape webhook endpoints use for their one-time signing secret. Store it before you do anything else; there is no endpoint that will hand it back. Minting is deliberately not offered as an MCP tool.
**`team_id` is required here, and only here.** Everywhere else in v1 the team comes from the calling token's frozen scope and a `team_id` in the body is ignored. Minting is the exception: a token is created **for** a team, and you may belong to several. The minted token gets `scope:team:` automatically. It cannot be widened later.
> **A token can never mint a stronger token.** The abilities you request must be a **subset** of the abilities the calling token holds, and `token:create` is withheld unconditionally from anything minted this way.
Minting used to be dashboard-only precisely so a leaked automation token could not spin up a higher-privilege one. That concern is real, and attenuation is the answer to it rather than a retreat from it: a compromised credential can produce nothing its holder could not already do directly. `token:create` is withheld on top of the subset rule for a separate reason: inherited, it would leave an unbounded ladder of tokens descending from one leak, with no single revocation that ends the chain. A human in the dashboard is not attenuated. They authenticated with a password and a second factor, not with a bearer string, so they can grant anything in the catalog.
If the calling token carries a **deprecated** coarse ability, it may still mint the precise abilities that ability covers; the split does not shrink what an existing token can delegate.
Asking for more than you hold returns `422`; `context.abilities` echoes back what you asked for, not what was refused, and the refused ones are named in `message`:
```json
{
"error": {
"code": "VALIDATION_FAILED",
"message": "A token cannot grant abilities it does not hold: coupon:delete, project:delete.",
"context": {
"abilities": ["coupon:delete", "project:delete", "project:view-any"]
}
}
}
```
A retired ability keeps working on tokens that already carry it, but no new token can be given one. This is a field-level failure, so every offending entry in the array is reported at once under `fields`, and the message names the replacements; an ability that is simply unknown gets a different message pointing at the catalog, because a typo and a retirement need different fixes:
```json
{
"error": {
"code": "VALIDATION_FAILED",
"message": "webhook-endpoint:manage is retired and cannot be granted to a new token. Use webhook-delivery:retry, webhook-delivery:view-any, webhook-endpoint:create, webhook-endpoint:delete, webhook-endpoint:update, webhook-endpoint:view, webhook-endpoint:view-any instead.",
"fields": {
"abilities.0": [
"webhook-endpoint:manage is retired and cannot be granted to a new token. Use webhook-delivery:retry, webhook-delivery:view-any, webhook-endpoint:create, webhook-endpoint:delete, webhook-endpoint:update, webhook-endpoint:view, webhook-endpoint:view-any instead."
]
}
}
}
```
- Requires ability: `token:create`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A token to mint: its label, the abilities it holds and the team it is minted for. The abilities must be a subset of the calling token's, and `token:create` is never granted this way.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | A label for the token, up to 255 characters, shown on Settings → API Tokens. |
| `abilities` | array of string | yes | The abilities the token holds, at least one. Each must be one the calling token holds itself (attenuation), and `token:create` is withheld unconditionally. |
| `team_id` | string (uuid) | yes | The team the token is minted **for**; it receives `scope:team:` automatically and cannot be widened later. Must be a team the caller owns or belongs to. Required here, unlike everywhere else in v1 where the team comes from the calling token's scope. |
### Responses
- **201**: The token resource plus the plaintext, as a 201.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The token's id: an **integer** serialised as a string, the one identifier in the platform that is not a UUID. |
| `data.name` | string | yes | The label the token was minted with. |
| `data.abilities` | array of any | yes | The gate abilities only; the scope tuples are split out into `scopes`. |
| `data.scopes` | object | yes | The token's confinement, read out of its `scope:team:` and `scope:project:` abilities as structure rather than opaque strings. |
| `data.scopes.team_id` | string | yes | The team the token is scoped to; null for a token minted without a team scope. |
| `data.scopes.project_ids` | array of string | yes | The projects the token is confined to; empty when it reaches every project of the team. |
| `data.abilities_count` | integer | yes | How many gate abilities the token holds, bookkeeping excluded. |
| `data.last_used_at` | string \| null | yes | When the token last authenticated a request, ISO 8601; null until it has. |
| `data.expires_at` | string \| null | yes | When the token stops working, ISO 8601; null for a token that does not expire. |
| `data.created_at` | string \| null | yes | When the token was minted, ISO 8601. |
| `token` | string | yes | The plaintext token, `sbt___`, returned exactly once. Store it before anything else. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when a requested ability is not held by the calling token, `token:create` is requested, `team_id` is not a team the caller owns or belongs to, an ability is retired or unknown, or `abilities` is empty.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a token
`GET /v1/tokens/{token}`
```bash
curl https://api.subscriby.net/v1/tokens/$TOKEN_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
One of the caller's tokens, without its value.
- `id` is an **integer** serialised as a string, the one identifier in the platform that is not a UUID. See [Identifiers](https://docs.subscriby.net/api/v1#identifiers).
- `abilities` is the gate strings only. Scope tuples (`scope:team:…`, `scope:project:…`) are split out into `scopes` for readability, so `abilities_count` counts real permissions rather than bookkeeping.
- The plaintext value is **never** returned here. Lose it and the only recovery is to revoke the token and mint another.
- Requires ability: `token:view`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `token` | path | integer | yes | One of the caller's tokens, resolved by the route binder. |
### Responses
- **200**: The token resource.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The token's id: an **integer** serialised as a string, the one identifier in the platform that is not a UUID. |
| `data.name` | string | yes | The label the token was minted with. |
| `data.abilities` | array of any | yes | The gate abilities only; the scope tuples are split out into `scopes`. |
| `data.scopes` | object | yes | The token's confinement, read out of its `scope:team:` and `scope:project:` abilities as structure rather than opaque strings. |
| `data.scopes.team_id` | string | yes | The team the token is scoped to; null for a token minted without a team scope. |
| `data.scopes.project_ids` | array of string | yes | The projects the token is confined to; empty when it reaches every project of the team. |
| `data.abilities_count` | integer | yes | How many gate abilities the token holds, bookkeeping excluded. |
| `data.last_used_at` | string \| null | yes | When the token last authenticated a request, ISO 8601; null until it has. |
| `data.expires_at` | string \| null | yes | When the token stops working, ISO 8601; null for a token that does not expire. |
| `data.created_at` | string \| null | yes | When the token was minted, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Revoke a token
`DELETE /v1/tokens/{token}`
```bash
curl -X DELETE https://api.subscriby.net/v1/tokens/$TOKEN_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Returns `204 No Content`. Revocation is immediate: in-flight requests already authenticated will finish, and the next one fails.
- Requires ability: `token:delete`
- MCP tools: `revoke_token`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `token` | path | integer | yes | One of the caller's tokens, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred. On this endpoint: `RESOURCE_NOT_FOUND`: when no token with that id belongs to the authenticated user.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: with `token: self-revoke blocked` when you tried to revoke the exact row you are authenticating with. Revoke it from the dashboard, or from another token on the same account. This exists so an automation cannot lock itself out mid-run and leave the failure looking like a network fault.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Authentication](https://docs.subscriby.net/api/v1/authentication): The scope:team: tuple and the bearer header.
- [Ability Catalog](https://docs.subscriby.net/api/v1/abilities): Every ability string a token can carry.
- [Tenancy & Scopes](https://docs.subscriby.net/api/v1/tenancy-and-scopes): How team and project scoping combine.
---
# Webhook Deliveries API
Source: https://docs.subscriby.net/api/v1/reference/webhook-deliveries
Every event Subscriby posts to one of your [webhook endpoints](https://docs.subscriby.net/api/v1/reference/webhook-endpoints) is a **delivery**: one row per endpoint per event, carrying the signed envelope that was sent, the target's response and where the row is on the [retry ladder](https://docs.subscriby.net/webhooks/v1/retries-and-delivery). This is the same log the dashboard shows under **Settings → Webhook Deliveries**, over REST, so an integrator on call can see why a consumer rejected an event without signing in.
The two replay paths are the dashboard's too. A single failed or dead-lettered row can be retried from the start of the ladder, and everything the team dead-lettered since an instant can be replayed at once after a fix. A token that still carries the retired `webhook-endpoint:manage` satisfies both abilities on this page; see the [ability catalog](https://docs.subscriby.net/api/v1/abilities).
## Endpoints
- `GET /v1/webhook-deliveries` — [List webhook deliveries](#list-webhook-deliveries)
- `GET /v1/webhook-deliveries/{delivery}` — [Get a webhook delivery](#get-a-webhook-delivery)
- `POST /v1/webhook-deliveries/retry-dead` — [Retry every recent dead delivery](#retry-every-recent-dead-delivery)
- `POST /v1/webhook-deliveries/{delivery}/retry` — [Retry a webhook delivery](#retry-a-webhook-delivery)
## List webhook deliveries
`GET /v1/webhook-deliveries`
```bash
curl "https://api.subscriby.net/v1/webhook-deliveries?status=dead" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The team's deliveries across every endpoint, **newest first**, 25 per page (`per_page` 1 to 100; `limit` is accepted as an alias).
- Requires ability: `webhook-delivery:view-any`
- MCP tools: `list_webhook_deliveries`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | `all`, `pending`, `delivered`, `failed`, `dead` \| null | no | One of the log's tabs: `all` (the default), `pending`, `delivered`, `failed` or `dead`. A tab rather than a raw status, so an omitted value lists every row instead of quietly matching nothing. Anything else is refused. |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 15. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
| `limit` | query | integer | no | Legacy alias of `per_page`, kept for clients that predate it. `per_page` wins when both are sent. |
### Responses
- **200**: The tenant's deliveries on that tab, newest first.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The delivery's id, the value the retry endpoint takes. |
| `data[].endpoint_id` | string | yes | The webhook endpoint the event was posted to. |
| `data[].event` | string | yes | The event name, as the catalog lists it. |
| `data[].event_id` | string | yes | The 26-character ULID the envelope's `id` carries without its `evt_` prefix; also the `SB-Event-Id` header the handler received. |
| `data[].status` | string | yes | `pending` (born, or reset by a retry), `failed` (a non-2xx or transport error while retries remain), `delivered` (the first 2xx) or `dead` (the ladder ran out or the endpoint vanished). `delivered` and `dead` are final until a retry. |
| `data[].attempts` | integer | yes | Posts made so far. Reset to 0 by a retry. |
| `data[].response_status` | integer \| null | yes | The HTTP status the endpoint returned on the last attempt; null before the first post or after a transport failure. |
| `data[].response_excerpt` | string \| null | yes | The start of the response body, or the transport error; null before the first post. |
| `data[].payload` | array of any | yes | The full event envelope that was, or would have been, posted, so a consumer can be debugged against it exactly. |
| `data[].next_attempt_at` | string \| null | yes | When the worker will post a `failed` row again, ISO 8601; null otherwise. |
| `data[].delivered_at` | string \| null | yes | Stamped on the first 2xx, ISO 8601; null until then. |
| `data[].dead_lettered_at` | string \| null | yes | Stamped when the ladder ran out, ISO 8601; null otherwise. |
| `data[].created_at` | string | yes | When the row was born, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: naming `status` when it is not one of the five tabs.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a webhook delivery
`GET /v1/webhook-deliveries/{delivery}`
```bash
curl https://api.subscriby.net/v1/webhook-deliveries/$DELIVERY_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
One delivery. Another team's delivery is a `404 RESOURCE_NOT_FOUND`, never a `403`.
| Field | Type | Notes |
| ------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `event` | string | The event name, as in the [catalog](https://docs.subscriby.net/api/v1/reference/webhook-events). |
| `event_id` | string | The 26-character ULID the envelope's `id` carries without its `evt_` prefix; also the `SB-Event-Id` header your handler received. |
| `status` | string | `pending`, `delivered`, `failed` or `dead`. See below. |
| `attempts` | integer | Posts made so far. Reset to `0` by a retry. |
| `response_status` | integer or null | The HTTP status your endpoint returned on the last attempt; `null` before the first post or after a transport failure. |
| `response_excerpt` | string or null | The start of the response body, or the transport error. |
| `payload` | object | The full [event envelope](https://docs.subscriby.net/webhooks/v1/events) that was, or would have been, posted, so a consumer can be debugged against it exactly. |
| `next_attempt_at` | string or null | When the worker will post a `failed` row again. |
| `delivered_at` | string or null | Stamped on the first 2xx. |
| `dead_lettered_at` | string or null | Stamped when the ladder ran out. |
There is no `updated_at`: a delivery only moves forward through its statuses, and the three timestamps say when each transition happened.
A row is born `pending`, becomes `failed` after a non-2xx or transport error while retries remain, `delivered` on the first 2xx, and `dead` once the ladder is exhausted or the endpoint vanished. `delivered` and `dead` are final until a retry resets the row to `pending`.
> **`payload` discloses nothing new.** The envelope is what your own endpoint already received, or would have had it answered. Reading it needs `webhook-delivery:view-any`, and the tenant scope reaches a delivery only through an endpoint of the token's team.
- Requires ability: `webhook-delivery:view-any`
- MCP tools: `get_webhook_delivery`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `delivery` | path | string (uuid) | yes | The delivery, resolved by the route binder. |
### Responses
- **200**: The delivery with the envelope its endpoint received.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The delivery's id, the value the retry endpoint takes. |
| `data.endpoint_id` | string | yes | The webhook endpoint the event was posted to. |
| `data.event` | string | yes | The event name, as the catalog lists it. |
| `data.event_id` | string | yes | The 26-character ULID the envelope's `id` carries without its `evt_` prefix; also the `SB-Event-Id` header the handler received. |
| `data.status` | string | yes | `pending` (born, or reset by a retry), `failed` (a non-2xx or transport error while retries remain), `delivered` (the first 2xx) or `dead` (the ladder ran out or the endpoint vanished). `delivered` and `dead` are final until a retry. |
| `data.attempts` | integer | yes | Posts made so far. Reset to 0 by a retry. |
| `data.response_status` | integer \| null | yes | The HTTP status the endpoint returned on the last attempt; null before the first post or after a transport failure. |
| `data.response_excerpt` | string \| null | yes | The start of the response body, or the transport error; null before the first post. |
| `data.payload` | array of any | yes | The full event envelope that was, or would have been, posted, so a consumer can be debugged against it exactly. |
| `data.next_attempt_at` | string \| null | yes | When the worker will post a `failed` row again, ISO 8601; null otherwise. |
| `data.delivered_at` | string \| null | yes | Stamped on the first 2xx, ISO 8601; null until then. |
| `data.dead_lettered_at` | string \| null | yes | Stamped when the ladder ran out, ISO 8601; null otherwise. |
| `data.created_at` | string | yes | When the row was born, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Retry every recent dead delivery
`POST /v1/webhook-deliveries/retry-dead`
Once the consumer is fixed, replay everything the team dead-lettered since an instant in one call.
```bash
curl -X POST https://api.subscriby.net/v1/webhook-deliveries/retry-dead \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"since": "2026-09-05T22:00:00Z"}'
```
`retried` is how many rows were reset and queued. Only `dead` rows are touched: a `failed` row is still on its ladder and will be posted again on its own. The replay is capped at 500 rows per call so one request after a long outage cannot flood the queue or the target; call it again if the count comes back at the ceiling.
Each replayed row posts its event again, so confirm the consumer is healthy first; see [replaying bulk dead deliveries](https://docs.subscriby.net/webhooks/v1/retries-and-delivery#replaying-bulk-dead-deliveries).
- Requires ability: `webhook-delivery:retry`
- MCP tools: `retry_dead_webhook_deliveries`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
How far back to replay. Optional: omitted, the last 24 hours are replayed, the dashboard's Replay window.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `since` | string \| null (date-time) | no | ISO 8601, not in the future. Only rows dead-lettered **at or after** this instant are replayed. Omit it for the last 24 hours. |
### Responses
- **200**: How many rows were re-queued and the instant the window opened at.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.retried` | integer | yes | How many dead rows were reset to pending and queued, at most 500 per call. |
| `data.since` | string | yes | The instant the window opened at, ISO 8601: as sent, or 24 hours ago when omitted. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `since` is malformed or in the future.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Retry a webhook delivery
`POST /v1/webhook-deliveries/{delivery}/retry`
```bash
curl -X POST https://api.subscriby.net/v1/webhook-deliveries/$DELIVERY_ID/retry \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Answers `200` with the row **pending again**: `attempts` back to `0`, `response_status`, `response_excerpt` and `dead_lettered_at` cleared, and the worker queued. The delivery log then shows the replay's own outcome rather than the original failure's. A retry re-posts the **original** event to its endpoint; nothing new is raised about the replay itself.
> **Only `failed` and `dead` rows can be retried.** Resetting a delivered row would post the event to your server a second time. The dashboard hides its retry button behind the same rule, but a row can be delivered between a render and a click, and the API has no button at all.
- Requires ability: `webhook-delivery:retry`
- MCP tools: `retry_webhook_delivery`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `delivery` | path | string (uuid) | yes | The delivery, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The delivery, pending again.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The delivery's id, the value the retry endpoint takes. |
| `data.endpoint_id` | string | yes | The webhook endpoint the event was posted to. |
| `data.event` | string | yes | The event name, as the catalog lists it. |
| `data.event_id` | string | yes | The 26-character ULID the envelope's `id` carries without its `evt_` prefix; also the `SB-Event-Id` header the handler received. |
| `data.status` | string | yes | `pending` (born, or reset by a retry), `failed` (a non-2xx or transport error while retries remain), `delivered` (the first 2xx) or `dead` (the ladder ran out or the endpoint vanished). `delivered` and `dead` are final until a retry. |
| `data.attempts` | integer | yes | Posts made so far. Reset to 0 by a retry. |
| `data.response_status` | integer \| null | yes | The HTTP status the endpoint returned on the last attempt; null before the first post or after a transport failure. |
| `data.response_excerpt` | string \| null | yes | The start of the response body, or the transport error; null before the first post. |
| `data.payload` | array of any | yes | The full event envelope that was, or would have been, posted, so a consumer can be debugged against it exactly. |
| `data.next_attempt_at` | string \| null | yes | When the worker will post a `failed` row again, ISO 8601; null otherwise. |
| `data.delivered_at` | string \| null | yes | Stamped on the first 2xx, ISO 8601; null until then. |
| `data.dead_lettered_at` | string \| null | yes | Stamped when the ladder ran out, ISO 8601; null otherwise. |
| `data.created_at` | string | yes | When the row was born, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: for a `pending` or `delivered` row, with the message *Only failed or dead-lettered deliveries can be retried.*
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Webhook Endpoints API](https://docs.subscriby.net/api/v1/reference/webhook-endpoints): The targets these deliveries were posted to; pause one while you fix it.
- [Webhook Events API](https://docs.subscriby.net/api/v1/reference/webhook-events): The polling view of recent deliveries by event type.
- [Retries & Delivery](https://docs.subscriby.net/webhooks/v1/retries-and-delivery): The ladder, dead-lettering and auto-disable.
- [Testing & Debugging Webhooks](https://docs.subscriby.net/webhooks/v1/testing-and-debugging): Fire a test payload, read the delivery log and retry a failed delivery by hand, without waiting for the production trigger.
---
# Webhook Endpoints API
Source: https://docs.subscriby.net/api/v1/reference/webhook-endpoints
A **webhook endpoint** is a URL of yours that Subscriby posts events to, with the event families it subscribes to and a signing secret you verify each delivery against. An endpoint belongs to the team and may be narrowed to one project; the secret is shown exactly twice, in the answer to registering the endpoint and in the answer to rotating it, and never appears on a read.
Each endpoint carries its delivery health: the last success and failure, and `disabled_at` when Subscriby switched it off after too many consecutive failures or when someone paused it. **Pause** and **resume** are the dashboard's on/off switch, and events raised while paused are not queued or replayed; **test** enqueues a synthetic delivery so a new handler can be checked before real traffic; **rotate secret** revokes the old secret at once, so deploy the new one first. The delivery log and the replay of a dead-lettered row live under [webhook deliveries](https://docs.subscriby.net/api/v1/reference/webhook-deliveries).
Every member of the team may list and read endpoints, but only the member who registered one, or the team owner, may rotate, test, pause, resume or delete it. The same routes answer under the `/v1/webhook-subscriptions` alias so Zapier's subscribe and unsubscribe hooks can target them directly. Endpoint lifecycle emits no webhooks of its own, since that would be recursive.
## The webhook-subscriptions alias
`GET`, `POST /v1/webhook-subscriptions` and `DELETE /v1/webhook-subscriptions/{endpoint}` are 1:1 aliases of the list, create and delete endpoints below, so Zapier's subscribe and unsubscribe hooks can target them directly. They take the same abilities, bodies and answers and are not listed separately. The retired umbrella ability `webhook-endpoint:manage` still satisfies every route here on tokens that carry it; new tokens take the per-verb abilities.
## Who may change an endpoint
Every member of the team can list and read the team's endpoints, but only the member who registered an endpoint, or the team owner, may rotate its secret, test it, pause or resume it, or delete it. A token minted by another member receives `403 FORBIDDEN` on those calls; an endpoint outside the token's team is a `404 RESOURCE_NOT_FOUND`, never a `403`, so an id cannot be probed.
## Endpoints
- `GET /v1/webhook-endpoints` — [List webhook endpoints](#list-webhook-endpoints)
- `POST /v1/webhook-endpoints` — [Register a webhook endpoint](#register-a-webhook-endpoint)
- `GET /v1/webhook-endpoints/{endpoint}` — [Get a webhook endpoint](#get-a-webhook-endpoint)
- `DELETE /v1/webhook-endpoints/{endpoint}` — [Delete a webhook endpoint](#delete-a-webhook-endpoint)
- `POST /v1/webhook-endpoints/{endpoint}/rotate-secret` — [Rotate a webhook endpoint's secret](#rotate-a-webhook-endpoints-secret)
- `POST /v1/webhook-endpoints/{endpoint}/test` — [Test-fire a webhook endpoint](#test-fire-a-webhook-endpoint)
- `POST /v1/webhook-endpoints/{endpoint}/pause` — [Pause a webhook endpoint](#pause-a-webhook-endpoint)
- `POST /v1/webhook-endpoints/{endpoint}/resume` — [Resume a webhook endpoint](#resume-a-webhook-endpoint)
## List webhook endpoints
`GET /v1/webhook-endpoints`
```bash
curl https://api.subscriby.net/v1/webhook-endpoints \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The team's endpoints without their secrets, paged with `page` and `per_page`.
- Requires ability: `webhook-endpoint:view-any`
- MCP tools: `list_webhook_endpoints`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | The 1-based page to return. A page past the last answers an empty `data` array with `meta.total` still filled, so a loop can stop without guessing. |
| `per_page` | query | integer | no | Rows per page, 1 to 100. A higher value clamps to the cap silently. Defaults to 15. |
| `sort_by` | query | string | no | The column to order by. Defaults to `created_at`; a column the endpoint does not offer falls back to the default rather than failing. |
| `sort_direction` | query | `asc`, `desc` | no | `asc` or `desc`. Defaults to `desc`. |
### Responses
- **200**: The team's endpoints without their secrets.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items on this page. |
| `data[].id` | string | yes | The endpoint's id. |
| `data[].name` | string | yes | The label the endpoint was registered with, 3 to 100 characters. |
| `data[].url` | string | yes | The `https://` URL events are posted to. |
| `data[].project_id` | string \| null | yes | The project the endpoint is confined to; null for a team-wide endpoint that receives every project's events. |
| `data[].events` | array of any | yes | The event names the endpoint is subscribed to. |
| `data[].is_active` | boolean | yes | Whether deliveries flow. False while paused by the creator or switched off by the worker after too many consecutive failures. |
| `data[].disabled_at` | string \| null | yes | When the endpoint was paused or auto-disabled, ISO 8601; null while active. |
| `data[].allowed_ips` | array \| null | yes | IPv4/IPv6 addresses or CIDR ranges the target must resolve to; a delivery whose host resolves elsewhere is dead-lettered without being posted. Null when unrestricted. |
| `data[].failure_count` | integer | yes | How many deliveries have failed over the endpoint's lifetime. |
| `data[].consecutive_failures` | integer | yes | The current failure streak; the worker disables the endpoint when it reaches the limit, and resuming resets it. |
| `data[].last_success_at` | string \| null | yes | When a delivery last got a 2xx, ISO 8601; null until one has. |
| `data[].last_failure_at` | string \| null | yes | When a delivery last failed, ISO 8601; null until one has. |
| `data[].created_at` | string \| null | yes | When the endpoint was registered, ISO 8601. |
| `data[].updated_at` | string \| null | yes | When the endpoint last changed, ISO 8601. |
| `links` | object | yes | Links to the first, last, previous and next pages. |
| `links.first` | string \| null | yes | The first page's URL. |
| `links.last` | string \| null | yes | The last page's URL. |
| `links.prev` | string \| null | yes | The previous page's URL; null on the first page. |
| `links.next` | string \| null | yes | The next page's URL; null on the last page. |
| `meta` | object | yes | The paging counters for this page. |
| `meta.current_page` | integer | yes | The page returned, 1-indexed. |
| `meta.from` | integer \| null | yes | The 1-indexed position of this page's first item across every page; null when the page is empty. |
| `meta.last_page` | integer | yes | How many pages there are. |
| `meta.links` | array of object | yes | Generated paginator links. |
| `meta.links[].url` | string \| null | yes | The page's URL; null for the ellipsis and the disabled arrows. |
| `meta.links[].label` | string | yes | The link's label: a page number, the previous or next arrow, or an ellipsis. |
| `meta.links[].active` | boolean | yes | Whether this link is the current page. |
| `meta.path` | string \| null | yes | Base path for paginator generated URLs. |
| `meta.per_page` | integer | yes | Number of items shown per page. |
| `meta.to` | integer \| null | yes | Number of the last item in the slice. |
| `meta.total` | integer | yes | Total number of items being paginated. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Register a webhook endpoint
`POST /v1/webhook-endpoints`
```bash
curl -X POST https://api.subscriby.net/v1/webhook-endpoints \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"name": "Zapier trigger",
"url": "https://hooks.zapier.com/hooks/catch/...",
"events": ["subscription.created", "payment.succeeded"]
}'
```
Answers `201` with the endpoint and, at the top level beside `data`, the plaintext signing `secret`.
> **The secret appears once.** The plaintext `secret` (prefixed `whsec_`) appears at the top level of the response **only** here and on rotate-secret. Subsequent reads never surface it; copy it into your consumer's secret storage immediately.
- Requires ability: `webhook-endpoint:create`
- MCP tools: `create_webhook_endpoint`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Request body (application/json)
A webhook endpoint to register: where to post, which events, and how to confine it. The signing secret is minted by Subscriby and returned once beside the endpoint.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | A label for the endpoint, 3 to 100 characters. |
| `url` | string (uri) | yes | The target, `https://` only, up to 2,048 characters, pointing at a public host: loopback, private, link-local and reserved addresses are refused, as is a public hostname that resolves to one. |
| `project_id` | string \| null (uuid) | no | Confine the endpoint to one project's events; omit it for a team-wide subscription. |
| `events` | array of `project.created`, `project.updated`, `project.archived`, `project.restored`, `project.deleted`, `project.resource.created`, `project.resource.linked`, `project.resource.unlinked`, `project.resource.deleted`, `project.resource.updated`, `project.resource.status_changed`, `project.payment_method.updated`, `project.payment_method.deleted`, `plan.created`, `plan.updated`, `plan.activated`, `plan.deactivated`, `plan.sold_out`, `plan.order_changed`, `plan.deleted`, `plan.sync_completed`, `pass.window_scheduled`, `pass.window_opened`, `pass.window_closed`, `pass.window_cancelled`, `pass.holder_queued`, `pass.holder_moved`, `pass.holder_missed`, `pass.holder_stranded`, `pass_series.purchased`, `pass_series.leg_added`, `pass_series.leg_completed`, `pass_series.leg_substituted`, `pass_series.leg_dropped`, `pass_series.seats_exhausted`, `pass_series.completed`, `pass_series.presale_opened`, `coupon.created`, `coupon.updated`, `coupon.activated`, `coupon.deactivated`, `coupon.deleted`, `coupon.redeemed`, `coupon.exhausted`, `subscription.created`, `subscription.activated`, `subscription.trial_started`, `subscription.trial_converting`, `subscription.trial_expired`, `subscription.renewed`, `subscription.reactivated`, `subscription.paused`, `subscription.unpaused`, `subscription.past_due`, `subscription.unpaid`, `subscription.cancelled`, `subscription.expired`, `subscription.refunded`, `subscription.upgraded`, `subscription.downgraded`, `payment.succeeded`, `payment.failed`, `payment.pending`, `payment.refunded`, `member.joined`, `member.trial_joined`, `member.converted`, `member.churned`, `member.removed`, `member.banned`, `member.unbanned`, `member.kicked`, `member.resource_added`, `member.resource_pending`, `member.resource_reissued`, `member.access_extended`, `member.resource_removed`, `member.identity_linked`, `member.identity_unlinked`, `creator_task.opened`, `creator_task.completed`, `connector.installed`, `connector.connected`, `connector.disconnected`, `connector.status_changed`, `connector.settings_updated`, `connector.uninstalled`, `connector.doctor_completed`, `connector.outage_opened`, `connector.outage_closed`, `connector.outage_compensated`, `recovery.incident_opened`, `recovery.incident_resolved`, `recovery.operation_started`, `recovery.operation_completed`, `recovery.operation_failed`, `recovery.operation_reverted`, `recovery.standby_registered`, `recovery.standby_removed`, `recovery.installation_failed_over`, `recovery.resource_failed_over`, `recovery.resource_replaced`, `recovery.identity_relinked`, `recovery.readiness_changed`, `broadcast.queued`, `broadcast.completed`, `access_code.generated`, `access_code.redeemed`, `access_code.expired`, `support.conversation.opened`, `support.conversation.assigned`, `support.conversation.resolved`, `support.conversation.reopened`, `support.conversation.blocked`, `support.conversation.unblocked`, `support.message.received`, `support.message.sent`, `support.canned_reply.created`, `support.canned_reply.updated`, `support.canned_reply.deleted`, `support.settings.updated`, `team.created`, `team.updated`, `team.deleted`, `team.member.invited`, `team.member.joined`, `team.member.removed`, `team.member.role_changed`, `role.created`, `role.updated`, `role.deleted`, `group.created`, `group.updated`, `group.members_synced`, `group.deleted`, `billing.invoice_created`, `billing.invoice_paid`, `billing.invoice_overdue`, `billing.payment_failed`, `billing.trial_ending`, `billing.grace_period_warning`, `billing.account_locked`, `billing.tier_upgraded`, `billing.tier_downgraded`, `billing.tier_cancelled` | yes | The events to subscribe to, at least one. |
| `allowed_ips` | array \| null | no | IPv4/IPv6 addresses or CIDR ranges the target must resolve to. Enforced on every delivery: when the endpoint's host resolves to an address outside the list, the delivery is dead-lettered without being posted. |
| `is_active` | boolean | no | Whether deliveries flow from the start. Defaults to true. |
### Responses
- **201**: 201 with the resource and a top-level `secret`.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The endpoint's id. |
| `data.name` | string | yes | The label the endpoint was registered with, 3 to 100 characters. |
| `data.url` | string | yes | The `https://` URL events are posted to. |
| `data.project_id` | string \| null | yes | The project the endpoint is confined to; null for a team-wide endpoint that receives every project's events. |
| `data.events` | array of any | yes | The event names the endpoint is subscribed to. |
| `data.is_active` | boolean | yes | Whether deliveries flow. False while paused by the creator or switched off by the worker after too many consecutive failures. |
| `data.disabled_at` | string \| null | yes | When the endpoint was paused or auto-disabled, ISO 8601; null while active. |
| `data.allowed_ips` | array \| null | yes | IPv4/IPv6 addresses or CIDR ranges the target must resolve to; a delivery whose host resolves elsewhere is dead-lettered without being posted. Null when unrestricted. |
| `data.failure_count` | integer | yes | How many deliveries have failed over the endpoint's lifetime. |
| `data.consecutive_failures` | integer | yes | The current failure streak; the worker disables the endpoint when it reaches the limit, and resuming resets it. |
| `data.last_success_at` | string \| null | yes | When a delivery last got a 2xx, ISO 8601; null until one has. |
| `data.last_failure_at` | string \| null | yes | When a delivery last failed, ISO 8601; null until one has. |
| `data.created_at` | string \| null | yes | When the endpoint was registered, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the endpoint last changed, ISO 8601. |
| `secret` | string | yes | The plaintext signing secret, `whsec_…`, returned once. Verify every delivery's `SB-Signature` with it. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `TENANT_MISMATCH`: with `reason: missing_team_scope` when the token carries no team scope.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `url` is not `https://`, points at a private or reserved host, or exceeds 2,048 characters; when `events` is empty or names an unknown event; when `allowed_ips` holds an invalid address or range; when `name` is outside 3 to 100 characters.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Get a webhook endpoint
`GET /v1/webhook-endpoints/{endpoint}`
```bash
curl https://api.subscriby.net/v1/webhook-endpoints/$ENDPOINT_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The endpoint and its delivery health. The signing secret is never part of it; it exists in the create and rotate-secret responses only.
`disabled_at` populates when Subscriby auto-disables an endpoint after too many consecutive failures, or when the creator pauses it. `last_success_at` / `last_failure_at` track the most recent delivery outcomes. `project_id` is `null` for team-wide endpoints.
- Requires ability: `webhook-endpoint:view`
- MCP tools: `get_webhook_endpoint`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `endpoint` | path | string (uuid) | yes | The endpoint, resolved by the route binder. |
### Responses
- **200**: The endpoint and its delivery health, without its secret.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The endpoint's id. |
| `data.name` | string | yes | The label the endpoint was registered with, 3 to 100 characters. |
| `data.url` | string | yes | The `https://` URL events are posted to. |
| `data.project_id` | string \| null | yes | The project the endpoint is confined to; null for a team-wide endpoint that receives every project's events. |
| `data.events` | array of any | yes | The event names the endpoint is subscribed to. |
| `data.is_active` | boolean | yes | Whether deliveries flow. False while paused by the creator or switched off by the worker after too many consecutive failures. |
| `data.disabled_at` | string \| null | yes | When the endpoint was paused or auto-disabled, ISO 8601; null while active. |
| `data.allowed_ips` | array \| null | yes | IPv4/IPv6 addresses or CIDR ranges the target must resolve to; a delivery whose host resolves elsewhere is dead-lettered without being posted. Null when unrestricted. |
| `data.failure_count` | integer | yes | How many deliveries have failed over the endpoint's lifetime. |
| `data.consecutive_failures` | integer | yes | The current failure streak; the worker disables the endpoint when it reaches the limit, and resuming resets it. |
| `data.last_success_at` | string \| null | yes | When a delivery last got a 2xx, ISO 8601; null until one has. |
| `data.last_failure_at` | string \| null | yes | When a delivery last failed, ISO 8601; null until one has. |
| `data.created_at` | string \| null | yes | When the endpoint was registered, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the endpoint last changed, ISO 8601. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Delete a webhook endpoint
`DELETE /v1/webhook-endpoints/{endpoint}`
```bash
curl -X DELETE https://api.subscriby.net/v1/webhook-endpoints/$ENDPOINT_ID \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Returns `204 No Content`. The endpoint's delivery log stays readable.
- Requires ability: `webhook-endpoint:delete`
- MCP tools: `delete_webhook_endpoint`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `endpoint` | path | string (uuid) | yes | The endpoint, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **204**: No content
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `FORBIDDEN`: when the caller neither registered the endpoint nor owns the team.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Rotate a webhook endpoint's secret
`POST /v1/webhook-endpoints/{endpoint}/rotate-secret`
```bash
curl -X POST https://api.subscriby.net/v1/webhook-endpoints/$ENDPOINT_ID/rotate-secret \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
Answers `200` with the endpoint and a brand-new `secret` alongside `data`. The old secret is revoked immediately: deploy the new one to your handler before calling this endpoint.
- Requires ability: `webhook-endpoint:update`
- MCP tools: `rotate_webhook_endpoint_secret`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `endpoint` | path | string (uuid) | yes | The endpoint, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The resource with the new plaintext `secret`, once.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The endpoint's id. |
| `data.name` | string | yes | The label the endpoint was registered with, 3 to 100 characters. |
| `data.url` | string | yes | The `https://` URL events are posted to. |
| `data.project_id` | string \| null | yes | The project the endpoint is confined to; null for a team-wide endpoint that receives every project's events. |
| `data.events` | array of any | yes | The event names the endpoint is subscribed to. |
| `data.is_active` | boolean | yes | Whether deliveries flow. False while paused by the creator or switched off by the worker after too many consecutive failures. |
| `data.disabled_at` | string \| null | yes | When the endpoint was paused or auto-disabled, ISO 8601; null while active. |
| `data.allowed_ips` | array \| null | yes | IPv4/IPv6 addresses or CIDR ranges the target must resolve to; a delivery whose host resolves elsewhere is dead-lettered without being posted. Null when unrestricted. |
| `data.failure_count` | integer | yes | How many deliveries have failed over the endpoint's lifetime. |
| `data.consecutive_failures` | integer | yes | The current failure streak; the worker disables the endpoint when it reaches the limit, and resuming resets it. |
| `data.last_success_at` | string \| null | yes | When a delivery last got a 2xx, ISO 8601; null until one has. |
| `data.last_failure_at` | string \| null | yes | When a delivery last failed, ISO 8601; null until one has. |
| `data.created_at` | string \| null | yes | When the endpoint was registered, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the endpoint last changed, ISO 8601. |
| `secret` | string | yes | The new plaintext signing secret, `whsec_…`, returned once. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `FORBIDDEN`: when the caller neither registered the endpoint nor owns the team.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Test-fire a webhook endpoint
`POST /v1/webhook-endpoints/{endpoint}/test`
Enqueues a synthetic `subscription.created`-shaped delivery against the endpoint and answers `202 Accepted`. The row is handed to the delivery worker immediately, so it shows up in **Settings → Webhook Deliveries** and moves to `delivered` or `failed` as soon as your handler answers. Rate-limited to 5 fires per minute per endpoint.
- Requires ability: `webhook-endpoint:update`
- MCP tools: `test_webhook_endpoint`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `endpoint` | path | string (uuid) | yes | The endpoint, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **202**: 202 with the new delivery's id and status.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.delivery_id` | any | yes | The delivery row to read the outcome from. |
| `data.status` | string | yes | Always `pending` at this point; read the delivery for what happened. |
| `data.enqueued_at` | string | yes | When the row was handed to the worker, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `FORBIDDEN`: when the caller neither registered the endpoint nor owns the team.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Pause a webhook endpoint
`POST /v1/webhook-endpoints/{endpoint}/pause`
```bash
curl -X POST https://api.subscriby.net/v1/webhook-endpoints/$ENDPOINT_ID/pause \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
The dashboard's on/off switch, off. A paused endpoint keeps its secret, its event subscriptions and its delivery log, but events raised while it is paused are **not** queued for it and are not replayed on resume; use the [deliveries API](https://docs.subscriby.net/api/v1/reference/webhook-deliveries) to replay rows that dead-lettered before the pause. Answers `200` with the endpoint; idempotent, pausing a paused endpoint changes nothing.
- Requires ability: `webhook-endpoint:update`
- MCP tools: `pause_webhook_endpoint`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `endpoint` | path | string (uuid) | yes | The endpoint, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The endpoint, paused.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The endpoint's id. |
| `data.name` | string | yes | The label the endpoint was registered with, 3 to 100 characters. |
| `data.url` | string | yes | The `https://` URL events are posted to. |
| `data.project_id` | string \| null | yes | The project the endpoint is confined to; null for a team-wide endpoint that receives every project's events. |
| `data.events` | array of any | yes | The event names the endpoint is subscribed to. |
| `data.is_active` | boolean | yes | Whether deliveries flow. False while paused by the creator or switched off by the worker after too many consecutive failures. |
| `data.disabled_at` | string \| null | yes | When the endpoint was paused or auto-disabled, ISO 8601; null while active. |
| `data.allowed_ips` | array \| null | yes | IPv4/IPv6 addresses or CIDR ranges the target must resolve to; a delivery whose host resolves elsewhere is dead-lettered without being posted. Null when unrestricted. |
| `data.failure_count` | integer | yes | How many deliveries have failed over the endpoint's lifetime. |
| `data.consecutive_failures` | integer | yes | The current failure streak; the worker disables the endpoint when it reaches the limit, and resuming resets it. |
| `data.last_success_at` | string \| null | yes | When a delivery last got a 2xx, ISO 8601; null until one has. |
| `data.last_failure_at` | string \| null | yes | When a delivery last failed, ISO 8601; null until one has. |
| `data.created_at` | string \| null | yes | When the endpoint was registered, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the endpoint last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `FORBIDDEN`: when the caller neither registered the endpoint nor owns the team.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Resume a webhook endpoint
`POST /v1/webhook-endpoints/{endpoint}/resume`
The switch, on. Resuming also clears the consecutive-failure streak, so a target that was fixed while paused is not disabled again on its first miss. Answers `200` with the endpoint; idempotent.
- Requires ability: `webhook-endpoint:update`
- MCP tools: `resume_webhook_endpoint`
- Idempotent: the same `Idempotency-Key` replays the original response for 24 hours.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `endpoint` | path | string (uuid) | yes | The endpoint, resolved by the route binder. |
| `Idempotency-Key` | header | string (uuid) | yes | A key unique to this operation, such as a fresh UUID. The same key replays the original 2xx response for 24 hours (with `Idempotent-Replay: true`), so a retry after a timeout never repeats the write; the same key with a different body is refused with 409. |
### Responses
- **200**: The endpoint, active.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object | yes | The response's payload. |
| `data.id` | string | yes | The endpoint's id. |
| `data.name` | string | yes | The label the endpoint was registered with, 3 to 100 characters. |
| `data.url` | string | yes | The `https://` URL events are posted to. |
| `data.project_id` | string \| null | yes | The project the endpoint is confined to; null for a team-wide endpoint that receives every project's events. |
| `data.events` | array of any | yes | The event names the endpoint is subscribed to. |
| `data.is_active` | boolean | yes | Whether deliveries flow. False while paused by the creator or switched off by the worker after too many consecutive failures. |
| `data.disabled_at` | string \| null | yes | When the endpoint was paused or auto-disabled, ISO 8601; null while active. |
| `data.allowed_ips` | array \| null | yes | IPv4/IPv6 addresses or CIDR ranges the target must resolve to; a delivery whose host resolves elsewhere is dead-lettered without being posted. Null when unrestricted. |
| `data.failure_count` | integer | yes | How many deliveries have failed over the endpoint's lifetime. |
| `data.consecutive_failures` | integer | yes | The current failure streak; the worker disables the endpoint when it reaches the limit, and resuming resets it. |
| `data.last_success_at` | string \| null | yes | When a delivery last got a 2xx, ISO 8601; null until one has. |
| `data.last_failure_at` | string \| null | yes | When a delivery last failed, ISO 8601; null until one has. |
| `data.created_at` | string \| null | yes | When the endpoint was registered, ISO 8601. |
| `data.updated_at` | string \| null | yes | When the endpoint last changed, ISO 8601. |
- **400**: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `FORBIDDEN`: when the caller neither registered the endpoint nor owns the team.
- **404**: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- **409**: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- **425**: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Webhook Deliveries API](https://docs.subscriby.net/api/v1/reference/webhook-deliveries): The delivery log, and how to replay a failed or dead-lettered row.
- [Webhook Events API](https://docs.subscriby.net/api/v1/reference/webhook-events): Consume recent deliveries over REST. Endpoint lifecycle itself emits no outbound webhooks, since that would be recursive; every other family can be subscribed to.
- [Webhook Security](https://docs.subscriby.net/webhooks/v1/security): Signatures, public hosts and the IP allowlist.
- [Ability Catalog](https://docs.subscriby.net/api/v1/abilities): Every ability string a token can carry, what enforces it on the API and the MCP server, and how to pick the right ones when minting.
---
# Webhook Events API
Source: https://docs.subscriby.net/api/v1/reference/webhook-events
A read-only polling endpoint that surfaces the most recent webhook deliveries for a given event type. The envelope is identical to the live webhook POST body, so polling samples and live hook payloads are indistinguishable.
> **Why it exists.** REST Hook integrations (Zapier, Make, n8n) can satisfy the polling fallback contract even when the user has not yet fired a live event. If your team has no deliveries for the requested type, the response is an empty `data` array; consumers fall back to their static sample in that case.
The endpoint takes `webhook-delivery:view-any`. A token that still carries the retired `webhook-endpoint:manage` satisfies it.
## Endpoints
- `GET /v1/webhook-events` — [Poll recent webhook events](#poll-recent-webhook-events)
## Poll recent webhook events
`GET /v1/webhook-events`
```bash
curl "https://api.subscriby.net/v1/webhook-events?type=subscription.created&limit=3" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
The most recent deliveries of one event type, newest first, as the envelopes the endpoints received. `limit` defaults to 3 and is capped at 10; `project_id` restricts to deliveries whose endpoint is scoped to that project.
`api_version` is the payload-envelope stamp the dispatcher wrote at delivery time; see [versioning](https://docs.subscriby.net/api/v1/versioning-and-deprecation). Results are ordered by `created_at` descending. Only deliveries against endpoints owned by the token's team are returned; other tenants' events are never surfaced.
`data` is empty when no endpoint in your team is subscribed to the requested event type yet, or when no delivery has fired for that type since the relevant endpoint was created. Zapier, Make, and n8n all fall back to the trigger's static sample in that case, so builders can still author a Zap end-to-end before the first live event flows.
- Requires ability: `webhook-delivery:view-any`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `type` | query | string | yes | The event name to sample, such as `subscription.created`; must be a case from the outbound webhook event catalog. An unknown name is refused. |
| `limit` | query | integer | no | How many recent deliveries to return, 1 to 10. |
| `project_id` | query | string (uuid) | no | Restrict to deliveries whose endpoint is confined to this project. |
### Responses
- **200**: Array of `WebhookEventResource`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of object | yes | The items. |
| `data[].id` | any | yes | The event id, `evt_` followed by a 26-character ULID; the same value the `SB-Event-Id` header carries without the prefix. |
| `data[].type` | string | yes | The event name, such as `subscription.created`. |
| `data[].created_at` | string | yes | When the event was raised, ISO 8601. |
| `data[].api_version` | string \| null | yes | The payload-envelope version the dispatcher stamped at delivery time, `2026-05-01` today. |
| `data[].project_id` | string \| null | yes | The project the event concerns; null for account-level events. |
| `data[].data` | string \| array of string | yes | The event's payload, exactly as the event's reference page documents it. |
- **401**: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- **403**: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- **404**: `TENANT_MISMATCH`: with `reason: missing_team_scope` when the token carries no team scope.
- **422**: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: when `type` is missing or not an event name from the catalog, `limit` is outside 1 to 10, or `project_id` is not a UUID.
- **429**: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.
## Related
- [Webhook Endpoints API](https://docs.subscriby.net/api/v1/reference/webhook-endpoints): Create and manage the endpoints whose deliveries feed this list.
- [Webhook Deliveries API](https://docs.subscriby.net/api/v1/reference/webhook-deliveries): The same rows with their delivery outcome.
- [Event Reference](https://docs.subscriby.net/webhooks/v1/events): Every event and its payload.
- [Ability Catalog](https://docs.subscriby.net/api/v1/abilities): Every ability string a token can carry, what enforces it on the API and the MCP server, and how to pick the right ones when minting.
---
# Tenancy & Scopes
Source: https://docs.subscriby.net/api/v1/tenancy-and-scopes
Subscriby is multi-tenant by construction. Every project belongs to a team, and every row that hangs off a project inherits that team's isolation. The API preserves the same boundary — a token minted for Team A can never read or mutate rows that belong to Team B.
## Request-scoped team
Every token carries a mandatory `scope:team:` entry in its ability list. When a request arrives on `api.subscriby.net`:
1. Sanctum validates the bearer token.
2. The server reads `scope:team:` from the token's ability list and scopes every query to that team for the duration of the request. No database write happens — the override lives in memory only.
From that point on, every read the request performs is automatically tenant-scoped — the same isolation the creator dashboard uses. You never pass `team_id` manually in the request body.
Missing the `scope:team:` entry on a token fails every request with
`TENANT_MISMATCH` before the controller even runs. The dashboard appends the
scope automatically — only hand-crafted tokens miss it.
## Optional project scope entries
To further restrict a token, attach one or more `scope:project:` entries alongside the team scope. The server parses them into an allow-list; any request touching a project outside the list returns `TENANT_MISMATCH` (HTTP 404 — see below).
Typical use cases:
- A Zapier Zap that should only see one project at a time.
- An MCP token handed to a contractor who should only work inside one project.
- A read-only analytics pipeline pulling metrics from a subset of projects.
## Cross-team access is 404, not 403
When a token with `scope:team:A` tries to `GET /v1/projects/`, the response is:
```http
HTTP/1.1 404 Not Found
Content-Type: application/json
{"error": {"code": "RESOURCE_NOT_FOUND", ...}}
```
This is intentional: a `403` would leak the fact that the project exists under a different team. `404` keeps tenant existence private.
## MCP inherits the same contract
The MCP server at `mcp.subscriby.net` enforces the same team and project scoping. A token issued for Team A that tries to exercise a tool against Team B's project returns the same `TENANT_MISMATCH` envelope — just wrapped in the MCP response shape.
## Related
- [Authentication](/api/v1/authentication) — how tokens are minted and presented.
- [Ability catalog](/api/v1/abilities) — every accepted ability string plus scope entries.
- [Error envelope](/api/v1/errors) — `TENANT_MISMATCH` remediation.
---
# Versioning & Deprecation
Source: https://docs.subscriby.net/api/v1/versioning-and-deprecation
The public API ships under `/v1/*`. `v1` is the only contract currently published. New functionality is added additively; breaking changes will only ever appear in a future major version such as `/v2/*`.
## Additive vs breaking
Treat your client as tolerant of additions. The following are **not** breaking and can ship at any time on `v1`:
- A new optional request parameter.
- A new field in a response body.
- A new enum case in an existing enum (subscription status, webhook event, payment provider).
- A new endpoint.
- A new error code (`error.code`).
- A reworded `error.message`. The stable contract is `error.code`, not the free-form message.
The following are breaking and will not happen on `v1`:
- Removing a field from a response.
- Renaming a field.
- Changing a field's type (e.g. string → integer).
- Adding a new **required** request parameter.
- Changing the semantics of an existing parameter or enum case.
- Renaming an endpoint path.
- Changing an HTTP status code for an existing scenario.
## Webhook payload versioning
Outbound webhook envelopes carry an `api_version` field. Consumers that care about payload stability should read it and branch on value changes — additive changes leave the version stamp unchanged.
```json
{
"type": "subscription.created",
"api_version": "2026-05-01",
"created_at": "2026-05-18T10:05:00Z",
"project_id": "prj_...",
"data":
}
```
Subscribing to a new event type is never breaking — the envelope shape doesn't change, just the `type` value.
## When a breaking change is eventually needed
If Subscriby ever needs to break a contract on the REST API, the process will be:
1. A new major version (`/v2/*`) is published alongside `/v1/*`.
2. `v1` continues to operate for a long transition window so existing integrations don't break.
3. Affected responses carry `Sunset` and `Deprecation` headers per [RFC 8594](https://datatracker.ietf.org/doc/html/rfc8594) as the window narrows.
4. After the window closes, `v1` is removed and callers receive `410 Gone`.
This document will be updated when a v2 contract ships. Today, `v1` is the only version.
## Related
- [Error envelope](/api/v1/errors) — stable `error.code` strings.
- [OpenAPI specification](/api/v1/openapi) — machine-readable `v1` shape.
---
# Connectors
Source: https://docs.subscriby.net/connectors
A Subscriby project runs on **connectors**. A connector is the package that speaks to one platform: it admits and removes members from the places you gate, carries your messages, verifies who a member is, and, where the platform has one, takes its native payment. A project installs any number of them, a plan can mix resources across them, and one purchase grants access on all of them.
Every connector is listed on the public [Connectors Marketplace](/connectors/marketplace), lane by lane, straight from the same catalogue the dashboard installs from. The per-project [Connectors](/creators/connectors) page is where you install one, connect it, watch its health and remove it.
## Status at a glance
| Connector | Lane | Places it gates | Broadcast | Access codes | Native payment |
| --------------------------------------------------- | ------------- | -------------------------------- | :-------: | :----------: | :------------: |
| [**Telegram**](/connectors/telegram/advantages) | Available now | Channels, groups and supergroups | ✅ | ✅ | Telegram Stars |
| Discord | Under development | Servers and roles | — | — | — |
| Slack | Coming soon | Workspaces and channels | — | — | — |
| WhatsApp | Coming soon | Groups | — | — | — |
Also listed as **Coming Soon** on the marketplace: Microsoft Teams, Discourse, Ghost, Google Drive, GitHub, Reddit, Guilded and Zoom. The [roadmap](/connectors/roadmap) says what each lane means and what "coming soon" commits us to.
Official connectors cost nothing. Installing a second connector on one project, or publishing a plan whose resources span two connectors, needs the `multi_connector` capability, which the Growth plan carries. The Connectors page says so before you install.
## Available now
**Complete coverage, live today.**
Your own bot, created with @BotFather and connected with its token, runs the membership: members are admitted to private channels, groups and supergroups the moment they pay and removed when their access ends, broadcasts go out paced inside Telegram's limits, Telegram Stars is a payment method, and the whole creator toolkit is reachable from the platform bot as well as the dashboard.
- [Overview](/connectors/telegram) — everything the Telegram connector does, in one place.
- [Advantages](/connectors/telegram/advantages) — why creators start on Telegram.
- [Onboarding](/connectors/telegram/onboarding) — create your account and connect your first bot.
- [Bot features](/connectors/telegram/bot-features) — what subscribers see.
- [Admin commands](/connectors/telegram/admin-commands) — creator controls inside the bot.
## On the way
**Under development.**
Server memberships granted as roles, with single-use invites for members who are not in the server yet, and slash commands for creators.
**Coming soon.**
Workspace-based memberships and paid channels, with member management through Slack's bot APIs.
**Coming soon.**
Group memberships through the WhatsApp Business API, with template-based messages.
## What every connector does the same way
Whatever the platform, a connector plugs into the same Subscriby:
- **Access** — a paid subscription turns into a grant on each resource of the plan. The connector decides how a grant is delivered (an invite link, a membership, a role, or a task for you to do by hand); the [subscription](/subscriptions) rules decide when it starts and ends.
- **Identity** — a member is one person with an account on each connector they use. The portal is where they link and unlink those accounts, and a purchase made before an account is linked waits as a pending grant until it is.
- **Messages** — confirmations, reminders, broadcasts and support replies are written once and rendered by each connector in its own format and within its own limits.
- **Health** — every installation and every gated place is probed on a schedule; the [Disaster Recovery Program](/disaster-recovery) uses the same probes to know when to act.
- **Recovery** — standby installations, standby places and account relinking exist wherever the platform lets a connector offer them; the readiness checklist only ever lists what your connectors can do.
## Build a connector
Connectors are Composer packages built on the Subscriby connector SDK. [Building a connector](/sdk/v1/building) explains the manifest, the ports, the data rules and the conformance kit a package passes before Subscriby reviews and installs it.
---
# Connectors Marketplace
Source: https://docs.subscriby.net/connectors/marketplace
The **Connectors Marketplace** at [www.subscriby.net/connectors](https://www.subscriby.net/connectors) lists every connector Subscriby knows about: the platforms it gates access on, sends messages through and takes payments from today, and the ones on the roadmap. The dashboard's per-project [Connectors](/creators/connectors) page, the [`GET /connectors`](/api/v1/reference/connectors) endpoint and the MCP resource `subscriby://connectors/catalog` render the same catalogue, so what the site promises is what a creator can install.
## Lanes
Cards are grouped by status:
| Lane | Meaning |
| --------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Available Now** | A package is installed and enabled. Any project can install it. |
| **Experimental** | Available, but still in its early days: expect rough edges and fast changes. |
| **Paused** | Enabled in the past, switched off during an incident. Existing installations keep their data; nothing new acts. |
| **Under Development** | The package exists but is not enabled here yet (it is being soaked on staging). |
| **Coming Soon** | A roadmap stub: there is no package yet. The card carries an ETA sentence and a **Notify me** form. |
## Badges
- **Official** (the check mark by the name) — built and maintained by Subscriby.
- **Community** — built by a third party against the connector SDK and reviewed by Subscriby.
- **New** — added to the catalogue within the last 60 days.
- **Trending** — the most installed connector over the last 30 days.
## Sorting and filtering
Cards come **most popular first**: the connector running on the most projects tops its lane, and the hero shows the most popular icons with a count of what is available and what is on the roadmap. **Sort by** switches to **Newest** (when the listing was added) or **Name**. The chips narrow the list to **Official**, **Community**, **Available Now** or **Coming Soon**, and the category row to **Messaging**, **Community**, **Payments** or **Productivity**. Every choice is a plain link (`?sort=`, `?filter=`, `?category=`), so a filtered view can be shared; the line under the toolbar says how many of the connectors are showing. Cards that already run on projects say on how many.
## A connector's page
Each card opens a page built from the connector's manifest, so nothing on it is typed by hand:
- **Overview** — the connector's own description.
- **What it can do**, in plain words — what you can sell access to (each named after the platform, such as "Telegram Channels", with the name members see and a four-row checklist of the plan shapes that kind of place can be sold under: one-time payments, recurring subscriptions, time-limited passes and pass series, a dash marking the ones it cannot), what it does for you (automatic access, messages to members, a support inbox, backups and so on, each in one sentence), how many everyday tasks you can run from inside the platform, and what you need to have ready to connect it (a token, a sign-in, or nothing when Subscriby runs the connection for you).
- **Made by, Category, Sign-in required, Version, Added, Status** — the manifest's listing block.
- **More info** — documentation, support, privacy policy, terms and homepage links, when the connector publishes them.
- **Pricing** — official connectors are free; a second connector on the same project needs the Growth plan (the `multi_connector` capability), and a few connectors are reserved for paid plans (see [which plans they will need](/connectors/roadmap#which-plans-they-will-need)). A reserved connector's card carries a **Starter plan** or **Growth plan** chip beside its lane, its details list a **Plan** row, and its pricing box names the plan it needs.
**Get Started** on an available connector signs you in (or registers you) and opens that connector inside the app, where **Connect to a project** installs it on the project you pick.
## Notify me
A **Coming Soon** card (and an **Under Development** one, until it is enabled) shows a **Notify me** form instead of the capability section. Leave an email and Subscriby writes to it the day the connector launches. The address goes to one marketing contact book with the connector's key attached and nothing else is sent to it. Asking to be notified about a connector that is already available redirects you to its page with a note to install it instead. The form is rate-limited to six submissions a minute per visitor.
The notify list for that connector is not open yet, and the card says so. Ask through **Request a connector** instead.
## Build a connector
Every connector on the page is a Composer package built on the Subscriby connector SDK: it declares its manifest in a `connector.json` file (key, resource kinds, capabilities, messaging limits, management commands, the listing block with the words the public site borrows for that platform, and its install fields), binds the ports it implements, and passes the SDK's conformance kit before Subscriby reviews and installs it. [Building a connector](/sdk/v1/building) explains the manifest, the ports, the data rules and the kit. If you would rather we built it, use **Request a connector** on the directory to tell us which platform your community lives on.
## Where else the catalogue appears
- **Dashboard** — **Connectors** in the sidebar opens the in-app marketplace: the same cards with search, the same chips, category and sort, a count of how many of your projects each connector runs on, and **Connect to a project** on the available cards, which opens a pending installation on the project you pick and takes you to that project's Connectors page to enter the credentials. Opening a card shows **Overview** (the public page's content) and **Configuration** (reachable directly with `?tab=configuration` on the connector's address, which the count badge on a card links to): every installation of that connector on your projects with its state, the fields it asked for on connect, its settings, and its capabilities grouped as **Messaging**, **Access**, **Recovery** and **Payments**, each with a line on what it does and a switch where the project may turn it off on that installation (messages, broadcasts, the support inbox, built-in payments and the recovery facets; access control and the rest are **Always On**, and so is account recovery on a project, because that switch belongs to the connector as a whole rather than to one project), plus a **Manage on project** button. Switching **Built-in payments** off hides the connector's currency from the payment-method picker and from plan pricing, refuses a new method for it and stops offering it at checkout; sales already made are untouched. A switched-off capability is honoured everywhere at once: a broadcast is refused before it is queued, a health check is skipped, a reply is not relayed. A project's own **Connectors** page and its **Browse Marketplace** view list the same cards with **Install**. See [Connectors](/creators/connectors).
- **API** — [`GET /connectors`](/api/v1/reference/connectors) returns the listings with status, badges, category, links and the declarative install and settings fields.
- **MCP** — the `list_connectors` and `get_connector` tools and the `subscriby://connectors/catalog` resource.
- **Zapier and n8n** — the connector dropdowns read the same endpoint.
---
# Connector Roadmap
Source: https://docs.subscriby.net/connectors/roadmap
Subscriby is a messaging-platform-first membership engine. Telegram is where we started, and where we go deepest today. Other platforms are on the roadmap, each arriving when we can deliver the same quality of bot-driven member management we do on Telegram.
## Current status
} title="Telegram">
**Live and actively developed.**
Full bot integration, all 9 payment providers including Telegram Stars, channel / group / supergroup support, access codes, recurring billing, admin commands. The full creator and subscriber experience is on Telegram today.
See [Telegram advantages](/connectors/telegram/advantages).
} title="Slack">
**Coming soon.**
We're planning for Slack workspace-based memberships, paid channels, and automated member management using Slack's bot APIs..
} title="Discord">
**Under development.**
Discord server membership, role-gated channels, and automated role assignment tied to subscription status..
} title="WhatsApp">
**Coming soon.**
Paid WhatsApp groups and broadcast lists with automatic access control, on the WhatsApp Business Platform..
## Also on the roadmap
Beyond the messaging platforms above, the [Connectors Marketplace](/connectors/marketplace) lists eight more connectors as **Coming Soon**. Each gates something a paying member gets:
- **Microsoft Teams** — team and channel membership.
- **Discourse** — private categories through forum groups.
- **Ghost** — paid tiers and gated posts.
- **Google Drive** — folders and files shared only with members.
- **GitHub** — private repositories and team access.
- **Reddit** — a private subreddit's approved users.
- **Guilded** — servers and roles.
- **Zoom** — seats in recurring meetings and webinars, registered while the plan runs.
Leave your email on any of their marketplace pages to hear the day one launches.
## Which plans they will need
Telegram, Discord, Discourse, Ghost, GitHub, Reddit and Guilded are open to every plan, the Free plan included. Five connectors are reserved for paid plans, because the platform behind them bills per message, demands a verified business account, or puts Subscriby through a vendor review on every creator's behalf:
| Connector | Needs |
| --- | --- |
| Slack, Zoom, Google Drive | Starter or higher |
| WhatsApp, Microsoft Teams | Growth |
The [pricing page](https://www.subscriby.net/pricing) shows the same table under **Connectors**, with a dash under every plan that cannot install a connector. Installing one on a project whose owner is on a lower plan is refused with the plan it needs.
## What "coming soon" means
When we call a platform "coming soon," it means:
- Engineering work is planned but we haven't committed to a public date.
- The [Connectors Marketplace](/connectors/marketplace) lists the connector in its **Coming Soon** lane with a **Notify me** form; nothing can install it yet.
- Existing Subscriby creators won't lose their Telegram work when other connectors go live. A project installs connectors, so adding Slack to a project that already runs Telegram is an install on that project's Connectors page, not a new project. A second connector on one project needs the Growth plan.
We don't publish hard dates for unreleased platforms, because our priority is
delivering the *same* quality of bot-driven member management we've spent
years building on Telegram. We'd rather ship late than ship a half-baked
integration that leaves your members half-managed.
## Why the order: Telegram → Slack → Discord → WhatsApp?
It's partly about the platform's bot maturity, partly about our existing customer concentration:
- **Telegram** — gold-standard bot API, private channels and groups, native payments (Stars). Huge creator adoption. Obvious first choice.
- **Slack** — mature bot / Events API, popular for B2B and professional communities.
- **Discord** — huge consumer reach, role-based access model that fits memberships well, well-documented bot framework.
- **WhatsApp** — the largest messaging app in the world; groups and broadcast lists are reachable through the WhatsApp Business Platform.
## What you can do today on other platforms
If you want membership-like features on a platform we don't officially support yet, two light workarounds:
1. **Lean on your Telegram bot as the source of truth.** Drive subscribers through Telegram for the subscription + access management. Post content on whatever platform makes sense separately — you just won't get auto-kick-on-expiry outside Telegram.
2. **Use [Access Codes](/payments/access-codes) for offline flows.** If you're selling access to a Discord server and collecting payment via another system (Gumroad, Patreon, etc.), generate a batch of access codes in Subscriby and hand them out — but you'll need to manually flip Discord roles based on the codes you issue.
Neither is as smooth as the Telegram integration; both are stopgaps until the official platform support lands.
## Staying informed
When a new platform goes live, we announce it via:
- **Email** to account holders (if you have the relevant [notification preferences](/account/notifications) turned on).
- An **in-app banner** on your creator dashboard the first time you sign in after the release.
## Related
- [Telegram integration](/connectors/telegram/integration) — the fully-supported platform today.
- [Supported platforms](/connectors) — high-level platform comparison.
---
# Admin Commands
Source: https://docs.subscriby.net/connectors/telegram/admin-commands
By the end of this page, you'll know exactly what admin commands are available inside your bot and how to use them safely.
Subscriby exposes a small set of admin commands you can use in the direct chat with your own project's bot. These commands are:
- **Hidden** from the bot's public command menu (regular users don't see them).
- **Available only to you** as the project owner — when the bot detects your Telegram user ID, it accepts admin commands; for any other user, the commands silently fail or return "unknown command".
Both commands resolve `` against the member's **Telegram ID** or their
**Subscriby member UUID**, so whichever one you have to hand is fine.
## Commands
### /check — inspect a user
Returns a detailed status report for a specific user, including their active subscription, payment history, and assigned resources.
**Usage:**
```
/check
```
**Example:**
```
/check 123456789
```
You can also invoke this via a [deep-link](/connectors/telegram/operations#admin-command-links):
```
https://t.me/YourBotName?start=-check_-123456789
```
**When to use it:**
- A subscriber claims they paid but don't have access — `/check` tells you their current status and recent payments.
- Pre-ban sanity check — make sure you're banning the right user.
- Support debugging — see exactly what the bot thinks about a specific person.
### /ban — permanently block a user
Bans a user from your project. This has significant consequences — read carefully before running.
**What /ban does (in order):**
1. Marks the user's status as **Banned**.
2. Cancels all their active subscriptions on the project.
3. Revokes all their invite links.
4. Kicks them from every connected Telegram channel, group, and supergroup.
**Usage:**
```
/ban
```
**Example:**
```
/ban 123456789
```
**Deep-link variants** (from [Deep-linking](/connectors/telegram/operations#admin-command-links)):
- **Interactive (recommended)** — opens a confirmation menu before applying:
```
https://t.me/YourBotName?start=-ban_-123456789
```
- **Immediate (automated)** — bans instantly without confirmation:
```
https://t.me/YourBotName?start=-ban_-123456789_-confirm
```
The immediate variant skips all safety prompts. Only use it in trusted
automation scripts where you're certain the user ID is correct. Once banned,
restoring a user requires direct database intervention — coordinate with
support if you ban in error.
## How to find a user's Telegram ID
Both commands take a Telegram user ID, not a username. Getting the ID:
- **From your dashboard** — open the [Members](/creators/managing-members) list. Each row shows the Telegram ID beneath the name.
- **From the user directly** — they can forward any of their messages to **[@userinfobot](https://t.me/userinfobot)**, which reveals their user ID.
- **From Telegram's own admin UI** — in a group or channel you both belong to, tap the user's avatar to see their profile; some clients display the numeric ID.
## Permissions
Both commands require the bot to recognise you as the **project owner**. The bot reads the Telegram user ID of the sender and matches against the project's owner — it's tied to your Telegram identity, not your Subscriby password or session.
If you run `/check` or `/ban` and nothing happens:
1. Confirm you're chatting with the correct bot (the one connected to your project).
2. Confirm your Telegram account matches the one your Subscriby creator account was registered with.
3. Confirm the project is **Active** on your dashboard.
## Audit and rollback
- Every admin command is logged in the project's **activity timeline** — visible per-member in the [Members detail view](/creators/managing-members#view-details-flyout).
- **Unbanning** isn't exposed as a command today. If you need to reverse a ban, contact support.
## Related
- [Deep-linking](/connectors/telegram/operations) — craft admin-command deep-links.
- [Managing members](/creators/managing-members) — find user IDs and see ban/activity history.
- [Telegram integration](/connectors/telegram/integration) — broader platform-level mechanics including whitelisting.
---
# Why Telegram
Source: https://docs.subscriby.net/connectors/telegram/advantages
import {
Cloud,
Server,
CreditCard,
Zap,
ShieldCheck,
Smartphone,
} from "lucide-react";
## Why Use Telegram?
} title="Free Hosting & Unlimited Bandwidth">
Share files up to 2GB (4GB with Premium) for free. Telegram's secure cloud
storage offers unlimited bandwidth for seamless file distribution to
subscribers.
} title="Reliable Uptime">
Subscriby runs on the same robust infrastructure as Telegram itself,
ensuring high availability. Telegram handles billions of requests daily for
over 800 million users.
} title="Flexible Payment Options">
Avoid vendor lock-in. Use [multiple payment methods](/payments)
simultaneously, or accept offline payments via Access Codes.
} title="Instant Settlements">
Subscriby facilitates direct payments. Funds are settled directly to your
connected accounts instantly—we do not hold your revenue.
} title="Data Ownership">
You retain full ownership of your data, channels, and groups. If you leave
Subscriby, your community and content remain yours.
} title="Mobile-First Management">
Manage your entire business via smartphone. Our bots offer near-complete
parity with the web dashboard, putting control at your fingertips.
---
# What Your Bot Can Do
Source: https://docs.subscriby.net/connectors/telegram/bot-features
By the end of this page, you'll have a solid mental map of every feature your bot exposes — for subscribers in the chat, and for you as the project owner.
When you [connect a Telegram bot](/connectors/telegram/connecting) to your Subscriby project, we turn that bare bot (created via [@BotFather](https://t.me/BotFather)) into a fully-featured membership assistant. You never write any code; Subscriby handles every message, button, and payment flow the bot offers.
## For subscribers
On `/start`, the bot greets new users with your project's description and offers Terms of Service / Privacy Policy links (your custom URLs, or Subscriby defaults). They tap **Agree & Proceed** to continue.
The bot paginates your active plans, showing name, price, cycle, and trial
info. A subscriber taps a plan, then picks a payment method, then completes
checkout — all inside Telegram where possible, or via a redirect for providers
that require a browser.
Subscribers can paste an [access code](/subscribers/redeem-access-code)
directly into the chat, or tap a start-parameter deep-link like
`t.me/YourBot?start=a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d` to auto-redeem. The
code is a UUID and goes in bare.
After successful subscription, the bot sends a *"Your Subscription Resources"*
message with unique, single-use invite links for every Telegram resource the
plan unlocks. Subscribers tap **Join** per resource.
Lost the invite message? Send `/my_resources` to the bot and it resends the
links.
Opening the bot shows the subscriber's current plan, status (Active /
Trialing), renewal/expiry date, and plan price.
A **❌ Cancel Subscription** button appears on the subscription card — one
tap, one confirmation, done.
A **🔐 Account Recovery** option lets subscribers add a verified recovery
email so they can still sign in via the [web
portal](/subscribers/portal) even if they lose Telegram access.
If you've enabled [Telegram Stars](/connectors/telegram/stars), subscribers can pay natively without leaving Telegram — one tap, confirmed with Apple / Google / Microsoft device auth.
## For you (the creator)
Your bot also exposes **admin commands** you can use directly in the chat with your own bot. These are hidden from regular users — only accessible to you as the project owner:
- [**/check**](/connectors/telegram/admin-commands#check-user) — pull a detailed status report for any user by Telegram ID.
- [**/ban**](/connectors/telegram/admin-commands#ban-user) — ban a user, revoke their access, and remove them from linked resources.
See [Admin commands](/connectors/telegram/admin-commands) for the full command reference with safety notes.
## Automatic member-management features
Behind the scenes, the bot handles all the mechanical work of running a paid community:
- **Add members on subscribe** — generates a single-use invite link per resource and delivers it.
- **Remove members when access ends** — on cancellation (at cycle-end), expiry, or ban, the bot kicks the user from all linked resources.
- **Detect unauthorised members** — users who sneak into private groups without a valid subscription are automatically removed.
- **Unban cleanly before re-invite** — if a subscriber returns after being removed, the bot unbans first so the rejoin works smoothly.
- **Webhook-driven** — Telegram pings our servers on every relevant event; we stay in sync within seconds. See [Webhooks](/connectors/telegram/webhooks).
## What the bot can't do
Worth knowing up front so you don't expect behaviour that isn't there:
- **Post autonomous content on your behalf.** The bot only sends messages triggered by subscriber actions or lifecycle events — it doesn't write your posts for you.
- **Moderate content in your groups.** Anti-spam, anti-bot, anti-abuse moderation is still Telegram's native tools (admins, auto-delete rules, external moderation bots).
- **Generate arbitrary analytics.** Subscription metrics appear in your [dashboard](/creators/dashboard-analytics); detailed engagement analytics are your job (or Telegram's own channel stats).
## Related
- [Connect your bot](/connectors/telegram/connecting) — wire up your bot for the first time.
- [Admin commands](/connectors/telegram/admin-commands) — /check and /ban reference.
- [Deep-linking](/connectors/telegram/operations) — craft start-parameter URLs for marketing and automation.
- [Telegram integration](/connectors/telegram/integration) — the platform-level mechanics.
---
# Connect Your Telegram Bot
Source: https://docs.subscriby.net/connectors/telegram/connecting
The bot is your project's first **connector**. The project's [Connectors page](/creators/connectors) lists it with its state and health, lets you verify, disconnect or uninstall it, and is where other connectors are installed from.
By the end of this page, you'll have a Telegram bot created with @BotFather and connected to your Subscriby project — ready to onboard members, enforce access, and handle payments.
A **Telegram bot** is the interface your subscribers interact with. They start with `/start`, pick a plan, go through payment, get invited to your private channels. Behind the scenes, Subscriby controls the bot via the token you provide. One bot per project.
## What you need
- A Telegram account with access to [@BotFather](https://t.me/BotFather) (everyone does — it's a public official bot).
- Your Subscriby project created. See [Creating a project](/creators/projects).
- About five minutes.
## Create a bot with @BotFather
### Open @BotFather in Telegram [step]
Open Telegram and search for **[@BotFather](https://t.me/BotFather)** — the official Telegram bot for creating other bots. Tap **Start** if you haven't chatted with it before.
### Send /newbot [step]
Send the command `/newbot`.
### Pick a display name [step]
BotFather asks for a **display name**. This is the human-friendly name that shows up in chat headers — e.g. _"Pro Chess Club Assistant"_. Send it.
### Pick a username [step]
BotFather asks for a **username**. This is the `@handle`. It must:
- End in `bot` (e.g. `pro_chess_bot`).
- Be unique across all of Telegram.
- Use only letters, numbers, and underscores.
### Copy the HTTP API token [step]
BotFather confirms creation and sends you the **HTTP API token** — a string that looks like `123456789:ABCdefGhIJKlmNoPQRstuVWxyz`. Copy it somewhere safe. **This is the credential Subscriby will use to control your bot.**
**Treat the bot token like a password.** Anyone with it can control your bot.
Never paste it into public channels, screenshots, or support chats. If you
accidentally leak it, send `/revoke` to BotFather to get a new token, then
update Subscriby.
## Connect the bot to your project
### Open All Projects [step]
Open Subscriby and navigate to **All Projects** from the sidebar.
### Choose Connect Telegram [step]
Find the project row, click the **Actions** (three-dots) menu, and choose **Connect Telegram**. The same modal opens from the project's [Connectors](/creators/connectors) page through **Connect** on the Telegram row.
### Paste the token [step]
A modal appears with a single input: **Bot token**. Paste the token you copied from BotFather.
### Activate the bot [step]
Click **Connect**.
Subscriby calls the Telegram API with the token, confirms the bot exists, and registers it with your project. You'll see a success toast; the row's **Connection** column turns **Connected**, the project flyout (**Actions → View Project**) shows the bot's **Public Link**, every project page's header gains an **Open Bot** button, and the project's [Connectors](/creators/connectors) page lists the installation with its state and health.
## Recommended post-connection setup
After activation, a few minutes of BotFather tuning goes a long way:
### Set a profile picture [step]
Send **`/setuserpic`** to BotFather, pick your bot, and upload a square profile picture. Members will see it in the chat header.
### Set a short description [step]
Send **`/setdescription`** — a short sentence shown before users press **Start**. Make it concise and outcome-oriented: _"Join our premium chess community with exclusive games, tactics, and live analysis."_
### Set the about text [step]
Send **`/setabouttext`** — the longer About section shown on the bot's profile.
### Set the command menu [step]
Send **`/setcommands`** if you want Telegram's native command menu to show built-in commands. Subscriby typically sets useful defaults automatically, but you can override from BotFather.
## Managing a connected bot
### View bot status
On **All Projects**, the **Connection** column says **Connected** or **Disconnected** and the **Connectors** badge counts the connectors the project runs; the flyout shows the bot's **Public Link**. The project's [Connectors](/creators/connectors) page shows the installation's state and health and when it was last checked, lets you **Verify** it on the spot, and runs the Connector Doctor across every channel and group the bot manages.
### Reconnect with a new token
If you regenerated a token via BotFather's `/revoke`, **Disconnect** the bot and **Connect** it again with the new token: disconnecting only wipes the stored credential, so resources, plans, members and their access stay in place and members don't notice the swap.
If the bot itself was taken away by Telegram rather than its token revoked, do not paste a new token here. Open [Disaster Recovery → Replacing a lost bot](/connectors/telegram/replacing-a-banned-bot) instead: it connects the new bot, gives you the announcement and the member email, and on Growth can fail over to a registered standby bot in one click. Reconnecting from either place re-registers the webhook and the command menu.
### Disconnect a bot
If you need to stop operations — e.g. migrating to a new bot or closing the project temporarily:
#### Open Actions → Disconnect Telegram [step]
On **All Projects**, click **Actions → Disconnect Telegram**, or click **Disconnect** on the Telegram row of the project's [Connectors](/creators/connectors) page.
#### Confirm disconnection [step]
Read the warning and confirm.
**Disconnecting has serious consequences.**
- The bot stops responding to new users.
- No more renewal reminders, trial nudges, or notifications.
- Subscriby can't kick / ban expired subscribers from your Telegram destinations.
- Existing recurring charges may still process at the payment-provider level until you cancel them — disconnection at the bot level doesn't cancel the subscriptions themselves.
If you just need a break, deactivate the **project** (flip Active off) instead — it stops the bot from processing new work while keeping everything wired up. See [Creating a project → Active toggle](/creators/projects).
## Common problems
Check that:
- You copied the full token (they contain a colon — easy to truncate).
- The token wasn't already revoked in BotFather.
- There's no leading/trailing whitespace.
Regenerate the token from BotFather (`/mybots → your bot → API Token → Revoke current token`) and try again with the fresh one.
- Confirm the project itself is **Active** (see [Creating a
project](/creators/projects)). - Make sure the bot has **Privacy Mode**
disabled — in @BotFather, send `/mybots`, pick your bot, open *Bot Settings →
Group Privacy*, and choose **Turn off**. Otherwise the bot can't read group
messages. - Has the bot been added to any linked resources? The bot needs
admin rights in the destination Telegram channel/group/supergroup to manage
members.
No — each bot token can only be connected to one project at a time. Create a
separate bot with `/newbot` for each project you run.
Cosmetic changes (name, description, photo) are fine. Username changes in
BotFather propagate automatically — Subscriby reads the current username via
the API. The token remains the same through all of this.
You'd lose the token and the bot itself. Create a fresh bot via `/newbot` and connect its new token to your Subscriby project. Any members already in your Telegram destinations stay there, but the bot can no longer manage them — re-add the new bot as admin to those destinations.
## Related
- [Resources](/creators/resources) — what the bot manages access to.
- [Subscription plans](/creators/plans) — what the bot sells.
- [Sharing your project](/creators/share) — getting the bot link in front of your audience.
- [Telegram integration](/connectors/telegram/integration) — deeper look at the platform-level mechanics.
- [Disaster Recovery](/disaster-recovery) — what happens when the bot, a channel or your account becomes unreachable, and the standby bot that makes it one click.
---
# Joining Through the Bot
Source: https://docs.subscriby.net/connectors/telegram/for-members
## Overview
The Telegram Bot is the fastest way to join a community. It handles everything from selecting a plan to delivering your private invite links — and now also lets you set up an **account recovery email** so you can still reach your subscriptions from the [web portal](/subscribers/portal) if you ever lose access to Telegram.
### Start the Bot [step]
Navigate to the project's bot (usually shared via a `t.me/ExampleBot` link) and tap **Start**.
You will see a welcome message describing the community, along with the Terms of Service and Privacy Policy.
Click **Agree & Proceed** to continue.
### Select a Plan [step]
The bot will display the available subscription plans. Use the pagination buttons (`⬅️ Previous Page`, `➡️ Next Page`) if there are many plans.
Each plan button shows:
- **Name**: The name of the tier (e.g., "VIP Access")
- **Price**: The cost and currency (e.g., "$10.00")
- **Cycle**: How often you are billed (e.g., "Monthly", "Yearly", "Lifetime")
Tap the button for the plan you want to join.
### Choose Payment Method [step]
Select your preferred payment method from the list (e.g., Stripe (Credit Card), Telegram Stars).
Some plans may support **Telegram Stars** (XTR), allowing you to pay using
your localized in-app purchase method directly through Telegram.
### Complete Payment [step]
The bot will generate a secure checkout link. Tap **Subscribe Now** (or "Pay") to proceed.
- **Stripe**: You will be redirected to a secure webpage to enter your card details.
- **Telegram Stars**: The payment prompt will appear directly in the chat.
Once the payment is successful, the bot will automatically detect it and activate your subscription.
### Access Content [step]
Upon successful payment, the bot will send you a **"Your Subscription Resources"** message.
This message contains unique buttons for each resource (Channel, Group, or Supergroup) included in your plan.
- Tap **Join "Channel Name"** to join the community.
- **Important**: These links are unique to you. Do not share them, or your access may be revoked.
- If you ever lose this message, use the `/my_resources` command (or "Show Invite Links" button if available) to retrieve them.
## Managing Your Subscription
You can view your status or cancel your subscription at any time.
### View Status
When you open the bot again, it will show your **Active Subscription** details, including:
- Current Plan
- Status (Active, Trialing)
- Next Billing Date (or Expiration Date)
- Renewal Price
### Cancelling [step]
To cancel, look for the **❌ Cancel Subscription** button on your subscription status card.
1. Tap **Cancel Subscription**.
2. The bot will ask for confirmation to prevent accidental clicks.
3. Confirm your choice.
Your access will remain active until the end of your current billing period, after which you will be removed from the private channels.
## Account Recovery
Telegram is the **only** identity tied to your bot subscription by default. If
you lose your phone, change your number, get banned, or Telegram becomes
unavailable in your region, you'd lose access to everything you've paid for.
Setting a **recovery email** takes 30 seconds and lets you sign back in via
the web portal using a magic link.
### Opening the Recovery Menu
On the plan selection screen, tap the **🔐 Account Recovery** button (it sits near the plan list, alongside other account actions). The bot replies with a status card that adapts to what you currently have set up:
The card reads:
> **🔐 Account Recovery** — No recovery email is set. Add one so you can sign in from the web portal if you ever lose access to Telegram.
A single button is shown:
- **📧 Add Recovery Email**
> **🔐 Account Recovery** — Pending verification: **you@example.com**
> Check your inbox for a verification link.
Three buttons are shown:
- **📨 Resend Verification** — Sends another verification link (rate-limited).
- **✏️ Change Email** — Replaces the address with a new one.
- **🗑 Remove Email** — Clears the address (asks for confirmation).
> **🔐 Account Recovery** — ✅ Verified: **you@example.com**
Two buttons are shown:
- **✏️ Change Email** — Replaces the address (the new one starts back at *pending*).
- **🗑 Remove Email** — Clears the address (asks for confirmation).
A **« Back to Subscription** button always returns you to the previous screen.
### Adding (or Changing) an Email
#### Tap "Add Recovery Email" or "Change Email" [step]
The bot enters a short wizard and replies:
> Please enter the email address you'd like to use for recovery. You'll receive a verification link to confirm it.
Type `cancel` (case-insensitive) at any prompt to abort the wizard and return
to the recovery menu without changes.
#### Send Your Email [step]
Type the email address as a normal chat message and send it. The bot validates it as a properly formatted email (RFC) up to 191 characters. If invalid, you'll see the validation error inline and can try again.
#### Uniqueness Check [step]
The address must not already be **verified** on a different account in the same project. If it is, the bot replies:
> This email is already linked to another account in this project.
Send a different address.
#### Verification Link Sent [step]
The bot saves the address (in _pending_ state), drops you back to the recovery menu, and confirms:
> Verification email sent to **you@example.com**. Click the link in the email to verify.
Open the email within **60 minutes** and click the link — it's **single-use**. Once you click it, the address is marked **Verified** and immediately becomes usable for magic-link sign-in on the [web portal](/subscribers/portal).
### Resending Verification
If the email never arrived, open the recovery menu and tap **📨 Resend Verification**. A fresh link is dispatched.
The bot caps recovery actions to prevent abuse:
- **Resend verification** — up to **3 attempts per hour**, per account.
- **Set / change email** — up to **5 attempts per hour**, per account.
Past the cap, the bot replies _"Too many attempts, try again later."_ and you'll need to wait for the window to reset.
### Removing the Email
Tap **🗑 Remove Email** on the recovery menu. The bot replies:
> Are you sure you want to remove the recovery email **you@example.com**?
with two buttons — **Yes, remove it** and **Keep it**. Confirming clears the address and its verified status. You can add a different one later at any time.
### What Happens After You Verify
Once your email is verified, you can sign in to the project's web portal **without Telegram**:
1. Open `my.subscriby.net/` and click **Login**.
2. In the sign-in modal, enter your verified email and click **Send sign-in link**.
3. Open the email and click the **single-use link** within 15 minutes — you're in.
See the [Via Web Portal](/subscribers/portal#signing-in) guide for the full sign-in flow and additional Google sign-in option.
## Redeeming Access Codes
If you have received a promotional **Access Code** (e.g., from a giveaway or offline purchase), you can redeem it directly in the bot.
1. **Send the code** as a message to the bot (e.g., `a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d`).
2. Alternatively, use a deep link: `t.me/ExampleBot?start=a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d`.
If the code is valid and unused, your subscription will be activated immediately, and you will receive your invite links.
## Asking the Creator a Question
Anything you send the bot that is not a command and not an access code reaches the creator directly. Type your question as an ordinary message — you can also send screenshots, voice notes and documents — and their reply comes back in the same chat, prefixed with their name so you can tell a person from the bot.
See [Contacting support](/subscribers/contacting-support) for the full picture.
---
# Create a Paid Channel
Source: https://docs.subscriby.net/connectors/telegram/how-to-create-a-paid-channel
By the end of this page, a private Telegram channel is a product: members pay through your own bot, are let in with a personal invite link, renew on their own and are removed when they stop paying. Nothing here needs code, and it fits into an hour.
## What you need
- A Telegram account that administers the channel. If the channel does not exist yet, create it and make it **private**.
- A Subscriby project on Free, Starter or Growth ([activate a plan](/creators/subscription), then [create a project](/creators/projects)).
- One way to get paid: a Stripe account for cards, PayPal, Razorpay for India, Paystack for Nigeria, Ghana, Kenya and South Africa, CoinPayments for crypto, or nothing at all if you start with [Telegram Stars](/connectors/telegram/stars).
## Create your own bot
Subscribers never see Subscriby. They talk to a bot that carries your name, and you create that bot yourself in two minutes.
### Open @BotFather and send /newbot [step]
Open a chat with [@BotFather](https://t.me/BotFather), Telegram's official bot for creating bots, and send `/newbot`.
### Name it [step]
Give it a display name subscribers will see, such as _"Alpha Signals VIP"_, and a username ending in `bot`, such as `@alphasignals_vip_bot`.
### Copy the token [step]
BotFather answers with the **HTTP API token**. Copy it somewhere safe: it is the credential Subscriby uses to drive the bot, so treat it like a password. The full walkthrough, with the profile picture, description and command menu worth setting afterwards, is [Connect Your Telegram Bot](/connectors/telegram/connecting).
## Make the channel private and the bot an administrator
### Set the channel to Private [step]
Open the channel's settings and set its type to **Private**. Telegram shows a permanent invite link for a private channel; never share it, because access has to be controlled by the bot, not by a link anyone can forward.
### Promote the bot [step]
Add the bot to the channel as an administrator with **Invite Users via Link** and **Ban Users**. The first lets the bot hand each paying member a fresh single-use link; the second lets it remove members whose subscription has ended. Everything else can stay off ([why the rights matter](/creators/resources#why-the-rights-matter)).
## Connect the bot and link the channel
### Connect the bot [step]
On **All Projects**, open the project's **Actions** menu and choose **Connect Telegram**. Paste the token and click **Connect**. The row's **Connection** column turns **Connected** and every project page gains an **Open Bot** button.
### Link the channel as a resource [step]
Open **Project Resources**, click **Create New Resource**, pick **Channel** and click **Send Request**. The bot sends you a _Select a Private Channel_ button; tap it, pick the channel in Telegram's picker and grant the rights it asks for. If you already promoted the bot yourself, it registers the channel on its own and messages you. Refresh the page and switch the new resource **Active** ([linking a place](/creators/resources#linking-a-place)).
## Connect the ways your audience pays
Under **Payment Methods**, switch on whatever fits your audience. Members see every active method at checkout and pick their own.
- **Stripe** covers cards, Apple Pay and Google Pay in most of the world, connected through Stripe's own onboarding ([Stripe](/payments/stripe)).
- **PayPal** and **Skrill** suit members who would rather not type a card number ([PayPal](/payments/paypal), [Skrill](/payments/skrill)).
- **Razorpay** gives Indian members UPI, local cards and net banking; **Paystack** does the same for cards and bank rails in Nigeria, Ghana, Kenya and South Africa; **CeyPay** covers Sri Lanka ([Razorpay](/payments/razorpay), [Paystack](/payments/paystack), [CeyPay](/payments/ceypay)).
- **CoinPayments** takes Bitcoin, Ethereum, Litecoin and stablecoins, for one-time purchases ([CoinPayments](/payments/coinpayments)).
- **Telegram Stars** needs no account at all: members pay inside Telegram, in live mode only and for one-time purchases ([Telegram Stars](/connectors/telegram/stars)).
- **Access codes** are not a gateway but cover bank transfers, cash at an event and gifts ([access codes](/creators/codes)).
Add a card gateway in **Test** or **Sandbox** mode first and switch it to
**Live** after your test purchase below ([payment
methods](/creators/methods)).
## Create the plan
Under **Subscription Plans**, create the first plan: a name members see, a price, a billing cycle (days, weeks, months, years or lifetime), an optional trial with or without a card up front, and the channel it unlocks ([subscription plans](/creators/plans)). A monthly plan with a discounted annual plan beside it is where most channels start. If you sell a scheduled session rather than open-ended membership, a [time-limited pass](/creators/time-limited-passes) admits members when the window opens and removes them when it closes.
## Share the bot's link
Every bot has a link, `https://t.me/YourBotName`, and every plan a deep link that opens the bot straight on that plan (**Copy Plan Bot Deeplink** on the plan's row menu); the hosted portal page lists your plans for people who arrive from a browser ([bot links and deep linking](/connectors/telegram/operations), [share your project](/creators/share)). Put the link in your free channel's pinned message and in your bios elsewhere.
A visitor who taps it is greeted by your bot, shown the plans, taken through payment on the method they choose, and handed a single-use invite link the moment the payment clears. The link works once and only for them.
## Test the whole flow
Subscribe to your own channel with the gateway in Test mode (Stripe's test card is `4242 4242 4242 4242` with any future expiry and any CVC). Check that the bot sends an invite link right after payment, that the link adds you to the channel, and that you appear under **Members** with the right plan and renewal date. If a step fails, the cause is almost always the bot's admin rights or a resource not linked to the plan ([bot issues](/reference/troubleshooting#bot-issues)). Then switch the gateway to Live, or add a Live method and deactivate the test one, and run the [go-live checklist](/creators/go-live-checklist).
## What runs on its own
Renewals are charged on the cycle, members hear from the bot at 7, 3, 2 and 1 days before an expiry, and anyone whose subscription ends is removed automatically, as is anyone who got in without a valid subscription ([how the bot admits and removes members](/connectors/telegram/integration#adding-members)). Questions members send the bot land in your [support inbox](/creators/support-inbox). If Telegram ever restricts your bot, your channel or your account, the [Disaster Recovery Program](/disaster-recovery) notices, alerts you and moves your members to a replacement.
## Frequently asked
Yes. New subscribers pay the new price at once; existing subscribers pay it
from their next renewal. To keep early supporters on the old price,
deactivate the old plan with **Allow disabled plan renewal** on and create a
new plan at the new price.
Yes. Set the billing cycle to **Lifetime**; the member pays once and stays
until you remove them.
Flip the plan's **Active** toggle off with **Allow disabled plan renewal**
on. The plan leaves the portal and the bot's plan list, and current members
keep renewing.
Generate an [access code](/creators/codes) for the plan and send it to them.
They redeem it with the bot and are admitted like any other member.
No. The bot and the hosted portal page are the whole storefront.
## Related
- [Create a paid group](/connectors/telegram/how-to-create-a-paid-group) — the same setup for a room where members talk to each other.
- [Connect Your Telegram Bot](/connectors/telegram/connecting) — every step of the bot setup, with what to set afterwards.
- [Telegram integration](/connectors/telegram/integration) — how the bot admits, removes and bans members.
- [Go-live checklist](/creators/go-live-checklist) — what to check before you announce.
---
# Create a Paid Group
Source: https://docs.subscriby.net/connectors/telegram/how-to-create-a-paid-group
A paid group differs from a paid channel in one way that changes everything: members can talk to each other. A channel is a broadcast; a group is a room. By the end of this page, your room admits paying members with a personal invite link each, removes them when they stop paying and never leaks a link a non-payer can use.
## What you need
- A Telegram account that administers the group, or is about to create it.
- A Subscriby project on Free, Starter or Growth ([activate a plan](/creators/subscription), then [create a project](/creators/projects)).
- One way to get paid ([choosing a provider](/payments/choosing-a-provider)), or [Telegram Stars](/connectors/telegram/stars) to start with nothing.
## Create the group as a supergroup
### Create a private group [step]
Create a new group in Telegram and keep it **private**, which is the default. Name it the way your brand reads: that name is what members see when the bot lets them in.
### Make it a supergroup on day one [step]
A basic group stops at 200 members; a supergroup holds far more and brings the admin tools you will want, such as slow mode, per-member restrictions and pinned messages that stay pinned. Telegram converts a group to a supergroup when you switch on certain settings and there is no way back, so do it now rather than at member 199. Subscriby links both kinds, as **Group** or **Supergroup** resources ([the resource kinds](/creators/resources#the-resource-kinds)).
## Create your own bot
### Open @BotFather and send /newbot [step]
Open a chat with [@BotFather](https://t.me/BotFather) and send `/newbot`.
### Name it and copy the token [step]
Answer the two prompts, a display name such as _"Premium Desk Bot"_ and a username ending in `bot` such as `@premiumdesk_bot`, and copy the **HTTP API token** BotFather hands back. Treat it like a password; the full walkthrough is [Connect Your Telegram Bot](/connectors/telegram/connecting).
## Make the bot an administrator of the group
Add the bot to the group, open the group's administrators and promote it with at least **Invite Users via Link** and **Ban Users**. The first lets it generate a fresh single-use invite link for each paying member; the second lets it remove members whose subscription ends. Without both, the automation has nothing to work with ([why the rights matter](/creators/resources#why-the-rights-matter)).
## Connect the bot and link the group
### Connect the bot [step]
On **All Projects**, open the project's **Actions** menu, choose **Connect Telegram**, paste the token and click **Connect**.
### Link the group as a resource [step]
Open **Project Resources**, click **Create New Resource**, pick **Group** or **Supergroup** and click **Send Request**; the bot sends you a button that opens Telegram's picker, and you pick the group. If you promoted the bot yourself first, it registers the group on its own and messages you. Refresh the page and switch the resource **Active** ([linking a place](/creators/resources#linking-a-place)).
### Connect a payment method [step]
Under **Payment Methods**, connect at least one gateway: Stripe for cards through its own onboarding, PayPal, Razorpay, Paystack, Skrill, CeyPay, CoinPayments for crypto, or Telegram Stars for in-app payments ([payment methods](/creators/methods)). Start a card gateway in **Test** mode and switch it to **Live** after the test purchase below.
### Create the plan [step]
Under **Subscription Plans**, create the first plan: price, billing cycle (days, weeks, months, years or lifetime) and the group it unlocks ([subscription plans](/creators/plans)).
## Offer a trial
A trial of a few days lowers the barrier for someone who is curious but not convinced. Set **Trial days** on the plan, with or without a card up front: a card-up-front trial converts at a higher rate, a cardless trial brings in more people and converts fewer of them, and access stops when a cardless trial ends unless they convert ([trial period](/creators/plans#trial-period-optional)).
## Never share a static invite link
A group's permanent invite link can be forwarded to anyone, and you cannot
revoke it for one person without breaking it for everyone. Keep it to
yourself.
The bot generates a single-use link per member, tied to their account. They join once, the link is spent, and when their subscription ends the bot removes them; someone else who tries a member's link is removed immediately ([adding and removing members](/connectors/telegram/integration#adding-members)). There is no link left over that would let a non-payer back in.
## Test the whole flow
Subscribe to your own group with the gateway in Test mode (Stripe's test card is `4242 4242 4242 4242` with any future expiry and any CVC). Check that the bot sends an invite link right after payment, that the link adds you to the group, and that you appear under **Members** with the right plan and renewal date. Then switch the gateway to Live and run the [go-live checklist](/creators/go-live-checklist).
## Price the room
Groups command higher prices than channels because members pay for access to each other as well as to you. An annual plan at a discount to twelve monthly payments gives members a reason to commit and gives you a year of predictable cash flow. If the group meets on a schedule, a weekly call or a monthly review, a [time-limited pass](/creators/time-limited-passes) sells access to that window alone.
## Mistakes to avoid
- **Mixing free and paid members in one group.** Paying members resent non-payers with the same access, and access control becomes impossible to reason about. Keep a free channel as the funnel and the paid group as the product.
- **Removing expired members by hand.** It eats hours at scale and you will miss people. The bot does it the moment a subscription ends.
- **One shared invite link.** When it leaks, every non-payer who has it is in until you reset it, and resetting breaks it for legitimate members too.
- **No free channel to funnel from.** Most paid-group members come from a free channel where they first saw your work. Run both.
## Frequently asked
Yes. Create several plans in one project, each unlocking different rooms. A
member on a plan that unlocks two groups is in both.
The member is told how to fix their payment; if the subscription lapses, the
bot removes them. Every failed payment shows under [managing
subscriptions](/creators/managing-subscriptions).
Yes. **Suspend Access** on the subscription removes the member from every
linked place and marks it **Paused**; **Restore Access** puts them back with
fresh invite links ([managing
subscriptions](/creators/managing-subscriptions)). Members cannot pause
themselves; they can cancel and keep access to the end of the period they
paid for.
Yes. What they send your bot lands in your [support
inbox](/creators/support-inbox) with their plan beside the thread, and your
reply reaches them in Telegram under your own name.
## Related
- [Create a paid channel](/connectors/telegram/how-to-create-a-paid-channel) — the same setup for a broadcast channel.
- [Telegram integration](/connectors/telegram/integration) — how the bot admits, removes and bans members.
- [Admin commands](/connectors/telegram/admin-commands) — checking and banning a member from the chat with your bot.
- [Go-live checklist](/creators/go-live-checklist) — what to check before you announce.
---
# Telegram Membership Bot
Source: https://docs.subscriby.net/connectors/telegram
Telegram is the first connector Subscriby shipped, and the one with complete coverage today. A
project that installs it connects one Telegram bot, and that bot is what actually admits, removes
and bans members as their subscriptions change. Other connectors sit beside it on the same project
(see [Connectors](/connectors)); this section is about Telegram's own mechanics.
This section covers the **platform-level mechanics**. What you sell and who you sell it to lives in
[For Creators](/creators); what your members experience lives in
[For Subscribers](/subscribers).
Subscriby drives a bot you create with Telegram's
[@BotFather](https://t.me/BotFather) and connect with its token. That is what
makes the bot yours — your name, your handle, your branding — rather than
subscribers interacting with something that says Subscriby.
## Set up a paid channel or group
## Start here
## Running it
## What Telegram supports
| Capability | Limit or note |
| ---------------------- | ---------------------------------------------------------------- |
| Channels | Supported |
| Groups and supergroups | Supported, up to **200,000** members |
| Automatic add / remove | Driven by subscription status, including expiry and cancellation |
| Bans | Supported, both manual and automatic |
| Broadcasts | Targeted at a chosen audience of your subscribers |
| Native payments | Telegram Stars (`XTR`), alongside every other supported provider |
## Related
- [Platforms overview](/connectors) — what else is planned, and when
- [For Creators](/creators) — projects, plans, resources and members
- [Payment methods](/payments) — per-provider setup, Telegram Stars included
- [Webhooks](/webhooks/v1) — outbound events for your own automation, distinct from Telegram's own webhook
---
# How the Integration Works
Source: https://docs.subscriby.net/connectors/telegram/integration
The Subscriby platform seamlessly integrates with the Telegram Bot API, providing essential features to manage your memberships.
**Key Features:**
- A robust backend for your membership bot.
- Automated member addition to private channels and groups.
- Automatic removal of members once their subscription expires.
## Your Membership Bot
Telegram bots are mini-apps within the Telegram ecosystem. With your Subscriby-powered bot, your clients can:
View plans, make payments, and manage recurring subscriptions.
Access paid channels and groups and activate access codes.
Receive renewal reminders and communicate with support.
The best part? You don’t need to code any of these features—Subscriby handles everything.
### Setup Guide
Setting up your membership bot is simple:
#### Register a Bot [step]
Open Telegram and search for **[@BotFather](https://t.me/BotFather)**. Send the command `/newbot` to create a new bot and get your token.
#### Connect to Subscriby [step]
Open your project in the Subscriby bot, select **Connect Bot**, and paste the token you received from @BotFather.
After setup, Subscriby will handle all backend operations, including making and answering requests on behalf of your bot.
## Your Membership Page
Your membership page is a public web portal hosted by Subscriby (e.g., `my.subscriby.net/your-handle`). Through this page, your clients can:
- View membership plans.
- Make one-time payments (via Stripe, etc).
- Manage recurring subscriptions.
- Check their current subscription status.
- Access paid channels and groups.
Membership pages are especially useful for converting website traffic, as
users can complete payments via the web interface if they prefer, or
seamlessly transition to the Telegram bot.
## Adding Members
Subscriby uses **Single-Use Invite Links** to grant access to private channels and groups, whether through the membership bot or page.
- Each customer receives a unique invite link, which Subscriby monitors in real-time.
- If someone else tries to use a link, it will be revoked, and the unauthorized user will be removed immediately.
We automatically **unban** users before showing them invite links. This
ensures that even if a user was previously removed (kicked) or banned, they
can rejoin successfully without Telegram throwing an error.
## Removing Members
Subscriby automatically removes users from channels and groups under the following circumstances:
1. Their subscription or free trial has ended.
2. A recurring subscription has been canceled (at the end of the billing period).
3. The user has joined your group without a valid subscription (unauthorized access).
4. Subscriby runs periodic consistency checks.
**Note**: Subscriby primarily **kicks** users (removes them) rather than
permanently banning them when a subscription expires. This allows them to
easily resubscribe and rejoin later. Since private groups require an invite
link to join, a removed user cannot simply click "Join" to return without a
new valid link.
## Whitelisting & Blacklisting
Manage who gets free access and who is blocked.
### Whitelisting
If you'd like to grant free access to someone, you have options:
- **Access Codes**: Generate a unique access code (in "Manage Access Codes") and send it to them. They can redeem it in the bot to get a valid subscription for free.
- **Administrator**: Make the user an **Administrator** in your Telegram Channel or Group. Subscriby does **not** remove administrators, even if they don't have a subscription in the database.
### Blacklisting
You can block users from using your bot entirely.
- Use Telegram's native blocking to stop a user from interacting with your bot.
- Once blocked, the bot will ignore messages from that user.
## Admin Commands
You can use these commands directly in the bot chat to manage your users. These commands by default are not made available in the bot's commands list as these are only available for project owner.
### Check User
Get a detailed report about a user's status, including their active subscription, payment history, and assigned resources.
**Usage:**
```bash
/check
```
**Example:**
```bash
/check 123456789
```
### Ban User
Ban a user from your project. This will:
1. Mark their status as **Banned**.
2. Cancel all active subscriptions.
3. Revoke all invite links.
4. Kick them from all connected channels and groups.
**Usage:**
```bash
/ban
```
**Example:**
```bash
/ban 123456789
```
---
# Create Your Account with Telegram
Source: https://docs.subscriby.net/connectors/telegram/onboarding
By the end of this page you'll have a Subscriby account with your Telegram account linked to it, and be ready to launch your first project.
Creator accounts are **email-first**: the registration form alone creates one, with an email address and a password. Telegram is one of the ways to sign up and sign in, and the account you link there is what the Telegram connector uses to recognise you inside bots and to reach you with alerts.
## Two ways to start
- **Continue with Telegram** — open **Sign up with a connected platform** on the [registration page](https://app.subscriby.net/register) and click the Telegram button. Telegram confirms who you are, the form comes back pre-filled with your name and marked as vouched for by Telegram, and you add an email address and a password to finish. Your Telegram account is linked the moment the account exists.
- **Register with email** — fill in the form and verify your email. Link Telegram later from **Settings → Security → Linked accounts**, or by opening our platform bot, **[@TrySubscribyBot](https://t.me/TrySubscribyBot)**, and following its prompt to link the account you are signed in with.
Both paths end in the same place: one creator account, one linked Telegram identity.
## Step-by-step
**Open the registration page.**
Go to [app.subscriby.net/register](https://app.subscriby.net/register). Open **Sign up with a connected platform** and choose **Continue with Telegram** to start from your Telegram account, or go straight to the form.
**Fill in the form.**
- **Name** — pre-filled from your Telegram profile when Telegram vouched for you; edit it if you want a different display name.
- **Email** — a real inbox you can check. Alerts that must reach you (billing, security, recovery) go here as well as to Telegram.
- **Password** — 8+ characters, mix of upper/lower/numbers.
Click **Create Account**.
The Telegram account you link is the one the bots recognise you by and the one your alerts go to. Choose it carefully; you can add a backup account later under **Settings → Security → Linked accounts** and switch to it if the first is ever lost.
**Verify your email.**
Open the verification email from Subscriby and click the link. Until verified, some actions are restricted.
**Secure your account.**
Before creating your first project, head to **Settings → Security** and set up either:
- [Two-factor authentication](/account/two-factor-auth) — TOTP via Google Authenticator or any compatible app.
- [Passkeys](/account/passkeys) — FIDO2 passwordless sign-in.
Either layer is a big upgrade over password-only protection. Details in the [Account & Security](/account) section.
**Install the Telegram connector on your first project.**
Create a project, open its **Connectors** page and install Telegram. The connector asks for the token of a bot you created with [@BotFather](https://t.me/BotFather); paste it and the bot is yours. [Connect your bot](/connectors/telegram/connecting) walks through the modal step by step.
**Launch.**
Head to the [Creator's walkthrough](/creators) and follow the launch path. The whole flow, from fresh account to first paying subscriber, is typically a single afternoon's work.
## Why link a Telegram account at all?
Telegram is where your members live, and the Telegram connector needs to know which Telegram user is you:
- **Continue with Telegram** works as a sign-in method next to email/password and passkeys.
- The platform bot recognises you, so you can manage projects, plans and payments from inside Telegram.
- Alerts you route to Telegram arrive in the chat you linked; critical ones go to your email too.
- If that account is ever lost, the [Disaster Recovery Program](/disaster-recovery) relinks a new one through a handshake, and a backup account you registered ahead of time takes over at once.
See [Signing in with Telegram](/account/social-login) for how the Telegram sign-in path works day-to-day.
## Commands the platform bot answers
Type these in your chat with **@TrySubscribyBot**, or pick them from Telegram's command menu next to the message box:
| Command | What it does |
| --- | --- |
| `/start` | Your home screen: every project with its connection status, plus the buttons to create a project, switch alerts and change language. |
| `/projects` | The same project list, whenever you want it back. |
| `/newcode` | Generates access codes: straight into the wizard when you have one project, a project picker when you have more. |
| `/subscription` | Signs you into the dashboard's billing page to change your Subscriby plan or payment method. |
| `/invoices` | Signs you in and opens the billing portal, where every invoice and receipt for your Subscriby subscription is. |
| `/help` | Links to this documentation and the news channel, and names the support address. |
| `/donate` | Supports Subscriby's development by card or with Telegram Stars. |
| `/language` | Changes the language the bot speaks to you. |
| `/cancel` | Abandons whatever wizard is in progress. |
## Related
- [Creating an account](/account/sign-up) — the same sign-up flow from the account-management perspective.
- [Connectors](/creators/connectors) — install, connect, verify and remove a connector on a project.
- [Connect your bot](/connectors/telegram/connecting) — wire up your first project's Telegram bot.
- [Telegram advantages](/connectors/telegram/advantages) — why run memberships on Telegram.
- [Telegram integration](/connectors/telegram/integration) — the platform-level mechanics.
---
# Deep Links
Source: https://docs.subscriby.net/connectors/telegram/operations
Your Subscriby bot supports powerful deep linking capabilities. You can create special links to:
- Launch the bot.
- Automatically open a specific subscription plan.
- Execute commands (including custom ones).
- Activate access codes.
## Bot Links
There are two primary ways to share your bot:
### Short Link (Telegram Only)
Inside Telegram (channels, groups, and chats), you can simply mention your bot by its username.
```
@YourBotName
```
### Full Link
A universal link that works anywhere (websites, emails, social media) starts with `t.me`.
```
https://t.me/YourBotName
```
## Deep Linking
You can pass parameters to your bot using the `?start` query parameter. This allows for powerful automation and seamless user onboarding.
### What the bot accepts
A start payload is read in one of three ways, in this order:
| Payload shape | Handled as |
| --------------- | --------------------------------- |
| `auth_` | Portal sign-in handoff (internal) |
| `-` | An admin command — see below |
| A bare **UUID** | A plan id, or an access code |
| Anything else | Ignored; the bot opens its home |
There is no `key=value` syntax. The bot checks the payload with
`Str::isUuid()` and, if it matches, looks for a plan with that id in the
project; failing that it tries the value as an access code. A payload of any
other shape — a plain word, a campaign tag, a `code=ABC123` pair — is silently
ignored and the subscriber simply lands on the project home screen.
### Subscription Plan Links
Link straight to one plan and skip the selection menu. Ideal for "Buy Now"
buttons on your site and pricing tables.
```
https://t.me/YourBotName?start=a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d
```
The payload is the plan's own UUID. You do not have to assemble it by hand.
#### Open the project's Subscription Plans [step]
Pick the project in the top-left project picker, then choose **Subscription
Plans** in the sidebar.
#### Open the row menu for the plan you want to link [step]
Click the **⋯** button at the right-hand end of that plan's row.
#### Choose "Copy Plan Bot Deeplink" [step]
The full link is copied to your clipboard and the item briefly reads
**Copied!**. Paste it into a button, a post, an email — anywhere a link goes.
The item only appears once the project has a Telegram bot connected. Without a
bot there is no link to build — connect one from [Bot
setup](/connectors/telegram/integration) first.
#### Getting the link programmatically [step]
For automation, ask the API instead — it returns the assembled link so you never
have to concatenate the bot URL and the plan id yourself:
```bash
curl "https://api.subscriby.net/v1/projects/$PROJECT_ID/distribution/deep-link?plan_id=$PLAN_ID" \
-H "Authorization: Bearer $SUBSCRIBY_TOKEN"
```
See [Distribution API](/api/v1/reference/distribution), or the
[`get_deep_link`](/mcp/v1/tools/connectors#get-deep-link) MCP tool for the same thing from an
AI agent.
### Access Code Links
The same slot redeems an access code — codes are UUIDs, so the link looks
identical. The bot tries the payload as a plan first and falls back to a code.
```
https://t.me/YourBotName?start=a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d
```
You rarely need to build these by hand: the access-code CSV export has a
**Join Link** column, and the bot's own "share" messages already contain the
link. See [Access codes](/creators/codes).
## Admin Command Links
Two admin commands can be driven from a link, for use in your own dashboards or
integrations. Both act on the operator who taps the link, so they only do
anything for a project admin.
**Format.** Drop the leading `/`, prefix the command with `-`, and join each
argument with `_-`.
The payload is split on `_`, so a command whose own name contains an
underscore cannot be addressed — `-cancel_subscription` resolves to the
command `cancel`, which does not exist, and the link falls through to the home
screen. There is no escape sequence that works, and there is no mechanism for
user-defined custom commands.
**Example: Check User Status**
Shows detailed information about a user (Subscription, Plans, Resources, Payments).
Original Command: `/check 12345`
Deep Link:
```
https://t.me/YourBotName?start=-check_-12345
```
**Example: Ban User**
Bans a user, revokes their invite links, and removes them from all resources.
Original Command: `/ban 12345`
**Option 1: Interactive (Recommended)**
```
https://t.me/YourBotName?start=-ban_-12345
```
This link opens a safety menu with a confirmation button to prevent accidental
bans.
**Option 2: Immediate (Automated)**
```
https://t.me/YourBotName?start=-ban_-12345_-confirm
```
This link bans the user **immediately** without confirmation. Use this only
for automated systems where you are sure about the action.
---
# Recovering Your Telegram Account
Source: https://docs.subscriby.net/connectors/telegram/recovering-your-account
Your Subscriby account is bound to one Telegram account: the one you sign in with, the one our alerts reach, and the one your creator bot talks to. When Telegram bans that account, nothing about your projects breaks — our platform bot runs your channels, not your account — but you can no longer sign in with Telegram or receive anything there.
The **Telegram Account** recovery moves that binding to a new Telegram account. Nothing else changes.
A banned Telegram account cannot use **Continue with Telegram**, so sign in
with your **email and password** or a **passkey** instead. Every creator
account has all three; see [If you can't sign
in](/account/account-recovery) if you are stuck on this step. This is
also why the readiness checklist asks for a second factor: the account that
runs a recovery should be hard to take over.
## Option 1: the one-click switch (Growth)
If you registered a **backup Telegram account** ahead of time under [Active Disaster Prevention](/disaster-recovery/active-disaster-prevention), the recovery page shows *Your backup account is ready* and a single button, **Switch to Backup Account**. Press it and:
- sign-in with Telegram, your notifications and your creator bot now use the backup account;
- the previous account is kept as your backup, so the switch can be undone the same way;
- the Telegram Account allowance is spent, like any recovery.
No handshake, no link, no code.
## Option 2: prove a new account
Without a backup, you prove the new account from inside Telegram. The page names what is **Currently Linked** so you can see what you are moving away from.
### Generate a link
Press **Generate a Link**. The page shows a deep link into our platform bot and, beside it, an eight-character code. Both work for **15 minutes**.
### Open it from the new account
On the phone or desktop where the **new** Telegram account is signed in, open the link — it starts our bot with a one-time payload — or, if you already have a chat with the bot, send it the code as a message. The bot confirms: *this account is ready to be linked; go back to the dashboard*.
### Confirm in the dashboard
The page updates on its own (it listens for the handshake) and shows **Account detected: name**. Press **Confirm and Relink**. You may be asked to confirm your password first if you have not done so in the last three hours.
### Done
Sign-in with Telegram, alerts and your creator bot use the new account from this moment. The bot sends a welcome message to the new account; you receive a security email with the undo link.
### What the bot refuses
The handshake protects everybody's account, so the bot refuses to complete it when:
- the link or code has **expired** (15 minutes) — generate a new one;
- the message came from a **group or channel** rather than a private chat;
- the Telegram account is **already bound to another creator's** Subscriby account — one Telegram account, one creator;
- the Telegram account is the one **already linked** — you tapped the link from the wrong device.
Five wrong codes in a minute from one chat and the bot stops answering codes from it for a while.
## What changes, and what does not
| Changes | Does not change |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| **Continue with Telegram** signs you in from the new account. | Email, password and passkeys. |
| Alerts, sale notices and support relays reach the new account. | Your projects, plans, subscriptions, members and payments. |
| Your creator bot's private chat with you moves to the new account. | Your channels and groups — our platform bot administers them, and it was never bound to your personal account. |
| The recovery history shows *Linked to name*. | Your project bots and their members' chats. |
## Undo
The security email that follows a relink carries a signed **undo** link, valid for **24 hours**. Using it:
- puts the previous Telegram account back;
- signs you out of **every** browser and device, so whoever is signed in has to sign in again;
- opens a *relink disputed* incident and tells our support team, so a relink you did not make is looked at by a person.
The undo is deliberately only in the email: a relink made by someone who took over your dashboard session cannot be defended from that same session. If you switched to a backup account instead, the same email lets you switch back.
## Example: a banned account on the day of a launch
A coach's personal Telegram account is banned on Friday morning, hours before their Saturday cohort opens. Their channel keeps working — our bot runs it — but they can no longer sign in with Telegram or receive the sale notices.
They sign in with email and password, open **Disaster Recovery** from the banner, and see *We can no longer reach your Telegram account* named at the top. They had not registered a backup, so they create a new Telegram account on their phone, press **Generate a Link**, open it from the new account, come back and press **Confirm and Relink**. Four minutes. The Saturday cohort opens on time, the sale notices arrive on the new account, and the security email sits in their inbox in case anything about it was not them.
The Telegram Account allowance is now used until 90 days from Friday. A bot or channel problem in the meantime is unaffected — those are their own allowances.
---
# Replacing a Banned Bot
Source: https://docs.subscriby.net/connectors/telegram/replacing-a-banned-bot
Each project has one bot: the one your members buy through, message for support, and receive their invite links from. When Telegram bans it, revokes its token or deletes it, checkout stops, the portal's "Start" button leads nowhere, and nobody can be messaged.
The **Project Bot** recovery puts a new bot in its place **without losing the member chats**. Every member's private conversation is bound to your project's bot *record*, not to a token; the recovery writes the new token into that same record, so as soon as a member presses Start on the new bot, their history, their links and their subscription are all there.
Channels and groups are administered by Subscriby's platform bot, not by
your project bot. A banned project bot never touches who is inside your
channels. What it does stop is selling, support and the messages that hand
members their links.
## Before you start
You need a new bot token from Telegram's **@BotFather** — the same steps as when you first connected the project, and the recovery page repeats them beside the form: `/newbot`, pick a name and a username, copy the token. On **Growth**, if you registered a [standby bot](/disaster-recovery/active-disaster-prevention#standby-bot) ahead of time, you can skip BotFather entirely.
## Option 1: switch to your standby bot (Growth)
When a healthy standby bot is registered for the project, the recovery page opens with **Your standby bot … is ready**, naming it, and a **Switch to Standby** button. Press it and the standby's token is written into the project's bot record: every member chat stays bound and follows it, the webhook and commands are registered, and the standby slot is emptied so you can register a new one when you have a moment.
If our probe found the standby itself no longer answering Telegram, the page says so and offers the token form instead.
With [Automatic Failover](/disaster-recovery/active-disaster-prevention#automatic-failover) switched on, you do not have to press anything: the moment the probe finds Telegram refusing your bot outright, the platform performs this same switch by itself, emails every member reachable by email the new bot link at the per-email fee you accepted, and tells you what it did.
## Option 2: connect a new token
### Create the bot in BotFather
Open **@BotFather**, send `/newbot`, choose a display name and a unique username ending in `bot`, and copy the token it gives you (the long `123456789:ABC…` string).
### Paste the token
On the recovery page, choose the project if you own several, paste the token into **New Bot Token** and press **Connect New Bot**. Subscriby asks Telegram who the bot is, registers the webhook and the bot's commands, and rebinds the project.
### Confirm it answers
Open the bot on Telegram and send `/start`. It answers as your project's bot, with your project's name and plans.
The page refuses a token that is already connected to another project (yours or anyone's), and only the project's **owner** can run the recovery — a teammate with project permissions sees the page but cannot recover on the owner's behalf.
## Tell your members
The new bot has the same members, but Telegram will not let it message anyone who has not pressed Start on it yet. That is the one thing this recovery cannot do for you, so the page ends with **Tell Your Members**:
- **An announcement to copy.** A short text, in your language, that names the new bot and asks members to press Start. Post it in your channel, your group, your newsletter — wherever your members already are. **Copy Announcement** puts it on your clipboard.
- **Email Your Members.** One click emails every member who has an email address, the one they verified on the portal or the one they paid with, the new bot's link and, when the project has a public portal, the portal link. Each email costs **$0.01, added to your transaction fees** on your next invoice; the button names the count and the total before you confirm (*Email 240 Members? … about $2.40*). Members who have already pressed Start cost nothing, because they are not emailed.
- **Download Member List (CSV).** Free. Every member with active access or a date still to come, pass and season-ticket holders included, with their name, the email they verified or paid with if any, Telegram id, plans, when their access ends, which resources they hold and whether the bot could reach them on Telegram. Send the announcement your own way.
Members who open the portal see the new bot's Start button there as well, and every invite link they hold keeps working.
## What the recovery records
- The **history** row reads *Old Bot replaced by Fresh Bot*, with the project's name.
- Your **Project Bot allowance** is spent for 90 days. Account and channel recoveries are unaffected.
- The open **Project Bot incident** resolves, the dashboard banner disappears, and `connector.connected` fires for anyone listening.
- You receive the closing **report** by email.
There is no undo for a bot recovery: the old bot is gone on Telegram's side, and nothing about your members changed.
## Example: a bot banned on a Saturday morning
A trading educator wakes to find their project bot banned overnight — a competitor mass-reported it. The hourly probe had already opened the incident at 03:00 and the email is waiting.
They open the recovery page, see *Telegram restricted or banned the bot that serves your project* at the top, and press **Recover** on Project Bot. They had registered a standby bot on Growth two months earlier, so the page offers **Switch to Standby**; one press and the project is back. They copy the announcement into their channel: *"Our bot moved — press Start on @educator_standby_bot to keep receiving your links."* Because 140 of their 380 members never opened the bot beyond buying, they also press **Email 140 Members** and accept the $1.40 on the next invoice. By 09:20 the checkout works again, and the report email lands at 09:21.
Two months later, if the standby is banned too, the Project Bot allowance is still spent — that is when the fair-use rules send them to support, who look at why two bots were banned.
---
# Replacing Channels and Groups
Source: https://docs.subscriby.net/connectors/telegram/replacing-channels-and-groups
A channel or group in Subscriby is a **resource**: something a plan grants. When Telegram bans the chat behind it, the resource still exists, the plans still sell it, the members still hold it — only the chat is gone. The **Channels and Groups** recovery points the resource at a replacement chat and re-admits everyone who is entitled to be there.
Once Telegram bans a channel, no API can read its history — not ours, not
Telegram Desktop's export. Recovery brings back the **members**, not the
posts. On Growth, the [live mirror](/disaster-recovery/active-disaster-prevention#live-mirror)
copies every post into your standby channel *as you publish it*, so a
failover lands members in a channel that already holds your content. That
is the only content safeguard there is; the readiness checklist reminds you
to keep a copy outside Telegram too.
## The page
Choose the project (if you own several) and the step lists every channel and group of the project with:
- **Health** — the last probe's verdict: *Healthy*, or the reason it is degraded (*Chat not found*, *Bot removed*, *Bot is not an administrator*, *Bot is missing rights*, *Telegram error*). A resource switched off shows *Switched Off*; one already swapped in this recovery shows *Replaced*.
- **Plans** — how many plans grant it.
- **Members Affected** — how many members the swap moves: everyone with active access, plus every pass and season-ticket holder whose date is open or still to come. Those inside an open window are re-admitted at once; the rest receive their link to the new chat before their date opens, and their join request waits in the queue until the window opens, exactly as it would have in the original chat.
- **Standby** — on Growth, the standby chat linked to it and whether it is healthy.
At the top, a badge says where your allowance stands: *1 self-service recovery available*, *Recovery in progress — further swaps join it*, or *Used on date — contact support* with a **Contact Support** button that opens the chat with the incident already attached.
## Handing the bot a new chat
Create the replacement in Telegram first — a channel for a channel, a group for a group (a group may be replaced by a supergroup, since Telegram upgrades groups on its own; a channel can never become a group or the other way round). Then either:
### Press Replace
Our platform bot sends you a message on Telegram with a **chat picker** button: *Tap the button below and pick the new Channel that replaces Signals*. Pick the new chat. The bot joins it as an administrator through that picker and confirms.
### Or make the bot an administrator yourself
Add `@SubscribyBot` (the platform bot) to the new chat as an administrator with the *invite users* and *restrict members* rights. The moment it is promoted while a replacement is waiting, it asks: *I noticed you just made me an administrator of the channel "Signals II". Should it replace "Signals"?* — with **Yes** and **Cancel** buttons.
### Watch the page
The row showed *Waiting on Telegram* (with a **Withdraw Request** button in case you change your mind); it now shows *Replaced*, an **Undo** button, and the roll call starts filling below the table.
On **Growth** with a healthy standby linked to the resource, the row also has **Use Standby**: one click, no Telegram round trip, and the standby becomes the resource's chat.
The bot refuses a chat that is already a resource of any project, and refuses a chat of the wrong type.
## What happens in the swap
In one transaction the resource is re-pointed at the new chat, switched on, marked healthy, and the invite links minted into the old chat are forgotten. Then, from the queue:
1. Every member with **active access** to the resource — through a live subscription, or through a pass or pass-series date whose window is open right now — gets a fresh personal invite link into the new chat. Holders whose window is still ahead have their old, now-dead link forgotten and are picked up by the ordinary pre-window link sweep, so they are not messaged twice; see [pass and pass-series holders](/disaster-recovery/what-members-see#pass-and-pass-series-holders).
2. Each member is told by the project bot: *🔁 Signals has moved* with a **Join** button. Their join request is approved automatically when they tap it.
3. The old chat is left exactly as it is — nobody is evicted — and the links into it are revoked, best-effort.
4. `project.resource.updated` fires with `changes.space_id`, and the open incident resolves.
### Members our bot cannot reach
Some members never started your project bot (they bought through the portal), and if the bot was banned in the same incident it cannot message anyone at all. Those members still hold their new link — it appears in the portal and the moment they start the bot — but they are not told. The step lets you choose, per project, how they are reached; the choice is remembered:
- **Email them for me** — Subscriby emails each unreachable member who has an email address, verified on the portal or used to pay, their new link, at **$0.01 per email, added to your transaction fees**. Members the bot could reach cost nothing.
- **I will email them myself** — nobody is emailed. Press **Download Member List (CSV)** for every member the swap moves, with the email they verified or paid with if any, and reach them your own way.
## The re-admission roll call
Below the table, the roll call follows the queue: **Re-admitted x / total** (members holding a new link), **Joined** (members who actually came through it — stamped the moment Telegram approves their join request), **Could not be messaged** and **Told by email**. A badge summarises it: *n still to join*, or *Everyone Has Joined*.
**Remind Members Still Outside** re-sends the "has moved" message, with the same link, to everyone who has not joined yet — in the connector's chat only, nothing is minted or emailed again. It can be pressed once per hour (the page shows *Next reminder in 43 minutes*), through the same rate-limited queue as the swap, so a channel's worth of people is never messaged twice in a minute. The same button sits on the [history](/disaster-recovery/history-and-undo) row.
## Several channels at once
A channel recovery **absorbs** every further swap you make within **24 hours** of the first: three banned channels fixed in one sitting are one use of the allowance, not three, and the page says *Recovery in progress — further swaps join it*. The roll call covers all of them together.
## Undo
Each swap can be undone for **24 hours** from the **Undo** button on its row, or from the history page. The resource goes back to its previous chat, the links into the replacement are forgotten, and everyone is re-admitted to the old chat through the same queue. No allowance is spent by an undo — undoing a recovery is not another recovery.
## Example: two channels, one incident
A crypto-signals creator sells a *Signals* channel and a *Lounge* group through one plan. At 21:40 Telegram bans the channel; the group is fine.
- The five-minute pass-window probe (a weekend pass is open) finds *Chat not found* and opens the incident; email and Telegram alert at 21:41.
- The creator creates *Signals II* on their phone, makes `@SubscribyBot` an administrator with both rights, and confirms the bot's *Should it replace "Signals"?* question at 21:47.
- 212 members hold live access. Within two minutes the roll call reads *Re-admitted 212 / 212 · Joined 168*. The creator had chosen **Email them for me** months ago, so the 19 members the bot could not reach are emailed at $0.19 total.
- At 22:50 they press **Remind Members Still Outside**; 31 more join by midnight. The report email at 22:02 already told them where things stood, and the history page keeps the counts.
- Ten days later Telegram also bans the *Lounge* group. The channel allowance was spent at 21:47, so the page shows *Used on … — support review* and the **Contact Support** button. Support looks at both bans, finds an over-zealous report campaign rather than a rules problem, and releases a grant; the group is swapped the same evening.
---
# Sign in with Telegram
Source: https://docs.subscriby.net/connectors/telegram/sign-in
A Subscriby creator account is **email-first**, and a Telegram account can be linked to it in two ways: start from **Continue with Telegram** on the sign-up page and the link is there from day one, or sign up with an email address and link Telegram later from **Settings › Security › Linked accounts**. Once linked, you can always sign in with **Continue with Telegram** as an alternative to your password or passkey.
This page covers how that Telegram sign-in path behaves, what data is shared, and what to do if something goes wrong. The connector-neutral picture, and how the same card links accounts on other connectors, is on [Signing in with a connected platform](/account/social-login).
**Creator accounts only sign in through connectors.** Google sign-in,
magic-link sign-in, and similar paths are used on the **member portal** (your
members' login page), not on the creator dashboard. See [For Members →
Portal](/subscribers/portal) if you're reading this as a member.
## What Telegram sign-in does
When you click **Continue with Telegram** at `/login`:
1. You authenticate with **Telegram**, not with Subscriby.
2. Telegram sends us your public profile — first name, last name, username (if any), profile photo, and your unique Telegram user ID.
3. We match that user ID to your existing creator account and sign you in.
4. You stay signed in to Subscriby even if you later sign out of Telegram — the connection is only used at the moment of sign-in.
## How to sign in with Telegram
At `/login`, open **Log in with a connected platform** above the email/password form and click **Continue with Telegram**.
The Telegram login widget appears. On **mobile**, you'll be asked to open the
Telegram app and tap **Confirm**. On **desktop**, you can scan a QR code or
confirm directly from your phone.
Telegram redirects you back to Subscriby. Because your Telegram user ID is
already linked to your account, you're signed in immediately — no extra steps.
If 2FA is enabled on your account, you'll still be prompted for your authenticator code at `/two-factor`.
## Why this is useful
### No password to remember
If you forget your password or don't want to enter one on a shared device, **Continue with Telegram** is an instant alternative — no typing required.
### Tied to Telegram user ID, not @username
If you change or remove your Telegram username later, your Subscriby link is unaffected. We match on the underlying numeric user ID, which Telegram never changes.
### One account, three paths
Signing in with Telegram, email/password, or a passkey all land you on the same dashboard with the same data. Mix and match as convenient.
### What data Telegram shares
Only the basics: first name, last name, username (if any), profile photo, and a unique Telegram user ID. Subscriby doesn't see messages, contacts, or anything else in your Telegram account.
## Linking a Telegram account later
If you signed up with an email address, or unlinked Telegram at some point, you can link it from your dashboard:
Open **Settings › Security** and find the **Linked accounts** card. It lists every account already linked and offers a **Link Telegram** button while none is.
Press **Link Telegram**. The card shows a link into **[@TrySubscribyBot](https://t.me/TrySubscribyBot)** and an eight-character code, both valid for fifteen minutes.
From the Telegram account you want linked, open the link — or, if it does not open, send the code to the bot as a message. Do this from a private chat with the bot, not from a group.
The bot confirms the link and the card refreshes on its own. From now on **Continue with Telegram** signs you in, the bot can reach you with alerts, and you can manage your projects from it.
A Telegram account can be linked to one Subscriby account at a time. If the bot answers that the account already signs in to another account, unlink it there first, or use a different Telegram account.
## Unlinking Telegram
Press **Unlink** next to the account in **Settings › Security › Linked accounts** and confirm. You keep your password and any passkeys, so nothing locks you out; what you lose until you link again is the Telegram side — **Continue with Telegram** no longer signs you in, and **[@TrySubscribyBot](https://t.me/TrySubscribyBot)** can no longer reach you or manage your projects. A **backup** Telegram account registered for Disaster Recovery is removed from the [Disaster Recovery](/account/account-recovery) page, not from here.
If you have changed Telegram accounts entirely, use the relink flow in Disaster Recovery instead of unlinking and linking: it moves your projects and members over in one step and keeps an undo link. See [Recovering your Telegram account](/connectors/telegram/recovering-your-account).
## Security notes
In most cases it's *more* secure. You're leaning on Telegram's own account security — which typically includes its own two-factor passphrase. Just make sure your Telegram account itself is protected (set a **Two-Step Verification** passphrase in Telegram's privacy settings).
They could potentially sign in to your Subscriby account. That's why we
strongly recommend you also enable [two-factor
auth](/account/two-factor-auth) or register a
[passkey](/account/passkeys) on Subscriby — those add a second challenge
regardless of which sign-in path someone takes.
No. We only receive the basic profile fields that the Telegram login widget exposes. We can't read, send, or see any of your Telegram messages.
## Related
- [Signing in with a connected platform](/account/social-login) — the connector-neutral picture.
- [Signing in](/account/sign-in) — every creator sign-in method compared.
- [If you can't sign in](/account/account-recovery) — what to do when none of the methods work.
---
# Telegram Stars
Source: https://docs.subscriby.net/connectors/telegram/stars
**Telegram Stars (XTR)** is Telegram's own in-app currency for digital goods and services. Subscribers pay with Stars directly in the chat using their Apple In-App Purchase or Google Play credentials — no external redirect, no card-entry friction.
## At a glance
| Feature | Support |
| --------------------- | ----------------------------- |
| Platforms | Telegram only (no web portal) |
| Recurring billing | ❌ One-time charges only |
| Product sync required | ❌ No |
| Modes | **Live only** (no test mode) |
| Supported currency | `XTR` (Telegram Stars) |
| Value | ~**$0.013 USD per Star** |
Stars is brought by the Telegram connector, so the connector's **Built-in payments** switch on the project's Configuration tab (Connectors → Telegram → Configuration) decides whether it is on sale there: switched off, Stars leaves the payment-method picker and the plan currency list, no new Stars method can be added, and the bot stops offering an existing one at checkout. Sales already made and their subscriptions are untouched, and switching it back on offers it again.
## Why use Telegram Stars?
- **High conversion** — subscribers pay in one or two taps using store credentials they already have set up.
- **Native, zero redirect** — they never leave Telegram.
- **Apple / Google compliance** — selling digital goods via Stars meets Apple's and Google's in-app-purchase policies, which often forbid linking to external card forms.
## Enable Telegram Stars
### Open Setup a Payment Method [step]
In Subscriby, go to **Payment Methods → Setup a Payment Method**.
### Pick Telegram Stars [step]
Pick **Platform Currency** / **Telegram Stars** from the list. No credentials to enter — this provider is wired directly into Telegram.
### Activate and save [step]
Toggle **Active** and click **Save Changes**.
## Exclusive-Stars mode
When adding Telegram Stars you'll see an option labelled similar to **Show Only Telegram Stars Payment Method in the Bot**. What it does:
- **On** — your bot **hides** every other payment method (Stripe, PayPal, Paystack, etc.). The only way to pay inside the chat is Stars.
- **Off** (default) — all enabled payment methods appear, and subscribers pick their preferred one.
### Why ever turn it on?
For strict compliance with Apple and Google policies on digital goods inside Telegram. If an auditor flags your bot for linking to external card payment pages, switching to Stars-only makes the compliance story airtight.
**Your web portal is unaffected by Exclusive-Stars mode.** Even with it turned
on, the portal page still shows every other enabled payment method —
subscribers who prefer cards can go there instead. Use this split if you want
a polite "Telegram for Stars, web for cards" flow.
## Portal and Stars
Telegram Stars **cannot be used from the web portal**. Stars are processed natively by Telegram on your subscriber's Apple / Google device — there's no browser-side flow. The portal filters Stars out of its payment selector, so plans priced in XTR will show no payment methods on the portal.
If you want Stars-only plans, promote your **bot link** as the subscribe path — see [Sharing your project](/creators/share).
## The subscriber experience
### Open the bot and pick a plan [step]
Subscriber opens your bot and picks a plan priced in Stars.
### Tap Telegram Stars [step]
Taps **Telegram Stars** on the payment method screen.
### See the native payment prompt [step]
Telegram shows a native in-app payment prompt with the Star cost.
The prompt is an invoice carrying your plan's name and description. Telegram caps an invoice title at 32 characters and its description at 255, so a longer plan name or description is shortened with an ellipsis on the invoice alone; the bot and the portal keep showing them in full. Any formatting in the description is dropped there too, because invoices are plain text.
### Authenticate the payment [step]
Confirms with device authentication (Touch ID / Face ID / passcode).
### Activate the subscription [step]
Stars are deducted from their balance and the bot activates the subscription immediately.
If the subscriber doesn't have enough Stars, Telegram walks them through topping up via Apple / Google in-app purchase right there in the flow.
## Financials and withdrawals
### Value
- **~$0.013 USD per Star.**
- Stars accumulate in your bot's balance (visible in your bot settings / Telegram directly).
### Withdrawals
- Collected Stars are withdrawn via **Fragment**, using **TON cryptocurrency**.
- Telegram enforces a holding period (typically around 21 days) before withdrawals unlock.
**Before accepting Stars**, verify that you're legally permitted to receive
and trade cryptocurrency in your jurisdiction. Some regions restrict or
require registration for crypto-related business activity — this applies to
any TON payouts Fragment sends you.
### Transaction fees
Subscriby calculates its transaction fees based on the **USD equivalent value**:
- **Conversion rate**: 1 Star ≈ $0.013 USD.
- **Billing**: Fees are invoiced to you in USD even though you received Stars.
- **Example**: Collecting 1,000 Stars ≈ $13 USD in inflow; on the Starter plan's 3 % fee, that's ~$0.39 in Subscriby transaction fees.
See [Transaction fees](/fees) for the full model.
## Frequently asked
Yes — when creating a plan attached to Telegram Stars, set the currency to **XTR** and the price to the Star count (e.g. `100 XTR`).
No. Each Stars payment is a one-time purchase. Plans priced in XTR must be
non-recurring.
Telegram's native flow walks them through topping up via in-app purchase. Your
subscriber never sees an error or dead-end.
Yes — leave Exclusive-Stars mode off, and Stars appears alongside your other
methods in the bot. The portal still shows only the web-based methods.
No — Telegram Stars only has a Live mode. You can test with small Star amounts on your own account first to verify the flow.
## Related
- [Making payments (for subscribers)](/subscribers/making-payments) — what Star checkout looks like for the subscriber.
- [Sharing your project](/creators/share) — lead subscribers to the bot if you're going Stars-only.
- [Transaction fees](/fees) — how fees are calculated on Star payments.
---
# How the Bot Stays Connected
Source: https://docs.subscriby.net/connectors/telegram/webhooks
You don't need to configure anything for your Subscriby bot to stay connected to Telegram — everything is handled automatically when you complete the [bot connection](/connectors/telegram/connecting). This page explains _how_ it works, for curiosity's sake and for troubleshooting if things ever get weird.
## The short version
When a subscriber sends a message to your bot, Telegram doesn't poll Subscriby for updates. Instead, Telegram pushes each event to Subscriby via a **webhook URL**. Subscriby processes it immediately and replies through the Telegram Bot API.
That's it — that's the whole story. No polling loops, no delays, no "waiting for the bot to check in." The connection is event-driven.
## What Subscriby does automatically
When you paste a bot token during [Bot connection](/connectors/telegram/connecting) and click **Connect**:
1. Subscriby verifies the token with Telegram.
2. Subscriby registers a secure webhook URL on that bot.
3. From that point on, Telegram sends every event — messages, button taps, new chat members — to that URL.
4. Subscriby handles the event and responds via the Bot API, all within a second or two of the user's action.
The webhook URL points to `app.subscriby.net` endpoints and includes a project-specific secret so only Telegram's verified payloads are accepted.
## What you don't need to configure
- **A server** — Subscriby hosts the receiving endpoint.
- **TLS certificates** — Telegram requires HTTPS; Subscriby provides it.
- **Message forwarding** — the bot token is all Telegram needs; our webhook claims it.
- **Polling or cron jobs** — the bot reacts in real time to user input, not on a schedule.
## When the webhook matters for you
Ninety-nine percent of the time you'll forget the webhook even exists. The moments you might care:
### The bot stops responding
If users say the bot has gone silent, a broken webhook is a candidate cause. Things that can break it:
- **You revoked or rotated the bot token** in BotFather but didn't update it in Subscriby. The stored token no longer authenticates, so Subscriby can't reply. Fix: paste the new token in the bot connection modal.
- **The bot was disabled or deleted** in BotFather. Telegram stops pushing events to a dead bot. Fix: re-enable it in BotFather, or replace with a new bot.
- **Another service is trying to set a webhook on the same bot.** Only one webhook can be active per bot — if a competing tool overwrites ours, Subscriby stops receiving events. Fix: disconnect the other service and reconnect the bot in Subscriby.
### You rotate the bot token
If you send `/revoke` in BotFather to generate a new token, Subscriby's stored token is invalidated immediately. Paste the new token in the bot connection modal — Subscriby re-registers the webhook with the new credentials and operations continue.
### You temporarily disconnect
[Disconnecting a bot](/connectors/telegram/connecting#disconnect-a-bot) tells Subscriby to stop processing events from that bot. Telegram may still push events at the webhook URL, but we ignore them. To re-enable, reconnect the bot.
## Security notes
- The webhook endpoint verifies a **secret token** included in every incoming request from Telegram. Requests without the correct secret are rejected.
- Our servers only store the **bot token** (encrypted), not the full webhook URL or secret — those are derived at runtime.
- Every webhook request is logged so we can audit unusual activity (rate spikes, malformed payloads, etc.).
**You never interact with webhooks directly from Subscriby.** If anything in
this page sounds like it requires your action, it doesn't — the only thing you
ever paste is the bot token, and the only failure mode that shows up as a
user-facing symptom is *"the bot stopped responding"*.
## Related
- [Connect your bot](/connectors/telegram/connecting) — where the bot token lives.
- [Telegram integration](/connectors/telegram/integration) — the higher-level mechanics (member add/remove, etc.).
- [Troubleshooting](/reference/troubleshooting) — common cross-cutting issues including "bot not responding".
---
# Broadcasts
Source: https://docs.subscriby.net/creators/broadcasts
By the end of this page you'll know exactly who each audience segment contains, why the recipient count is the number to trust, and how to reach the holders of one dated access window without touching anyone else.
A **broadcast** is one message sent from your project's bot to a segment of your members. It is queued and delivered in the background, paced to stay inside the platform's rate limits.
**Opening the composer.** From **All Projects**, open the row menu on the
project you want and choose **Broadcast Message**.
## Only people who have used your bot can receive one
A broadcast is delivered as a message from your bot, so it reaches a member only if they have a chat with **this project's currently connected bot**.
That is narrower than your member list, and deliberately so. Someone who bought through your portal page and never opened the bot has no chat to deliver to. If you reconnect the project to a different bot, chats belonging to the old one no longer count.
This is not applied at send time as a silent filter — it is part of the definition of every segment below, so an unreachable member is **neither counted nor attempted**.
## The recipient count is the same query as the send
Under the audience picker, one line reports what the broadcast will do:
> **37 recipients**
> Reachable on this bot · about 2 sec to send
That number is not an estimate of the segment. It is the result of the exact query the worker will run, so the people counted and the people messaged are the same set by construction. When it says **No recipients**, the Send button is disabled — there is nothing to send.
The figure recalculates whenever you change the audience or the access window, and shows a placeholder while it does. Sending is refused if the count has fallen to zero in the meantime.
## Audience segments
### Member segments
These filter on a member's status, which is a fact about the person rather than about anything they bought.
| Segment | Who it contains |
| ------------------ | ---------------------------------------------------------- |
| **All Users** | Every member reachable on this bot, whatever their status. |
| **Customers Only** | Members whose status is Customer. |
| **Trialing Users** | Members currently on a trial. |
| **Leads** | Members who have interacted but never subscribed. |
| **Churned Users** | Members who were customers and are not any more. |
See [Managing members](/creators/managing-members#the-five-member-statuses) for exactly what moves a member between those statuses.
### Subscription segments
A status describes the person. These four describe the **subscription**, which is what a status cannot do: someone who cancelled but still has three weeks left carries the status **Customer**, the same as someone renewing happily, and until these existed the two could only be messaged together.
| Segment | Who it contains |
| ---------------------------------------- | --------------------------------------------------------------------------- |
| **Expiring Soon** | Members whose access runs out inside the horizon you choose. |
| **Cancelled, Still Inside Their Period** | Members who turned renewal off but have not run out yet. |
| **Paused Subscriptions** | Members who paused their subscription rather than ending it. |
| **Trialing Without a Card** | Members on a trial with no card on file, who will not convert on their own. |
This is the one thing to understand before writing a renewal nudge, because the obvious reading is wrong.
A subscription's end date is **rewritten to the new period end every time it renews**. So "ends within 7 days" describes an auto-renewing member's next _invoice_, not the end of their access — and counting it that way would sweep every monthly subscriber into this segment once a month, every month.
A member is counted only when their access genuinely lapses, which means one of two things: they turned renewal off, or the plan does not renew at all (a one-off or a pass). Everyone else is renewing, and nothing is expiring.
**Expiring Soon** asks for a horizon — **7**, **14** or **30 days** — and the recipient count updates as you change it.
**Cancelled, Still Inside Their Period** deliberately has no horizon. The whole value of that audience is reaching somebody while they still have something to lose, and three weeks out is a better moment to ask why than three days out.
### Narrowing any segment to one plan
Under the segment picker is an optional **Narrow to a Plan** control. It does not replace the segment you chose — it **composes** with it:
| Segment + plan | Who that reaches |
| ------------------------------- | ---------------------------------------------------- |
| **Customers Only** + Gold | People paying for Gold right now. |
| **Churned Users** + Gold | People who held Gold and left. |
| **Paused Subscriptions** + Gold | People whose _paused_ subscription is the Gold one. |
| **Expiring Soon** + Gold | Gold members whose access lapses inside the horizon. |
The plan and the state always describe the **same subscription**. Someone paying for Silver who tried Gold last year is not a Gold customer, and a Gold-only announcement will not reach them. **Churned Users** is the deliberate exception in the other direction: the subscription that proves they held the plan is precisely the one that ended, so it is not required to be live.
Leave it on **Any plan** to address the whole segment.
A plan you have deactivated still appears, marked **Retired**. The audiences most worth a plan filter are the ones who have already left, and a churned cohort very often churned from a plan you have since withdrawn — hiding it would make exactly the people the filter exists for unreachable.
The control is hidden for **Leads**, who by definition never subscribed, and for the pass segments, whose plan is already implied by the window you pick. Changing the segment to one of those clears any plan you had chosen, so a filter can never keep narrowing a send from a control that is no longer on screen.
### Pass segments
These filter on a **pass** — a subscription bound to a dated access window — rather than on the member. They need [Time-Limited Passes](/creators/time-limited-passes), which is on the **Growth** plan or available as the **Passes Addon** on any plan, and each is marked with a purple **Passes Addon** badge in the picker.
| Segment | Window | Who it contains |
| ---------------------------------------- | -------------- | ------------------------------------------------------------------------- |
| **All Active Pass Holders** | Every upcoming | Everyone holding a valid pass for any window that has not finished. |
| **All Active Pass Holders Not in Queue** | Every upcoming | The same people, narrowed to those who have not tapped their invite link. |
| **Active Pass Holders** | One you choose | Everyone holding a valid pass for that single window. |
| **Active Pass Holders Not in Queue** | One you choose | Holders of that single window who have not tapped their invite link. |
The two are not the same message reaching a wider group. A weekly slate has a
Saturday and a Sunday window; **All Active Pass Holders** reaches both sets at
once, while **Active Pass Holders** reaches exactly the date you pick. Picking
the wrong one sends a "doors open in an hour" message to people holding next
week's ticket.
#### What "active" means
A holder counts as active while their pass can still deliver access — their payment is settled or trialing, they have not cancelled, and their window has not finished. When a window closes, its holders are marked **Never Joined** or **Access Ended**, so they leave every pass segment automatically. You never have to prune a past date out of the audience yourself.
An ordinary recurring subscriber is not a pass holder and is never swept into these segments, even on a project that sells both.
#### What "not in queue" means
Tapping the invite link before a window opens puts a holder in the channel's **queue**, and the window opener admits whatever is waiting there. A holder who never taps is not locked out — links are reissued when the window opens — but they have to be at their phone to use them.
**Not in Queue** is therefore the segment of people with something left to do, and it is the one worth a "your link is still waiting" message. It excludes anyone already queued or already admitted, because for them there is nothing to act on.
For nudging those same people about their invite link specifically — with the
link attached — use **Send Reminders** on the window instead. See [Nudging
them
yourself](/creators/time-limited-passes#nudging-them-yourself-from-the-window-panel).
A broadcast is for anything else you want to say to them.
## Choosing an access window
Picking either single-window segment reveals an **Access Window** select immediately below the audience.
### Windows are listed soonest first
The one starting next carries a green **Next Window** badge. Each option shows the window's full date range in the plan's own schedule timezone, with the zone named — a pass measured in hours is meaningless as a bare date, and a reader in another country cannot guess the offset.
### Each option carries its own holder count
The purple **12 holders** badge on an option is the number that option would actually reach, counted with the same query as everything else on this screen. It is **specific to the segment you picked**, not a generic sold count: a window with 12 holders where 10 have queued shows _12 holders_ under **Active Pass Holders** and _2 holders_ under **Active Pass Holders Not in Queue**.
### Empty windows are not offered
A window with nobody in the chosen segment is left out of the list entirely, so you can never select an option the recipient line then reports as zero. Switching between the two pass segments re-evaluates the list, and moves your selection to the soonest window that still has recipients.
### Finished windows are never listed
Only windows that have not ended appear. A finished window's holders are no longer active, so every such option would be empty.
If no upcoming window has anyone in the chosen segment, the picker is replaced by a note saying so, and there is nothing to send.
### Why a window can be missing from the list
**Manage Access Windows** and this picker count different things, and the difference is worth understanding before it looks like a bug.
| Screen | Counts |
| ------------------------- | --------------------------------------------------------------------------------------------------------- |
| **Manage Access Windows** | Every subscription ever bound to the window — including cancelled ones, and buyers with no chat. |
| **Broadcast picker** | Only people this broadcast could actually deliver to. |
So a window reading **1 sold** over there can be absent here, and both numbers are correct. Its single holder may have cancelled, may already be in the queue when you picked the not-in-queue segment, or may have bought through your portal page and never opened the bot.
When that happens, a line under the picker says how many upcoming windows were left out and why, rather than letting them disappear silently.
## From the bot
The same broadcast exists on the Subscriby bot, offering the same segments and queuing the same job — a menu that named its own list would let you reach a population the dashboard cannot, or miss one it can.
Open your project from the bot and choose **Broadcast Message**. The bot lists every segment with its own recipient count, then offers the same set as buttons — so the numbers are in front of you before you choose:
- **👥 All Users (312)**
- **💳 Customers Only (188)**
- **⏳ Trialing Users (14)**
- **🔔 Leads (96)**
- **🚪 Churned Users (14)**
- **⌛ Expiring Soon (9)**
- **↩️ Cancelled, Still Inside Their Period (17)**
- **⏸ Paused Subscriptions (6)**
- **💤 Trialing Without a Card (11)**
- **🎟 All Active Pass Holders (41)**
- **⏰ All Active Pass Holders Not in Queue (12)**
- **🎫 Active Pass Holders (0)**
- **🕒 Active Pass Holders Not in Queue (0)**
### The bot asks the same questions, one at a time
A chat keyboard cannot show two controls at once, so what the dashboard puts on one screen the bot asks as steps. Only the steps your segment actually needs appear, and each one shows counts before you commit to it:
### Access window — single-window pass segments only
Soonest first, each showing the holders it would reach, empty windows left out. The next window to open is marked ⏭.
### Running Out Within — Expiring Soon only
Three buttons — **⌛ 7 days**, **14 days**, **30 days** — each carrying the number of members that horizon reaches.
### Narrow to a plan — every segment except Leads and the pass segments
**👥 Any plan** first, then one **🏷** button per plan with its own count. Only plans with somebody in the chosen segment are listed, so every button leads somewhere.
**Any plan** is a real answer, not a skipped question, so choosing it moves straight on to the message rather than asking again. Going back and re-picking the segment clears every refinement you had made, which is how you back out of a wrong turn.
If you have more plans than one keyboard can hold, the bot lists the largest audiences and says how many it left out — those are reachable from the dashboard.
The preview before you confirm names everything you chose, so a narrowed send is never ambiguous: _Expiring Soon — within 30 days on Gold_.
A chat keyboard has no disabled state, so a creator without Time-Limited
Passes is not shown those buttons at all — tapping one would only tell them it
was unavailable after they had chosen it. On the dashboard, where an option
*can* be shown disabled, they stay visible with the **Passes Addon** badge.
The preview before sending names the window as well as the segment, because a pass broadcast that does not say which date it is addressed to is indistinguishable from one addressed to every date — and those reach different people.
A window can also stop qualifying while the keyboard sits in your chat: its holders all queue up, it closes, or you cancel it. Tapping it then is refused with a note and the list is re-offered, rather than sending to a window that no longer has anyone in the segment.
## Writing the message
The editor supports the formatting your platform's chat accepts: **bold**, _italic_, underline, strikethrough, `code`, and links.
What you type is converted to the connector's message format before sending. Paragraphs become blank lines, line breaks are preserved, and anything the platform does not accept is stripped rather than sent raw — a platform can reject a whole message on unknown markup, so a stray tag would otherwise fail delivery for every recipient at once.
A message that comes out empty after that conversion is refused rather than queued, so an editor you opened and never typed into cannot mail everyone a blank message.
A broadcast lands in the private chat each member has with your bot. It cannot
be recalled once delivered, and there is no per-member opt-out beyond blocking
the bot — which also stops every other notification Subscriby sends them.
Compose accordingly.
## Sending
Press **Send Broadcast** and the job is queued immediately; the composer closes and a toast confirms how many recipients it went out to.
Delivery is paced at roughly 28 messages a second, which is what the arrival estimate is calculated from. Individual failures do not stop the run:
- A member who has **blocked the bot** is skipped without retrying — that never recovers.
- A **rate limit** from the platform is respected, waiting the interval it asks for. On a long wait the job hands the rest of the run to a delayed follow-up that starts from the next member rather than tying up a worker, so nobody receives the message twice.
- Any other refusal is logged against that recipient and the run continues.
Blocking is per member, not per broadcast. If a large share of a send fails,
check that the bot is still connected and healthy on the project before
assuming the audience was wrong.
## What happens if your plan changes after queuing
A queued broadcast carries its audience across time, so entitlement is re-checked when the worker picks it up, not only when you press send.
If the project's owner no longer holds Time-Limited Passes by then — the plan was downgraded, or the Passes Addon was cancelled — a pass broadcast is **abandoned rather than sent**, and the reason is logged. The gate is real at both ends rather than only in the dashboard.
Entitlement always follows the **project owner**, never whoever is operating the dashboard. A teammate administering someone else's project is judged on the plan that pays for that project.
## Frequently asked
Because a broadcast can only reach members with a chat on this
project's currently connected bot. Members who bought through the portal page
and never opened the bot, and chats belonging to a bot you have since
replaced, are both excluded.
**Cancelled** is everyone who turned renewal off, however long they have left
— someone with three months remaining is in it. **Expiring Soon** is only the
people running out inside your chosen horizon, and it also picks up one-off
plans and passes that were never going to renew in the first place.
Use **Cancelled** to ask why while there is still time to change their mind,
and **Expiring Soon** for the last nudge before the door closes.
Because an auto-renewing subscription is not expiring. Its end date moves
forward every time it renews, so the date you are picturing is the next
charge, not the end of their access.
A member appears here only when nothing will renew them — they turned renewal
off, or the plan does not renew at all. If the count is near zero, that is
usually good news.
No. It reaches people whose **current** subscription is that plan. Somebody
paying for Silver today who tried Gold last year is a Silver customer, and a
Gold-only message will not reach them.
**Churned Users** plus a plan works the other way on purpose: the subscription
that proves they held it is the one that ended, so it does not have to be live.
No, it is deliberate, and it is marked **Retired** so you can tell. The
audiences most worth narrowing by plan are the ones who have already left, and
a churned cohort very often churned from a plan you have since withdrawn.
Hiding it would make exactly those people unreachable.
Not in one send. Choose the single-window segment and send once per window, or
use the every-window form if all upcoming windows are in scope.
No. The every-window segments select members, not passes, so someone holding
both Saturday and Sunday receives one message.
Yes. An open window has not finished, so its holders are still active. They
appear in the every-window segments and their window remains selectable until
it ends.
Not yet. A broadcast is queued the moment you send it. For time-sensitive pass
messages, the automatic reminders a day and an hour before a window opens run
on their own — see [Time-Limited
Passes](/creators/time-limited-passes#customers-who-havent-joined-the-queue-are-chased).
The project's owner does not hold Time-Limited Passes. It is bundled with the
Growth plan, or available as the Passes Addon on any plan — the callout under
the picker links straight to billing.
## Related
- [Time-Limited Passes](/creators/time-limited-passes) — access windows, the queue, and the per-window reminder button.
- [Managing members](/creators/managing-members) — the statuses the member segments filter on.
- [Connecting a bot](/connectors/telegram/connecting) — the bot every broadcast is delivered through.
- [Subscription](/creators/subscription) — the Growth plan and the Passes Addon.
---
# Generate Access Codes
Source: https://docs.subscriby.net/creators/codes
By the end of this page, you'll know how to generate access codes in bulk, set expiry rules, export them, and understand the quota system.
An **access code** is a unique alphanumeric key that a user can redeem instead of paying through a payment gateway. Each code is tied to a specific subscription plan and grants one subscription when redeemed. Perfect for:
- **Offline sales** — you took a bank transfer, cash, or invoice-based payment, and now you issue a code.
- **Giveaways** — free access for community winners, contest participants, or loyal fans.
- **Influencer gifting** — send 50 codes to an influencer to distribute to their audience.
- **Team access** — grant employees or collaborators free memberships without going through billing.
- **Access-code-only plans** — some plans are designed to only be reachable via codes (see [Subscription plans → Access codes only](/creators/plans#access-restrictions-optional)).
**Navigating to Access Codes.** Select a project first, then open **Access
Codes** from the sidebar.
## Prerequisites
Before you can generate codes, two things must be in place:
1. **A connected connector** — codes are redeemed through your project's bot, so it needs to exist. See [Connectors](/creators/connectors).
2. **Access Codes enabled as a payment method** — toggle it on from [Payment Methods](/creators/methods). No configuration fields needed, just enable it.
## Generate codes
### Open the modal [step]
Click **Generate New Access Codes** at the top-right of the Access Codes page.
### Pick a subscription plan [step]
Every code is tied to **one plan**. When redeemed, the user gets that plan's subscription. Choose from the dropdown — only active plans are shown.
### Quantity [step]
How many unique codes to generate in this batch. Up to **1,000 per batch** — generate multiple batches if you need more.
Every code is a freshly-generated string unique to your project — members can't guess or brute-force codes, and two projects never share code space.
### Code expiry [step]
This controls when the **code itself** becomes unredeemable — not the subscription duration. A subscription activated by a code follows the plan's normal billing cycle.
- **No expiry** — the code stays redeemable indefinitely.
- **Presets** — 1 day, 1 week, 1 month, 1 year.
- **Custom** — enter a custom number of days (e.g. "redeemable for 3 days only").
Unredeemed codes that pass their expiry are **automatically deleted** to free
up your quota. Redeemed codes stay in your history for reporting.
### Consent & review [step]
Tick the consent checkbox to confirm you're generating codes you intend to distribute. Review the summary — including any fees if you're over your free quota (see below).
Click **Generate Codes**. The system queues a background job and Subscriby's bot sends you the codes as soon as they're ready.
## How the codes arrive
You'll receive the batch directly in your chat with Subscriby's bot on your linked platform. For small batches (under 25 codes), each code is sent as an individually forwardable message — convenient for gifting or pasting into Slack/DMs. For larger batches, the bot delivers a CSV file you can download and manage in Excel, Google Sheets, or Airtable.
The **CSV export** contains these columns per code:
| Column | What it holds |
| ----------- | -------------------------------------------------------------------------- |
| Access Code | The actual redeemable string |
| Plan Name | Which plan this code unlocks |
| Join Link | A deep-link into your project's bot that carries the code |
| Expires At | When the code becomes invalid |
| Price | Face value of the plan |
| Currency | Currency of the plan |
| Started On | When the code was generated |
| Ends On | When the resulting subscription would expire (if the plan is not Lifetime) |
Paste the **Join Link** into email campaigns, landing pages, or influencer packs — tapping it drops the user into your bot with the code already applied, so they just confirm and get their access.
## How subscribers redeem
From your subscriber's perspective:
The easiest redemption path — one tap.
1. You share the **Join Link** (from the CSV) with them.
2. They tap it. The platform opens your bot and passes the code along.
3. The bot greets them, confirms the plan, and after consent, activates the subscription.
Works for anyone who receives just the code (e.g. typed on a business card, printed flyer, forwarded in DM).
1. They open your bot and send `/start`.
2. The bot shows its normal plan list.
3. They tap a dedicated **Redeem a code** option.
4. They paste the code. The bot validates and activates the subscription.
## View, filter, and audit codes
The Access Codes page lists every code you've generated, with filters for plan, redemption status, and expiry window. You can:
- **Search** by the code string itself (handy when a subscriber emails you a code that "doesn't work" — you can look it up).
- **Filter by status** — show only unredeemed codes, only redeemed, only expired, etc.
- **Export CSV** — download the filtered view for external reporting, including the columns shown above.
## Quotas and fees
Every creator plan includes a **free quota** of access codes per billing cycle:
- **Free:** 5 codes per cycle
- **Starter monthly:** 25 codes per cycle
- **Starter annually:** 250 codes per cycle
- **Growth monthly:** 150 codes per cycle
- **Growth annually:** 1,500 codes per cycle
A code counts against the quota **when a subscriber redeems it**, not when you
generate it. Generating a batch costs nothing on its own, and codes that expire
unredeemed never count at all.
A trial gives you every feature of the plan you are trying, but the access-code
quota stays at the Free plan's **5 per cycle** until your first payment goes
through. Each code inside the quota is a sale Subscriby waives its commission
on, so that part of the plan starts when the plan does — the same reason your
transaction fee stays at the Free rate during a trial.
You can still generate as many codes as you like while trialling. Redemptions
beyond the five are simply charged at your usual rate, and the full quota opens
up the moment the first payment lands. See
[Transaction fees](/fees#fees-on-access-code-redemptions).
### Going over quota
Once redemptions pass your quota for the cycle:
- **Per-code overage fee** applies to each redemption beyond the free limit.
- The fee is included in your monthly Subscriby invoice (metered billing).
- You'll see the expected overage cost in the generation modal before you confirm, based on the codes you are about to create.
**Unused quota doesn't roll over.** If your cycle gives you 25 codes and only
8 are redeemed, the remaining 17 disappear at cycle-end. Plan bulk campaigns
to land within a single cycle if you're on tight quotas.
### Deleted codes free up quota
Unredeemed codes that expire are auto-deleted, and the deletion **frees up quota** for the current cycle. So a code you generated yesterday that expires today doesn't count against your allowance anymore.
## Frequently asked
The subscription activates immediately for the subscriber. The code is marked **redeemed** on your dashboard with a timestamp and the subscriber's account id on the connector. It cannot be redeemed again.
You can't "un-redeem" a code, but you can cancel the resulting subscription
from [Managing subscriptions](/creators/managing-subscriptions). That
removes the subscriber's access.
No — access codes are single-use, and every redemption needs a fresh code. If
you want one string redeemable by many people, that's a [coupon
code](/creators/coupon-codes) — a different feature that discounts what
people pay rather than granting access outright.
Yes. Code-based access on a $0 plan is the standard "free membership" pattern.
Configure the plan with `price = 0` and generate codes against it.
No. Subscriptions activated via access codes are **non-recurring** by design.
At the end of the plan's duration, access expires. The user can redeem another
code (if you issue one) or subscribe via a normal payment method to continue.
Not for access codes — those are system-generated to guarantee uniqueness and resist guessing. A custom-branded string like `BLACKFRIDAY` is a [coupon code](/creators/coupon-codes), where you choose the string yourself and many people redeem the same one for money off.
## Related
- [Payment methods](/creators/methods#supported-providers) — enable Access Codes as a payment method before generating.
- [Subscription plans](/creators/plans) — the plans your codes unlock.
- [Exporting data](/creators/exporting-data) — CSV export details, including access code columns.
- [Redeem an access code](/subscribers/redeem-access-code) — the subscriber-side experience.
---
# Connectors
Source: https://docs.subscriby.net/creators/connectors
A project runs on **connectors**: the platforms it gates access on, sends messages through and takes payments from. The **Connectors** page of a project lists what it runs and lets you install more from the directory.
## Installed connectors
Open a project and choose **Connectors** in the sidebar. Every installation the project holds is a row:
- **Connector** — which platform, and whether the installation is **live** (the one that acts for the project) or a **standby** kept by the [Disaster Recovery Program](/disaster-recovery).
- **State** — `Pending` (installed, not connected yet), `Connected`, `Degraded` (the last probe found something wrong; hover for the reason), `Revoked` (the platform withdrew it) or `Disconnected` (you did). A **Sales Paused** badge beside a degraded state means the platform refused the installation outright (token revoked or regenerated, bot deleted): every plan that unlocks a place on that connector is off sale until it answers again, and members are compensated when it does — see [Connector outages](/disaster-recovery/connector-outages). A red **Paused** badge means Subscriby has switched the whole connector off during an incident on our side or the platform's: nothing is sent through it, nothing is probed, and what members send waits on the platform until it is switched back on. Your installations, plans and members are untouched.
- **Account** — the platform's own name and handle for the installation (the bot's name and username).
- **Last checked** — when the hourly health probe, or a verify you ran, last asked the platform.
The row's menu offers:
- **Connect** — on a pending installation, opens the same modal the projects page does: paste the bot token and activate it. Once the installation is connected the same entry reads **Reconnect**, and it is where you replace a bot token or move the project onto a different bot: paste the new token, and the new bot takes over the moment the platform accepts it.
- **Verify** — a fresh probe rather than the hourly one; the state updates at once.
- **Restore Access** — offered after you install a connector again that you had uninstalled, once it is connected. Every place the uninstall detached is asked about; the ones the connector still controls come back active, and every member with a live purchase of a plan granting them gets fresh access to exactly those places (nothing they already hold is touched). A place the connector no longer controls stays detached — run the doctor to see why — and plans the uninstall took off sale stay off sale until you publish them again.
- **Disconnect** — the connector withdraws the installation and its credentials are wiped. Members keep their access; grants, resources, plans and identities are untouched. Connect again at any time.
- **Uninstall** — removes the connector from the project after showing you what that touches (below).
## The directory
**Browse Marketplace** opens the in-app [Connectors Marketplace](/connectors/marketplace) with this project already picked: every connector Subscriby knows, lane by lane — **Available Now**, **Experimental**, **Paused**, **Under Development**, **Coming Soon** — with its badges (Official or Community, New, Trending) and a one-line description. **Connect to a project** on an available card opens a pending installation on this project and brings you back to this page, where connecting it with its credentials finishes the job.
Official connectors are free. Running two different connectors on the same project (and selling plans that span them) is a Growth feature: the second install is refused until the project owner upgrades, or the other connector is uninstalled.
## Uninstalling a connector
Uninstalling is not disconnecting, and it never deletes anything. Before anything happens, the dialog shows the impact:
- how many of the connector's **resources** will be detached (deactivated, their platform ids kept),
- how many **members lose access** (every live grant on those resources is revoked and the member is put outside),
- which **plans are left with nothing to grant**, and how many recurring and one-time purchases sit on them.
When plans would be emptied, two switches appear, both on by default:
- **Take the emptied plans off sale** — the plans are unpublished so nobody buys a plan that grants nothing.
- **Cancel their recurring subscriptions at period end and email each member** — every live recurring subscription on those plans stops renewing; members keep their access until the end of the paid period and receive an email saying so. One-time and lifetime purchases are never cancelled; a refund stays your call.
The installation stays on record as uninstalled with its settings kept, and member accounts are never removed. Installing the connector again brings it back as pending; the detached resources stay inactive until you relink them.
## From the API and MCP
Everything on this page is on the [Connectors API](/api/v1/reference/connectors) and the MCP tools ([`list_connectors`](/mcp/v1/tools/connectors#list-connectors), [`install_connector`](/mcp/v1/tools/connectors#install-connector), [`verify_connector_installation`](/mcp/v1/tools/connectors#verify-connector-installation), [`disconnect_connector`](/mcp/v1/tools/connectors#disconnect-connector), [`get_connector_uninstall_preview`](/mcp/v1/tools/connectors#get-connector-uninstall-preview), [`uninstall_connector`](/mcp/v1/tools/connectors#uninstall-connector)), and the [`connector.*`](/webhooks/v1/events/connector) webhook events announce every move. Connecting, which needs the credential typed by a person, stays on the dashboard.
---
# Coupon Codes
Source: https://docs.subscriby.net/creators/coupon-codes
A **coupon code** is one string any number of your subscribers can type at checkout for money off.
You choose the string — `BLACKFRIDAY`, not a generated UUID.
An [access code](/creators/codes) is **one code for one person** and
grants access outright, without a payment. A coupon is **one code for many
people** and reduces what they pay. Both exist, and both are worth having — if
you want to give somebody free access, that is still an access code.
Coupon codes are included on **Growth**, or available on any plan through the
[Coupons Addon](/addons/coupons) at $5 / month.
## Why not just use access codes for a sale
Creators did, for a long time, because it was the only lever available. It does not fit:
| | Coupon code | Access code |
| -------------------- | ------------------------------ | -------------------------- |
| How many people | One code, many people | One code, one person |
| What it does | Reduces what they pay | Grants access outright |
| Who chooses the code | You | Subscriby generates it |
| They still pay | Yes, the discounted amount | No |
| Limited in number | No — issue as many as you like | Yes, your plan's allowance |
Running a 20%-off sale with access codes means generating a code per buyer, collecting the money
yourself somewhere else, and handing each person their own string — and access codes are **capped
per cycle** by your tier, so the workaround gets more expensive the better the sale goes.
## Creating one
### Open Coupon Codes [step]
In your project menu, choose **Coupon Codes**, then **Create New Coupon Code**.
### Choose the code [step]
Letters, numbers and dashes, 3 to 64 characters. Subscribers type this at checkout, so keep it short
and memorable. Case does not matter — it is stored and matched in uppercase, so `BLACKFRIDAY` and
`blackfriday` are the same code.
Use **Suggest** if you would rather not invent one.
### Set the discount [step]
A **percentage** works on every plan whatever its currency. A **fixed amount** needs a currency, and
only applies to plans priced in it — because "$10 off" a plan priced in euros is not a question we
can answer honestly.
### Fence it in [step]
Set a total cap, a per-subscriber cap, a minimum purchase, specific plans, and a start and end date.
All optional. Every one of them exists because a discount with no fence around it is a liability.
### Save [step]
The code works immediately unless you gave it a future start date, or switched it off.
## What you can control
| Control | What it does |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Percentage / fixed** | A percentage covers every currency; a fixed amount is tied to one. |
| **Total redemptions** | First 500 uses, then the code stops applying. Leave empty for unlimited. |
| **Uses per subscriber** | Once each by default. |
| **Minimum purchase** | Stops a generous percentage being spent on your cheapest tier. |
| **Restrict to plans** | Specific plans — or leave empty and it covers every plan, including ones you add later. |
| **When it runs** | A start and end, in [your own timezone](/account/language-region), so a sale opens and closes without you. If you have never confirmed that timezone, the section says so before you schedule against it. |
| **Active** | Switch off to stop new redemptions immediately, keeping the history. |
## The discount applies to the first payment
Renewals charge list price. That is deliberate, and worth explaining rather than filing under
limitations.
A code that kept discounting every renewal would be an ongoing promise to your customers — and it
would have to be locked in place the way the [Passes Addon](/addons/passes) is, so you could
not drop the feature while people were still riding discounts you had granted. Keeping coupons to
the first payment means the opposite: once a discounted payment settles, the discount is history,
and the addon can be removed whenever you like.
It also matches what a sale usually means. "25% off" on a launch banner is understood as 25% off what
you pay now, not a permanent price change for everyone who caught the promo.
On a **one-time plan** — a lifetime plan, or a [time-limited pass](/creators/time-limited-passes) —
there is only a first payment, so the whole purchase is discounted.
## An abandoned checkout costs you nothing
When somebody applies a code, Subscriby puts a temporary hold on one use. The hold counts towards
your cap, which is what stops a hundred people simultaneously claiming the last of a hundred uses. If
the payment never completes, the hold expires and the use returns to the pool.
A code capped at 100 can show as fully claimed while its last few uses are
only *held* by checkouts in progress, and become available again minutes later
when one of those is abandoned. That is the count being honest, not a bug —
and it means no slots are ever quietly lost to people who wandered off.
## Which payment methods can discount
Every payment method you can sell a one-time plan or a pass with carries a coupon. Only **Stripe**
can discount a **recurring** plan — it is the only provider that lets a discount be attached to the
subscription itself. The others take a single amount at checkout, and one number cannot mean "less
now, full price later".
If a subscriber applies a code and then picks a method that cannot discount that plan, they are told
to choose another method or continue without the code. They are never charged the full amount while
believing a discount applied. The full matrix is on the
[Coupons Addon](/addons/coupons#which-payment-methods-can-discount) page.
## Two things a discount cannot do
**It cannot take the price below what a payment provider accepts.** An 80%-off code on a $1 plan
leaves 20 cents, which every payment provider rejects as too small. Subscriby checks the discounted
total against that minimum first and tells the buyer why the code will not apply, rather than sending
them to a payment page that would fail.
**It cannot reach money that has already moved.** Change a code's terms mid-campaign and subscribers
who already redeemed keep exactly what they paid.
## Your transaction fee follows the discount
Subscriby's [transaction fee](/fees) is taken on what was actually charged, not the plan's list
price. Discount a $40 plan to $30 and the fee is calculated on $30. Your revenue figures and exports
follow the same rule, so a discounted sale is never overstated on your dashboard.
## Managing codes from the bot
**Manage Coupon Codes** in your creator bot walks you through code, name, discount and cap, and lets
you inspect, switch off and delete a code from your phone.
Editing terms stays in the dashboard — a seven-field edit is a form, not a chat.
## Measuring what a campaign did
Both your project dashboard and your overall dashboard carry a **Promotion Impact** card:
redemptions, discount given, revenue actually taken, the average depth of discount, and a per-code
ranking.
It counts only the uses where the money actually arrived, so a campaign is never flattered by
checkouts people started and walked away from. The card stays hidden until a code has been redeemed in the period you are
looking at.
## Retiring a code
**Switch it off.** New redemptions stop immediately and every redemption already recorded is kept.
This is the safe option, and the one to reach for.
**Delete it** only if you want the record gone too — deleting removes the code's redemption history
along with it. If a checkout using the code is still in progress, deleting is refused and you are
asked to switch it off instead.
Either way, anyone who already redeemed keeps what they paid and their subscription is untouched.
## Questions
Yes. Codes are unique per project, so `BLACKFRIDAY` in one project and
`BLACKFRIDAY` in another are separate coupons with separate caps.
No. One code per checkout.
Nothing is clawed back. The code simply reports no remaining uses and stops
applying.
The discount applies to the first *payment*, so on a plan with a trial it
applies to the charge that lands when the trial ends.
Yes — the Promotion Impact card ranks per code, and each redemption is
recorded against the subscription it bought. The
[`coupon.redeemed`](/webhooks/v1/events/coupon#coupon-redeemed) webhook carries
the subscriber id if you want it in your own systems.
They stay in your dashboard and stop applying at checkout. Discounts already
given stand. Add the addon again — or move to Growth — and every code starts
working immediately with its terms and counts exactly as you left them.
## Related
- [Coupons Addon](/addons/coupons) — pricing, and the full payment-method matrix.
- [Redeeming a coupon code](/subscribers/redeem-coupon-code) — what your subscribers see.
- [Access Codes](/creators/codes) — one code, one person, grants access.
- [Coupons API](/api/v1/reference/coupons) — author and audit codes programmatically.
- [`coupon.*` webhooks](/webhooks/v1/events/coupon) — seven events, including redemption and exhaustion.
---
# Dashboard & Analytics
Source: https://docs.subscriby.net/creators/dashboard-analytics
By the end of this page, you'll know how to scope the Subscriby dashboard to exactly the slice you care about, read every metric it surfaces, and pull a snapshot out as a PDF, spreadsheet, or plain-text report.
Subscriby ships **two distinct dashboards** that share a common toolbar and visual language but each tailor their KPIs, charts, and tables to their scope:
- **Overall Dashboard** — your portfolio view across every project you own or are a team member of. The first page you see after signing in when no project is selected.
- **Project Dashboard** — drill-down view for a single project. Activated as soon as you pick a project from the top-left selector, or by visiting a project's URL directly.
**"All analytics information and data updates every 5 minutes."** The
dashboard reflects a rolling snapshot, not live-to-the-second data. If you've
just made a change and don't see it immediately, that's why.
## Switching between the two dashboards
- Open the project picker at the top-left of the sidebar.
- Pick a project → you land on that project's **Project Dashboard**.
- Click **Back to Main Dashboard** in the sidebar (or clear the project selection) → you go back to the **Overall Dashboard**.
Filters, period settings, and comparisons all behave identically in both modes — only the metrics, charts, and tables differ.
## The toolbar (shared)
Across the top of both dashboards is a toolbar with three groups: **time controls**, **filters**, and **actions**.
### Period selector
The period dropdown controls the time window every metric covers.
| Value | Label |
| ----- | ------------------------ |
| `7d` | Last 7 days |
| `14d` | Last 14 days |
| `30d` | Last 30 days _(default)_ |
| `60d` | Last 60 days |
| `90d` | Last 90 days |
| `1y` | Last 12 months |
| `mtd` | Month to date |
| `qtd` | Quarter to date |
| `ytd` | Year to date |
| `all` | All time |
Changing the period refreshes every card, chart, and table on the page.
### Custom date range
Next to the period dropdown is a **date-range picker**. Pick a start and an end date and every
metric on the page recalculates against exactly that window — useful for reporting on a campaign, a
launch week, or a specific month that no preset covers.
The range only takes effect once **both** dates are set. With one end chosen,
the dashboard stays on whatever the period dropdown says — so a half-finished
range never silently reports a window you did not mean.
Clear the picker to hand control back to the period dropdown. The picker is hidden on narrow
screens, where the period presets carry the whole job.
### Comparison selector
Next to the period picker, the **compared to** dropdown sets the baseline for the trend indicators (green up / red down arrows you'll see on metric cards):
- **Previous period** _(default)_ — if you're looking at the last 30 days, compare to the 30 days before that.
- **Same period last year** — good for seasonal or annual-cycle businesses.
- **Last month**
- **Last quarter**
- **Last 6 months**
- **Last 12 months**
### The filter bar
The **Filter by** bar has three multi-select, **searchable** listboxes:
Pick any number of plans. The dashboard narrows every metric to subscriptions on those specific plans.
Great for: _"How did the Pro plan do last month?"_
Pick any combination of the [13 subscription statuses](/creators/managing-subscriptions#the-13-subscription-statuses) — Active, Trialing, Expired, Canceled, Pending, and more.
Great for: _"How many of this period's signups actually became paying customers vs. stayed in Pending?"_
Pick any number of configured payment methods. Each option shows the provider name _(e.g. Stripe, PayPal)_ and its mode _(Live / Test)_.
Great for: _"How much revenue flowed through Razorpay vs. Stripe this month?"_
Filters **stack** — narrow to "_Active_ status on the _Pro_ plan paid via _Stripe Live_" and every chart, gauge, and table responds in real time. Each listbox is searchable, so typing into the trigger filters the option list as you go.
The Overall Dashboard does **not** include a project filter. To narrow to a
single project, use the project selector in the top-left sidebar — that takes
you straight to the Project Dashboard.
### Reset filters
If you've set many filters and want to go back to the defaults, click **Reset Filters** (top-right of the toolbar). It restores:
- Period: Last 30 days
- Custom date range: cleared
- Compared to: Previous period
- All filter listboxes: cleared
### Export
To the left of **Reset Filters** sits the **Export** dropdown. Use it to download the current view as a file in any of four formats:
| Format | Best for |
| ----------------- | --------------------------------------------------------------------------------- |
| **PDF** | A polished, print-ready summary you can email a stakeholder or attach to a deck. |
| **Excel (.xlsx)** | Multi-sheet workbook with KPIs, top tables, and the recent transaction log. |
| **CSV** | Flat, spreadsheet-friendly text dump for Excel / Numbers / Google Sheets imports. |
| **Plain Text** | Compact, monospace summary for terminals, email pasting, or quick eyeballing. |
Whatever filters and period preset you have set are baked into the file — exporting "Last 90 days, Stripe Live only" gives you a report scoped to exactly that slice. A custom date range is the one control an export does not carry: the file always reports the period preset, so its filename and range line stay predictable across downloads.
## Overall Dashboard
The Overall Dashboard answers: _"How is my entire portfolio doing?"_
### KPI strip
Nine cards in a horizontally scrollable strip. Each shows the metric's title, the current value, a
trend against the comparison baseline, and a sparkline of the metric's recent shape.
#### Reading the trend and the sparkline
Three details are worth knowing, because they are deliberate rather than accidental:
- **Rate metrics move in points, not percent.** Churn Rate and Trial Conversion show `pp`
(percentage points) — a churn rate going from 4% to 5% is `+1pp`, not `+25%`. Reporting a
percentage change of a percentage is how a rounding wobble reads as a crisis.
- **Down is good for churn.** Churn Rate's arrow is inverted: falling churn shows green.
- **A metric with no honest daily series draws no chart.** Rather than a flat line at zero, cards
whose underlying metric has no meaningful day-by-day shape simply show the value and the trend.
Where a rate does have a daily series, the sparkline plots its **numerator** — churn per day on a
small base is either 0% or 100%, which tells you nothing.
| Card | What it shows |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Total Revenue** | Gross USD across all projects in the period. |
| **Total Transactions** | Successful payment count. |
| **Transaction Fees** | Fees paid across all projects, all-time, with the window's movement and shape. Recorded in USD. |
| **Total Users** | Distinct subscribers (project users) across all projects. |
| **MRR** | Recurring monthly income from active subscriptions on recurring plans, normalized to a monthly figure from each plan's billing cycle. One-time and lifetime plans are excluded. |
| **New Subscribers** | Subscribers who signed up within the period. |
| **Churn Rate** | % of active subscribers at the start of the period that canceled before the end. |
| **Trial Conversion** | % of trials started in the period that converted to a paid Active subscription. |
| **Net Earnings** | Gross revenue minus transaction fees (your take-home, USD-normalized). |
### The Portfolio card
Below the KPI strip, the account dashboard shows what only the account can: one **Portfolio** card that ranks your projects and shows how the whole splits.
The top half is a **leaderboard**, one row per project, ranked by revenue in the window. The top five projects each get a colour and a row of their own; the rest fold into an **Other** row at the bottom. A row carries the rank, the project's name with the icons of its live connectors, a bar proportional to the leader's revenue, a 30-day sparkline of that project's daily revenue, the revenue with its share of the window's total, and the project's active members. The card's corner shows the revenue the ranking adds up to.
The bottom half, **How the whole splits**, is four segmented bars, each with its total and a legend of every segment's value and share: **Revenue by Project**, **Members by Project** (active subscribers), **Revenue by Plan Kind** (subscriptions, passes, pass series) and **Kept vs Fees** (what stayed with you against what went to fees). A bar with nothing in it reads **No data**. The per-project fee, transaction, currency and outcome donuts live on each project's dashboard; the account keeps fees as a KPI card and payment outcomes as the **Payment Success** gauge below.
### The Cash flow card
To the right of the Portfolio card sits **Cash flow**. Its two figures follow the toolbar window like every other card: **Money in** is the gross amount your members paid, and **Fees out** is what the gateways and the platform took from it. Under them, a bar chart shows the last six calendar months of money in, oldest first, with the current month drawn in colour and the five before it in grey, so you can see where the window's figure sits in the run of months around it. Hover a bar for that month's exact money in and fees.
The callout at the bottom reads the month so far: what has come in since the 1st, how that compares with the **same number of days** of the previous month (a comparison on the 5th is never read against a whole month), and the pace, which is the month so far scaled to the whole month. An account with no payments yet shows **No payments yet** in place of the bars.
### Bar charts
- **Revenue per Project** — top 10 projects by gross USD in the window.
- **Revenue by Payment Provider** — gross USD broken out per provider (Stripe, PayPal, Razorpay, …).
When a card has only **a single project, plan, or provider** with revenue in
the window, Subscriby swaps the bar chart for a single-row progress-bar
fallback. Once you have at least two non-zero entries, it switches to a full
bar chart automatically.
### Plan Mix Over Time
A stacked area chart showing which plans carry your revenue, month by month, across your whole
portfolio. Your **top five plans** get their own band; everything else is grouped into **Other
plans**, because a chart with fourteen bands is a colour puzzle rather than an answer.
Use it to spot a shift you would miss in a single revenue total — one plan quietly overtaking
another, or a launch tier that peaked and faded.
When there is no plan revenue in the selected window the card says so rather than drawing an empty
chart.
### Promotion Impact
Appears once a [coupon code](/creators/coupon-codes) has been redeemed in the selected period.
It reports, across every project you own:
- **Redemptions** — how many times codes were used
- **Discount Given** — what you gave away, USD-normalized
- **Revenue Taken** — what you actually collected on those sales
- **Off** — the discount as a share of list price, so you can see how deep the average sale was
- A per-code breakdown, best performer first, with each code's uses against its cap
Only redemptions where money actually moved are counted. A checkout that
applied a code and was abandoned holds a use temporarily but never appears
here — so the card cannot flatter a campaign.
The card stays hidden entirely when no codes were redeemed in the window, so a dashboard with no
promotions running is not cluttered by a zeroed-out panel.
### Gauges
A **Subscriber Health** card surfaces two gauges:
- **Retention** — `(active at window start − canceled in window) / active at window start × 100`. The percentage of your starting active base that you held onto.
- **Trial Conversion** — same value as the KPI card, visualised as a gauge for at-a-glance reading.
### Tables
- **Top Performing Projects** — name, the connectors the project runs on (icon and name; icons alone when it runs several, a dimmed icon for an installation that is not operational), active subscribers, transactions, revenue, share of total. Click a project name to open its **Project Dashboard**.
- **Recent Transactions** — last 25 successful and non-successful payments. Each row opens with a calendar tile (the month band over the day) beside the full date and time and the payment reference, then the project and plan with the project's connectors beside them. Status is rendered as a color-coded badge; the date column links to that subscription's project.
## Project Dashboard
The Project Dashboard answers: _"How is **this** project doing?"_
### KPI strip
Eight cards tailored to the single-project view:
| Card | What it shows |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Total Revenue** | Gross USD for this project in the period. |
| **Total Transactions** | Successful payment count for this project. |
| **Total Users** | Subscribers belonging to this project. |
| **MRR** | Project-level recurring monthly income. Only active subscriptions on recurring plans count; one-time and lifetime plans are excluded. |
| **Net Earnings** | Gross revenue minus transaction fees. |
| **Churn Rate** | Window-over-window churn within this project. |
| **Average Lifetime** | Average days a subscription is active before canceling or expiring (uses completed cycles). |
| **Lifetime Value** | `ARPU × average lifetime in months`. A USD estimate of what one new active subscriber is worth. |
### Sales charts
Same three sales charts as the Overall Dashboard, but scoped to this project.
### Bar charts
- **Revenue per Plan** — the project's plans ranked by gross USD in the window, at most ten: a rank, the plan's name, a bar proportional to the leader, the amount and its share of the window's plan revenue. Long plan names wrap rather than being cut.
- **Acquisitions vs Cancellations** — grouped daily bars showing new signups (green) and cancellations (rose).
### Subscriber flow
Three tiles that turn the raw counts above into the questions a creator actually asks:
- **Net Subscriber Change** — signups minus cancellations for the window, with the two figures that
produced it underneath — _41 joined, 12 left_. A positive total with heavy churn on both sides
is a different business from a quiet positive total, and a single net number hides that.
- **Trials Started** — trials begun in the window, with how many of them converted to paid.
- **Value Per Month** — lifetime value spread over the average lifetime, so it is comparable with
MRR rather than being a lump sum you have to divide in your head.
### Promotion Impact
The same card as on the Overall Dashboard, scoped to this project. Appears once a
[coupon code](/creators/coupon-codes) has been redeemed here in the selected period, and shows
redemptions, discount given, revenue taken, the average depth of discount, and a per-code breakdown.
### Stacked subscription-status chart
A wide stacked bar chart titled **Subscription Status Distribution** plots, day by day, how many subscribers are in each status (Active, Trial, Past Due, Canceled, Expired). The shape of the stack tells you whether your active base is growing, churning, or stalling in trial.
### Gauges
A **Project Health** card surfaces two gauges:
- **Active Subscriber Health** — % of subscribers currently in the **Active** status (vs. trial / past due / canceled / expired).
- **Plan Utilization** — % of plans that have at least one active subscriber. A low number means you have plans drawing zero traction.
### Tables
- **Top Plans by Revenue** — plan name, the connectors its resources are gated on (a plan with no gated resource shows the project's connectors), active subscribers, MRR, revenue, share. Click a plan name to open the project's plan list.
- **Recent Subscribers** — name, the connectors the member holds an identity on (preferred first, a dash for a member with none yet), plan, status badge, started-on date, lifetime spend, lifetime days. Click a name to open the subscribers page.
- **Recent Transactions** — a calendar tile (month band over the day) beside the full date and time and the payment reference (clickable to subscriptions), plan, the plan's connectors, subscriber, status badge, provider, amount, currency.
- **Access Code Usage** — code, plan, redeemed-by, redeemed-at, expiry. Click a code to jump to the access-codes page.
## Putting it all together: common workflows
Set period to **Last 7 days**, compare to **Previous period**. Scan the KPI strip. Look for red trend arrows — those are the metrics to pay attention to. Open anything with a double-digit drop.
Set period to **Last 30 days**, compare to **Same period last year** (if you
have a year of data — otherwise use **Last month**). Narrow by plan to
understand which tier shifted most. Hit **Export → PDF** to keep a snapshot of
the month for your records.
Open the **Project Dashboard** for the project you just launched. Narrow to
**Last 7 days** and the new plan in the filter. Watch the **Acquisitions vs
Cancellations** chart and the **Recent Subscribers** table fill up in
near-real-time.
Narrow the payment-method filter to a single provider. If numbers dropped
versus the comparison baseline while other methods stayed flat, the issue is
isolated to that provider — investigate its dashboard next.
Read the **Trial Conversion** KPI card directly, or open the **Subscriber
Health** gauge for a visual. For per-plan conversion, narrow the plan filter
and watch the value move.
Set the period and any filters that make sense, then **Export → Excel**. The workbook arrives with separate sheets for the KPI summary, top plans / projects, recent transactions, and (on Project Dashboards) recent subscribers — ready to email.
## What lives elsewhere
- **Per-subscription detail** — the [Managing Subscriptions](/creators/managing-subscriptions) page has the row-level data behind the dashboard aggregates.
- **The whole payment ledger** — [All Transactions](/creators/transactions) lists every payment across your projects with search, sorting and filters by project, connector, plan, method, status and period; the **View all** button on either Recent Transactions card opens it.
- **Per-member detail** — [Managing members](/creators/managing-members) is the equivalent for individual subscribers.
- **Weekly digest email** — enable **Weekly Emails** in [Notifications](/account/notifications) to get a once-a-week summary delivered to your inbox.
- **Bulk access-code export** — [Exporting data](/creators/exporting-data) covers the access-code-batch export, which is separate from the dashboard report exports above.
## Frequently asked
A few possibilities:
1. **Timezone** — Subscriby shows data in your account timezone; the provider may default to UTC.
2. **Currency conversion** — for mixed-currency revenue, Subscriby converts to a common display currency (USD); the provider shows each transaction in its settlement currency.
3. **Refunds not yet synced** — a refund in the last ~5 minutes may not have reflected in the dashboard yet.
4. **Trials** — providers like Stripe typically don't show the trial-billing cycle until it converts to paid; Subscriby shows trials from day one.
Data refreshes **every 5 minutes**. If you're monitoring a launch in real
time, watch the [activity timeline](/creators/managing-subscriptions) —
individual events land there as they happen.
Check:
- You have the **correct project selected**, or are on the **Overall Dashboard** if you want a portfolio view.
- Your period is wide enough to include past activity — bump it to **Last 90 days**.
- Filters aren't accidentally narrowing to a combination that excludes everything — click **Reset Filters**.
Bar charts need at least **two non-zero entries** on the X-axis to draw a
meaningful chart. With a single project, plan, or provider in the window,
Subscriby renders a labelled progress bar instead — once you have two or more,
it auto-promotes to a full bar chart.
Because that metric has no honest day-by-day series in the selected window.
Drawing a flat line at zero would suggest the metric sat at zero all period,
which is a different claim. The value and the trend are still accurate.
It doesn't — check the sign. Churn's arrow is inverted deliberately: green
means churn **fell**. Every other card treats up as good, and churn is the one
where that would be backwards.
Because the metric is itself a percentage. Churn Rate and Trial Conversion
move in **percentage points** — 4% to 5% is `+1pp`. Calling that `+25%` is
technically a percentage change of a percentage, and it makes small movements
look enormous.
It only appears once a [coupon code](/creators/coupon-codes) has
actually been redeemed in the period you're looking at. If you've just
launched a code and nobody has used it yet, or your window predates the
campaign, the card stays hidden. It also needs the coupons capability —
Growth, or the [Coupons Addon](/addons/coupons).
Both ends have to be set. With only a start or only an end date the dashboard
keeps using the period dropdown, so a half-picked range never quietly reports
the wrong window. Clear the picker to go back to the presets.
Yes — use the **Export** dropdown in the toolbar. PDF, Excel, CSV, and Plain
Text are all supported, and the file respects the period and filters you have
set. The bulk **access-code batch** export is separate and lives on the
access-code page.
By design. To narrow the Overall Dashboard to a single project, pick the project from the top-left selector — that takes you straight into the matching **Project Dashboard**, which is purpose-built for the single-project view (different KPIs, more drill-down tables).
## Related
- [Managing subscriptions](/creators/managing-subscriptions) — drill into the specific subscriptions behind dashboard totals.
- [Managing members](/creators/managing-members) — the people behind those subscriptions.
- [Coupon codes](/creators/coupon-codes) — what the Promotion Impact card is reporting on.
- [Exporting data](/creators/exporting-data) — bulk access-code export and other off-dashboard exports.
- [Transaction fees](/fees) — how Subscriby's cut affects your net earnings.
---
# Exporting Data
Source: https://docs.subscriby.net/creators/exporting-data
By the end of this page, you'll know how to pull your Subscriby project data into external tools like Excel, Google Sheets, Airtable, or a BI dashboard.
Subscriby currently supports **direct CSV export for access codes**. Subscription and member exports are on the roadmap but not yet available as one-click actions — use the dashboard filters, pagination, and per-subscription detail views for now, or contact support for a one-off data dump.
## Access codes export
Every batch of [access codes](/creators/codes) you generate can be exported as a CSV — either automatically on generation, or retroactively from the Access Codes page.
### Export columns
The CSV contains one row per code, with these columns:
| Column | Content |
| --------------- | ------------------------------------------------------------------------------------------------------------ |
| **Access Code** | The redeemable code string itself (unique per code). |
| **Plan Name** | The subscription plan this code unlocks. |
| **Join Link** | A one-tap bot deep-link pre-filled with the code — paste it into emails, landing pages, or influencer packs. |
| **Expires At** | When the code itself becomes unredeemable. Blank if the code never expires. |
| **Price** | Face value of the plan (if the subscriber had paid for it normally). |
| **Currency** | Currency of the plan's price. |
| **Started On** | When the code was generated. |
| **Ends On** | When the resulting subscription would expire after redemption — only relevant for non-lifetime plans. |
### Export on generation
When you generate a batch of access codes, the bot automatically delivers them to you:
- **Small batches (< 25 codes)** → individually forwardable messages, each containing one code and its join-link.
- **Large batches** → a CSV file delivered in your chat with Subscriby's bot.
Save the CSV to your computer, cloud drive, or password manager. Keep it secure — anyone with a code can redeem it.
### Retroactive export from the dashboard
If you've lost the original file or want a filtered subset:
#### Open Access Codes [step]
Open the project's **Access Codes** page from the sidebar.
#### Apply filters [step]
Apply any filters you want — by plan, status (redeemed / unredeemed), or expiry window.
#### Click Export [step]
Click **Export** (typically a download icon in the toolbar).
#### Download the CSV [step]
The CSV downloads to your browser with the columns described above, filtered to match the view.
## What to do with exported codes
Load the CSV into your email platform's campaign tool. Merge the **Join Link** column into a personalised message — *"Here's your exclusive access link: {{ Join Link }}"*. Each recipient gets a unique code and link.
Send them the CSV (or a secure sharing link to it). They can distribute
through their own channels. The Join Link column means recipients don't even
need to type the code — they just tap.
Re-download the CSV a week later and compare the **Redeemed At** field. Divide
redeemed by total to get a redemption rate — a key marketing metric for
gifting campaigns.
Mail-merge the codes onto printed postcards, business cards, or event swag.
For QR-code-based distribution, generate a QR for each Join Link using any QR
service.
Have a corporate customer of 50 people? Generate 50 codes in one batch and email each to a person on your customer's team.
## Other data you can read (without an export button)
Even without built-in exports, you can still extract most of your project's data:
### Dashboard filters
Almost every filter on the [Dashboard](/creators/dashboard-analytics) is copy-pasteable as a screenshot for reporting. For a quick monthly-review slide, filter to the month and screenshot the summary cards.
### Per-subscription detail views
The [Subscriptions page](/creators/managing-subscriptions) shows every field for each subscription — plan, payment method, status, dates, fees, the access code (if any), and the payment history. Paginate through and copy what you need.
### Gateway dashboards
Your payment provider (Stripe, PayPal, Razorpay, etc.) has its own export functionality — often more detailed for financial reporting (invoice PDFs, transaction ledgers). Use those for accounting; use Subscriby for membership-level context.
## Frequently asked
Not currently. Exports are manual (generation time or retroactive click). Scheduled exports are a planned feature — contact support if you need this urgently for reporting infrastructure.
Not publicly available as of this docs version. Enterprise creators with large
data needs should contact sales to discuss custom integrations.
Not through a built-in export today. You can see and sort emails on the
[Members page](/creators/managing-members). For larger data dumps,
contact support with a description of what you need.
Usually a **character encoding** issue — CSVs are UTF-8 and Excel sometimes mangles non-Latin characters on open. Import the CSV explicitly via **Data → From Text/CSV** and pick UTF-8 encoding in the import wizard. Google Sheets handles this correctly on direct open.
## Related
- [Access codes](/creators/codes) — generate the codes that populate the export.
- [Managing subscriptions](/creators/managing-subscriptions) — manual review of subscription data.
- [Dashboard analytics](/creators/dashboard-analytics) — aggregate views you can screenshot for reports.
---
# Go-Live Checklist
Source: https://docs.subscriby.net/creators/go-live-checklist
Run through this page once before you announce your community. Every line links to the page that sets it up; the [quickstart](/quickstart) builds all of it in order if you are starting from nothing.
## Account
- Your creator subscription is active: Free, Starter or Growth ([activate a plan](/creators/subscription)). Paid tiers start on a 7-day trial, and sales during the trial stay on the Free tier's rate.
- Two-factor authentication or a passkey guards the account ([two-factor authentication](/account/two-factor-auth), [passkeys](/account/passkeys)), and a backup sign-in account is registered ([if you can't sign in](/account/account-recovery)).
## Project
- The project is **Active** ([project settings](/creators/projects#all-the-project-fields)). An inactive project's bot stays silent and its portal takes no signups.
- Name, description, banner and logo are set. The description's first 155 characters are the snippet search engines show for the portal, so lead with what members get.
- Your own terms and privacy documents are linked, or you are happy with the defaults the bot asks members to accept.
- A portal handle is set if you want the hosted page at a memorable address (Starter and above).
## Connector and places
- The connector is installed and its bot connected ([connectors](/creators/connectors)).
- The bot is an administrator of every place a plan grants, with the right to invite members by link; that second right is usually a separate toggle and the one most often missing ([why the rights matter](/creators/resources#why-the-rights-matter)).
- Every place is linked as a resource, and anything you hand over yourself is a manual resource ([resources](/creators/resources)).
## Plans
- At least one plan is **Active** and linked to the resources it unlocks ([subscription plans](/creators/plans)).
- Prices, billing cycles and trial rules are what you mean to sell, and the storefront order puts the plan you want bought first at the top.
- Plans are synced to the providers that keep a product catalogue ([sync subscription plans](/creators/methods#sync-subscription-plans-for-providers-with-product-catalogues)).
## Payments
- At least one payment method is **Active** in **Live** mode with live keys ([payment methods](/creators/methods)). A method in Test or Sandbox mode takes no real money, and a portal whose gateways are all in test mode is kept out of search results.
- You have made one test purchase in Test mode and switched the method to Live afterwards, or added a separate Live method and deactivated the test one.
- Access Codes is switched on as a method if you sell offline, gift memberships or run giveaways ([access codes](/creators/codes)).
## Sharing
- The portal page renders with your branding and every active plan ([share your project](/creators/share)).
- Your bot link, plan deep links or QR codes are ready for wherever you announce.
## Members and support
- The support inbox is on if you want member questions to reach you inside Subscriby ([support inbox](/creators/support-inbox)).
- You know where members appear and how to remove one ([managing members](/creators/managing-members), [managing subscriptions](/creators/managing-subscriptions)).
## Recovery
- The readiness tiles are green: a standby bot, a standby for every place, automatic failover and a live mirror of every broadcast place ([readiness checklist](/disaster-recovery/active-disaster-prevention#readiness-checklist)).
- A second human administrator sits in every place and a copy of your content lives outside the platform, the two lines no tile can verify.
Once the first member pays, [transactions](/creators/transactions),
[analytics](/creators/dashboard-analytics) and
[exports](/creators/exporting-data) are where you watch it grow, and
[webhooks](/webhooks/v1) and the [integrations](/integrations) carry every
event to the tools you already run.
---
# Creator Guide
Source: https://docs.subscriby.net/creators
import {
Zap,
FolderPlus,
Layers,
CreditCard,
Tags,
Bot,
Ticket,
Share2,
Users,
Repeat,
BarChart3,
Download,
} from "lucide-react";
Welcome to the creator side of Subscriby. This section walks you from **zero** (a fresh account) to a **launched, paying community** — then covers how to run and grow it day-to-day.
Every page in this section is task-oriented and written for non-technical creators. If you prefer concept-first reading, start with [How Subscriby works](/how-it-works) and come back here for the hands-on bits.
## The launch path
Follow these eight steps in order the first time through. You can skip optional ones (like access codes) and come back later.
### Activate your plan [step]
Pick Free, Starter, or Growth and add a payment method. All three require a card on file because transaction fees apply to every tier. Paid tiers open with a **7-day free trial** — nothing is charged until it ends, though sales during it stay on the Free tier's [10% rate](/fees#during-your-free-trial).
→ [Activate your subscription](/creators/subscription)
### Create your first project [step]
A project is the container for your plans, members, and bot. You can run multiple — the Free plan includes three.
→ [Create a project](/creators/projects)
### Install a connector [step]
Install the connector for your platform from the project's [Connectors](/creators/connectors) page and connect its bot, so Subscriby can manage your community on your behalf. Do this before defining resources — the places a connector gates can only be linked through a connected connector.
→ [Install and connect a connector](/creators/connectors)
### Define your resources [step]
Resources are the places your members unlock when they subscribe — private channels, groups or servers on your connected platform. With your connector connected, you can link them right away — or start with a **Manual Perk** for anything off-platform.
→ [Resources](/creators/resources)
### Enable payment methods [step]
Pick from Stripe, PayPal, Razorpay, Skrill, Paystack, CoinPayments and CeyPay, free Access Codes, plus the native payment your connector brings.
→ [Payment methods](/creators/methods)
### Create subscription plans [step]
Price tiers ("$9.99/month Premium", "$99 lifetime"), trial rules, and eligibility filters all live here.
→ [Subscription plans](/creators/plans)
### Generate access codes _(optional)_ [step]
Useful for giveaways, manual sales, team accounts, or influencer gifting.
→ [Access codes](/creators/codes)
### Issue coupon codes _(optional)_ [step]
One string many subscribers redeem for money off — for launches, seasonal sales and win-backs.
→ [Coupon codes](/creators/coupon-codes)
### Share and go live [step]
Your bot link for instant onboarding on your platform, or a hosted portal page with your custom handle — share however fits your audience.
→ [Sharing your project](/creators/share)
## Running your project day-to-day
Once your first paying member joins, these are the tools you'll use most:
}
title="Managing members"
href="/creators/managing-members"
>
Search, filter, and take action on individual subscribers.
}
title="Managing subscriptions"
href="/creators/managing-subscriptions"
>
See active, expired, and one-time subscriptions — cancel, refund, or audit any
of them.
}
title="Dashboard & analytics"
href="/creators/dashboard-analytics"
>
Revenue, members, and growth — with period comparisons and plan-level filters.
}
title="Exporting data"
href="/creators/exporting-data"
>
Download your access codes and subscriber data for external reporting.
## Related sections
- [Account & Security](/account) — your personal account settings (sign-in, 2FA, passkeys).
- [Teams & Roles](/teams) — bring collaborators in with scoped permissions (Growth plan).
- [For Subscribers](/subscribers) — what the other side of the experience looks like for your members.
- [Connectors](/connectors) — the platforms a project runs on, and each connector's own mechanics.
- [Payment methods](/payments) — per-provider setup guides.
---
# Managing Members
Source: https://docs.subscriby.net/creators/managing-members
By the end of this page, you'll know how to find any user in your project, read their current status, and open their details.
A **member** (called a "Project User" internally) is anyone who's interacted with your project — a person who tapped your bot, started a trial, bought a subscription, or was once a customer and has since left. Every member lives inside exactly one project.
**Navigating to Members.** From the sidebar, open **Project Users** (or
**Members** — the label depends on context). When a project is selected,
you'll see only that project's members; on the global *All Project Users*
route you see everyone across all your projects.
## The five member statuses
Every member carries exactly one status at any time. Subscriby updates it as the person's relationship to your project changes.
| Status | Badge colour | Meaning |
| ------------ | ------------ | -------------------------------------------------------------------------------- |
| **Lead** | zinc | Interacted with the bot but hasn't subscribed (or trialled) yet. |
| **Trialing** | yellow | Currently inside a trial period of a plan. |
| **Customer** | green | Has an active paid subscription. |
| **Churned** | red | Was previously a Customer or Trialing, and no longer has an active subscription. |
| **Banned** | black | Blocked from your project — can't subscribe or interact. |
## Filters
The Members page has four filter tabs at the top, matching the most common statuses:
Default view. Shows every member regardless of status — Leads, Trialing, Customers, Churned, and Banned.
Members whose status is **Customer** — your active paying base.
Members whose status is **Trialing** — inside a trial period right now.
Members whose status is **Churned** — previously active, no longer subscribed.
Banned members don't have a dedicated filter tab, but they're included in
**All users**. Their black status badge makes them easy to spot.
### Dropdown filters
Alongside the tabs sits a row of dropdown filters, available on both a single project's Members page and the global _All Project Users_ view:
- **Filter by Status** — limit to one of the member statuses above.
- **Per page** — choose 15, 25, 50, or 100 rows per page.
On the global _All Project Users_ view, an extra **Filter by Project** selector appears first, letting you scope the list to one of your projects. **Reset Filters** clears every dropdown back to its default.
## List columns
Each row on the Members page shows:
| Column | Content |
| --------------- | ---------------------------------------------------------------------------------- |
| **User** | Avatar, display name, and the id of their connected account. |
| **Status** | Badge showing Lead / Trialing / Customer / Churned / Banned. |
| **Project** | Which project this record is under (only shown on the global view). |
| **Joined Time** | When the member was first seen on the project, formatted in your account timezone. |
You can sort by **User** (name), **Status**, **Project**, or **Joined Time** — click any column header to toggle asc/desc. Pagination defaults to **15 rows per page**.
## Per-row actions
Every row has a small ellipsis (⋯) dropdown with two actions:
- **View Details** — opens a flyout with the member's full information (see below).
- **Activity** — opens a timeline flyout showing every tracked activity for this member (signups, payments, cancellations, bot interactions).
**That's it for built-in row actions.** There's no "Send magic login link",
"Manually add member", "Edit", or "Remove" on the Members page. Member
lifecycle happens through the subscription flow (members subscribe via your
bot or portal) and through the
[Subscriptions](/creators/managing-subscriptions) page when you need to
take action on their subscription.
## View Details flyout
Clicking **View Details** opens a right-side flyout showing:
- **User ID** — Subscriby's internal identifier.
- **User Details** — avatar, display name, email (or _"No Email"_ if the member never provided one).
- **Project** — which project, its icon, and the connector it runs on.
- **Status** — the same status badge, in its full coloured form.
- **Connected accounts** — the member's accounts on each connector, with the platform's own id.
- **Referred By** — if the member was brought in by another member via referral, the original referrer's name and avatar.
- **Joined Time** — the first-seen date, formatted in your account timezone.
- **Subscriptions & Passes** — a card per subscription: plan name and price, payment status, when it started, when it renews or ends, the trial end date if there is one, the access code it was redeemed with, and the payment gateway's own reference.
It's a "see everything about this member at a glance" view, not an edit form. The one action it offers is on a pass — see below.
### Pass cards carry the queue state
A subscription bought as a [time-limited pass](/creators/time-limited-passes) is marked with a purple **Pass** badge and carries four extra fields:
- **Pass window** — the exact window they bought, in your account timezone.
- **Window status** — **Scheduled**, **Open**, **Closed** or **Canceled**.
- **Queue status** — **Join request not sent**, **In the queue**, or **Admitted**. This is the one that matters: a queued holder is admitted automatically when the window opens, one who never tapped their link has to be at their phone on the day.
- **Last reminded** — how long ago they were last nudged about it, or **Never**. Hover for the exact time.
When the queue status is **Join request not sent** and the window can still be entered, a **Send Reminder** button appears under them. It sends that one customer their invite link with a note saying you sent it by hand. Everything about the message, and the window-wide version of the same thing, is in [Time-Limited Passes](/creators/time-limited-passes#nudging-one-customer).
All four are read live rather than from a cached copy of the member, so a customer who joins the queue while the flyout is open shows correctly the next time you open it.
## Activity timeline
Clicking **Activity** opens a separate flyout with a chronological stream of events for this member — every signup, renewal, payment, cancellation, and notable bot interaction Subscriby has recorded. Useful for:
- **Diagnosing a support ticket** — _"What did they actually do before they say they were charged?"_
- **Verifying automation** — _"Did the bot really send the onboarding message?"_
- **Audit** — when a payment dispute or chargeback lands, the timeline is your first stop.
## Common workflows
Open the list. Sort by name or joined time. Use your browser's in-page search (Ctrl/Cmd + F) on the visible rows, or page through the list. For global search by name or email, use your browser — the current view doesn't expose a free-text search box.
Open the **Churned** filter tab. Sort by **Joined Time** descending to see
when each person first arrived. For per-subscription detail (e.g. *why* they
left), follow up on the [Managing
subscriptions](/creators/managing-subscriptions) page.
Open the **Trial** filter tab. Cross-reference with the [Subscriptions
page](/creators/managing-subscriptions) — filter it to the same members
and sort by *Trial ends at* ascending. The trial rows closest to expiry are
your conversion priorities.
Open **All users**, sort by Status, find the rows with the black **Banned** badge. Open **Activity** to see what triggered the ban. Bans typically come from a ban inside one of your places or from your own enforcement actions.
## Frequently asked
The creator plans don't cap total Leads, Trialing, Customers, Churned, or Banned records. The one quota that *does* exist — commonly referred to as "users limit" inside the billing settings — applies **only to lifetime-membership subscribers** (customers who purchased a non-recurring plan with lifetime validity). Short-term recurring subscribers, trialists, and all other statuses don't count against it.
The lifetime-membership caps by plan are:
- **Free:** 5,000 lifetime memberships
- **Starter monthly:** 20,000 (annual: 24,000)
- **Growth:** unlimited
Even then, the limit is only applied to **new** lifetime subscribers joining after you connect each resource — older records are grandfathered. See [plan footnotes on billing](/creators/subscription) for the exact rule.
Every person who has interacted with your project — Leads (said hi to the
bot), Trialing, Customers, Churned, and Banned. This is an audit / operations
view, not a billing cap. A Lead interacting with your bot doesn't cost you
anything or consume a plan quota.
Not from this page. The intended path is: members subscribe themselves via
your bot or portal, or you gift them access using an [access
code](/creators/codes) on a `$0` or access-code-only plan.
Not directly. Profile data (name, email, connected accounts) comes from their
own interactions with the bot or the subscriber portal. You can't overwrite their
display name.
- **Lead → Trialing** — when they start a trial subscription.
- **Lead / Trialing → Customer** — on successful paid signup.
- **Customer / Trialing → Churned** — on subscription expiry, cancellation, or payment-failure lapse.
- **Any → Banned** — from a ban or equivalent enforcement action inside a linked place.
Transitions are driven by the subscription lifecycle and bot commands, not by a manual "change status" button on this page.
Yes — each member record is per-project. The same real-world person can be a Customer on Project A, a Lead on Project B, and Churned on Project C. Each record is independent and tracks its own status.
## Related
- [Managing subscriptions](/creators/managing-subscriptions) — for the subscription lifecycle that drives member statuses.
- [Subscription plans → targeting](/creators/plans#access-restrictions-optional) — target Newcomers / Customers / Churned at the plan level.
- [Dashboard analytics](/creators/dashboard-analytics) — aggregate counts across all statuses.
---
# Managing Subscriptions
Source: https://docs.subscriby.net/creators/managing-subscriptions
By the end of this page, you'll know where to find every subscription in your project, how to read its status, and which actions are available.
A **subscription** is the record created when a member signs up to one of your plans. It tracks which plan was chosen, which payment method was used, the current payment status, the amount paid, and which subscriber it belongs to.
**Navigating to Subscriptions.** Select a project first (top-left picker),
then open **Subscriptions** from the sidebar. The Subscriptions page is
separate from [Members](/creators/managing-members) — one member can
have multiple subscription records over time (original trial, upgrade,
re-subscribe after churn).
## Filters
The Subscriptions page has URL-routed filter tabs at the top:
Every subscription, regardless of status — active, expired, pending,
cancelled.
Only subscriptions with status **Active** — currently granting paid access.
Subscriptions that have ended — typically after cancellation reached
cycle-end, or after a one-time plan's duration lapsed.
Non-recurring subscriptions: lifetime plans, access-code-activated plans, and
any plan with `Recurring = off`.
An additional **Pending** filter exists on the global _All Project Subscriptions_ route — it surfaces subscriptions whose payments are still being confirmed by the gateway.
### Dropdown filters
Below the tabs, a row of dropdown filters narrows the list further. These appear on both a single project's Subscriptions page and the global _All Project Subscriptions_ view:
- **Filter by Plan** — limit to subscriptions on a specific plan.
- **Filter by Payment Method** — limit to a specific configured provider and mode (Live / Test).
- **Filter by Status** — limit to one of the 13 statuses below.
- **Per page** — choose 15, 25, 50, or 100 rows per page.
On the global _All Project Subscriptions_ view, an extra **Filter by Project** selector appears first. Pick a project to scope the list to it; doing so also populates the **Plan** and **Payment Method** dropdowns with that project's options. **Reset Filters** clears every dropdown back to its default.
## The 13 subscription statuses
Subscriby tracks subscription state using a thirteen-option enum. Each status has its own badge colour so you can scan the list quickly:
| Status | Colour | Meaning |
| -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------- |
| **Active** | green | Currently running and paid up. |
| **Trialing** | yellow | Inside a trial period of the plan. |
| **Pending** | blue | Payment submitted, awaiting confirmation. |
| **Processing** | blue | Gateway is processing the payment. |
| **Capturable** | blue | Payment is authorized but not yet captured. |
| **Succeeded** | green | Payment completed successfully. |
| **Incomplete** | orange | Payment hasn't completed in time — manual intervention may be needed. |
| **Past Due** | red | Renewal payment failed; retry window open. |
| **Unpaid** | red | Payment failed and retries exhausted. |
| **Failed** | red | Payment outright failed. |
| **Expired** | red | Billing period ended and access has stopped. |
| **Canceled** | zinc | Subscriber or creator cancelled. Access usually continues until the current period ends, then the subscription becomes Expired. |
| **Paused** | yellow | Temporarily paused by you or the payment provider. |
See [Status reference](/reference/status-codes) for how these map to member-visible states like "your subscription is paused" or "payment failed".
## List columns
Each row on the Subscriptions page shows:
| Column | Content |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Plan** | The plan's name, with the bot avatar and the project name beneath. |
| **Payment Method** | Provider (Stripe, PayPal, etc.) and its mode (Live / Test), plus the gateway's transaction ID. |
| **Status** | Colour-coded status badge (one of the 13 above). |
| **Amount** | Price paid, formatted in the subscription's currency. |
| **Subscriber** | The member's name and connected-account id. If the subscription hasn't been claimed by a user yet — e.g. an unredeemed access-code-activated record — this reads _"Unclaimed Subscription"_. |
Click a column header to sort by that field (ascending/descending toggle).
### Time-limited passes in the list
A [pass](/creators/time-limited-passes) is a subscription record like any other, so it
appears in this list — but two columns carry extra information, because the payment status
alone describes a pass poorly. A pass reads **Active** from the moment it is paid for, which
says nothing about whether its window has run.
- **Plan** adds the **access window** the customer bought, as a time range in the plan's
timezone. A purchase not yet bound to a window reads _"Awaiting window assignment"_.
- **Status** stacks a second badge for the pass's own state: **Awaiting Window** before it
opens, **Access Granted** while it runs, **Access Ended** once it has closed, and **Never
Joined** for a window that passed without the holder being admitted.
- **Status** adds a third badge while a pass is still awaiting its window: **Queue Joined** or
**Not Queued**. That is the one thing on the row you can act on — a queued holder is admitted
automatically at the start time, an unqueued one has to be at their phone.
### Ordinary memberships and one-time purchases in the list
A recurring membership or a one-time purchase hands the member one invite per gated place the
moment they pay, so the only thing left to see is whether they used it. While the purchase is
active, **Status** adds **Joined** (every invite used), **Not Joined** (none yet) or **1 of 3
joined** when the places disagree. A plan that only hands out perks shows nothing here: there is
nowhere to join. The **View Details** flyout lists every place under **Places** with its kind,
the moment the member joined and the same badge, so you can see exactly which channel or group
a paying member still has not entered.
### Pass series in the list
A [series](/creators/pass-series) is one row covering many dates, so it is read
differently again. Its **Status** column carries how far through the slate the holder is —
_"6 passes left of 10"_ — plus **N missed** once dates start going by without them, in place of
the single-window badges above.
Those badges would describe whichever date happens to be next and read as the state of the
whole purchase, which on a thirty-date season is wrong about twenty-nine of them.
**View Details** splits the same way: a **Season** block with the span, progress, timezone and
attended / missed counts, then a **Next pass** block naming that date and whether its queue has
been joined.
### Badges that override the pass state
Three situations mean the window no longer matters, and they replace the state badge rather
than sit beside it:
| Badge | What it means |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Cancelled** | The subscription was cancelled. On a pass or a series it stands alone, with no lifecycle badge beside it: the holder will **not** be admitted, so Active would promise access that cannot arrive. On an ordinary membership whose paid period still runs the badge reads **Renewal Cancelled** in amber beside Active, because access really does run to the end of that period; it turns red **Cancelled** once the period has ended. |
| **Will Not Open** | The pass ended before its window ran — refunded, expired, or otherwise closed out. It cannot open, whatever the countdown once said. |
| **Refunded** | Shown in place of _Expired_ when a refund is what ended it. The lifecycle state is genuinely expired; this names the reason, which lives on the payment rows. |
In the **View Details** flyout the **Active** row answers one question: does this subscription grant access right now, meaning paid or trialing and inside its period? A cancelled renewal still reads **Yes** until its end date, and the flyout says until when. The subscriber block shows the address the member can be reached at, marked **Billing email** when it came from the payment provider rather than from the member's own account.
The **View Details** flyout shows the same window and state, plus a flag when the holder has
not tapped their invite link yet — see [Customers who haven't joined the
queue](/creators/time-limited-passes#customers-who-havent-joined-the-queue-are-chased).
It replaces _Subscription Ends At_ with the window, because on a pass that timestamp is the
close of a bought window rather than the end of a paid period.
Cancelling a subscription from this screen removes access and stops billing,
but sends the customer **no message**. On a pass that is worth knowing: they
keep a working-looking invite link and only discover the change when they are
not admitted. Until that changes, tell them yourself — or issue a refund
instead, which does message them.
## Per-row actions
Each row has an ellipsis (⋯) dropdown with these actions:
- **View Details** — opens a flyout with the subscription's full metadata.
- **View Payments** — opens a separate flyout listing every individual payment attached to this subscription, with its own pagination (10 per page).
- **Cancel** — opens the cancellation confirmation modal. Only available for **Active** or **Trialing** subscriptions that aren't already cancelled.
- **Activity** — opens the timeline view showing every tracked event for this subscription.
- **Suspend Access** — removes the member from this project's linked resources without cancelling. Shown for **Active** and **Trialing** subscriptions.
- **Restore Access** — puts a suspended member back, with fresh invite links. Shown only for **Paused** subscriptions.
- **Reactivate** — calls off a scheduled cancellation. Shown only for cancelled subscriptions paid through **Stripe**.
## Cancelling a subscription
### Find a cancellable subscription [step]
Find the subscription in the list. If it's not **Active** or **Trialing**, cancellation isn't applicable (you'll see an error toast if you try).
### Choose Cancel [step]
Click the ellipsis dropdown and choose **Cancel**.
### Confirm in the modal [step]
A confirmation modal explains the impact. Confirm.
### Subscriby runs CancelSubscriptionJob [step]
Subscriby queues a **CancelSubscriptionJob** — a background job that:
- Marks the subscription as cancelled.
- Stops future automatic renewals (for recurring plans).
- Lets current paid access continue until the cycle ends, then flips the record to **Expired** and removes the member from the places.
You'll see a toast: _"Subscription cancellation queued. The subscription cancellation has been queued and will be processed shortly."_
**Cancellation doesn't automatically issue a refund.** Refunds are handled
through your payment provider's own dashboard (Stripe, PayPal, Razorpay,
etc.). Decide your refund policy up front and communicate it to subscribers.
## Suspending access
**Suspend Access** removes a member from every linked place on this project and marks the subscription **Paused**. **Restore Access** puts them back with newly generated invite links — the old ones were revoked and cannot be reinstated.
**Suspending does not stop billing.** The member keeps being charged by
their payment provider on the normal schedule while they have no access. If
you leave someone suspended for a month, they pay for that month and get
nothing — so tell them, or cancel instead.
Subscriby settles through seven payment providers and only some can hold a
recurring charge at all. A suspension that meant "stop billing" would work
on some of your members and silently not on others, which is worse than a
rule that is the same for everyone. Access is the part Subscriby controls
directly, so it behaves identically no matter how the member pays.
Use it for a moderation timeout, a dispute you are investigating, or a member who asked for a short break and agreed to keep paying. For anything longer, cancelling is the honest option.
## Reactivating a cancelled subscription
**Reactivate** calls off a cancellation that has not yet taken effect, putting the subscription back on its original billing schedule — the win-back path when someone changes their mind.
**Stripe only.** On every other provider, cancelling ends the agreement with
the provider outright, so there is nothing left to resume and the member has
to subscribe again. The action is hidden for those subscriptions rather than
shown and then refused.
A subscription whose period has already expired cannot be reactivated on any provider.
## The View Payments flyout
Each subscription can have multiple payments — the first checkout, any subsequent renewals, and any adjustments. **View Payments** opens a paginated list of those, including:
- Payment method used
- Amount and currency
- Payment status (from the same 13-status enum)
- Gateway-side identifiers for looking up the transaction in Stripe/PayPal/etc.
Use it when a subscriber asks _"was my last renewal charged?"_ or when reconciling with your gateway dashboard.
## Refunds
Subscriby doesn't process refunds directly — you issue them in your provider's dashboard, and
Subscriby reacts to the webhook.
**A full refund revokes access.** When the provider reports the whole payment back, Subscriby
removes the member from every place on that plan, marks the subscription
**Refunded**, records a refund row in the payment history, and emits `subscription.refunded`
and `payment.refunded` to your webhook subscribers. For a
[time-limited pass](/creators/time-limited-passes) whose window hasn't run yet, that
means the holder is **not admitted when it opens** — the window sweep only admits subscriptions
that are still live.
**A partial refund leaves access alone.** Sending a few pounds back doesn't evict a paying
member, so the subscription continues untouched and only the payment history records it.
**The customer is told.** A refunded buyer receives a message naming the plan, the amount
refunded, and — on a pass — the access window they no longer hold, plus a note that refunds
usually reach their payment method within 5–10 business days. They are explicitly told their
invite link no longer works, so a refunded pass holder doesn't sit waiting for a window that
will never open for them.
A refunded **series** holder gets the same message written for a season: it names the plan and
the amount, and says every remaining pass has been withdrawn rather than naming a single date.
One date out of thirty would be the wrong thing to tell someone whose whole season just ended.
Stripe's enabled-event list is configured **per endpoint and per mode**, so a refund in live
mode produces nothing if only the test endpoint subscribes to `charge.refunded` — and vice
versa. Nothing in Subscriby can detect the difference between "no refund happened" and "we
were never told", so a refund can appear to be ignored entirely while the money is already
back with the customer.
If a refund isn't reflected within a minute or two, check the endpoint's enabled events before
anything else.
- **Crypto providers** — refunds must be coordinated manually.
- **Access codes** — no refund applicable (no money changed hands). Cancel the subscription to revoke access.
## Finding subscriptions activated via access codes
Subscriptions created by redeeming an [access code](/creators/codes) are listed like any other — the only giveaway is the provider column: they'll show _"Access Codes"_ instead of Stripe/PayPal/etc. The gateway transaction ID column will typically be empty for these.
Open the detail flyout to see the original access code string that activated the subscription — useful for tracing which campaign or influencer batch the signup came from.
## Filter recipes
Filter by **Active** → sort by the subscription's ends-at field (if available as a sort column) or by status.
On the **All** tab, sort by **Status** and look for red badges — Failed, Past
Due, Unpaid, Expired.
Switch to the **One-time** tab, sum the Amount column (or export if
available). That's your non-recurring cohort.
Open the **Pending** tab on the global route, find the row, open **View Payments** — you'll see exactly which gateway event is still awaited.
## Frequently asked
Not as a one-click action on this page. The cleanest workaround is to generate an [access code](/creators/codes) on a matching plan (or a $0 plan) and apply it manually. For unusual scenarios, contact support.
There's no "move to plan X" button. The clean path: cancel the current
subscription, then enrol the subscriber in the new plan (via a gifted access
code or a normal signup flow). Mid-cycle plan swaps have edge cases per
provider that are better avoided.
Some providers confirm slowly — crypto needs blockchain confirmations, bank
transfers take their time. If status persists beyond the provider's normal
confirmation window, check your gateway dashboard — there may be a fraud hold
or failed webhook to fix.
Most common reasons: **payment failed and retries exhausted** (card expired,
funds insufficient), **one-time plan reached its duration**,
**subscription-provider webhook** informed us the subscription ended.
No direct re-activation. The subscriber (or you, on their behalf) signs them up again — it becomes a new subscription record, linked to the same member.
## Related
- [Managing members](/creators/managing-members) — the subscribers behind the subscriptions.
- [Subscription plans](/creators/plans) — the plans these subscriptions are instances of.
- [Status reference](/reference/status-codes) — every status explained in plain language.
- [Dashboard analytics](/creators/dashboard-analytics) — aggregated revenue and retention metrics.
---
# Payment Methods
Source: https://docs.subscriby.net/creators/methods
By the end of this page, you'll have added at least one payment method to your project so it can start accepting subscriptions. Each method has its own setup guide linked below.
A **payment method** is a gateway connection — Stripe, PayPal, Razorpay, etc. — that processes subscriber payments on your behalf. Subscriby supports **nine** payment options, and you can enable as many as you like per project. Subscribers see the ones you've enabled and pick the one that suits them.
**Navigating to Payment Methods.** Select a project first (top-left project
picker or **Select as Current** on the All Projects page), then open **Payment
Methods** from the sidebar.
## Add a new payment method
### Open Setup a Payment Method [step]
Click **Setup a Payment Method** at the top-right of the Payment Methods page.
### Pick a provider and mode [step]
**Pick a provider** from the grid (Stripe, PayPal, etc.) and **select a mode**:
- **Live** — production, real money flowing.
- **Test** / **Sandbox** — testing, no real money.
Some providers don't have a Test mode — native platform payments and
**Access Codes** are Live-only.
### Enter the provider's configuration [step]
Enter the provider's API keys, secret keys, merchant IDs, depending on the provider. Keys must match the mode: live keys (e.g. `sk_live_…`) for Live mode, test keys (e.g. `sk_test_…`) for Test mode. Subscriby validates the prefixes for most providers.
The exact fields for each provider are documented in the per-provider setup guides below.
### Mark the method Active [step]
Toggle **Mark Payment Method as Active** to make it immediately available to your subscribers. Leave it off while you're still validating the connection.
### Save changes [step]
Click **Save Changes** (or **Connect** for Stripe, which redirects through Stripe's own onboarding flow).
## Provider setup guides
Each provider has its own config fields and instructions. Click through for the step-by-step:
42 countries, direct charges, 150+ currencies. Handled via Stripe Connect — Subscriby automates key exchange so you won't paste any keys yourself.
200+ countries, 24 currencies, supports recurring subscriptions. Requires a
**Client ID** and **Secret** from the PayPal Developer portal.
131 countries, e-wallet and card payments, one-time charges only. Requires
your Skrill merchant **email** and a **secret word**.
Bitcoin and USD stablecoins. Needs a **Client ID**, **Client Secret**, and an
**API domain** (a-api or b-api).
Seven African countries — Nigeria, Ghana, South Africa, Kenya, and more.
Requires an **API key** (`pk_live_…` / `pk_test_…`) and a **secret**
(`sk_live_…` / `sk_test_…`).
India-exclusive, 80+ currencies, supports recurring. Needs a **Key ID**
(`rzp_live_…` / `rzp_test_…`) and **Key Secret**.
Sri Lanka-exclusive crypto gateway (USDT variants). Requires **API key**
(`ak_live_…` / `ak_test_…`) and **Secret key** (`sk_live_…` / `sk_test_…`).
The in-app payment a connector brings, priced in its platform's own currency.
**Live only**, one-time only — no recurring subscriptions.
Single-use codes you generate and hand out — for free giveaways or manual sales. No external gateway needed. For one string many people redeem for money off, see coupon codes instead.
## Sync subscription plans (for providers with product catalogues)
Some providers keep their own product / plan catalogue, and Subscriby needs to push your plans to that catalogue before subscribers can transact through them. This applies to **Stripe**, **PayPal**, **CoinPayments**, and **Razorpay**. The other providers don't need this step.
Subscriby does this push on its own whenever a plan that is on sale is created, saved or published, so in day-to-day use you never run it by hand. Only plans that are **on sale** are pushed: a draft is pushed the moment you publish it. The manual sync below re-pushes every plan at once, which is what you want after linking a new provider, after reconnecting one, or whenever the bot or the portal says a plan is not available for a provider.
### Confirm plans are configured [step]
Make sure your [subscription plans](/creators/plans) are created and configured.
### Locate the provider's row [step]
On the **Payment Methods** page, find the provider's row.
### Choose Sync Subscription Plans [step]
Click the **options menu** (three-dots icon) and choose **Sync Subscription Plans**.
### Wait for the sync job [step]
Subscriby queues a sync job. Behind the scenes, it creates matching products and pricing entries inside the provider's dashboard — Stripe products and prices, PayPal plans, Razorpay plans, and so on.
### Check the provider's dashboard [step]
When the sync completes, plans become available through that provider, and each one shows up in the provider's own dashboard as a product with a price. Plans you add, edit or publish afterwards are pushed automatically; re-run the sync only when a plan is missing on the provider's side.
**Recurring vs. one-time support.** Separate from sync, providers differ in whether they support **automatic recurring billing** for subscriptions. The ones that do: **Stripe, PayPal, Paystack, Razorpay**. The ones that don't — only one-time charges: **Skrill, CoinPayments, CeyPay, native platform payments, Access Codes**.
**Validation rule to be aware of:** Subscriby blocks you from saving a plan with **Recurring = on** if the plan's **currency** is crypto (BTC, USDT, USDC, and so on) or a platform's native currency. For fiat currencies, the form doesn't block the save — so you could configure a recurring plan in USD with Skrill attached, but there'd be no actual auto-renewal at runtime because Skrill doesn't have a recurring mechanism. Always match your plan's recurring setting to what the payment methods you intend to accept can actually deliver.
## Choosing providers wisely
Rather than enabling every provider on day one, pick based on your audience.
### Global audience
**Stripe** for the broadest fiat reach (138+ currencies, cards, wallets, Apple Pay, Google Pay). Pair with **PayPal** if part of your audience prefers not entering card numbers.
### Regional providers
- **India-only** → **Razorpay** (UPI, netbanking, Indian cards, and wallets locals expect).
- **Africa (Nigeria, Ghana, Kenya, South Africa)** → **Paystack** (local cards, mobile money, USSD, bank transfers).
### Crypto-first audience
**CoinPayments** globally (BTC, ETH, stablecoins on many chains) or **CeyPay** for Sri Lankan-settled USDT.
### Platform-native
A connector may bring its platform's own in-app payment — instant, no redirect, store-compliant, one-time charges only, and only reachable through the bot (not the portal). It is not a gateway Subscriby integrates: the option, its explainer and the checkout card come from the connector, and a project whose connector declares no native payment never sees it. See [Native payment methods](/payments/native-payments).
### Offline sales, gifting, influencer giveaways
**Access Codes** — no gateway at all, you generate codes and hand them out.
See [Choosing a payment provider](/payments/choosing-a-provider) for a decision tree walkthrough.
## Editing or removing a payment method
### Edit
Update keys, rename, or toggle active. Click the method's row on the Payment Methods page, adjust the fields, save.
### Deactivate
Flip **Active** off. Existing subscribers on this method keep their renewals; new signups won't see it on the checkout list.
### Delete
Permanent. Existing subscriptions tied to this method are left without a gateway to bill from, and their next renewal will fail. Only delete methods that were mistakes or are truly retired.
Changing a provider's keys mid-flight can interrupt renewals for subscribers
signed up under the old keys. When rotating credentials, coordinate closely
with the provider's documentation — some providers keep legacy product IDs
valid across key changes, others require a re-sync.
## Frequently asked
**Yes — add it in Test / Sandbox mode first.**
1. Add the method in **Test** mode.
2. Create a test plan and link it to that method.
3. Go through a subscribe flow as a subscriber would.
4. Once you're happy, add the same provider in **Live** mode separately.
Test and Live can coexist on the same project.
**Up to two per project: one Live + one Test.**
- ✅ One Live
- ✅ One Test / Sandbox
- ❌ Two Live — not allowed
Running Live and Test side-by-side is normal — keeps QA separate from production. Need two separate Live accounts (e.g. different revenue streams)? Put them in two different projects.
Double-check:
- The **mode** matches the keys (live keys for Live mode, test keys for Test mode).
- You haven't accidentally swapped API key and secret key.
- The keys have the permissions Subscriby needs (read/write on subscriptions and customers, typically).
See the individual provider guides for exact key requirements.
Sort of — Subscriby shows prices in the currency you set on the plan, but the subscriber's payment provider often converts from their local currency to that settlement currency. See [Currency conversion](/payments/currency-conversion).
## Related
- [Choosing a payment provider](/payments/choosing-a-provider) — decision guide.
- [Subscription plans](/creators/plans) — where payment methods and plans come together.
- [Transaction fees](/fees) — how Subscriby charges on top of gateway fees.
---
# Migrating From Another Tool
Source: https://docs.subscriby.net/creators/migrating-from-another-tool
By the end of this page, you'll have moved a paid community from another membership tool to Subscriby without a single member paying twice or losing a day of access.
## What no tool can move
A subscription is a billing agreement between your member and a payment gateway, made under the old tool's account. Subscriby cannot take that agreement over, charge that card next month or know when it ends, and neither can any other tool. What moves is the community itself: the place your members are in, the plans they buy, and each member's remaining paid time, which you cover with an access code so nobody pays for the same period twice.
The method below runs both tools side by side for one billing cycle. Members keep their access throughout, the old subscriptions end on their own, and the new bot takes over the renewals from there.
## Before you start
- **Every member's end date.** Export from the old tool whatever it gives you: each member, their plan, what they paid and when their current period ends. The end dates are the migration plan.
- **Your own gateway accounts.** Subscriby connects to your Stripe, PayPal, Razorpay or Paystack account (see [Payment methods](/creators/methods)); the money keeps landing where it always did.
- **A Subscriby project with a connected connector.** The [quickstart](/quickstart) takes about ten minutes.
- **The same place.** The new bot joins your existing channel, group or server as a second administrator beside the old one. Nobody re-joins anything and the history stays.
Match your prices. A member who sees a different price on the new bot assumes
the move is a price rise. Recreate the tiers exactly and change prices later
if you must.
## Move the community
### Recreate your plans [step]
Create the plans members buy today at the same prices and cycles, each linked to the resources it unlocks. [Subscription plans](/creators/plans) walks through every option.
### Create the bridge plan [step]
The bridge is an ordinary plan nobody can buy, only redeem:
- **Billing cycle:** Days, with a count that covers the longest remaining paid period among your members (30 for a monthly community).
- **Recurring:** off, so it is a one-time purchase that simply ends.
- **Access codes only:** on, so it never appears on the portal or in the bot's plan list.
- **Resources:** the same places as the paid plans.
- **Price:** the price of the plan it stands in for. It is never charged to the member; it is what the per-code fee is calculated from once you pass your free quota (see below).
Yearly members need a longer bridge: a second bridge plan with a longer count, or one long bridge for everyone if the extra days do not matter to you.
### Generate one code per paying member [step]
On the bridge plan, [generate access codes](/creators/codes): one per paying member, with a short expiry (a week is plenty) so unsent codes clean themselves up. The codes arrive with a **Join Link** each, which opens your bot with the code already applied.
### Tell your members [step]
Send each member one message with their Join Link that says what is happening: a new bot, the same place, tap to keep your access, and your next payment goes through the new bot when your current period ends. A member who taps the link is in; nothing changes for them until their paid period ends.
### Stop new sales on the old tool [step]
The day the new bot goes live, close the old tool's checkout so nothing you sell today has to be moved tomorrow. The existing subscriptions run out on their own.
### Let the reminders do the chasing [step]
A subscription activated by a code does not renew, so the bot sends its [renewal reminders](/subscribers/manage-subscription#renewal--expiry-reminders) at 7, 3, 2 and 1 days before the bridge ends, asking the member to renew; they buy the real plan from the new bot and their card subscription starts there. A member who never redeemed is still covered by the old tool until their period ends, and can buy from the new bot at any time.
### Retire the old bot [step]
When the last old subscription has ended, remove the old bot from the place and deactivate the bridge plan. From here the new bot admits, renews and removes members on its own.
## What the codes cost
A code counts against your quota when it is redeemed, and each code inside the quota is a sale Subscriby waives its commission on: 5 per cycle on Free, 25 on Starter (250 when billed annually), 150 on Growth (1,500 when billed annually). Redemptions beyond the quota carry the per-code fee shown in the generation modal, calculated from the bridge plan's price. Codes that expire unredeemed never count. [Quotas and fees](/creators/codes#quotas-and-fees) has the detail.
## Frequently asked
No. The bridge covers the rest of the period they already paid for, without
a charge. They pay the new bot only when that period would have renewed
anyway.
No. Add the new bot as a second administrator of the existing one; the
history, members and pinned posts stay where they are. Two bots as
administrators for a few weeks is normal.
Yes. They are your accounts, not the old tool's; connect the same ones on
the [Payment methods](/creators/methods) page and payouts continue as
before.
One full cycle of your longest plan. Monthly communities need a month; give
yearly members a longer bridge and remove the old bot when the last old
subscription ends.
Not yet. This page is the assistance, and
[support](/reference/troubleshooting#when-to-contact-support) answers
questions along the way.
## Related
- [Generate access codes](/creators/codes) — the one-code-per-member step, quotas and expiry.
- [Subscription plans](/creators/plans) — the access-codes-only restriction and the renewal toggle.
- [Payment methods](/creators/methods) — connecting your own gateway accounts.
- [Switching guides on subscriby.net](https://www.subscriby.net/switch) — one guide per tool you might be leaving, with what its quirks mean for the move.
---
# Pass Series
Source: https://docs.subscriby.net/creators/pass-series
A [time-limited pass](./time-limited-passes) sells one dated window. A **Pass Series** sells
many of them at once: you assemble a slate from the passes your other plans already produce,
put a single price on it, and the buyer gets every one of them from that one payment.
It is a season ticket. Ten match days, an eight-week course, a Q1 cohort — bought once,
attended over weeks, with each date opening and closing on its own exactly as it would if it
had been bought individually.
Pass Series is unlocked by the same capability as passes. It is included in
**Growth**; on **Free** and **Starter** you can unlock it with the **Passes
Addon**, billed on your plan's cycle — **$19 USD per month**, or **$190 USD per
year** on an annual Starter plan.
If you already sell passes, you already have this. There is **no extra transaction
fee** on a series sale — it is billed at your usual rate, exactly like any other
plan.
## The three kinds of plan
Every plan you create now starts with one choice, and it is the choice that decides which
half of the form you see next.
| Kind | What it sells | Renews |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- |
| **Recurring Subscription** | The traditional subscription. Nothing is scheduled — members pay each cycle and keep access for as long as they keep paying. | Yes, on a cycle |
| **Time-Limited Pass** | A ticket to one dated session — workshops, match days, one-off events. Access opens and closes on its own and never renews. | No |
| **Pass Series** | A season ticket over many passes — seasons, courses, cohorts. One payment covers every pass you bundle from your pass plans. | No |
The distinction that matters between the last two: a **pass plan owns its own dates** and
generates them from a schedule. A **series owns none** — it points at dates that already
exist on your pass plans. That is why you must have at least one pass plan with upcoming
windows before a series has anything to sell.
Everything a series holds belongs to a pass plan. Removing a pass from a series
does not cancel it — it stays on its own plan, on sale to individual buyers.
Cancelling it on its own plan removes it from every series that holds it.
This is worth internalising before you build one, because it is the rule behind
almost every behaviour further down this page.
## When to use one
A series is right when the same audience should attend a known set of dates and you want to
sell them together rather than one at a time:
- A club selling a **season ticket** across a fixture list
- A tutor selling an **eight-week course** rather than eight lessons
- An analyst running a **quarterly cohort** with a fixed calendar
- A festival selling a **weekend wristband** across several sessions
Use a plain **Time-Limited Pass** when each date genuinely stands alone and buyers pick and
choose. Use a **Recurring Subscription** when nothing is scheduled at all.
## Creating one
### Choose Pass Series [step]
Open **Create New Subscription Plan** and pick **Pass Series** from the three cards at the
top. Fill in the name and description as usual.
Switching the card at any point discards the shape you moved away from — a series you had
half-composed is not saved if you save the plan as a subscription, and pass schedule slots
are not saved if you switch to a series. Only the shape the plan is saved as is persisted.
### Link the season lounge — optional here, and it means something different [step]
Pick **Pass Series** under **Plan Type** first and the field below it renames itself to
**Season Lounge Resources**, because on a series it is not the thing being sold.
On every other kind of plan at least one linked resource is required, because otherwise a
purchase buys nothing. **A series is the exception and may link none.**
What each date grants comes from **the pass plan that date belongs to**, not from the series.
So a Thursday drawn from your _Weeknight Room_ plan admits the buyer to that room's channels,
and a Sunday drawn from _Match Day_ admits them to that one — automatically, without you
mapping anything.
Anything you _do_ link on the series itself is a **lounge**: a channel the holder keeps for
the whole span, between dates as well as during them. A season-ticket-holders' chat is the
usual case. Leave it empty if you do not want one.
Its invite link arrives **with the purchase confirmation**, not on the 24-hour schedule the
dated rooms follow — there is nothing to wait for, because the lounge is not tied to any one
date. It survives every date opening and closing, and is only taken away when the last pass
in the season closes.
This is the one mistake here that costs you money, and nothing about it looks wrong on the
screen. A lounge is granted **at purchase and kept all season**. So if you link a channel one
of your slate passes was scheduled to open, every holder is handed it permanently the moment
they pay — that pass's date stops gating anything, and your season ticket becomes a permanent
key to the channel.
A creator who linked their four match-night channels here had their first buyer receive four
invite links at checkout, for four rooms meant to open one night each.
**What belongs here:** a holders-only discussion room nothing on the slate opens — or nothing
at all.
**What does not:** any channel a slate pass opens. Those are already granted by their own pass
plans, on their own dates, without you mapping anything.
If the two overlap, the field turns red and names the offending channels. Take them out of
this field; you are not removing them from the season, only from the lounge.
### Set the price [step]
**Plan Pricing** carries two controls.
| Control | What it does |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Currency** | Any currency an active payment method on the project supports. The badge beside each option tells you which methods accept it. |
| **Charge** | What the **whole series** costs. The suffix reads **once for the whole series** for exactly this reason — it is not per pass, and there is no cycle, trial or renewal here. |
The buyer pays this figure once and receives every pass on the slate, however many there are.
Adding a date later does not charge anyone again.
Both sections disappear on a series. Access comes from the dates, not from a
period, so there is nothing to renew and nothing to trial. If you need a
trial, you want a recurring subscription.
### Compose the slate [step]
**Series Passes** is the heart of it. The summary here shows what the series currently holds
— a count and a date span — and two controls:
| Control | What it does |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Prevent Overlapping Passes** | Refuses to save a slate holding two passes that run at the same time. Turn it **off** when a customer is genuinely meant to be able to attend both at once. On by default. |
| **Pick Passes** / **Manage Passes** | Opens the **Pass Series Passes Picker**, where the slate is actually built. The button reads _Pick Passes_ while the series is empty and _Manage Passes_ after. |
Everything about the picker is in [its own section below](#the-pass-series-passes-picker) —
it is the most involved screen in the product and deserves the room.
Until you open it, the summary says **No passes in this series yet. Open the picker to choose
them.** A series needs at least two passes before it can be saved — but **a rule counts towards that**, so a season composed entirely by rules with nothing ticked by hand is perfectly valid.
### Set a seat limit, if you want one [step]
**Seat Limit** caps how many people may hold the series at once. Leave it empty for
**Unlimited**.
The cap counts holders who actually **hold** the series — a purchase that was refused, or one
whose payment never completed, does not consume a seat. On the portal the card shows
**N seats left**, and **Sold out** once the cap is reached. The moment the last seat is taken, the plan is also switched off automatically and shows **Seats Full** in your plan list, so the pause is never mistaken for someone on the team unpublishing it; raise the seat cap before switching it back on.
_"A seat cap of zero would put the series on sale with nothing to sell."_
Leave the field empty to mean unlimited; zero is not the way to take something
off sale. Deactivate the plan for that.
### Decide when sales close [step]
**Late Entry** is a pair of controls that work like the sales cutoff on a pass plan, but
measured against the **whole season** rather than one window. There are four anchors, and they
form a progression from the earliest deadline to the latest.
| Anchor | The season comes off sale… |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **minutes before the first pass opens** (default) | …before it has begun, so nobody buys a season they have already started missing. Leave at `0` to sell right up to the first pass. |
| **minutes before the first pass ends** | …during its opening pass. Latecomers can still join on the night, but once that first session is over the season is closed. |
| **minutes before the last pass opens** | …as its closing pass begins. The season sells for weeks, but a buyer always gets at least one **whole** pass. |
| **minutes before the last pass ends** | …only at the very end. The season stays on sale throughout, and someone can join with a single date left — or part of one. |
A mid-season buyer pays the **full price** for the remainder. Nothing is prorated, and passes
that have already run are never issued to them. If that feels wrong for your season, use the
first anchor.
**minutes before the first pass ends** is the one to reach for when you are selling a course or
a league rather than a drop-in. Someone who hears about it on opening night can still buy in and
be let straight into that session — they are admitted automatically within a minute — but nobody
turns up in week four having missed three quarters of it.
**minutes before the last pass opens** is the middle path if you would rather keep selling all
season without ever taking money for a fragment. It is the only anchor that guarantees the buyer
a complete pass: the deadline lands as the closing session begins, so there is always one whole
date still ahead of them. Compare it with **before the last pass ends**, which will happily sell
a season with ten minutes of its final pass remaining.
A buyer who arrives after a pass has opened is admitted by a background sweep that runs once a
minute, and a busy minute can run long. Closing sales fewer than **5 minutes** before the first
pass ends is refused on save, because inside that margin someone can pay and never be let in.
The other three have no floor, because none of their deadlines falls inside a pass the buyer
still needs letting into: **before the first pass opens** closes before anyone is admitted at
all, **before the last pass opens** closes as a pass begins rather than during one, and **before
the last pass ends** leaves the whole season behind the deadline.
### Link the next season, if there is one [step]
**Next Series** points at another Pass Series in the same project — the one this season's
holders should buy when this one finishes. Leave it on **No follow-on series** if there is no
next season.
**Holders buy first for N hours** then sets how long your existing holders get that next
season to themselves before it opens to everyone else. See
[When the season ends](#when-the-season-ends) for exactly what happens.
This is the single most useful property of the successor link, and it is worth
stating plainly: you can link a next season **months after** the current one
sold out, and every buyer still gets the invitation. Nothing is stamped onto a
purchase at checkout.
So there is no penalty for not having planned the follow-on in advance. Add it
whenever you know what it is.
### Save, then activate [step]
New plans are created switched off. Check the slate reads the way you expect on the plan row,
then activate it.
## The Pass Series Passes Picker
The picker has two sections, and understanding the difference between them is the whole
skill of authoring a series.
| Section | What it does | Keeps working after you save? |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| **Automatic Passes Selection with Rule Sets** | Describes passes by a rule — "every pass on this plan between these dates". Matching passes are taken in on their own. | **Yes.** New matching passes are added later, including to holders. |
| **Manually Handpick Passes from Plans** | You tick individual passes. Exactly those, and nothing else. | **No.** The list never grows on its own. |
They are not alternatives — **a series can use both at once**, and most good ones do. Rules
carry the regular slate; handpicking adds the one-off that does not fit the pattern.
### Automatic Passes Selection with Rule Sets
The section header carries a **Configure Rules** button that reveals the rule rows, and each
rule row has its own **Remove**. **Add a Rule** appends another.
While there are none, the section reads: _"No rules yet. Add one and every matching pass is
taken in automatically from now on."_
Each rule row has four or five fields depending on its type.
| Field | What it does |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Source Pass Plan** | Which pass plan the rule watches. Only Time-Limited Pass plans in this project are offered. One rule watches one plan — add a second rule for a second plan. |
| **Rule Type** | **Every Pass Starting in a Period** or **The Next Few Passes**. See below. |
| **Watch From** | The earliest pass start the rule will accept. Leave empty for no lower bound. |
| **Watch Until** | The latest pass start the rule will accept. Leave empty for no upper bound. |
| **Number of Passes to Take** | Only on **The Next Few Passes**. How many to take, counting forward from the earliest match. |
#### Every Pass Starting in a Period
A window rule. Every pass on that plan whose start falls inside **Watch From → Watch Until**
joins the series — including ones that do not exist yet.
This is the rule you want for a season: set the plan, set the two dates to the season's
bounds, and every fixture generated between them lands on the slate as it is created.
#### The Next Few Passes
A count rule. It takes the **first N** matching passes and then it is finished.
The distinction that catches people out: **a count rule never tops itself back up.** Once it
has taken its five, it has taken its five — a pass being cancelled afterwards does not make
the rule reach for a sixth. If you want the count maintained, you want a period rule with an
end date instead.
_"Say how many passes this rule should take in."_ A count rule with an empty
**Number of Passes to Take** is incomplete and blocks the save — it is not
quietly treated as "all of them".
### Manually Handpick Passes from Plans
**Showing Passes From** chooses which pass plan's upcoming windows to display. Switching
plans **does not clear what you have already ticked** — the note under the field says so,
because the tiles disappearing when you switch is otherwise alarming.
The passes appear as a grid of tiles. Tick one to add it, untick to remove it. **Select all**
and **Clear these** act on the plan currently shown, not on the whole series.
If the chosen plan has nothing upcoming: _"This plan has no upcoming access windows. Add some
to it, or pick a different plan."_
#### The three tile states
A tile is not simply on or off, because a pass can be on the slate for two different reasons
and the difference decides what unticking it means.
| State | What it means | Unticking it |
| ------------ | -------------------------------------------- | ---------------------------------------------------------------------------- |
| Plain ticked | You picked it by hand. | Removes it. Nothing else happens. |
| **By rule** | A rule brought it in. You did not pick it. | **Excludes it permanently** — the rule will not put it back. |
| **Excluded** | A rule matches it, but you have excluded it. | Ticking it again **lifts the exclusion** and the rule keeps it from then on. |
The picker states this outright above the grid: _"Passes marked "By rule" were added
automatically. Unticking one excludes it for good — the rule will not put it back — and
ticking it again lifts that exclusion."_
You do not have to save to see what a rule will do. The moment a rule is
complete, every existing pass it matches appears **ticked and badged By
rule** in the grid, and the count and date span above update to match. What
you see is what the series will hold.
Passes the source plan has not generated yet are not shown, for the obvious
reason — but they are taken in on their own when they appear, and handed to
everyone already holding the series.
This is what makes rules usable in practice. "Every fixture this season, except the one we
are playing behind closed doors" is a rule plus one exclusion — not a hand-typed list of
nineteen dates.
Picking a pass takes a moment to register; the tile shows a spinner over itself while it
does, so a slow click is never mistaken for a click that missed.
### Passes in this Series
Below the grid, everything currently on the slate is listed **in date order**, whichever
plan each came from and however it got there. While it is empty: _"Nothing picked yet. Tick a
pass above and it appears here in order."_
A badge beside the heading counts the slate, and a summary strip above it gives the date
span, how many days it covers, the timezone every time is shown in, and — when the whole
slate comes from a single plan — that plan's name.
The slate itself is a **table**, one row per pass in running order. The subheading states
the thing most worth internalising about it: _"Everything one payment buys, in the order it
runs. Each pass still opens and closes on its own — this is only the running order, not one
long window."_
| Column | What it holds |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **#** | Position in the running order, counting from 1. |
| **Date** | The day the pass opens, as `Wed 9 Sep 2026`. |
| **Time** | Start and end in the slate's timezone. A pass running past midnight carries an **ends 11 Sep** badge, so an overnight session is never read as a short one. |
| **Length** | How long the pass runs, in short human form — `3h`, `1d 9h`. |
| **From plan** | The pass plan the date belongs to, which is also what decides the rooms it opens. |
| **Sold** | A purple count of how many holders it already has. Blank when none — worth a look before removing a row. |
| **Added by** | **Picked** when you ticked it by hand, or a lime **Rule** badge when an automatic inclusion rule took it in. |
| **Status** | The window's own status — **Scheduled**, **Open**, **Closed** or **Canceled** — plus an amber **Clashes** badge when it runs at the same time as another row. |
| _(last)_ | An **×** that drops the pass from the series. It removes it from the slate only; the pass itself stays on its own plan. |
Below the table, a warning callout counts the clashes when there are any: _"N passes run at
the same time as another, flagged with a warning icon above."_ The plan summary repeats it
as _Some passes run at the same time_, with the reminder that you can turn the toggle off if
attending both is genuinely intended.
**Prevent Overlapping Passes** is a guard, not a hint. With it on, saving a series whose
passes run at the same time fails with an error naming the first offending pair — in the
dashboard, over the REST API and through MCP alike. It also stops a rule absorbing a date
that clashes with what is already on the slate.
Turn it **off** when holders are genuinely meant to attend both at once; overlapping dates
are then a legitimate thing to sell and nothing is refused.
### Save Changes
What the picker's own **Save Changes** does depends on where you opened it from.
- **From the plan editor** (the **Pick Passes** / **Manage Passes** button while creating or
editing the series), it stores your picks against the form. The footnote says the rest:
_"Your picks are kept here — save the plan itself to publish them."_ Nothing is live until
the plan is saved.
- **From the plan row** (the **Manage Passes** action on the list), there is no editor behind
the picker, so **Save Changes** writes the slate to the series straight away — the footnote
reads _"Save to publish your picks to this series straight away."_ The series is re-synced
and its rules re-run in the background exactly as after saving the plan.
## What the buyer sees
### On the portal
A series card carries a banner the ordinary plan card does not: **N passes included**, the
full date span, and — when you set one — a **N seats left** pill that reads **Sold out** at
the cap.
Beneath it, the opening days are previewed — each day on its own line with that day's session
times beside it, rather than one row per pass. A season running three sessions a day reads as
three days rather than as nine near-identical lines, which is what tells a buyer the _shape_
of it. A running countdown to the first pass sits in the banner, and **+N more passes across
M more days** closes the preview.
A pass that runs past midnight carries its **closing date** beside the times — `16:00–01:00
→ Fri 11 Sep` — because the day heading above it only says when the pass opens. Without it a
33-hour pass under a _Wed 9 Sep_ heading reads as a nine-hour evening that ended before it
began, and a buyer prices three nights as one. Same-day passes stay as bare times, which is
the common case and the one the extra date would only clutter.
The source plan is named on a pass only when the season actually draws from more than one.
At checkout every date is listed, grouped by day. Where every pass opens the same rooms — the
common case — those are hoisted into a single **Every pass opens** panel instead of being
repeated under all twenty-eight.
At checkout the whole slate is listed under **What you get**, so nobody pays without having
seen every date.
### After they pay
Their entitlements are written immediately — one per pass — and the timeline appears on the
portal under **Your passes**, with running **N attended** and **N missed** counts.
Each entry reads **Pass N of M** with its window, and carries a state: **Live now**, **Up
next**, **You attended**, or **Missed**. A pass they already held before buying the series is
marked _"You already held this pass before buying the series, so it was not issued twice."_ —
they are not charged twice for it and it is not issued twice.
Each entry also carries that date's own join buttons once its invite links exist. Until then
it says when they arrive — _"Your invite link arrives Tue 1 Sep, 10:30."_ — so a slate that is
mostly buttonless in its first week reads as a schedule rather than as something missing.
### When invite links arrive
A season has two kinds of room, and their links arrive at different times.
| Room | Link arrives | Taken away |
| ---------------------------------------------------------- | ------------------------------- | --------------------------------------- |
| **A dated room** — from the pass plan each date belongs to | 24 hours before that date opens | When that date closes |
| **The lounge** — linked on the series itself | With the purchase confirmation | When the last date of the season closes |
**Dated rooms wait on purpose.** A season can open six weeks out. A link minted at purchase
would be dead long before the date arrived, so each one is issued a day ahead — the same moment
the first reminder goes out, so that reminder is the message carrying the button.
**The lounge does not wait**, because it is not tied to any date. There is no window whose end
could make it stale, so the holder gets it immediately and keeps it all season.
Two dated links arrive sooner than the day-ahead rule alone would manage:
- **The next date's link is sent as the current one ends.** The holder is already looking at
the message telling them the session is over, which is the cheapest moment they will ever
have to tap one more button.
- **A holder who buys inside the last 24 hours gets theirs on the next sweep**, within a
minute, rather than waiting for a deadline that has already passed.
Tapping a link early costs nothing. The join request sits pending until that window opens,
however many days away, and is approved automatically the moment it does.
### On the bot
A holder gets **My Timetable** on the project bot, listing the whole slate with the same
states. Each date's invite link is sent ahead of that date opening, on the schedule above — a
series holder does not have to do anything different.
### After it finishes
A finished season does not disappear. **My Memberships** carries a **Past** section below the
active ones, listing every pass and series that has run with its attended and missed counts.
Opening one shows the full timeline exactly as it looked on the day.
That section is deliberately limited to the **scheduled** kinds. A lapsed monthly subscription
is lapsed billing and belongs in nobody's history; a dated purchase is an event somebody either
turned up to or did not, and that answer is the only thing left once the dates have run.
### In their calendar
The portal offers **Add to your calendar**: a subscription URL that puts every pass into
Apple Calendar, Google Calendar or Outlook, _with a reminder an hour before each one opens_.
This is offered to **single-pass holders too**, not only to series buyers — anyone holding a
dated window benefits from it being in the calendar they actually look at.
The link is secret and durable, so it also carries **Shared this by mistake? Get a new
link** — rotating it stops the old URL working for anyone it was shared with, and the holder
resubscribes.
The block disappears once nothing on the slate is still ahead. Subscribing a calendar client to
a feed of things that have already happened is not a feature, and the timeline is where a
finished season is read.
## Running the season
### Rules keep working, and holders are told
When a new pass appears on a plan a rule watches, it is added to the series **and granted to
everyone currently holding it**. They are messaged about the new date and it joins their
timeline. Nothing is charged.
This is the behaviour that separates rules from handpicking, and it is why the picker
describes it in those words. If you do not want the slate growing under people who have
already bought, use handpicking.
The `pass_series.leg_added` webhook fires so an external calendar or CRM can be re-synced.
### Buyers are told the season can still grow
A season built with rules is not a fixed list, and the portal and the bot both say so before
anyone pays — _"Any new pass added before 30 Sep is yours too, at no extra cost."_ The
checkout repeats it under **This season is still growing**.
That line appears only while a rule can genuinely still match something: a rule whose
**Watch Until** has passed, or a count rule that has taken its full count, can bring nothing
else in, so nothing is promised. A season composed entirely by hand never shows it.
It is worth understanding as a selling point rather than a disclaimer. A buyer choosing
between one dated pass and the season is being told the season may be worth more by the end
than the slate they can see today.
With the toggle on, a new pass that clashes with something already on the
slate is **not** absorbed — the series refuses it rather than handing holders
two things at the same time. With the toggle off it is absorbed like any
other.
### A series holds at most 120 passes
One slate holds at most **120** passes. A rule that would take it past the ceiling stops there
rather than partially applying, and the save is refused with _"A series can carry up to 120
passes."_
### Finding a season holder in Subscriptions
A season appears in **Subscriptions** like any other purchase, but its status column is read
per-season rather than per-date. Instead of the single-window pair a pass shows, the row carries
how far through the slate the holder is — _"6 passes left of 10"_ — plus **N missed** once dates
start going by without them.
That is deliberate. A season has many dates and one row, so "Awaiting Window / Not Queued" would
describe whichever date happens to be next and read as the state of the whole purchase.
**View Details** opens the same split: a **Season** block with the span, the progress, the
timezone and running attended / missed counts, then a **Next pass** block naming that date, its
countdown, and whether the holder has joined its queue yet.
**Cancel Subscription** is on the row's menu, the same as any other purchase.
Cancelling withdraws every remaining date at once — the window sweep admits
only holders who have not cancelled — and the row then reads **Cancelled**
with no lifecycle badge beside it. Like cancelling a pass, it sends the holder
no message; issue a refund instead if you want them told.
### Cancelling a pass that a series holds
Cancel it from **Manage Access Windows** on its own plan, exactly as you would any window.
What happens to series holders depends on whether a substitute exists.
| Outcome | What happens |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| **Substituted** | Holders are moved to a replacement they do not already hold. Their season continues at the same length. `pass_series.leg_substituted` fires. |
| **Dropped** | No substitute exists, so that date leaves their season. The rest is untouched. `pass_series.leg_dropped` fires with the pro-rata figure. |
A dropped date tells the holder plainly: which pass was cancelled, where they now stand, and
**about what it was worth** as a share of what they paid — _"That pass was worth about $9.90 of
what you paid."_
Subscriby computes it and shows it. It does **not** move any money. The
message says nothing is charged or refunded automatically and asks them to
contact you, and the webhook carries the same figure so you can act on it —
but the refund is yours to issue from your payment provider.
If the cancelled pass was the **last one** in someone's season, they are told so: _"That was
the last pass in your series, so the series has now ended."_
## When the season ends
### The plan takes itself off sale
Once the last pass has closed, the series is **deactivated automatically** and you are
messaged about it. Its sales cutoff had already hidden it from buyers; this stops it reading
**Active** in your plan list while selling nothing.
Nothing is deleted. A season is a record of what was sold and who turned up, so the plan, the
slate and every holder's attendance stay exactly where they are. Add new passes to it and
switch it back on to run another season, or duplicate it for a fresh one.
`plan.deactivated` fires with `reason: series_exhausted`.
### Holders get first refusal on the next one
If you linked a **Next Series**, the presale opens at the same moment. Every holder of the
finished season is messaged — naming the next season, how long they have, and a link straight
to it.
For the length of the window you set, **only those holders can buy it**. Anyone else who
tries is turned away, in the portal and on the bot alike, and told when it goes on general
sale. When the window elapses the plan sells to everyone with no further action from you.
`pass_series.presale_opened` fires when the window opens.
### Starting the next season
**Start Next Season** on the finished plan's row menu does the whole setup in one step. It
creates a new Pass Series carrying everything except the slate, and — the part that is
actually easy to forget — **links it as this season's successor**, which is what decides
whether the presale above reaches anybody at all.
| Carried over | Left behind |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| Price, currency, description and eligibility flags | The slate — every pass on it belongs to a season that has run |
| **Prevent Overlapping Passes**, **Seat Limit** and both **Late Entry** controls | Exclusions, which named specific passes that no longer matter |
| Lounge resources | Holders — a new season is a new purchase |
| Every rule, with its source plan, type and count | |
The new season arrives **inactive with an empty slate**, and the picker opens straight away so
you can compose it. That is deliberate: a duplicate that went on sale immediately would be
selling a season with nothing in it.
It is named after the one it follows — _Champions Room (Season 2)_, then _(Season 3)_ — because
plan names must be unique within a project and a bare copy would be refused for something you
did not type.
A rule copied verbatim would carry last season's **Watch From** and **Watch
Until**, match nothing at all, and leave you with an empty slate and no clue
why. So each rule's window is shifted forward by the **length of the season
that just ended** — a Sept–Dec season produces a rule watching the equivalent
block immediately after it.
That is a sensible guess, not a certainty. The dates are in the picker; check
them before the season goes on sale.
If the finished season had no presale window set, the new link gets a default of **48 hours**.
An existing value is left exactly as you set it.
Everyone who bought the finished season and did **not** cancel it — whether or
not their subscription has expired, which by this point most have. Someone who
cancelled partway through is not invited: they said they did not want the
season, so a promise made to its holders is not one they are owed.
## What Subscriby refuses, and why
Every one of these blocks the save and names itself, so nothing here is discovered after the
fact.
| Refusal | Why |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| _"A series needs at least two passes. Tick some in the picker, or add a rule that will bring them in."_ | One date sold as a series gives the buyer a worse version of a product that already exists. Passes a rule will take in count towards the two, so this only appears when the series would genuinely be empty. |
| _"A series can carry up to 120 passes."_ | The ceiling on one slate. |
| _"Two passes in this series run at the same time…"_ | **Prevent Overlapping Passes** is on and the slate holds a clash. The message names the first offending pair. Remove one, or turn the guard off if holders are meant to attend both. |
| _"Say how many passes this rule should take in."_ | A count rule with no count is incomplete, not "all of them". |
| _"A series cannot lead to itself."_ | A successor loop would invite holders to buy the season they just finished. |
| _"The next series must be another Pass Series in this project."_ | A successor is a season, not a subscription, and not another project's plan. |
| _"A seat cap of zero would put the series on sale with nothing to sell."_ | Empty means unlimited; zero means nothing. Deactivate the plan instead. |
| _"A holders-only presale can run from 1 hour up to a year."_ | Below an hour nobody sees it; above a year it is not a presale. |
Mixing pass plans whose **timezones differ** is refused too, naming both zones. A series has
no timezone of its own — it renders its timetable in its source plans' — so two disagreeing
zones leave nothing to render in.
## Automating around a series
| Event | Fires |
| ----------------------------- | -------------------------------------------------------------------------------- |
| `pass_series.leg_added` | A rule absorbed a new pass into a live series — the one to re-sync a calendar on |
| `pass_series.leg_substituted` | A cancelled pass was replaced, carrying both windows |
| `pass_series.leg_dropped` | A cancelled pass was dropped, carrying the pro-rata figure |
| `pass_series.completed` | A holder's last pass closed, with final attendance |
| `pass_series.presale_opened` | The holders-only window on the next season opened |
The ordinary [`pass.*` events](/webhooks/v1/events/pass) still fire for each individual window,
because each date is a real window on a real plan — a series does not replace them.
## Questions
Yes. A series points at dates that already exist; it never generates any.
Build at least one [time-limited pass](./time-limited-passes) plan with
upcoming windows first, then compose a series from them.
Yes, and that is the normal case. Each date grants **its own plan's**
channels, so a season ticket can span a match-day channel and a weeknight room
without you mapping anything.
Yes. They add together: the rules carry the regular slate, handpicking adds
the one-off that does not fit the pattern. Unticking a rule-added pass
excludes it permanently; ticking it again lifts the exclusion.
Yes. The pass joins the slate, every current holder is granted it, they are
messaged, and it appears on their timeline. Nothing is charged. That is the
point of a rule — if you do not want it, handpick instead.
It is not issued twice and they are not charged twice for it. Their timeline
marks it _"You already held this pass before buying the series."_ and the date
disappears from their individual picker while the series covers it.
Yes, with any of the three later **Late Entry** anchors. Counting back from
when the **first pass ends** lets them join during the opening session only.
From when the **last pass opens**, they can join any time up to the closing
session and always get at least one whole pass. From when the **last pass
ends**, they can join at any point at all. Either way they pay the full price
for whatever is left, nothing is prorated, and passes that have already run
are never issued to them.
Yes. Their access to that pass is granted automatically within about a minute
of paying — the same sweep that admits anyone arriving late to a window. That
is exactly why the anchor refuses a cutoff under 5 minutes: it guarantees the
sweep has time to let them in before the doors close.
No. Access is removed when each date closes, which is what makes the gating
mean anything — but their next invite link is sent in the same message, so
they tap once while still in the conversation. Anything you link **on the
series itself** is a lounge and is kept for the whole span.
No. Each date stands alone. A missed one is recorded as **Missed** on the
timeline and everything else is untouched.
No, the same as a pass. A dated purchase is not an ongoing membership, so the
portal does not offer a cancel control. Cancel it yourself from
**Subscriptions**, or issue a refund.
No. There is no surcharge — a series sale is billed at your usual transaction
rate. It is unlocked by the same capability as passes, so on Growth or with
the Passes Addon you already have it at no additional cost.
Yes. [Access codes](./codes) work on a series exactly as on any other plan, so
you can hand someone the whole slate without a payment.
It is deactivated automatically and you are told. Nothing is deleted — add new
passes and switch it back on for another season, or use **Start Next Season**.
If you linked a **Next Series**, the holders-only presale opens at the same
moment.
**Start Next Season** on the finished plan's row menu. It copies the price,
the settings, the lounge and every rule, links the old season to the new one
so the presale works, and opens the picker. Only the slate is left empty —
that is the part that has to be new. See [Starting the next
season](#starting-the-next-season).
Yes. It moves to the **Past** section of My Memberships with its attended and
missed counts, and opening it shows the full timeline. Only passes and series
are kept there — a lapsed ordinary subscription is not.
## Related
- [Time-Limited Passes](./time-limited-passes) — the dated windows a series is built from
- [Subscription Plans](./plans) — ordinary recurring plans
- [Passes Addon](/addons/passes) — what unlocks passes and series on Free and Starter
- [Using a Pass Series](/subscribers/using-a-pass-series) — the same feature from your buyer's side
- [Using a pass](/subscribers/using-a-pass) — the subscriber's side of a single dated window
- [`pass.*` webhook events](/webhooks/v1/events/pass) — automate around windows opening and closing
- [Pass Series on subscriby.net](https://www.subscriby.net/features/pass-series) — what a series is, which plan includes it and the questions creators ask before selling one
---
# Subscription Plans
Source: https://docs.subscriby.net/creators/plans
By the end of this page, you'll have created your first subscription plan and know how to shape every option — from trial days to "churned-only" eligibility filters.
A **subscription plan** is the product your members actually buy. Each plan defines a price, a billing cycle, a set of resources it unlocks, and any eligibility or usage rules. You can create unlimited plans per project — monthly, yearly, lifetime, access-code-only — mix and match to fit your business.
**Navigating to Subscription Plans.** Select a project first (top-left project
picker), then open **Subscription Plans** from the sidebar.
## Prerequisites
Before creating a plan, you should have at least:
- **One [payment method](/creators/methods)** enabled — otherwise there's nothing for the plan to charge through.
- **One [resource](/creators/resources)** available — plans require at least one linked resource to define what the subscriber unlocks.
## Create a plan
From the Subscription Plans page, click **Create New Subscription Plan** at the top-right. A multi-section form opens. The sections below walk through every field in order.
### Choose the kind of plan [step]
Three cards sit at the top of the form, and this is the choice that decides which half of the
form you see next. Everything on the rest of this page describes a **Recurring Subscription**,
which is the default.
| Kind | What it sells | Renews |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- |
| **Recurring Subscription** | The traditional subscription. Nothing is scheduled — members pay each cycle and keep access for as long as they keep paying. | Yes, on a cycle |
| **Time-Limited Pass** | A ticket to one dated session — workshops, match days, one-off events. Access opens and closes on its own and never renews. | No |
| **Pass Series** | A season ticket over many passes — seasons, courses, cohorts. One payment covers every pass you bundle from your pass plans. | No |
The two scheduled kinds replace the pricing, billing-cycle and trial sections below with their
own controls, because access comes from a date rather than from a period. They are unlocked by
the same capability — included in **Growth**, or on Free and Starter with the
[Passes Addon](/addons/passes) — and neither carries an extra transaction fee.
A plan is saved as exactly one kind. Half-composed pass slots are not kept if
you save as a subscription, and a half-composed series is not kept if you
switch to a pass. Pick the kind first, then fill the form.
See [Time-Limited Passes](./time-limited-passes) and [Pass Series](./pass-series) for those two
in full.
### Basic details [step]
- **Plan Name** _(required)_ — 5–255 characters, must be unique within the project. Public-facing, so make it descriptive: _"Pro Monthly"_, _"Annual Lifetime Legacy"_.
- **Description** _(optional)_ — up to 1,000 characters. HTML is allowed (bold, italics, lists, links) and sanitized for safety. Use it to sell the plan.
### Linked resources [step]
**At least one required.** Tick the [resources](/creators/resources) this plan grants access to.
- Ordering matters: the sequence you select is the order they appear on the invoice and in the member's portal view.
- For gated places, Subscriby automatically admits and removes subscribers on lifecycle events.
### Pricing [step]
- **Currency** _(required)_ — pick one of the currencies your enabled payment methods support. If no enabled method supports it, the plan will be blocked from saving until you either change the currency or enable a compatible method.
- **Price** — at least the **$1.00 USD equivalent** in your chosen currency. Payment providers reject dust amounts, so a plan priced at a few cents could never actually be bought. Enter something below the floor and the error names the minimum in both your own currency and USD.
- **Price of 0** publishes a **free plan**, which is available on **Starter** and **Growth**. On the Free tier the price field will not accept 0 and the form points you at an upgrade.
### Billing cycle [step]
- **Cycle type** — **Days**, **Weeks**, **Months**, **Years**, or **Lifetime**.
- **Cycle count** — an integer ≥ 1 representing how many of the cycle units pass between charges.
Examples:
- Monthly billing → cycle type `Months`, count `1`.
- Every-3-months plan → cycle type `Months`, count `3`.
- Weekly plan → cycle type `Weeks`, count `1`.
- Annual plan → cycle type `Years`, count `1`.
- Lifetime (pay once, keep forever) → cycle type `Lifetime`. Count is locked to `1`.
### Trial period _(optional)_ [step]
- **Trial days** — 0 or more. Zero disables trials entirely.
- **Trial type** —
- _"Once for project"_ — the subscriber gets one trial across all plans in this project. Switching plans during trial doesn't reset it.
- _"Once for plan"_ — the subscriber can trial this plan even if they trialled a different plan previously.
- **Cardless trial** —
- **On** — we don't ask for payment details up front. Access stops when the trial ends unless they convert.
- **Off** — we take a card during signup and auto-charge when the trial ends (recurring plans only).
### Renewal behaviour [step]
- **Recurring** _(default on)_ — plan auto-renews. At the end of each billing cycle, Subscriby tries to charge the saved payment method and extends access on success.
If turned off, the plan becomes a **one-time purchase**: access is granted for the billing duration, then expires. No automatic renewal. Think "season pass" or "lifetime deal".
Recurring can't be enabled for crypto or platform-currency plans
(CoinPayments, CeyPay, a connector's native currency) — they only support one-time charges
by nature. Subscriby will block the save and explain why.
- **Allow disabled plan renewal** _(default off)_ — if you later mark this plan **Inactive**, existing subscribers on it either keep renewing (this toggle **on**) or are locked out of renewal (this toggle **off**).
Turning it **on** is how you **grandfather** early supporters into legacy pricing while hiding the plan from new buyers.
### Access restrictions _(optional)_ [step]
Pick at most **one** of the following audience filters:
- **Newcomers only** — only users who have _never_ subscribed before. Great for intro offers.
- **Customers only** — only users with an _active_ subscription. Perfect for VIP upgrades.
- **Churned only** — only users whose subscription has _expired_. Great for win-back campaigns.
And optionally one or both usage filters:
- **Single use** — plan hides from the subscriber's view after they've bought it once.
- **Access codes only** — plan never appears on the public portal or in the bot's plan list. The only way to subscribe is by redeeming a pre-generated [access code](/creators/codes).
### Sales limit _(optional)_ [step]
- **Sales cap** — stop selling after this many successful purchases. When the last one lands, Subscriby switches the plan off on the spot and marks it **Sold Out** in the plan list, so you can see the platform paused it rather than a teammate. Existing subscribers are untouched.
- Switch the plan back on to sell another batch of the same size; the counter restarts at zero. Changing the cap while the plan is on sale restarts the counter too. Leave it empty for no limit.
- Only paid purchases count. Access-code redemptions and cardless trials do not spend the cap.
The cap works on every kind of plan — a subscription, a pass or a season. A `pass_series` plan also has a **seat cap** in its season settings: the seat cap refuses the next checkout the moment the seats are taken, the sales cap counts confirmed purchases and pauses the plan after the last one. When a season fills its last seat, the plan is paused with the reason **Seats Full**.
### Save and activate [step]
- **Active** _(toggle)_ — when on, the plan is visible and buyable. Turn off to hide without deleting.
- Click **Create Subscription Plan**.
## Field reference
A quick summary of every configurable field on a plan:
| Field | Type | Default | Notes |
| ------------------- | --------- | --------- | ------------------------------------------------------- |
| Name | string | — | Required, 5–255 chars, unique per project |
| Description | rich text | _(empty)_ | Up to 1,000 chars, HTML allowed & sanitized |
| Price | decimal | — | ≥ the $1.00 USD equivalent, or 0 on Starter / Growth |
| Currency | enum | — | Must be supported by ≥ 1 active payment method |
| Billing cycle | enum | Months | Days / Weeks / Months / Years / Lifetime |
| Billing cycle count | integer | 1 | ≥ 1; must be 1 for Lifetime |
| Trial days | integer | 0 | ≥ 0 |
| Trial cardless | boolean | false | Can't require payment up front |
| Trial type | enum | Project | Project / Plan |
| Recurring | boolean | true | Can't be on for crypto / platform currencies |
| Disabled renewal | boolean | false | "Grandfather" early subscribers |
| Newcomers only | boolean | false | Mutually exclusive with Customers / Churned |
| Customers only | boolean | false | Mutually exclusive with Newcomers / Churned |
| Churned only | boolean | false | Mutually exclusive with Newcomers / Customers |
| Single use | boolean | false | Hides plan from users who've bought it |
| Access codes only | boolean | false | Plan never appears publicly |
| Sales cap | integer | _(none)_ | 1–100,000; pauses the plan after that many purchases |
| Active | boolean | true | When false, plan is hidden (but existing subs continue) |
| Resources | array | — | Must link at least 1 |
## Common plan recipes
- **Name:** Pro Monthly - **Price:** $9.99 - **Billing:** Monthly (count 1) -
**Recurring:** On - **Resources:** Main group, premium channel
- **Name:** Pro Annual *(save 20 %)* - **Price:** $95.99 - **Billing:** Yearly
(count 1) - **Recurring:** On - **Resources:** Same as monthly
- **Name:** Lifetime Founder - **Price:** $299 - **Billing:** Lifetime -
**Recurring:** Off (Lifetime auto-disables recurring) - **Resources:** Same as
other paid tiers - **Single use:** On *(optional — prevents duplicate
purchases)*
- **Name:** Pro Monthly + 7-day trial - **Price:** $9.99 - **Billing:**
Monthly - **Recurring:** On - **Trial days:** 7 - **Trial type:** Once for
project - **Cardless trial:** Off *(auto-charge at trial end)*
- **Name:** We Miss You — 50 % off for 3 months - **Price:** $4.99 -
**Billing:** Monthly - **Recurring:** On - **Churned only:** On - **Single
use:** On
- **Name:** Creator Gift - **Price:** $9.99 - **Billing:** Months, count 1
*(or whatever matches the gift duration)* - **Recurring:** Off - **Access
codes only:** On
The price is never charged to the member — they redeem a code instead — but it
is what the platform fee is calculated from once you go past your monthly free
access codes. Pricing the plan at 0 makes it a free plan, which needs Starter
or Growth.
## Sync with payment providers
Recurring plans need to exist in your payment provider's own catalogue for automatic billing to work. See [Sync subscription plans](/creators/methods#sync-subscription-plans-for-providers-with-product-catalogues) for the one-click sync action.
Create or update the plan in Subscriby **first**, then run Sync. Running Sync
before saving changes won't propagate them.
## Edit, deactivate, or delete
### Edit a plan
Change any field on the plan form and save. Edits apply immediately to new subscribers. For existing subscribers:
- **Price changes** apply at their next renewal.
- **Resource changes** apply immediately (existing members gain / lose access on the next sync).
- **Eligibility filters** don't kick existing members out — they only affect new purchases.
### Deactivate a plan
Flip the **Active** toggle off. The plan disappears from the public portal and the bot's plan list. Existing subscribers keep renewing only if **Allow disabled plan renewal** is on — otherwise they lapse at cycle end.
### Paused automatically
A plan the platform switched off shows an amber **Sold Out** or **Seats Full** badge instead of a plain **No** in the Active column, and the plan's detail panel says why. Switching the plan back on clears the badge and, for a sales cap, starts a fresh batch. A season whose seats are full keeps them full: raise its seat cap before switching it back on.
## Arrange the storefront order
By default the portal and the bot list passes first, then seasons, then subscriptions, each group cheapest first. To pin a different order — a weekly bundle above the four daily passes it contains, say — click **Arrange Storefront Order** above the plan list, drag the plans into place and **Save Order**. The portal and the bot follow immediately.
Plans you create later join the end in the default order until you arrange again, and **Reset to Default Order** removes every pin. The same order is available to integrations through `POST /v1/projects/{project}/plans/order` and the `reorder_plans` MCP tool, and every change fires the `plan.order_changed` webhook.
### Delete a plan
Permanent. Active subscribers on a deleted plan lose access at their current cycle end. Use with care — prefer Deactivate unless you truly need the plan gone.
## Frequently asked
Yes — but make them distinct by billing cycle, resources, or trial offer. Names must be unique within the project.
On **Starter** and **Growth**, yes — price the plan at 0 and members join
without paying. On the **Free** tier the price field will not accept 0,
because every platform fee is a share of what you charge and a free plan
earns nothing to share. Give the plan a price, or upgrade.
Free plans you authored on the Free tier before this took effect have been
**taken off sale**: they no longer accept new members, and we emailed you the
list. Nothing was deleted and nobody lost access — everyone who already joined
keeps their membership.
A zero-priced plan that is off sale stays editable: rename it, change its
resources, price it. What you cannot do on the Free plan is put it **back** on
sale — the _Activate_ action is greyed out, and the API and MCP tools refuse it
too. Give it a price, or move up to Starter or Growth, and it activates
normally. Downgrading to Free is refused while any free plans are
still live, so price or delete them first.
To give access away without a free plan, use
[access codes](/payments/access-codes): the member redeems a code
instead of paying, and you get a free allotment every billing cycle.
Any currency supported by at least one active payment method on the project.
Stripe, PayPal, and Razorpay between them cover 150+ currencies; crypto
providers cover the major cryptocurrencies; a connector's own currency is
offered only while that connector is connected on the project, because only
its bot can charge it.
Technically yes, but in practice it's disruptive. The safer pattern is: create
a *new* plan with the new cycle, migrate subscribers (by offering them the new
plan), then deactivate the old plan with **Allow disabled plan renewal** on if
you want any remaining subscribers to stay on it.
Typically one active plan per subscriber per project at a time. Upgrading or
downgrading cancels the old and starts the new. If you need bundled access,
build one plan that unlocks multiple resources.
**Single use** hides the plan from a specific subscriber after they've bought it (good for one-time offers). **Access codes only** hides the plan from *everyone* except those who redeem a pre-generated code — the plan never appears on the portal at all.
Yes. Set a **Sales cap** on the plan. After that many successful purchases Subscriby switches the plan off, marks it **Sold Out** in the plan list and stops new checkouts; existing subscribers keep their access. Switch it back on whenever you want to sell another batch of the same size. For a season ticket you can also set a **seat cap**, which refuses the next checkout the moment the seats are taken.
Yes. Click **Arrange Storefront Order** above the plan list and drag the plans into place. Buyers see that order on the portal and in the bot; plans you leave alone keep the default order (passes, seasons, then subscriptions, cheapest first) after the pinned ones.
## Related
- [Resources](/creators/resources) — what plans unlock.
- [Payment methods](/creators/methods) — where plans get paid.
- [Access codes](/creators/codes) — generate codes for access-code-only plans.
- [Transaction fees](/fees) — how Subscriby calculates its cut on every transaction.
---
# Creating a Project
Source: https://docs.subscriby.net/creators/projects
By the end of this page, you'll have a fully-configured project ready to connect a bot and start selling memberships.
A **project** in Subscriby is a self-contained business entity. Everything a specific community needs — plans, payment methods, members, linked resources, bot connection — lives inside one project. Most creators run one project; ambitious ones run many.
## Before you begin
You'll need:
- An active [creator subscription](/creators/subscription) — Free, Starter, or Growth.
- A clear idea of which community this project will monetize.
- Optional: a banner image and a logo for the public portal page.
## Open the create-project flow
### Start a new project [step]
Sign in and open the dashboard. From the top-left project picker (or from the **Projects** entry in the sidebar), choose **Create New Project**.
### Review the form [step]
A multi-section form appears. Every field is covered below. Required fields are marked — you can leave optional ones blank and add them later.
## All the project fields
A project is not tied to a platform when you create it. The platforms it runs on are **connectors** you install afterwards from the project's [Connectors](/creators/connectors) page or the [Connectors Marketplace](/connectors/marketplace), which lists what is live today and what is on the way. The projects list shows which connector each project runs and whether it is connected.
The public display name for your project — shown on the portal, in invoices, and in the bot's messages.
- **Length:** 5–255 characters.
- **Style:** treat it like a brand name. Short, distinctive, easy to say.
A unique URL-friendly identifier that powers your project's **public portal page**.
- Example: `pro-chess-club` → portal URL `https://my.subscriby.net/pro-chess-club`.
- **Length:** 5–255 characters.
- **Allowed characters:** lowercase letters, numbers, and dashes only. No spaces, no underscores, no uppercase.
- **Must be unique** across all Subscriby projects — you'll see a validation error if someone else has the handle.
- **Starter-plan requirement:** the handle field is gated to Starter and Growth. On Free, you can leave it blank (the portal is either disabled or auto-assigned).
Leave blank if you don't want a public portal at all — members can still sign up through your project's bot directly.
A welcome message sent to users the first time they interact with the bot (or send `/start`). This is your bot's elevator pitch.
- **Length:** up to 1,000 characters.
- **Formatting:** HTML is allowed but sanitized for safety. Expect bold, italics, paragraph breaks — not arbitrary scripts.
A wide hero image displayed at the top of your **public portal** page.
- **Format:** JPG, PNG, GIF, or WEBP. HEIC (the default iPhone photo format) is not supported — convert to JPG first.
- **Max file size:** 5 MB.
- **Displayed at:** 480×160 pixels, centre-cropped. Design for a wide banner, and keep important content centred.
The main square logo/avatar for your project on the portal page and in the bot's profile.
- **Format:** JPG, PNG, GIF, or WEBP. HEIC (the default iPhone photo format) is not supported — convert to JPG first.
- **Max file size:** 5 MB.
- **Displayed at:** 500×500 pixels, centre-cropped. A square logo works best.
Direct links to your own Terms of Service and Privacy Policy documents.
- **Terms URL** — up to 255 characters, must be a valid URL.
- **Privacy URL** — up to 255 characters, must be a valid URL.
If you provide these, the bot will ask new members for explicit consent during onboarding, referencing **your** documents. If you leave them blank, the default Subscriby terms and privacy documents are used instead.
If you're on Growth and have set up [Teams](/teams), you can assign this
project to a team so collaborators can manage it. Leave blank if you're a solo
creator.
When enabled, Subscriby collects richer analytics about portal visits, bot interactions, and member activity to power your [Dashboard](/creators/dashboard-analytics).
Defaults to **off** — enable it when you're ready to observe engagement patterns.
Controls whether the project's bot responds to users.
- **Active** (default) — the bot handles `/start`, processes subscriptions, and onboards members.
- **Inactive** — the bot stays silent. Useful for maintenance windows or pausing onboarding without deleting the project.
## Save the project
### Click Save Changes [step]
Once you've filled the required field (the name), click **Save Changes** at the bottom of the form.
### Land in the project dashboard [step]
The project is created and you're redirected into its dashboard. You'll see an onboarding strip guiding you to the next steps: installing a connector, linking resources, adding payment methods, and creating plans.
### Edit anytime [step]
Every field is editable later from the project's **Settings** page — nothing here is locked.
## What happens next
Now you have an empty shell. To turn it into a working membership:
1. [Install a connector](/creators/connectors) and connect it, so you can actually onboard members — each [connector's own section](/connectors) walks through its connection steps.
2. [Link or create resources](/creators/resources) — decide what members unlock.
3. [Enable at least one payment method](/creators/methods) — so subscriptions can be paid.
4. [Build subscription plans](/creators/plans) — the pricing tiers your members choose from.
Once those four are in place, you can share your project ([Sharing](/creators/share)) and start onboarding your first subscribers.
## Managing existing projects
Once a project is live, three actions cover the most common lifecycle operations.
### Rename or edit
Open the project, go to **Settings**, and adjust any field. Hit **Save Changes** — updates apply instantly everywhere the project is referenced.
### Archive or deactivate
If you want to pause a project without deleting it, flip the **Active** toggle off. The bot stops responding; the portal stops processing new signups; existing subscribers keep their access. Flip it back on any time.
This is the recommended "take a break" path — it preserves all your data and makes resuming a one-click affair.
### Delete
Deletion is permanent. From the project settings, click **Delete Project** and confirm.
Deleting a project removes its plans, access codes, and subscription records.
Active subscribers lose access. Always prefer **Deactivate** over **Delete**
unless you really mean it.
## Frequently asked
Yes — plan-limited:
- **Free:** up to 3 projects
- **Starter monthly:** up to 10 (annual: 12)
- **Growth monthly:** up to 20 (annual: 25)
Upgrade from [Activate your subscription](/creators/subscription) when you hit the cap.
One project runs one bot per connector, and that bot can gate any number of
places, so one project covers an ecosystem of related communities on that
platform. A project can also run more than one connector
(a second one needs the Growth plan), so the same members and plans span
platforms as connectors arrive.
You'll get a validation error before the project saves. Pick a different
handle and try again.
Not directly from the UI. For ownership transfer, contact support — we'll
verify both parties' identity before re-assigning.
Confirm the file is under **5 MB**, is a **JPG / PNG / GIF / WEBP**, and isn't corrupted. If you're uploading straight from an iPhone, the photo is likely a **HEIC** file, which isn't supported — open it and export/convert to JPG first. Very large images (high megapixel counts) sometimes fail even under the file-size cap — resize to around 2000×2000 pixels and re-upload.
## Related
- [Resources](/creators/resources) — what members actually unlock.
- [Subscription plans](/creators/plans) — how members pay to join.
- [Connectors](/creators/connectors) — the platforms your project runs on; [each connector's section](/connectors) covers wiring it in.
- [Share your project](/creators/share) — get the word out.
---
# Resources
Source: https://docs.subscriby.net/creators/resources
By the end of this page, you'll know how to define the _deliverables_ of your membership — the things a subscriber actually gets when they pay.
In Subscriby vocabulary, a **resource** is anything a subscription plan unlocks: a place on a connected platform (a private channel, a group, a server) or a manually-tracked digital good. Resources are the middle layer between **plans** (what members pay for) and **access** (what they receive).
**Navigating to resources.** Make sure a project is selected first — use the
project picker in the top-left of the dashboard or click **Select as Current**
from the **All Projects** page. Once a project is selected, open **Project
Resources** from the sidebar.
## The resource kinds
Nothing can be added until a connector on the project is connected: the editor shows a **Connect a connector first** notice pointing at the project's Connectors page, because a resource is what a plan unlocks and nothing is sold before a connector can deliver it. Once one is connected the list offers a **Manual Perk**, and one gated kind for each kind of place the project's connected connectors can control. The tab strip above the list follows the same rule: **All Project Resources**, then one tab per kind, so a project with nothing connected shows no kind tabs and a connector that gates something else brings its own.
A **manual perk** is a placeholder you create yourself — it's not linked to any place on a platform. Use it for anything you fulfil by hand: a Google Drive folder, a Notion workspace, a weekly mentoring call, an Amazon gift card, or any off-platform reward.
- Created **directly from the dashboard**.
- Title and description are fully free-form — you decide what members see.
- **No automation**: Subscriby doesn't add or remove anyone from anywhere. Fulfilment is your responsibility.
Description text is shown to the subscriber as-is, so it's the natural place to paste things like secret URLs, access codes, or redemption instructions.
A **gated place** is somewhere on a connected platform that the connector controls for you: a private channel, a group, a server or a role, depending on the platform.
- Created **only through the connector** — not from the dashboard form. Subscriby needs the connector to confirm it has the rights it needs before promising to manage membership.
- When a plan linked to the place is subscribed → the connector issues the subscriber personal access to it.
- When a subscription ends (cancellation or expiry) → the connector removes the subscriber automatically.
On a project running Telegram the gated kinds are **Channel** (a private broadcast channel), **Group** (a private group) and **Supergroup** (the larger group variant with topics and threaded replies). All three are bot-created only, and the bot must be an administrator of the chat before the resource exists. Access is a personal, single-use invite link per subscriber.
**Only `Manual` resources can be created from the dashboard form.** Gated
places appear automatically once the connector confirms it controls the place —
see [Linking a place](#linking-a-place) below.
## Create a manual resource
### Open Create New Resource [step]
From the **Project Resources** page, click **Create New Resource**.
### Select the Manual type [step]
Select **Manual** from the list of resource types.
### Fill in the fields [step]
- **Title** (required) — 5–255 characters. What members see as the deliverable.
Example: _"Amazon $50 Gift Card"_, _"Weekly 1:1 Coaching Call"_.
- **Description** (optional) — up to 1,000 characters, with basic HTML formatting (bold, italics, links) allowed. This is where you put the actual content, URL, voucher code, or instructions.
- **Active** (toggle, default on) — whether the resource is available to link to plans. Turn off while drafting.
### Save the resource [step]
Click **Save Changes**. The resource appears in your list and can now be linked to any [subscription plan](/creators/plans).
## Linking a place
A place is linked through its connector, which verifies that it has the rights it needs before the resource exists; the resource editor only offers gated kinds while the project runs a connected connector that gates them. There are **two** ways to do it.
### Option A — Request through the connector (recommended for private places)
**Connect the connector to the project first** — see [Connectors](/creators/connectors).
From **Project Resources**, click **Create New Resource**, then pick the kind
of place. You'll see an information screen explaining that linking happens
through the connector.
Click **Send Request**. The button beside it opens your project's bot on the
platform, where the request is waiting.
The bot asks you to pick the place. Pick the one you want to link, and grant
the bot the rights it names when the platform asks.
Refresh the **Project Resources** page. The new resource appears with the kind you picked, the title pulled from the platform, and the place it is bound to. Toggle **Active** on to make it available for plans.
### Option B — Give the bot the rights yourself
Sometimes the request flow doesn't surface a specific private place in the picker. In that case you can grant the rights yourself:
On the platform, open the place you want to link.
Make your project's bot an **administrator** there, with the rights the
connector names.
The bot detects its new rights, registers the place as a resource on your
project, and sends you a confirmation message.
Back in the dashboard, refresh **Project Resources**. Your new resource appears and can be edited (title, description) or linked to plans.
In the request flow the bot sends you a **reply keyboard** button (e.g. *"Select a Private Channel"*); tapping it opens Telegram's picker of your channels, groups and supergroups, and Telegram then prompts you to add the bot as an administrator with **Invite Users via Link** and **Ban Users** (or *Restrict Members*). To grant the rights yourself, open the chat, add your project's bot as an administrator, and give it at minimum:
| Permission | Why the bot needs it |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **Invite Users via Link** | To generate unique, trackable join links per subscriber so access is scoped to one person at a time. |
| **Ban Users** (restrict members) | To remove subscribers when their access expires, is cancelled, or is revoked — without this, cancelled members stay in your group. |
| **Post Messages** (channels only) | Optional. Enables the bot to send broadcast-style onboarding or notification messages in-channel. |
Full platform detail, including how the bot enforces access afterwards: [Telegram integration](/connectors/telegram/integration).
**Don't demote or remove the bot from a linked place while subscribers are
active.** Without its rights, the connector can't enforce access —
cancelled subscribers won't be removed, and new ones can't be admitted. If you
need to rotate the bot's rights, coordinate with your members first.
## Why the rights matter
Subscriby acts as your automated community manager. For that to work safely, the connector needs to be able to **issue access** (a personal, trackable way in, scoped to one person at a time) and to **remove members** (when their access expires, is cancelled or is revoked — without this, cancelled members stay inside). Some platforms also let it **post** in the place, which enables in-place onboarding or notification messages. Each connector names the exact rights on its platform.
## Linking resources to plans
A resource is useless until it's attached to at least one subscription plan. The link happens **from the plan side**, not the resource side:
1. Open [Subscription plans](/creators/plans).
2. Create or edit a plan.
3. In the **Resources** section, tick every resource this plan should unlock.
A plan can include many resources (a "VIP" plan that grants a main channel, a private group, and a Manual coaching resource). A resource can be part of many plans (the main channel is included in Basic, Premium, and VIP tiers).
## Edit, deactivate, or delete
On the resources list:
- **Edit** — change title, description, or active status. The kind never changes: a Manual Perk stays a Manual Perk, and a linked place keeps the kind its connector reported when it was linked.
- **Deactivate** — flip **Active** off. Plans that reference the resource still work for _existing_ members, but you can't add it to _new_ plans until you reactivate.
- **Delete** — permanent. Breaks any plan that still references the resource; existing subscribers relying on it lose access the next time the system checks.
Prefer **Deactivate** over **Delete** for anything that has or had subscribers
on it. Deletion is for resources that were mistakes or are truly retired.
To move a resource to a **different place** without losing anyone — a new channel replacing one the platform took away, or a planned migration — use **Swap & Grant Resource** from the row menu instead of deleting and recreating it. Every live member receives personal access to the new place and is admitted automatically. The **Health** column on the same list shows the last probe result for every linked place. See [Swap and grant a resource](/disaster-recovery/swap-and-grant-resource), [Replacing a lost place](/disaster-recovery/replacing-places) and [How detection works](/disaster-recovery/how-detection-works).
## Common patterns
Basic plan unlocks the main channel. Premium plan unlocks the main channel *plus* a premium-only group. Ultimate plan unlocks both of those *plus* a small group with direct access to you.
Attach each resource to the right plan — the system handles adding and removing members correctly as they upgrade or downgrade.
Trial plan unlocks a single onboarding channel. Paid plans unlock that same
channel *plus* premium resources. Subscribers don't leave the onboarding
channel when they upgrade — only additional resources light up.
Mix manual and gated resources on the same plan. Example: *"Access to VIP group (automatic) + weekly 1:1 call (booked via Calendly, manual) + Notion dashboard (URL in description, manual)."* Some parts automate, others you fulfil manually — and the subscriber sees a clean bundled list either way.
## Frequently asked
Because Subscriby needs the connector to confirm it really controls the place before promising to manage members there. Having the bot confirm when it's given the rights is the cleanest and most secure way to do that.
The connector detects unauthorised members and removes or flags them based on
your project's enforcement settings. Each connector's own section explains how
it enforces access; see [Connectors](/connectors).
No. A resource is scoped to one project. If two of your projects both want to
use the same place, link it to each project separately — they won't share
state.
Refresh the Project Resources page. If it's still missing after a minute, confirm the bot still has its rights in the place — if it's been demoted, the link is rejected. Try again with the bot as a confirmed administrator.
## Related
- [Connectors](/creators/connectors) — required before you can link a place.
- [Subscription plans](/creators/plans) — where resources are attached to pricing tiers.
- [Connectors](/connectors) — each connector's own mechanics, and how its bot enforces access.
- [Disaster Recovery](/disaster-recovery) — replacing a place that became unreachable, standby places and the live mirror.
---
# Share Your Project
Source: https://docs.subscriby.net/creators/share
By the end of this page, you'll know every channel you can use to put your Subscriby project in front of prospective subscribers.
Once your project has a connected connector, active plans, and at least one enabled payment method, you're ready to let people in. You have **two distribution surfaces**:
1. **The bot link** — opens your project's bot on its platform, instant onboarding.
2. **The portal page** — a hosted, branded landing page on the web.
Both live under your project at the same time — use whichever is best for each channel you're promoting through.
## The bot link
A **bot link** opens a conversation with your project's bot on the platform your community lives on. Every link Subscriby generates for you — the portal's start button, **Copy Plan Bot Deeplink** on a plan, the join link on an access code — already carries the payload that tells the bot what to open, so a member lands on the right screen with one tap.
### Find your bot link
#### Open the project from All Projects [step]
On **All Projects**, find your project and click **Actions → View Project**.
#### Locate the link [step]
The flyout's **Public Link** field holds it; the **Open Bot** button in the header of every project page opens the same chat.
#### Copy the URL [step]
Copy the URL. For a link that lands on one plan, use **Copy Plan Bot Deeplink** on that plan instead.
A Telegram bot link is `https://t.me/YourProjectBotUsername`, and the payload travels in `?start=`. Always share it **with** a payload: a bare `https://t.me/YourBot` opens the chat and stops there — a returning member lands in an empty thread and a new one sees a **START** button that sends nothing the bot can route, so both have to know to type `/start`. Use `https://t.me/YourBot?start=start` for a plain "open my bot" link, a plan's UUID to land them on that plan, or an access code's UUID to redeem it on the spot; anything that is not a bare UUID is ignored and the bot just opens its home screen. The full parameter reference is on [Telegram deep-linking](/connectors/telegram/operations).
### Where to use bot links
- **Social media bios** (Twitter/X, Instagram, Threads, TikTok) — one tap opens the app.
- **Your existing community** — pin a post with the bot link so current members can upgrade.
- **YouTube video descriptions** and **podcast show notes** — easy to tap from mobile.
- **Email newsletters** — frictionless for subscribers already on the platform.
- **QR codes** on printed materials — flyers, business cards, event signage.
## The portal page
The **portal page** is a hosted, auto-generated website for your project. It displays your branding (banner, logo, description), all your active plans with prices, and a call-to-action for each. Subscribers can sign up and pay **without ever opening the platform first**.
Search engines are invited to list the portal once the project is active, offers at least one active plan and takes payments through a gateway in live mode; until then the page asks them not to index it, so a portal that is still being set up never appears in results with nothing to buy. The first 155 characters of your description become the snippet shown under the result, cut at a word.
Example URL:
```
https://my.subscriby.net/your-handle
```
### Prerequisites for the portal
- You're on **Starter** or higher — custom handles are a Starter+ feature.
- You've set a **handle** on your project (see [Creating a project → Handle](/creators/projects)).
If you're on Free, the portal is either disabled or assigned a system handle — [activate a Starter plan](/creators/subscription) to unlock custom handles.
### Find your portal URL
#### Click Open Portal Page [step]
On **All Projects**, many rows show an **Open Portal Page** button directly. Click it to preview what subscribers will see.
#### Or open via Actions → View Project [step]
Otherwise, open **Actions → View Project**. The portal URL is displayed at the top of the modal.
#### Copy the URL [step]
Copy the URL. It looks like `https://my.subscriby.net/your-handle`.
### Where to use the portal link
- **Your website or Linktree** — one URL for the world.
- **Paid ads** (Meta, Google, TikTok) — easier for ad platforms to approve a proper landing page than a deep link into an app.
- **Email signatures** and **LinkedIn profile** — look professional without asking the viewer to open an app.
- **Anywhere you want to show pricing before committing** — some audiences want to browse plans first.
## Bot link vs. portal — when to use which
**Best when** your audience:
- Is already on the platform (an existing community there, crypto / web3 audiences).
- Will be on mobile when they click.
- Wants frictionless signup — the fewest taps possible.
**Avoid when** your audience:
- Isn't on the platform and would bounce on an "Open in the app" prompt.
- Needs to browse and compare plans on a web view.
**Best when** your audience:
- Is browsing on a desktop or somewhere other than the platform.
- Wants to review pricing, features, and terms before committing.
- Is coming from paid advertising or referral partnerships where trust needs to be earned.
**Avoid when** your audience:
- Is already on the platform — a bot link gets them in faster.
- Prefers mobile-first, in-app flows.
Most creators share **both** — the portal link as the primary marketing URL, the bot link for communities already on the platform.
## QR codes
A QR code encoding your bot or portal link works great on:
- Event badges and booths.
- Printed flyers or posters.
- Merch and swag.
- Live-stream overlay graphics.
You can generate a QR code from any free service (qrcode-monkey.com, qr-code-generator.com) by pasting your bot or portal URL. Subscriby doesn't generate QR codes directly, but the links are plain URLs that every QR generator accepts.
## Embedding on a website
If you have your own WordPress / Webflow / Ghost site, drop a prominent **Subscribe** button that links to your portal or bot:
```html title="example-button.html"
Join the community
```
```markdown
[Join the community](https://my.subscriby.net/your-handle)
```
## Marketing tips
*"Tap to join ChessMentor — daily tactics puzzles, weekly game analysis, all for $9/mo"*. A plain link has far lower conversion than a link with context.
**Copy Plan Bot Deeplink** on a plan gives you a link that opens the bot with
that plan already selected, so a campaign lands people on the offer it
advertises instead of the home screen. An access code's join link works the
same way and is redeemed on the spot.
The generated links already carry the payload the bot needs to open on the
right screen. If you write a link by hand, in a storefront button, an email or
an ad, follow your connector's link format — see your connector's tab above.
Before you run any paid campaign, open an incognito window, tap your own bot
link, and go through the subscribe flow. Fix anything that feels bumpy before
thousands of people see it.
Subscriby does not read arbitrary link parameters — only the payloads it
generates are acted on, and nothing else is stored against the member. To
tell channels apart, give each one its own [access code](/creators/codes)
batch, or its own plan, and compare redemptions in the dashboard.
Don't worry about sharing your bot link "too widely". Access is issued only when someone actually subscribes; freeloaders can poke around but can't get into your private places without paying.
## Related
- [Access codes](/creators/codes) — generate one-off codes for gifting or manual sales.
- [Connectors](/connectors) — each connector's link format and deep-link payloads.
- [Managing members](/creators/managing-members) — track who joins and where they came from.
---
# Activate Your Subscriby Subscription
Source: https://docs.subscriby.net/creators/subscription
Before you can create a project and start collecting subscriptions, you need an active **Subscriby creator subscription** on your account. This is the platform subscription _you_ pay to Subscriby — separate from the subscriptions your members later pay to _you_.
By the end of this page, you'll know which tier fits your situation and how to activate it.
## The three creator plans at a glance
| Plan | Price (Monthly) | Price (Annually) | Projects | Lifetime memberships | Access codes | Custom handle | Teams | Free plans | Time-Limited Passes | Coupon Codes | Transaction fee |
| ----------- | --------------- | ---------------- | -------------- | ---------------------- | ------------------ | ------------- | ----- | ---------- | ------------------- | ------------ | --------------- |
| **Free** | $0 | — | 3 | 5,000 | 5 | ❌ | ❌ | ❌ | Addon | Addon | **10 %** |
| **Starter** | $39 / mo | $390 / yr | 10 (12 yearly) | 20,000 (24,000 yearly) | 25 (250 yearly) | ✅ | ❌ | ✅ | Addon | Addon | **3 %** |
| **Growth** | $89 / mo | $890 / yr | 20 (25 yearly) | Unlimited | 150 (1,500 yearly) | ✅ | ✅ | ✅ | ✅ Included | ✅ Included | **1 %** |
A **free plan** is a subscription plan you price at **0** — members join and
keep access without paying. Subscriby earns nothing on one, because every
platform fee is a share of what you charge, so only the **Starter** and
**Growth** plans can sell one. On Free, every plan needs a price of at least
the $1.00 USD equivalent. There is no addon for this — pricing the plan or
moving up a plan are the only two ways.
Free plans authored on the Free tier before this took effect have been **taken
off sale** — they no longer accept new members. Nothing was deleted and nobody
lost access: everyone who already joined keeps their membership, and the plan
stays editable. Give it a price, or upgrade to Starter or Growth and switch it
back on. Downgrading to Free is refused while any free plans are still live, so
price or delete them first.
Both features are bundled with Growth, and both can be bought on a cheaper
tier instead of upgrading for them. An addon always bills on the same cycle as
your plan, and Free is monthly-only, so the addon is the monthly price there.
| Addon | Monthly | Annually | Bundled with |
| -------------------------------------- | -------- | --------- | ------------ |
| [Passes Addon](/addons/passes) | $19 / mo | $190 / yr | Growth |
| [Coupons Addon](/addons/coupons) | $5 / mo | $50 / yr | Growth |
The annual price applies on an annual Starter plan. See
[Addons](/addons).
**About the "Lifetime memberships" limit.** This is the only member-count
quota — and it applies **only** to subscribers on non-recurring,
lifetime-validity plans (a one-time paid customer whose access never expires).
Short-term recurring subscribers, trialists, and bot-only "Leads" aren't
counted against it. It's also only applied to *new* lifetime subscribers
joining after you link a resource — earlier lifetime subscribers are
grandfathered.
**Annual plans are billed once a year and include expanded limits** (more
projects, more access codes). Think of them as a yearly commitment discount
plus a small bump in resources. Month-to-month plans are strictly cheaper per
month if you only need a few months of service.
**Both paid tiers open with a 7-day free trial, once per account** — on the
monthly and the annual cycle alike. The transaction fee column above is what
you pay *once the trial converts*: while it runs, your sales stay on the
**Free tier's 10% rate**, since the reduced rate is what the first payment
buys. See [Transaction Fees](/fees#during-your-free-trial).
## What's actually different between tiers
### Transaction fees are where the real savings are
Every paid subscription your members take out is subject to a **Subscriby transaction fee** on top of whatever the payment provider charges. That fee drops quickly as you climb tiers:
- **Free:** 10 % of each transaction
- **Starter:** 3 %
- **Growth:** 1 %
If you're processing $1,000/month in membership revenue, that's $100/mo on Free, $30 on Starter, $10 on Growth. At scale the plan price pays for itself many times over.
### Project and lifetime-membership caps
Every plan limits how many **projects** you can run simultaneously, and caps how many **lifetime memberships** (see the note below) you can serve across them.
- **Free** is designed for hobbyists or testing — three small projects.
- **Starter** is where most serious creators land — ten to twelve projects.
- **Growth** raises both caps substantially — ideal for agencies or anyone running a large portfolio.
Normal recurring subscriptions, trials, leads, and all non-lifetime members are **not** counted against your plan limits.
### Custom handle and public portal
A **handle** is the unique URL slug used by your public-facing portal page (e.g. `my.subscriby.net/my-community`). It's how prospective members land on your plans list.
- **Free** doesn't include a custom handle — the public portal on Free is either disabled or auto-assigned.
- **Starter and Growth** let you pick your own handle, making the portal URL memorable and brandable. See [Creating a project → Handle](/creators/projects).
- **Moving to a plan without custom handles** replaces each chosen handle with a generated one when the change takes effect. The confirmation names the projects affected, and you are emailed the new links.
### Teams (collaborators)
- **Teams** is a **Growth-and-above** feature. Free and Starter creators manage projects alone.
- With Teams you can invite collaborators, assign roles, and split permissions with fine-grained control. See [Teams & Roles](/teams).
### Free subscription plans
- Pricing one of your own plans at **0** — members join and keep access without paying — is a **Starter-and-above** feature. Unlike coupons and passes there is no addon that adds it to a cheaper plan.
- Subscriby earns nothing on a free plan, because every platform fee is a share of what you charge. On Free, every plan needs a price of at least the $1.00 USD equivalent.
- To give access away on any tier, use [access codes](/payments/access-codes) instead. The member redeems a code rather than paying, and you get a free allotment every billing cycle.
### Coupon codes
- **Coupon codes** are bundled with **Growth**, or available on Free and Starter as the **Coupons Addon** ($5 / mo, $50 / yr).
- They let you discount your own plans by percentage or fixed amount, with expiry dates, redemption limits and per-plan restrictions. See [Coupon codes](/creators/coupon-codes).
- Let the entitlement lapse and existing codes stop discounting, not just stop being authored — so wind promotions down before dropping it.
### Time-limited passes
- **Time-limited passes** are bundled with **Growth**, or available on Free and Starter as the **Passes Addon** ($19 / mo, $190 / yr).
- A pass sells one scheduled access window rather than a period that starts at payment. See [Time-limited passes](/creators/time-limited-passes).
- Because customers buy windows ahead of time, you cannot drop the entitlement while pass-enabled plans are still selling — turn those plans off first.
## Payment methods are always required
**Even on the Free plan, Subscriby needs a valid payment method on file.**
This is because transaction fees (10 %, 3 %, or 1 % depending on plan) apply
to every paid subscription your members take out — and we need somewhere to
bill those from. You won't be charged for the Free plan itself ($0/mo), but
per-transaction fees are collected monthly.
When you activate any plan, you'll be redirected to our secure payment processor (**Stripe**) to add a card. From that point on, Stripe is the billing method for your creator subscription and your transaction fees.
## Activate a plan
### Open Plans & Billing [step]
If you've just verified your email and have never activated a plan, Subscriby redirects you straight to **Plans & Billing** on first sign-in. Otherwise, click **Plans & Billing** in the left sidebar.
### Compare plans side-by-side [step]
The billing page shows every plan's key limits and its price. Toggle **Monthly / Annually** to flip the pricing view.
### Click _Try Free for 7 Days_ [step]
Choose the plan you want to start on. Every paid plan — monthly or annual — opens with a **7-day free trial**, and a confirmation dialog summarises what happens before you commit. If you have already used your trial the button reads _Subscribe to [Plan Name]_ instead, and billing starts immediately. You can always upgrade or downgrade later — see [Switching Plans](/subscriptions#upgrading-or-downgrading).
The trial gives you the plan's features, not its commission rate. Sales you
take while it runs stay on the Free tier's **10% rate** rather than your
plan's 1%–3% — the reduced rate is what your first payment buys, and it
applies from the moment that payment is received. Full detail in [Transaction
Fees](/fees#during-your-free-trial).
### Complete payment via Stripe [step]
A Stripe-hosted checkout opens. Enter your card details, name, and billing address. Stripe handles the whole process — we don't see your raw card number.
Your card is collected up front but **nothing is charged until the trial ends**, so the subscription converts by itself and you keep your plan. Cancel before then and you are never charged. The trial is available **once per account** — resubscribing later starts a normal paid subscription billed from day one.
We email you **3 days** and again **24 hours** before the trial ends, naming
the exact amount your card will be charged and the date it happens. If a
connected account is linked, the same warning arrives on its bot. The amount
comes from your upcoming invoice, so it already includes tax, any discount and
the transaction fees your sales accrued during the trial.
### Subscription is activated [step]
Once Stripe confirms, you're returned to Subscriby with your new plan active. You can now create projects, add payment methods, and build subscription plans. See [Create your first project](/creators/projects).
## Promotions and discounts on your own Subscriby plan
When Subscriby is running a promotion on a tier, the discount is applied **automatically** — there's no code to type and nothing to remember. The promotional price is what you see on the pricing table, what you see in the plan comparison, and what you're charged.
This section is about what *you* pay Subscriby. The codes you issue to your
own subscribers are a separate feature — see [Coupon
Codes](/creators/coupon-codes).
### Your price is locked in
Once a promotional price is applied to your subscription, it stays with you for as long as that subscription lives — not just until the promotion ends. When the campaign closes, new signups pay list price and **you keep paying what you signed up at**.
You can confirm this yourself on **Plans & Billing**. A locked-in subscription shows the price you actually pay, a badge naming the promotion, and the current list price beside it:
> **Your Current Plan: Starter — $29.00 USD / per month**
> 🔒 Early Bird — locked in · List price $39.00
If you don't see a badge, you're on list price — that's the normal state for anyone who signed up outside a promotional window.
A locked-in price belongs to the subscription, not to your account. Resuming
during the grace period keeps it, because the subscription never ended. But if
you let it lapse completely and start a new one later, you'll start at
whatever pricing is current then.
### Switching tiers while you hold a promotion
Promotional amounts are set per tier, so what happens when you switch depends on whether the promotion is still running:
- **Promotion still open** — you get the new tier's promotional price. Moving from a tier discounted by $10 to one discounted by $20 gives you the $20.
- **Promotion closed** — your existing discount travels with you to the new tier. You aren't pushed back to list price for changing plan, and you don't lose the benefit by upgrading.
Either way, the discount survives upgrades, downgrades, and switching between monthly and annual billing.
### Invite links
Some promotions are offered through a link rather than published on the pricing page — a launch partner deal or a marketplace campaign, for example. Opening a link like `/promo/summer-deal` and then creating your account applies that promotion to it automatically, and the pricing you're shown in the app reflects it from the start.
Two things worth knowing:
- **Link promotions are for new accounts.** Opening one on an account you already have won't change your existing subscription.
- **One promotion per account.** They don't stack, and the first one applied is the one you keep.
The Early Bird promotion on Starter and Growth ends on 12 September 2026.
Everyone already on an Early Bird price keeps it under the rules above — there
is nothing you need to do to retain it.
## Switching, cancelling, and pausing
### Upgrading
Upgrading is instant: you're prorated for the remainder of your current period, and the new plan takes effect immediately. Increased limits apply right away.
Your remaining trial days carry over to the new plan and nothing is charged or
prorated at the switch — there is no paid time to account for yet. The 10%
rate also holds until your first payment. Full detail in [Switching plans
during your trial](/subscriptions#switching-plans-during-your-trial).
### Downgrading
Downgrades take effect at your next billing cycle — so you keep the current tier's limits until then. If you're over the lower tier's limits (e.g. more projects than the target tier allows), Subscriby will prompt you to reduce before confirming. If any of your projects uses a custom handle and the lower tier doesn't include one, the confirmation lists those projects: when the downgrade takes effect, each gets a generated handle, links to the old address stop working, and you receive the new links by email.
### Cancellation
Cancelling ends your creator subscription at the close of the current billing period. After that:
- You can't create new projects.
- Your existing projects stop processing new subscriptions.
- Your historical data remains visible for a grace period in case you reactivate.
See [Cancelling your subscription](/subscriptions#cancel-subscription) for the full cancellation flow.
## Frequently asked
Yes — that's the recommended path if you're exploring. Activate Free, create a project, connect your bot, [share the bot or portal link](/creators/share), and let your first subscribers sign up through it. Upgrade the moment you feel the caps (project count, lifetime-membership count, or — most often — the 10% transaction fee).
Yes. Transaction fees apply to **every** paid subscription across every tier;
only the percentage changes. On Free it's 10 %, so you're strongly
incentivised to upgrade the moment you start processing meaningful revenue.
We'll retry and email you. Repeated failures can pause your projects' ability
to accept new subscriptions until billing is healthy. Keep your payment method
current from the Billing section.
Yes — for high-volume creators or agencies, we offer Custom plans with higher
limits and dedicated support. Contact sales via the Help menu.
Once one has been arranged for you, it appears at the top of **Plans & Billing**
marked _Offered to you_, above the standard tiers. It is tied to your account:
nobody else can see it or subscribe to it, and it never appears on public
pricing. You can switch to a standard plan later and come back to it whenever you
like — it stays reserved for you.
No. Every creator account needs a valid payment method before it can launch projects — see the callout above.
## Related
- [Subscriptions & billing logic](/subscriptions) — deeper dive into grace periods, upgrades, downgrades, and cancellations.
- [Transaction fees explained](/fees) — the full mechanics of how we calculate and bill fees.
- [Create your first project](/creators/projects) — the next step once your plan is active.
---
# Support Inbox
Source: https://docs.subscriby.net/creators/support-inbox
By the end of this page you'll know how members reach you, where their messages land, and how to answer from the dashboard or straight from your platform's chat.
Your project bot already handles commands, checkouts and access codes. The support inbox handles everything else: when a member types something the bot doesn't recognise, it becomes a **support conversation** instead of an error message.
**This is on by default.** Any project with a connected bot already accepts
support messages — there is nothing to switch on. Open **Support Inbox** in
the project sidebar to see what has arrived.
## What becomes a support message
A member's message lands in the inbox when it is **not** any of the following:
- A recognised bot command (`/start`, `/plans`, and the rest).
- An access code — those still redeem as normal.
- An answer the bot was waiting for. If the bot has just asked a member a question as part of a flow, their reply goes to that flow, not to your inbox.
Everything else arrives: plain text, photos, videos, voice notes, documents, stickers, locations and shared contacts. A photo sent with no caption arrives too — before the inbox existed, that got the member an "I can't understand your command" reply.
## One thread per member
There is exactly one conversation per member, per channel. It's a chat thread, not a ticket queue, so a member who writes to you in March and again in September lands in the same thread with all the history intact.
This is why **resolving is not closing**. Resolving clears a conversation out of your open queue; if the member writes again, the thread reopens by itself and comes back to the top.
## The inbox
Open **Support Inbox** from the project sidebar. The page is two panes: conversations on the left, the selected thread on the right. The sidebar item carries a badge with your unread count.
Five tabs across the top narrow the list:
| Tab | Shows |
| --------------------- | ----------------------------------------- |
| **All Conversations** | Everything, newest activity first. |
| **Open** | Needs attention. |
| **Pending** | Waiting on the member. |
| **Assigned to Me** | Threads handed to you specifically. |
| **Resolved** | Answered and cleared — kept, not deleted. |
Search matches the member's name and the text of any message in the thread, so you can find a conversation by something the member said months ago.
The inbox is **per project**. Each project's bot has its own members and its
own inbox, reached from that project's sidebar. If you run several projects,
switch project to switch inbox — there is no combined view in the dashboard.
(The [API](/api/v1/reference/support-inbox) and the [MCP
tools](/mcp/v1/tools/support#list-support-conversations) *can* read across projects in
one call, and both take a `project_id` filter.)
## Answering
### From the dashboard
Open a thread and type in the composer at the bottom. Your reply reaches the member in their chat with the bot, prefixed with your display name so it reads as coming from a person rather than from the bot.
The right-hand pane also shows you who you're talking to — their status, current plan, member-since date and how many times they've written — so you can answer without opening another page.
Attachments go both ways. Drop a file into the composer to send it; media a member sends you renders inline in the thread. Subscriby fetches a member's file from the platform the first time the thread is opened and keeps its own copy, so the thread keeps rendering after the platform's own download link has expired.
### From the bot
When a member writes, Subscriby sends **you** a direct message through its bot on your linked platform with a summary of who they are and what they said, plus two buttons:
- **Reply** — type your answer straight into the DM and it goes to the member.
- **Open Inbox** — a magic link into the dashboard thread, already signed in.
This is what makes support workable from a phone. You never have to open the dashboard to answer.
**You get one DM per conversation per five minutes.** A member sending ten
messages in a burst produces one notification, not ten. Everything is still in
the thread.
### In a group with topics
If a team answers your members, pick **Group With Topics** instead. Every member conversation then becomes its own topic in a group you control, and anyone in the group can answer by writing inside that topic.
1. Create a group on your platform and turn on **Topics** (threads) in its settings.
2. Add your **project bot** (the bot your members talk to) as an administrator with permission to manage topics.
3. The bot posts a confirmation into the group and DMs you; the settings modal shows **Support group connected**.
From then on the member's first message opens a topic named after them, with the same summary the DM carries; later messages land in that topic; anything a group member writes in the topic goes back to the member as your reply. Nothing is throttled here — the topic is the thread. If the bot is removed from the group, it tells you, and messages reach only the dashboard until you connect a group again.
## Settings
Open the settings modal from the inbox toolbar.
| Setting | What it does |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Accept support messages** | Turn the inbox off entirely. Unrecognised messages go back to getting "I can't understand your command". |
| **How should I notify you?** | `Direct Message to Me` (the default), `Group With Topics` or `Dashboard Only`. With **Dashboard Only** you rely on the dashboard badge. |
| **Display name on your replies** | The name members see on your replies. Defaults to the project name. Max 60 characters. |
| **Automatic acknowledgement** | An immediate reply so a member knows they've been heard, e.g. "Thanks — we usually reply within a few hours." Max 1000 characters. Leave empty for none. |
| **Also email me** | Send an email alongside the bot's direct message. |
If you have no platform account linked to Subscriby, there is nowhere to send
the message. Support still works and messages still arrive in the dashboard —
you just won't be pinged in a chat. Link your account, or turn on **Also email
me**.
The automatic acknowledgement is sent at most **once an hour per conversation**, so a member writing several times in a row does not get spammed by it.
## Saved replies
For the questions you answer constantly, save the answer once. Open **Saved Replies** from the inbox and add a title, the reply text, and an optional shortcut.
In the composer, saved replies are one click away — pick one, edit it if you need to, and send.
## Assignment, internal notes and blocking
- **Assign** a thread to a teammate so it's clear who owns it. Assignment is advisory — it doesn't stop anyone else on the team from replying — and the **Assigned to Me** tab is what makes it useful. Clearing an assignment is allowed.
- **Internal notes** are recorded on the thread but never delivered to the member. Use them to leave context for a teammate. They raise no webhook and don't appear anywhere the member can see.
- **Blocking** a contact stops their messages from arriving at all. Their history stays; new messages are dropped, they can't reopen a resolved thread, and they get no indication they've been blocked.
## When a reply doesn't arrive
A reply can fail to reach a member, and the thread tells you which:
- **Unreachable** — the member has blocked your bot. The reply is stored but will never arrive, and retrying won't help. There is no way to message someone who has blocked the bot; you'd need to reach them another way.
- **Failed** — something else went wrong. Subscriby retries automatically before marking it failed.
## Automating it
Every part of this is reachable from outside the dashboard:
- **[Webhooks](/webhooks/v1/events/support)** — twelve `support.*` events, so member questions can page an on-call rota or open a ticket in your own helpdesk. Message bodies are deliberately kept out of webhook payloads; fetch them over the API.
- **[REST API](/api/v1/reference/support-inbox)** — list, read, reply, assign and resolve.
- **[MCP tools](/mcp/v1/tools/support#list-support-conversations)** — let an AI assistant triage the queue and draft replies for you to approve.
- **[Zapier](/integrations/zapier)** and **[n8n](/integrations/n8n)** — the same operations as prebuilt steps.
## Frequently asked
Yes. Anyone who can reach your bot can open a conversation, including someone
who has never paid. Their status in the thread sidebar shows as **Lead**,
which is usually a signal worth acting on — it's a pre-sales question.
Inbound messages are rate-limited per member. Once someone crosses the limit
their extra messages are dropped silently — no reply, no notification, no
event. They aren't queued up to arrive later either.
The message updates in place in your thread and is marked as edited. You don't
get a duplicate, and no new notification fires.
No — that's the point of **Display name on your replies**. A platform won't let
a bot change its sender name per message, so Subscriby prefixes your reply
with your display name. The member sees the bot as the channel and you as the
author.
Only with a token that can also *write* to conversations. Reading a private
remark about a member takes the same permission as writing one, so a read-only
token never sees notes.
No. If the bot is waiting on an answer from a member, their reply goes to that
flow. The inbox only ever gets messages nothing else claimed.
## Related
- [Contacting support](/subscribers/contacting-support) — what your members see
- [Managing Members](/creators/managing-members)
- [`support.*` webhook events](/webhooks/v1/events/support)
- [Support Conversations API](/api/v1/reference/support-inbox)
- [Member Support Inbox on subscriby.net](https://www.subscriby.net/features/support-inbox) — the feature at a glance, its limits and the questions creators ask
---
# Time-Limited Passes
Source: https://docs.subscriby.net/creators/time-limited-passes
An ordinary subscription starts the moment someone pays. A **time-limited pass** doesn't —
it grants access during a window _you_ schedule. Someone can buy your Sunday pass on
Wednesday, be admitted Sunday morning, and be removed Sunday night, without you touching
anything on the day.
One purchase buys one window. To attend another window, the customer buys another pass — or
you bundle a whole slate of windows into a [Pass Series](./pass-series) and sell it as a
season ticket.
Time-Limited Passes is included in **Growth**. On **Free** and **Starter** you
can unlock it with the **Passes Addon**, billed on your plan's cycle —
**$19 USD per month**, or **$190 USD per year** on an annual Starter plan.
Free is monthly-only, so the addon is always the monthly price there. See
[Addons](/addons/passes) for what happens when you add, remove or change it.
Ordinary recurring plans are available on every tier, including the free one.
## When to use one
Passes fit anything sold by the occasion rather than by the month:
- A sports handicapper selling per-slate access — Thursday, Saturday, Sunday, Monday
- A trader running a London-session or market-open room
- An educator selling a seat in a scheduled class or cohort
- An analyst opening a channel on the day a report drops
If your product is an ongoing membership, use an ordinary [subscription plan](./plans)
instead. If a buyer should get a whole set of dates from one payment — a season, a course, a
cohort — build the pass plans first and then bundle them into a
[Pass Series](./pass-series).
## The three kinds of plan
Every plan starts with one choice, and it is the choice that decides which half of the form
you see next.
| Kind | What it sells | Renews |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- |
| **Recurring Subscription** | The traditional subscription. Nothing is scheduled — members pay each cycle and keep access for as long as they keep paying. | Yes, on a cycle |
| **Time-Limited Pass** | A ticket to one dated session — workshops, match days, one-off events. Access opens and closes on its own and never renews. | No |
| **Pass Series** | A season ticket over many passes — seasons, courses, cohorts. One payment covers every pass you bundle from your pass plans. | No |
This page is the middle one. The distinction that matters against the third: a **pass plan
owns its own dates** and generates them from a schedule, while a **series owns none** — it
points at dates that already exist on pass plans like this one. Build these first, and a
[Pass Series](./pass-series) can then bundle them.
Both scheduled kinds are unlocked by the same capability and neither carries an extra
transaction fee.
## Creating one
### Choose Time-Limited Pass [step]
Open **Create New Subscription Plan**, pick **Time-Limited Pass** from the three cards at the
top, then fill in the name, description and linked resources as usual.
The pricing, trial and recurring sections are replaced by **Pass Schedule** — a pass is a
one-off purchase, so there is nothing to renew and no trial to run.
Switching the card at any point discards the shape you moved away from: schedule slots are not
kept if you save the plan as a subscription. Only the shape the plan is saved as is persisted.
### Pick the timezone [step]
Times you enter are **wall-clock times in this timezone**. A 9:00 AM window stays 9:00 AM
after a daylight-saving change rather than drifting by an hour.
It is prefilled from your account timezone, which Subscriby detects from your browser the
first time you sign in. If the plan's timezone and your browser disagree, a warning appears
under the field showing what your window times would actually mean — the one check that
catches a zone you simply mis-picked.
A pass is nothing but scheduled times, so a zone nobody ever confirmed puts windows on
sale at an hour you never chose — and every screen afterwards renders that same zone, so
nothing else in the product would ever disagree with it.
Until you confirm it, **Activate Subscription Plan** is replaced by **Activate — Confirm
Your Timezone First**. Confirming takes one click in Localization settings: choosing a
zone there — even the one already shown — counts. New pass plans are also created
switched off for the same reason, so you see the generated dates before anything can
sell.
### Set the price and how often it repeats [step]
**Currency** and **Charge** work exactly as they do on an ordinary plan — a pass can be
priced in any currency an active payment method supports, and the price shown beside
**Add Window** is what **each** window sells for, not the whole series.
**Place Each Pass by Hand** decides how the schedule is built:
- **Off (the default)** — windows are generated from a pattern. Best for a regular slate.
**Repeats** then sets the pattern: **Daily**, **Weekly** or **Monthly**.
- **On** — no pattern at all. **Repeats** disappears, and you place each date by hand from
**Manage Access Windows** after saving. Best for one-off events: a single match, a
one-time workshop.
A hand-placed date is never removed when you later edit a repeating schedule, so you can
also add one to a repeating plan for the occasional extra session that doesn't fit the
pattern.
**Stop selling** closes sales a chosen number of minutes before each window — and the
dropdown beside it chooses **which end** those minutes count back from. That second control
does more than it looks: it decides whether a window can be bought at all once it has
started.
| Anchor | What it means |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **before a window starts** (default) | Sales close that many minutes before the window opens, and nothing is sold once it is running. Leave the number at `0` to sell right up to the start. |
| **before a window ends** | The window **keeps selling while it runs**. Someone can join a session already in progress and be let in straight away — their access still ends when that window closes. Minimum 5 minutes, and the window itself must be longer than 5 minutes. |
Existing plans are on **before a window starts**, so nothing you already sell changes.
A mid-window buyer pays the **full price** for whatever is left. Someone
buying at 2 PM on a 12:00–23:00 window pays the same as someone who bought on
Wednesday, and is removed at 23:00 with everyone else. That is deliberate —
see [one price, every window](#why-moving-them-is-safe-one-price-every-window)
— and the cutoff is the control that stops the last-minute case feeling
unfair.
Also see [When a payment lands late](#when-a-payment-lands-late).
### Add your access windows [step]
Each row is one window with its **own length**, so a single plan can mix them:
| Day | Starts | Lasts | Unit |
| -------- | ------ | ----- | ----- |
| Thursday | 19:00 | 3 | Hours |
| Saturday | 12:00 | 10 | Hours |
| Sunday | 09:00 | 14 | Hours |
Choose **Repeats** first — weekly asks for a day, monthly asks for a day of the month, and
daily just asks for times.
**Monthly days 29, 30 and 31 skip months that don't have them.** They are never rolled
back to the 28th — a window on the 31st runs in January, March, May, July, August, October
and December only, so **7 times a year rather than 12**. The 30th skips February; the 29th
skips February in non-leap years.
There is no "last day of the month" option, and adding slots for 28, 30 and 31 does not
make one — it generates three separate windows in any month that has all three days. For a
window in **every** month, pick a day of 28 or lower.
### Save and check the dates [step]
Subscriby generates the actual dates from your schedule. Open **Manage Access Windows** from
the plan's row menu to see what it produced, how many customers hold each window, and to
cancel one if plans change.
Each date is listed with its time range in the plan's timezone, a status badge — **Scheduled**
before it starts, **Open** while it runs, **Closed** once it's finished, **Canceled** if you
cancelled it — and an **N sold** count. A window that runs past midnight also carries the day
it closes on, so an overnight session is never mistaken for one that ended the same morning. Both are read live, so a purchase or a window opening
shows on your next look at the panel. Yesterday's windows stay listed so a session that has
just finished is still visible.
Beside the sold count, an amber **N not in queue** badge appears when some of those buyers
have not tapped their invite link yet. That is the number who are relying on noticing a
message on the day rather than being admitted silently, and it is the only thing on the panel
you can still do something about — see [Nudging them
yourself](#nudging-them-yourself-from-the-window-panel) below.
Once anyone holding the window has been reminded — by the automatic ladder or by you — the
top-right corner of the card reads **Last reminded 6 hours ago**. Hover it for the exact date
and time. It is absent until the first reminder goes out.
## Creating one from the bot
The plan wizard on the Subscriby bot builds pass schedules too, so a slate can go on sale
from your phone.
Start **Add Plan** on your project as usual. After the price, the bot asks how customers
should get access — choose **Time-Limited Pass** and it takes over from there:
- **Timezone** — your account timezone is offered as a button, alongside a short list of
common zones. Anything else you type is accepted, so `Europe/Madrid` and `GMT` both work.
Older names are resolved to their modern equivalent rather than rejected, so
`Asia/Calcutta` is stored as `Asia/Kolkata` and `US/Eastern` as `America/New_York`.
- **Repeats** — **Daily**, **Weekly** or **Monthly**, the same three patterns as the
dashboard.
- **Access windows** — send one per message as day, start time and length:
`Sunday 19:00 3h` on a weekly plan, `15 19:00 3h` on a monthly one, or `19:00 3h` when it
repeats daily. Day names work in English or in your bot's language, times take `19:00` or
`7pm`, and lengths take `m`, `h` or `d`. Tap **Done** when the slate is complete, or
**Remove Last** to drop the window you just added.
- **Stop selling** — send a number of minutes, or tap **Skip** to sell right up to each
window opening. Any number above zero then asks whether it counts back from when a window
**starts** or when it **ends**, the same choice as the dashboard.
Choosing a pass skips the billing cycle, trial and renewal questions — a pass has no cycle
to bill and nothing to renew — and goes straight to linked resources. The plan is created
inactive, exactly as the dashboard leaves it, and the bot reports how many windows went on
sale.
The bot only builds **repeating** schedules. **Use individual dates instead of
a pattern** needs an explicit date and time for every window, which no chat
flow handles well, so place those from **Manage Access Windows** in the
dashboard or through the [REST API](/api/v1/reference/plans).
## Adding a date by hand
**Manage Access Windows** has an **Add a Window** form at the bottom: pick a **Date** and a
**Starts** time, set how long it **Lasts**, and add it.
This is how a fixed-dates plan gets every one of its windows. On a repeating plan it's for
the exception — a rescheduled match, a bonus session — because a hand-placed window is
**never removed when you edit the schedule**. Only generated windows are rebuilt.
Times are wall-clock in the plan's timezone, exactly like the slot times above, so 14:00
means 14:00 where your audience is rather than wherever you happen to be.
Four things are refused, with the reason shown:
- a start time in the past
- a window shorter or longer than the configured limits
- a start time this plan already has a window for
- on a plan set to **before a window ends**, a window no longer than its own cutoff — it
would close its sales before it ever opened
That last one matters most on a fixed-date plan. Those have no repeating slots, so there is
no schedule for the checks above to measure and this is the only place the length is
checked against the cutoff.
To remove a hand-placed date, cancel it like any other window — holders get moved to the
next one or told to ask you for a refund, which is why there's no plain delete.
## Cancelling one customer's pass
Open **Subscriptions**, find the purchase and choose **Cancel Subscription**. Cancelling a
pass does two things: the customer is not admitted when the window opens, and **that date goes
back on sale for them**, so they can rebook it or pick a different one.
That second part is what makes cancelling the right tool for clearing up test purchases. A
date stays reserved for whoever holds it, so a live pass keeps its window off their list —
but a cancelled one releases it.
Cancelling does not remove anyone already inside the channel. If the window is running and
you want them out now, cancel the pass and then remove them from the place on your platform.
A cancelled pass reads **Cancelled** on its own, with no lifecycle badge beside it. An ordinary
membership cancelled with time still on it stays **Active**, because access genuinely runs to
the end of the period already paid for — but a pass has no such period. The window sweep admits
only holders who have not cancelled, so "Active" there would promise access that cannot arrive.
Cancelling sends them **no message**. Their invite link keeps looking valid, and they find
out by not being let in when the window opens.
If you want them informed, either message them yourself or **issue a refund instead** — a
refund revokes access the same way and does message them, naming the amount and the window
they no longer hold. See [Refunds](/creators/managing-subscriptions#refunds).
## What your customer sees
1. They pick a **date** at checkout — the next one is preselected, and dates they already
hold are hidden. On a plan that sells through its windows, one already running is marked
**On now** and leads the list, because it is the one they get into immediately.
2. After paying, the bot names the window they bought — plan, start, end and how long until
it opens — and their invite links follow in the next message. The portal's confirmation
screen states the same window, so the date is visible without leaving the browser.
3. Tapping the link puts them in a **queue**. The bot confirms this straight away and states
when access opens, when it ends, and how long they'll have.
4. When the window opens they are admitted automatically, whether or not they are at their
phone — and the bot says so, with a **Join** button for every channel or group the pass
covers and a countdown to when access ends. That message replaces the generic resource
list at this moment, so what they get is unmistakably about the window they were waiting
for rather than a repeat of their purchase receipt.
5. When it closes the pass ends and they are removed — **unless another pass of theirs, or
an ordinary subscription, still grants that same channel.** Removal is decided per
channel, so a customer who holds a Sunday pass and a monthly membership keeps their seat
when Sunday's window closes.
The queue is what makes a pass feel automatic. A customer acts once, days
early, and is let in at the right moment without being present.
### Where a holder can check their own pass
A pass is not an ongoing membership, so it deliberately does not take over the bot's home
screen — otherwise buying Sunday would hide Thursday, and selling several slates is the whole
point. Instead a **Your passes** block sits above the plan list, listing up to three of the
soonest with a **My pass** button for each.
Opening one shows everything about it: what was paid, the exact window in your schedule's
timezone, a countdown to when it opens or ends, whether their join request is in the queue,
and what the plan includes. A pass they can no longer use is left out entirely rather than
listed with "access ended" under a heading saying what they hold.
The web portal carries the same detail. The plan card shows their own window and countdown
beside **Manage** — distinct from the strip at the top of the card, which counts down to the
next window on **sale** and is rarely the one they bought — and **My Memberships** repeats it
per membership.
Every one of those surfaces states the join status, and it is the one thing a
holder can still act on. **In the queue** means they tapped their link and will
be admitted automatically. **Join request not sent** means they have not, so
there is nothing waiting for the window opener to approve and they are relying
on noticing a message on the day.
That distinction decides what every reminder below asks of them, and it is why
the status is shown rather than left implicit.
### They can put it in their calendar
The portal offers every pass holder **Add to your calendar** — a subscription URL that puts
their window into Apple Calendar, Google Calendar or Outlook, with a reminder an hour before
it opens.
This matters more on a single pass than it looks. A holder acts once, days early, and then
has nothing to do until the window opens — which is exactly the situation where a date slips
a mind. The reminder ladder chases them on their platform; the calendar puts it where they plan the
rest of their week.
Nothing to configure. The link is minted the first time a holder opens their membership
screen, so most purchases never carry one at all.
It works without a login, so anyone they forward it to can see their dates. If
they share it by mistake, **Shared this by mistake? Get a new link** rotates
it — the old address stops working immediately and they resubscribe with the
new one.
You cannot rotate it for them; it is on their own membership screen.
### Every holder is reminded before the window opens
A holder who never taps their link isn't locked out — links are reissued when the window
opens — but they then have to be at their phone to use them, and on a three-hour window
noticing an hour late costs a third of what they paid for.
So the bot messages holders automatically: about **a day** before the window opens, and again
about **an hour** before. Both name the plan, the window and an exact countdown to the
opening. What the message asks of them depends on where they stand:
- **Not in the queue** — their invite link is attached as a button, so joining is one tap.
- **Already queued** — they're told there's nothing to do, that they'll be admitted
automatically, and that the window goes ahead as scheduled unless you cancel it. No
buttons, because a second tap would only produce a request the platform refuses.
Someone who queued a fortnight ago has forgotten it exists, and a dated window is worth
raising before they plan their evening around something else — which is why the reminder goes
out either way rather than only to the people with something left to do.
A milestone that had already passed at purchase is skipped rather than fired late, so a
customer buying two hours ahead gets the hour reminder only. You don't configure or trigger
any of this.
### You're reminded too, with the roll call
A window is a live event you have to be ready for, so the bot tells you about it three times:
| When | What it is for |
| ----------------------- | --------------------------------------------------------------------------------------------- |
| **An hour before** | The full roll call, while there is still time to chase anyone who has not queued. |
| **15 minutes before** | A short final call — the countdown, who is expected, and who still has not tapped their link. |
| **The moment it opens** | What actually happened: how many were admitted, and whether anyone is still to arrive. |
The first two name the project, the plan, the window and the countdown, then give you the
numbers that matter:
| Line | What it counts |
| ------------------------- | --------------------------------------------------------------------------------------------- |
| **Passes sold** | Every purchase ever bound to this window, including ones later undone. The commercial figure. |
| **Active now** | Holders who will actually turn up — not cancelled, not expired. |
| **Refunded or cancelled** | Purchases that came undone, whether by refund or cancellation. |
| **In the queue** | Active holders who tapped their link and will be admitted automatically. |
| **Not in the queue** | Active holders who haven't, and will need to be at their phone. |
The last two are the point. **Not in the queue** is the only figure you can still change
before the doors open, so when it is above zero the notice points you at **Send Reminders**
in **Manage Access Windows**; when it is zero it says so plainly, which saves you opening the
panel to check.
There is a third case, and it is the one worth reading carefully: if every pass sold has since
been **refunded or cancelled**, the notice says nobody is due to arrive rather than telling you
everything is in order. An empty queue and an empty window look identical in the numbers alone.
If your **stop selling** cutoff is anchored to the window's **end**, the window is still on
sale after it opens — so a pass can be bought during the session. Any notice sent while that is
true says so, because "2 sold" mid-session is a running total, not a result.
With the cutoff anchored to the **start**, sales are closed by the time the doors open and the
figures are final.
Only windows somebody has actually bought are announced. An empty slot on your
recurrence is not an event, and reminding you about every one of them would
turn the notice into something you mute.
### Nudging them yourself, from the window panel
Two rungs a day apart cannot cover every reason you might want to reach these buyers. A venue
changed, the slate went up late, or a window was bought long enough ago that both milestones
have already passed — and there is nothing scheduled between now and the window opening.
Open **Manage Access Windows**, and any window with an amber **N not in queue** badge gets a
**Send Reminders** button beside **Cancel Window**. Confirm, and everyone in that count is
messaged with their own invite link attached. The button reports back — _"7 purchases have
been notified."_ — and the number is what the platform actually accepted, not what was attempted,
so a buyer who has blocked your bot is excluded from it rather than counted as reached.
The message is headed **Reminder from** your project name and says outright that you sent it
by hand, so a nudge landing outside the usual times doesn't read as the bot misfiring. It
names the plan, the window and an exact countdown — to the opening, or to the close if the
window is already running — and carries one button per linked resource with that customer's
own invite link, so joining is one tap and they never have to go looking for a message from
days ago.
The button only reaches buyers who are still out of the queue, and only on a window that can
still be entered — a cancelled or finished window has nothing to nudge anyone about, so it
gets neither the badge nor the button. Anyone who joins the queue between the panel being
drawn and the button being pressed is skipped.
Sending a reminder counts as that window's most recent one, so the automatic ladder will not
follow it minutes later with the same thing. A **later** milestone still fires normally: nudge
someone two days out and they still get the one-hour reminder.
### Nudging one customer
When a single buyer has written in to say they cannot find their link, the whole window is the
wrong instrument. Open that subscriber from **Users**, and their pass card carries a **Queue
status** badge — **Join request not sent**, **In the queue** or **Admitted** — a **Last
reminded** line, and a **Send Reminder** button when there is something to remind them about.
It sends exactly the message above, to exactly that person.
### Saying something else to them
**Send Reminders** sends one fixed message: your invite link is waiting, here it is. When you need to tell holders something else — a venue change, a kick-off delay, a thank-you after the final whistle — use a [broadcast](/creators/broadcasts) instead.
The broadcast composer offers the same populations as audience segments: **Active Pass Holders** and **Active Pass Holders Not in Queue** for one window you pick, and **All Active Pass Holders** / **All Active Pass Holders Not in Queue** across every upcoming window at once. The window picker there shows the holder count each option would reach, so you can see who a message lands on before writing it.
### If a purchase produces no access
Provisioning can fail for reasons that have nothing to do with the payment — most often the
bot no longer being an administrator with the **Invite Users via Link** right on the channel.
When a paid purchase produces no invite links at all, both sides are told: the customer gets
a message saying their payment is safe and offering **🔄 Refresh Invite Links**, and you get
one naming the customer, the plan, and the likely cause. Fix the bot's permissions and the
customer's refresh will then work — you do not need to re-issue anything yourself.
A plan whose resources are all **manual** is not a failure and stays silent: there are no
links to issue, and the customer's confirmation screen says you will arrange access directly.
## When a payment lands late
Buying and paying are not the same instant. A customer taps **Pay** at 10:59 for an 11:00
window, and the payment provider confirms it at 11:00, 11:01, or — on a card that needs 3-D
Secure, or a crypto payment waiting on confirmations — considerably later. Subscriby only
learns the payment succeeded when the provider's webhook arrives.
Three things can happen, and all three are handled without you intervening.
### The window has not started yet
The normal case. The pass is bound to the window they chose and they are admitted when it
opens, exactly as if they had paid a week earlier.
### The window has already opened
They are bound to that window and admitted **within about a minute**, keeping whatever is
left of it. Someone confirmed at 11:01 on an 11:00–14:00 window gets 2 hours 59 minutes.
This is worth understanding because the admission does not come from the window opening —
that already happened. A separate catch-up runs every minute and admits anyone whose payment
landed after their window opened, so late arrivals are never stranded waiting for an event
that has passed.
If they tap their invite link before that catch-up runs, they are let straight in. Either
route counts as attending; neither is recorded as a missed window.
The same machinery is what makes selling during a window work: on **before a window ends**
this is not a late payment being rescued, it is the normal path. Set that anchor and the
catch-up becomes the front door.
The catch-up is scheduled once a minute, but that is not a promise: it will not
start while the previous run is still going, so a slow pass can push the next
one several minutes out.
Sell into the final seconds and a customer can pay, receive a working invite
link, and have the window close before anything reaches them. Five minutes is
the floor for that reason. If such a purchase does slip through, Subscriby
records it as **Access Ended** rather than **Never Joined** — it will not tell
you a paying customer no-showed when the timing was ours, because that is
exactly the case where you may owe a refund.
### The window has already ended
They are **not** bound to a window that can no longer deliver anything. Subscriby moves them
to the **next available window** on the same plan and their pass runs then instead, and
messages them to say so — naming the window they missed, the one they now hold, and telling
them to contact you if that date does not suit.
If the plan has no further window — a one-off, or a series that has finished — the purchase
is left unbound and logged for you to resolve. The customer is told plainly that they were
given no access and should contact you for a refund. Check **Manage Access Windows** after
any schedule ends and refund through your payment provider.
### Why moving them is safe: one price, every window
Every window on a plan sells for the **same price**, which is what makes this automatic move
fair rather than presumptuous. Subscriby treats the windows on one plan as interchangeable —
same price, so the same content and experience is assumed throughout.
If your windows are **not** interchangeable — Sunday's slate is worth more than Thursday's, a
weekend intensive is not the same product as a weeknight Q&A — do not model them as one plan
with several slots. **Create a separate pass plan for each**, priced and named on its own.
Then nobody is ever moved between two things you consider different.
Rule of thumb: put windows on the same plan when a buyer would be happy to
attend any of them for the price. Split them into separate plans when they
wouldn't.
This is the case worth designing away, because the customer has paid and
received nothing on the date they wanted. A **sales cutoff** closes the gap.
### Use the sales cutoff to control this
**Stop selling** closes sales a set number of minutes before each window, at whichever end
the anchor names. It exists precisely for this race.
On **before a window starts** — nothing is sold once a window is running:
| Cutoff | Effect |
| ------------- | ----------------------------------------------------------------------------------------- |
| `0` (default) | Sells right up to the start time. Maximum revenue, maximum chance of a late confirmation. |
| `15` | A comfortable margin for card payments, including 3-D Secure challenges. |
| `60`+ | Sensible if you accept crypto, or want time to prepare before anyone is let in. |
On **before a window ends** — the window stays on sale while it runs:
| Cutoff | Effect |
| ------ | ----------------------------------------------------------------------------------------------------------------- |
| `5` | The minimum. Sells almost to the end; leaves just enough time for a card payment to confirm and admission to run. |
| `30` | A comfortable margin, and the same advice as the start anchor. |
| `60`+ | Sensible on crypto, or when the last hour is not worth selling. |
The cutoff hides the window from checkout — it does not affect anyone who already bought it.
Slot lengths are per-slot on purpose, so one plan can run a three-hour Thursday
beside a fourteen-hour Sunday. A single **before a window ends** cutoff lands
differently on each.
At 240 minutes, that three-hour Thursday would stop selling an _hour before it
even opened_, while the Sunday still sold for ten hours. Subscriby refuses that
and names the window holding the ceiling down, because nothing about the
schedule would look wrong — the number is plausible, the dates are right, and
the window would quietly never sell.
The shortest window Subscriby allows is 5 minutes, and the closing margin is
also 5 — so on such a window no number works: anything below the margin is
refused for being too close to the end, and anything at or above it swallows the
window whole.
Subscriby therefore refuses the **anchor** rather than the number, so you are not
left retyping values that each fail for a different reason. Use **before a window
starts**, or make the window longer than 5 minutes.
## Changing a schedule
Editing slots rebuilds future dates — **except any window a customer has already bought**.
Those keep their original times and are never moved or deleted, because someone paid for
that occurrence.
So after a change a plan can legitimately show an old Saturday 12:00 that 47 people bought
alongside a new Saturday 13:00 for future buyers. The windows panel marks the sold ones.
## Cancelling a window
A postponed game is the one case where a sold window disappears, and it is never automatic.
Cancel it from **Manage Access Windows** and every holder is either **moved to the next
available window** on the same plan, or — if there is none — has their pass ended and is told
to ask you for a refund. Everyone affected is messaged either way.
There is no reinstate button, and there is no way to re-create a window at the
same start time afterwards: the cancelled row keeps that slot, so schedule
regeneration skips it and **Add a Window** refuses it with _"This plan already
has a window starting at that time."_
Only that one occurrence is affected — a weekly plan still generates every other
Saturday. But if you cancel Sat 29 Aug 08:00 and then want it back, the closest
you can get is a hand-placed window at a different time, say 08:05.
## Postponing one properly
The order matters, because the replacement is chosen at the moment you cancel. Cancel first
and you are gambling on whatever the schedule happens to offer next — which may be a fortnight
away, or nothing at all.
### Add the replacement window first [step]
Use **Add a Window** at the bottom of the panel and give it the corrected date and time.
On a repeating plan the next occurrence is usually already there, so there is nothing to do —
check the panel before adding a duplicate.
### Check it is still on sale [step]
The replacement only counts if it satisfies your
[sales cutoff](#use-the-sales-cutoff-to-control-this). A window already inside its cutoff is
closed to sales, and is therefore not offered as a replacement either.
### Then cancel the old window [step]
Every holder lands on the date you just added, because it is now the soonest one they don't
already hold.
A window only counts as a replacement while it is **still sellable**. On a plan
with a 60-minute _before a window starts_ cutoff, a window opening in 30 minutes
is already closed to sales — so it is not offered as a replacement either.
The panel will show you that later date sitting right there while every holder is
expired and told to ask for a refund. If you are cancelling at short notice, add a
replacement far enough ahead to clear your own cutoff.
### What the customer receives
Everyone affected is messaged, and the wording differs by where they stood.
| Their situation | What they're told |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Moved, and had already tapped their link | Both dates, plus that they're already in the queue and will be admitted automatically. Nothing is asked of them. |
| Moved, but hadn't joined the queue yet | Both dates, plus their own invite link as a button so they can queue for the new date. |
| Stranded, because nothing was available | The date that was cancelled, that their pass has ended, that nothing further will be charged, and to contact you about a refund. |
None of them invites the customer to buy again — an invitation to buy the next one is exactly
wrong for somebody stranded because there is no next one.
The exact wording is on the subscriber page:
[If the organiser cancels your window](/subscribers/using-a-pass#if-the-organiser-cancels-your-window).
### A worked example
A Saturday football plan, weekly at 08:00, £15, 41 sold for **Sat 29 Aug**. The pitch floods on
the Thursday.
| Step | Result |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Add a window for **Sat 5 Sep, 08:00** — already there as part of the weekly pattern | Nothing to do |
| Cancel **Sat 29 Aug** | 38 holders move to 5 Sep. 2 had cancelled their own pass — untouched. 1 was past due — untouched. |
| Toast reads | _"38 customers were moved to the next window and 0 need a refund."_ |
| The 12 who had queued | Stay queued for 5 Sep, nothing asked of them |
| The 26 who hadn't | Get the message with their Join button |
| 5 Sep now shows | Its own original buyers **plus** the 38 moved across |
Nobody is moved onto a date they already hold — a holder of both 29 Aug and 5 Sep skips to
12 Sep instead.
### When to cancel a window — and when not to
| Situation | Use |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Match postponed, same fixture a week later | **Cancel the window.** This is exactly what it is for. |
| Venue changed, same date and same value | **Don't cancel.** Nothing about the window changed — [broadcast](/creators/broadcasts) the holders instead. |
| Wrong time entered, nobody has bought yet | **Edit the schedule.** Unsold generated windows are rebuilt; cancelling would burn that slot permanently. |
| Wrong time entered, people have bought | Add the corrected window, then cancel the old one. The old start time is then unusable — pick the real time, not the wrong one. |
| One customer needs removing | **[Cancel their pass](#cancelling-one-customers-pass)**, not the window. |
| Event cancelled outright, no replacement coming | Cancel the window, then **refund every holder by hand**. They are told to ask you; nothing is refunded automatically. |
| Sunday is worth more than Thursday and you cancel Sunday | Holders get moved onto Thursday at Sunday's price. If that is wrong, [split them into separate plans](#why-moving-them-is-safe-one-price-every-window) before this ever comes up. |
Only holders who are currently active and have not cancelled are resettled.
Anyone **paused**, **past due**, or who **cancelled their own pass** is left
bound to the cancelled window: not moved, not counted, and not messaged by the
cancellation.
They have still paid for a date that is not happening. The counts in the toast
exclude them, so refunding from that number under-refunds. Check
**Subscriptions** filtered to that plan before you reach for the refund list.
The _"N need a refund"_ figure appears in a single toast and is never stored.
Nothing in the dashboard marks those subscriptions as owing money — they look
like ordinary expiries afterwards.
Note the names before you navigate away, or find them under **Subscriptions**
with the plan filter. Refunds are issued from your payment provider's dashboard;
see [Refunds](/creators/managing-subscriptions#refunds).
If the window is already **Open** when you cancel it, anyone admitted is
removed from your places as part of the cancellation — you don't have to go
into the platform and do it by hand. They keep their moved pass and are re-admitted
automatically when the replacement window opens.
Holders who never joined the queue are untouched, because there is nothing to
take back from them.
### Automating the fallout
A cancellation emits three kinds of webhook, so none of this has to be reconciled by hand:
| Event | Fires |
| ---------------------------------------------------------------------- | ---------------------------------------------- |
| [`pass.holder_moved`](/webhooks/v1/events/pass#pass-holder-moved) | Once per holder moved, with both windows |
| [`pass.holder_stranded`](/webhooks/v1/events/pass#pass-holder-stranded) | Once per holder owed a refund, with the amount |
| [`pass.window_cancelled`](/webhooks/v1/events/pass#pass-window-cancelled) | Once, last, carrying both tallies |
`pass.holder_stranded` is the one worth wiring up: it carries the subscriber, the amount, the
currency and the gateway payment reference, which is the only durable record of who you owe.
Both are available as [Zapier](/integrations/zapier) triggers and in the
[n8n](/integrations/n8n) trigger node.
A plan cannot be deleted while customers hold windows that have not run yet.
Disable it instead — that stops new sales while the purchased windows still go
ahead.
## Questions
Yes — that is the point. They pay whenever they like and are admitted
automatically when the window opens.
Yes. A pass is a ticket to a dated window, not a membership, so a customer can
hold a Thursday pass and a Sunday pass at the same time — and an ordinary
subscription alongside them. Each has its own invite links.
Yes. Admissions are processed when the window opens and paced to stay inside
the platform's rate limits, so the same thing happens whether five or five
hundred people bought.
They are admitted within about a minute and keep the rest of the window — 2
hours 59 minutes of an 11:00–14:00 slot. A catch-up runs every minute for
exactly this, so they are not left waiting on an event that already fired. See
[When a payment lands late](#when-a-payment-lands-late).
They are moved to the next available window on that plan rather than bound to
one that cannot deliver anything, and are messaged explaining the move. If
there is no next window the purchase is left unbound, the customer is told to
ask you for a refund, and it is logged for you. Setting a **sales cutoff**
closes the gap.
Only if you set **Stop selling** to count back from when a window **ends**. On
the default start anchor a window stops selling the moment it opens.
With the end anchor they are let in straight away and removed when that window
closes like everyone else, paying the full price for whatever is left. Useful
when a session is going well and someone wants the rest of the afternoon. See
[Stop selling](#set-the-price-and-how-often-it-repeats).
Only if a buyer would be happy to attend any of them for the same price —
every window on a plan costs the same, and Subscriby will move someone between
them when a payment lands late. If your Sunday slate is worth more than your
Thursday one, give each its own pass plan. See [Why moving them is
safe](#why-moving-them-is-safe-one-price-every-window).
A window that was never opened is closed rather than opened late, so nobody is
admitted to a session that has already finished. Affected holders are recorded
as having missed it.
Yes. Both the [REST API](/api/v1/reference/plans) and the [MCP create-plan
tool](/mcp/v1/tools/plan#create-plan) accept the pass fields, and the bot's plan
wizard builds repeating schedules — see [Creating one from the
bot](#creating-one-from-the-bot).
It is ignored. Access comes from the window, not the cycle, so a pass shows
its window length rather than a renewal period.
## Related
- [Pass Series](./pass-series) — bundle many of these windows into one season ticket
- [Subscription Plans](./plans) — ordinary recurring plans
- [Managing Subscriptions](./managing-subscriptions) — what a held pass looks like
- [`pass.*` webhook events](/webhooks/v1/events/pass) — automate around windows opening and closing
- [Time-Limited Passes on subscriby.net](https://www.subscriby.net/features/time-limited-passes) — what the feature is, which plan includes it and the questions creators ask before switching it on
---
# All Transactions
Source: https://docs.subscriby.net/creators/transactions
By the end of this page, you'll know where the complete payment ledger lives, what each row tells you, and how to narrow thousands of payments down to the one you are looking for.
A **transaction** is one payment attempt a member made against a subscription: a first purchase, a renewal, a refund or a failed charge. The dashboards show the most recent ones in their **Recent Transactions** cards; **All Transactions** is the whole ledger.
**Navigating to All Transactions.** With no project selected (top-left picker), open **All Transactions** under **Overall Projects** in the sidebar. The badge on the entry counts every payment across the projects you own or share. The **View all** button on a dashboard's **Recent Transactions** card lands here too; from a Project Dashboard it arrives with that project already picked.
## What a row shows
Each row reads the same way as the dashboard card, so nothing has to be relearned:
- **Transaction** — a calendar tile (the month over the day) beside the full date and time, with the payment reference the provider issued underneath. A payment without a provider reference shows its own id.
- **Project & Plan** — the project the payment belongs to, with the plan under it.
- **Connector** — the connectors the project runs on (icon and name, or icons alone when it runs several; a dimmed icon marks an installation that is not operational).
- **Subscriber** — the member who paid, or **Unclaimed** for a payment whose subscription has no holder yet.
- **Status** — a colour-coded badge: **Successful**, **Pending**, **Failed** or **Refunded**.
- **Provider** — the payment provider that took the money.
- **Amount** — in the currency the member paid in, never converted.
The row menu offers **View Details**, **View Subscription** (opens the subscription on the All Subscriptions page) and **View Subscriber** (opens the member on the All Users page).
## Searching, filtering and sorting
The toolbar above the table narrows the list. Every control writes itself into the page address, so a filtered view can be bookmarked or shared with a teammate.
- **Search** matches the provider's payment reference, its event reference, the subscription's own reference, the member's name or email, the plan name and the project name. Case does not matter.
- **Filter by Project** shows one project's payments. Picking a project unlocks two more filters, **Filter by Plan** and **Filter by Payment Method**, which list that project's plans and methods; changing the project clears them.
- **Filter by Connector** shows the payments of every project that runs the chosen connector. Only connectors installed on at least one of your projects are offered.
- **Filter by Status** narrows to one badge.
- **Filter by Period** applies a dashboard preset (last 7, 14, 30, 60 or 90 days, month, quarter or year to date, one year). The default, **All time**, is the whole ledger.
- **Rows per page** offers 15, 25, 50 or 100.
- **Reset Filters** appears as soon as any control leaves its default and puts everything back.
Click **Transaction**, **Status**, **Provider** or **Amount** in the header to sort by that column; click the same header again to flip the direction. The list opens newest first.
## The detail flyout
**View Details** opens a side panel with everything recorded about the payment: the reference, the date and time, the amount and the fee in the payment's currency, the status, the payment method and its mode (live or test), the billing reason (first purchase, renewal, refund, access code or backfill), the currency, the project and plan, the member with the account they paid from, the provider's event reference, and the failure reason when the provider gave one. Two buttons at the bottom jump to the subscription and to the member.
Opening a payment adds `?show=` to the address, so the link to one payment can be pasted straight into a message.
**Amounts are as paid.** The ledger keeps every amount in the currency of the payment. For USD-normalised totals across currencies, read the [Overall Dashboard](/creators/dashboard-analytics), whose KPI strip and Cash flow card convert at the daily rate.
## Related
- [Dashboard & Analytics](/creators/dashboard-analytics) — the Recent Transactions card and the Cash flow card that summarise this ledger.
- [Managing Subscriptions](/creators/managing-subscriptions) — the subscription each payment belongs to, with its own payment history flyout.
- [Analytics API](/api/v1/reference/analytics) — the same ledger over REST, one project at a time, with keyset pagination.
---
# Active Disaster Prevention
Source: https://docs.subscriby.net/disaster-recovery/active-disaster-prevention
Detection and every recovery are yours on any plan. **Active Disaster Prevention** is the part of the program you set up _before_ anything happens, so that when a platform does take something away the fix is already in place. It lives on the Disaster Recovery page, under the recovery cards, with the badge **Available with Growth**.
On Free and Starter the section is visible but dimmed under an upgrade card, so you can see exactly what you would be getting; every control in it refuses until the account holding the project is on Growth. Growth is read from the **project owner's** plan, never from the teammate acting — a member of a Growth creator's team can set these up on their behalf.
Active Disaster Prevention is included in **Growth**, with no addon to buy. On
**Free** and **Starter** the section is visible but locked; upgrade from
**Billing → Plans** and every safeguard below unlocks for all of that
account's projects. The gate covers only the safeguards on this page:
detection, the alerts, the three recoveries, the roll call, undo and the
history are yours on every plan, including Free. See
[Subscriptions](/subscriptions) for the full plan comparison.
Leaving Growth while a standby bot, a standby chat, automatic failover or a
backup account is still configured is refused with a message telling you to
remove them first. Nothing is ever deleted on a downgrade.
## Backup Account
A second account on the platform that you control, proven through the same bot handshake as an account relink (link or eight-character code, 15 minutes, from the backup account). Once registered:
- you can **sign in through the connector** from either account;
- an **account incident alert** goes to the backup account, since the main one can no longer receive it;
- our platform bot recognises you by either account;
- the recovery page offers **Switch to Backup Account** — one click, no handshake — and the previous account becomes the backup, so the switch can be undone the same way.
Remove it with **Remove Backup** whenever you like; register another the same way.
## Standby Bot
Each project can keep one **standby bot**: a second set of credentials from the platform, stored **without being registered**, so it receives nothing until it takes over and the platform never sees it do anything. Our hourly probe checks it stays valid; if the platform refuses the standby's credentials you receive _Your standby bot for … no longer answers_, in the connector's words, by email, because a standby that cannot take over is worse than none.
When the project's bot is banned, the recovery page offers **Switch to Standby**: the standby's credentials are written into the project's existing installation, every member chat follows it, and the slot is emptied for a new standby.
## Standby Places
Each place can have one **standby place** of the same kind, handed over the way the connector links any place (**Add Standby** → pick it on the platform, or make the bot an administrator of the standby and confirm its offer). The request reaches your main account and, once you have registered one, your backup account as well, so a main account the platform has restricted from creating places is no obstacle: create the standby from the backup account and hand it over from there. The bot is an administrator of the standby from that moment, so a swap needs no round trip to the platform, and the standby is probed alongside the resource so you learn if it degrades before you need it.
Each row shows the standby's name with a health dot, and for a channel the **Mirror Content (Copy Over)** switch.
### Live Mirror
Only channels are mirrored, and the page says why: a channel is content, and a banned channel takes every post with it; a group is conversation, and copying its members' messages into an empty standby would be noise. Groups still get a standby, and the failover still re-admits everyone.
With the mirror on, every post published in the channel is **copied** into its standby the moment it is made — text, media by file reference, without re-uploading — and an edit is reflected by replacing the mirrored copy. Nothing is backfilled: posts made before the mirror was switched on are not copied, because they can only be read while the channel is alive, and the mirror exists precisely for the day it is not. Switch it on early.
Switch it off and posts stop being copied; what was already mirrored stays in the standby.
## Automatic Failover
Per project, one switch that lets the platform act **the moment we detect a loss, with nobody signed in**, in two situations:
- a banned channel or group, or one our bot was removed from, is **swapped for its healthy standby chat**;
- a project bot the platform refuses outright (token revoked or regenerated, bot deleted) is **replaced by the project's standby bot**.
A channel swap runs when all of these are true:
1. the project owner is on Growth;
2. the project's Automatic Failover switch is on;
3. the resource has a standby and the standby is healthy;
4. the reason is a real loss — _Chat not found_ or _Bot removed_; a missing right never triggers a failover, because giving the right back is the fix;
5. the **Channels and Groups allowance** has a use left (or a support grant covers it). An automatic failover counts exactly like a manual one.
When it runs, the resource is swapped, every member is re-admitted through the queue, and you receive _Automatic failover: Signals now runs on Signals Standby_ by email and on your connected account, with the roll call and the 24-hour undo. The standby slot is then empty: link a new standby so the next failover is just as quick.
A bot switch runs when the owner is on Growth, the switch is on, a [standby bot](#standby-bot) is registered and its last probe found it healthy, the refusal is platform-caused (a rate limit or a passing API error never counts), and the **Project Bot allowance** has a use left. The standby's token is written into the project's bot, the incident is resolved, the [connector outage](/disaster-recovery/connector-outages) closes as replaced, plans return to sale, and every member with a verified or billing address is emailed the new bot link on your behalf at the per-email fee you accepted below, because the refused bot can no longer carry a message. You receive _Automatic failover: Night Owls now runs on Night Owls Standby_ by email and on your connected account, saying how many members were emailed, and [`recovery.installation_failed_over`](/webhooks/v1/events/recovery#recovery-installation-failed-over) fires. A bot switch cannot be undone from the dashboard, since the refused bot is gone as far as the platform is concerned; register a new standby bot afterwards.
When either **cannot** run, you are told why rather than left guessing: _Automatic failover did not run for Signals_ (or _for Night Owls's bot_) names the reason — the allowance is spent, the standby is unavailable or unhealthy, the plan lapsed, or the swap was refused — and the incident stays open for you to handle by hand.
### The email-fee consent
Switching Automatic Failover on asks you to tick one box: **I accept $0.01 per email, added to my transaction fees, for members our bot cannot reach during an automatic failover.** A failover runs with nobody signed in, and the project bot may be banned at the same time, so members the bot cannot reach on the platform are emailed their new link on your behalf — there is nobody there to choose the CSV instead. Members the bot can reach cost nothing. The date you accepted is shown under the switch.
## Readiness Checklist
At the top of the section, a grid of tiles scores what is in place. Two tiles are about your account and appear for everyone:
| Tile | In place when |
| -------------------------------------- | --------------------------------------------------------------------- |
| Two-Factor Authentication or a Passkey | A second factor or a passkey guards the account that runs recoveries. |
| A Backup Sign-In Account | One is registered. |
The rest come from the connectors your projects run, each tile tagged with the connector's name, so you see your connector's tiles and nothing about a platform you do not use. A connector that keeps standby bots and standby places brings four:
| Tile | In place when |
| -------------------------------------- | ----------------------------------------------------- |
| A standby bot for every project | Every connected project has one. |
| A standby for every place | Every resource has a healthy standby. |
| Automatic failover | Every project with a standby has the switch on. |
| A live mirror of every broadcast place | Every broadcast place with a standby mirrors into it. |
A creator with several projects on one connector sees one tile per line, and its detail counts the projects ("2 of 3 projects") when they stand differently.
Two **reminders** the platform cannot verify sit under a separator: keep a copy of your content outside the platform, and keep a second human administrator in every space so the space itself survives a ban on your account. Each unfinished tile's **Set Up** button scrolls to the card on the page that fixes it; the two-factor tile opens your security settings.
The same checklist, with each line's state and the totals, is readable by integrations at [`GET /v1/recovery/readiness`](/api/v1/reference/disaster-recovery#get-the-readiness-checklist) and by an agent through [`get_recovery_readiness`](/mcp/v1/tools/disaster-recovery#get-recovery-readiness). When a verifiable line changes state, after a recovery event or the nightly check, the [`recovery.readiness_changed`](/webhooks/v1/events/recovery#recovery-readiness-changed) webhook carries the whole checklist as it stands.
## Example: the failover you sleep through
A trader on Growth runs _Signals_ with a standby _Signals (Standby)_, the mirror on since the day the standby was linked, and Automatic Failover on with the fee accepted. At 03:14 the platform bans _Signals_.
- **03:19** — the hourly probe finds _Chat not found_ and opens the incident. All five conditions hold, so the failover job runs: _Signals_ now points at the standby, which already holds every post since the mirror was switched on.
- **03:20** — 340 members receive their new link through the bot; 22 who never started the bot are emailed at $0.22 in total, because the trader accepted the fee when switching the failover on.
- **08:30** — the trader wakes to the _Automatic failover: Signals now runs on Signals (Standby)_ email, opens the page, sees _Re-admitted 340 / 340 · Joined 301_, links a fresh standby for next time, and switches the mirror on for it. The Channels and Groups allowance is spent until December; a second ban before then goes through support.
## Related
- [Disaster Recovery Program](/disaster-recovery) — detection, alerts and the three recoveries on every plan
- [Replacing channels and groups](/disaster-recovery/replacing-places) — the manual swap a standby makes automatic
- [Standby Bots and Automatic Failover on subscriby.net](https://www.subscriby.net/features/standby-bots) — the prevention set at a glance, which plan includes it and the questions creators ask
---
# Allowances and Support Review
Source: https://docs.subscriby.net/disaster-recovery/allowances-and-support-review
The program's promise to every platform Subscriby runs on, and to every honest creator, is that it cannot be used to hop from one banned place to the next. The allowance is how that promise is kept.
## The rule
Each **kind** of recovery — Connected Account, Connection, Places — is self-service **once per rolling 90 days**, counted per creator and per kind independently:
- Using one kind never reduces another. You may run all three in one sitting, or one today and the others next month.
- The window is rolling: a channel recovery run on 1 March opens again on 30 May, whatever else happened in between.
- The count is taken from the moment a recovery **starts**, not from when it finishes, so a recovery that never finished still counts (and a recovery that failed before changing anything gives its use back).
## What spends the allowance, and what does not
| Spends it | Does not |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| Relinking your connected account, by handshake or by switching to the backup account. | Registering or removing a backup account. |
| Replacing a project bot, by new credentials or by switching to the standby. | Registering or removing a standby bot. |
| The first channel swap of a channel recovery — **and every automatic failover**. | Further swaps within **24 hours** of the first: they join the same recovery and count once. |
| A swap of a **degraded** resource from the Resources table (it is routed to the recovery). | A [Swap & Grant Resource](/disaster-recovery/swap-and-grant-resource) of a **healthy** resource: on demand, never a recovery. |
| | Undoing a recovery. Undo is not another recovery. |
| | Running a recovery when nothing was detected — it spends the allowance like any other, but it is allowed. |
## What you see when it is spent
The recovery card for that kind shows **Used on date — support review** instead of _Self-Service Available_, and its step opens with **Support Review Required**: _You already used the self-service channel recovery inside the current 90-day window. Our support team reviews the account and its content before releasing another; a confirmed violation of Subscriby's or the platform's terms closes the account._ The **Contact Support** button opens the support chat with the incident, the kind and the project already attached, so you do not have to explain what happened.
The other two cards are unaffected and say so.
## What support audits
When you ask for another recovery inside the window, a person looks at:
- **the account** — how long it has existed, its billing standing, previous incidents and recoveries;
- **the content** of the channels and groups involved, as far as it can still be seen (the live mirror, the portal, your plans and their descriptions);
- **the history of the bans** — how many, how close together, and whether the platform's stated reason points at content or at a pattern.
They tell you what they found. If the bans read as errors, reports by a competitor, or a pattern that is not yours, they release a **grant**: one or more extra uses, for one kind or any kind, optionally with an expiry, with their note and their name kept on your history page. The grant is spent before any refusal, exactly like the self-service allowance.
If the audit finds you directly violating Subscriby's or the platform's terms
— content the platform removes for breaking its rules, or a pattern of bans
for the same reason — the recovery is refused and we take the necessary steps
to restrict and, under our Terms of Service, close the Subscriby account. You
are told by email why, and your members are told that the project has ended.
There is no appeal path inside the program; the program is not for that.
## Where the numbers come from
The window (90 days), the self-service uses per kind (1), the absorption grace (24 hours) and the undo window (24 hours) are deliberately tight, and every page reads the live values rather than repeating them, so what a card says is always what applies.
Integrations read the same allowances at [`GET /v1/recovery/allowances`](/api/v1/reference/disaster-recovery#get-the-recovery-allowances), one row per kind with `allowed`, `uses_grant` and `next_self_service_at` already worked out, and an agent through [`get_recovery_allowances`](/mcp/v1/tools/disaster-recovery#get-recovery-allowances).
## Example: three creators, three outcomes
- **Maya** had her channel banned in March and swapped it herself. In May a second channel is banned. The card says _Used on 12 March — support review_. Support finds both channels were reported by the same account with no rules problem, releases one channel-kind grant valid for 30 days, and Maya swaps the second channel the same afternoon. In June, 90 days after 12 March, her self-service allowance returns on its own.
- **Dev** runs a channel that the platform removed for content it does not allow, replaced it, and two weeks later the replacement is removed for the same reason. Support's audit finds the reason in the platform's own message and the same content in the mirror. The recovery is refused and the account is closed under the Terms; his members are told the project has ended.
- **Lina** never touched the allowance: on Growth, her standby channel with automatic failover took over when her channel was banned, which spent the channel-kind allowance automatically. Her account and bot allowances are untouched, and the page tells her the date the channel one returns.
---
# Connector Outages
Source: https://docs.subscriby.net/disaster-recovery/connector-outages
A **connector outage** is the platform refusing your project's live installation: the token was revoked or regenerated, or the bot was deleted. It is not a rate limit, not a passing API error, and not you pressing **Disconnect**; those never open one. Subscriby's health probe, hourly on every plan and **every 15 minutes on Growth**, notices the refusal, records the installation as **Degraded** with the reason, opens a [Disaster Recovery incident](/disaster-recovery/how-detection-works) and, from this release, an outage. The probe's cadence is also the most an outage can run unnoticed, which is why the pricing page states it beside the Disaster Recovery Program on every plan.
## While the outage runs
- **Sales freeze.** Every plan that unlocks a place on that connector is taken off sale everywhere at once: the portal shows **Temporarily Unavailable** on the card, the bot refuses the plan, access codes and free trials for it are refused, and the REST checkout answers with a validation error. Plans that hand out perks only stay on sale, because there is nothing there the connector has to deliver.
- **Existing members keep what they have.** Nobody is removed from a channel or group during an outage; the connector cannot act, so expiries wait.
- **The dashboard says so.** The project's Connectors page badges the installation **Sales Paused**, the Disaster Recovery page shows an amber **Connector outage** callout naming the project, the connector and when it began, and the installation object on the [Connectors API](/api/v1/reference/connectors) reads `sales_paused: true` with an `outage` block.
- **Integrations hear it.** [`connector.outage_opened`](/webhooks/v1/events/connector#connector-outage-opened) fires once when the outage begins.
## When it ends
An outage ends when the probe finds the installation healthy again, when you reconnect it, when another bot takes its place (a standby bot through automatic failover, or one you relinked yourself), or when you uninstall the connector. [`connector.outage_closed`](/webhooks/v1/events/connector#connector-outage-closed) fires with how long it ran and how it ended, the plans return to sale, and the settlement below runs from the queue.
## Outage Compensation
Every project carries an **Outage Compensation** switch in its settings, on by default. While it is on, an outage that lasted **an hour or more** and ended because the connector recovered or was replaced compensates every affected member: each purchase that grants access right now and unlocks a place on that connector has the outage's length **banked** on it, so nobody pays for hours they could not use. Passes and series are left alone, because a dated window cannot move, and so are plans that hand out perks only. An outage shorter than an hour, roughly the gap between two probes, is treated as a blip and earns nothing.
### Why the time is banked rather than billed
No payment provider lets a renewal move by a few hours: Stripe, PayPal, Paystack, Razorpay and the rest bill on their own schedule and rewrite the period end on every sync. Subscriby therefore never touches a charge, a refund or a renewal date. It banks the seconds on the purchase and adds them back on top of every period end a gateway reports, so the time survives every renewal. Where the member feels it depends on how the plan bills:
- **One-time and lifetime-style purchases** end later at once: the end date moves by the outage's length.
- **Recurring plans** keep their renewal date and charges exactly as they were. The member's access always runs the banked time past the period they last paid for, and they receive it when the membership ends, whether they cancel, let it lapse or a renewal fails.
A member who lives through two outages banks both; the total is `compensation_seconds` on the [subscription object](/api/v1/reference/subscriptions), and `ends_at` already includes it.
### Who hears what
- **Members** are told twice, in the chat their connector binds to them and by email, in plain words and without naming the platform or the cause: a fixed-term purchase is told its new end date, a recurring one that the time waits at the end of the membership. The same line then sits on their portal membership card and in the bot's subscription screen, for example _Outage credit: 3 hours added to the end of your membership_, and the portal keeps showing the real renewal date rather than the extended end.
- **You** receive one summary per outage, by email and on your connected account, and in the notification centre: how long the connector refused the installation, how many members received how much, and how many invites that never reached a member during the outage were re-sent. Nothing is sent when there was nothing to do.
- **Your dashboard** marks each compensated purchase with a **Compensated** badge in the subscriptions list, spells the credit out in its details, and lists every outage on the [Recovery History](/disaster-recovery) page with how long it ran, how it ended and its **Compensation** column.
- **Integrations** hear [`member.access_extended`](/webhooks/v1/events/member#member-access-extended) once per member, saying whether the time applies `now` or at `membership_end`, then [`connector.outage_compensated`](/webhooks/v1/events/connector#connector-outage-compensated) once with the totals.
Switch it off if you would rather settle outages yourself, for example with refunds or coupons. The switch is also `outage_compensations` on the [Projects API](/api/v1/reference/projects) and the [`update_project`](/mcp/v1/tools/project#update-project) tool.
An uninstall ends an outage but compensates nobody: the uninstall's own impact
preview and its two opt-ins decide what members get, as described in
[Uninstalling a
connector](/creators/connectors#uninstalling-a-connector). Invites that
never reached a member during the outage are re-sent whatever the setting,
because they were paid for and owed.
## Related
- [How detection works](/disaster-recovery/how-detection-works) — the probes behind the verdict.
- [Replacing a lost connection](/disaster-recovery/replacing-your-connection) — relinking, and the standby bot.
- [What members see](/disaster-recovery/what-members-see) — the member's side of an incident.
---
# History and Undo
Source: https://docs.subscriby.net/disaster-recovery/history-and-undo
Everything the program detected and everything it did for you is kept, in order, at **Disaster Recovery → History** (`/recovery/history`). It is the same ledger our support team reads before deciding on a grant, so what you see is what they see.
## Open incidents
At the top, anything still broken: the kind, the project, the place or bot it names, the reason the platform gave, and when it was detected. Each row has a way back into the recovery page with the incident pre-selected. An incident disappears from here the moment a probe finds the thing healthy again or a recovery replaces it.
## Recoveries
Below, every operation ever run, newest first, fifteen to a page:
| Column | What it shows |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Started** | When the recovery started — the moment that counts for the 90-day allowance. |
| **By** | _You_, _Automatic_ for a failover the platform ran, **On Demand** for a Swap & Grant of a healthy resource, or **Support Grant** when a released grant paid for it. |
| **Kind** | Connected Account, Connection or Places, in your connector's words, with the project's name. |
| **What Changed** | Account: _Linked to name_. Bot: _Old Bot replaced by Fresh Bot_. Channels: one line per swapped resource with its own **Undo** or **Undone** badge, then the roll call. |
| **Status** | In progress, Completed, Failed or Reverted. |
| **Undo until** | For a channel recovery still inside its window, the moment the undo closes. |
### The roll call on a channel recovery
Under the swapped resources: **Re-admitted x / total · Joined y**, read live from the invite links — _Joined_ is stamped the moment the platform confirms a member is inside — and, while somebody is still outside and the last reminder is more than an hour old, a **Remind** button that re-sends the "has moved" message to exactly those members. The same counts and the same button sit on the recovery page while the recovery is running; here they stay after it is done.
## Undo
| Recovery | How to undo | Window |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
| **Channel swap** | **Undo** on the resource's line, here or on the recovery page. The resource returns to its previous chat, the links into the replacement are forgotten, everyone is re-admitted to the old chat. | 24 hours |
| **Account relink** | The signed link in the security email only — it puts the previous account back, signs every browser out, and opens a _relink disputed_ incident for our support team. | 24 hours |
| **Bot replacement** | No undo. The old bot is gone on the platform's side and nothing about your members changed. | — |
| **Automatic failover** | Exactly like a channel swap: **Undo** on the line, from the page, the history or the link in the failover email. | 24 hours |
An undo spends no allowance. An undone swap keeps its line in the history with the **Undone** badge, so the record of what happened is never rewritten.
A relink made by someone who took over your dashboard session cannot be
defended from that same session. The email reaches the address on your
account, not the browser, which is why it is the only place the undo lives.
## The report email
Each recovery you run closes with a **Recovery complete** email: what changed in one or two sentences (the account now linked, the bot replaced, the channels re-pointed with the re-admission counts), a reminder that your plans, subscriptions and payments were not touched, the undo that applies, a button into this page, and the fair-use line. A channel recovery's report waits a quarter of an hour so its counts reflect the queue's work rather than the first second. Automatic failovers have their own email; on-demand swaps, being no recovery, send none.
## Example: reading the ledger
Six months in, a creator's history shows:
- **12 Mar · You · Channels and Groups** — _Signals_ swapped, _Undone_ the same evening (wrong chat), then swapped again at 21:50; re-admitted 212 / 212 · joined 209. _Completed._
- **3 May · Automatic · Channels and Groups** — _Lounge_ failed over to its standby at 03:19; re-admitted 340 / 340 · joined 338. _Completed._ Undo until 4 May 03:19 (passed).
- **3 May · Support Grant · Channels and Groups** — the grant support released after the second ban paid for a manual swap of _Signals_, whose allowance was still spent from March. _Completed._
- **20 Jun · On Demand · Channels and Groups** — _Members_ moved to a new supergroup; no incident, no allowance. _Completed._
- **1 Aug · You · Connected Account** — _Linked to Ada (new)_. _Completed._
Support reading the same page sees an account that has been banned twice in the same window, both times swapped back with a clean audit — which is why the grant was released — and a creator who otherwise uses the tools exactly as intended.
## Over the API
The same ledger is readable by integrations and the creator apps. [`GET /v1/recovery/incidents`](/api/v1/reference/disaster-recovery#list-incidents) lists what is broken (open by default), [`GET /v1/recovery/operations`](/api/v1/reference/disaster-recovery#list-recovery-operations) lists every recovery with `revertible` and `revert_window_ends_at` beside it, and [`GET /v1/recovery/operations/{operation}/roll-call`](/api/v1/reference/disaster-recovery#get-a-recoverys-roll-call) is the live roll call; the MCP tools [`list_recovery_incidents`](/mcp/v1/tools/disaster-recovery#list-recovery-incidents), [`list_recovery_operations`](/mcp/v1/tools/disaster-recovery#list-recovery-operations) and [`get_recovery_roll_call`](/mcp/v1/tools/disaster-recovery#get-recovery-roll-call) answer the same questions to an agent. The undo itself stays on this page until the write endpoints land.
---
# How Detection Works
Source: https://docs.subscriby.net/disaster-recovery/how-detection-works
You should never learn about a platform ban from a member. The program watches three things on your behalf and tells you the moment one of them stops answering — usually before the first complaint arrives.
## What is watched, and how often
| What | How the platform is asked | How often |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Every project bot** | The connector asks the platform whether the bot's credentials are still accepted. | Hourly; **every 15 minutes** on Growth. Standby bots on Growth are probed in the same pass. |
| **Every channel and group** | Our platform bot asks whether it is still an administrator with the rights it needs. | Hourly for every resource, **every 15 minutes** on Growth; **every five minutes** for a resource whose pass window is open or opens within the hour. Standby chats are probed too. |
| **Your connected account** | Our platform bot asks the platform about the account you sign in with. | Every six hours. |
| **Every member's access** | Your bot asks whether each member with live access is still inside the place, and whether anyone whose access ended is still there. | Members whose access ended in the last day: every 15 minutes. Members with live access: once a day each, spread across the day; **every 15 minutes** on Growth. After a connector outage ends, everyone at once. |
Only a **transition** counts. A probe that finds the same state as the last one changes nothing and sends nothing, so an incident is announced once, not once an hour. A probe that cannot reach the platform at all keeps the previous state rather than guessing.
## Instant detection
Probes catch the quiet failures. Real failures are caught the moment they happen:
- A member buys access and **their access cannot be issued**, because the place is gone or the bot lost its rights. The sale still goes through; the resource is probed immediately.
- A member uses their access and **the connector cannot admit them**. Same: immediate probe.
- We try to message you about a sale, a pass window or a support thread and the platform answers that **your account is deactivated**. Your account is marked unreachable at once.
A platform usually answers the same way for a banned account and for an
account that simply never started our bot. Only an explicit "this account is
deactivated" answer opens an account incident, so a creator who has not talked
to the bot yet is never told their account is gone.
## What opens an incident
An incident is the program's word for "something is still broken and here is the way to fix it". One is opened when:
| Detected | Incident kind | Also |
| ---------------------------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| A project bot's credentials are refused or the bot was deleted. | **Connection** | `connector.status_changed` webhook; a connector outage opens; on Growth with automatic failover on and a healthy standby bot, the standby takes over. |
| A place no longer exists, or our bot was removed from it. | **Places** | `project.resource.status_changed` webhook; on Growth with automatic failover on, the swap runs. |
| Our bot is still in the place but lost its administrator role or its rights. | **Places** | The incident names the missing right; giving it back resolves the incident without a swap. |
| The platform reports the account you sign in with as deactivated. | **Connected Account** | The alert goes to your backup account when you registered one. |
The recovery page names each kind in your connector's own words. An incident resolves itself when a later probe finds the thing healthy again — you gave the bot its rights back, the platform lifted a restriction — or when a recovery replaces it.
## How you are told
Every incident reaches you three ways:
1. **Email**, with a one-tap button into the recovery page that pre-selects the incident.
2. **Your connected account**, a direct message from our platform bot in your language, with a signed link that signs you in and lands on the page. For an account incident the message goes to your backup account, since the main one can no longer receive it.
3. **The dashboard**, where a banner above every page reads "Start Recovery" until the incident is resolved.
Your members are told too, in their own words: see [What your members see](/disaster-recovery/what-members-see).
## What is not detected
- **A content restriction on a place that still answers.** A platform can restrict a place for a content flag while its bot API keeps working. Nothing stops, so nothing is detected; if you want to move anyway, the recovery page lets you run a place recovery without a detected incident, and the all-clear notice tells you it spends the allowance like any other.
- **A restriction on your account that still lets it be reached.** Same reasoning: if the platform still answers for the account, we cannot tell.
- **Anything about content.** Once a channel is banned, no API can read its history. This is why the live mirror on Growth copies posts _before_ anything happens; see [Active Disaster Prevention](/disaster-recovery/active-disaster-prevention).
## Example: a bot token revoked by mistake
A creator regenerates their bot's token on the platform while tidying up, forgetting that Subscriby holds the old one.
- **10:00** — the hourly bot probe is refused by the platform. The bot is marked degraded with the reason _Bot token refused_, a **Connection incident** opens, the creator gets an email and a message on their connected account, and any `connector.status_changed` webhook fires with `status: degraded, reason: connector_api_unauthorized`.
- **10:02** — members who message the bot get nothing back from the platform, but members who open the portal see _Access Is Being Restored_.
- **10:15** — the creator opens the page from the alert, pastes the new token into **Replace the Bot**, and the same bot row is rebound: every member chat follows, the webhook and commands are registered, and the incident resolves. Because nothing about the bot's identity changed, members do not even need to press Start again.
Had they regenerated the token on purpose and pasted it into the project's bot settings instead, the ordinary bot-connection flow would have re-registered the webhook and the next probe would have resolved the incident on its own.
## What a refusal starts
A probe that comes back with the platform refusing the installation outright — _Bot token refused_ or _Bot not found_ — does more than open the incident above: it opens a [connector outage](/disaster-recovery/connector-outages). From that moment every plan that unlocks a place on that connector is off sale, the Connectors page badges the installation **Sales Paused**, and [`connector.outage_opened`](/webhooks/v1/events/connector#connector-outage-opened) fires. The outage ends when the probe finds the installation healthy again, when you relink a bot, when a standby takes over, or when you uninstall the connector; members whose paid access overlapped an outage of an hour or more then have the lost time banked on their purchase through [Outage Compensation](/disaster-recovery/connector-outages#outage-compensation). A rate limit or a passing API error still marks the installation degraded, but opens no outage. The quarter-hour cadence on Growth is what bounds an outage's undetected stretch, and so what the compensation is measured from, to fifteen minutes.
---
# Disaster Recovery Program
Source: https://docs.subscriby.net/disaster-recovery
import {
Radar,
UserRound,
Bot,
Megaphone,
ArrowLeftRight,
ShieldCheck,
Scale,
Users,
History,
} from "lucide-react";
A platform ban is the one failure a community business cannot route around on its own. When the platform restricts the account you sign in with, the bot that serves your members, or a place that holds your content, everything you sell is suddenly unreachable — while your subscribers keep paying and keep asking where their community went.
The **Disaster Recovery Program** exists for exactly that day. It watches your connected accounts, your bots and your places around the clock, tells you the moment one of them stops answering its platform, and gives you a calm page from which you move to a new account, a new bot or a new place in a few clicks — with every paying member re-admitted automatically and your plans, subscriptions and payments untouched. Nobody else offers this, and it is included on **every plan, including Free**. On **Growth** it goes further: standby bots, standby places with a live mirror of every post, a backup account and automatic failover turn a recovery into one click, or no clicks at all. That is the peace of mind the program is built for.
The program is for creators who were banned or restricted **through no fault
of their own**. Each kind of recovery is self-service **once per rolling 90
days**; anything beyond that goes through our support team, who audit the
account and its content first. It is not a way around any platform's rules, and
we have zero tolerance for using it as one — see [Fair use and
enforcement](#fair-use-and-enforcement) below.
## The three things a platform can take away
Everything on the recovery page is organised around three kinds of loss, because each one is fixed differently and each one has its own allowance:
| Kind | What happened | What recovery does | Page |
| --------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| **Connected Account** | The platform restricted or banned the account you sign in to Subscriby with. | Moves your Subscriby account to a new account on that platform, proven from that account. Sign-in, alerts and your creator bot follow it. Nothing else changes. | [Recovering your connected account](/disaster-recovery/recovering-your-account) |
| **Connection** | The platform restricted or banned the bot that serves one of your projects. | Connects new credentials in its place. Every member's chat follows the new bot the moment they start it. | [Replacing a lost connection](/disaster-recovery/replacing-your-connection) |
| **Places** | The platform banned a place your plans grant, or the connector lost the rights to run it. | Points the resource at a replacement place. Every member with active access receives personal access and is admitted automatically. | [Replacing a lost place](/disaster-recovery/replacing-places) |
The page lives at **Disaster Recovery** in your profile menu and in the Settings side navigation, at `/recovery`. When an incident is open, a banner across the dashboard takes you straight there. If your projects run more than one connector, the recoveries appear once per connector, each group headed by the connector's name and worded in its own terms, and the prevention card of each project offers only the safeguards its connector performs.
## Where to start
}
title="How detection works"
href="/disaster-recovery/how-detection-works"
>
Hourly probes and a daily re-check of every member's access (both every 15 minutes on Growth), five-minute probes around a pass window, instant detection when a sale fails, and the alerts you receive by email and on your connected account.
}
title="Recovering your connected account"
href="/disaster-recovery/recovering-your-account"
>
The handshake that proves a new account on the platform, the one-click switch to a backup account, and the 24-hour undo.
}
title="Replacing a lost connection"
href="/disaster-recovery/replacing-your-connection"
>
Connect new credentials or switch to your standby bot, then tell your members — through the bot, by email at $0.01 a message, or with a CSV you send yourself.
}
title="Replacing a lost place"
href="/disaster-recovery/replacing-places"
>
Hand the connector a new place, watch the re-admission roll call fill, remind anyone still outside, and undo within 24 hours if you picked the wrong place.
}
title="Swap & Grant Resource"
href="/disaster-recovery/swap-and-grant-resource"
>
The same swap and re-admission for a healthy place you simply want to move — on demand, from the Resources table, spending no allowance.
}
title="Active Disaster Prevention"
href="/disaster-recovery/active-disaster-prevention"
>
Growth only: standby bot, standby places with a live mirror, automatic failover, a backup account and the readiness checklist.
}
title="Allowances and support review"
href="/disaster-recovery/allowances-and-support-review"
>
Once per kind per 90 days, what counts and what does not, and what our support team looks at before releasing another recovery.
}
title="What your members see"
href="/disaster-recovery/what-members-see"
>
The portal notice, the bot's auto-reply during an incident, the "has moved" message with its Join button, and the emails they may receive.
}
title="History and undo"
href="/disaster-recovery/history-and-undo"
>
Every incident and every recovery ever run, the undo buttons while their window is open, and the report email that closes each one.
## What never changes
Whatever kind of recovery you run:
- **Your plans, subscriptions and payments are untouched.** Recovery re-points the platform; it never touches billing. Nobody is charged again, nobody loses a day of access on paper.
- **Your members keep their place.** Every member with active access, and every pass holder inside an open access window, is re-admitted to the replacement automatically. Nobody has to buy again.
- **Nothing is deleted.** The old account, bot or place is left as it is. Access into a replaced place is revoked, but nobody is evicted from it.
- **You can undo.** An account relink is undone from the security email we send you; a channel swap is undone from the recovery page. Both stay open for 24 hours.
- **You are told.** Every incident, every recovery and every automatic failover reaches you by email and, where the platform still lets us, on your connected account.
## Example: a Sunday afternoon
Say you run a sports-picks community. On Sunday at 14:07 the platform bans your main channel while forty holders of today's pass are inside it. Here is what the program does, and what you do:
1. **14:09** — a member's join request for the channel cannot be approved because the chat no longer exists. That failure triggers an immediate probe; the probe finds `chat not found`, marks the resource degraded and opens a **Places incident**. You receive an email and a message on your connected account with a one-tap link into the recovery page. If you have `project.resource.status_changed` webhooks, your automation hears it too.
2. **14:10** — your members who write to the bot are answered automatically: _"We are restoring access to this community right now. You do not need to do anything."_ Your portal shows the same notice.
3. **14:12** — you open the page from the alert, see the incident named at the top, and press **Recover** on Places. Because you had already created a fresh channel and made the bot its administrator, the bot offers it to you on the platform with one button; you confirm. (Without that, you press **Replace** and the connector asks you to pick the new place instead.)
4. **14:13** — the resource points at the new channel. Every one of the forty pass holders receives personal access through the bot, taps it, and is admitted automatically. The page shows the roll call filling: _Re-admitted 40 / 40 · Joined 33_. Seven who missed the message are reminded an hour later with one click.
5. **14:28** — the closing report lands in your inbox with the counts. The swap can be undone until 14:13 tomorrow, and the channel recovery you just used is self-service again in 90 days; an account or bot recovery in between is unaffected.
On Growth with a standby channel, automatic failover and the live mirror switched on, steps 3 and 4 happen on their own the moment the incident opens — into a channel that already holds every post you ever published — and you read about it in the email.
## Fair use and enforcement
The Disaster Recovery Program is not a way around any platform's Terms of Service, and Subscriby has zero tolerance for using it as one. Every surface that describes the program says the same four things, in the same order:
1. **It exists for creators who were banned or restricted through no fault of their own.** Platforms ban places in error, restrict accounts by pattern, and occasionally sweep up bots that broke nothing. That is who this is for.
2. **Each kind of recovery is self-service once per rolling 90 days.** Account, bot and channel recoveries are counted independently, so using one never reduces another; several channel swaps made within 24 hours of the first count as one use. Automatic failovers count. The on-demand [Swap & Grant Resource](/disaster-recovery/swap-and-grant-resource) does not, because a healthy channel you chose to move is not a recovery.
3. **Anything beyond that goes through our support team.** While the cooldown of a kind is still running, a similar issue is not self-service: you contact support from the recovery page, and they audit the account, the content of the channels involved and the history of the bans before releasing another recovery. They tell you what they found. See [Allowances and support review](/disaster-recovery/allowances-and-support-review).
4. **A confirmed violation closes the account.** If that audit finds you directly violating Subscriby's or the platform's terms — for example content the platform removes for breaking its rules, or a pattern of bans for the same reason — the recovery is refused and we take the necessary steps to restrict and, under our Terms of Service, close the Subscriby account. You are told by email why, and your members are told that the project has ended.
This is deliberate. A recovery tool that could be used to hop from one banned channel to the next would make every honest creator on Subscriby look like a bad actor to the platforms it runs on. Keeping the program tight is what keeps it available.
## Related
- [How detection works](/disaster-recovery/how-detection-works) — the probes and the failed sale or join that open an incident
- [Active disaster prevention](/disaster-recovery/active-disaster-prevention) — standby bots, standby channels, the mirror and automatic failover on Growth
- [Allowances and support review](/disaster-recovery/allowances-and-support-review) — the self-service allowance and what happens beyond it
- [Disaster Recovery on subscriby.net](https://www.subscriby.net/features/disaster-recovery) — the program at a glance, on every plan, and the questions creators ask
---
# Recovering Your Connected Account
Source: https://docs.subscriby.net/disaster-recovery/recovering-your-account
Your creator account can be linked to an account on each connected platform: the one you sign in with through that connector, the one its alerts reach, and the one its creator bot talks to. When the platform bans that account, nothing about your projects breaks. Your places are administered by the connector's own installation, never by your personal account. What you lose is the way in through that platform, and everything it delivered to you there.
The **account recovery** moves that link to a new account on the same platform. Nothing else changes.
A banned account cannot use the connector's **Continue with …** button, so
sign in with your **email and password** or a **passkey** instead. Every
creator account has all three; see [If you can't sign
in](/account/account-recovery) if you are stuck on this step. This is
also why the readiness checklist asks for a second factor: the account that
runs a recovery should be hard to take over.
## Option 1: the one-click switch (Growth)
If you registered a **backup account** ahead of time under [Active Disaster Prevention](/disaster-recovery/active-disaster-prevention), the recovery page shows *Your backup account is ready* and a single button, **Switch to Backup Account**. Press it and:
- sign-in through the connector, your notifications and your creator bot now use the backup account;
- the previous account is kept as your backup, so the switch can be undone the same way;
- the account allowance is spent, like any recovery.
No handshake, no link, no code.
## Option 2: prove a new account
Without a backup, you prove the new account from inside the platform. The page names what is **Currently Linked** so you can see what you are moving away from.
### Generate a link
Press **Generate a Link**. The page shows a link into the connector's platform bot and, beside it, an eight-character code. Both work for **15 minutes**.
### Open it from the new account
On the device where the **new** account is signed in, open the link, or, if you already have a chat with the bot, send it the code as a message. The bot confirms that the account is ready to be linked.
### Confirm in the dashboard
The page updates on its own and shows **Account detected: name**. Press **Confirm and Relink**. You may be asked to confirm your password first if you have not done so in the last three hours.
### Done
Sign-in, alerts and your creator bot use the new account from this moment. The bot greets the new account; you receive a security email with the undo link.
### What the bot refuses
The handshake protects everybody's account, so the bot refuses to complete it when the link or code has **expired** (15 minutes), when the message came from a **group or channel** rather than a private chat, when the account is **already linked to another creator's** Subscriby account, or when it is the one **already linked**, which means you opened the link from the wrong device. Five wrong codes in a minute from one chat and the bot stops answering codes from it for a while.
## What changes, and what does not
| Changes | Does not change |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| **Continue with …** signs you in from the new account. | Email, password and passkeys. |
| Alerts, sale notices and support relays reach the new account. | Your projects, plans, subscriptions, members and payments. |
| Your creator bot's private chat with you moves to the new account. | Your places: the connector's installation administers them, and it was never bound to your personal account. |
| The recovery history shows *Linked to name*. | Your project bots and their members' chats. |
## Undo
The security email that follows a relink carries a signed **undo** link, valid for **24 hours**. Using it puts the previous account back, signs you out of **every** browser and device, and opens a *relink disputed* incident that our support team looks at, so a relink you did not make is seen by a person.
The undo is deliberately only in the email: a relink made by someone who took over your dashboard session cannot be defended from that same session. If you switched to a backup account instead, the same email lets you switch back.
## Per connector
On Telegram the link opens **@TrySubscribyBot** with a one-time payload, the account is matched on your numeric Telegram user id, and the recovery is worth reading with a worked example of a banned account on a launch day: [Recovering your Telegram account](/connectors/telegram/recovering-your-account).
---
# Replacing a Lost Place
Source: https://docs.subscriby.net/disaster-recovery/replacing-places
A place in Subscriby is a **resource**: something a plan grants. When the platform bans the place behind it, or the connector loses the rights it needs there, the resource still exists, the plans still sell it, the members still hold it; only the place is gone. The **place recovery** points the resource at a replacement and re-admits everyone who is entitled to be there.
Once a platform bans a place, nothing can read its history back, ours
included. Recovery brings back the **members**, not the posts. On Growth, the
[live mirror](/disaster-recovery/active-disaster-prevention#live-mirror)
copies every post into your standby place *as you publish it*, so a failover
lands members somewhere that already holds your content. That is the only
content safeguard there is; the readiness checklist reminds you to keep a
copy outside the platform too.
## The page
Choose the project (if you own several) and the step lists every place of the project with:
- **Health**: the last probe's verdict, *Healthy* or the reason it is degraded, in the connector's own words. A resource switched off shows *Switched Off*; one already swapped in this recovery shows *Replaced*.
- **Plans**: how many plans grant it.
- **Members Affected**: how many members the swap moves. Everyone with active access, plus every pass and season-ticket holder whose date is open or still to come. Those inside an open window are re-admitted at once; the rest receive their access to the new place before their date opens, and it waits until the window opens, exactly as it would have in the original place.
- **Standby**: on Growth, the standby place linked to it and whether it is healthy.
At the top, a badge says where your allowance stands: *1 self-service recovery available*, *Recovery in progress — further swaps join it*, or *Used on date — contact support* with a **Contact Support** button that opens the chat with the incident already attached.
## Handing the connector a new place
Create the replacement on the platform first, of the same kind as the one it replaces; the connector says which kinds may stand in for which. Then:
### Press Replace
The connector asks you, on the platform, to pick the new place, and takes the rights it needs there through that pick.
### Or give it the rights yourself
Make the connector's installation an administrator of the new place with the rights the connector names. The moment that happens while a replacement is waiting, it asks whether the new place should replace the lost one.
### Watch the page
The row showed *Waiting on the connector* (with a **Withdraw Request** button in case you change your mind); it now shows *Replaced*, an **Undo** button, and the roll call starts filling below the table.
On **Growth** with a healthy standby linked to the resource, the row also has **Use Standby**: one click, no round trip to the platform, and the standby becomes the resource's place.
The connector refuses a place that is already a resource of any project, and refuses a place of the wrong kind.
## What happens in the swap
In one transaction the resource is re-pointed at the new place, switched on, marked healthy, and the access minted into the old place is forgotten. Then, from the queue:
1. Every member with **active access** to the resource, through a live subscription or through a pass or pass-series date whose window is open right now, gets fresh personal access into the new place. Holders whose window is still ahead have their old access forgotten and are picked up by the ordinary pre-window sweep, so they are not messaged twice; see [pass and pass-series holders](/disaster-recovery/what-members-see#pass-and-pass-series-holders).
2. Each member is told by the project's bot that the place has moved, with a button to join. The connector admits them when they use it.
3. The old place is left exactly as it is, nobody is evicted, and the access into it is revoked, best-effort.
4. `project.resource.updated` fires with `changes.space_id`, and the open incident resolves.
### Members the bot cannot reach
Some members never opened your project's bot (they bought through the portal), and if the bot was banned in the same incident it cannot message anyone at all. Those members still hold their new access, which appears in the portal and the moment they open the bot, but they are not told. The step lets you choose, per project, how they are reached; the choice is remembered:
- **Email them for me.** Subscriby emails each unreachable member who has an email address, verified on the portal or used to pay, their new access, at **$0.01 per email, added to your transaction fees**. Members the bot could reach cost nothing.
- **I will email them myself.** Nobody is emailed. Press **Download Member List (CSV)** for every member the swap moves, with the email they verified or paid with if any, and reach them your own way.
## The re-admission roll call
Below the table, the roll call follows the queue: **Re-admitted x / total** (members holding new access), **Joined** (members who actually came through it, stamped the moment the connector admits them), **Could not be messaged** and **Told by email**. A badge summarises it: *n still to join*, or *Everyone Has Joined*.
**Remind Members Still Outside** re-sends the "has moved" message, with the same access, to everyone who has not joined yet, in the connector's chat only; nothing is minted or emailed again. It can be pressed once per hour, through the same rate-limited queue as the swap, so a place's worth of people is never messaged twice in a minute. The same button sits on the [history](/disaster-recovery/history-and-undo) row.
## Several places at once
A place recovery **absorbs** every further swap you make within **24 hours** of the first: three banned places fixed in one sitting are one use of the allowance, not three, and the page says *Recovery in progress — further swaps join it*. The roll call covers all of them together.
## Undo
Each swap can be undone for **24 hours** from the **Undo** button on its row, or from the history page. The resource goes back to its previous place, the access into the replacement is forgotten, and everyone is re-admitted to the old place through the same queue. No allowance is spent by an undo.
## Per connector
On Telegram a place is a channel, group or supergroup, the pick happens through a chat-picker button our platform bot sends you, the rights are *invite users* and *restrict members* for **@SubscribyBot**, a group may be replaced by a supergroup but a channel never by a group, and the access is a personal invite link whose join request is approved automatically. The Telegram walkthrough with a two-channel example is on [Replacing channels and groups](/connectors/telegram/replacing-channels-and-groups).
---
# Replacing a Lost Connection
Source: https://docs.subscriby.net/disaster-recovery/replacing-your-connection
Each connector a project runs gives it one **connection** on that platform: the bot your members buy through, message for support, and receive their access from. When the platform bans it, revokes its credentials or deletes it, checkout stops, the portal's start button leads nowhere, and nobody can be messaged.
The **connection recovery** puts a new bot in its place **without losing the member chats**. Every member's conversation is bound to the project's installation, not to a credential; the recovery writes the new credential into that same installation, so the moment a member opens the new bot, their history, their access and their subscription are all there.
The places members get into are administered by the connector's own
installation, not by your project's bot. A banned project bot never touches
who is inside them. What it does stop is selling, support and the messages
that hand members their access.
## Before you start
You need new credentials from the platform, the same as when you first connected the project, and the recovery page repeats the connector's own steps beside the form. On **Growth**, if you registered a [standby](/disaster-recovery/active-disaster-prevention#standby-bot) ahead of time, you can skip that entirely.
## Option 1: switch to your standby (Growth)
When a healthy standby is registered for the project, the recovery page opens with **Your standby … is ready**, naming it, and a **Switch to Standby** button. Press it and the standby's credentials are written into the project's installation: every member chat stays bound and follows it, the connection is registered with the platform again, and the standby slot is emptied so you can register a new one when you have a moment.
If our probe found the standby itself no longer answering, the page says so and offers the credential form instead.
With [Automatic Failover](/disaster-recovery/active-disaster-prevention#automatic-failover) switched on, you do not have to press anything: the moment the probe finds the platform refusing your connection outright, Subscriby performs this same switch by itself, emails every member reachable by email the new link at the per-email fee you accepted, and tells you what it did.
## Option 2: connect new credentials
### Create the new bot on the platform
Follow the connector's steps, shown beside the form, to create a new bot and copy its credentials.
### Paste them
On the recovery page, choose the project if you own several, paste the credentials and press **Connect New Bot**. Subscriby asks the platform who the bot is, registers it, and rebinds the project.
### Confirm it answers
Open the new bot on the platform and start it. It answers as your project's bot, with your project's name and plans.
The page refuses credentials already connected to another project (yours or anyone's), and only the project's **owner** can run the recovery. A teammate with project permissions sees the page but cannot recover on the owner's behalf.
## Tell your members
The new bot has the same members, but most platforms will not let a bot message anyone who has not opened it yet. That is the one thing this recovery cannot do for you, so the page ends with **Tell Your Members**:
- **An announcement to copy.** A short text, in your language, that names the new bot and asks members to open it. Post it in your places, your newsletter, wherever your members already are.
- **Email Your Members.** One click emails every member who has an email address, the one they verified on the portal or the one they paid with, the new link and, when the project has a public portal, the portal link. Each email costs **$0.01, added to your transaction fees** on your next invoice; the button names the count and the total before you confirm. Members who have already opened the new bot cost nothing.
- **Download Member List (CSV).** Free. Every member with active access or a date still to come, pass and season-ticket holders included, with their name, the email they verified or paid with if any, their account id on the connector, their plans, when their access ends, which places they hold and whether the bot could reach them there.
Members who open the portal see the new bot's start button there as well, and every access they hold keeps working.
## What the recovery records
- The **history** row reads *Old Bot replaced by New Bot*, with the project's name.
- Your **connection allowance** is spent for 90 days. Account and place recoveries are unaffected.
- The open incident resolves, the dashboard banner disappears, and `connector.connected` fires for anyone listening.
- You receive the closing **report** by email.
There is no undo for a connection recovery: the old bot is gone on the platform's side, and nothing about your members changed.
## Per connector
On Telegram the credential is the bot token from **@BotFather** (`/newbot`, a name, a username ending in `bot`, copy the token), the recovery re-registers the webhook and the command menu, and a standby is a second bot you created earlier. The Telegram walkthrough with a worked Saturday-morning example is on [Replacing a banned bot](/connectors/telegram/replacing-a-banned-bot).
---
# Swap & Grant Resource
Source: https://docs.subscriby.net/disaster-recovery/swap-and-grant-resource
Not every move is a disaster. You may want to leave a group whose history got messy, split a community into a fresh supergroup, or hand a channel over to a new one with a better name. **Swap & Grant Resource** gives you the recovery's swap and re-admission machinery for exactly that, from the place you already manage resources.
## Where it is
Open the project, go to **Resources**, open the actions menu on a channel or group and choose **Swap & Grant Resource**. The dialog explains what will happen: the resource keeps its place in your plans; every member with active access, and every pass holder inside an open window, receives a personal link to the new chat and is admitted automatically; the old chat is left as it is and the links into it are revoked.
## What it costs, and when it counts
Nothing, and never — as long as the resource is **healthy**. A swap you choose to make is filed in your history as **On Demand**, spends no recovery allowance, does not join a running channel recovery, and is never absorbed by one.
If our last probe found the resource degraded (the chat is gone, or the bot
lost its rights), the dialog says **This counts as a channel recovery**:
replacing it is self-service once every 90 days with support review beyond
that, and the Disaster Recovery page walks you through it with the roll
call. The same rule from either door, so nobody can route around the
allowance by swapping from the Resources table.
## Choosing how members are told
The dialog asks the same question as the recovery step, and remembers your answer per project:
- **Email them for me** — members our bot cannot reach on the platform are emailed their new link, at $0.01 per email added to your transaction fees.
- **I will email them myself** — nobody is emailed; **Download Member List (CSV)** gives you every member the swap moves, with the email they verified or paid with if any.
Members the bot *can* reach are always told through the bot, with their new invite link, the moment the swap runs.
### When the bot itself is down
If your project bot no longer answers its platform, the dialog says so — **Your project bot no longer answers**, in the connector's words — and offers **Relink the Bot** instead of the swap. Members cannot be told through a dead bot, so connect a new one first from the Disaster Recovery page, then come back and swap the chat, so the invite links actually reach them.
## Picking the new chat
Press **Pick the New Place**. The connector asks you, on its platform, to pick the new place, exactly as in a [place recovery](/disaster-recovery/replacing-places#handing-the-connector-a-new-place); or make the bot an administrator of the new place and confirm its offer. The dialog shows *Waiting on the platform* until the connector has the place, and lets you withdraw the request.
The kind must match — the connector says which kinds may stand in for which — and a place that is already a resource anywhere is refused.
## After the swap
- The resource points at the new chat, switched on and healthy.
- Every entitled member is re-admitted through the queue; the [history](/disaster-recovery/history-and-undo) row shows the roll call, the **Remind** button and the **Undo** button for 24 hours.
- `project.resource.updated` fires with the chat change.
## Example: splitting a community
A fitness coach's single *Members* group has grown to 900 people and turned into noise. They create a new supergroup with topics, open **Resources → Members → Swap & Grant Resource**, keep *I will email them myself* because they will announce it in the old group anyway, and pick the new supergroup on the platform. 900 personal links go out through the bot over the next minute; the old group stays as an archive with its links revoked. No incident, no allowance spent — and had the coach picked the wrong supergroup, **Undo** on the history page would have put everyone back within the day.
---
# What Your Members See
Source: https://docs.subscriby.net/disaster-recovery/what-members-see
A recovery is yours to run, but your members live through it too. The program keeps them informed in plain words, never asks them to pay again, and never asks them to do anything a person could not do in one tap. Here is everything they can see, in the order they usually see it.
## While an incident is open
### On the portal
Every visitor to your project's public portal — signed in or not — sees a calm notice above the plans: **Access Is Being Restored**. *We are restoring access to this community right now. You do not need to do anything: the moment it is back, your new invite link is waiting for you in the bot and in the portal.* It shows only for a **bot** or **channel** incident; your own account being unreachable changes nothing for the people in your channels, so it says nothing.
### In the bot
A member who writes to your project bot while a bot or channel incident is open receives the same sentence as an automatic reply, ahead of any fixed acknowledgement you configured for the support inbox. Their message still lands in your inbox. (If the bot itself is the thing that was banned, the platform delivers nothing at all in either direction; the portal notice is what they have until the bot is replaced.)
## When a place is replaced
Every member with active access receives, from your project bot:
> 🔁 **Signals has moved**
>
> The channel you have access to was replaced with a new one. Your membership is unchanged — tap the button below to join it. Your join request is approved automatically.
>
> **🥰 Join "Signals" Channel**
Tapping the button opens the place; the connector admits them within a second. Nothing else is asked of them — no code, no payment, no re-registration.
Members the bot **cannot** reach — they bought through the portal and never started the bot, or the bot was banned in the same incident — still hold their new link. It appears:
- in the **portal**, on their membership, the moment they open it;
- in the **bot**, the moment they press Start;
- by **email**, if you chose *Email them for me* — *Signals has moved*, with the same Join button and, when your project has a portal, a link to it. (Emails are sent from Subscriby and signed by our team, without a support reply-to, so a reply goes nowhere by mistake.)
A member who has not joined after a while may receive the same "has moved" message again when you press **Remind Members Still Outside**. It is the identical message with the identical link, so a member who already joined and taps it again simply lands in the chat they are already in.
### Pass and pass-series holders
A [time-limited pass](/creators/time-limited-passes) or a [pass series](/creators/pass-series) grants the resource for a **window**, not open-endedly, so the swap treats holders by where their window stands at that moment:
| Holder | What happens |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Inside a window that is open right now** | Treated exactly like a live subscriber: a fresh personal link into the new chat, the *has moved* message with its Join button, automatic approval. They are back inside within a minute and counted in the roll call. When the window ends, they are removed from the **new** chat on time, because the window's schedule follows the resource, not the chat. |
| **Holding a window that has not opened yet** | Nothing arrives now. The link they were sent ahead of the date opened the old chat, so it is forgotten, and the ordinary pre-window sweep sends them a fresh link into the new chat before their window opens — the same message they would have received anyway. They are not part of this swap's roll call. |
| **Holding a series with several dates** | Each date is its own window, so both rows above apply date by date: today's date is re-admitted now, the remaining dates get fresh links ahead of time. |
| **Whose window has already closed** | Nothing. Their access ended before the swap, and a swap never grants access to anyone who did not hold it. |
Holders are never charged again and their window never moves: a Sunday pass bought on Wednesday is still Sunday's pass, in whichever chat Sunday's channel turns out to be.
## When a bot is replaced
Nothing arrives by itself: platforms do not let a new bot message people who have not started it. What members see depends on what you do next:
- Your **announcement** — wherever you post it — asks them to press Start on the new bot. Once they do, the bot answers as your project's bot with all their links and subscriptions intact.
- **Email Your Members** sends *Your project's bot has moved* with a **Start** button that opens the new bot, and the portal link when there is one.
- The **portal** shows the new bot's Start button on every screen that used to show the old one.
Their subscriptions, invite links and payment history were bound to your project's bot *record*, which the recovery rebinds, so a member who presses Start finds everything exactly as it was.
## When your connected account is relinked
Members notice nothing. Your account is how *you* sign in and are alerted; it plays no part in what they receive. Your creator bot's private chat with you moves to the new account, and that is all.
## When a swap is undone
The members receive the same "has moved" message again, this time pointing at the previous chat, and are re-admitted there through the same queue. Members who never left the old chat are simply approved again; nothing is revoked from them.
## What members never see
- **Any change to billing.** No charge, no refund, no new invoice, no pause. Their renewal date does not move.
- **The old chat disappearing.** The chat a recovery replaced is left as it is; only the links into it are revoked. If the platform deleted it, it was gone before the recovery started.
- **Your reasons.** No notice names a ban, a restriction or the platform. Members are told that access is being restored and where to go — nothing about why.
- **Each other.** Every link is personal and every message is a private one from the bot.
## Example: a member's afternoon
Ana holds a monthly plan that grants *Signals*. At 14:07 the platform bans the channel; she notices the chat is gone at 14:20 and writes to the bot: *"Where did the channel go?"* The bot answers at once: *We are restoring access to this community right now…* — and her message lands in your inbox anyway. At 14:13 (before she even asked) the swap had already sent her *🔁 Signals has moved*; she scrolls up, taps **Join**, and is inside *Signals II* by 14:21. Her plan renews on the 3rd as it always did, and she never learns what the ban was about.
## During a connector outage
When the platform refuses your bot outright — a revoked or regenerated token, a deleted bot — Subscriby treats the gap as a [connector outage](/disaster-recovery/connector-outages). Members who already hold access keep it; nobody is removed. Would-be members meet **Temporarily Unavailable** on every plan that unlocks a place on that connector, on the portal and in the bot alike, so nobody pays for access that cannot be delivered. When the connector answers again and the outage lasted an hour or more, every member whose paid access overlapped it has the lost time banked on their purchase automatically, as long as the project's **Outage Compensation** setting is on. A member on a one-time purchase sees their end date move; a member on a recurring plan keeps their renewal date and charges exactly as they were, and receives the banked time at the end of their membership, after the last period they paid for, because no payment provider lets a renewal move by a few hours. Each member hears it twice, in the bot chat and by email, and the same line then shows on their portal membership card and in the bot's subscription screen ("Outage credit: 3 hours added to the end of your membership"). As with every recovery notice, members are never told why: only that access is being restored and, later, that time was added.
---
# Transaction Fees
Source: https://docs.subscriby.net/fees
## Overview
Transaction fees are usage-based charges considered part of your Subscriby subscription cost. These fees are charged as a percentage of your processed volume and vary depending on your subscription plan.
Transaction fees apply to **live** payments only. Any payment taken through a
payment method running in **sandbox/test mode** incurs **no transaction fee**
— so you can test your checkout end to end before going live without accruing
charges.
## Fee Structure
Subscriby applies different transaction fee rates based on your active plan tier.
} title="Free Tier">
**10%** commission on every sale. Ideal for starting out without upfront
costs. Pay only when you earn.
} title="Paid Plans">
**1% - 3%** commission based on plan. As your volume grows, upgrading to a
paid plan significantly reduces your transaction fees. - **Starter Plan**:
3% fee - **Growth Plan**: 1% fee
An [addon](/addons) — such as the **Passes Addon** at $19 / mo or $190 /
yr — is a **fixed subscription charge** on your own invoice, like your plan.
It is not a percentage of your sales and it does **not** change your
transaction fee rate. Only your plan tier moves that rate.
## When a paid plan pays for itself
You never have to work out whether a higher tier is worth it: Subscriby does
the sum for you, on the numbers of your current billing cycle.
- **On the dashboards.** While you are on Free or Starter, a card under the
KPI tiles shows the fees you have paid this cycle, what the same sales would
have cost in fees on the next tier plus that tier's price, and how much of
the tier's price your fee saving already covers. The overall dashboard adds
up every project you own; a project's dashboard scopes the sales and fees to
that project alone, so with several projects you can see what each one
contributes. The card only appears for the account owner, since the plan is
theirs to change.
- **By email, once a cycle.** The first day your fee saving exceeds the next
tier's price, Subscriby sends you the sum once for that cycle, and the same
note lands in your Notifications Center and on your connected account if you
route billing alerts there. Nothing is sent while the tier would still cost
more than it saves, and nothing repeats within a cycle.
The break-even point is simple arithmetic: Starter's $39 pays for itself once
you sell about $557 a month (the 7-point gap between 10% and 3%), and Growth's
$89 once you sell about $4,450 a month on Starter (the 2-point gap between 3%
and 1%). Switching takes a minute from Plans & Billing and carries your
projects, members and payment methods over untouched.
## During your free trial
Every paid plan opens with a **7-day free trial**, on both the monthly and the
annual cycle. You get the plan's full feature set from the moment you start.
The trial gives you the plan's *features*. Its reduced commission is what the
first payment buys — so until then your sales stay on the **Free tier's 10%
rate** rather than the 1%–3% that comes with the plan. There is no extra charge
for trialling; you simply have not started paying for the discount yet.
Your plan's own rate applies **from the moment your first payment is received**,
and to every sale after that. Nothing is backdated: sales made during the trial
stay at 10%.
Your card is collected when you start the trial but nothing is charged until it
ends, so the subscription converts on its own and you keep your plan without
doing anything. Cancel before the trial ends and you are never charged.
If the first payment fails, the Free-tier rate stays in force until it succeeds —
your commission does not drop to the plan rate on an unpaid subscription. See
[What happens if you don't pay](#what-happens-if-you-dont-pay).
### Ending the trial early
If you are selling during the trial, the 10% rate may cost you more than the
plan does. A banner on your dashboard shows the trial status while it runs, with
**End Trial & Start Plan**: it charges your first payment immediately — the exact
figure comes from your upcoming invoice, including tax and any usage already
metered — and your plan's rate applies as soon as that payment clears.
Sales already made during the trial stay at 10%. Nothing is recalculated
backwards, and the trial cannot be resumed once ended.
The trial is available once. Cancelling and resubscribing later — to the same
plan or a different one — starts a normal paid subscription billed from day
one, and your plan's transaction fee applies immediately.
## Billing Methods
How you are charged depends on the payment method used for the transaction.
### Stripe Direct Charge
If you are using **Stripe** exclusively with the [Direct Charge](/creators/methods#stripe) configuration, transaction fees are **deducted automatically** from the incoming payment in real-time. You receive the net amount (payment minus fee) directly in your Stripe balance. No separate invoice is generated for these fees.
**Supported Regions**: This method works for Stripe accounts in **all countries except**:
### Accumulated Billing (Other Methods)
For all other payment methods (PayPal, Crypto, etc.) **and Stripe accounts in the excluded regions listed above**, transaction fees are **accumulated** as a pending balance on your account.
These accumulated fees are charged to your payment method on file:
1. **At the end of your billing cycle**: Added to your monthly/yearly subscription renewal invoice.
2. **When a threshold is reached**: To prevent large unexpected bills, we charge fees immediately when they reach a certain threshold (e.g., $50).
Each plan has a usage billing threshold (typically ranging between $10-$50).
If your accumulated transaction fees exceed this amount before your next
renewal date, an invoice for the usage amount is generated and charged
immediately. The usage counter then resets to zero. If you were to exceed the
threshold again, same process is repeated.
### The cost of collecting accumulated fees
Fees taken by direct charge cost Subscriby nothing to collect — they are deducted from the
payment as it passes through. Fees billed to you by invoice are different: our payment
processor charges us to collect that invoice, and that cost scales with the amount being
collected.
That cost is passed on at cost and nothing more. When an invoice carrying accumulated fees
is created, the cost of collecting its fee lines is added to your usage, so it appears in
the transaction-fee usage of the **following** billing cycle rather than as a separate line
on the invoice itself. Your billing page says so under the usage figure.
It applies only to the accumulated-billing route. If your fees are taken by direct charge,
you never pay it and your rate is exactly the headline percentage.
## Currency Conversion
Subscriby always calculates and charges transaction fees in **USD**. If your project accepts payments in other currencies, the transaction fee is calculated by converting the transaction amount to USD based on the daily exchange rate.
### Fiat currencies
Converted to USD using daily exchange rates from **CurrencyBeacon API**. The
effective exchange rates are displayed as a ticker on your dashboard.
### Cryptocurrencies
Converted to USD using rates from the **CryptoCompare API**, quoted per USD
exactly like the fiat rates. A chain suffix is dropped before the rate is
looked up, so BTC.BEP20 uses the BTC rate.
USD stablecoins (USDT, USDC, TUSD) quote at approximately `1.0`, so they
convert roughly 1:1.
### Native platform currencies
A connector that brings its platform's own payment prices plans in that platform's currency, at the fixed rate the platform publishes, and your fee is calculated on the USD value. See [Native payment methods](/payments/native-payments).
**1 Telegram Star (XTR) = 0.013 USD**, a fixed rate that follows Telegram's policy.
## Refunds & Disputes
**Subscriby does not refund transaction fees** for refunded or disputed payments. The transaction fee covers the cost of processing the original transaction and is non-recoverable.
## Plan Changes
If you upgrade or downgrade your plan, the new transaction fee rate applies **immediately** to all new payments.
Switching plans **during a trial** is the one exception: your sales stay on the Free tier's 10% rate until your first payment is received, whichever plan you switch to.
We do not recalculate fees for transactions that occurred while you were on a
previous plan. For example, if you process $1,000 on the Free Tier (10% fee)
and then upgrade to Starter (3% fee), the $100 fee for those initial
transactions remains valid.
## What happens if you don't pay
Transaction fees that accumulate get added to your invoices — either immediately (when the usage threshold is hit) or at renewal. If an invoice goes unpaid, Subscriby follows a defined sequence of milestones so there's a predictable path back to good standing.
### Your dashboard pauses immediately
The moment a charge fails, your **web dashboard and Subscriby's bots stop
accepting management actions**. Both point you at the outstanding invoice
instead. Settling it restores access immediately.
Your **projects keep running** throughout — subscribers keep their access, and existing subscriptions continue to bill and renew. What pauses is your ability to manage things, not your members' access. Subscriby also keeps retrying the failed charge automatically over the following days, and a fresh payment method triggers a retry straight away.
Transaction fees **continue to accrue while an invoice is overdue**. They're added to your balance and collected once payment succeeds, so an unpaid invoice defers the bill rather than waiving it.
### The overdue clock
How long you have depends on what you have actually committed. Creators on the **Free plan** have no subscription revenue behind them, so their clock is much shorter — and so does anyone whose **free trial never converted**, because no payment has landed yet.
Every day below is counted from your **first failed payment**.
| Milestone | Free plan | Unconverted trial | Paid plans |
| -------------------------------------------------------------- | --------- | ----------------- | ---------- |
| Dashboard and bot pause | Immediate | Immediate | Immediate |
| Lockdown warning email | Day 3 | Day 3 | Day 23 |
| **Account lockdown** — projects stop accepting new subscribers | Day 7 | Day 7 | Day 30 |
| Deletion warning email | Day 10 | Day 60 | Day 83 |
| **Permanent deletion** | Day 14 | Day 67 | Day 90 |
Lockdown lands at day 30 on paid plans because the automatic payment retries are exhausted by roughly then — waiting longer doesn't recover the charge. Deletion stays a long way behind it at day 90, because lockdown is reversible and deletion is not.
The **unconverted trial** column applies when your free trial ends and the first payment does not go through. Lockdown comes early, on the Free plan's schedule, because nothing has been paid for the plan's higher limits you have been using. Deletion stays on the paid schedule, because it cancels your subscribers as well as your account, and a card that simply needs replacing should not cost you your audience. Which column you are on is fixed when the first payment fails — it does not change as the days pass.
Internally the last two milestones are measured from the lockdown date rather than from the failed payment — 3 and 7 days after lockdown on the Free plan, 53 and 60 days on paid plans and after an unconverted trial. The day numbers above assume lockdown lands on schedule; if it happens later, the deletion milestones move back with it.
At the final milestone the account and everything tied to it — projects,
subscription plans and your customer base — are permanently deleted. Settling
the invoice at any point before then cancels the schedule and restores full
access automatically.
Every milestone is preceded by an email, on every plan — the debt is the same regardless of which plan you're on, only the timings differ.
## Fees on access-code redemptions
Access codes have their own fee model, layered on top of the general transaction-fee rules:
- Your plan's free access-code allotment resets at the start of each billing cycle (not rolled over).
- An access code is counted as **"used" when it's redeemed by a subscriber** — not when you generate it.
- Codes redeemed **beyond your plan's free allotment** are subject to the same standard transaction fee rate (10 % / 3 % / 1 %).
- Unused free allotment **does not carry over** — it resets at the start of each new cycle.
- **While a free trial runs the allotment stays at the Free plan's 5 per cycle**, whichever plan you are trying. A code inside the allotment is a sale the platform waives its commission on, so it is priced with the plan rather than lent with the features — exactly like the transaction rate above. Your plan's full allotment opens up as soon as the first payment lands.
See [Access codes](/creators/codes) for the full mechanics.
---
# How Subscriby Works
Source: https://docs.subscriby.net/how-it-works
import {
Rocket,
Compass,
BookOpen,
Wrench,
Users,
Wallet,
RefreshCw,
ShieldCheck,
CreditCard,
Coins,
Server,
Hand,
UserPlus,
LayoutDashboard,
Bot,
Package,
TicketPercent,
MessagesSquare,
Share2,
} from "lucide-react";
By the end of this page you'll understand how a Subscriby-powered membership goes from _empty project_ to _paying, automated community_ — and who does what along the way.
Subscribers never see Subscriby as a brand. They see **your** bot, **your**
channel, **your** plans. Subscriby is the plumbing.
## The flow at a glance
} title="1. Creator sets up">
Project, plans, payment methods, and a connected connector. One-time
afternoon of work.
}
title="2. Subscriber discovers"
>
Bot deep-link or portal page, picks a plan, enters payment.
}
title="3. Subscriby grants access"
>
Webhook confirms payment, subscription row is created, bot issues single-use
invite links.
}
title="4. Subscriber joins"
>
Taps each button in the bot — lands in the places the plan grants.
}
title="5. Subscriby keeps running"
>
Renewals, expiry kicks, unauthorised-join cleanup, dashboard sync — all
automatic from here.
## Creator-side setup (one time)
### Register as a creator [step]
Register with an email address and a password, or press **Continue with …** to start from an account on a connected platform and link it on the way in. See [Creating an account](/account/sign-up).
### Activate a creator plan [step]
Pick Free, Starter, or Growth and add a payment method on file. Even the Free plan requires a card because per-transaction fees apply. Paid plans start with a 7-day free trial, charged nothing until it ends — though sales during it stay on the Free tier's [10% rate](/fees#during-your-free-trial). See [Activate your subscription](/creators/subscription).
### Create a project [step]
Name, handle (for the portal URL), banner, and description. See [Creating a project](/creators/projects).
### Install and connect a connector [step]
Install the connector for the platform your community lives on from the project's [Connectors](/creators/connectors) page, then connect it: the connector walks you through creating its bot on the platform and pasting the credential. Do this before defining resources — the places a connector gates can only be linked once it is connected.
### Define resources [step]
With your connector connected, link the places you want to monetize directly — or start with a **Manual Perk** (PDF, Notion link, perk text) for anything off-platform. See [Resources](/creators/resources).
### Enable payment methods [step]
Pick from Stripe, PayPal, Skrill, CoinPayments, Paystack, Razorpay, CeyPay and Access Codes, plus any [native payment](/payments/native-payments) your connector brings. See [Payment methods](/creators/methods).
### Build subscription plans [step]
Pricing tier plus billing cycle plus trial rules plus the resources each plan unlocks. See [Subscription plans](/creators/plans).
### Share your bot or portal URL [step]
Post it on social media, existing channels, or via direct deep-links. See [Sharing your project](/creators/share).
Typically one afternoon. After that, subscribers can start joining on their own.
## Subscriber-side flow (recurring)
### Discover the project [step]
Subscriber opens your bot link on their platform or visits your portal page (`my.subscriby.net/your-handle`).
### Pick a plan [step]
**On the bot** — welcome message, terms acceptance, interactive plan list. **On the portal** — sign in with a connected platform, Google or an email magic link, plan cards, **Subscribe Now** button.
### Pick a payment method [step]
Only methods you've enabled are shown. The portal lists web-based providers; the bot additionally shows any native payment the connector brings.
### Pay the provider [step]
Card details (Stripe, PayPal, etc.), in-app purchase (a connector's native payment), or crypto send (CoinPayments, CeyPay) — whichever flow the chosen provider uses.
### Access is provisioned [step]
On confirmation, a Subscriby webhook receives the event, creates the subscription row, and instructs the connector to issue personal access to every place the plan unlocks.
### Subscriber joins [step]
The bot sends the member their access, one button per place. The subscriber taps each one and the connector admits them.
From the subscriber's perspective — few taps, one payment, under a minute.
Behind the scenes Subscriby is orchestrating webhooks, access issuing,
membership grants, and audit logging.
## The ongoing lifecycle
For recurring plans the payment provider charges their saved method at each
cycle and Subscriby updates renewal dates automatically. Zero manual work.
If a renewal fails, the provider runs its own retry policy. During retries the
status is **Past Due** and access continues. If retries exhaust, status flips
to **Unpaid** then **Expired** and the bot removes the subscriber from your
places.
When a subscriber taps Cancel (portal or bot), a confirmation job queues.
Access continues until cycle-end. After that the bot removes them and flips
the subscription to **Expired**.
Someone sneaks in without a valid subscription — for example via a leaked
invite link — Subscriby's periodic consistency sweep detects them and removes
them.
A churned member can subscribe again through the normal flow anytime. It
becomes a fresh subscription row; their history (including the previous churn)
stays visible for audit.
## The money path
}
title="Subscribers pay your provider"
>
Funds go **directly into your Stripe / PayPal / Razorpay / … account**.
Subscriby never holds your revenue.
} title="Provider payouts">
Each provider has its own payout schedule. Stripe ≈ 2–7 days, PayPal usually
faster, crypto providers settle near-instantly (but one-way — no automatic
refund path).
}
title="You pay Subscriby"
>
The [transaction fee](/fees) is either deducted at charge time
(Stripe — Direct Charge model) or accumulated and invoiced monthly (every
other provider). Creator-plan subscription is billed separately by
Subscriby through Stripe.
## What's automated vs what's on you
}
title="Platform connection upkeep"
>
Keeps each connector's link to its platform registered so the bot hears
every event in real time.
}
title="Access issuing"
>
Issues personal access per subscriber per place, in whatever form the
platform uses.
}
title="Unauthorised-member sweep"
>
Detects members who snuck in without a subscription and removes them on a
periodic sweep.
} title="Clean re-invites">
Lifts a previous removal cleanly before re-admitting a subscriber so the
platform does not refuse them.
}
title="Provider catalogue sync"
>
Keeps payment-provider product / price catalogues in sync when you change
plans.
} title="Subscriber notifications">
Trial-ending reminders, renewal-failure notices, and more — delivered through
the bot.
}
title="Activity timeline"
>
Tracks every lifecycle event per subscription for audit and support.
} title="Content">
Obviously. Create the posts, host the calls, answer the questions.
} title="Marketing">
Share the bot link, run ads, engage your audience.
} title="Moderation">
Your platform's own moderation tools — deleting messages, banning bad actors —
are still yours to run.
} title="Refunds">
Issued via the payment provider when needed. Subscriby reflects status; the
decision and execution are yours.
} title="Support">
Subscribers ask you questions; you answer them. Subscriby is
infrastructure, not a customer-service layer.
## Ready to build?
}
title="Quickstart"
href="/quickstart"
>
Zero to your first live plan in about 10 minutes.
}
title="For Creators"
href="/creators"
>
The full setup walkthrough — every screen, every option.
}
title="For Subscribers"
href="/subscribers"
>
The same flow seen from your audience's side.
---
# Platform Introduction
Source: https://docs.subscriby.net
import {
Sparkles,
Zap,
BookOpen,
UserStar,
Podcast,
ShieldCheck,
Users,
CreditCard,
Send,
Library,
LifeBuoy,
HelpCircle,
Rocket,
Wrench,
Globe2,
Receipt,
Terminal,
Webhook,
Bot,
Plug,
FolderPlus,
TicketPercent,
KeyRound,
Share2,
LayoutDashboard,
BarChart3,
} from "lucide-react";
Welcome. This is the home for everything you can do with **Subscriby** — from launching your first paid community in under an hour, to running it at scale with teams, custom roles, and nine payment providers.
Subscriby is a **membership-as-a-service platform** built by **Envigo Innovations, LLC**. It turns private communities on the platforms your members already use into subscription businesses — no code, no servers, no manual member management. Subscribers pay, the bot invites them, and if they stop paying it removes them. That's the whole product in one sentence; the rest of these docs cover how to make the most of it.
**New here?** The fastest way to get your bearings is the [Quickstart
Guide](/quickstart) — it's a 10-minute, no-skip walk from "create an
account" to "first paying subscriber". The conceptual tour lives at [How
Subscriby works](/how-it-works).
## Start your journey here
}
title="What is Subscriby?"
href="/introduction"
>
A plain-English overview — who builds it, who uses it, what it does, and why
it exists. Read first if you're evaluating the platform.
}
title="Quickstart"
href="/quickstart"
>
Zero to first live plan in about 10 minutes. Nine numbered steps, each linking
to deeper docs if you want more context.
}
title="How it works"
href="/how-it-works"
>
The end-to-end flow — creator setup, subscriber signup, the money path, what
the bot does automatically, and what you still handle manually.
}
title="FAQ"
href="/questions"
>
Common questions about billing, features, platforms, and edge cases.
## Pick your path
Subscriby has two kinds of users — **creators** (the people running communities) and **subscribers** (their paying members). Almost every page in these docs is geared toward one of the two. Start with the path that matches you.
}
title="For Creators"
href="/creators"
>
Build and run a subscription business. Covers plans, projects, bot setup,
payment methods, access codes, members, subscriptions, dashboard, and more.
}
title="For Subscribers"
href="/subscribers"
>
Subscribe to a creator's community, manage your membership, redeem access
codes, and get help if something goes wrong.
## Browse by topic
}
title="Account & Security"
href="/account"
>
Sign-in methods, two-factor auth, passkeys, recovery, and all your personal
account settings.
}
title="Teams & Roles"
href="/teams"
>
Invite collaborators, define custom roles and groups, assign the right
permissions. Growth plan and above.
}
title="Billing & Subscriptions"
href="/subscriptions"
>
How Subscriby bills you, how transaction fees work, grace periods, and
upgrade/downgrade mechanics.
}
title="Payment Methods"
href="/payments"
>
Stripe, PayPal, Skrill, CoinPayments, Paystack, Razorpay, CeyPay and Access
Codes, plus the native payment your connector brings.
}
title="Connectors"
href="/connectors"
>
The platforms a project runs on — what is live and what is coming, and each
connector's own mechanics: bot setup, place linking, admin commands.
}
title="Reference"
href="/reference/glossary"
>
Glossary, status codes, and cross-cutting troubleshooting when you need a
quick lookup.
## Developers & automation
}
title="REST API"
href="/api/v1"
>
The public API at `api.subscriby.net/v1` — Sanctum tokens, idempotent mutations,
OpenAPI 3.1 spec, and an agent-friendly error envelope.
}
title="Outbound Webhooks"
href="/webhooks/v1"
>
Subscribe any HTTPS endpoint to the 104-event taxonomy. HMAC signing, 8-retry
backoff over ~4 days, dead-letter replay.
} title="MCP Server" href="/mcp/v1">
Connect Claude Desktop, Cursor, or ChatGPT Desktop to `mcp.subscriby.net` and
drive your creator workflow via natural language.
}
title="Integrations"
href="/integrations"
>
Zapier, n8n, Make, LangChain, and any OpenAPI-aware toolkit. Build your own in
minutes against the hosted spec.
## Common creator tasks
The setup journey in the order creators actually do it — plus the day-two tasks you return to once subscribers start flowing in.
### Day-one tasks
Mirrors the [Quickstart](/quickstart) checklist — the sequence the dashboard's onboarding enforces.
}
title="1. Activate your creator subscription"
href="/creators/subscription"
>
Pick Free, Starter, or Growth. Add a payment method on file (required even
on Free because per-transaction fees apply).
}
title="2. Create your first project"
href="/creators/projects"
>
Name, handle, banner, description. Every field explained.
}
title="3. Install and connect a connector"
href="/creators/connectors"
>
Install the connector for your platform and connect its bot. Done once per
project, and required before you can link a place as a resource.
}
title="4. Add a resource"
href="/creators/resources"
>
With your connector connected, link a place directly — or start with a
**Manual** resource for anything off-platform.
}
title="5. Set up a payment method"
href="/creators/methods"
>
Configure Stripe, PayPal, Razorpay, or any of the other six supported
providers.
}
title="6. Add a subscription plan"
href="/creators/plans"
>
Pricing tier, billing cycle, trial rules, eligibility filters. Link the
resource from Step 4; a plan that is on sale is pushed to your payment
methods on its own.
}
title="7. Share your bot and portal"
href="/creators/share"
>
Your bot link plus `my.subscriby.net/your-handle`. Marketing links, QR
codes, deep-link start parameters.
### Day-two tasks
}
title="Generate access codes"
href="/creators/codes"
>
Bulk-mint single-use codes for offline sales, giveaways, influencer gifts,
or gated plans.
}
title="Read your dashboard"
href="/creators/dashboard-analytics"
>
Period filters, compare-to windows, plan / status / provider slicing. The
same numbers the Analytics API returns.
## Connectors
Subscriby runs on connectors, and the [Connectors Marketplace](/connectors/marketplace) is the one place that says which platforms are live, in beta, under development or coming soon, straight from the catalogue the dashboard installs from. Every available connector has its own section in this guide with the platform's exact steps, and the [connector roadmap](/connectors/roadmap) says what each lane commits us to.
}
title="Connectors Marketplace"
href="/connectors/marketplace"
>
Every connector, lane by lane, with what each one gates and what it brings.
} href="/connectors/roadmap">
Discord under development; Slack, WhatsApp and seven more on the way.
## When things go wrong
}
title="Troubleshooting"
href="/reference/troubleshooting"
>
Common issues across both creator and subscriber paths — sign-in problems,
bot not responding, payment failures, plan-sync errors, and more.
}
title="Subscriber troubleshooting"
href="/subscribers/troubleshooting"
>
The subscriber-specific variant — can't access a group, payment failed, bot
didn't add me, portal sign-in issues.
## About this documentation
- **English only for now.** The Subscriby **app itself** supports 10 languages (see [Language & region](/account/language-region)); the docs are English-only while the product catches up.
Can't find what you're looking for? Use the **search box** at the top of the
sidebar — it indexes every page in these docs. Still stuck? Check
[Troubleshooting](/reference/troubleshooting) or contact support from
the help menu inside your Subscriby dashboard.
---
# Building Custom Agents
Source: https://docs.subscriby.net/integrations/custom-agents
Prefer writing your own agent to using Zapier or n8n? The REST API, OpenAPI spec, and outbound webhooks give you everything you need.
## Core building blocks
1. **Personal access token** — mint with narrow abilities. See [authentication](/api/v1/authentication).
2. **OpenAPI spec** — `https://api.subscriby.net/openapi.json`. Feed it to your favourite code-gen tool (openapi-generator, Stainless, Speakeasy).
3. **Typed SDK** — optional; a generated client beats hand-rolled `fetch` calls once your surface gets non-trivial.
4. **Outbound webhooks** — for event-driven work. Sign-verify per [security guide](/webhooks/v1/security).
5. **Idempotency** — every write takes `Idempotency-Key`. Your SDK should inject a UUID automatically.
## Suggested architecture
```
┌──────────────┐ ┌──────────────────────┐ ┌───────────────┐
│ Your app │◀─webhook─│ Subscriby API │◀─REST────│ Your agent │
│ │ (POST) │ api.subscriby.net │ │ (LLM + tools)│
└──────────────┘ └──────────────────────┘ └───────────────┘
```
- Your agent pushes commands to Subscriby over REST.
- Subscriby pushes state changes back to your app over outbound webhooks.
- The agent can observe state either by polling GET endpoints or by subscribing to its own webhook inbox.
## Read-first, write-later
Most agents should start in read-only mode:
- Token abilities: `project:view-any`, `project-user:view-any`, `project-subscription-plan:view-any`.
- Build and test the retrieval + summary pipeline.
- Only then mint a second token with write abilities and enable the write paths.
Keeping read and write tokens separate limits blast radius on a leak.
## Retry and back-off policy
- Always retry on `5xx` and `429` with exponential back-off.
- Honour `Retry-After` when present.
- Use the same `Idempotency-Key` across retries of the same logical operation so replays are free.
## Surfacing errors to the user
Subscriby returns structured errors:
```json
{
"error": {
"code": "TOKEN_MISSING_ABILITY",
"message": "...",
"docs_url": "https://docs.subscriby.net/api/v1/errors#TOKEN_MISSING_ABILITY",
"remediation": "Mint a new token that includes the required ability.",
"request_id": "..."
}
}
```
Echo `code`, `message`, and `docs_url` into your agent's failure output. The `request_id` is worth passing back to support if you need help diagnosing a specific call.
## Observing your agent's actions
Every REST call and MCP tool invocation is logged under `activity_log` with `actor_kind = 'api_token'` or `'mcp'`. Review in the dashboard, or fetch via `GET /v1/activity`.
## When to use MCP instead
If the agent is a Claude-family model, MCP gives you typed tool-calls and first-class ability enforcement without an OpenAPI layer. Use MCP for Claude Desktop / Cursor; use the raw REST API when you're running inference on OpenAI, Google, Mistral, or self-hosted models.
---
# Automations & Integrations
Source: https://docs.subscriby.net/integrations
import {
Zap,
Workflow,
Boxes,
Bot,
Code2,
TerminalSquare,
Webhook,
BrainCircuit,
ShieldCheck,
Wrench,
Sparkles,
GitBranch,
} from "lucide-react";
Hook Subscriby up to the rest of your stack. Every integration on this page reuses the same Sanctum token catalogue, the same idempotent REST surface, and the same 104-event outbound webhook taxonomy documented elsewhere in the Developers section — so authoring a new automation is a choice of tooling, not a separate product.
Whether you're in Zapier, n8n, Make, or a hand-rolled Python agent, the
primitives are identical: a `sbt_live_…` token for auth, an `Idempotency-Key`
header on every write, and an HMAC-signed POST landing on your webhook
endpoint for every state change.
## Which path is right for me?
Pick the row that matches how you want to build. Every row lands you on the same primitives — tokens, idempotent writes, signed webhooks — just via a different entry point.
| If you… | Start with | Why | Jump to |
| ----------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| live in Zapier, n8n, or Make and want drag-and-drop automation without writing HTTP | **Zapier** or **n8n** | Pre-built catalogs — 145 triggers, 98 actions and 70 searches on Zapier; 145 events and 29 resources on n8n. Zero code. | [Native integrations](#native-integrations) |
| want Claude / Cursor / ChatGPT / VS Code to **drive** Subscriby in natural language | **MCP server** | 161 tools, 6 resources, fine-grained ability gate. LLMs self-correct via the error envelope. | [MCP server](/mcp/v1) |
| need a scenario tool that renders Subscriby on the canvas but isn't Zapier | **Make (Integromat)** | Built-in HTTP + Webhook modules talk to the OpenAPI spec directly. EU hosting if that matters. | [Make](/integrations/make) |
| are building a bespoke backend, dashboard, or custom agent | **OpenAPI spec** + **codegen** | Feed `/openapi.json` into LangChain, Stainless, Speakeasy, openapi-generator, Fern — typed client in minutes. | [Build your own](#build-your-own) |
| just want to poke at the API interactively before committing to anything | **Postman / Insomnia** | Import the spec, wire bearer auth + auto-generated idempotency keys, start clicking. | [Postman / Insomnia](/integrations/postman-insomnia) |
## Native integrations
Integrations we ship and maintain. Each one speaks Subscriby natively — install, mint a token, pick a trigger or action, go.
}
title="Zapier"
href="/integrations/zapier"
>
**145 triggers · 98 actions · 70 searches** — full parity with the REST
surface + the complete webhook catalog. Listed in the Zapier App
Directory. OAuth-less connection using `sbt_live_…` tokens. Also see the
[recipe gallery](/integrations/zapier#recipes).
}
title="n8n"
href="/integrations/n8n"
>
Verified node **`n8n-nodes-subscriby`**, published to npm. Trigger node covers
all **145** webhook events; action node covers **29 resources** with every
write + read operation, including the Analytics API. Works on n8n Cloud and
self-hosted.
}
title="MCP server"
href="/mcp/v1"
>
Streamable-HTTP MCP endpoint at **`mcp.subscriby.net`**. Bearer-token
auth, 161 tools, 6 resources. Works with Claude Desktop, Cursor,
ChatGPT Desktop, and VS Code out of the box.
## OpenAPI-driven toolkits
Platforms that don't ship a dedicated Subscriby app, but can consume the hosted OpenAPI spec at **`https://api.subscriby.net/openapi.json`** — which means every `/v1/*` endpoint is available on day one.
}
title="Make (Integromat)"
href="/integrations/make"
>
Drive Subscriby via Make's built-in **HTTP** + **Webhooks** modules.
Sample scenario for signature verification and branching on
`subscription.created`.
}
title="LangChain"
href="/integrations/langchain"
>
Python and TypeScript. `OpenAPIToolkit` turns the spec into LLM-callable
tools. Works with any chat model.
}
title="Postman / Insomnia"
href="/integrations/postman-insomnia"
>
Import the spec as a collection, wire bearer auth plus auto-generated
idempotency keys, start clicking.
## Build your own
Writing a custom agent or a from-scratch backend? Pick the tool that fits your stack — the spec covers them all.
}
title="Custom agents"
href="/integrations/custom-agents"
>
LangGraph, Mastra, Semantic Kernel, or anything else. Architecture patterns,
token-scoping strategy, read-first / write-later playbook, and pointers to
codegen tools (openapi-generator, Stainless, Speakeasy, Fern) so you never
hand-roll a client.
## Quick-start snippets
```bash
curl https://api.subscriby.net/v1/teams/current \
-H "Authorization: Bearer sbt_live_..."
```
Returns the team your token is scoped to. The canonical smoke test — every integration wizard uses it.
```python
from langchain_community.agent_toolkits.openapi import planner
from langchain_community.utilities.requests import RequestsWrapper
from langchain_openai import ChatOpenAI
import requests, uuid
spec = requests.get("https://api.subscriby.net/openapi.json").json()
requests_wrapper = RequestsWrapper(
headers={
"Authorization": "Bearer sbt_live_...",
"Idempotency-Key": lambda: str(uuid.uuid4()),
}
)
agent = planner.create_openapi_agent(
api_spec=spec,
requests_wrapper=requests_wrapper,
llm=ChatOpenAI(model="gpt-4o"),
allow_dangerous_requests=True,
)
```
```js
// collection pre-request script
pm.request.headers.add({
key: "Idempotency-Key",
value: pm.variables.replaceIn("{{$guid}}"),
});
```
Pair with a collection variable `TOKEN = sbt_live_…` and **Bearer Token** auth bound to `{{TOKEN}}`.
## Capability matrix
| Platform | Native triggers | Native actions | OpenAPI spec | Best for |
| ---------------- | --------------- | -------------- | ------------ | --------------------------------- |
| **Zapier** | ✓ 145 triggers | ✓ 98 actions + 70 searches | — | Non-technical creators |
| **n8n** | ✓ 145 events | ✓ 173 operations | — | Self-hosted automation + devops |
| **MCP server** | — | 161 tools | — | AI-agent workflows |
| **Make** | via Webhooks | via HTTP | ✓ | Visual scenarios, EU hosting |
| **LangChain** | via Webhooks | via OpenAPI | ✓ | Python/TS agent frameworks |
| **Postman** | — | via OpenAPI | ✓ | Interactive exploration, testing |
| **Insomnia** | — | via OpenAPI | ✓ | Same as Postman, designer-focused |
| **Custom agent** | via Webhooks | via OpenAPI | ✓ | Anything else |
## Common questions
No — one `sbt_…` token works everywhere. **Do** mint one token per
integration, though. Makes revocation targeted: leaking the Zapier token
shouldn't brick your LangChain agent. Each token carries its own audit trail
through the [activity log](/api/v1/reference/activity).
Every write takes `Idempotency-Key`. Zapier, n8n, and the Postman pre-request
script inject a fresh UUID automatically. For LangChain and custom agents, the
`RequestsWrapper` snippet above uses a lambda so every call gets a fresh key —
never reuse keys across distinct operations. See
[idempotency](/api/v1/idempotency) for the full semantics.
Every outbound POST carries `SB-Signature: t=…,v1=…`. Make, LangChain, and
custom handlers need to verify HMAC-SHA256 against the endpoint's secret
(returned once when you create the endpoint via `POST /v1/webhook-endpoints`).
Signature-verification snippets live at [webhooks / signature
verification](/webhooks/v1/signature-verification).
Any platform that speaks OpenAPI 3.1 can drive Subscriby today. Import
`https://api.subscriby.net/openapi.json` and you have a typed client
instantly. Email **support@subscriby.net** if you'd like an official listing
added.
## Foundations every integration reuses
}
title="Authentication"
href="/api/v1/authentication"
>
Stripe-style `sbt_live__` tokens, frozen team scope at mint,
optional project scope for further narrowing.
}
title="Idempotency"
href="/api/v1/idempotency"
>
One UUID per write. Retries are safe and return the cached response with
`Idempotent-Replay: true`.
}
title="Webhooks"
href="/webhooks/v1"
>
145 events, HMAC-signed, 8-retry backoff, dead-letter replay. The push side of
every event-driven integration.
}
title="OpenAPI spec"
href="/api/v1/openapi"
>
Auto-generated. Ships with `x-required-scopes` on every operation so
codegen tools can annotate each method with the ability it needs.
## Support
Questions, feature requests, or a platform you want us to add? Email
[support@subscriby.net](mailto:support@subscriby.net) — we keep an eye on
every new platform that supports OpenAPI 3.1 and will add official listings
where the volume justifies it.
---
# LangChain
Source: https://docs.subscriby.net/integrations/langchain
LangChain (Python and TypeScript) ships an OpenAPI toolkit that converts any OpenAPI spec into a set of tools an LLM agent can call. Subscriby's OpenAPI 3.1 spec works out of the box.
## Python
```python
from langchain_community.agent_toolkits.openapi import planner
from langchain_community.utilities.requests import RequestsWrapper
from langchain_openai import ChatOpenAI
import yaml, requests
spec = requests.get("https://api.subscriby.net/openapi.json").json()
requests_wrapper = RequestsWrapper(
headers={
"Authorization": "Bearer sbt_live_...",
"Idempotency-Key": lambda: str(uuid4()),
}
)
agent = planner.create_openapi_agent(
api_spec=spec,
requests_wrapper=requests_wrapper,
llm=ChatOpenAI(model="gpt-4o"),
allow_dangerous_requests=True,
)
agent.invoke(
"List my Subscriby projects."
)
```
## TypeScript
```ts
const spec = await fetch("https://api.subscriby.net/openapi.json").then((r) =>
r.json(),
);
const agent = await createOpenAPIAgent({
spec,
llm: new ChatOpenAI({ model: "gpt-4o" }),
requestOptions: {
headers: { Authorization: "Bearer sbt_live_..." },
},
});
await agent.invoke({ input: "List my Subscriby projects." });
```
## Respecting rate limits and idempotency
The OpenAPI toolkit doesn't auto-inject `Idempotency-Key` — wire a middleware that generates a UUID per request and retries on 429:
```python
def before_request(method, url, headers, body):
headers["Idempotency-Key"] = str(uuid4())
return method, url, headers, body
```
## Narrowing the toolkit
LangChain's planner loads every operation from the spec by default. For scoped agents, pre-filter the spec to only the endpoints you want exposed:
```python
allowed = {"/v1/projects", "/v1/projects/{id}", "/v1/webhook-endpoints"}
spec["paths"] = {k: v for k, v in spec["paths"].items() if k in allowed}
```
## Prefer MCP for Claude-family models
If the LLM driving the agent is a Claude model, connect it directly to the [MCP server](/mcp/v1) instead of LangChain. You skip the OpenAPI-toolkit layer entirely and get typed responses + better ability gating.
---
# Make (Integromat)
Source: https://docs.subscriby.net/integrations/make
Make (formerly Integromat) does not ship a native Subscriby app today. Integrate via Make's built-in **HTTP** and **Webhooks** modules against the hosted OpenAPI spec — every Subscriby REST route is available.
## Outbound: subscribe to a Subscriby event
1. Create a Make scenario and add a **Webhooks → Custom webhook** listener at the top.
2. Copy the listener URL.
3. In Subscriby: **Settings → Webhooks → New endpoint**. Use the Make listener URL as the target. Pick events. Save. Copy the secret.
4. Add a **Tools → Compose a string** module (or Serverless → Code) to verify `SB-Signature` using the secret.
5. Branch into downstream modules.
### Signature verification in a Serverless JavaScript module
```js
const rawBody = context.raw;
const header = context.headers["sb-signature"];
const [tPart, v1Part] = header.split(",");
const t = tPart.split("=")[1];
const v1 = v1Part.split("=")[1];
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return {
valid: crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)),
};
```
Make normalizes JSON bodies by default — for correct signature verification you must read the raw body (`context.raw`), not the parsed `context.body`.
## Outbound: call the REST API
Use an **HTTP → Make a request** module:
- **URL**: `https://api.subscriby.net/v1/`.
- **Method**: whatever the endpoint needs.
- **Headers**: `Authorization: Bearer sbt_…`, `Idempotency-Key: {{uuid}}`.
- **Body type**: JSON for writes.
## Alternatives with native support
If you want typed triggers + actions without writing HTTP modules yourself, use:
- **[Zapier](/integrations/zapier)** — 145 triggers, 98 write actions, 70 searches in the App Directory.
- **[n8n](/integrations/n8n)** — community node on npm (`n8n-nodes-subscriby`) covering the full REST surface.
---
# n8n
Source: https://docs.subscriby.net/integrations/n8n
**n8n-nodes-subscriby** is a **verified community node** on n8n, installable on Cloud and self-hosted alike. It ships:
- A **trigger node** listing all **145 webhook events** from the `WebhookEvent` catalog.
- An **action node** covering **173 operations across 29 resources** — full parity with the live `api.subscriby.net/v1` surface. Every count on this page is generated from the node's source.
## Install
### Add the node [step]
Open any workflow, click **+** to add a node, search **Subscriby**, and drag it onto the canvas. No package install — verified nodes are available directly from the canvas.
If Subscriby does not appear in search, an admin must enable **Verified
Community Nodes** under **Admin Panel → Settings**, then restart the instance.
In your n8n instance: **Settings → Community Nodes → Install**. Enter `n8n-nodes-subscriby` and confirm. n8n restarts automatically and picks up the new nodes.
CLI alternative:
```bash
cd ~/.n8n/custom
npm install n8n-nodes-subscriby
```
Restart the n8n process after installing.
### Add the credential [step]
**Credentials → New → Subscriby API**. Paste an API token minted at [app.subscriby.net/settings/tokens](https://app.subscriby.net/settings/tokens). The credential test calls `GET /v1/teams/current`; a 200 means you're connected.
Required token abilities:
- `team:view` — always (credential test relies on it).
- `webhook-endpoint:manage` — for the trigger node (registers + deletes endpoints on workflow activation / deactivation).
- Plus any ability each action operation you drop onto the canvas requires (see the [ability catalog](/api/v1/abilities)).
Production tokens start with `sbt_live_`; non-production (staging, local)
tokens start with `sbt_test_`. The credential placeholder expects the full
Stripe-style token — copy-paste everything after "Bearer ".
## Trigger node
**Subscriby Trigger** starts a workflow when one or more events fire. Multi-select the events, optionally scope to a single project, activate the workflow. On activation n8n calls `POST /v1/webhook-subscriptions`; on deactivation it calls the matching `DELETE`. Idempotency keys are generated automatically.
Signature verification (`SB-Signature` HMAC) is enabled by default — the node validates every inbound request and silently drops mismatches. Turn it off only for local debugging.
| Family | Count | Prefix |
| ---------------------- | ------- | ---------------- |
| Project | 13 | `project.*` |
| Plan | 8 | `plan.*` |
| Pass | 8 | `pass.*` |
| Pass Series | 8 | `pass_series.*` |
| Coupon | 7 | `coupon.*` |
| Subscription | 16 | `subscription.*` |
| Payment | 4 | `payment.*` |
| Member | 15 | `member.*` |
| Creator Task | 2 | `creator_task.*` |
| Connector | 10 | `connector.*` |
| Recovery | 13 | `recovery.*` |
| Broadcast | 2 | `broadcast.*` |
| Access Code | 3 | `access_code.*` |
| Support | 12 | `support.*` |
| Team | 7 | `team.*` |
| Role | 3 | `role.*` |
| Group | 4 | `group.*` |
| Billing (creator tier) | 10 | `billing.*` |
| **Total** | **145** | |
The full `value → name` mapping follows the canonical [webhook event catalog](/api/v1/reference/webhook-events). New events added to Subscriby will appear here on the next release bump.
## Action node
**Subscriby** exposes every mutation and read route on the API. Pick a **Resource** then an **Operation** from the sidebar. Every write auto-sends a fresh `Idempotency-Key` header — re-running a node is safe (Subscriby returns the cached response for replays within 24h).
### Resource + operation matrix
| Resource | Operations |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Access Code** | Bulk Generate, List, Delete, Preview |
| **Account** | Get Me |
| **Activity** | List |
| **Analytics** | Get Connector Analytics, Get Dashboard, Get Earnings, Get Plan Performance, Get Revenue Composition, Get Subscribers, Get Transaction Breakdown, List Transactions |
| **Broadcast** | List Audiences, Preview, Send |
| **Canned Reply** | Create, Delete, Get, List, Update |
| **Connector** | Disconnect, Get, Get Installation, Get Uninstall Preview, Install, List, List Installations, Restore Access, Run Doctor, Uninstall, Update Settings, Verify |
| **Coupon** | Activate, Create, Deactivate, Delete, Get, List, Update |
| **Creator Task** | Complete, List |
| **Distribution** | Get Portal URL, Get Deep Link |
| **Group** | Create, Delete, Get, List, Sync Members, Update |
| **Member** | Ban, Get, Kick, List, List Identities, Unban, Unlink Identity |
| **Notification** | List, Mark All Read, Mark Read |
| **Pass Window** | Cancel, Create, Get, List, Remind Queue |
| **Payment Method** | Activate, Deactivate, Delete, Get, List, Sync Plans |
| **Plan** | Arrange Storefront Order, Create, Delete, Find by Name, Get, List, Publish, Start Next Season, Unpublish, Update |
| **Project** | Archive, Create, Delete, Find by Handle, Get, List, Restore, Update |
| **Recovery** | Get Allowances, Get Incident, Get Operation, Get Readiness, Get Roll Call, Get Settings, Get Standby, List Incidents, List Operations, Notify Members, Nudge Pending Readmissions, Remove Standby, Remove Standby Installation, Request Replacement, Request Standby, Revert, Set Standby Mirror, Update Settings, Use Standby, Withdraw Replacement Request, Withdraw Standby Request |
| **Resource** | Activate, Create, Deactivate, Delete, Get, List, Request Link, Unlink, Update |
| **Role** | Create, Delete, Get, List, Update |
| **Subscriber** | Find by Connector Account |
| **Subscription** | Cancel, Get, List, List Grants, Pause Access, Reactivate, Reissue Grants, Remind Pass Holder, Unpause Access |
| **Support Conversation** | Assign, Block Contact, Get, List, List Messages, Reopen, Reply, Resolve, Unblock Contact |
| **Support Inbox** | Get Settings, Update Settings |
| **Team** | Create, Delete, Get, Get Current, List, Update |
| **Team Member** | Cancel Invitation, Get, Invite, List, Remove, Update Role |
| **Token** | List, Get, Revoke |
| **Webhook Delivery** | Get, List, Retry, Retry Dead |
| **Webhook Endpoint** | Create, Delete, Get, List, Pause, Resume, Rotate Secret, Test |
### Broadcast → Send
The audience is built from three fields that compose. **Audience** picks a segment, **Plan ID** optionally narrows it to one plan, and **Running Out Within (Days)** sets the horizon that `Expiring Soon` reads.
Thirteen segments are available: the five member statuses (`All Users`, `Customers Only`, `Trialing Users`, `Leads`, `Churned Users`), four that describe a subscription (`Expiring Soon`, `Cancelled, Still Inside Their Period`, `Paused Subscriptions`, `Trialing Without a Card`), and four pass segments that need Time-Limited Passes.
**Plan ID** narrows rather than replaces: _Customers Only_ plus a plan reaches people paying for it now, _Churned Users_ plus a plan reaches people who held it and left. It is refused for _Leads_, who never subscribed, and for the pass segments, whose plan is implied by the window.
**An auto-renewing subscription is never `Expiring Soon`.** A subscription's
end date is rewritten to the new period end on every renewal, so a date alone
describes the next invoice rather than an expiry — treating it as one would
sweep every monthly subscriber into the segment once a month. A member is
counted only once their access genuinely lapses.
Run **Preview** before **Send**. A broadcast cannot be recalled, and the Send response reports what was _addressed_, not what was _delivered_ — watch the `broadcast.completed` trigger for the tallies.
**Error envelope.** Any non-2xx response is translated into an n8n
`NodeApiError` whose message carries `error.message` and whose description
carries `error.remediation` + `error.docs_url`. Branch on `error.code`
(`TOKEN_MISSING_ABILITY`, `TENANT_MISMATCH`, etc.) in downstream IF nodes.
## Plan writes and Pass Series — 2.0.0
**Plan → Create** and **Plan → Update** now take a **Plan Kind**, and the fields below it change to match: _Recurring Subscription_ (billing cycle, trial, renewal), _Time-Limited Pass_ (timezone, recurrence, sales cutoff, access windows) or _Pass Series_ (a slate of other pass plans' windows).
It sent `currency` where the API expects `currency_id`, a billing cycle value
outside the API's enum, and never sent the required `resources` — so every
call returned 422. There was no working workflow to break, which is why this
change carries no migration.
### Building a Pass Series
A series points at windows that **already exist** on your pass plans:
1. **Pass Window → List** — the new resource. Returns each window's ID, local range, status, on-sale state and holder count. Filter by plan, status, on-sale-only or date range.
2. **Plan → Create** with Plan Kind → _Pass Series_, pasting those IDs into **Pass Window IDs** as a comma-separated list.
3. Optionally add **Automatic Inclusion Rules** — a repeating collection. A rule keeps matching after the save: a window scheduled later joins the slate **and is granted to everyone already holding the series**, at no charge.
**Resource IDs** is optional on a series and means a lounge the holder keeps for the whole span; each window already grants its own plan's resources.
## Fallback with built-in nodes
If you cannot use the Subscriby node (a locked-down instance, a policy that disables community nodes entirely, or an older n8n version), the integration still works with n8n's built-in **HTTP Request** + **Webhook** nodes.
1. Start the workflow with a **Webhook** node; copy its production URL.
2. In Subscriby: **Settings → Webhooks → New endpoint**. Paste the n8n URL, pick events, save. Store the plaintext `secret`.
3. After the Webhook node add a **Function** node that verifies `SB-Signature` against the secret:
```js
const crypto = require("crypto");
const header = $json.headers["sb-signature"] || "";
const [tPart, v1Part] = header.split(",");
const t = tPart.split("=")[1];
const v1 = v1Part.split("=")[1];
const expected = crypto
.createHmac("sha256", "whsec_your_secret")
.update(`${t}.${JSON.stringify($json.body)}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
? [$json]
: [];
```
**Caveat:** n8n re-serializes the body. For strict signature verification enable the Webhook node's **Raw Body** option so you can verify against the exact bytes Subscriby signed.
Use an **HTTP Request** node with:
- **Method**: `POST` / `GET` / `PATCH` / `DELETE`
- **URL**: `https://api.subscriby.net/v1/`
- **Authentication**: Generic Credential Type → Header Auth → `Authorization: Bearer sbt_live__`
- **Headers**: add `Idempotency-Key` with a fresh UUID on every write
Import the OpenAPI spec (`https://api.subscriby.net/openapi.json`) into the HTTP Request node for auto-complete on parameters.
## Recipes
Prebuilt workflow ideas combining the Subscriby Trigger and action nodes with common downstream integrations. Swap the destination node for whatever service fits your stack.
### Revenue ops
#### 1. New subscriber → welcome email
- **Trigger node**: `subscription.created`.
- **Action**: Mailjet / Resend / Postmark node → send a "Welcome" email using the subscriber's email field.
Most members have no `email` — they join through a bot and are never asked for one. `billing_email` is often the only address on file, and it is unverified, so treat it as identification rather than a consented mailing address.
#### 2. New subscriber → CRM upsert
- **Trigger node**: `subscription.created`.
- **Action**: HubSpot / Pipedrive / Attio node → create-or-update contact; map plan name + MRR fields.
#### 3. Churn → Slack alert
- **Trigger node**: `subscription.cancelled` or `subscription.expired`.
- **Action**: Slack node → post in `#churn-watch` with subscriber handle, plan, and days-subscribed.
#### 4. Failed payment → Slack + follow-up email
- **Trigger node**: `payment.failed`.
- **Action 1**: Slack node → post in `#revenue-ops`.
- **Action 2**: Wait node (12 hours) → Subscriby action node **Get Subscription** to re-check state. If still unpaid, send a Mailjet reminder with a payment-update link.
#### 5. Past due → automated drip
- **Trigger node**: `subscription.past_due`.
- **Action**: Mailjet / ActiveCampaign node → enroll in a sequence: day 1 "payment failed", day 3 "reminder", day 7 "last chance".
#### 6. Trial converting → personal nudge
- **Trigger node**: `subscription.trial_converting`.
- **Action**: Slack node → DM the creator with the subscriber's profile link so they can intervene before the trial flips to paid.
### Access codes
#### 7. Access-code batch generated → Airtable export
- **Trigger node**: `access_code.generated`.
- **Action**: Airtable node → append a row with batch metadata (plan, count, expiry).
#### 8. Access-code redeemed → Google Sheet log
- **Trigger node**: `access_code.redeemed`.
- **Action**: Google Sheets node → append a row with subscriber id, plan id, masked code, timestamp.
#### 9. Gift campaign → generate + DM
- **Trigger**: Airtable trigger on a new "Influencer List" row.
- **Action 1**: Subscriby action node **Bulk Generate Access Codes** (1 code, `export_type=file`).
- **Action 2**: Subscriby action node **Get Deep Link** with `access_code=`.
- **Action 3**: Slack / Gmail node → send the influencer the one-tap bot link.
### Team + audit
#### 10. Member banned → Telegram ops chat
- **Trigger node**: `member.banned`.
- **Action**: Telegram node → send to an ops chat with reason + the ban-issuing admin's name.
#### 11. Team role changed → audit trail
- **Trigger node**: `team.member.role_changed`.
- **Action**: Notion node → append a database entry with old/new role, actor, timestamp.
#### 12. Suspicious activity → auto-revoke token
- **Trigger**: Schedule trigger (hourly).
- **Action 1**: Subscriby action node **List Activity**, filter for `actor_kind = 'mcp'` entries on high-value resources using an IF node.
- **Action 2**: If count exceeds threshold, Subscriby action node **Revoke Token** on the flagged token id + Slack alert.
### Billing
#### 13. Grace-period warning → creator heads-up
- **Trigger node**: `billing.grace_period_warning`.
- **Action**: Gmail / Mailjet node → email the account owner with a link to `/settings/billing` before the grace window elapses.
#### 14. Account locked → ops escalation
- **Trigger node**: `billing.account_locked`.
- **Action 1**: PagerDuty node → create incident.
- **Action 2**: Slack node → post in `#oncall` with tier cycle and last successful payment date.
#### 15. Tier upgraded → CRM annotation + welcome pack
- **Trigger node**: `billing.tier_upgraded`.
- **Action 1**: HubSpot node → update lifecycle stage to "paid".
- **Action 2**: Gmail / Notion node → send the creator the tier-specific onboarding playbook.
### Analytics & reporting
#### 16. Weekly revenue digest
- **Trigger**: Schedule trigger (every Monday 09:00).
- **Action 1**: Subscriby action node **Get Dashboard** with `period=7d`.
- **Action 2**: Subscriby action node **Get Transaction Breakdown** with `dimension=payment_provider`, `period=7d`.
- **Action 3**: Gmail / Slack node → send digest combining both results.
#### 17. Plan performance → Google Sheet
- **Trigger**: Schedule trigger (daily).
- **Action 1**: Subscriby action node **Get Plan Performance** for each active project (loop with SplitInBatches node).
- **Action 2**: Google Sheets node → append rows (one per plan) for the finance dashboard.
#### 18. Month-end close → earnings statement
- **Trigger**: Schedule trigger (1st of each month 06:00).
- **Action 1**: Subscriby action node **Get Earnings** with `granularity=day` for the previous month.
- **Action 2**: HTTP Request node (DocRaptor) → generate PDF → Gmail node to accounting.
### Integrations hygiene
#### 19. Onboarding checklist
- **Trigger node**: `member.trial_joined`.
- **Action 1**: HubSpot node → start "Trial onboarding" sequence.
- **Action 2**: Wait node (3 days).
- **Action 3**: Subscriby action node **Get Member** to check status; if still trialing, send a reminder.
#### 20. Test webhook endpoint on deploy
- **Trigger**: GitHub trigger (release published).
- **Action**: Subscriby action node **Test Webhook Endpoint** against every active endpoint (loop with SplitInBatches). Slack node → post summary.
#### 21. Rotate webhook secret → secret manager
- **Trigger**: Schedule trigger (every 90 days).
- **Action 1**: Subscriby action node **Rotate Webhook Secret** on the endpoint.
- **Action 2**: HTTP Request node → write the fresh `secret` into HashiCorp Vault / AWS Secrets Manager.
- **Action 3**: Slack node → notify `#platform` that the rotation landed.
## Source
The node package is maintained at [github.com/envigoinnovations/subscriby-n8n](https://github.com/envigoinnovations/subscriby-n8n) and published to npm as [`n8n-nodes-subscriby`](https://www.npmjs.com/package/n8n-nodes-subscriby). See the [CHANGELOG](https://github.com/envigoinnovations/subscriby-n8n/blob/main/CHANGELOG.md) for per-version deltas.
## Related
- [Webhook event catalog](/api/v1/reference/webhook-events) — the 106 event `type` values the trigger subscribes to.
- [API authentication](/api/v1/authentication) — how `sbt_live_` / `sbt_test_` tokens are minted.
- [Signature verification](/webhooks/v1/signature-verification) — HMAC format used by both the native node and the fallback.
- [Analytics API](/api/v1/reference/analytics) — the REST endpoints behind the Analytics resource.
---
# Postman / Insomnia
Source: https://docs.subscriby.net/integrations/postman-insomnia
## Postman
1. Open Postman → **APIs** tab → **Import**.
2. Choose **Link** and paste `https://api.subscriby.net/openapi.json`.
3. Postman generates a collection with every `/v1/*` endpoint organised by tag.
4. Set a **collection variable** called `TOKEN` to your Subscriby `sbt_…` token.
5. In the collection's **Authorization** tab, pick **Bearer Token** and bind to `{{TOKEN}}`.
6. In the collection's **Pre-request Script**, add an idempotency key generator:
```js
pm.request.headers.add({
key: "Idempotency-Key",
value: pm.variables.replaceIn("{{$guid}}"),
});
```
Now every write request in the collection auto-injects a fresh UUID as the idempotency key.
## Insomnia
1. Open Insomnia → **Create → Import from URL** → paste `https://api.subscriby.net/openapi.json`.
2. Select "Convert to Design Document" so you can browse operations by tag.
3. Create a new environment variable `TOKEN` and set it to your token value.
4. Insomnia reads the OpenAPI `security` block — requests inherit the bearer auth automatically.
5. Add a plugin or Before-Request script that sets `Idempotency-Key: {% uuid 'v4' %}`.
## Syncing updates
The OpenAPI spec is regenerated on every Subscriby deploy. Re-import periodically, or configure Postman's "Sync" feature to auto-pull changes from the URL.
## Using the spec for codegen
Both apps can export the spec back out, but for code generation you're better off using the raw JSON URL as the input to:
- `openapi-generator` (open-source, multi-language)
- Speakeasy / Stainless / Fern (commercial, opinionated SDKs)
See the [OpenAPI](/api/v1/openapi) reference for the `x-required-scopes` extension — any of the codegen tools preserves it as a comment on each operation.
---
# Zapier
Source: https://docs.subscriby.net/integrations/zapier
The Subscriby Zapier app exposes **145 instant triggers**, **98 write actions** and **70 searches** — one trigger per webhook event, an action for every write and a search for every read on the live `api.subscriby.net/v1` surface. Every count on this page is generated from the app's source.
## Install
The Subscriby Zapier integration is currently **invite-only** and is not yet publicly listed in the Zapier App Directory. To request access, contact the Subscriby team. Once you receive an invite link, click it to connect your account. During the OAuth-less connection step Zapier asks for an **API token** — mint one at [app.subscriby.net/settings/tokens](https://app.subscriby.net/settings/tokens) with the abilities listed per trigger / action below.
The Subscriby Zapier app is undergoing testing and validation before its
public Zapier listing. Access is invite-only during this period.
Production tokens start with `sbt_live_`; non-production (staging, local)
tokens start with `sbt_test_`. Both work — Subscriby rejects tokens from the
wrong environment automatically.
## Triggers
Grouped by family. Every trigger uses the REST Hook pattern: on Zap activation Zapier calls `POST /v1/webhook-subscriptions`, on deactivation it calls the matching `DELETE`. All triggers additionally implement a polling fallback (`performList`) against [`/v1/webhook-events`](/api/v1/reference/webhook-events) so the Zap editor can show a sample even before any live event has fired. One trigger is polling only: `notification_received` reads [`/v1/me/notifications?unread=1`](/api/v1/reference/notifications) and fires once per new entry of your notification centre, because the notification centre raises no webhook of its own.
**145 instant triggers**, one per event, grouped by family.
| Trigger | Event | Required ability |
| ----------------------------- | --------------------------------- | ----------------------------- |
| **New Project** | `project.created` | `project:view` |
| **Project Updated** | `project.updated` | `project:view` |
| **Project Archived** | `project.archived` | `project:view` |
| **Project Restored** | `project.restored` | `project:view` |
| **Project Deleted** | `project.deleted` | `project:view` |
| **Project Resource Created** | `project.resource.created` | `project-resource:view` |
| **Project Resource Linked** | `project.resource.linked` | `project-resource:view` |
| **Project Resource Unlinked** | `project.resource.unlinked` | `project-resource:view` |
| **Project Resource Deleted** | `project.resource.deleted` | `project-resource:view` |
| **Resource Updated** | `project.resource.updated` | `project-resource:view` |
| **Resource Status Changed** | `project.resource.status_changed` | `project-resource:view` |
| **Payment Method Updated** | `project.payment_method.updated` | `project-payment-method:view` |
| **Payment Method Deleted** | `project.payment_method.deleted` | `project-payment-method:view` |
| Trigger | Event | Required ability |
| ----------------------- | --------------------- | -------------------------------- |
| **New Plan** | `plan.created` | `project-subscription-plan:view` |
| **Plan Updated** | `plan.updated` | `project-subscription-plan:view` |
| **Plan Activated** | `plan.activated` | `project-subscription-plan:view` |
| **Plan Deactivated** | `plan.deactivated` | `project-subscription-plan:view` |
| **Plan Sold Out** | `plan.sold_out` | `project-subscription-plan:view` |
| **Plan Order Changed** | `plan.order_changed` | `project-subscription-plan:view` |
| **Plan Deleted** | `plan.deleted` | `project-subscription-plan:view` |
| **Plan Sync Completed** | `plan.sync_completed` | `project-subscription-plan:view` |
| Trigger | Event | Required ability |
| ------------------------------- | ----------------------- | -------------------------------- |
| **Pass Window Scheduled** | `pass.window_scheduled` | `project-subscription-plan:view` |
| **Pass Window Opened** | `pass.window_opened` | `project-subscription-plan:view` |
| **Pass Window Closed** | `pass.window_closed` | `project-subscription-plan:view` |
| **Pass Window Cancelled** | `pass.window_cancelled` | `project-subscription-plan:view` |
| **Pass Holder Queued** | `pass.holder_queued` | `project-subscription:view` |
| **Pass Holder Moved** | `pass.holder_moved` | `project-subscription:view` |
| **Pass Holder Missed a Window** | `pass.holder_missed` | `project-subscription:view` |
| **Pass Holder Stranded** | `pass.holder_stranded` | `project-subscription:view` |
| Trigger | Event | Required ability |
| -------------------------------- | ----------------------------- | -------------------------------- |
| **Pass Series Purchased** | `pass_series.purchased` | `project-subscription:view` |
| **Pass Series Pass Added** | `pass_series.leg_added` | `project-subscription-plan:view` |
| **Pass Series Pass Completed** | `pass_series.leg_completed` | `project-subscription:view` |
| **Pass Series Pass Substituted** | `pass_series.leg_substituted` | `project-subscription:view` |
| **Pass Series Pass Dropped** | `pass_series.leg_dropped` | `project-subscription:view` |
| **Pass Series Sold Out** | `pass_series.seats_exhausted` | `project-subscription-plan:view` |
| **Pass Series Completed** | `pass_series.completed` | `project-subscription:view` |
| **Pass Series Presale Opened** | `pass_series.presale_opened` | `project-subscription-plan:view` |
| Trigger | Event | Required ability |
| ---------------------- | -------------------- | --------------------- |
| **New Coupon** | `coupon.created` | `project-coupon:view` |
| **Coupon Updated** | `coupon.updated` | `project-coupon:view` |
| **Coupon Activated** | `coupon.activated` | `project-coupon:view` |
| **Coupon Deactivated** | `coupon.deactivated` | `project-coupon:view` |
| **Coupon Deleted** | `coupon.deleted` | `project-coupon:view` |
| **Coupon Redeemed** | `coupon.redeemed` | `project-coupon:view` |
| **Coupon Exhausted** | `coupon.exhausted` | `project-coupon:view` |
| Trigger | Event | Required ability |
| --------------------------------- | ------------------------------- | --------------------------- |
| **New Subscription** | `subscription.created` | `project-subscription:view` |
| **Subscription Activated** | `subscription.activated` | `project-subscription:view` |
| **Subscription Trial Started** | `subscription.trial_started` | `project-subscription:view` |
| **Subscription Trial Converting** | `subscription.trial_converting` | `project-subscription:view` |
| **Subscription Trial Expired** | `subscription.trial_expired` | `project-subscription:view` |
| **Subscription Renewed** | `subscription.renewed` | `project-subscription:view` |
| **Subscription Reactivated** | `subscription.reactivated` | `project-subscription:view` |
| **Subscription Paused** | `subscription.paused` | `project-subscription:view` |
| **Subscription Unpaused** | `subscription.unpaused` | `project-subscription:view` |
| **Subscription Past Due** | `subscription.past_due` | `project-subscription:view` |
| **Subscription Unpaid** | `subscription.unpaid` | `project-subscription:view` |
| **Subscription Cancelled** | `subscription.cancelled` | `project-subscription:view` |
| **Subscription Expired** | `subscription.expired` | `project-subscription:view` |
| **Subscription Refunded** | `subscription.refunded` | `project-subscription:view` |
| **Subscription Upgraded** | `subscription.upgraded` | `project-subscription:view` |
| **Subscription Downgraded** | `subscription.downgraded` | `project-subscription:view` |
| Trigger | Event | Required ability |
| --------------------- | ------------------- | --------------------------- |
| **Payment Succeeded** | `payment.succeeded` | `project-subscription:view` |
| **Payment Failed** | `payment.failed` | `project-subscription:view` |
| **Payment Pending** | `payment.pending` | `project-subscription:view` |
| **Payment Refunded** | `payment.refunded` | `project-subscription:view` |
| Trigger | Event | Required ability |
| -------------------------------- | -------------------------- | ------------------- |
| **Member Joined** | `member.joined` | `project-user:view` |
| **Trial Member Joined** | `member.trial_joined` | `project-user:view` |
| **Member Converted** | `member.converted` | `project-user:view` |
| **Member Churned** | `member.churned` | `project-user:view` |
| **Member Removed** | `member.removed` | `project-user:view` |
| **Member Banned** | `member.banned` | `project-user:view` |
| **Member Unbanned** | `member.unbanned` | `project-user:view` |
| **Member Kicked** | `member.kicked` | `project-user:view` |
| **Member Added to Resource** | `member.resource_added` | `project-user:view` |
| **Member Access Pending** | `member.resource_pending` | `project-user:view` |
| **Member Access Reissued** | `member.resource_reissued` | `project-user:view` |
| **Member Access Extended** | `member.access_extended` | `project-user:view` |
| **Member Removed From Resource** | `member.resource_removed` | `project-user:view` |
| **Member Connected Account** | `member.identity_linked` | `project-user:view` |
| **Member Disconnected Account** | `member.identity_unlinked` | `project-user:view` |
| Trigger | Event | Required ability |
| -------------------------- | ------------------------ | --------------------------- |
| **Creator Task Opened** | `creator_task.opened` | `project-subscription:view` |
| **Creator Task Completed** | `creator_task.completed` | `project-subscription:view` |
| Trigger | Event | Required ability |
| -------------------------------- | ------------------------------ | ------------------------ |
| **Connector Installed** | `connector.installed` | `project-connector:view` |
| **Connector Connected** | `connector.connected` | `project-connector:view` |
| **Connector Disconnected** | `connector.disconnected` | `project-connector:view` |
| **Connector Status Changed** | `connector.status_changed` | `project-connector:view` |
| **Connector Settings Updated** | `connector.settings_updated` | `project-connector:view` |
| **Connector Uninstalled** | `connector.uninstalled` | `project-connector:view` |
| **Connector Doctor Completed** | `connector.doctor_completed` | `project-connector:view` |
| **Connector Outage Opened** | `connector.outage_opened` | `project-connector:view` |
| **Connector Outage Closed** | `connector.outage_closed` | `project-connector:view` |
| **Connector Outage Compensated** | `connector.outage_compensated` | `project-connector:view` |
The family every connector answers to.
| Trigger | Event | Required ability |
| ------------------------------------- | ----------------------------------- | ----------------------- |
| **Recovery Incident Opened** | `recovery.incident_opened` | `project-recovery:view` |
| **Recovery Incident Resolved** | `recovery.incident_resolved` | `project-recovery:view` |
| **Recovery Operation Started** | `recovery.operation_started` | `project-recovery:view` |
| **Recovery Operation Completed** | `recovery.operation_completed` | `project-recovery:view` |
| **Recovery Operation Failed** | `recovery.operation_failed` | `project-recovery:view` |
| **Recovery Operation Reverted** | `recovery.operation_reverted` | `project-recovery:view` |
| **Recovery Standby Registered** | `recovery.standby_registered` | `project-recovery:view` |
| **Recovery Standby Removed** | `recovery.standby_removed` | `project-recovery:view` |
| **Recovery Installation Failed Over** | `recovery.installation_failed_over` | `project-recovery:view` |
| **Recovery Resource Failed Over** | `recovery.resource_failed_over` | `project-recovery:view` |
| **Recovery Resource Replaced** | `recovery.resource_replaced` | `project-recovery:view` |
| **Recovery Identity Relinked** | `recovery.identity_relinked` | `project-recovery:view` |
| **Recovery Readiness Changed** | `recovery.readiness_changed` | `project-recovery:view` |
| Trigger | Event | Required ability |
| ----------------------- | --------------------- | ---------------- |
| **Broadcast Queued** | `broadcast.queued` | `broadcast:send` |
| **Broadcast Completed** | `broadcast.completed` | `broadcast:send` |
| Trigger | Event | Required ability |
| -------------------------- | ----------------------- | -------------------------- |
| **Access Codes Generated** | `access_code.generated` | `project-access-code:view` |
| **Access Code Redeemed** | `access_code.redeemed` | `project-access-code:view` |
| **Access Code Expired** | `access_code.expired` | `project-access-code:view` |
| Trigger | Event | Required ability |
| --------------------------------- | -------------------------------- | --------------------------- |
| **Support Conversation Opened** | `support.conversation.opened` | `support-conversation:view` |
| **Support Conversation Assigned** | `support.conversation.assigned` | `support-conversation:view` |
| **Support Conversation Resolved** | `support.conversation.resolved` | `support-conversation:view` |
| **Support Conversation Reopened** | `support.conversation.reopened` | `support-conversation:view` |
| **Support Contact Blocked** | `support.conversation.blocked` | `support-conversation:view` |
| **Support Contact Unblocked** | `support.conversation.unblocked` | `support-conversation:view` |
| **Support Message Received** | `support.message.received` | `support-conversation:view` |
| **Support Message Sent** | `support.message.sent` | `support-conversation:view` |
| **New Canned Reply** | `support.canned_reply.created` | `support-conversation:view` |
| **Canned Reply Updated** | `support.canned_reply.updated` | `support-conversation:view` |
| **Canned Reply Deleted** | `support.canned_reply.deleted` | `support-conversation:view` |
| **Support Settings Updated** | `support.settings.updated` | `support-conversation:view` |
| Trigger | Event | Required ability |
| ---------------------------- | -------------------------- | ---------------- |
| **Team Created** | `team.created` | `team:view` |
| **Team Updated** | `team.updated` | `team:view` |
| **Team Deleted** | `team.deleted` | `team:view` |
| **Team Member Invited** | `team.member.invited` | `team:view` |
| **Team Member Joined** | `team.member.joined` | `team:view` |
| **Team Member Removed** | `team.member.removed` | `team:view` |
| **Team Member Role Changed** | `team.member.role_changed` | `team:view` |
| Trigger | Event | Required ability |
| ---------------- | -------------- | ---------------- |
| **Role Created** | `role.created` | `role:view` |
| **Role Updated** | `role.updated` | `role:view` |
| **Role Deleted** | `role.deleted` | `role:view` |
| Trigger | Event | Required ability |
| ------------------------ | ---------------------- | ---------------- |
| **Group Created** | `group.created` | `group:view` |
| **Group Updated** | `group.updated` | `group:view` |
| **Group Members Synced** | `group.members_synced` | `group:view` |
| **Group Deleted** | `group.deleted` | `group:view` |
| Trigger | Event | Required ability |
| ----------------------------- | ------------------------------ | ---------------- |
| **Tier Invoice Created** | `billing.invoice_created` | `billing:read` |
| **Tier Invoice Paid** | `billing.invoice_paid` | `billing:read` |
| **Tier Invoice Overdue** | `billing.invoice_overdue` | `billing:read` |
| **Tier Payment Failed** | `billing.payment_failed` | `billing:read` |
| **Tier Trial Ending** | `billing.trial_ending` | `billing:read` |
| **Tier Grace Period Warning** | `billing.grace_period_warning` | `billing:read` |
| **Tier Account Locked** | `billing.account_locked` | `billing:read` |
| **Tier Upgraded** | `billing.tier_upgraded` | `billing:read` |
| **Tier Downgraded** | `billing.tier_downgraded` | `billing:read` |
| **Tier Cancelled** | `billing.tier_cancelled` | `billing:read` |
Every trigger additionally requires `webhook-endpoint:manage` on the token so Zapier can create and delete the underlying webhook endpoints.
## How triggers work
### You pick a trigger in Zapier [step]
Example: **New Subscription**. Zapier's `performSubscribe` calls:
```http
POST https://api.subscriby.net/v1/webhook-subscriptions
Authorization: Bearer sbt_live__
Idempotency-Key:
Content-Type: application/json
{
"name": "Zapier — New Subscription",
"url": "",
"events": ["subscription.created"]
}
```
### Subscriby registers the endpoint [step]
The response includes `{ data: { id }, secret }`. Zapier stores both — the `secret` powers `SB-Signature` verification on every inbound event.
### Event fires inside Subscriby [step]
Any matching event enqueues a delivery to the Zapier inbound URL. Zapier verifies the signature, decodes the envelope, and hands `data.*` to your Zap.
### You turn the Zap off [step]
Zapier calls `DELETE /v1/webhook-subscriptions/{id}` with a matching idempotency key. The endpoint is removed server-side.
## Actions
All writes auto-inject a fresh `Idempotency-Key` header per call — retries are safe.
**98 actions**, grouped by what they act on. Each line names the Zapier key, the label you pick in the editor, the route it calls and the token ability that route needs.
### Access Codes (2)
- `bulk_generate_access_codes` — **Bulk Generate Access Codes** — `POST /v1/projects/{project}/plans/{plan}/access-codes/bulk-generate` · `project-access-code:create`
Queue a bulk access-code generation batch for a plan. Returns immediately with a preview of the worst-case overage ("if every code is redeemed, this is what it would bill"). Stripe metered overage fires on redemption, not generation.
- `delete_access_code` — **Delete Access Code** — `DELETE /v1/projects/{project}/plans/{plan}/access-codes/{accessCode}` · `project-access-code:delete`
Delete (invalidate) a specific access code on a plan. Redeemed codes remain locked — only unredeemed codes can be removed.
### Account (2)
- `mark_all_notifications_read` — **Mark All Notifications Read** — `POST /v1/me/notifications/read-all` · `account:write`
Mark every unread entry of your notification centre as read in one call and learn how many were marked. Running it again marks nothing and answers 0.
- `mark_notification_read` — **Mark Notification Read** — `POST /v1/me/notifications/{notification}/read` · `account:write`
Mark one entry of your notification centre as read, so the dashboard bell stops counting it. An entry already read keeps its first read time.
### Broadcasts (1)
- `broadcast_message` — **Broadcast Message** — `POST /v1/projects/{project}/broadcasts` · `broadcast:send`
Send a Telegram message to a segment of a project’s members. The send is queued and runs in the background — watch the Broadcast Completed trigger for the sent/failed tallies. Cannot be recalled once sent.
### Coupons (5)
- `activate_coupon` — **Activate Coupon** — `POST /v1/projects/{project}/coupons/{coupon}/activate` · `project-coupon:update`
Switch a coupon on so it can be redeemed again. Raises Coupon Activated rather than Coupon Updated. Active is not the same as redeemable — a code outside its window or fully claimed stays unredeemable while on.
- `create_coupon` — **Create Coupon** — `POST /v1/projects/{project}/coupons` · `project-coupon:create`
Issue a discount code on a project. A coupon is one code any number of subscribers can redeem for money off at checkout — not an access code, which is one code for one person and grants access outright. The discount applies to the first payment only. Requires the Coupons Addon or a Growth plan.
- `deactivate_coupon` — **Deactivate Coupon** — `POST /v1/projects/{project}/coupons/{coupon}/deactivate` · `project-coupon:update`
Switch a coupon off. The safe way to retire a code: new redemptions stop immediately and every redemption already recorded is kept. A checkout already in flight still completes, so expect a late Coupon Redeemed shortly after. Raises Coupon Deactivated.
- `delete_coupon` — **Delete Coupon** — `DELETE /v1/projects/{project}/coupons/{coupon}` · `project-coupon:delete`
Permanently delete a coupon and its redemption history. Refused (422) while any checkout has this code quoted to a payment provider — deactivate instead, which stops new redemptions and keeps the history.
- `update_coupon` — **Update Coupon** — `PATCH /v1/projects/{project}/coupons/{coupon}` · `project-coupon:update`
Partial-update a coupon. Only the fields you fill in change; blank fields keep their value. Changes are not retroactive — subscribers who already redeemed keep what they paid. Raising the cap on an exhausted code makes it redeemable again; lowering it below the count claws nothing back.
### Creator Tasks (1)
- `complete_creator_task` — **Complete Creator Task** — `POST /v1/projects/{project}/creator-tasks/{task}/complete` · `project-subscription:update`
Mark a hand-arranged perk as handed over. The task is recorded done by the token's user, the subscriber's grant is issued, and Creator Task Completed then Member Added to Resource fire for it. A task already done, or one whose purchase has ended, is refused.
### Groups (4)
- `create_group` — **Create Group** — `POST /v1/groups` · `group:create`
Create a permission group inside a team. A role is what one collaborator is; a group is a named bundle several people can be put into. Membership is set separately with Sync Group Members. Growth-tier feature.
- `delete_group` — **Delete Group** — `DELETE /v1/groups/{group}` · `group:delete`
Delete a permission group. Everyone in it loses whatever the group granted, though they stay in the team.
- `sync_group_members` — **Sync Group Members** — `PUT /v1/groups/{group}/members` · `group:update`
Replace a group's membership with the supplied user IDs. This is a SYNC, not an add — anyone left out is removed, and an empty list empties the group. Every ID must already belong to the group's team, or the whole call is refused rather than partly applied.
- `update_group` — **Update Group** — `PATCH /v1/groups/{group}` · `group:update`
Rename a group and replace its permissions. Permissions REPLACE the existing set rather than adding to it — leave the field empty to keep the current one. The code cannot be changed, and membership is untouched here.
### Members (4)
- `ban_member` — **Ban Member** — `POST /v1/projects/{project}/members/{member}/ban` · `project-user:update`
Ban a project subscriber. Flips status to Banned and emits the member.banned webhook.
- `kick_member` — **Kick Member** — `POST /v1/projects/{project}/members/{member}/kick` · `project-user:update`
Kick a project subscriber without banning them. Rolls status to Churned.
- `unban_member` — **Unban Member** — `POST /v1/projects/{project}/members/{member}/unban` · `project-user:update`
Lift a ban on a project subscriber. Restores the subscriber to active status and emits the member.unbanned webhook.
- `unlink_member_identity` — **Unlink Member Identity** — `DELETE /v1/projects/{project}/members/{member}/identities/{link}` · `project-user:update`
Disconnect one of a member's platform accounts. Refused when it is their last way to sign in. Emits the member.identity_unlinked webhook.
### Pass Windows (3)
- `cancel_pass_window` — **Cancel Pass Window** — `POST /v1/projects/{project}/pass-windows/{window}/cancel` · `pass-window:delete`
Cancel a dated access window and resettle everyone holding it: each holder is moved to the plan's next window on sale, has the date dropped from their season ticket, or has their pass ended and flagged for a refund when nothing is left. Holders are messaged and money may be owed, so confirm the window first. The tallies come back under meta: rebound, refund_due and legs_dropped. Subscriby never moves the refunds itself. An already-cancelled window answers with zero tallies.
- `create_pass_window` — **Create Pass Window** — `POST /v1/projects/{project}/plans/{plan}/pass-windows` · `pass-window:create`
Place one dated access window by hand on a time-limited pass plan. It is marked manual, so a schedule rebuild keeps it. The start must be in the future and the plan's configured minimum and maximum length apply.
- `remind_pass_window_queue` — **Remind Pass Window Queue** — `POST /v1/projects/{project}/pass-windows/{window}/remind` · `pass-window:update`
Nudge everyone who bought a window but has not sent their Telegram join request yet, re-attaching their invite links. Answers with how many holders Telegram accepted the message for; a window that has ended or been cancelled reminds nobody. This messages real people — do not repeat it within the same window.
### Payment Methods (4)
- `activate_payment_method` — **Activate Payment Method** — `POST /v1/projects/{project}/payment-methods/{method}/activate` · `project-payment-method:update`
Offer a configured payment gateway at checkout again and re-queue the plan sync for it. A Stripe method whose Connect onboarding never finished is refused — onboarding can only be completed from the dashboard. Activating a method that is already on changes nothing.
- `deactivate_payment_method` — **Deactivate Payment Method** — `POST /v1/projects/{project}/payment-methods/{method}/deactivate` · `project-payment-method:update`
Stop offering a payment gateway to new buyers; subscriptions already sold through it keep renewing. The plan sync for the gateway is re-queued. Deactivating a method that is already off changes nothing.
- `delete_payment_method` — **Delete Payment Method** — `DELETE /v1/projects/{project}/payment-methods/{method}` · `project-payment-method:delete`
Remove a payment gateway from a project. Buyers lose that way to pay at once; subscriptions already sold through it keep their gateway for refunds and history, and configuring the same gateway again later revives the row.
- `sync_payment_method_plans` — **Sync Payment Method Plans** — `POST /v1/projects/{project}/payment-methods/{method}/sync` · `project-payment-method:update`
Queue a push of the project's plans into a gateway's catalogue (Stripe products and prices, PayPal, Razorpay or CoinPayments plans). Answers sync_queued at once; the work runs in the background and each plan reports through the plan.sync_completed event. A gateway that keeps no catalogue (Telegram Stars, access codes, the redirect gateways) is refused.
### Plans (6)
- `create_plan` — **Create Plan** — `POST /v1/projects/{project}/plans` · `project-subscription-plan:create`
Create a plan on a Subscriby project. Pick a kind first — a recurring subscription, a time-limited pass that sells one dated window, or a pass series that sells a whole slate of your pass plans' windows for one payment. The fields below change to match.
- `delete_plan` — **Delete Plan** — `DELETE /v1/projects/{project}/plans/{plan}` · `project-subscription-plan:delete`
Permanently delete a plan within a project. Active subscriptions must be migrated or cancelled beforehand — the API rejects deletes otherwise.
- `publish_plan` — **Publish Plan** — `POST /v1/projects/{project}/plans/{plan}/publish` · `project-subscription-plan:update`
Activate a plan so new sign-ups can purchase it. Emits plan.activated. Noop if already active.
- `start_next_season` — **Start Next Season** — `POST /v1/projects/{project}/plans/{plan}/successor` · `project-subscription-plan:create`
Create the successor of a pass series plan: a new season built from the same series, carrying its rules onto the next run of windows, so a season ticket can be sold again without re-authoring it. The current season is untouched. Only a plan of kind pass_series has a successor; any other kind is refused.
- `unpublish_plan` — **Unpublish Plan** — `POST /v1/projects/{project}/plans/{plan}/unpublish` · `project-subscription-plan:update`
Deactivate a plan so new sign-ups can no longer purchase. Existing subscribers keep their subscription; only new sign-ups are blocked. Emits plan.deactivated.
- `update_plan` — **Update Plan** — `PATCH /v1/projects/{project}/plans/{plan}` · `project-subscription-plan:update`
Partial-update an existing plan. Pick the kind the plan already is — the fields below change to match, and sending a block belonging to another kind is refused. Lifetime count=1, mutually-exclusive eligibility flags, recurring-vs-crypto and currency support checks all run server-side.
### Projects (12)
- `archive_project` — **Archive Project** — `POST /v1/projects/{project}/archive` · `project:update`
Flip a project to active=false. Emits project.archived. Noop if already archived.
- `create_project` — **Create Project** — `POST /v1/projects` · `project:create`
Create a new Subscriby project. Delegates to the REST v1 endpoint — plan limits, the custom_handle tier gate, and cache invalidation all run server-side.
- `delete_project` — **Delete Project** — `DELETE /v1/projects/{project}` · `project:delete`
Permanently delete a Subscriby project. Subscribers, plans, resources, and access codes are removed server-side.
- `disconnect_connector` — **Disconnect Connector** — `DELETE /v1/projects/{project}/connectors/{key}/installation` · `project-connector:delete`
Disconnect a project's installation of one connector: the connector withdraws it, the credentials are wiped and the installation turns disconnected. Members keep their access; grants, resources, plans and identities are untouched; the creator can connect it again from the dashboard. The neutral twin of Disconnect Bot.
- `install_connector` — **Install Connector** — `POST /v1/projects/{project}/connectors/{key}` · `project-connector:create`
Install a connector on a project: opens a pending installation with no credentials yet, which the creator connects from the dashboard. Installing one that is already installed changes nothing. Refused for a connector that is not installable today, or for a second connector when the project owner's plan lacks the multi-connector capability.
- `restore_connector_access` — **Restore Connector Access** — `POST /v1/projects/{project}/connectors/{key}/installation/restore-access` · `project-connector:update`
Bring a project's detached places on a reinstalled connector back. Every resource the uninstall detached is asked about; the ones the connector still controls are reactivated, and every live purchase of a plan granting them is handed fresh access (Member Resource Added fires per grant issued). Places the connector no longer controls stay detached for Run Connector Doctor to explain; plans taken off sale stay off sale. Refused until the installation is connected again.
- `restore_project` — **Restore Project** — `POST /v1/projects/{project}/restore` · `project:update`
Flip a previously archived project back to active. Emits project.restored. Noop if already active.
- `run_connector_doctor` — **Run Connector Doctor** — `POST /v1/projects/{project}/connectors/{key}/installation/doctor` · `project-connector:update`
Verify a project's installation of a connector and ask the connector about every resource it gates. Answers one report with a finding per check: the installation first, then each resource, each with its severity (ok, warning, critical), the connector's own state word and sentence, whether the creator can fix it and where. The report is kept on the installation; Connector Doctor Completed fires only when the findings changed.
- `uninstall_connector` — **Uninstall Connector** — `DELETE /v1/projects/{project}/connectors/{key}` · `project-connector:delete`
Uninstall a connector from a project: every live grant on its resources is revoked, its resources are deactivated as detached, the installation row is kept and identities are never removed. Two opt-ins, on by default, act on the plans left with nothing to grant: take them off sale, and cancel their recurring subscriptions at period end with an email to each member. Run Get Connector Uninstall Preview first.
- `update_connector_installation_settings` — **Update Connector Installation Settings** — `PATCH /v1/projects/{project}/connectors/{key}/installation/settings` · `project-connector:update`
Change a project's connector installation settings. Keys are the field names the connector declares (read them with Get Connector); every declared rule runs, an undeclared key is refused, and fields left out keep their value. Values are never returned.
- `update_project` — **Update Project** — `PATCH /v1/projects/{project}` · `project:update`
Partial-update an existing Subscriby project. Only fields you supply are changed; omitted fields keep their current value. Handle uniqueness and custom_handle tier gate are enforced server-side.
- `verify_connector_installation` — **Verify Connector Installation** — `POST /v1/projects/{project}/connectors/{key}/installation/verify` · `project-connector:update`
Ask the connector whether a project's installation still answers and record the verdict on it: connected, degraded (with the reason) or revoked. A pending installation is returned as it is. Fires Connector Status Changed only when the state moved.
### Recovery (12)
- `notify_members_of_recovery` — **Notify Members Of Recovery** — `POST /v1/recovery/operations/{operation}/notify-members` · `project-recovery:update`
Email every member the project can reach that its bot changed after a bot replacement, at the per-email fee. Sent once per recovery.
- `nudge_pending_readmissions` — **Nudge Pending Readmissions** — `POST /v1/recovery/operations/{operation}/nudge` · `project-recovery:update`
Send one reminder, with a fresh link, to every member a channel recovery re-admitted who has not joined the new chat yet.
- `remove_resource_standby` — **Remove Resource Standby** — `DELETE /v1/projects/{project}/resources/{resource}/standby` · `project-recovery:delete`
Stop keeping a standby for one resource; the chat itself is untouched and the resource keeps its live chat.
- `remove_standby_installation` — **Remove Standby Installation** — `DELETE /v1/projects/{project}/recovery/standby-installation` · `project-recovery:delete`
Stop keeping the standby installation (the spare bot) registered for a project.
- `request_resource_replacement` — **Request Resource Replacement** — `POST /v1/projects/{project}/resources/{resource}/replacement/request` · `project-recovery:create`
Ask the creator, through the connector, to pick the chat that replaces a resource's; the swap runs the moment they choose. Nothing is swapped by the call itself.
- `request_resource_standby` — **Request Resource Standby** — `POST /v1/projects/{project}/resources/{resource}/standby/request` · `project-recovery:create`
Ask the creator, through the connector, to pick the chat that becomes the standby for one resource. Nothing is linked by the call itself.
- `revert_recovery_operation` — **Revert Recovery Operation** — `POST /v1/recovery/operations/{operation}/revert` · `project-recovery:update`
Undo a completed Disaster Recovery inside its window — a swapped channel put back, or the previous sign-in account restored.
- `set_resource_standby_mirror` — **Set Resource Standby Mirror** — `PATCH /v1/projects/{project}/resources/{resource}/standby` · `project-recovery:update`
Switch the live mirror into a resource's standby on or off, so a failover lands members in a channel that already holds the content.
- `update_recovery_settings` — **Update Recovery Settings** — `PATCH /v1/projects/{project}/recovery/settings` · `project-recovery:update`
Change one project's Disaster Recovery settings: switch automatic failover on or off (with the fee consent), and choose how members are told after a swap. Fields left out keep their value.
- `use_resource_standby` — **Use Resource Standby** — `POST /v1/projects/{project}/resources/{resource}/standby/use` · `project-recovery:create`
Swap a resource onto its standby right now: old links revoked, every active member re-admitted, the standby consumed. Returns the recovery operation it ran under.
- `withdraw_resource_replacement_request` — **Withdraw Resource Replacement Request** — `DELETE /v1/projects/{project}/resources/{resource}/replacement/request` · `project-recovery:delete`
Take back the replacement request the creator has open on the connector. Takes no input.
- `withdraw_resource_standby_request` — **Withdraw Resource Standby Request** — `DELETE /v1/projects/{project}/resources/{resource}/standby/request` · `project-recovery:delete`
Take back the standby request the creator has open on the connector. Takes no input.
### Resources (7)
- `activate_resource` — **Activate Resource** — `POST /v1/projects/{project}/resources/{resource}/activate` · `project-resource:update`
Switch a resource back on so plans grant it to members again. Activating a resource that is already on changes nothing.
- `create_resource` — **Create Resource** — `POST /v1/projects/{project}/resources` · `project-resource:create`
Create a manual perk (custom text content, an external URL, a token the creator hands over by hand). A place a connector gates is linked through the connector with Request Resource Link; only manual perks are created here, and only once a connector on the project is connected.
- `deactivate_resource` — **Deactivate Resource** — `POST /v1/projects/{project}/resources/{resource}/deactivate` · `project-resource:update`
Switch a resource off without deleting it. It keeps its Telegram link but drops out of what plans grant until switched back on. Deactivating a resource that is already off changes nothing.
- `delete_resource` — **Delete Resource** — `DELETE /v1/projects/{project}/resources/{resource}` · `project-resource:delete`
Delete a project resource row. The linked Telegram destination is unlinked first if applicable.
- `request_resource_link` — **Request Resource Link** — `POST /v1/projects/{project}/resources/link-requests` · `project-resource:create`
Ask the creator, through the connector, to pick the place a new resource will be (a kind of place the connector gates, such as a Telegram channel); the resource appears the moment they choose. Nothing is created by the call itself. Manual perks are created with Create Resource instead.
- `unlink_resource` — **Unlink Resource** — `POST /v1/projects/{project}/resources/{resource}/unlink` · `project-resource:update`
Detach the place from a project resource. The row remains and keeps its kind and connector; it is bound to no space until the creator links another through the connector.
- `update_resource` — **Update Resource** — `PATCH /v1/projects/{project}/resources/{resource}` · `project-resource:update`
Change a resource's title, description or on/off switch. Partial: only the fields you fill in change, so a title-only update leaves the description as stored. The type and Telegram link are not editable here.
### Roles (3)
- `create_role` — **Create Role** — `POST /v1/roles` · `role:create`
Create a permission role inside a team. The code is the stable identifier permissions are addressed by — it must be unique within the team and cannot be changed later. Growth-tier feature.
- `delete_role` — **Delete Role** — `DELETE /v1/roles/{role}` · `role:delete`
Delete a team role and detach its permissions. Anyone holding the role loses whatever it granted. Restricted to whoever created the role, unless you own the team.
- `update_role` — **Update Role** — `PATCH /v1/roles/{role}` · `role:update`
Rename a role and replace its permissions. Permissions REPLACE the existing set rather than adding to it — send the full list you want the role to end up with, or leave the field empty to keep the current one. The code cannot be changed.
### Subscriptions (6)
- `cancel_subscription` — **Cancel Subscription** — `POST /v1/subscriptions/{subscription}/cancel` · `project-subscription:update`
Queues cancellation for a Subscriby subscription. Provider-side cancellation + local state mutation run asynchronously; the action returns once the job is queued.
- `pause_subscription` — **Pause Subscription Access** — `POST /v1/subscriptions/{subscription}/pause` · `project-subscription:update`
Suspend a member’s access to the project’s linked Telegram resources. Billing is NOT affected — the payment provider keeps charging on schedule. Use Unpause Subscription Access to restore it with fresh invite links.
- `reactivate_subscription` — **Reactivate Subscription** — `POST /v1/subscriptions/{subscription}/reactivate` · `project-subscription:update`
Call off a scheduled cancellation so the subscription keeps billing normally — the win-back action. Stripe only: on every other provider cancelling ends the agreement outright and the member must subscribe again.
- `reissue_subscription_grants` — **Reissue Subscription Grants** — `POST /v1/subscriptions/{subscription}/grants/reissue` · `project-subscription:update`
Revoke the access grants a member holds on a subscription — every resource, or one — and have fresh ones issued. The invite links they held die and the bot sends them the new ones. Raises Member Access Reissued per resource, then Member Added to Resource for each fresh grant.
- `remind_pass_holder` — **Remind Pass Holder** — `POST /v1/subscriptions/{subscription}/remind` · `project-subscription:update`
Nudge one pass holder who bought a window but has not sent their Telegram join request yet, re-attaching their invite links. Answers reminded: true when Telegram accepted the message and reminded: false when there was nothing to send — no window on the subscription, a window that has ended or been cancelled, a holder who already queued, or one who cannot be reached. False is a normal answer, not an error. Do not repeat the nudge within the same window.
- `unpause_subscription` — **Unpause Subscription Access** — `POST /v1/subscriptions/{subscription}/unpause` · `project-subscription:update`
Restore a suspended member’s access, issuing fresh invite links. Only works on a subscription that is currently paused.
### Support Canned Replies (3)
- `create_canned_reply` — **Create Canned Reply** — `POST /v1/projects/{project}/support/canned-replies` · `support-canned-reply:create`
Save a reply snippet to a project's support picker, so agents can insert it with one click or by typing its shortcut. The shortcut must be unique within the project.
- `delete_canned_reply` — **Delete Canned Reply** — `DELETE /v1/projects/{project}/support/canned-replies/{reply}` · `support-canned-reply:delete`
Remove a saved reply from a project's support picker. Replies already sent with the snippet are untouched.
- `update_canned_reply` — **Update Canned Reply** — `PATCH /v1/projects/{project}/support/canned-replies/{reply}` · `support-canned-reply:update`
Change a saved support reply. Partial: only the fields you fill in change, and a call that restates the stored values writes nothing.
### Support Conversations (6)
- `assign_support_conversation` — **Assign Support Conversation** — `POST /v1/support/conversations/{conversation}/assign` · `support-conversation:update`
Hand a support conversation to a team member. Leave Assignee empty to clear an existing assignment.
- `block_support_contact` — **Block Support Contact** — `POST /v1/support/conversations/{conversation}/block` · `support-conversation:update`
Block the member behind a support conversation: their messages to the project's bot are dropped without notice and the thread cannot reopen on inbound. Their paid access is untouched — use Ban Member for moderation. Blocking an already-blocked contact changes nothing.
- `reopen_support_conversation` — **Reopen Support Conversation** — `POST /v1/support/conversations/{conversation}/reopen` · `support-conversation:update`
Put a resolved support conversation back in the open queue. A member writing back reopens a thread on its own; use this when a creator changes their mind or owes a follow-up before the member speaks again.
- `reply_support_conversation` — **Reply to Support Conversation** — `POST /v1/support/conversations/{conversation}/messages` · `support-conversation:update`
Send a reply to a member in a support conversation. The member receives it on Telegram as a bot message attributed to you, so treat this as sending a real message to a real person. Set Internal Note to record a private remark for your team instead.
- `resolve_support_conversation` — **Resolve Support Conversation** — `POST /v1/support/conversations/{conversation}/resolve` · `support-conversation:update`
Mark a support conversation resolved, clearing it from the open queue. Nothing is deleted, and the conversation reopens by itself if the member writes again.
- `unblock_support_contact` — **Unblock Support Contact** — `POST /v1/support/conversations/{conversation}/unblock` · `support-conversation:update`
Lift a support block so the member behind the conversation can reach the inbox again. Unblocking a contact who is not blocked changes nothing.
### Support Settings (1)
- `update_support_settings` — **Update Support Settings** — `PATCH /v1/projects/{project}/support/settings` · `project:update`
Change a project's support inbox settings. Partial: only the fields you fill in change. Turning support off stops new member threads reaching the inbox; existing threads are kept.
### Team Members (4)
- `cancel_team_invitation` — **Cancel Team Invitation** — `DELETE /v1/teams/{team}/invitations/{invitation}` · `team-member:remove`
Withdraw an invitation nobody has accepted yet, so its link stops working. Distinct from Remove Team Member: an invitee has no membership row, only a pending invitation, and the two fail on different things.
- `update_team_member_role` — **Change Team Member Role** — `PATCH /v1/teams/{team}/members/{member}/role` · `team-member:update-role`
Move an existing collaborator onto a different role. The team owner cannot be re-roled — ownership is not a membership row — and someone who is not in the team is refused. Fires the Team Member Role Changed trigger.
- `invite_team_member` — **Invite Team Member** — `POST /v1/teams/{team}/members` · `team-member:invite`
Invite someone to a team by email and send them an invitation. Addressed by email rather than user ID because the invitee may not have an account yet. The role must already exist on the team — create it first with Create Role. Someone already in the team is refused.
- `remove_team_member` — **Remove Team Member** — `DELETE /v1/teams/{team}/members/{member}` · `team-member:remove`
Remove a collaborator from a team. They immediately lose access to every project and setting scoped to it, though their own account is untouched. The team owner cannot be removed. To withdraw an invitation nobody has accepted, use Cancel Team Invitation instead.
### Teams (3)
- `create_team` — **Create Team** — `POST /v1/teams` · `team:create`
Create a Subscriby team. Teams are the tenant every project, plan and subscription belongs to, and are a Growth-tier feature — a lower tier returns TEAM_TIER_REQUIRED. The token owner becomes the team owner.
- `delete_team` — **Delete Team** — `DELETE /v1/teams/{team}` · `team:delete`
Permanently delete a Subscriby team, owner only. Every project, plan and subscription scoped to the team goes with it and there is no restore. Your last remaining team is refused — an account with no tenant is not a valid state.
- `update_team` — **Update Team** — `PATCH /v1/teams/{team}` · `team:update`
Rename a Subscriby team. Owner only — belonging to a team is not enough. Name is the only mutable field; membership and roles move through their own actions.
### Tokens (1)
- `revoke_token` — **Revoke Token** — `DELETE /v1/tokens/{token}` · `token:delete`
Revoke a personal access token. Subsequent requests using that token will fail with 401.
### Webhook Deliveries (2)
- `retry_dead_webhook_deliveries` — **Retry Dead Webhook Deliveries** — `POST /v1/webhook-deliveries/retry-dead` · `webhook-delivery:retry`
Replay every webhook delivery the team dead-lettered since a point in time, once the consumer is fixed. Leave Since blank for the last 24 hours, the same window the dashboard's Replay button uses. Each replayed row posts its event again, so confirm the consumer is healthy first.
- `retry_webhook_delivery` — **Retry Webhook Delivery** — `POST /v1/webhook-deliveries/{delivery}/retry` · `webhook-delivery:retry`
Replay one failed or dead-lettered webhook delivery from the start of the retry ladder, posting the same event to the same endpoint again. A pending or already-delivered row is refused, because replaying it would post the event twice.
### Webhook Endpoints (6)
- `create_webhook_endpoint` — **Create Webhook Endpoint** — `POST /v1/webhook-endpoints` · `webhook-endpoint:create`
Register a new webhook endpoint. The one-time plaintext signing secret is returned on creation and is exposed as the "secret" output field — store it now, it will not be shown again.
- `delete_webhook_endpoint` — **Delete Webhook Endpoint** — `DELETE /v1/webhook-endpoints/{endpoint}` · `webhook-endpoint:delete`
Remove a webhook endpoint. Pending deliveries will be dropped server-side.
- `pause_webhook_endpoint` — **Pause Webhook Endpoint** — `POST /v1/webhook-endpoints/{endpoint}/pause` · `webhook-endpoint:update`
Stop deliveries to a webhook endpoint without deleting it. Events raised while it is paused are not queued for it and are not replayed on resume. Only the member who registered the endpoint, or the team owner, may pause it.
- `resume_webhook_endpoint` — **Resume Webhook Endpoint** — `POST /v1/webhook-endpoints/{endpoint}/resume` · `webhook-endpoint:update`
Restart deliveries to a paused webhook endpoint and clear its consecutive-failure streak, so a target that was fixed is not disabled again on its first miss. Events raised while it was paused are not replayed; use Retry Dead Webhook Deliveries for rows that dead-lettered before the pause.
- `rotate_webhook_secret` — **Rotate Webhook Secret** — `POST /v1/webhook-endpoints/{endpoint}/rotate-secret` · `webhook-endpoint:update`
Regenerate the signing secret for a webhook endpoint. The new plaintext secret is returned once — store it immediately.
- `test_webhook_endpoint` — **Test Webhook Endpoint** — `POST /v1/webhook-endpoints/{endpoint}/test` · `webhook-endpoint:update`
Send a synthetic test event to a webhook endpoint. Useful for verifying the receiver signature + 2xx handling before relying on production deliveries.
### Notes on specific actions
#### Plan
##### Plan Kind — a breaking change in 3.0.0
**Create Plan** and **Update Plan** now open with a **Plan Kind** dropdown, and the fields below it change to match:
| Plan Kind | Sells |
| -------------------------- | -------------------------------------------------------------------- |
| **Recurring Subscription** | Access that begins at payment and renews on a cycle. |
| **Time-Limited Pass** | One dated access window per purchase. |
| **Pass Series** | A slate of _other_ pass plans' windows, sold once — a season ticket. |
Only the fields your chosen kind accepts are shown, mirroring the API, which refuses a block belonging to another kind rather than ignoring it.
The field keys behind these two actions changed in 3.0.0. A Zap that set
*Billing Cycle* still sets one — the field moved into the subscription kind's
group — but the mapping has to be re-made by whoever built the Zap. No other
trigger, action or search changed its keys.
##### Building a Pass Series
A series points at windows that **already exist** on your pass plans, so the order matters:
1. **Find Pass Windows** — the new search. Returns each window's ID, its local range, whether it is still on sale and how many people hold it. Filter by plan, status or date range.
2. **Create Plan** with Plan Kind → _Pass Series_, pasting those IDs into **Pass Window IDs**.
3. Optionally add **Automatic Inclusion Rules**. A rule describes windows rather than naming them and keeps matching after the save: a window scheduled later is added to the slate **and granted to everyone already holding the series**, at no charge. Handpicked IDs never grow on their own — the two compose.
`Resource IDs` is optional on a series and means a lounge the holder keeps for the whole span; each window already grants its own plan's resources.
#### Recovery
All run the same actions the Disaster Recovery pages run, so a refusal (a teammate, a plan without the feature, a closed undo window) comes back as the dashboard's own sentence.
#### Broadcast
Sends one Telegram message to a segment of a project's members. The send is queued, so the action returns the audience it resolved and the recipient count — not a delivery result. Pair it with the **Broadcast Completed** trigger to log `sent` and `failed`.
A broadcast cannot be recalled, and **Audience** defaults to _All Users_. Clicking **Test action** while building the Zap sends a real message to every member with a linked chat, not a sample. Set the audience — and, if you want it, the plan — before you test, or point the test at a project with no members.
##### Choosing the audience
Three fields compose. **Audience** picks a segment, **Narrow to a Plan** optionally restricts it to one plan, and **Running Out Within (Days)** sets the horizon that _Expiring Soon_ reads.
| Group | Segments |
| ---------------------- | ------------------------------------------------------------------------------------------------- |
| **Member status** | All Users, Customers Only, Trialing Users, Leads, Churned Users |
| **Subscription state** | Expiring Soon, Cancelled Still Inside Their Period, Paused Subscriptions, Trialing Without a Card |
| **Passes** | All Active Pass Holders, All Active Pass Holders Not in Queue, and the two single-window forms |
**Narrow to a Plan** composes rather than replaces: _Customers Only_ plus a plan reaches people paying for it right now, _Churned Users_ plus a plan reaches people who held it and left. The plan and the state always describe the **same** subscription, so somebody paying for Silver who once trialled Gold is not a Gold customer.
The API **refuses** a plan on _Leads_, who never subscribed, and on the pass segments, whose plan is implied by the window — a `422` rather than a silently widened send.
A subscription's end date is rewritten to the new period end every time it renews, so a date inside the horizon describes the next **invoice**, not an expiry. Counting it would sweep every monthly subscriber into the segment once a month. A member appears only once their access genuinely lapses — renewal is off, or the plan does not renew at all.
The two single-window pass segments additionally require a **Pass Window ID**; the API refuses the send without one rather than quietly addressing nobody.
#### Team + RBAC
**Permissions replace.** Sending the permissions field on _Update Role_ or _Update Group_ makes that list the entire set. Omit it to leave existing permissions alone.
**Sync Group Members is a sync, not an add.** Anyone missing from the list is removed from the group, and an empty list empties it. Read the group first if you mean to append.
Creating and re-permissioning need the Growth plan. Deleting and removing do not — a creator whose plan lapsed still has collaborators attached and has to be able to take access away.
## Searches
Return a single object (wrapped in `[x]`) or an array. Use them in dynamic dropdowns, cross-Zap lookups, or as standalone fetch steps.
**70 searches**, grouped by what they read. Each line names the Zapier key, the label you pick in the editor, the route it calls and the token ability that route needs.
### Access Codes (2)
- `list_access_codes` — **List Access Codes** — `GET /v1/projects/{project}/plans/{plan}/access-codes` · `project-access-code:view-any`
List access codes for a plan, optionally filtered by status.
- `preview_access_code_cost` — **Preview Access Code Cost** — `GET /v1/projects/{project}/plans/{plan}/access-codes/preview` · `project-access-code:view-any`
Preview the cost (in platform credits / currency) of generating a batch of access codes for a plan.
### Account (2)
- `get_me` — **Get Me** — `GET /v1/me` · `account:read`
Retrieve the creator the token belongs to: the team it is scoped to, every team held, plan capabilities, connected accounts and alert destinations.
- `list_notifications` — **List Notifications** — `GET /v1/me/notifications` · `account:read`
List the entries of your notification centre, newest first — every alert Subscriby sent you, with its class, title, body, where it points and whether it was read — or only the unread ones, or one class.
### Activity (1)
- `list_activity` — **List Activity** — `GET /v1/activity` · `activity:read`
List activity log entries for a given subject (project, subscription, member, plan, access-code).
### Analytics (8)
- `get_connector_analytics` — **Get Connector Analytics** — `GET /v1/analytics/connectors` · `dashboard:read`
Retrieve members, access and revenue per connector for the team or a specific project: live installations, members with a linked account, live and pending grants, grants issued and revoked in the window, gross revenue in USD and its share. A purchase spanning two connectors counts toward both.
- `get_dashboard_metrics` — **Get Dashboard Metrics** — `GET /v1/analytics/dashboard` · `dashboard:read`
Retrieve the dashboard metric cards for the current team or a specific project.
- `get_earnings_report` — **Get Earnings Report** — `GET /v1/analytics/earnings` · `dashboard:read`
Retrieve the earnings (net revenue) time series for the current team or a specific project.
- `get_plan_performance` — **Get Plan Performance** — `GET /v1/analytics/plan-performance` · `dashboard:read`
Retrieve per-plan performance metrics (subscribers, revenue, conversion) for a project.
- `get_revenue_composition` — **Get Revenue Composition** — `GET /v1/analytics/composition` · `dashboard:read`
Retrieve how revenue and payments are composed for the team or a specific project: transaction fees by payment provider, settled transactions by plan kind, gross revenue by currency, payment attempts by outcome, and the monthly recurring revenue split by plan. Each dataset carries its total, unit and slices with shares.
- `get_subscriber_analytics` — **Get Subscriber Analytics** — `GET /v1/analytics/subscribers` · `dashboard:read`
Retrieve subscriber growth, churn, and cohort analytics for the team or a specific project.
- `get_transaction_breakdown` — **Get Transaction Breakdown** — `GET /v1/analytics/transactions/breakdown` · `dashboard:read`
Retrieve a transaction breakdown grouped by plan, payment provider, currency, or project.
- `list_transactions` — **List Transactions** — `GET /v1/analytics/transactions` · `dashboard:read`
List transactions for a project, filterable by status, provider, plan, or currency. Returns a cursor-paginated page.
### Connectors (2)
- `get_connector` — **Get Connector** — `GET /v1/connectors/{key}` · `project-connector:view-any`
Retrieve one Connectors Marketplace card by key — its lane, badges and, for a connector that exists as a package, its manifest and the form that connects it.
- `list_connectors` — **List Connectors** — `GET /v1/connectors` · `project-connector:view-any`
List the Connectors Marketplace — every connector Subscriby knows, lane by lane, with its badges, manifest and the declarative form that connects it.
### Coupons (2)
- `find_coupons` — **Find Coupons** — `GET /v1/projects/{project}/coupons` · `project-coupon:view-any`
List a project’s coupons, newest first, optionally narrowed to one exact code or to the on/off switch. Read "redeemable" on each row for whether a code actually applies right now — "active" is only the switch.
- `get_coupon` — **Get Coupon** — `GET /v1/projects/{project}/coupons/{coupon}` · `project-coupon:view`
Retrieve one coupon by UUID, with its discount, limits, live redemption tally and sales window. "redemptions.remaining" already accounts for checkouts in flight, so prefer it over subtracting the count from the cap yourself.
### Creator Tasks (1)
- `list_creator_tasks` — **List Creator Tasks** — `GET /v1/projects/{project}/creator-tasks` · `project-subscription:view`
List the hand-arranged perks a creator still has to hand over in a project — one task per subscriber and manual resource, oldest first — or the ones already done.
### Distribution (2)
- `get_deep_link` — **Get Deep Link** — `GET /v1/projects/{project}/distribution/deep-link` · `distribution:read`
Generate a deep link for a project. Pass at most one of access_code, plan_id, or custom — omit all to get the default project link.
- `get_portal_url` — **Get Portal URL** — `GET /v1/projects/{project}/distribution/portal-url` · `distribution:read`
Retrieve the public subscriber portal URL for a project.
### Groups (2)
- `get_group` — **Get Group** — `GET /v1/groups/{group}` · `group:view`
Retrieve a single collaborator group by UUID.
- `list_groups` — **List Groups** — `GET /v1/groups` · `group:view-any`
List every collaborator group defined on the current team.
### Members (4)
- `find_member_by_identity` — **Find Member by Connector Account** — `GET /v1/projects/{project}/members` · `project-user:view-any`
Look up a project member by the account they connected on a connector: the connector and the platform's own id for the account, as a bot or a server hands it over.
- `find_member_by_id` — **Find Member by ID** — `GET /v1/projects/{project}/members/{member}` · `project-user:view`
Look up a project member by their UUID. Pair with Member Joined / Member Banned triggers for follow-up steps.
- `list_member_identities` — **List Member Identities** — `GET /v1/projects/{project}/members/{member}/identities` · `project-user:view`
List the platform accounts a project member has connected, with the one the project reaches first marked preferred.
- `list_members` — **List Members** — `GET /v1/projects/{project}/members` · `project-user:view-any`
List members within a project, optionally filtered by status.
### Pass Windows (2)
- `find_pass_windows` — **Find Pass Windows** — `GET /v1/projects/{project}/pass-windows` · `pass-window:view-any`
List the dated access windows a project's time-limited pass plans generate, with their IDs. This is where the Pass Window IDs for a Pass Series come from — a series points at windows that already exist rather than creating any, so run this first, then feed the IDs into Create Plan.
- `get_pass_window` — **Get Pass Window** — `GET /v1/projects/{project}/pass-windows/{window}` · `pass-window:view`
Retrieve one dated access window by UUID, with its local range, lifecycle status, whether it is still on sale and how many holders bought it.
### Payment Methods (2)
- `get_payment_method` — **Get Payment Method** — `GET /v1/projects/{project}/payment-methods/{method}` · `project-payment-method:view`
Retrieve a single project payment method by UUID.
- `list_payment_methods` — **List Payment Methods** — `GET /v1/projects/{project}/payment-methods` · `project-payment-method:view-any`
List the payment providers enabled for a project.
### Plans (3)
- `find_plan_by_id` — **Find Plan by ID** — `GET /v1/projects/{project}/plans/{plan}` · `project-subscription-plan:view`
Look up a plan by its UUID on a given project. Direct lookup — faster than the name-based search for ID-driven flows.
- `find_plan_by_name` — **Find Plan by Name** — `GET /v1/projects/{project}/plans` · `project-subscription-plan:view-any`
Look up a plan by its name within a specific project.
- `list_plans` — **List Plans** — `GET /v1/projects/{project}/plans` · `project-subscription-plan:view-any`
List every plan within a project.
### Projects (6)
- `find_project_by_handle` — **Find Project by Handle** — `GET /v1/projects` · `project:view-any`
Look up a project by its URL handle (e.g. "research-premium").
- `get_connector_installation` — **Get Connector Installation** — `GET /v1/projects/{project}/connectors/{key}/installation` · `project-connector:view`
Retrieve a project's live installation of one connector — its state, health and the platform's own account for it. The neutral twin of Get Bot Status.
- `get_connector_uninstall_preview` — **Get Connector Uninstall Preview** — `GET /v1/projects/{project}/connectors/{key}/uninstall-preview` · `project-connector:view`
Show what uninstalling a connector from a project would touch, changing nothing: its resources, the live grants on them, the plans left with nothing to grant, the subscriptions on those plans, and the opt-in defaults.
- `get_project` — **Get Project** — `GET /v1/projects/{project}` · `project:view`
Retrieve a single project by its UUID.
- `list_connector_installations` — **List Connector Installations** — `GET /v1/projects/{project}/connectors` · `project-connector:view-any`
List every connector installation a project holds — live and standby — with its state, health and the platform's own account for it.
- `list_projects` — **List Projects** — `GET /v1/projects` · `project:view-any`
List every project the authenticated token can access.
### Recovery (7)
- `get_recovery_allowances` — **Get Recovery Allowances** — `GET /v1/recovery/allowances` · `project-recovery:view-any`
Retrieve how many self-service Disaster Recoveries of each kind the creator the token acts for may still run, what support has released on top, and when the allowance returns.
- `get_recovery_readiness` — **Get Recovery Readiness** — `GET /v1/recovery/readiness` · `project-recovery:view-any`
Retrieve the Disaster Recovery readiness checklist for the creator the token acts for: every line with its state, whether the plan locks it, and the totals.
- `get_recovery_roll_call` — **Get Recovery Roll Call** — `GET /v1/recovery/operations/{operation}/roll-call` · `project-recovery:view`
Retrieve where the re-admission after one Disaster Recovery operation stands: members re-admitted, joined, still outside, and whether a reminder may go out now.
- `get_recovery_settings` — **Get Recovery Settings** — `GET /v1/projects/{project}/recovery/settings` · `project-recovery:view`
Read one project's Disaster Recovery settings — automatic failover and its fee consent, how members are told after a swap, and whether a standby installation is kept.
- `get_resource_standby` — **Get Resource Standby** — `GET /v1/projects/{project}/resources/{resource}/standby` · `project-recovery:view`
Read the standby kept for one resource — its health, whether posts are mirrored into it, and when it was last probed and written to.
- `list_recovery_incidents` — **List Recovery Incidents** — `GET /v1/recovery/incidents` · `project-recovery:view-any`
List the Disaster Recovery incidents of the creator the token acts for — what the health probes found broken, open by default — with the reason in the connector's words.
- `list_recovery_operations` — **List Recovery Operations** — `GET /v1/recovery/operations` · `project-recovery:view-any`
List every Disaster Recovery operation of the creator the token acts for — run by the creator, the platform or on demand — with its state and whether it can still be undone.
### Resources (2)
- `get_resource` — **Get Resource** — `GET /v1/projects/{project}/resources/{resource}` · `project-resource:view`
Retrieve a single project resource by UUID: its kind (manual or connector:kind), connector, the place it is bound to, title, description and switch.
- `list_resources` — **List Resources** — `GET /v1/projects/{project}/resources` · `project-resource:view-any`
List every resource attached to a project, or only those of one kind (manual, telegram:channel) or on one connector.
### Roles (2)
- `get_role` — **Get Role** — `GET /v1/roles/{role}` · `role:view`
Retrieve a single role by UUID.
- `list_roles` — **List Roles** — `GET /v1/roles` · `role:view-any`
List every role defined for the current team.
### Subscriptions (3)
- `find_subscription_by_id` — **Find Subscription by ID** — `GET /v1/subscriptions/{subscription}` · `project-subscription:view`
Look up a subscription by its UUID. Useful for follow-up steps after a subscription webhook fires.
- `list_subscription_grants` — **List Subscription Grants** — `GET /v1/subscriptions/{subscription}/grants` · `project-subscription:view`
List the access grants a subscription holds: one per resource (and per pass window), with the connector, how access was given, where it stands and why it failed if it did.
- `list_subscriptions` — **List Subscriptions** — `GET /v1/subscriptions` · `project-subscription:view-any`
List subscriptions, optionally filtered by status or plan.
### Support Canned Replies (2)
- `find_canned_replies` — **Find Canned Replies** — `GET /v1/projects/{project}/support/canned-replies` · `support-canned-reply:view-any`
List the saved replies in a project's support picker, in picker order.
- `get_canned_reply` — **Get Canned Reply** — `GET /v1/projects/{project}/support/canned-replies/{reply}` · `support-canned-reply:view-any`
Retrieve one saved support reply by UUID.
### Support Conversations (3)
- `find_support_conversation` — **Find Support Conversation** — `GET /v1/support/conversations/{conversation}` · `support-conversation:view`
Fetch a single support conversation by ID.
- `list_support_conversations` — **List Support Conversations** — `GET /v1/support/conversations` · `support-conversation:view-any`
List support conversations, newest activity first, optionally filtered by project, status or assignee.
- `list_support_messages` — **List Support Messages** — `GET /v1/support/conversations/{conversation}/messages` · `support-conversation:view`
List the messages in a support conversation, oldest first. This is where the message bodies live — the Support Message Received trigger deliberately omits them so member text never lands in webhook logs. Internal notes are only returned when the token can also write to the conversation.
### Support Settings (1)
- `get_support_settings` — **Get Support Settings** — `GET /v1/projects/{project}/support/settings` · `project:view`
Read a project's support inbox settings: whether support is on, where new threads are relayed, the agent name, the auto-reply and email notifications. The relay chat id is never exposed.
### Team Members (2)
- `get_team_member` — **Get Team Member** — `GET /v1/teams/{team}/members/{member}` · `team-member:view`
Retrieve a single team member by team ID + user ID.
- `list_team_members` — **List Team Members** — `GET /v1/teams/{team}/members` · `team-member:view-any`
List every collaborator attached to a team.
### Teams (3)
- `get_current_team` — **Get Current Team** — `GET /v1/teams/current` · `team:view`
Retrieve the team the authenticated token is scoped to.
- `get_team` — **Get Team** — `GET /v1/teams/{team}` · `team:view`
Retrieve a single team by UUID.
- `list_teams` — **List Teams** — `GET /v1/teams` · `team:view-any`
List every team the authenticated user belongs to.
### Tokens (2)
- `get_token` — **Get Token** — `GET /v1/tokens/{token}` · `token:view`
Retrieve metadata for a single personal access token by UUID.
- `list_tokens` — **List Tokens** — `GET /v1/tokens` · `token:view-any`
List every personal access token issued for the authenticated user / team.
### Webhook Deliveries (2)
- `get_webhook_delivery` — **Get Webhook Delivery** — `GET /v1/webhook-deliveries/{delivery}` · `webhook-delivery:view-any`
Retrieve one outbound webhook delivery by UUID: its event, status, attempts, the payload that was posted and what the endpoint answered.
- `list_webhook_deliveries` — **List Webhook Deliveries** — `GET /v1/webhook-deliveries` · `webhook-delivery:view-any`
List the team's outbound webhook delivery log, newest first — what was posted, what the endpoint answered and where each row is on the retry ladder. Narrow by status to find the failed or dead-lettered rows worth retrying.
### Webhook Endpoints (2)
- `get_webhook_endpoint` — **Get Webhook Endpoint** — `GET /v1/webhook-endpoints/{endpoint}` · `webhook-endpoint:view`
Retrieve one of the team's outbound webhook endpoints by UUID. The signing secret is never returned; it exists in the create and rotate-secret responses only.
- `list_webhook_endpoints` — **List Webhook Endpoints** — `GET /v1/webhook-endpoints` · `webhook-endpoint:view-any`
List every webhook endpoint registered for the team.
## Recipes
Prebuilt Zap ideas combining Subscriby triggers with common downstream apps. Swap the destination for whatever CRM, spreadsheet, or messaging tool fits your stack.
### Revenue ops
#### 1. New subscriber → welcome email
- **Trigger**: `subscription.created` on project X.
- **Action**: Mailjet / Resend / Postmark → send a "Welcome" email using the subscriber's email field.
Most members have no `email` — they join through a bot and are never asked for one. `billing_email` is often the only address on file, and it is unverified, so treat it as identification rather than a consented mailing address.
#### 2. New subscriber → CRM upsert
- **Trigger**: `subscription.created`.
- **Action**: HubSpot / Pipedrive / Attio → create-or-update contact; tag with plan name + initial MRR.
#### 3. Churn → Slack alert
- **Trigger**: `subscription.cancelled` or `subscription.expired`.
- **Action**: Slack → post in `#churn-watch` with subscriber handle, plan, and days-subscribed.
#### 4. Failed payment → Slack + follow-up email
- **Trigger**: `payment.failed`.
- **Action 1**: Slack → post in `#revenue-ops`.
- **Action 2**: Delay 12 hours, then run the `find_subscription_by_id` search. If the next retry hasn't cleared the state, fire a Mailjet reminder with a one-tap link to update the payment method.
#### 5. Past due → automated drip
- **Trigger**: `subscription.past_due`.
- **Action**: Mailjet sequence — day 1 "payment failed", day 3 "reminder", day 7 "last chance".
#### 6. Trial converting → personal nudge
- **Trigger**: `subscription.trial_converting`.
- **Action**: Slack DM to the creator with a link to the subscriber's profile so they can intervene before the trial flips to paid.
### Access codes
#### 7. Access-code batch generated → Airtable export
- **Trigger**: `access_code.generated`.
- **Action**: Airtable → append a row with batch metadata (plan, count, expiry).
#### 8. Access-code redeemed → Google Sheet log
- **Trigger**: `access_code.redeemed`.
- **Action**: Google Sheets → append a row with subscriber id, plan id, masked code, timestamp.
#### 9. Gift campaign → generate + DM
- **Trigger**: New row in an Airtable "Influencer List".
- **Action 1**: `bulk_generate_access_codes` (1 code, `export_type=file`).
- **Action 2**: `get_deep_link` with `access_code=` to build a one-tap bot link.
- **Action 3**: Slack DM / email the influencer with the link.
### Team + audit
#### 10. Member banned → Telegram ops chat
- **Trigger**: `member.banned`.
- **Action**: Telegram → send to an ops chat with reason + the ban-issuing admin's name.
#### 11. Team role changed → audit trail
- **Trigger**: `team.member.role_changed`.
- **Action**: Notion database → append an entry with old/new role, actor, timestamp.
#### 12. Suspicious activity → auto-revoke token
- **Trigger**: `list_activity` search on an hourly schedule, filter for `actor_kind = 'mcp'` entries touching high-value resources.
- **Action**: If count exceeds your threshold, call `revoke_token` on the flagged token id and post the incident to Slack.
### Billing
#### 13. Grace-period warning → creator heads-up
- **Trigger**: `billing.grace_period_warning`.
- **Action**: Email the account owner with a link to `/settings/billing` before the grace window elapses.
#### 14. Account locked → ops escalation
- **Trigger**: `billing.account_locked`.
- **Action 1**: PagerDuty → incident.
- **Action 2**: Slack → post in `#oncall` with the tier cycle and last successful payment date.
#### 15. Tier upgraded → CRM annotation + welcome pack
- **Trigger**: `billing.tier_upgraded`.
- **Action 1**: HubSpot → update lifecycle stage to "paid".
- **Action 2**: Loom or Notion → send the creator the tier-specific onboarding playbook.
### Analytics & reporting
#### 16. Weekly revenue digest
- **Trigger**: Schedule — every Monday 09:00.
- **Action 1**: `get_dashboard_metrics` with `period=7d`.
- **Action 2**: `get_transaction_breakdown` with `dimension=payment_provider`, `period=7d`.
- **Action 3**: Email / Slack a digest combining both.
#### 17. Plan performance → Google Sheet
- **Trigger**: Schedule — daily.
- **Action 1**: `get_plan_performance` for each active project.
- **Action 2**: Google Sheets → append rows (one per plan) for the dashboard you hand to finance.
#### 18. Month-end close → earnings statement
- **Trigger**: Schedule — 1st of each month 06:00.
- **Action 1**: `get_earnings_report` with `granularity=day` for the previous month.
- **Action 2**: Generate a PDF via DocRaptor and email it to accounting@yourco.
### Integrations hygiene
#### 19. Onboarding checklist
- **Trigger**: `member.trial_joined`.
- **Action 1**: HubSpot → start "Trial onboarding" sequence.
- **Action 2**: Delay 3 days.
- **Action 3**: `find_member_by_id` to check status; if still trialing, send a reminder.
#### 20. Test webhook endpoint on deploy
- **Trigger**: GitHub Actions → new release published.
- **Action**: `test_webhook_endpoint` against every active endpoint in your team. Post a summary in Slack.
#### 21. Rotate webhook secret → secret manager
- **Trigger**: Schedule — every 90 days.
- **Action 1**: `rotate_webhook_secret` on the endpoint.
- **Action 2**: Write the fresh `secret` into HashiCorp Vault / AWS Secrets Manager / 1Password.
- **Action 3**: Slack → notify `#platform` that the rotation landed.
## Troubleshooting
| Symptom | Likely cause | Fix |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Test-auth returns `401 AUTHENTICATION_REQUIRED` | Token expired or revoked | Mint a new token |
| Test-auth returns `403 TOKEN_MISSING_ABILITY` | Token lacks `team:view` | Mint with `team:view` plus the specific abilities each trigger / action / search needs |
| Test-auth returns `404 TENANT_MISMATCH` | Token has no `scope:team:` entry | Mint from the dashboard — the UI always appends the scope automatically |
| `performSubscribe` returns `422 VALIDATION_FAILED` on `events.*.in` | Event name typo / outdated | Rebuild the Zap; the Zapier app is kept in lockstep with [the webhook catalog](/api/v1/reference/webhook-events) |
| Zap receives no events | Endpoint auto-disabled after consecutive failures | Re-enable from `/settings/webhooks` or use the `test_webhook_endpoint` action for a diagnostic ping |
| Analytics search returns `400 VALIDATION_FAILED` on `dimension` | `get_transaction_breakdown` requires `plan` / `payment_provider` / `currency` / `project` | Pick one of the four |
## Source
The Zapier app is maintained at [github.com/envigo-innovations/subscriby-zapier](https://github.com/envigo-innovations/subscriby-zapier). PRs welcome. See the [CHANGELOG](https://github.com/envigo-innovations/subscriby-zapier/blob/main/CHANGELOG.md) for per-version event / action / search deltas.
## Related
- [Webhook events](/api/v1/reference/webhook-events) — canonical catalog of every `type` value.
- [API authentication](/api/v1/authentication) — how `sbt_live_` / `sbt_test_` tokens are minted.
---
# What is Subscriby?
Source: https://docs.subscriby.net/introduction
import {
Users,
Lock,
CreditCard,
Globe,
ShieldCheck,
Bot,
LayoutDashboard,
Link2,
Package,
TicketPercent,
MessagesSquare,
Building2,
Rocket,
Compass,
BookOpen,
} from "lucide-react";
**Subscriby** is a membership-management platform for creators, businesses, and communities — built by [Envigo Innovations, LLC](https://web.facebook.com/envigo). It lets you monetize private communities on the platforms your members already use — through connectors, with Discord under development and Slack and WhatsApp on the roadmap — using subscription plans, access codes, and multi-gateway payments. No code required.
You run a community or produce content that is worth paying for. Subscriby
gives you a bot plus a web portal that sits in front of your private places,
handles subscribe / renew / cancel flows, accepts payments through nine
providers, automatically admits paying members, removes expired ones, and
reports everything back to you through a single dashboard. Subscribers stay
on their platform, where they're comfortable; you stay in control of your
business.
## Who it's for
}
title="Individual creators"
>
Coaches, educators, podcasters, analysts, mentors — anyone with an audience
who wants to charge for premium access.
} title="Communities">
Private clubs, niche subreddits, investing groups, support networks — any
organisation that needs gated access with subscription billing.
}
title="Businesses & agencies"
>
Small teams selling training material or content libraries. Agencies managing
multiple creators at once (via [Teams](/teams)).
}
title="Anyone with a private channel"
>
Already have a private channel, group or server? Subscriby is the fastest
path from "private" to "paid private".
## Who it's not for
Selling one-off digital products or physical goods? Subscriby is overkill —
use a Gumroad, Shopify, or equivalent instead.
The [Connectors Marketplace](/connectors/marketplace) lists what is live
today; Discord is under development, and Slack, WhatsApp and seven more
connectors are on the [roadmap](/connectors/roadmap).
Even Free-plan creators must keep a payment method on file because
transaction fees apply. If you run a purely free-access community,
your platform's own private-group features are simpler.
## The three concepts you'll use every day
}
title="Project"
href="/creators/projects"
>
The container for one specific membership business. Holds plans, payment
methods, resources, and members.
}
title="Plan"
href="/creators/plans"
>
The pricing tier subscribers buy — _$9.99 / month Premium_, _$99 lifetime_,
etc. Currency, billing cycle, trial rules, audience filters.
}
title="Resource"
href="/creators/resources"
>
The thing a plan unlocks — a place on your connected platform, or a
manually-tracked digital good.
Once those three are set up and you've [connected a connector](/creators/connectors), subscribers join via your bot link or your [portal page](/creators/share).
## Why does Subscriby exist?
Running a paid community used to mean cobbling together spreadsheets, manual invite management, and nagging reminders about unpaid renewals. It worked until the first hundred members; past that it broke.
Subscriby turns _"running a paid community"_ into a repeatable, nearly-zero-touch operation — so creators focus on the content, not the bookkeeping.
## Security and compliance
}
title="Data residency"
>
All Subscriby data is processed and stored in our **North Virginia, United
States** data centres. Fully compliant with **GDPR** (EU subscribers) and
**CCPA** (California subscribers).
} title="Account security">
Two-factor authentication, [passkeys](/account/passkeys), and
connected-account recovery protect creator accounts.
}
title="Payment credentials"
>
Provider API keys are stored encrypted. Subscriby never sees raw card
numbers or crypto wallets — those live with the payment providers.
## How the money flows
### Subscribers pay you [step]
Members pay through whichever payment provider they pick at checkout (Stripe, PayPal, Razorpay, and so on). The provider deposits funds **directly into your account** — Subscriby never holds your revenue.
### You pay Subscriby [step]
You pay for your chosen creator plan (Free, Starter, or Growth) on a monthly or annual cycle, plus **per-transaction fees** on the income you process. Transaction fees settle monthly or when you hit a usage threshold — see [Transaction fees](/fees).
### Payouts and refunds [step]
Payouts to your bank and refunds to subscribers both happen at the **payment-provider level**. Subscriby reflects current status through the dashboard but never intermediates or holds funds.
**Key takeaway:** Your revenue never passes through Subscriby. We are a SaaS
tool that automates the community, not a payment processor.
## The Subscriby ecosystem
}
title="Creator dashboard"
>
The web app where you build and run projects. Sign in at
`app.subscriby.net` (or your deployment's URL). Every setup task ends with
an action here.
} title="Subscriby's platform bots">
Each connector runs one shared bot through which you link your account,
receive alerts and manage projects on the go. Distinct from your project
bots; shared across every creator.
} title="Project bots">
A project installs **connectors**, and each gives it a bot of its own on that
platform, owned by you. Connect it once; from then on it handles every
subscriber interaction. See [Connectors](/creators/connectors).
} title="Project portals">
Each project gets an auto-generated public page at
`my.subscriby.net/{your-handle}`. Subscribers can browse plans, subscribe,
and manage memberships from the web — handy for audiences not yet on
your platform.
## Ready to try it?
}
title="Quickstart"
href="/quickstart"
>
The 10-minute path from signup to your first live plan.
}
title="How it works"
href="/how-it-works"
>
End-to-end tour of the subscriber-facing experience.
}
title="For Creators"
href="/creators"
>
The full creator playbook — every feature, every screen.
---
# Async Jobs
Source: https://docs.subscriby.net/mcp/v1/async-jobs
Some MCP operations — today, only `bulk_generate_access_codes` — take longer than the 30-second budget a short-lived HTTP request can hold. Those tools return a `job_id` immediately and the client polls `get_job_status` for completion.
## The pattern
1. The client invokes a long-running tool — for example `bulk_generate_access_codes` with a batch of several hundred codes.
2. The server enqueues the work and returns immediately:
```json
{
"data": {
"job_id": "0a4e7b96-c358-4d12-9f6b-25a8013ce74f",
"status": "queued",
"enqueued_at": "2026-05-18T10:05:00Z",
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"quantity": 250,
"preview": {
"free_remaining": 120,
"chargeable_quantity": 130,
"will_bill_overage": true
}
}
}
```
`preview` is there so an agent can tell a human what the batch will cost before
the work runs. See [`bulk_generate_access_codes`](/mcp/v1/tools/access-code#bulk-generate-access-codes).
3. The client periodically calls `get_job_status` with the `job_id`:
```json
{
"data": {
"job_id": "0a4e7b96-c358-4d12-9f6b-25a8013ce74f",
"tool_name": "bulk_generate_access_codes",
"status": "completed",
"result": { "count": 250 },
"error": null,
"started_at": "2026-05-18T10:05:01Z",
"completed_at": "2026-05-18T10:05:04Z"
}
}
```
The envelope is the same for every async tool; `result` is whatever the tool
that enqueued the work returns, so read it against that tool's own page.
4. When `status` becomes `completed` or `failed`, the caller reads `result` or `error` accordingly.
Every job reaches one of those two. A worker that gives up after its retries
records the failure on the row rather than leaving it queued, and the guard
clauses that abandon a batch early — a deleted plan, a project with no
access-code payment method, a deleted user — each say so in `error.message`.
## Status values
| Status | Meaning |
| ----------- | ------------------------------------------------------------ |
| `queued` | Job enqueued but not yet picked up by a worker. |
| `running` | Worker started executing the job. |
| `completed` | Job finished successfully. `data.result` carries the output. |
| `failed` | Job failed. `data.error` carries a standard error envelope. |
## Suggested polling cadence
- **First 10 seconds:** poll every second.
- **Next 50 seconds:** poll every 5 seconds.
- **After 1 minute:** poll every 30 seconds.
Jobs typically complete in well under a minute.
## Persisting across sessions
Job IDs are stable. An agent that loses its conversation context can still poll `get_job_status` from a new session as long as the token owner matches — the job row is scoped to the token's team the same as any other read.
## Which tools are async
Only `bulk_generate_access_codes` enqueues an async job today. Every other tool runs synchronously. The tool's description (surfaced at MCP handshake time) states whether the tool returns a `job_id`.
## Related
- [`bulk_generate_access_codes`](/mcp/v1/tools/access-code#bulk-generate-access-codes) — the tool that queues a job.
- [`get_job_status`](/mcp/v1/tools/observability#get-job-status) — the polling tool.
- [`preview_access_code_cost`](/mcp/v1/tools/access-code#preview-access-code-cost) — call this first to see whether a batch will trigger overage billing before you queue it.
---
# MCP Authentication
Source: https://docs.subscriby.net/mcp/v1/authentication
The MCP server at `https://mcp.subscriby.net` accepts two credentials on the same endpoint:
| Credential | How you get it | Acts as | Best for |
| ----------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| **OAuth 2.1 access token** | The client asks; you sign in to Subscriby and press **Authorize**. No token to copy. | You, on the team you are working in, with every ability you hold there | Claude, Claude Code, Cursor, VS Code, ChatGPT — any OAuth-aware client |
| **Personal access token** (`sbt_…`) | **Settings → API Tokens**, choosing the abilities and the team scope by hand | The abilities and `scope:team:` / `scope:project:` you minted | Scripts, CI, clients you configure with a header, narrow grants |
Both run through the same tenant resolution and the same per-tool ability gate. Nothing about a tool changes with the credential; what changes is how broad the credential is and how it was granted.
## Connecting with OAuth
Add the server URL — exactly `https://mcp.subscriby.net` — to a client that supports remote MCP servers and choose to connect. The client discovers the authorization server on its own, registers itself, and sends you to Subscriby:
1. If you are not signed in to the dashboard, you sign in first (two-factor and passkeys apply as usual).
2. Subscriby shows a consent screen naming the client, the account you are signed in as, and what the connection will be able to do.
3. **Authorize** returns you to the client, which now holds a short-lived access token and a refresh token.
An access token lasts **one hour**. The client renews it silently with the refresh token, which lasts **30 days from its last use and is rotated on every renewal**; a connection you stop using expires by itself. Disconnecting the server inside the client discards both tokens.
The connection acts as **you**, on the team you had selected when you
authorized it, with every ability your role there grants — the same reach you
have in the dashboard, and the same tenant boundary. Tools still enforce their
own abilities, and destructive tools still ask for confirmation inside the
client before they run. If you want a connection that can only read, or only
reach one project, mint a personal access token with those abilities instead
and configure the client with it.
### Under the hood
For anyone wiring up a client by hand, the server follows the MCP authorization specification:
- An unauthenticated call is answered with `401` and `WWW-Authenticate: Bearer resource_metadata="https://mcp.subscriby.net/.well-known/oauth-protected-resource", scope="mcp:use"`.
- The protected resource metadata names the endpoint as its `resource` and `https://app.subscriby.net` as the authorization server.
- `https://app.subscriby.net/.well-known/oauth-authorization-server` lists the authorization, token and dynamic registration endpoints; the grants are `authorization_code` and `refresh_token`, PKCE is `S256` only, and the one scope is `mcp:use`.
- Clients register dynamically (RFC 7591) as public clients; the token endpoint accepts `application/x-www-form-urlencoded`; a spent or rotated refresh token is refused with `invalid_grant`.
## Connecting with a personal access token
Mint a token in **Settings → API Tokens** and send it on every request:
```http
Authorization: Bearer sbt_live__
```
Configure the client with the header (each [client page](/mcp/v1) shows where). The token carries only the abilities you ticked, frozen to one team and optionally to a list of projects, exactly as on the [REST API](/api/v1/authentication).
## Per-tool abilities
Each tool enforces one concrete ability from the [catalog](/api/v1/abilities). A personal access token carrying `project:view-any` can call `list_projects` but receives `TOKEN_MISSING_ABILITY` on `list_subscribers`; an OAuth connection satisfies every ability because the creator consented as themselves. Grant personal access tokens narrowly.
An ability means the same thing on both transports: a tool requires exactly what its REST equivalent does, so mint for the operations you intend to call rather than for the channel you intend to call them over.
| Tool | Required ability |
| ------------------------ | ------------------------------------ |
| `list_projects` | `project:view-any` |
| `get_project` | `project:view` |
| `list_subscribers` | `project-user:view-any` |
| `list_plans` | `project-subscription-plan:view-any` |
| `list_access_codes` | `project-access-code:view-any` |
| `list_webhook_endpoints` | `webhook-endpoint:view-any` |
| `get_activity_log` | `activity:read` |
The [tools reference](/mcp/v1/tools-reference) carries the ability mapping for every tool.
MCP used to require a token carrying `mcp:full` before any tool handler ran.
It was removed in 3.0.0: any personal access token whose abilities cover the
tools it calls may use MCP, exactly as with REST, and nothing in the ability
model got wider.
## Team and project scope
A personal access token carries `scope:team:` and optional `scope:project:` entries, the same ones the REST API uses. An OAuth connection is scoped to the team you were working in when you authorized it and to every project on it. The MCP server runs the same tenant-resolution step either way, so every tool call is team-isolated exactly as a REST request is.
## Revocation
- **Personal access tokens**: revoke from **Settings → API Tokens**. Revocation takes effect on the next call; any client still holding the token receives `AUTHENTICATION_REQUIRED`.
- **OAuth connections**: disconnect the server inside the client, which discards its tokens. The access token it held expires within the hour and the refresh token 30 days after its last use.
## Rate limiting
Authenticated MCP calls are bucketed at **120/min per token** — per personal access token, or per OAuth access token. Hitting the limit returns `RATE_LIMITED` with `Retry-After`. Claude, Cursor, ChatGPT Desktop and VS Code all pace their tool calls internally; exhausting this bucket in practice usually means a tight loop on the client side.
Unauthenticated traffic to the MCP host (probes, port scanners, misconfigured clients with no credential attached) is bucketed separately at **5/min per IP**. The OAuth discovery documents and the registration endpoint have their own bucket of 60/min per IP, so a client's first handshake is never throttled by someone else's probing. See [Rate limiting](/api/v1/rate-limiting) for the full bucket table.
## CORS
Browser-based MCP clients (the hosted MCP Inspector, in-browser playgrounds, web extensions) issue cross-origin requests against `mcp.subscriby.net`. The server returns a permissive `Access-Control-Allow-Origin: *` so any browser origin can complete the handshake; `Access-Control-Allow-Credentials` is **not** set because we authenticate exclusively through `Authorization: Bearer …` and never accept cookies on this surface.
The CORS layer exposes the request/response headers a browser client needs to read:
```http
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, GET, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, Accept, MCP-Session-Id, MCP-Protocol-Version, Last-Event-ID
Access-Control-Expose-Headers: MCP-Session-Id, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After, WWW-Authenticate
Access-Control-Max-Age: 86400
```
Preflight `OPTIONS` requests short-circuit before authentication so a browser can complete its handshake without first owning a credential. Native MCP clients ignore CORS entirely — these headers only matter when the caller is a browser.
## Logging and auditing
Every MCP tool invocation is recorded in the activity log and is reachable through the `activity:read` ability (via either the REST `GET /v1/activity` endpoint or the `get_activity_log` MCP tool). Each entry identifies the invoking creator and the credential kind, so you can audit what an agent did on your behalf.
## Related
- [REST API authentication](/api/v1/authentication) — the personal access tokens, same scope entries.
- [Ability catalog](/api/v1/abilities) — every ability string, grouped by domain.
- [Security model](/mcp/v1/security) — credential hygiene and threat model.
---
# Connecting ChatGPT Desktop
Source: https://docs.subscriby.net/mcp/v1/connecting-chatgpt-desktop
## Requirements
- ChatGPT with developer mode connectors enabled (Plus, Pro, Team, Enterprise or Edu; on a workspace plan an admin may need to allow them).
- A Subscriby account. ChatGPT signs you in; a personal access token is only needed when you want a connection narrower than your own reach.
### Add the connector [step]
Open **Settings → Connectors → Create** (developer mode) and enter:
- **Name**: `Subscriby`
- **MCP server URL**: `https://mcp.subscriby.net` — exactly this, no trailing slash
- **Authentication**: **OAuth**
Save. ChatGPT opens Subscriby in your browser: sign in if you are not already, read the consent screen and press **Authorize**. ChatGPT negotiates the MCP handshake and lists the tools in the connector panel.
### Test [step]
Open a fresh chat with the Subscriby connector enabled (toggle icon in the composer) and ask:
> List my Subscriby projects.
The response includes which tool was invoked. If the call fails, inspect the error — every Subscriby MCP error carries a `docs_url` pointing back to this documentation.
## Using a personal access token instead
Choose **Authentication: No authentication** on the connector, and add a custom header `Authorization` with the value `Bearer sbt_…` from a token minted in **Settings → API Tokens**. ChatGPT then skips the sign-in and the connection carries only the abilities you ticked.
## Scope tips
ChatGPT tends to invoke multiple tools per turn. For read-only exploratory chats, a personal access token with only `view-any` + `view` abilities keeps the assistant from writing anything. For "build me a plan" workflows the OAuth connection is simpler; every tool that changes or deletes data is marked destructive, so ChatGPT asks before running it.
## Troubleshooting
- **Connector disabled**: enable it in the composer toolbar for the current chat.
- **Sign-in never completes**: the URL must be exactly `https://mcp.subscriby.net`; finish the browser sign-in and try connecting again.
- **Auth failed** after a while: disconnect and reconnect the connector to sign in again; with a personal access token, re-mint it.
- **Tool 403s** with a personal access token: the token is missing the ability that specific tool enforces — the error envelope includes `required_ability` to make fixing it fast.
---
# Connecting Claude
Source: https://docs.subscriby.net/mcp/v1/connecting-claude-desktop
Claude reaches Subscriby as a **connector**: you give it the server URL, it sends you to Subscriby to sign in, and you approve the connection. Connectors are shared across claude.ai and Claude Desktop on the same account, so you set one up once.
## Requirements
- A Claude plan that allows custom connectors (Pro, Max, Team or Enterprise; on Team and Enterprise an admin may need to enable them).
- A Subscriby account. The connection acts as you, on the team you are working in.
## Add the connector [step]
In claude.ai or Claude Desktop open **Settings → Connectors**, choose **Add custom connector**, and enter:
- **Name**: `Subscriby`
- **Remote MCP server URL**: `https://mcp.subscriby.net` — exactly this, no trailing slash
Save it, then press **Connect**.
## Sign in and authorize [step]
Claude opens Subscriby in your browser. Sign in if you are not already (two-factor and passkeys apply as usual), read the consent screen — it names the client, the account you are signed in as and what the connection can do — and press **Authorize**. You are sent back to Claude and the connector shows as connected.
Access renews itself for as long as you keep using the connection; a connection you stop using for 30 days expires on its own. Disconnecting in **Settings → Connectors** ends it immediately.
## Verify [step]
Open a fresh chat, make sure the Subscriby connector is enabled for it (the tools menu in the composer), and ask:
> List my Subscriby projects.
Claude calls `list_projects` and renders the answer. Read tools run as you ask; tools that change or delete data ask you to confirm inside Claude before they run.
## Claude Code
From a terminal:
```bash
claude mcp add --transport http subscriby https://mcp.subscriby.net
```
Then run `/mcp` inside Claude Code and pick **Authenticate** next to `subscriby`; the same browser sign-in and consent screen follow.
## Using a personal access token instead
If you would rather the connection carry only the abilities you choose — read-only, or one project — mint a token in **Settings → API Tokens** and configure Claude with it as a header instead of signing in.
In Claude Code:
```bash
claude mcp add --transport http subscriby https://mcp.subscriby.net \
--header "Authorization: Bearer sbt_live_YOUR_TOKEN"
```
In Claude Desktop through `claude_desktop_config.json` (macOS `~/Library/Application Support/Claude/`, Windows `%APPDATA%/Claude/`), using the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) bridge, because the config file speaks stdio:
```json
{
"mcpServers": {
"subscriby": {
"command": "npx",
"args": [
"mcp-remote",
"https://mcp.subscriby.net",
"--header",
"Authorization:${AUTH_TOKEN}"
],
"env": {
"AUTH_TOKEN": "Bearer sbt_live_YOUR_TOKEN"
}
}
}
}
```
Keep the token inside `env.AUTH_TOKEN`, not inline in `args`. On Windows,
`npx` splits arguments on whitespace — embedding `Bearer ` directly
breaks the header. The `${AUTH_TOKEN}` placeholder is expanded by
`mcp-remote` after the args are parsed, preserving the space.
Fully quit and relaunch Claude Desktop after editing the file; MCP servers are loaded at launch. When the token rotates, mint a new one, replace the value, relaunch.
## Troubleshooting
- **The connector will not connect**: the URL must be `https://mcp.subscriby.net` with nothing after it. Claude compares it to the address the server publishes and refuses a mismatch.
- **Sent to the dashboard but no consent screen**: you were asked to sign in first; complete the sign-in and Claude retries the authorization.
- **Tools vanished after a while**: Claude renews access silently; if the renewal failed, disconnect and connect again from **Settings → Connectors**.
- **A 403 on one tool** with a personal access token: it lacks that tool's ability; the error names it in `error.context.required_ability`.
- **Logs**: Claude Desktop keeps MCP logs under **Developer → MCP → View logs**.
## Security tip
An OAuth connection never puts a secret on your disk. A personal access token in a config file is plaintext: treat it as you would any credential file — filesystem ACLs, no screen recording while the config is open, and rotate it when the laptop changes hands.
---
# Connecting Cursor
Source: https://docs.subscriby.net/mcp/v1/connecting-cursor
## Requirements
- Cursor 0.46 or newer (MCP support GA).
- A Subscriby account. Cursor signs you in; a personal access token is only needed when you want a connection narrower than your own reach.
## Config
Cursor's MCP configuration lives at `~/.cursor/mcp.json` on macOS / Linux and `%APPDATA%/Cursor/mcp.json` on Windows.
### Add the server [step]
Open the config file and merge:
```json
{
"mcpServers": {
"subscriby": {
"url": "https://mcp.subscriby.net"
}
}
}
```
Restart Cursor.
### Sign in [step]
Open **Settings → MCP**. The `subscriby` entry shows **Needs login**; click it. Cursor opens Subscriby in your browser: sign in if you are not already, read the consent screen and press **Authorize**. Cursor picks up the connection and lists the tools.
### Verify [step]
Open Composer (or chat) and ask:
> Using the subscriby server, list my projects.
Cursor shows the tool invocation in its sidebar. You can approve each call individually or toggle "auto-approve for session" on the subscriby namespace; tools that change or delete data are marked so Cursor asks first.
## Using a personal access token instead
Mint a token in **Settings → API Tokens** with the abilities you want Cursor to exercise and send it as a header; Cursor then skips the sign-in:
```json
{
"mcpServers": {
"subscriby": {
"url": "https://mcp.subscriby.net",
"headers": {
"Authorization": "Bearer sbt_live_YOUR_TOKEN"
}
}
}
}
```
## Project-scoped versus user-scoped
Cursor also supports per-workspace `.cursor/mcp.json`. If you want the subscriby integration only active inside a specific repo, put the config there instead of the global location. Useful when you have a repo-specific Subscriby automation you want the AI agent to help with.
## Troubleshooting
- **Stuck on "Needs login"**: the URL must be exactly `https://mcp.subscriby.net`; finish the browser sign-in and return to Cursor, which retries on its own.
- **Tool list empty**: check Cursor's "MCP" panel in the settings drawer for connection errors.
- **403 on a call** with a personal access token: the token lacks that tool's ability. The response names it in `error.context.required_ability`.
- **404 on specific tools**: the token's team or project scope does not include the entity you referenced — see the [tools reference](/mcp/v1/tools-reference).
---
# Connecting VS Code
Source: https://docs.subscriby.net/mcp/v1/connecting-vscode-claude
## Requirements
- VS Code 1.99 or newer, with MCP servers enabled (`chat.mcp.enabled`), or the Claude Code extension.
- A Subscriby account. VS Code signs you in; a personal access token is only needed when you want a connection narrower than your own reach.
### Add the server [step]
Run **MCP: Add Server** from the command palette, choose **HTTP**, and enter `https://mcp.subscriby.net` with the name `subscriby`. VS Code writes it to `.vscode/mcp.json` (workspace) or your user profile:
```json
{
"servers": {
"subscriby": {
"type": "http",
"url": "https://mcp.subscriby.net"
}
}
}
```
### Sign in [step]
When the server starts, VS Code asks to authenticate with Subscriby and opens your browser. Sign in if you are not already, read the consent screen and press **Authorize**; VS Code stores the connection in its secret storage, never in the config file.
Using the Claude Code extension instead? Open its terminal and run `claude mcp add --transport http subscriby https://mcp.subscriby.net`, then `/mcp` → **Authenticate**.
### Use it [step]
Open the chat panel in agent mode and prefix prompts with "using subscriby" when you want MCP calls to run. Example:
> Using subscriby, list my five most recent subscribers on project "Research Premium".
The panel shows each tool invocation inline with an approve / deny prompt. Approve per call or pin auto-approve on the subscriby namespace; tools that change or delete data are marked so the prompt always appears for them.
## Using a personal access token instead
Mint a token in **Settings → API Tokens** with the abilities you want the assistant to exercise and send it as a header; VS Code then skips the sign-in:
```json
{
"servers": {
"subscriby": {
"type": "http",
"url": "https://mcp.subscriby.net",
"headers": {
"Authorization": "Bearer sbt_live_YOUR_TOKEN"
}
}
}
}
```
Prefer an input variable over a literal token in a workspace file that is committed.
## Workspace vs. user scope
Put the config in the workspace `.vscode/mcp.json` if the Subscriby access should only apply to a specific repo. Keep it in your user profile if every VS Code window should have access.
## Troubleshooting
- **Never asked to sign in**: the URL must be exactly `https://mcp.subscriby.net`; check **View → Output → MCP** for the server's log.
- **Server fails to connect**: the same output panel shows the handshake; a 401 there means the stored connection lapsed — run **MCP: Reset Trust** or remove and re-add the server to sign in again.
- **Tool 403s** with a personal access token: the error envelope names the missing ability — mint a new token with it.
---
# MCP Server for AI Agents
Source: https://docs.subscriby.net/mcp/v1
Subscriby hosts a Model Context Protocol (MCP) server at `https://mcp.subscriby.net`. Any MCP-aware client can connect by signing in to Subscriby (OAuth 2.1) or with a personal access token, and drive the creator workflow through tool calls.
## What you get
- **161 tools** covering your own account (who the token belongs to, its team, plan capabilities, connected accounts and alert routing), the Connectors Marketplace (every connector in its lane, what each can do and the form that connects it) and a project's connector installations (list and read them with their health; install, verify, configure, disconnect, and uninstall them after an impact preview), projects, plans, pass windows, subscriptions and their access grants, the hand-arranged perks still to deliver, the Disaster Recovery ledger (what is broken, what was done about it and whether it can still be undone, the re-admission roll call, the quota allowances and the readiness checklist) and its controls (failover settings, standbys and their mirror, the chat pickers the creator is sent, undo, reminders, the handover mail), subscribers and their connected accounts, subscriptions, access codes, coupons, member support conversations, saved replies and inbox settings, payment methods, payments, transactions, resources, bot status and disconnection, teams, roles, groups, tokens, deep links, portal URLs, member moderation, message broadcasts, activity log, webhook endpoints and deliveries, analytics and reports (dashboard metrics, earnings, subscriber analytics, transaction breakdowns, revenue composition, plan performance, analytics by connector), and async-job polling. [Full catalog](/mcp/v1/tools-reference).
- **6 resources** — the `connectors/catalog` directory and the `ability`, `error-code`, `payment-provider`, `subscription-status` and `webhook-event` enums. Agents load these for context before calling a tool. [Reference](/mcp/v1/resources-reference).
- **No prompts** are shipped — the entire surface is tools + resources.
- **Fine-grained authorization.** Every tool enforces its own ability (`project:view-any`, `project-subscription-plan:create`, …), the same one its REST equivalent requires, so a leaked MCP token cannot act beyond the scopes you granted.
## Connection details
- **Endpoint:** `https://mcp.subscriby.net`
- **Transport:** MCP streamable HTTP
- **Auth:** OAuth 2.1 — the client sends you to Subscriby to sign in and authorize, no token to copy — or `Authorization: Bearer sbt_live_...` carrying the abilities of the tools you intend to call
- **Ability catalog:** `subscriby://enums/ability` resource
- **Rate limit:** 120 tool calls/min per token (`mcp` bucket); unauthenticated probes capped at 5/min per IP
**Signing in gives the client your own reach.** An OAuth connection acts as
you on the team you are working in. When you want a narrower connection —
read-only, or one project — mint a personal access token with just the
`*.view` and `*.view-any` abilities and configure the client with it instead.
## Which client?
## Example prompts
Natural-language calls any MCP client can translate into tool invocations against the Subscriby surface:
- _"How are my earnings last month split by payment provider?"_
- _"List my projects and tell me which one has the most active subscribers."_
- _"Which subscription plan in project **Premium Trading** earned the most revenue last month?"_
- _"Show me the five most recent failed payments across all my projects."_
- _"How many unredeemed access codes are outstanding on plan **Elite Monthly**?"_
- _"Audit the activity log for subscriber `usr_01H...` — did anyone ban and unban them in the last 30 days?"_
- _"Create a new project called 'Research Premium' and scaffold a monthly plan at $29."_
- _"Cancel `jane@example.com`'s subscription with reason 'user requested'."_
Each tool's reference page includes more targeted examples.
## Related
- [Quickstart](/mcp/v1/quickstart) — two-minute setup.
- [Authentication](/mcp/v1/authentication) — OAuth connections and personal access tokens side by side.
- [Tools reference](/mcp/v1/tools-reference) — every tool with its required ability.
- [Resources reference](/mcp/v1/resources-reference) — the 6 enum resources.
- [Async jobs](/mcp/v1/async-jobs) — how long-running tools return a `job_id` and let you poll.
- [Security](/mcp/v1/security) — token scope, revocation, audit logging.
---
# Prompting Guide
Source: https://docs.subscriby.net/mcp/v1/prompting-guide
MCP gives an LLM real levers. Good prompts respect rate limits, keep the blast radius small, and let the human see what's happening. Bad prompts either over-invoke tools or get stuck in agent-loops asking for confirmation.
## Ground first [step]
Tell the assistant what universe it's in before asking for action:
> The Subscriby enum catalog is at `subscriby://enums/subscription-status`. Load it, then tell me which of my subscriptions are past due.
This is faster and safer than letting the assistant guess a status name it doesn't know.
## Be explicit about which project [step]
Almost every tool takes a `project_id` or `project_handle`. Prompts that name the project once up front are far more reliable:
> Work on the project with handle `research-premium` for the rest of this conversation. First, list its active plans.
This prevents Claude from silently switching projects mid-chat, which is the most common source of confusion.
## Scope the token narrowly [step]
An agent token limited to `project:view-any`, `project:view`, `project-subscription-plan:view-any`, and `project-user:view-any` can answer 90% of analytics questions. Don't hand it write abilities unless the prompt explicitly involves writes — mint a second token instead.
## Use the assistant for aggregations [step]
The server surface is low-level. The assistant's value is combining tools:
> For each active plan on `research-premium`, count subscribers whose most recent payment succeeded in the last 30 days.
That becomes: `list_plans` → iterate → `list_recent_payments(plan_id=..., since=...)` → tally → render.
## Avoid unbounded loops [step]
When you ask "find every X", cap it:
> List up to 50 subscribers who match…
Otherwise an overly-literal agent paginates through 100,000 rows before answering.
## Drive writes through previews [step]
When write tools land, run them through a dry-run pattern:
> Plan 10 new access codes for `research-premium`. Show me the exact payload you'd send, but don't execute yet.
Then:
> OK, go ahead.
This is cheaper than re-running a large batch operation because the prompt was unclear.
## Reference
The full ability catalog and tool list are at [abilities](/api/v1/abilities) and [tools reference](/mcp/v1/tools-reference).
---
# MCP Quickstart
Source: https://docs.subscriby.net/mcp/v1/quickstart
## Add the server to Claude [step]
In claude.ai or Claude Desktop open **Settings → Connectors → Add custom connector** and enter the name `Subscriby` and the URL `https://mcp.subscriby.net`. Save, then press **Connect**.
Using Claude Code instead? Run `claude mcp add --transport http subscriby https://mcp.subscriby.net`, then `/mcp` → **Authenticate**.
## Sign in and authorize [step]
Your browser opens Subscriby. Sign in if you are not already, read what the connection will be able to do, and press **Authorize**. You are returned to Claude with the connector connected.
There is no token to copy: the connection acts as you, on the team you are working in, and renews itself while you keep using it.
## Try it [step]
Open a new chat and ask:
> List my Subscriby projects.
Claude invokes the `list_projects` tool and renders the response. You can also ask:
> How many subscribers are on Premium Monthly?
> Which subscribers redeemed access codes in the last week?
Tools that change or delete data ask you to confirm inside Claude first.
## Prefer a narrower connection?
Mint a personal access token in **Settings → API Tokens** with only the abilities you want — start read-only with `project:view-any`, `project-user:view-any` and `project-subscription-plan:view-any` — and configure the client with it as a bearer header. Each client page shows where; [connecting Claude](/mcp/v1/connecting-claude-desktop) covers Claude Desktop and Claude Code.
## What if I use Cursor, ChatGPT Desktop, or VS Code instead?
Same server URL, same sign-in, different settings screens:
- [Cursor setup](/mcp/v1/connecting-cursor)
- [ChatGPT Desktop setup](/mcp/v1/connecting-chatgpt-desktop)
- [VS Code setup](/mcp/v1/connecting-vscode-claude)
## Next
- [Authentication](/mcp/v1/authentication) — OAuth connections and personal access tokens side by side.
- [Tools reference](/mcp/v1/tools-reference)
- [Prompting guide](/mcp/v1/prompting-guide)
- [Security model](/mcp/v1/security)
---
# Resources Reference
Source: https://docs.subscriby.net/mcp/v1/resources-reference
MCP resources are read-only JSON snapshots the server exposes over a URI scheme. Agents load them to ground themselves before running tool calls — reading the ability catalog, for example, lets an agent propose the minimum-privilege token to mint for a given task.
## URI scheme
Every resource URI starts with `subscriby://`. Seven resources are published:
| URI | Contents |
| --------------------------------------- | --------------------------------------------------------------------------------------------- |
| `subscriby://connectors/catalog` | The Connectors Marketplace: every connector in its lane, its badges, manifest and connect form. |
| `subscriby://enums/ability` | The full Sanctum ability catalog, grouped by domain. |
| `subscriby://enums/error-code` | Every error code with HTTP status, default message, remediation hint, and `docs_url` anchor. |
| `subscriby://enums/payment-provider` | Every payment provider key: the gateway slugs, access codes, one `connector:provider` key per currency a registered connector brings (each with `connector` and `requires_connector`), and `platformcurrency` flagged `legacy_alias`. |
| `subscriby://enums/subscription-status` | The canonical subscription statuses with human label and UI colour family. |
| `subscriby://enums/webhook-event` | The outbound webhook catalog with the minimum subscribing-creator ability for each event. |
## Why load them
An agent that knows the enum universe before making calls will:
- Avoid proposing a status that does not exist ("is the subscription in 'dormant' state?" when `dormant` is not a value).
- Pattern-match error codes to remediation hints without a docs round-trip.
- Pre-filter webhook events when registering an endpoint via the REST `POST /v1/webhook-endpoints` endpoint.
- Mint minimum-privilege follow-up tokens by reading the ability catalog.
- Propose a connector a creator can actually install today, and render its connect form from the declared fields, by reading the connector catalog.
## Example — Claude Desktop
Reference a resource in a prompt:
> Load `subscriby://enums/subscription-status` and explain the difference between `unpaid` and `past_due`.
Claude fetches the resource, parses the JSON, and answers without calling any tools.
## Payload shape
There is **no shared envelope**. Each resource returns JSON keyed by its own collection name, with whatever counts are meaningful for it — and none of them carry a `version` field.
| Resource | Top-level keys |
| --------------------------------------- | ---------------------------------------------------- |
| `subscriby://connectors/catalog` | `connectors[]`, `total` |
| `subscriby://enums/ability` | `abilities[]`, `core_count`, `extras_count`, `total` |
| `subscriby://enums/error-code` | `error_codes[]` |
| `subscriby://enums/payment-provider` | `payment_providers[]` |
| `subscriby://enums/subscription-status` | `subscription_statuses[]` |
| `subscriby://enums/webhook-event` | `webhook_events[]`, `families[]` |
```json
{
"abilities": [
{
"value": "project:view-any",
"category": "project",
"label": "View Any Project"
}
],
"core_count": 50,
"extras_count": 25,
"total": 75
}
```
The `{ version, count, items[] }` envelope belongs to the **HTTP** manifests at
`api.subscriby.net/*.json`, not to these MCP resources. If you are switching an
agent between the two sources, the shapes differ.
## Prompts
The MCP server does not publish any prompts today — the full surface is tools plus the seven resources above.
---
# MCP Security
Source: https://docs.subscriby.net/mcp/v1/security
## Threat model
The MCP server accepts two credentials over HTTPS — an OAuth 2.1 access token the creator granted by signing in, or a personal access token — and runs the request inside the same tenancy boundary as the REST API. The most common risks are:
1. **A token leaking into LLM conversation logs** — Claude, Cursor, and ChatGPT Desktop all log the session. A developer sharing a transcript can leak an `Authorization` header if the client logs it.
2. **Over-privileged credentials** — minting a personal access token with every ability ticked, or connecting over OAuth from an account with more reach than the task needs, makes a leak costly.
3. **Accidental writes** — an eager agent executing a tool that turns out to mutate state.
We design for the first two and accept the third as a prompt-engineering issue, with the server marking every tool that changes or deletes data as destructive so a client asks before running it. Below are the controls.
## OAuth connections
- **Consent, not a pasted secret.** The creator signs in to the dashboard, with two-factor and passkeys applying as usual, and reads a consent screen that names the client, the signed-in account, the reach of the connection and where they will be sent back to. Nothing is copied to disk.
- **Short-lived access.** An access token lasts one hour. The refresh token lasts 30 days from its last use and is rotated on every renewal; presenting a spent one is refused, so a copied refresh token dies the moment the client renews.
- **Public clients with PKCE.** Every client registers dynamically as a public client and must present an `S256` code challenge; there is no client secret to leak.
- **One scope, the creator's own reach.** The connection acts as the creator on the team they were working in, with every ability their role grants there, inside the same tenant boundary as the dashboard. A connection that should be narrower is a personal access token instead.
- **Disconnect ends it.** Disconnecting the server inside the client discards its tokens; the access token expires within the hour and the refresh token cannot be renewed once discarded.
## Personal access tokens
### Prefix scanning
All tokens start with `sbt_`. GitHub's secret scanning treats this as a Subscriby-specific credential and alerts on push. If a token lands in a public repo, we usually find out before the creator does.
### Mandatory team scope
Every token carries `scope:team:` frozen at mint time. A leaked token is constrained to exactly one team — it can never pivot. Project-level `scope:project:` tuples tighten this further.
### Minimum-privilege defaults
The token-minting UI pre-selects **no abilities** and makes you opt into each. This is deliberate friction — most tokens only need two or three abilities.
### Expiry
Personal access tokens do not expire automatically. Long-lived "production" tokens (Zapier, CI, ops dashboards) should be rotated manually on your own cadence — revoke the old token in the dashboard, mint a fresh one with the same abilities, and update the client config.
### Revocation
Revoke a token from **Settings → API Tokens → Revoke**. Revocation is immediate; any client still holding the token gets `AUTHENTICATION_REQUIRED` on its next call.
## Audit trail
Every MCP tool invocation is recorded in the activity log with the invoking user as the `causer`, the subject entity, and an `actor_kind` field that distinguishes MCP calls from dashboard and REST actions. Review what an agent did in **Settings → Activity Log**, or read the log over the API via the [`get_activity_log`](/mcp/v1/tools/observability#get-activity-log) MCP tool or `GET /v1/activity` REST endpoint.
## Leaked-credential playbook
For a personal access token:
1. Revoke the token in **Settings → API Tokens**.
2. Review the audit trail for the last 24 hours under that token's `causer_id`.
3. If writes happened you didn't authorise, contact support with the token ID (not the secret).
4. Mint a replacement token with the same abilities.
5. Update the client config (Claude Desktop, Cursor, etc.) with the new token.
For an OAuth connection whose client or device you no longer trust: disconnect the server inside the client, change your Subscriby password (which signs out every browser session), and review the audit trail the same way. Access the connection still held expires within the hour.
## Related
- [Ability catalog](/api/v1/abilities)
- [MCP authentication](/mcp/v1/authentication)
---
# Tools Reference
Source: https://docs.subscriby.net/mcp/v1/tools-reference
161 tools across 19 groups: 56 read, 97 write (82 of them destructive), 7 analytics and one async-polling tool. Every write goes through the same business-logic layer that powers the dashboard, so plan-limit enforcement, cache invalidation and policy gates all run identically. Every count and list on this page is generated from the server's own export.
## Machine-readable catalog
The authoritative, auto-generated list is published at:
```
GET https://api.subscriby.net/mcp-tools.json
```
Sibling artifacts at the same host — regenerated on every deploy:
- `https://api.subscriby.net/abilities.json` — full ability catalog.
- `https://api.subscriby.net/error-codes.json` — error code / HTTP status / remediation.
- `https://api.subscriby.net/webhook-events.json` — every outbound webhook event.
## Tools by group
Each group below is one page of the reference. A page opens with what the group's tools are for, lists them, and then documents every tool in full: its arguments as the server validates them, what it returns, how it fails, the REST endpoint that runs the same action, the webhook events it fires, example prompts and the `tools/call` request an MCP client sends. A row here opens the tool on its page.
### Access Code Tools
An access code is a pre-generated, single-use activation a creator hands out; redeeming it claims a pending subscription. [Open the group](https://docs.subscriby.net/mcp/v1/tools/access-code).
- [`bulk_generate_access_codes`](https://docs.subscriby.net/mcp/v1/tools/access-code#bulk-generate-access-codes) — Bulk Generate Access Codes (Async) (destructive)
- [`list_access_codes`](https://docs.subscriby.net/mcp/v1/tools/access-code#list-access-codes) — List Access Codes (read)
- [`preview_access_code_cost`](https://docs.subscriby.net/mcp/v1/tools/access-code#preview-access-code-cost) — Preview Access-Code Generation Cost (read)
### Account Tools
The account behind the token: who is acting, which team the session is scoped to and what it may do. [Open the group](https://docs.subscriby.net/mcp/v1/tools/account).
- [`get_me`](https://docs.subscriby.net/mcp/v1/tools/account#get-me) — Get Me (read)
- [`list_notifications`](https://docs.subscriby.net/mcp/v1/tools/account#list-notifications) — List Notifications (read)
- [`mark_all_notifications_read`](https://docs.subscriby.net/mcp/v1/tools/account#mark-all-notifications-read) — Mark All Notifications Read (write)
- [`mark_notification_read`](https://docs.subscriby.net/mcp/v1/tools/account#mark-notification-read) — Mark Notification Read (write)
### Analytics & Reports Tools
Every figure the creator dashboard renders is readable here, from revenue and churn to the transaction list, computed by the same service layer, so an agent and the dashboard never disagree. [Open the group](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports).
- [`get_connector_analytics`](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-connector-analytics) — Connector Analytics (analytics)
- [`get_dashboard_metrics`](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-dashboard-metrics) — Headline Dashboard Metrics (analytics)
- [`get_earnings_report`](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-earnings-report) — Earnings Report (Timeseries) (analytics)
- [`get_plan_performance`](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-plan-performance) — Per-Plan Performance (analytics)
- [`get_revenue_composition`](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-revenue-composition) — Revenue Composition (analytics)
- [`get_subscriber_analytics`](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-subscriber-analytics) — Subscriber Analytics (analytics)
- [`get_transaction_breakdown`](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-transaction-breakdown) — Transaction Breakdown by Dimension (analytics)
- [`list_transactions`](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#list-transactions) — List Transactions (Keyset) (read)
### Connector Tools
A project runs on connectors: the platforms it gates access on, messages through and takes payments from. [Open the group](https://docs.subscriby.net/mcp/v1/tools/connectors).
- [`disconnect_connector`](https://docs.subscriby.net/mcp/v1/tools/connectors#disconnect-connector) — Disconnect Connector (destructive)
- [`get_connector`](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector) — Get Connector (read)
- [`get_connector_installation`](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-installation) — Get Connector Installation (read)
- [`get_connector_uninstall_preview`](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-uninstall-preview) — Get Connector Uninstall Preview (read)
- [`get_deep_link`](https://docs.subscriby.net/mcp/v1/tools/connectors#get-deep-link) — Build Bot Deep Link (read)
- [`get_portal_url`](https://docs.subscriby.net/mcp/v1/tools/connectors#get-portal-url) — Build Subscriber Portal URL (read)
- [`install_connector`](https://docs.subscriby.net/mcp/v1/tools/connectors#install-connector) — Install Connector (write)
- [`list_connector_installations`](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connector-installations) — List Connector Installations (read)
- [`list_connectors`](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connectors) — List Connectors (read)
- [`restore_connector_access`](https://docs.subscriby.net/mcp/v1/tools/connectors#restore-connector-access) — Restore Connector Access (write)
- [`run_connector_doctor`](https://docs.subscriby.net/mcp/v1/tools/connectors#run-connector-doctor) — Run Connector Doctor (write)
- [`uninstall_connector`](https://docs.subscriby.net/mcp/v1/tools/connectors#uninstall-connector) — Uninstall Connector (destructive)
- [`update_connector_installation_settings`](https://docs.subscriby.net/mcp/v1/tools/connectors#update-connector-installation-settings) — Update Connector Installation Settings (write)
- [`verify_connector_installation`](https://docs.subscriby.net/mcp/v1/tools/connectors#verify-connector-installation) — Verify Connector Installation (write)
### Coupon Tools
A coupon is one code many buyers can redeem for money off at checkout, the multi-use counterpart of an access code. [Open the group](https://docs.subscriby.net/mcp/v1/tools/coupon).
- [`activate_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#activate-coupon) — Activate Coupon (destructive)
- [`create_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#create-coupon) — Create Coupon (destructive)
- [`deactivate_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#deactivate-coupon) — Deactivate Coupon (destructive)
- [`delete_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#delete-coupon) — Delete Coupon (destructive)
- [`get_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#get-coupon) — Get Coupon (read)
- [`list_coupons`](https://docs.subscriby.net/mcp/v1/tools/coupon#list-coupons) — List Coupons (read)
- [`update_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#update-coupon) — Update Coupon (destructive)
### Creator Task Tools
A creator task is a grant no connector can hand over: a perk the creator delivers by hand once a purchase entitles a member to it. [Open the group](https://docs.subscriby.net/mcp/v1/tools/creator-task).
- [`complete_creator_task`](https://docs.subscriby.net/mcp/v1/tools/creator-task#complete-creator-task) — Complete Creator Task (destructive)
- [`list_creator_tasks`](https://docs.subscriby.net/mcp/v1/tools/creator-task#list-creator-tasks) — List Creator Tasks (read)
### Disaster Recovery Tools
Disaster Recovery is Subscriby's answer to a connector outage or a lost channel: what the probes found, what was recovered and whether it can still be undone. [Open the group](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery).
- [`get_recovery_allowances`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-allowances) — Get Recovery Allowances (read)
- [`get_recovery_readiness`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-readiness) — Get Recovery Readiness (read)
- [`get_recovery_roll_call`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-roll-call) — Get Recovery Roll Call (read)
- [`get_recovery_settings`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-settings) — Get Recovery Settings (read)
- [`get_resource_standby`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-resource-standby) — Get Resource Standby (read)
- [`list_recovery_incidents`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#list-recovery-incidents) — List Recovery Incidents (read)
- [`list_recovery_operations`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#list-recovery-operations) — List Recovery Operations (read)
- [`notify_members_of_recovery`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#notify-members-of-recovery) — Notify Members Of Recovery (destructive)
- [`nudge_pending_readmissions`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#nudge-pending-readmissions) — Nudge Pending Readmissions (write)
- [`remove_resource_standby`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#remove-resource-standby) — Remove Resource Standby (destructive)
- [`remove_standby_installation`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#remove-standby-installation) — Remove Standby Installation (destructive)
- [`request_resource_replacement`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#request-resource-replacement) — Request Resource Replacement (write)
- [`request_resource_standby`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#request-resource-standby) — Request Resource Standby (write)
- [`revert_recovery_operation`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#revert-recovery-operation) — Revert Recovery Operation (destructive)
- [`set_resource_standby_mirror`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#set-resource-standby-mirror) — Set Resource Standby Mirror (write)
- [`update_recovery_settings`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#update-recovery-settings) — Update Recovery Settings (write)
- [`use_resource_standby`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#use-resource-standby) — Use Resource Standby (destructive)
- [`withdraw_resource_replacement_request`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#withdraw-resource-replacement-request) — Withdraw Resource Replacement Request (write)
- [`withdraw_resource_standby_request`](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#withdraw-resource-standby-request) — Withdraw Resource Standby Request (write)
### Member Tools
Members are a project's subscribers, the people every payment is made for and every access grant resolves to. [Open the group](https://docs.subscriby.net/mcp/v1/tools/member).
- [`ban_member`](https://docs.subscriby.net/mcp/v1/tools/member#ban-member) — Ban Member (destructive)
- [`broadcast_message`](https://docs.subscriby.net/mcp/v1/tools/member#broadcast-message) — Broadcast Message (destructive)
- [`find_member_by_identity`](https://docs.subscriby.net/mcp/v1/tools/member#find-member-by-identity) — Find Member by Identity (read)
- [`get_subscriber`](https://docs.subscriby.net/mcp/v1/tools/member#get-subscriber) — Get Subscriber (read)
- [`kick_member`](https://docs.subscriby.net/mcp/v1/tools/member#kick-member) — Kick Member (destructive)
- [`list_member_identities`](https://docs.subscriby.net/mcp/v1/tools/member#list-member-identities) — List Member Identities (read)
- [`list_subscribers`](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers) — List Subscribers (read)
- [`preview_broadcast_audience`](https://docs.subscriby.net/mcp/v1/tools/member#preview-broadcast-audience) — Preview Broadcast Audience (read)
- [`unban_member`](https://docs.subscriby.net/mcp/v1/tools/member#unban-member) — Unban Member (destructive)
- [`unlink_member_identity`](https://docs.subscriby.net/mcp/v1/tools/member#unlink-member-identity) — Unlink Member Identity (destructive)
### Observability Tools
Where to look when something did not happen: webhook endpoints and their deliveries, the activity log of every mutation, and the status of a job another tool queued. [Open the group](https://docs.subscriby.net/mcp/v1/tools/observability).
- [`create_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#create-webhook-endpoint) — Create Webhook Endpoint (destructive)
- [`delete_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#delete-webhook-endpoint) — Delete Webhook Endpoint (destructive)
- [`get_activity_log`](https://docs.subscriby.net/mcp/v1/tools/observability#get-activity-log) — Activity Log (read)
- [`get_job_status`](https://docs.subscriby.net/mcp/v1/tools/observability#get-job-status) — Poll MCP Async Job (async)
- [`get_webhook_delivery`](https://docs.subscriby.net/mcp/v1/tools/observability#get-webhook-delivery) — Get Webhook Delivery (read)
- [`get_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#get-webhook-endpoint) — Get Webhook Endpoint (read)
- [`list_webhook_deliveries`](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-deliveries) — List Webhook Deliveries (read)
- [`list_webhook_endpoints`](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-endpoints) — List Webhook Endpoints (read)
- [`pause_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#pause-webhook-endpoint) — Pause Webhook Endpoint (destructive)
- [`resume_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#resume-webhook-endpoint) — Resume Webhook Endpoint (destructive)
- [`retry_dead_webhook_deliveries`](https://docs.subscriby.net/mcp/v1/tools/observability#retry-dead-webhook-deliveries) — Retry Dead Webhook Deliveries (destructive)
- [`retry_webhook_delivery`](https://docs.subscriby.net/mcp/v1/tools/observability#retry-webhook-delivery) — Retry Webhook Delivery (destructive)
- [`rotate_webhook_endpoint_secret`](https://docs.subscriby.net/mcp/v1/tools/observability#rotate-webhook-endpoint-secret) — Rotate Webhook Endpoint Secret (destructive)
- [`test_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#test-webhook-endpoint) — Test Webhook Endpoint (destructive)
### Payment Tools
A project sells through one or more payment providers, each connected in test or live mode. [Open the group](https://docs.subscriby.net/mcp/v1/tools/payment).
- [`activate_payment_method`](https://docs.subscriby.net/mcp/v1/tools/payment#activate-payment-method) — Activate Payment Method (destructive)
- [`deactivate_payment_method`](https://docs.subscriby.net/mcp/v1/tools/payment#deactivate-payment-method) — Deactivate Payment Method (destructive)
- [`delete_payment_method`](https://docs.subscriby.net/mcp/v1/tools/payment#delete-payment-method) — Delete Payment Method (destructive)
- [`get_payment_method`](https://docs.subscriby.net/mcp/v1/tools/payment#get-payment-method) — Get Payment Method (read)
- [`list_payment_methods`](https://docs.subscriby.net/mcp/v1/tools/payment#list-payment-methods) — List Payment Methods (read)
- [`list_recent_payments`](https://docs.subscriby.net/mcp/v1/tools/payment#list-recent-payments) — Recent Payments (read)
- [`sync_payment_method_plans`](https://docs.subscriby.net/mcp/v1/tools/payment#sync-payment-method-plans) — Sync Payment Method Plans (destructive)
### Plan Tools
Plans are what a project sells, and every plan has a kind that decides its shape: a subscription that renews on a cycle, a pass that sells dated windows, or a series that sells a slate of windows at once. [Open the group](https://docs.subscriby.net/mcp/v1/tools/plan).
- [`cancel_pass_window`](https://docs.subscriby.net/mcp/v1/tools/plan#cancel-pass-window) — Cancel Pass Window (destructive)
- [`create_pass_window`](https://docs.subscriby.net/mcp/v1/tools/plan#create-pass-window) — Create Pass Window (destructive)
- [`create_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#create-plan) — Create Plan (destructive)
- [`delete_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#delete-plan) — Delete Plan (destructive)
- [`get_pass_window`](https://docs.subscriby.net/mcp/v1/tools/plan#get-pass-window) — Get Pass Window (read)
- [`get_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#get-plan) — Get Plan (read)
- [`list_pass_windows`](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) — List Pass Windows (read)
- [`list_plans`](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans) — List Plans (read)
- [`publish_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#publish-plan) — Publish or Unpublish Plan (destructive)
- [`remind_pass_window_queue`](https://docs.subscriby.net/mcp/v1/tools/plan#remind-pass-window-queue) — Remind Pass Window Queue (destructive)
- [`reorder_plans`](https://docs.subscriby.net/mcp/v1/tools/plan#reorder-plans) — Reorder Plans (destructive)
- [`start_next_season`](https://docs.subscriby.net/mcp/v1/tools/plan#start-next-season) — Start Next Season (destructive)
- [`update_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#update-plan) — Update Plan (destructive)
### Project Tools
A project is the container for one membership business: its plans, members, payment methods, resources and connectors. [Open the group](https://docs.subscriby.net/mcp/v1/tools/project).
- [`archive_project`](https://docs.subscriby.net/mcp/v1/tools/project#archive-project) — Archive Project (destructive)
- [`create_project`](https://docs.subscriby.net/mcp/v1/tools/project#create-project) — Create Project (destructive)
- [`delete_project`](https://docs.subscriby.net/mcp/v1/tools/project#delete-project) — Delete Project (destructive)
- [`get_project`](https://docs.subscriby.net/mcp/v1/tools/project#get-project) — Get Project (read)
- [`list_projects`](https://docs.subscriby.net/mcp/v1/tools/project#list-projects) — List Projects (read)
- [`restore_project`](https://docs.subscriby.net/mcp/v1/tools/project#restore-project) — Restore Project (destructive)
- [`update_project`](https://docs.subscriby.net/mcp/v1/tools/project#update-project) — Update Project (destructive)
### Resource Tools
A resource is what a plan unlocks: a place a connector gates, or a perk tracked by hand. [Open the group](https://docs.subscriby.net/mcp/v1/tools/resource).
- [`activate_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#activate-resource) — Activate Resource (destructive)
- [`create_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#create-resource) — Create Manual Resource (destructive)
- [`deactivate_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#deactivate-resource) — Deactivate Resource (destructive)
- [`delete_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#delete-resource) — Delete Resource (destructive)
- [`get_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource) — Get Resource (read)
- [`list_resources`](https://docs.subscriby.net/mcp/v1/tools/resource#list-resources) — List Resources (read)
- [`request_resource_link`](https://docs.subscriby.net/mcp/v1/tools/resource#request-resource-link) — Request Resource Link (write)
- [`unlink_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#unlink-resource) — Unlink Resource (destructive)
- [`update_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#update-resource) — Update Resource (destructive)
### Role & Group Tools
A role is the permission set one collaborator holds; a group is a bundle several collaborators share. [Open the group](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups).
- [`create_group`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-group) — Create Group (destructive)
- [`create_role`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-role) — Create Role (destructive)
- [`delete_group`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#delete-group) — Delete Group (destructive)
- [`delete_role`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#delete-role) — Delete Role (destructive)
- [`list_groups`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-groups) — List Groups (read)
- [`list_roles`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-roles) — List Roles (read)
- [`sync_group_members`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#sync-group-members) — Sync Group Members (destructive)
- [`update_group`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#update-group) — Update Group (destructive)
- [`update_role`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#update-role) — Update Role (destructive)
### Subscription Tools
A subscription is one member's purchase of one plan, and it is never created through a tool: checkout and access codes do that. [Open the group](https://docs.subscriby.net/mcp/v1/tools/subscription).
- [`cancel_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#cancel-subscription) — Cancel Subscription (destructive)
- [`get_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#get-subscription) — Get Subscription (read)
- [`list_subscription_grants`](https://docs.subscriby.net/mcp/v1/tools/subscription#list-subscription-grants) — List Subscription Grants (read)
- [`pause_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#pause-subscription) — Pause Subscription (destructive)
- [`reactivate_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#reactivate-subscription) — Reactivate Subscription (destructive)
- [`reissue_subscription_grants`](https://docs.subscriby.net/mcp/v1/tools/subscription#reissue-subscription-grants) — Reissue Subscription Grants (destructive)
- [`remind_pass_holder`](https://docs.subscriby.net/mcp/v1/tools/subscription#remind-pass-holder) — Remind Pass Holder (destructive)
- [`unpause_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#unpause-subscription) — Unpause Subscription (destructive)
### Support Tools
The support inbox holds one durable conversation per member and channel, the saved replies a team answers with, and the project's support settings. [Open the group](https://docs.subscriby.net/mcp/v1/tools/support).
- [`assign_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#assign-support-conversation) — Assign Support Conversation (destructive)
- [`block_support_contact`](https://docs.subscriby.net/mcp/v1/tools/support#block-support-contact) — Block Support Contact (destructive)
- [`create_canned_reply`](https://docs.subscriby.net/mcp/v1/tools/support#create-canned-reply) — Create Canned Reply (destructive)
- [`delete_canned_reply`](https://docs.subscriby.net/mcp/v1/tools/support#delete-canned-reply) — Delete Canned Reply (destructive)
- [`get_canned_reply`](https://docs.subscriby.net/mcp/v1/tools/support#get-canned-reply) — Get Canned Reply (read)
- [`get_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#get-support-conversation) — Get Support Conversation (read)
- [`get_support_settings`](https://docs.subscriby.net/mcp/v1/tools/support#get-support-settings) — Get Support Settings (read)
- [`list_canned_replies`](https://docs.subscriby.net/mcp/v1/tools/support#list-canned-replies) — List Canned Replies (read)
- [`list_support_conversations`](https://docs.subscriby.net/mcp/v1/tools/support#list-support-conversations) — List Support Conversations (read)
- [`reopen_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#reopen-support-conversation) — Reopen Support Conversation (destructive)
- [`reply_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#reply-support-conversation) — Reply to Support Conversation (destructive)
- [`resolve_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#resolve-support-conversation) — Resolve Support Conversation (destructive)
- [`unblock_support_contact`](https://docs.subscriby.net/mcp/v1/tools/support#unblock-support-contact) — Unblock Support Contact (destructive)
- [`update_canned_reply`](https://docs.subscriby.net/mcp/v1/tools/support#update-canned-reply) — Update Canned Reply (destructive)
- [`update_support_settings`](https://docs.subscriby.net/mcp/v1/tools/support#update-support-settings) — Update Support Settings (destructive)
### Team Member Tools
Team members are the people who collaborate inside a team, distinct from a project's members. [Open the group](https://docs.subscriby.net/mcp/v1/tools/team-members).
- [`cancel_team_invitation`](https://docs.subscriby.net/mcp/v1/tools/team-members#cancel-team-invitation) — Cancel Team Invitation (destructive)
- [`invite_team_member`](https://docs.subscriby.net/mcp/v1/tools/team-members#invite-team-member) — Invite Team Member (destructive)
- [`list_team_members`](https://docs.subscriby.net/mcp/v1/tools/team-members#list-team-members) — List Team Members (read)
- [`remove_team_member`](https://docs.subscriby.net/mcp/v1/tools/team-members#remove-team-member) — Remove Team Member (destructive)
- [`update_team_member_role`](https://docs.subscriby.net/mcp/v1/tools/team-members#update-team-member-role) — Change Team Member Role (destructive)
### Team Tools
A team groups creators, roles and projects, and every token is scoped to exactly one. [Open the group](https://docs.subscriby.net/mcp/v1/tools/team).
- [`create_team`](https://docs.subscriby.net/mcp/v1/tools/team#create-team) — Create Team (destructive)
- [`delete_team`](https://docs.subscriby.net/mcp/v1/tools/team#delete-team) — Delete Team (destructive)
- [`get_team`](https://docs.subscriby.net/mcp/v1/tools/team#get-team) — Get Team (read)
- [`list_teams`](https://docs.subscriby.net/mcp/v1/tools/team#list-teams) — List Teams (read)
- [`update_team`](https://docs.subscriby.net/mcp/v1/tools/team#update-team) — Rename Team (destructive)
### Token Tools
Personal access tokens authorise every REST and MCP call, and the plaintext value exists exactly once. [Open the group](https://docs.subscriby.net/mcp/v1/tools/tokens).
- [`list_tokens`](https://docs.subscriby.net/mcp/v1/tools/tokens#list-tokens) — List Personal Access Tokens (read)
- [`revoke_token`](https://docs.subscriby.net/mcp/v1/tools/tokens#revoke-token) — Revoke Personal Access Token (destructive)
## Async polling
One tool, [`get_job_status`](https://docs.subscriby.net/mcp/v1/tools/observability#get-job-status), checks on work another tool queued (today only `bulk_generate_access_codes` queues). Visibility is locked to jobs the caller originally kicked off, which is why no separate ability gates it — polling reveals nothing you did not already cause, and the tool that enqueued the work enforced its own ability.
See [async jobs](https://docs.subscriby.net/mcp/v1/async-jobs) for the full lifecycle.
## Resource catalogs
Alongside tools, the server exposes 6 read-only resources:
- `subscriby://enums/ability` — **API Ability Catalog**: Every ability a Sanctum personal access token can carry — core CRUD gates plus top-level scopes for team, role, group, token, payment, pass-window, support-canned-reply, webhook-endpoint, webhook-delivery, broadcast, bot, account, billing, dashboard, activity, platform admin and distribution. MCP and REST answer to the same catalog: a tool requires the same ability its REST equivalent does, so mint for the operations you intend to call rather than for the channel. Read once at session start to know what the current token can do and to mint future tokens with the minimum-privilege list. Returns `{ abilities: [{value, category, label}], core_count, extras_count, total }`.
- `subscriby://enums/error-code` — **API Error Code Catalog**: Every error code the API surface and MCP tools can emit. Each entry carries HTTP status, default message, remediation hint, and a docs_url anchor. Read this whenever a tool response carries an `error` envelope so the agent can pick the right remediation before retrying. Returns `{ error_codes: [{code, http_status, message, remediation, docs_url}] }`.
- `subscriby://connectors/catalog` — **Connector Catalog**: Every connector Subscriby knows, lane by lane: `status` (`available`, `beta`, `paused`, `in_development`, `coming_soon`), `installable`, `official`, badges, category, tagline, links and, for a connector that exists as a package, its manifest (resource kinds with their grant mode, capabilities, messaging limits, pacing, the management commands it renders and the ones it misses, recovery facets) and the declarative install and settings fields a client renders as the connect form. Read this before proposing a connector for a project or explaining what one can do. Returns `{ connectors: [card], total }`.
- `subscriby://enums/payment-provider` — **Payment Providers**: The payment-provider keys Subscriby supports: the gateway codes (Stripe, PayPal, Razorpay, Paystack, CoinPayments, Skrill, CeyPay), access codes, and one `connector:provider` key per native currency a connector brings, which can only be configured on a project where that connector is connected. Most flows use payment-method UUIDs from `list_payment_methods` rather than provider codes; consult this resource only when you need the canonical provider string. Returns `{ payment_providers: [{value, connector, requires_connector, legacy_alias}] }`; `platformcurrency` is the legacy alias earlier native rows were stored under.
- `subscriby://enums/subscription-status` — **Subscription Status Catalog**: Every status a subscription or payment row can hold, with the human label and the dashboard color family. Read this before reasoning about lifecycle — e.g. is `past_due` a dunning trigger, is `capturable` retryable, is `canceled` terminal? Avoids hard-coding free-text status strings on the agent side. Returns `{ subscription_statuses: [{value, label, color}] }`.
- `subscriby://enums/webhook-event` — **Outbound Webhook Event Catalog**: Every outbound webhook event Subscriby can emit, grouped by family (project, plan, member, subscription, payment, access-code, etc.). Each row carries the minimum ability a subscribing creator must hold to receive that event. Read this before configuring a webhook endpoint or wiring up an external automation (Zapier, n8n) so the event allow-list lines up with the events the source actions actually produce. Returns `{ webhook_events: [{name, family, required_ability}], families: [string] }`.
Clients that support resource browsing pick any of these as context before driving the tools. See the [resources reference](https://docs.subscriby.net/mcp/v1/resources-reference) for payload shapes.
---
# Access Code Tools
Source: https://docs.subscriby.net/mcp/v1/tools/access-code
An access code is a pre-generated, single-use activation a creator hands out; redeeming it claims a pending subscription. These tools mint codes singly or in bulk, list them and revoke the ones that should no longer work.
## Tools
- [`bulk_generate_access_codes`](#bulk-generate-access-codes) — Bulk Generate Access Codes (Async) (destructive)
- [`list_access_codes`](#list-access-codes) — List Access Codes (read)
- [`preview_access_code_cost`](#preview-access-code-cost) — Preview Access-Code Generation Cost (read)
## bulk_generate_access_codes
Queue a bulk access-code generation batch. Returns a job_id immediately — poll get_job_status for completion.
Bulk-generate access codes for a plan. The underlying job can take minutes for large batches, so the tool returns a `job_id` immediately; clients poll [`get_job_status`](https://docs.subscriby.net/mcp/v1/tools/observability#get-job-status) until `status=completed`. The `access_code.generated` event emits once per batch when the worker finishes.
> **Note**
>
> **Billing model:** generation is free. The Stripe metered overage triggers on
> **redemption** when the creator's tier free allotment is exhausted. The
> response embeds a `preview` so callers can still surface the worst-case cost
> ("if all N get redeemed"). For a cost-only query, call
> [`preview_access_code_cost`](https://docs.subscriby.net/mcp/v1/tools/access-code#preview-access-code-cost).
> **Warning**
>
> Delivery goes through the creator's connector chat (the Telegram bot today),
> never through this tool's response. `export_type` must be `file` (a CSV sent
> to the creator) or `messages` (one forwardable message per code) — both
> require the creator account to have a linked connector identity. Agents that
> need programmatic access must also call
> [`list_access_codes`](https://docs.subscriby.net/mcp/v1/tools/access-code#list-access-codes) after the batch completes.
- Requires ability: `project-access-code:create`
- Runs the same action as [`POST /v1/projects/{project}/plans/{plan}/access-codes/bulk-generate`](https://docs.subscriby.net/api/v1/reference/access-codes#generate-a-batch-of-codes)
- Fires events: [`access_code.generated`](https://docs.subscriby.net/webhooks/v1/events/access-code#access-code-generated)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | yes | UUID of the plan to attach the generated codes to. |
| `quantity` | integer | yes | How many access codes to generate (1-1000). |
| `export_type` | string | yes | How the job delivers the codes to the creator — file (CSV via bot) or messages (one per code via bot). Required. |
| `expiry_preset` | string | yes | Required. One of no_expiry, 1_day, 1_week, 1_month, 1_year, custom. |
| `custom_expiry_amount` | integer | no | When expiry_preset=custom, the amount of custom_expiry_type units. Required when preset=custom. |
| `custom_expiry_type` | string | no | When expiry_preset=custom, one of days/weeks/months/years. Required when preset=custom. |
| `consent` | boolean | no | Required and must be true when export_type=messages — acknowledges that one message per code will be sent to the creator's connector chat. |
### Example call
```json
{
"name": "bulk_generate_access_codes",
"arguments": {
"plan_id": "789c4fba-32b8-4800-a195-4c9fd62c9ecf",
"quantity": 1,
"export_type": "",
"expiry_preset": ""
}
}
```
### What it returns
```json
{
"data": {
"job_id": "0a4e7b96-c358-4d12-9f6b-25a8013ce74f",
"status": "queued",
"enqueued_at": "2026-05-18T10:05:00Z",
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"quantity": 50,
"preview": {
"free_remaining": 3,
"chargeable_quantity": 47,
"cost": "1.41"
}
}
}
```
Poll [`get_job_status`](https://docs.subscriby.net/mcp/v1/tools/observability#get-job-status) with the returned `job_id` until `status` is `completed` or `failed`. It always reaches one of the two.
On completion the job's `result` carries the batch summary:
```json
{
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"plan_name": "Premium Monthly",
"count": 50,
"export_type": "file",
"expires_at": null,
"delivered": true,
"delivery_error": null
}
```
> **Generation and delivery succeed separately**
>
> The codes are written first, then handed to the creator over their connector.
> If that delivery fails — the bot is disconnected, the platform is down — the
> job still **completes**: the codes exist and are redeemable, and
> [`access_code.generated`](https://docs.subscriby.net/webhooks/v1/events/access-code#access-code-generated)
> still fires. `delivered` goes `false` and `delivery_error` carries the reason,
> so you can tell the creator their CSV never arrived without anyone concluding
> the batch failed and generating it twice.
### How it fails
- `VALIDATION_FAILED` — quantity out of range, unknown export_type, missing consent when export_type=messages, missing custom_expiry_amount / custom_expiry_type when expiry_preset=custom, unknown expiry_preset, a batch for the same plan already in flight, or the underlying Action rejects the inputs.
- `RESOURCE_NOT_FOUND` — unknown plan_id, or the plan belongs to a team outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-access-code:create.
### One batch per plan at a time
Queueing a second batch for a plan whose previous one has not reported an
outcome is refused with `VALIDATION_FAILED`, and the message names the live
`job_id` to poll instead.
The worker deduplicates by creator, project and plan, so a second batch inside
that window would be dropped without a word — handing you a job id for work that
was never going to run. Refusing up front is the honest version of the same
constraint.
### Example prompts
> "Generate 10 access codes for plan `c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b`, deliver as CSV, no expiry."
> "Generate 200 access codes for the Premium plan with a 1-year expiry, deliver as messages. I acknowledge the Telegram per-code delivery."
### Related
- [preview_access_code_cost](https://docs.subscriby.net/mcp/v1/tools/access-code#preview-access-code-cost)
- [get_job_status](https://docs.subscriby.net/mcp/v1/tools/observability#get-job-status)
- [list_access_codes](https://docs.subscriby.net/mcp/v1/tools/access-code#list-access-codes)
- [Access Codes API](https://docs.subscriby.net/api/v1/reference/access-codes)
## list_access_codes
Paginated list of access codes for a plan, with first/last-4 masked prefixes only — full codes are never surfaced.
List access codes for a plan. Requires `plan_id`, accepts an optional `status` filter (`unredeemed`, `redeemed`, `expired`, or `all`). Results are scoped to the caller's team.
> **Note**
>
> Codes are returned with a masked prefix (first 4 + asterisks + last 4). The
> plaintext value is delivered to the creator's connector chat by
> [`bulk_generate_access_codes`](https://docs.subscriby.net/mcp/v1/tools/access-code#bulk-generate-access-codes) — the
> MCP layer is read-only on the secret itself.
- Requires ability: `project-access-code:view-any`
- Runs the same action as [`GET /v1/projects/{project}/plans/{plan}/access-codes`](https://docs.subscriby.net/api/v1/reference/access-codes#list-a-plans-access-codes)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | yes | UUID of the plan to list codes for. |
| `status` | `all`, `unredeemed`, `redeemed`, `expired` | no | Filter by code state. One of: unredeemed, redeemed, expired, all. |
| `limit` | integer | no | Maximum codes to return per page (1..100). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_access_codes",
"arguments": {
"plan_id": "789c4fba-32b8-4800-a195-4c9fd62c9ecf"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"code_masked": "A3F1****7Z9P",
"redeemed_at": null,
"expires_at": "2026-08-01T00:00:00Z",
"redeemed_by": null,
"created_at": "2026-05-18T10:05:00Z"
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 47,
"has_more": true,
"status": "unredeemed"
}
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks project-access-code:view-any.
### Example prompts
> "How many access codes are still unredeemed on plan `c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b`?"
> "List expired access codes for the Premium plan."
### Related
- [bulk_generate_access_codes](https://docs.subscriby.net/mcp/v1/tools/access-code#bulk-generate-access-codes)
- [preview_access_code_cost](https://docs.subscriby.net/mcp/v1/tools/access-code#preview-access-code-cost)
- [Access Codes API](https://docs.subscriby.net/api/v1/reference/access-codes)
## preview_access_code_cost
Preview the cost of generating access codes before queuing the batch. Returns free_remaining, chargeable_quantity, and cost.
Preview the cost of generating access codes **before** queuing the batch. Returns `free_remaining`, `chargeable_quantity`, and the resulting `cost` in the creator-tier currency so the agent can surface the charge to a human. Always call this first when quantity might exceed the creator tier free allotment — the generation tool refuses to run with chargeable codes unless the caller acknowledges overage.
- Requires ability: `project-access-code:create`
- Runs the same action as [`GET /v1/projects/{project}/plans/{plan}/access-codes/preview`](https://docs.subscriby.net/api/v1/reference/access-codes#price-a-batch)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | yes | UUID of the plan the batch would be attached to. |
| `quantity` | integer | yes | How many access codes the agent is considering generating (1-10000). |
### Example call
```json
{
"name": "preview_access_code_cost",
"arguments": {
"plan_id": "789c4fba-32b8-4800-a195-4c9fd62c9ecf",
"quantity": 1
}
}
```
### What it returns
```json
{
"data": {
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"quantity": 50,
"preview": {
"free_remaining": 3,
"chargeable_quantity": 47,
"cost": "1.41"
},
"will_bill_overage": true
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown plan_id, or the plan belongs to a team outside the token's scope.
- `VALIDATION_FAILED` — quantity is outside the 1..10,000 range.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-access-code:create.
### Example prompts
> "Preview the cost of generating 200 access codes for plan `c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b`."
> "Before I hand out codes, tell me whether this would trigger an overage charge."
### Related
- [bulk_generate_access_codes](https://docs.subscriby.net/mcp/v1/tools/access-code#bulk-generate-access-codes)
- [list_access_codes](https://docs.subscriby.net/mcp/v1/tools/access-code#list-access-codes)
- [Access Codes API](https://docs.subscriby.net/api/v1/reference/access-codes)
---
# Account Tools
Source: https://docs.subscriby.net/mcp/v1/tools/account
The account behind the token: who is acting, which team the session is scoped to and what it may do. These tools answer that in one call and manage the account's own settings.
## Tools
- [`get_me`](#get-me) — Get Me (read)
- [`list_notifications`](#list-notifications) — List Notifications (read)
- [`mark_all_notifications_read`](#mark-all-notifications-read) — Mark All Notifications Read (write)
- [`mark_notification_read`](#mark-notification-read) — Mark Notification Read (write)
## get_me
Describe the creator the token belongs to — plan and capabilities, the team the token is scoped to, every team held, connected accounts and alert destinations. Takes no input.
Learn who the agent is acting as before doing anything else: the creator, the team the token is scoped to (`current_team.id`, the id the other tools act in), every team they belong to, the platform plan and the capabilities it grants, the accounts linked on the connectors, and where alerts go. The payload is exactly what [`GET /v1/me`](https://docs.subscriby.net/api/v1/reference/me) returns.
- Requires ability: `account:read`
- Runs the same action as [`GET /v1/me/alert-destinations`](https://docs.subscriby.net/api/v1/reference/alert-destinations#list-your-alert-destinations)
- Runs the same action as [`GET /v1/me/identities`](https://docs.subscriby.net/api/v1/reference/identities#list-the-callers-linked-accounts)
- Runs the same action as [`GET /v1/me`](https://docs.subscriby.net/api/v1/reference/me#get-the-callers-account)
- Runs the same action as [`GET /v1/teams/current`](https://docs.subscriby.net/api/v1/reference/teams#get-the-current-team)
- Annotations: Read-only
### Arguments
This tool takes no arguments.
### Example call
```json
{
"name": "get_me",
"arguments": {}
}
```
### What it returns
```json
{
"data": {
"id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"name": "Ada Lovelace",
"email": "ada@example.com",
"locale": "en",
"email_verified": true,
"plan": "growth",
"capabilities": {
"no_branding": true,
"custom_handle": true,
"time_limited_passes": true,
"coupons": true,
"free_plans": true,
"teams": true,
"disaster_recovery_prevention": true,
"multi_connector": true
},
"current_team": {
"id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"name": "Analytical Engines",
"owner_user_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"personal": true,
"created_at": "2026-05-18T10:05:00Z"
},
"teams": [{ "id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86", "name": "Analytical Engines", "owner_user_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80", "personal": true, "created_at": "2026-05-18T10:05:00Z" }],
"identities": [{ "id": "0c2d8a7e-4b1f-4d3e-9a6b-2f5e8c1d7a90", "connector": "telegram", "purpose": "primary", "source": "handshake", "external_id": "123456789", "display_name": "Ada Lovelace", "username": "ada", "linked_at": "2026-09-12T10:05:00Z", "verified_at": "2026-09-12T10:05:00Z" }],
"alert_destinations": [],
"alert_destinations_default": true,
"created_at": "2026-05-18T10:05:00Z"
}
}
```
`alert_destinations_default` is `true` while the creator has written no destination: every class of alert then reaches their primary connected account, and the critical classes their email. `plan` is `null` without an active platform subscription.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks account:read.
### Example prompts
> "Who am I signed in as, and which team will you act in?"
> "Do I have coupons and free plans on my current plan?"
> "Which accounts are linked to my Subscriby account, and where do my alerts go?"
### Related
- [list_teams](https://docs.subscriby.net/mcp/v1/tools/team#list-teams)
- [get_team](https://docs.subscriby.net/mcp/v1/tools/team#get-team)
- [Me API](https://docs.subscriby.net/api/v1/reference/me)
- [Alert Destinations API](https://docs.subscriby.net/api/v1/reference/alert-destinations)
## list_notifications
Read the notification centre of the creator the token acts for — every alert sent to them, newest first, with its class, where it points and whether it was read.
Answer "what has happened on my account that I have not looked at?". The Notifications Center is the sidebar entry with its unread count and the page at `/notifications`: every alert Subscriby sent the creator (a sale, a support backlog, a bot or channel that went silent, a billing or security notice, a pass window, an onboarding nudge). Each row is the same object [`GET /v1/me/notifications`](https://docs.subscriby.net/api/v1/reference/notifications) returns; `meta.unread` says how many are unread in all.
- Requires ability: `account:read`
- Runs the same action as [`GET /v1/me/notifications`](https://docs.subscriby.net/api/v1/reference/notifications#list-the-callers-notifications)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `unread` | boolean | no | `true` for entries not yet read, `false` for entries already read; omit for every entry. |
| `class` | `sales`, `support`, `recovery`, `billing`, `security`, `passes`, `onboarding` | no | One class of alert, or omit for every class. |
| `query` | string | no | Words a title or body must contain; omit for every entry. |
| `limit` | integer | no | Entries per page (1..100, default 25). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_notifications",
"arguments": {
"unread": true,
"class": "sales"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "0b1f6e2a-7c3d-4e5f-8a9b-0c1d2e3f4a5b",
"class": "support",
"class_label": "Support",
"title": "Support backlog in Signals",
"body": "Three members are waiting for an answer.",
"route": "projects.inbox",
"params": { "projectId": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13" },
"url": "https://app.subscriby.net/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/inbox",
"read": false,
"read_at": null,
"created_at": "2026-09-12T10:05:30Z"
}
],
"meta": { "page": 1, "limit": 25, "total": 1, "has_more": false, "unread": 1 }
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks account:read.
- `VALIDATION_FAILED` — class is not one of the seven classes.
### Example prompts
> "Anything I should look at? Show me my unread notifications."
> "List the recovery notifications from this week."
### Related
- [mark_notification_read](https://docs.subscriby.net/mcp/v1/tools/account#mark-notification-read) — clear one entry.
- [mark_all_notifications_read](https://docs.subscriby.net/mcp/v1/tools/account#mark-all-notifications-read) — clear the backlog.
- [get_me](https://docs.subscriby.net/mcp/v1/tools/account#get-me) — the creator, with unread_notifications.
- [Notifications API](https://docs.subscriby.net/api/v1/reference/notifications)
## mark_all_notifications_read
Mark every unread entry of the creator's notification centre as read in one call.
The bell's "Mark all as read" for agents: every unread entry is marked read in one statement and the tool answers how many it marked. Idempotent: a second call marks nothing and answers `0`. Use it when the creator has caught up; to clear one entry use [`mark_notification_read`](https://docs.subscriby.net/mcp/v1/tools/account#mark-notification-read).
- Requires ability: `account:write`
- Runs the same action as [`POST /v1/me/notifications/read-all`](https://docs.subscriby.net/api/v1/reference/notifications#mark-every-notification-read)
- Annotations: Idempotent
### Arguments
This tool takes no arguments.
### Example call
```json
{
"name": "mark_all_notifications_read",
"arguments": {}
}
```
### What it returns
```json
{
"data": { "marked": 3 },
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks account:write.
### Example prompts
> "Clear my notifications, I've read them all."
### Related
- [list_notifications](https://docs.subscriby.net/mcp/v1/tools/account#list-notifications)
- [mark_notification_read](https://docs.subscriby.net/mcp/v1/tools/account#mark-notification-read)
- [Notifications API](https://docs.subscriby.net/api/v1/reference/notifications)
## mark_notification_read
Mark one entry of the creator's notification centre as read.
The bell's click for agents: one entry of the creator's notification centre is marked read, so the dashboard bell stops counting it. Idempotent: an entry already read keeps its first `read_at`. Take the id from [`list_notifications`](https://docs.subscriby.net/mcp/v1/tools/account#list-notifications).
- Requires ability: `account:write`
- Runs the same action as [`POST /v1/me/notifications/{notification}/read`](https://docs.subscriby.net/api/v1/reference/notifications#mark-a-notification-read)
- Annotations: Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `notification_id` | string | yes | UUID of the entry to mark read, from `list_notifications`. |
### Example call
```json
{
"name": "mark_notification_read",
"arguments": {
"notification_id": "b29f2396-f113-4000-aca8-5a2f9827393a"
}
}
```
### What it returns
```json
{
"data": {
"id": "0b1f6e2a-7c3d-4e5f-8a9b-0c1d2e3f4a5b",
"class": "support",
"class_label": "Support",
"title": "Support backlog in Signals",
"body": "Three members are waiting for an answer.",
"route": "projects.inbox",
"params": { "projectId": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13" },
"url": "https://app.subscriby.net/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/inbox",
"read": true,
"read_at": "2026-09-12T10:35:00Z",
"created_at": "2026-09-12T10:05:30Z"
},
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks account:write.
- `RESOURCE_NOT_FOUND` — the id is not one of this creator's entries.
### Example prompts
> "I've dealt with the support backlog one — mark it read."
### Related
- [list_notifications](https://docs.subscriby.net/mcp/v1/tools/account#list-notifications)
- [mark_all_notifications_read](https://docs.subscriby.net/mcp/v1/tools/account#mark-all-notifications-read)
- [Notifications API](https://docs.subscriby.net/api/v1/reference/notifications)
---
# Analytics & Reports Tools
Source: https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports
Every figure the creator dashboard renders is readable here, from revenue and churn to the transaction list, computed by the same service layer, so an agent and the dashboard never disagree. All of these tools answer to the dashboard:read ability.
## Tools
- [`get_connector_analytics`](#get-connector-analytics) — Connector Analytics (analytics)
- [`get_dashboard_metrics`](#get-dashboard-metrics) — Headline Dashboard Metrics (analytics)
- [`get_earnings_report`](#get-earnings-report) — Earnings Report (Timeseries) (analytics)
- [`get_plan_performance`](#get-plan-performance) — Per-Plan Performance (analytics)
- [`get_revenue_composition`](#get-revenue-composition) — Revenue Composition (analytics)
- [`get_subscriber_analytics`](#get-subscriber-analytics) — Subscriber Analytics (analytics)
- [`get_transaction_breakdown`](#get-transaction-breakdown) — Transaction Breakdown by Dimension (analytics)
- [`list_transactions`](#list-transactions) — List Transactions (Keyset) (read)
## get_connector_analytics
Members, access and revenue per connector — which connector earns, and which one members actually use.
Return one row per connector the creator's projects run or ever granted access on: the live installations, the members holding a linked account (and how many linked one in the window), the live and pending grants, the grants issued and revoked in the window, the gross revenue in USD with its transaction count and its share of the total. The same figures the dashboards' **By Connector** panel and [`GET /v1/analytics/connectors`](https://docs.subscriby.net/api/v1/reference/analytics#by-connector) show. A purchase that grants access on two connectors counts toward both; `attribution` says so in the creator's language.
- Requires ability: `dashboard:read`
- Runs the same action as [`GET /v1/analytics/connectors`](https://docs.subscriby.net/api/v1/reference/analytics#by-connector)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID to scope the figures. Leave blank for all projects in the current team. |
| `period` | string | no | Period preset — 7d, 14d, 30d, 60d, 90d, mtd, qtd, ytd, 1y, all. Defaults to 30d. |
| `from` | string | no | Explicit start date (ISO YYYY-MM-DD). Overrides period when paired with "to". |
| `to` | string | no | Explicit end date (ISO YYYY-MM-DD). Overrides period when paired with "from". |
### Example call
```json
{
"name": "get_connector_analytics",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"period": ""
}
}
```
### What it returns
```json
{
"data": {
"range": { "from": "2026-08-13", "to": "2026-09-12" },
"total_gross_usd": 1682.0,
"attribution": "A purchase that grants access on two connectors counts toward both.",
"connectors": [
{
"key": "telegram",
"name": "Telegram",
"installations": 2,
"members": 241,
"members_linked_in_window": 18,
"live_grants": 236,
"pending_grants": 3,
"granted_in_window": 21,
"revoked_in_window": 4,
"gross_usd": 1682.0,
"transactions": 58,
"share_percent": 100
}
]
},
"meta": { "period": "30d", "project_id": null }
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks dashboard:read.
### Example prompts
> "Which connector brought in the most revenue this quarter?"
> "How many members are on Telegram versus Discord in the Signals project?"
### Related
- [get_dashboard_metrics](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-dashboard-metrics)
- [get_subscriber_analytics](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-subscriber-analytics)
- [list_connectors](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connectors) — the connectors themselves.
- [Analytics API](https://docs.subscriby.net/api/v1/reference/analytics)
## get_dashboard_metrics
Return headline dashboard metrics — MRR, active subscribers, churn, and plan-level breakdown — with a period-over-period comparison.
Return headline dashboard metrics for the current creator: MRR, active subscribers, churn, and a plan-level breakdown. Delegates to the same analytics service that powers the Livewire dashboard, so responses ride the same 5-minute cache — agent calls warm the human-facing view and vice versa.
- Requires ability: `dashboard:read`
- Runs the same action as [`GET /v1/analytics/dashboard`](https://docs.subscriby.net/api/v1/reference/analytics#headline-figures)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID to scope the metrics to. Leave blank for all projects in the current team. |
| `period` | string | no | Period preset — 7d, 14d, 30d, 60d, 90d, mtd, qtd, ytd, 1y, all. Defaults to 30d. Ignored when both from and to are supplied. |
| `from` | string | no | Explicit start date (ISO YYYY-MM-DD). Overrides period when paired with "to". |
| `to` | string | no | Explicit end date (ISO YYYY-MM-DD). Overrides period when paired with "from". |
| `compare_period` | string | no | Baseline to compare against — "previous" (prior window) or "none". Defaults to "previous". |
| `plan_ids` | array of any | no | Optional plan-UUID allow-list. |
| `statuses` | array of any | no | Optional SubscriptionStatus allow-list. |
| `payment_method_ids` | array of any | no | Optional payment-method UUID allow-list. |
### Example call
```json
{
"name": "get_dashboard_metrics",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"period": ""
}
}
```
### What it returns
```json
{
"data": {
"mrr": "5280.00",
"active_subscribers": 182,
"new_subscribers": 14,
"churn_rate": 3.2,
"by_plan": [
{
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"name": "Premium",
"active": 120
}
]
},
"meta": {
"period": "30d",
"compare_period": "previous",
"project_id": null
}
}
```
`mrr` is recurring monthly income from active subscriptions on recurring plans, normalized to a monthly figure from each plan's billing cycle. One-time and lifetime plans do not contribute.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks dashboard:read.
### Example prompts
> "Show me MRR, active subscribers, and churn for the last 30 days across all my projects."
> "Give me headline metrics month-to-date for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
### Related
- [get_earnings_report](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-earnings-report)
- [get_subscriber_analytics](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-subscriber-analytics)
- [get_transaction_breakdown](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-transaction-breakdown)
- [get_plan_performance](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-plan-performance)
- [Analytics reference](https://docs.subscriby.net/api/v1/reference/analytics)
## get_earnings_report
Full earnings report — totals plus a day, week, or month timeseries. Accepts explicit from/to dates or period presets.
Return a full earnings report for the creator — totals (gross, fees, net, transactions) plus a timeseries bucketed by day, week, or month. Accepts period presets or explicit `from` / `to` ISO dates; if both are supplied, the explicit dates win.
- Requires ability: `dashboard:read`
- Runs the same action as [`GET /v1/analytics/earnings`](https://docs.subscriby.net/api/v1/reference/analytics#earnings-report)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID to scope the report. Leave blank for all projects in the current team. |
| `period` | string | no | Period preset — 7d, 14d, 30d, 60d, 90d, mtd, qtd, ytd, 1y, all. Defaults to 30d. Ignored when both from and to are supplied. |
| `from` | string | no | Explicit start date (ISO YYYY-MM-DD). Overrides period when paired with "to". |
| `to` | string | no | Explicit end date (ISO YYYY-MM-DD). Overrides period when paired with "from". |
| `granularity` | string | no | Timeseries bucket size — day, week, month. Defaults to day. |
| `plan_ids` | array of any | no | Optional plan-UUID allow-list. |
| `statuses` | array of any | no | Optional SubscriptionStatus allow-list applied to parent subscriptions. |
| `payment_method_ids` | array of any | no | Optional payment-method UUID allow-list applied to parent subscriptions. |
### Example call
```json
{
"name": "get_earnings_report",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"period": ""
}
}
```
### What it returns
```json
{
"data": {
"totals": {
"gross": "4821.50",
"fees": "193.27",
"net": "4628.23",
"transactions": 184
},
"timeseries": [
{
"bucket": "2026-03-22",
"gross": "145.00",
"net": "139.20",
"transactions": 6
}
]
},
"meta": {
"period": "30d",
"granularity": "day"
}
}
```
### How it fails
- `VALIDATION_FAILED` — granularity is not one of day, week, month.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks dashboard:read.
### Example prompts
> "Show weekly earnings year-to-date across all projects."
> "What's the total revenue for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` from 2026-01-01 to 2026-03-31, broken down by month?"
### Related
- [get_dashboard_metrics](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-dashboard-metrics)
- [get_transaction_breakdown](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-transaction-breakdown)
- [get_plan_performance](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-plan-performance)
- [Creator dashboard analytics](https://docs.subscriby.net/creators/dashboard-analytics)
## get_plan_performance
Per-plan performance metrics for a project — active subscribers, revenue in window, and average ticket size.
Return one row per plan in a project with active-subscriber count, successful-payment count and revenue in the requested window, and an average ticket size. Accepts period presets or explicit `from` / `to` ISO dates.
- Requires ability: `dashboard:read`
- Runs the same action as [`GET /v1/analytics/plan-performance`](https://docs.subscriby.net/api/v1/reference/analytics#plan-performance)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project to report on. |
| `period` | string | no | Period preset — 7d, 14d, 30d, 60d, 90d, mtd, qtd, ytd, 1y, all. Defaults to 30d. Ignored when both from and to are supplied. |
| `from` | string | no | Explicit start date (ISO YYYY-MM-DD). Overrides period when paired with "to". |
| `to` | string | no | Explicit end date (ISO YYYY-MM-DD). Overrides period when paired with "from". |
### Example call
```json
{
"name": "get_plan_performance",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": [
{
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"plan_name": "Premium Monthly",
"active_subscribers": 120,
"payments_in_window": 98,
"revenue_in_window": "2842.00",
"avg_ticket": "29.00"
}
],
"meta": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"range": {
"from": "2026-03-22",
"to": "2026-04-22"
}
}
}
```
### How it fails
- `VALIDATION_FAILED` — missing project_id.
- `TOKEN_MISSING_ABILITY` — token lacks dashboard:read.
### Example prompts
> "Which plans on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` generated the most revenue in the last 30 days?"
> "Plan performance for my Research Premium project from 2026-01-01 to 2026-03-31."
### Related
- [get_dashboard_metrics](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-dashboard-metrics)
- [get_earnings_report](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-earnings-report)
- [get_transaction_breakdown](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-transaction-breakdown)
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans)
- [Analytics reference](https://docs.subscriby.net/api/v1/reference/analytics)
## get_revenue_composition
The dashboards' donuts — fees by provider, transactions by plan kind, revenue by currency, payment outcomes and MRR by plan.
Return how the window's revenue and payments are composed, as the five donuts the dashboards draw: transaction fees by payment provider, settled transactions by plan kind (subscription, one-time, lifetime, pass), gross revenue by currency in USD, payment attempts by outcome (succeeded, pending, failed) and, unwindowed because it is a balance, the monthly recurring revenue split by plan. The same figures [`GET /v1/analytics/composition`](https://docs.subscriby.net/api/v1/reference/analytics#revenue-composition) returns. Every dataset carries `total`, `unit` (`usd` or `count`) and `slices` sorted largest first, each slice with its `key`, `label`, `value`, `share_percent` and chart `color`.
- Requires ability: `dashboard:read`
- Runs the same action as [`GET /v1/analytics/composition`](https://docs.subscriby.net/api/v1/reference/analytics#revenue-composition)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID to scope the figures. Leave blank for all projects in the current team. |
| `period` | string | no | Period preset — 7d, 14d, 30d, 60d, 90d, mtd, qtd, ytd, 1y, all. Defaults to 30d. MRR by plan ignores it: it is a balance, not a flow. |
| `from` | string | no | Explicit start date (ISO YYYY-MM-DD). Overrides period when paired with "to". |
| `to` | string | no | Explicit end date (ISO YYYY-MM-DD). Overrides period when paired with "from". |
### Example call
```json
{
"name": "get_revenue_composition",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"period": ""
}
}
```
### What it returns
```json
{
"data": {
"range": { "from": "2026-08-13", "to": "2026-09-12" },
"fees_by_provider": {
"total": 84.1,
"unit": "usd",
"slices": [
{
"key": "stripe",
"label": "Stripe",
"value": 61.4,
"share_percent": 73.01,
"color": "blue"
},
{
"key": "paypal",
"label": "PayPal",
"value": 22.7,
"share_percent": 26.99,
"color": "violet"
}
]
},
"transactions_by_kind": {
"total": 58,
"unit": "count",
"slices": [
{
"key": "subscription",
"label": "Subscription",
"value": 41,
"share_percent": 70.69,
"color": "blue"
},
{
"key": "pass",
"label": "Pass",
"value": 17,
"share_percent": 29.31,
"color": "violet"
}
]
},
"revenue_by_currency": {
"total": 1682.0,
"unit": "usd",
"slices": [
{
"key": "USD",
"label": "USD",
"value": 1490.0,
"share_percent": 88.59,
"color": "blue"
},
{
"key": "EUR",
"label": "EUR",
"value": 192.0,
"share_percent": 11.41,
"color": "violet"
}
]
},
"payment_outcomes": {
"total": 63,
"unit": "count",
"slices": [
{
"key": "succeeded",
"label": "Succeeded",
"value": 58,
"share_percent": 92.06,
"color": "emerald"
},
{
"key": "failed",
"label": "Failed",
"value": 4,
"share_percent": 6.35,
"color": "rose"
},
{
"key": "pending",
"label": "Pending",
"value": 1,
"share_percent": 1.59,
"color": "amber"
}
]
},
"mrr_by_plan": {
"total": 4120.0,
"unit": "usd",
"slices": [
{
"key": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"label": "Gold",
"value": 2980.0,
"share_percent": 72.33,
"color": "blue"
},
{
"key": "0c1e7a54-2f6b-4d8e-9a3c-1b5d7e9f2a46",
"label": "Silver",
"value": 1140.0,
"share_percent": 27.67,
"color": "violet"
}
]
}
},
"meta": {
"period": "30d",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13"
}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks dashboard:read.
### Example prompts
> "Where did the transaction fees go last month, by payment provider?"
> "How many charges failed this quarter, and which plans carry the MRR?"
### Related
- [get_dashboard_metrics](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-dashboard-metrics)
- [get_transaction_breakdown](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-transaction-breakdown) — gross revenue grouped by one dimension.
- [get_plan_performance](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-plan-performance)
- [Analytics API](https://docs.subscriby.net/api/v1/reference/analytics)
## get_subscriber_analytics
Subscriber-centric analytics — new signups, cancellations, churn rate, trial conversion, and status distribution for a window.
Return subscriber-centric analytics for the creator — new signups, cancellations, churn rate, trial-to-paid conversion, and a status-distribution snapshot. Accepts period presets or explicit `from` / `to` ISO dates.
- Requires ability: `dashboard:read`
- Runs the same action as [`GET /v1/analytics/subscribers`](https://docs.subscriby.net/api/v1/reference/analytics#subscriber-analytics)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID to scope the analytics. Leave blank for all projects in the current team. |
| `period` | string | no | Period preset — 7d, 14d, 30d, 60d, 90d, mtd, qtd, ytd, 1y, all. Defaults to 30d. |
| `from` | string | no | Explicit start date (ISO YYYY-MM-DD). Overrides period when paired with "to". |
| `to` | string | no | Explicit end date (ISO YYYY-MM-DD). Overrides period when paired with "from". |
### Example call
```json
{
"name": "get_subscriber_analytics",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"period": ""
}
}
```
### What it returns
```json
{
"data": {
"new_subscribers": 42,
"canceled_subscribers": 7,
"churn_rate_percent": 2.2,
"trial_started": 15,
"trial_converted": 11,
"trial_to_paid_percent": 73.33,
"status_distribution": {
"active": 341,
"trialing": 4,
"canceled": 22
}
},
"meta": { "period": "30d" }
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks dashboard:read.
### Example prompts
> "What's my churn rate this quarter?"
> "Show the trial-to-paid conversion rate year-to-date for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
### Related
- [get_dashboard_metrics](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-dashboard-metrics)
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers)
- [get_plan_performance](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-plan-performance)
- [Creator dashboard analytics](https://docs.subscriby.net/creators/dashboard-analytics)
## get_transaction_breakdown
USD-normalized successful-payment totals split by plan, payment_provider, currency, or project. Includes share percent per group.
Split USD-normalized successful-payment totals by one dimension for the requested window. Supported dimensions: `plan`, `payment_provider`, `currency`, `project`. Each group carries `gross_usd`, `transactions`, and `share_percent` so agents can answer "which provider drove the most revenue?" in one call.
- Requires ability: `dashboard:read`
- Runs the same action as [`GET /v1/analytics/transactions/breakdown`](https://docs.subscriby.net/api/v1/reference/analytics#transaction-breakdown)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID to scope the breakdown. Leave blank for all projects in the current team. |
| `dimension` | string | yes | Required. Grouping dimension — plan, payment_provider, currency, or project. |
| `period` | string | no | Period preset — 7d, 14d, 30d, 60d, 90d, mtd, qtd, ytd, 1y, all. Defaults to 30d. |
| `from` | string | no | Explicit start date (ISO YYYY-MM-DD). Overrides period when paired with "to". |
| `to` | string | no | Explicit end date (ISO YYYY-MM-DD). Overrides period when paired with "from". |
| `plan_ids` | array of any | no | Optional plan-UUID allow-list. |
| `statuses` | array of any | no | Optional SubscriptionStatus allow-list applied to parent subscriptions. |
| `payment_method_ids` | array of any | no | Optional payment-method UUID allow-list applied to parent subscriptions. |
### Example call
```json
{
"name": "get_transaction_breakdown",
"arguments": {
"dimension": ""
}
}
```
### What it returns
```json
{
"data": {
"total_gross_usd": "4821.50",
"groups": [
{
"key": "stripe",
"label": "Stripe",
"gross_usd": "2891.00",
"transactions": 110,
"share_percent": 59.96
}
]
},
"meta": {
"period": "30d",
"dimension": "payment_provider"
}
}
```
### How it fails
- `VALIDATION_FAILED` — dimension is missing or not one of the allowed values.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks dashboard:read.
### Example prompts
> "Break down last month's earnings by payment provider."
> "Revenue by currency for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` year-to-date."
### Related
- [get_earnings_report](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-earnings-report)
- [get_plan_performance](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-plan-performance)
- [list_transactions](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#list-transactions)
- [Creator dashboard analytics](https://docs.subscriby.net/creators/dashboard-analytics)
## list_transactions
Keyset-paginated transaction listing for one project with date, status, provider, plan, and currency filters. Reverse-chronological.
Row-level transaction listing scoped to a single project. Filters by date range (explicit `from` / `to` or period preset), payment status, provider, plan, and currency. Reverse-chronological by `occurred_at` with opaque keyset pagination via `meta.next_cursor`. Each row carries its owning project, plan and subscriber, so a payment can be attributed without a follow-up lookup. No raw webhook payloads are returned.
- Requires ability: `project-subscription:view-any`
- Runs the same action as [`GET /v1/analytics/transactions`](https://docs.subscriby.net/api/v1/reference/analytics#list-transactions)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose transactions to list. |
| `period` | string | no | Period preset — 7d, 14d, 30d, 60d, 90d, mtd, qtd, ytd, 1y, all. Defaults to 30d. |
| `from` | string | no | Explicit start date (ISO YYYY-MM-DD). Overrides period when paired with "to". |
| `to` | string | no | Explicit end date (ISO YYYY-MM-DD). Overrides period when paired with "from". |
| `statuses` | array of any | no | Optional payment-status allow-list. Values: successful, failed, pending, refunded. |
| `provider_ids` | array of any | no | Optional payment-method (provider) UUID allow-list. |
| `plan_ids` | array of any | no | Optional plan-UUID allow-list applied to the parent subscription. |
| `currency_ids` | array of any | no | Optional currency UUID allow-list. |
| `cursor` | string | no | Opaque keyset cursor returned in a prior meta.next_cursor. Pass to continue pagination. |
| `limit` | integer | no | Maximum rows to return (1..200, default 50). |
### Example call
```json
{
"name": "list_transactions",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "8b0c4a15-e792-4360-95d8-1f47c0b3e926",
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"project_name": "Beautiful Mouths",
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"plan_name": "Monthly",
"subscriber_id": "6f9b2e37-c184-4a05-8d72-30e16bc9f458",
"subscriber_name": "Ada Lovelace",
"status": "successful",
"amount": "29.00",
"currency": "USD",
"provider": "stripe",
"provider_label": "Stripe",
"method_id": "a15d70c8-3e46-4b92-b70f-58c9d2140e63",
"calculated_fee": "1.16",
"billing_reason": "subscription_cycle",
"external_payment_id": "pi_3Nxy...",
"occurred_at": "2026-04-22T10:05:00Z"
}
],
"meta": {
"next_cursor": "MjAyNi0wNC...",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13"
}
}
```
### How it fails
- `VALIDATION_FAILED` — missing project_id.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:view-any.
### Example prompts
> "Show me the last 100 successful transactions in project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "List refunds issued between 2026-01-01 and 2026-03-31."
### Related
- [list_recent_payments](https://docs.subscriby.net/mcp/v1/tools/payment#list-recent-payments) — simpler, no date-range filters.
- [get_transaction_breakdown](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-transaction-breakdown) — aggregate view of the same data.
- [get_earnings_report](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-earnings-report)
---
# Connector Tools
Source: https://docs.subscriby.net/mcp/v1/tools/connectors
A project runs on connectors: the platforms it gates access on, messages through and takes payments from. These tools browse the directory, install and remove connectors on a project, verify an installation, read a bot's status and change an installation's settings.
## Tools
- [`disconnect_connector`](#disconnect-connector) — Disconnect Connector (destructive)
- [`get_connector`](#get-connector) — Get Connector (read)
- [`get_connector_installation`](#get-connector-installation) — Get Connector Installation (read)
- [`get_connector_uninstall_preview`](#get-connector-uninstall-preview) — Get Connector Uninstall Preview (read)
- [`get_deep_link`](#get-deep-link) — Build Bot Deep Link (read)
- [`get_portal_url`](#get-portal-url) — Build Subscriber Portal URL (read)
- [`install_connector`](#install-connector) — Install Connector (write)
- [`list_connector_installations`](#list-connector-installations) — List Connector Installations (read)
- [`list_connectors`](#list-connectors) — List Connectors (read)
- [`restore_connector_access`](#restore-connector-access) — Restore Connector Access (write)
- [`run_connector_doctor`](#run-connector-doctor) — Run Connector Doctor (write)
- [`uninstall_connector`](#uninstall-connector) — Uninstall Connector (destructive)
- [`update_connector_installation_settings`](#update-connector-installation-settings) — Update Connector Installation Settings (write)
- [`verify_connector_installation`](#verify-connector-installation) — Verify Connector Installation (write)
## disconnect_connector
Disconnect a project's installation of one connector — the connector withdraws it, the credentials are wiped, the row stays. Destructive.
Detaches a project's live installation of one connector. The connector withdraws the installation on its platform, the credentials are wiped and the installation turns `disconnected`. Members keep the access they hold; grants, resources, plans and identities are untouched; the creator can connect it again from the dashboard. An installation already disconnected is left alone. The REST twin is [`DELETE /v1/projects/{project}/connectors/{key}/installation`](https://docs.subscriby.net/api/v1/reference/connectors#disconnect-an-installation).
- Requires ability: `project-connector:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/connectors/{key}/installation`](https://docs.subscriby.net/api/v1/reference/connectors#disconnect-an-installation)
- Fires events: [`connector.disconnected`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-disconnected)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project. |
| `connector` | string | yes | The connector key, as `list_connectors` returns it. |
### Example call
```json
{
"name": "disconnect_connector",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"connector": ""
}
}
```
### What it returns
```json
{
"data": {
"installation_id": "3b8f0c6e-2d41-4a97-9e5f-1c7d6b2a8e40",
"connector": "telegram",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"disconnected": true
},
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:delete.
- `RESOURCE_NOT_FOUND` — the project is not visible to this token, or context.reason is unknown_connector.
- `CONNECTOR_NOT_INSTALLED` — the project has no installation of the connector.
### Events emitted
- [`connector.disconnected`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-disconnected).
### Example prompts
> "Disconnect the Telegram bot from Research Premium; I'll connect a new one tomorrow."
### Related
- [install_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#install-connector) — the row stays, so reinstalling is not needed; connect again from the dashboard.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors)
## get_connector
Read one Connectors Marketplace card by key — its lane, badges and, for a connector that exists as a package, its manifest and the form that connects it.
Answer "what can this connector do?" or "what does connecting it ask the creator for?" for one connector. The card is the same [`list_connectors`](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connectors) returns: `status` and `installable`, `official` and the `badges`, the category, tagline, overview and links, and for a registered connector the manifest with its `resource_kinds`, `capabilities`, `messaging` limits, `pacing`, `management_commands` and `missing_commands`, `recovery` facets and the declarative `install_fields` and `settings_fields`. A roadmap entry (`coming_soon`) answers too, with every manifest key `null` or empty. The REST twin is [`GET /v1/connectors/{key}`](https://docs.subscriby.net/api/v1/reference/connectors#get-one-connector).
- Requires ability: `project-connector:view-any`
- Runs the same action as [`GET /v1/connectors/{key}`](https://docs.subscriby.net/api/v1/reference/connectors#get-one-connector)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `connector` | string | yes | The connector key, as `list_connectors` returns it. |
### Example call
```json
{
"name": "get_connector",
"arguments": {
"connector": ""
}
}
```
### What it returns
```json
{
"data": {
"key": "telegram",
"name": "Telegram",
"vendor": "Subscriby",
"version": "1.0.0",
"status": "available",
"status_label": "Available Now",
"installable": true,
"official": true,
"badges": [{ "key": "official", "label": "Official" }],
"category": "messaging",
"tagline": "Sell access to Telegram channels, groups and supergroups through your own bot.",
"links": {
"documentation": "https://docs.subscriby.net/connectors/telegram",
"support": "mailto:support@subscriby.net",
"privacy": "https://telegram.org/privacy",
"terms": "https://telegram.org/tos",
"homepage": "https://telegram.org"
},
"added_at": "2025-04-21",
"install_mode": "paste_credential",
"scopes": ["project", "platform"],
"resource_kinds": [
{
"kind": "channel",
"label": "Channel",
"portal_label": "Channel",
"icon": "megaphone",
"grant_mode": "bearer_link",
"supports_early_admission_hold": true
}
],
"capabilities": [
"messaging",
"access_control",
"management_surface",
"recovery_probes"
],
"messaging": {
"max_length": 4096,
"buttons_per_row": 8,
"max_buttons": 100,
"callback_data_bytes": 64,
"supports_underline": true,
"supports_spoiler": true,
"supports_files": true
},
"pacing": {
"min_interval_microseconds": 35000,
"burst": 30,
"per_recipient_interval_microseconds": 1000000
},
"management_commands": ["project_create", "plan_manage"],
"missing_commands": ["creator_tasks"],
"recovery": {
"probes": true,
"standby_installations": true,
"resource_standby": false,
"mirror": false,
"identity_relink": false
},
"install_fields": [
{
"name": "token",
"type": "secret",
"label": "Bot token",
"help": "Paste the token @BotFather gave you.",
"required": true,
"rules": ["string"],
"options": [],
"value": null,
"steps": [],
"links": {}
}
],
"settings_fields": []
},
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:view-any.
- `RESOURCE_NOT_FOUND` — no connector is known by that key; context.connector echoes it.
### Example prompts
> "Does the Telegram connector support holding early admission for passes?"
> "What are the message length limits on Discord?"
### Related
- [list_connectors](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connectors) — every card.
- [get_connector_installation](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-installation) — whether a project runs it, and how it is doing.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors)
## get_connector_installation
Read a project's live installation of one connector — its state, health and the platform's own account for it — and learn why it is missing when it is.
Reads the project's **live** installation of one connector: the `state` (`pending`, `connected`, `degraded`, `revoked`, `disconnected`) with its reason and detail, whether the creator can fix it (`creator_actionable`), whether it can act right now (`operational`), the platform's own `external_id`, `display_name`, `handle` and `avatar_url`, and the timestamps of its life. The REST twin is [`GET /v1/projects/{project}/connectors/{key}/installation`](https://docs.subscriby.net/api/v1/reference/connectors#get-a-projects-installation-of-a-connector).
- Requires ability: `project-connector:view`
- Runs the same action as [`GET /v1/projects/{project}/connectors/{key}/installation`](https://docs.subscriby.net/api/v1/reference/connectors#get-a-projects-installation-of-a-connector)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project. |
| `connector` | string | yes | The connector key, as `list_connectors` returns it. |
### Example call
```json
{
"name": "get_connector_installation",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"connector": ""
}
}
```
### What it returns
```json
{
"data": {
"id": "3b8f0c6e-2d41-4a97-9e5f-1c7d6b2a8e40",
"connector": "telegram",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"scope": "project",
"role": "live",
"state": "degraded",
"state_reason": "connector_api_unauthorized",
"state_detail": "The token was revoked in @BotFather.",
"creator_actionable": true,
"operational": true,
"external_id": "7123456789",
"display_name": "Research Bot",
"handle": "research_bot",
"avatar_url": null,
"health_checked_at": "2026-09-12T06:00:00Z",
"connected_at": "2026-04-21T09:15:00Z",
"verified_at": "2026-09-11T06:00:00Z",
"revoked_at": null,
"disconnected_at": null,
"uninstalled_at": null,
"created_at": "2026-04-21T09:15:00Z"
},
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:view.
- `RESOURCE_NOT_FOUND` — the project is not visible to this token; or context.reason is unknown_connector (the key names no connector) or not_installed (the project has no live installation of it).
### Example prompts
> "Is the Telegram bot on Research Premium still connected?"
> "Why is the Discord installation on my project degraded?"
### Related
- [list_connector_installations](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connector-installations) — every installation, standbys included.
- [get_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector) — what the connector can do.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors)
## get_connector_uninstall_preview
Show what uninstalling a connector from a project would touch, changing nothing — the dialog to confirm before uninstall_connector.
The impact preview the Connectors tab shows before an uninstall, for an agent to read back to the creator. Nothing changes: it lists the connector's `resources` (which would be deactivated as detached), counts the `live_grants` on them (which would be revoked and their members put outside), names the `emptied_plans` that would keep nothing to grant, counts the `recurring_subscriptions` on those plans (which the creator may have cancelled at period end) and the `one_time_subscriptions` (never cancelled), lists `affected_subscription_ids`, and counts the `upcoming_windows` of emptied pass plans, the `open_support_threads` on the connector and the `member_identities` that would lose their reach. `options` carries the two opt-ins with their defaults. The REST twin is [`GET /v1/projects/{project}/connectors/{key}/uninstall-preview`](https://docs.subscriby.net/api/v1/reference/connectors#uninstall-a-connector).
- Requires ability: `project-connector:view`
- Runs the same action as [`GET /v1/projects/{project}/connectors/{key}/uninstall-preview`](https://docs.subscriby.net/api/v1/reference/connectors#preview-an-uninstall)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project. |
| `connector` | string | yes | The connector key, as `list_connectors` returns it. |
### Example call
```json
{
"name": "get_connector_uninstall_preview",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"connector": ""
}
}
```
### What it returns
```json
{
"data": {
"installation": {
"id": "3b8f0c6e-2d41-4a97-9e5f-1c7d6b2a8e40",
"connector": "discord",
"state": "connected",
"operational": true
},
"connector": "discord",
"resources": [
{
"id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"title": "Members Lounge",
"type": null,
"kind": "discord:role",
"active": true
}
],
"live_grants": 41,
"emptied_plans": [
{
"id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"name": "Lounge Monthly",
"active": true,
"recurring": true
}
],
"recurring_subscriptions": 17,
"one_time_subscriptions": 3,
"affected_subscription_ids": ["5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4"],
"upcoming_windows": 0,
"open_support_threads": 2,
"member_identities": 44,
"empties_plans": true,
"options": {
"unpublish_emptied_plans": true,
"cancel_recurring_subscriptions": true
}
},
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:view.
- `RESOURCE_NOT_FOUND` — the project is not visible to this token, or context.reason is unknown_connector.
- `CONNECTOR_NOT_INSTALLED` — the project has no installation of the connector.
### Example prompts
> "What would happen if I removed Discord from Research Premium?"
### Related
- [uninstall_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#uninstall-connector) — the act this previews.
- [disconnect_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#disconnect-connector) — the reversible alternative.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors)
## get_deep_link
Build a sharable deep link into a project's bot on its connector, for a project, plan, or access code.
Build a sharable deep link into a project's bot on its connector. The `start` payload depends on which optional input wins, in this precedence order:
| Input | Resulting payload | Use case |
| ------------- | -------------------- | ----------------------------------------------------------- |
| `access_code` | the UUID verbatim | Hand out one-time redemption links. |
| `plan_id` | the plan's bare UUID | Send a subscriber straight to a plan's checkout flow. |
| `custom` | caller-supplied | Track ad campaigns or referral codes with your own payload. |
| _(none)_ | `project_` | Default — opens the bot on the project home flow. |
- Requires ability: `distribution:read`
- Runs the same action as [`GET /v1/projects/{project}/distribution/deep-link`](https://docs.subscriby.net/api/v1/reference/distribution#build-a-deep-link)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose bot URL to embed in the deep-link. |
| `plan_id` | string | no | Optional plan UUID. When set, the payload is that UUID verbatim and the bot opens the plan checkout. |
| `access_code` | string | no | Optional access-code UUID. When set, payload is the UUID verbatim for one-tap redemption. |
| `custom` | string | no | Optional caller-supplied payload. Useful for campaign / referral tracking. |
### Example call
```json
{
"name": "get_deep_link",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"bot_url": "https://t.me/research_bot",
"start_payload": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"deep_link": "https://t.me/research_bot?start=a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — project_id is not a valid UUID or out of tenant scope; or plan_id doesn't belong to the project.
- `VALIDATION_FAILED` — plan_id is malformed, or the project has no bot attached (reason: no_bot_attached).
- `TOKEN_MISSING_ABILITY` — token lacks distribution:read.
### Example prompts
> "Give me the Telegram link for my $29 Premium plan so I can post it on X."
> "Build a deep-link for access code `abc123` so I can DM it to a subscriber."
### Related
- [get_connector_installation](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-installation) — check the installation's state before building deep-links.
- [get_portal_url](https://docs.subscriby.net/mcp/v1/tools/connectors#get-portal-url)
- [Distribution API](https://docs.subscriby.net/api/v1/reference/distribution)
## get_portal_url
Compute the subscriber portal URL for a project — {frontend_url}/{handle}. Always reflects the current handle.
Compute the subscriber portal URL for a project — `{frontend_url}/{handle}`. Always reflects the current handle, so updating the handle invalidates old portal URLs automatically.
- Requires ability: `distribution:read`
- Runs the same action as [`GET /v1/projects/{project}/distribution/portal-url`](https://docs.subscriby.net/api/v1/reference/distribution#get-the-portal-url)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose portal URL to return. |
### Example call
```json
{
"name": "get_portal_url",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"handle": "research-premium",
"portal_url": "https://my.subscriby.net/research-premium"
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — project_id is not a valid UUID, or the project belongs to a team outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks distribution:read.
### Example prompts
> "What's the portal URL for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`?"
> "Give me the sharable link to my Research Premium project."
### Related
- [get_deep_link](https://docs.subscriby.net/mcp/v1/tools/connectors#get-deep-link)
- [get_project](https://docs.subscriby.net/mcp/v1/tools/project#get-project)
- [Distribution API](https://docs.subscriby.net/api/v1/reference/distribution)
## install_connector
Install a connector on a project — a pending installation with no credentials yet, which the creator connects from the dashboard.
Mark a project as running a connector. Installing opens a **pending** installation, the row the Connectors tab shows until the creator connects it; connecting needs a credential typed by a person, so no tool does it. Installing a connector that is already installed changes nothing and returns the existing installation. The REST twin is [`POST /v1/projects/{project}/connectors/{key}`](https://docs.subscriby.net/api/v1/reference/connectors#install-a-connector).
- Requires ability: `project-connector:create`
- Runs the same action as [`POST /v1/projects/{project}/connectors/{key}`](https://docs.subscriby.net/api/v1/reference/connectors#install-a-connector)
- Fires events: [`connector.installed`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-installed)
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project. |
| `connector` | string | yes | The connector key, as `list_connectors` returns it; only an `installable` card can be installed. |
### Example call
```json
{
"name": "install_connector",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"connector": ""
}
}
```
### What it returns
```json
{
"data": {
"id": "3b8f0c6e-2d41-4a97-9e5f-1c7d6b2a8e40",
"connector": "discord",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"scope": "project",
"role": "live",
"state": "pending",
"state_reason": null,
"state_detail": null,
"creator_actionable": false,
"operational": false,
"external_id": null,
"display_name": null,
"handle": null,
"avatar_url": null,
"health_checked_at": null,
"connected_at": null,
"verified_at": null,
"revoked_at": null,
"disconnected_at": null,
"uninstalled_at": null,
"created_at": "2026-09-12T10:05:00+00:00"
},
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:create.
- `RESOURCE_NOT_FOUND` — the project is not visible to this token.
- `CONNECTOR_UNAVAILABLE` — the key names no connector creators may install today (its card is not installable).
- `CONNECTOR_TIER_REQUIRED` — a second distinct connector on the project, and the project owner's plan lacks multi_connector (Growth).
### Events emitted
- [`connector.installed`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-installed) when a row is opened (or an uninstalled one brought back).
### Example prompts
> "Add Discord to Research Premium so I can connect it later."
### Related
- [list_connectors](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connectors) — which cards are installable.
- [get_connector_installation](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-installation) — the row afterwards.
- [disconnect_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#disconnect-connector) — the reverse of connecting.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors)
## list_connector_installations
List every connector installation a project holds — live and standby — with its state, health and the platform's own account for it.
Answer "which connectors does this project run, and are they healthy?". Every row the Connectors tab shows: the `connector` key, the `scope` (`project`, or `platform` for a shared presence Subscriby runs), the `role` (`live` for the one that acts, `standby` for the spare the Disaster Recovery Program keeps), the `state` (`pending`, `connected`, `degraded`, `revoked`, `disconnected`) with its `state_reason` and `state_detail`, whether the creator can fix it (`creator_actionable`), whether it can act right now (`operational`), the platform's own `external_id`, `display_name`, `handle` and `avatar_url`, and when it was connected, verified, revoked, disconnected or uninstalled. Credentials and settings never appear. The REST twin is [`GET /v1/projects/{project}/connectors`](https://docs.subscriby.net/api/v1/reference/connectors#list-a-projects-installations).
- Requires ability: `project-connector:view-any`
- Runs the same action as [`GET /v1/projects/{project}/connectors`](https://docs.subscriby.net/api/v1/reference/connectors#list-a-projects-installations)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose installations to list. |
### Example call
```json
{
"name": "list_connector_installations",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "3b8f0c6e-2d41-4a97-9e5f-1c7d6b2a8e40",
"connector": "telegram",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"scope": "project",
"role": "live",
"state": "connected",
"state_reason": null,
"state_detail": null,
"creator_actionable": false,
"operational": true,
"external_id": "7123456789",
"display_name": "Research Bot",
"handle": "research_bot",
"avatar_url": null,
"health_checked_at": "2026-09-12T06:00:00Z",
"connected_at": "2026-04-21T09:15:00Z",
"verified_at": "2026-09-12T06:00:00Z",
"revoked_at": null,
"disconnected_at": null,
"uninstalled_at": null,
"created_at": "2026-04-21T09:15:00Z"
}
],
"meta": { "total": 1 }
}
```
Rows come oldest first.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:view-any.
- `RESOURCE_NOT_FOUND` — the project is not visible to this token.
### Example prompts
> "Is anything wrong with the connectors on Research Premium?"
> "Which of my projects still run a standby bot?"
### Related
- [get_connector_installation](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-installation) — one installation by connector key.
- [list_connectors](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connectors) — what each connector can do.
- [get_recovery_readiness](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-readiness) — whether a standby is kept for every project.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors)
## list_connectors
List the Connectors Marketplace — every connector Subscriby knows, lane by lane, with its badges, manifest and the form that connects it.
Answer "which connectors exist, which can a creator install today, and what does each do?". Every card carries its lane (`status`: `available`, `beta`, `paused`, `in_development`, `coming_soon`), whether it is `installable`, whether it is `official`, its `badges` (official or community, new, trending), the category, tagline, overview and links, and, for a connector that exists as a package, the whole manifest: `install_mode`, `scopes`, the `resource_kinds` it gates with their grant mode, `capabilities`, `messaging` limits, `pacing`, the `management_commands` its in-chat surface renders and the `missing_commands` it does not, `relay_modes`, the `recovery` facets, and the declarative `install_fields` and `settings_fields` a client renders as the connect form. The payload is exactly what [`GET /v1/connectors`](https://docs.subscriby.net/api/v1/reference/connectors) returns; the same document is the [`subscriby://connectors/catalog`](https://docs.subscriby.net/mcp/v1/resources-reference) resource.
- Requires ability: `project-connector:view-any`
- Runs the same action as [`GET /v1/connectors`](https://docs.subscriby.net/api/v1/reference/connectors#list-the-connector-directory)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | `available`, `beta`, `paused`, `in_development`, `coming_soon` | no | Narrow to one lane: `available`, `beta`, `paused`, `in_development` or `coming_soon`. Omit for every lane. |
### Example call
```json
{
"name": "list_connectors",
"arguments": {
"status": "available"
}
}
```
### What it returns
```json
{
"data": [
{
"key": "telegram",
"name": "Telegram",
"vendor": "Subscriby",
"version": "1.0.0",
"status": "available",
"status_label": "Available Now",
"installable": true,
"official": true,
"badges": [{ "key": "official", "label": "Official" }],
"category": "messaging",
"tagline": "Sell access to Telegram channels, groups and supergroups through your own bot.",
"install_mode": "paste_credential",
"scopes": ["project", "platform"],
"resource_kinds": [
{ "kind": "channel", "label": "Channel", "portal_label": "Channel", "icon": "megaphone", "grant_mode": "bearer_link", "supports_early_admission_hold": true, "upgrades_from": null, "plan_kinds": ["one_time", "recurring", "pass", "pass_series"] }
],
"capabilities": ["messaging", "access_control", "management_surface"],
"install_fields": [
{ "name": "token", "type": "secret", "label": "Bot token", "required": true, "rules": ["string"], "steps": [], "links": {} }
]
},
{
"key": "slack",
"name": "Slack",
"status": "coming_soon",
"status_label": "Coming Soon",
"installable": false,
"badges": [],
"eta": "Planned",
"install_mode": null,
"resource_kinds": [],
"install_fields": []
}
],
"meta": { "total": 5 }
}
```
Cards come installable lanes first, then by name. See the [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors#list-the-connector-directory) for every lane, badge and manifest key.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:view-any.
- `VALIDATION_FAILED` — status names no lane; context.accepted lists them.
### Example prompts
> "Which connectors can I add to my project right now?"
> "What does connecting Telegram ask me for?"
### Related
- [get_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector) — one card by key.
- [list_connector_installations](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connector-installations) — what a project actually runs.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors)
## restore_connector_access
Bring a project's detached places on a reinstalled connector back and hand every live purchase of a plan granting them fresh access.
The dashboard's **Restore Access** for agents. An [uninstall](https://docs.subscriby.net/mcp/v1/tools/connectors#uninstall-connector) leaves every place on the connector detached and revokes its grants; once the connector is installed and connected again, this asks the connector about every detached place, reactivates the ones it still controls, and hands every live purchase of a plan granting them fresh access — only the grants a purchase lacks are issued, which are exactly the ones the uninstall took away, and `member.resource_added` fires per grant. A place the connector no longer controls stays detached for [`run_connector_doctor`](https://docs.subscriby.net/mcp/v1/tools/connectors#run-connector-doctor) to explain. Plans the uninstall took off sale stay off sale.
- Requires ability: `project-connector:update`
- Runs the same action as [`POST /v1/projects/{project}/connectors/{key}/installation/restore-access`](https://docs.subscriby.net/api/v1/reference/connectors#restore-access-after-a-reinstall)
- Fires events: [`member.resource_added`](https://docs.subscriby.net/webhooks/v1/events/member#member-resource-added)
- Annotations: Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project. |
| `connector` | string | yes | The connector key, as `list_connectors` returns it. |
### Example call
```json
{
"name": "restore_connector_access",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"connector": ""
}
}
```
### What it returns
```json
{
"data": {
"installation": {
"id": "3b8f0c6e-2d41-4a97-9e5f-1c7d6b2a8e40",
"connector": "telegram",
"state": "connected"
},
"connector": "telegram",
"resources": [
{
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"title": "Premium Channel",
"restored": true
},
{
"resource_id": "0d4a7e19-5c2b-4f83-9a61-2e8b7c3d5f10",
"title": "Archive Group",
"restored": false
}
],
"resources_reactivated": 1,
"resources_still_detached": 1,
"subscriptions_reissued": 14
},
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:update.
- `RESOURCE_NOT_FOUND` — the project is unknown or outside the token's reach.
- `CONNECTOR_NOT_INSTALLED` — the project has no installation of the connector.
- `CONNECTOR_NOT_CONNECTED` — the installation is pending or disconnected; connect it and verify it first.
### Example prompts
> "I reinstalled Telegram on the Signals project — bring the channels back and give everyone their access again."
> "Restore access on the Research project's connector and tell me which places did not come back."
### Related
- [uninstall_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#uninstall-connector) — what detaches the places in the first place.
- [install_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#install-connector) — brings an uninstalled row back as pending.
- [run_connector_doctor](https://docs.subscriby.net/mcp/v1/tools/connectors#run-connector-doctor) — why a place stayed detached.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors#restore-access-after-a-reinstall)
## run_connector_doctor
Verify a project's installation of a connector and ask the connector about every resource it gates — one report, a finding per check.
The dashboard's **Run Doctor** for agents. The installation is verified exactly as [`verify_connector_installation`](https://docs.subscriby.net/mcp/v1/tools/connectors#verify-connector-installation) does it, then the connector is asked about every resource the installation gates, and the answer is one report: a finding for the installation, then one per resource, each with a `severity` (`ok`, `warning`, `critical`), the connector's own `state` word, a sentence in the creator's language, whether the creator can fix it and where. A resource the connector has no place for is a `warning`, never silence. The report is kept on the installation and raises `connector.doctor_completed` only when the findings differ from the previous run's. The REST twin is [`POST /v1/projects/{project}/connectors/{key}/installation/doctor`](https://docs.subscriby.net/api/v1/reference/connectors#run-the-connector-doctor).
- Requires ability: `project-connector:update`
- Runs the same action as [`POST /v1/projects/{project}/connectors/{key}/installation/doctor`](https://docs.subscriby.net/api/v1/reference/connectors#run-the-connector-doctor)
- Fires events: [`connector.doctor_completed`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-doctor-completed)
- Annotations: Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project. |
| `connector` | string | yes | The connector key, as `list_connectors` returns it. |
### Example call
```json
{
"name": "run_connector_doctor",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"connector": ""
}
}
```
### What it returns
```json
{
"data": {
"installation_id": "3b8f0c6e-2d41-4a97-9e5f-1c7d6b2a8e40",
"connector": "telegram",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"ran_at": "2026-09-12T10:07:00Z",
"healthy": false,
"critical": 1,
"warnings": 0,
"findings": [
{
"key": "installation",
"kind": "installation",
"severity": "ok",
"severity_label": "OK",
"subject": "Research Bot",
"state": "connected",
"message": "The connector still answers for this installation.",
"creator_actionable": false,
"fix_url": null,
"resource_id": null
},
{
"key": "resource:b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"kind": "resource",
"severity": "critical",
"severity_label": "Critical",
"subject": "Premium Channel",
"state": "not_member",
"message": "The bot was removed from this channel.",
"creator_actionable": true,
"fix_url": "https://app.subscriby.net/projects/7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13/resources",
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6"
}
]
},
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:update.
- `RESOURCE_NOT_FOUND` — the project is unknown or outside the token's reach.
- `CONNECTOR_NOT_INSTALLED` — the project has no installation of the connector.
### Example prompts
> "Is Telegram fully working for the Signals project? Check everything, not just the bot."
> "Run the doctor on every connector of this project and tell me what needs fixing."
### Related
- [verify_connector_installation](https://docs.subscriby.net/mcp/v1/tools/connectors#verify-connector-installation) — the installation alone.
- [get_connector_installation](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-installation) — the installation with doctor_ran_at and doctor_healthy.
- [connector.doctor_completed](https://docs.subscriby.net/webhooks/v1/events/connector#connector-doctor-completed)
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors#run-the-connector-doctor)
## uninstall_connector
Uninstall a connector from a project — grants revoked, resources detached, the row kept — with two opt-ins for the plans it empties. Destructive.
Distinct from disconnecting and never a deletion. Every live grant on the connector's resources is revoked (members are put outside; `member.resource_removed` per grant), its resources are deactivated as detached with their external ids kept, the installation row stays with `uninstalled_at`, identities are never removed, and no money moves by itself. Two opt-ins, both on by default, act on the plans left with nothing to grant: `unpublish_emptied_plans` takes them off sale; `cancel_recurring_subscriptions` cancels their live recurring subscriptions at the end of the paid period and emails each member. One-time and lifetime purchases are never cancelled. Always read [`get_connector_uninstall_preview`](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-uninstall-preview) and confirm with the creator first. The REST twin is [`DELETE /v1/projects/{project}/connectors/{key}`](https://docs.subscriby.net/api/v1/reference/connectors#uninstall-a-connector).
- Requires ability: `project-connector:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/connectors/{key}`](https://docs.subscriby.net/api/v1/reference/connectors#uninstall-a-connector)
- Fires events: [`connector.uninstalled`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-uninstalled), [`member.resource_removed`](https://docs.subscriby.net/webhooks/v1/events/member#member-resource-removed), [`plan.deactivated`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-deactivated), [`subscription.cancelled`](https://docs.subscriby.net/webhooks/v1/events/subscription#subscription-cancelled)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project. |
| `connector` | string | yes | The connector key, as `list_connectors` returns it. |
| `unpublish_emptied_plans` | boolean | no | Take the plans left with nothing to grant off sale. Default true. |
| `cancel_recurring_subscriptions` | boolean | no | Cancel the live recurring subscriptions on those plans at the end of their paid period and email each member. Default true. One-time purchases are never cancelled. |
### Example call
```json
{
"name": "uninstall_connector",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"connector": ""
}
}
```
### What it returns
```json
{
"data": {
"installation": {
"id": "3b8f0c6e-2d41-4a97-9e5f-1c7d6b2a8e40",
"connector": "discord",
"state": "disconnected",
"operational": false,
"uninstalled_at": "2026-09-12T11:30:00+00:00"
},
"connector": "discord",
"grants_revoked": 41,
"resources_detached": 2,
"plans_unpublished": 1,
"subscriptions_cancelled": 17,
"members_notified": 17
},
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:delete.
- `RESOURCE_NOT_FOUND` — the project is not visible to this token, or context.reason is unknown_connector.
- `CONNECTOR_NOT_INSTALLED` — the project has no installation of the connector.
### Events emitted
- [`connector.uninstalled`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-uninstalled) with the counts.
- [`member.resource_removed`](https://docs.subscriby.net/webhooks/v1/events/member) per revoked grant.
- [`plan.deactivated`](https://docs.subscriby.net/webhooks/v1/events/plan) per emptied plan taken off sale, and [`subscription.cancelled`](https://docs.subscriby.net/webhooks/v1/events/subscription) per recurring subscription cancelled, when the opt-ins are on.
### Example prompts
> "Remove Discord from Research Premium, take the Lounge plan off sale and let its subscribers run out at period end."
### Related
- [get_connector_uninstall_preview](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-uninstall-preview) — read it first.
- [disconnect_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#disconnect-connector) — reversible, touches nothing else.
- [install_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#install-connector) — brings the installation back as pending, settings kept.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors)
## update_connector_installation_settings
Change a project's connector installation settings, validated against the fields the connector declares.
Write the settings a connector declares for its installations. `settings` is an object keyed by the field names in the connector's `settings_fields` (read them with [`get_connector`](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector)); every declared rule runs, a key the connector never declared is refused, and fields left out keep their value. The installation is returned without its settings, because a settings field may be a secret. The REST twin is [`PATCH /v1/projects/{project}/connectors/{key}/installation/settings`](https://docs.subscriby.net/api/v1/reference/connectors#change-an-installations-settings).
- Requires ability: `project-connector:update`
- Runs the same action as [`PATCH /v1/projects/{project}/connectors/{key}/installation/settings`](https://docs.subscriby.net/api/v1/reference/connectors#change-an-installations-settings)
- Fires events: [`connector.settings_updated`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-settings-updated)
- Annotations: Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project. |
| `connector` | string | yes | The connector key, as `list_connectors` returns it. |
| `settings` | object | no | The fields to change, keyed by the names the connector declares in `settings_fields`; fields left out keep their value. Optional when `capabilities` is given. |
| `capabilities` | object | no | Capability switches to change, keyed by capability value (`messaging`, `broadcasts`, `support_relay`, `native_payments`, `recovery_*`) to true or false; switches left out keep their value. Optional when `settings` is given. |
### Example call
```json
{
"name": "update_connector_installation_settings",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"connector": ""
}
}
```
### What it returns
The installation, as [`get_connector_installation`](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-installation) renders it, `capabilities` included: every capability the connector declares with `enabled` and `toggleable`. Settings are never serialised.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:update.
- `RESOURCE_NOT_FOUND` — the project is not visible to this token, or context.reason is unknown_connector.
- `CONNECTOR_NOT_INSTALLED` — the project has no installation of the connector.
- `VALIDATION_FAILED` — a key the connector does not declare, a declared rule failed, a capability it does not declare or cannot switch off, a switch that is not true or false, or neither settings nor capabilities given; context names the field or the capability.
### Events emitted
- [`connector.settings_updated`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-settings-updated) naming the fields and the switches (`capabilities.`) that changed, only when something did.
### Example prompts
> "Set the Discord installation's welcome message on Research Premium to 'Welcome aboard'."
> "Switch broadcasts off for the Telegram connector on Research Premium; keep everything else."
### Related
- [get_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector) — the declared settings_fields.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors)
## verify_connector_installation
Ask the connector whether a project's installation still answers, and record the verdict on it.
A fresh probe rather than the hourly one. The connector is asked about the installation and the answer is recorded: `state` becomes `connected`, `degraded` (with `state_reason`, `state_detail` and whether the creator can fix it) or `revoked`, and `health_checked_at` is stamped. A `pending` installation has nothing to verify and is returned as it is. The REST twin is [`POST /v1/projects/{project}/connectors/{key}/installation/verify`](https://docs.subscriby.net/api/v1/reference/connectors#verify-an-installation).
- Requires ability: `project-connector:update`
- Runs the same action as [`POST /v1/projects/{project}/connectors/{key}/installation/verify`](https://docs.subscriby.net/api/v1/reference/connectors#verify-an-installation)
- Fires events: [`connector.status_changed`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-status-changed)
- Annotations: Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project. |
| `connector` | string | yes | The connector key, as `list_connectors` returns it. |
### Example call
```json
{
"name": "verify_connector_installation",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"connector": ""
}
}
```
### What it returns
```json
{
"data": {
"id": "3b8f0c6e-2d41-4a97-9e5f-1c7d6b2a8e40",
"connector": "telegram",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"scope": "project",
"role": "live",
"state": "degraded",
"state_reason": "connector_api_unauthorized",
"state_detail": "The token was revoked in @BotFather.",
"creator_actionable": true,
"operational": true,
"external_id": "7123456789",
"display_name": "Research Bot",
"handle": "research_bot",
"avatar_url": null,
"health_checked_at": "2026-09-12T10:07:00+00:00",
"connected_at": "2026-04-21T09:15:00+00:00",
"verified_at": "2026-09-11T06:00:00+00:00",
"revoked_at": null,
"disconnected_at": null,
"uninstalled_at": null,
"created_at": "2026-04-21T09:15:00+00:00"
},
"meta": {}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-connector:update.
- `RESOURCE_NOT_FOUND` — the project is not visible to this token, or context.reason is unknown_connector.
- `CONNECTOR_NOT_INSTALLED` — the project has no installation of the connector.
### Events emitted
- [`connector.status_changed`](https://docs.subscriby.net/webhooks/v1/events/connector#connector-status-changed) only when the state moved.
### Example prompts
> "Check whether the Telegram bot on Research Premium still works."
### Related
- [get_connector_installation](https://docs.subscriby.net/mcp/v1/tools/connectors#get-connector-installation) — the row without a fresh probe.
- [list_recovery_incidents](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#list-recovery-incidents) — what a degraded verdict may have opened.
- [Connectors API](https://docs.subscriby.net/api/v1/reference/connectors)
---
# Coupon Tools
Source: https://docs.subscriby.net/mcp/v1/tools/coupon
A coupon is one code many buyers can redeem for money off at checkout, the multi-use counterpart of an access code. These tools create coupons, switch them on and off, list redemptions and retire them.
## Tools
- [`activate_coupon`](#activate-coupon) — Activate Coupon (destructive)
- [`create_coupon`](#create-coupon) — Create Coupon (destructive)
- [`deactivate_coupon`](#deactivate-coupon) — Deactivate Coupon (destructive)
- [`delete_coupon`](#delete-coupon) — Delete Coupon (destructive)
- [`get_coupon`](#get-coupon) — Get Coupon (read)
- [`list_coupons`](#list-coupons) — List Coupons (read)
- [`update_coupon`](#update-coupon) — Update Coupon (destructive)
## activate_coupon
Switch a coupon code on so subscribers can redeem it again, subject to its own dates and cap. Emits coupon.activated.
Turn a coupon back on. Switching on does not override the code's own rules: it still honours its
`starts_at`, `expires_at` and redemption cap, so an activated code can come back with
`redeemable: false` when it is outside its window or exhausted. Read that field, not `active`, to
tell a subscriber whether the code will work.
This is the counterpart of [`deactivate_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#deactivate-coupon) and the narrow
alternative to [`update_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#update-coupon) with `active: true`: it announces
[`coupon.activated`](https://docs.subscriby.net/webhooks/v1/events/coupon#coupon-activated) rather than a generic `coupon.updated`, so an
automation that cares about codes going live does not have to diff payloads to notice.
> **Entitlement is checked on the way back on**
>
> Requires the **Coupons Addon** or a Growth plan on the project owner. A
> creator whose tier lost coupons is told why the code stays off with
> `TEAM_TIER_REQUIRED`; the code is left as it was.
Re-calling on a code that is already on leaves it unchanged. The event emits on every successful
call, so key a consumer on the coupon's state rather than on counting events.
- Requires ability: `project-coupon:update`
- Runs the same action as [`POST /v1/projects/{project}/coupons/{coupon}/activate`](https://docs.subscriby.net/api/v1/reference/coupons#activate-a-coupon)
- Fires events: [`coupon.activated`](https://docs.subscriby.net/webhooks/v1/events/coupon#coupon-activated)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `coupon_id` | string | yes | UUID of the coupon to switch on. |
### Example call
```json
{
"name": "activate_coupon",
"arguments": {
"coupon_id": "108e7ad1-2b76-4380-a714-6fc987e6960e"
}
}
```
### What it returns
```json
{
"data": {
"id": "4b9d3e08-a1f6-4275-9c83-7e01d6a2f594",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"code": "BLACKFRIDAY",
"name": "Black Friday 2026",
"discount_type": "percentage",
"discount_value": "25.0000",
"currency": null,
"duration": "once",
"max_redemptions": 500,
"max_redemptions_per_user": 1,
"minimum_amount": null,
"redemptions": {
"count": 137,
"remaining": 363,
"exhausted": false
},
"starts_at": "2026-11-27T00:00:00+00:00",
"expires_at": "2026-12-01T00:00:00+00:00",
"plan_ids": [],
"active": true,
"redeemable": true,
"created_at": "2026-11-20T09:14:02+00:00"
}
}
```
The full [`get_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#get-coupon) row, with `active: true`. `redeemable` is the field
that says whether the code will apply at checkout right now.
### How it fails
- `TEAM_TIER_REQUIRED` — the project owner's tier no longer includes coupons. error.context.reason
- `VALIDATION_FAILED` — the caller is a team member whose role lacks the team's coupon-update
- `RESOURCE_NOT_FOUND` — unknown coupon_id, a coupon on another team's project, one outside the
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-coupon:update.
### Example prompts
> "Switch coupon `4b9d3e08-a1f6-4275-9c83-7e01d6a2f594` back on."
> "Re-enable the BLACKFRIDAY code — the sale is live again."
> "Turn the WELCOME10 code on and tell me whether it's actually redeemable now."
### Related
- [deactivate_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#deactivate-coupon) — the other half of the switch.
- [get_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#get-coupon) — read active and redeemable first.
- [update_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#update-coupon) — change the dates or cap that keep a live code from
- [Coupons API](https://docs.subscriby.net/api/v1/reference/coupons#activate-a-coupon) — the REST equivalent.
- [Coupon Codes](https://docs.subscriby.net/creators/coupon-codes) — the feature walkthrough.
## create_coupon
Create a coupon code any number of subscribers can redeem for money off. Returns the coupon immediately — no job to poll.
Author a coupon code: one string many subscribers redeem for a discount at checkout. Distinct from
an [access code](https://docs.subscriby.net/mcp/v1/tools/access-code#bulk-generate-access-codes), which is one code for one person and grants
access outright without a payment.
Synchronous — the coupon is returned in the response, and
[`coupon.created`](https://docs.subscriby.net/webhooks/v1/events/coupon#coupon-created) emits once.
> **Note**
>
> Discounts apply to the **first payment only**. Renewals charge list price, so
> an agent should not describe a coupon as changing a subscriber's ongoing rate.
> **Warning**
>
> Requires the **Coupons Addon** or a Growth plan. Without it the call fails
> with `TEAM_TIER_REQUIRED` rather than creating anything. Entitlement is
> checked again when a subscriber redeems, so a coupon created today stops
> applying if the addon later lapses.
- Requires ability: `project-coupon:create`
- Runs the same action as [`POST /v1/projects/{project}/coupons`](https://docs.subscriby.net/api/v1/reference/coupons#create-a-coupon)
- Fires events: [`coupon.created`](https://docs.subscriby.net/webhooks/v1/events/coupon#coupon-created)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the code belongs to. |
| `code` | string | yes | The code subscribers type at checkout. Letters, numbers and dashes only, 3 to 64 characters. Stored uppercase. |
| `name` | string | no | Internal label to tell your codes apart, e.g. "Black Friday 2026". Defaults to the code itself. |
| `discount_type` | string | yes | Either "percentage" (works on plans in any currency) or "fixed" (requires currency_id, and only applies to plans priced in it). |
| `discount_value` | number | yes | For percentage, 1..99. For fixed, an amount greater than zero in the chosen currency. |
| `currency_id` | string | no | Currency UUID. Required when discount_type is "fixed", ignored otherwise. |
| `max_redemptions` | integer | no | Total uses allowed across everyone. Omit for unlimited. |
| `max_redemptions_per_user` | integer | no | How many times one subscriber may use the code. Defaults to 1. |
| `minimum_amount` | number | no | Only apply the code when the plan costs at least this much, in the plan's own currency. |
| `starts_at` | string | no | ISO 8601 instant the code becomes usable. Omit to start immediately. |
| `expires_at` | string | no | ISO 8601 instant the code stops working. Omit for no end date. |
| `plan_ids` | array of string | no | Plan UUIDs to restrict the code to. Leave empty or omit to cover every plan in the project, including ones added later. |
| `active` | boolean | no | Whether the code is usable right away. Defaults to true. |
### Example call
```json
{
"name": "create_coupon",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"code": "",
"discount_type": "",
"discount_value": 1
}
}
```
### What it returns
```json
{
"data": {
"id": "4b9d3e08-a1f6-4275-9c83-7e01d6a2f594",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"code": "BLACKFRIDAY",
"name": "Black Friday 2026",
"discount_type": "percentage",
"discount_value": "25.0000",
"currency": null,
"duration": "once",
"max_redemptions": 500,
"max_redemptions_per_user": 1,
"minimum_amount": null,
"starts_at": "2026-11-27T00:00:00+00:00",
"expires_at": "2026-12-01T00:00:00+00:00",
"plan_ids": [],
"active": true,
"redeemable": true,
"created_at": "2026-11-20T09:14:02+00:00"
}
}
```
`code` comes back uppercase regardless of what was sent. `discount_value` is a decimal string —
parse it as a decimal, not a float.
### How it fails
- `TEAM_TIER_REQUIRED` — the creator has neither the Coupons Addon nor a Growth plan.
- `VALIDATION_FAILED` — code already taken in this project (compared uppercase), code outside 3–64
- `RESOURCE_NOT_FOUND` — unknown project_id, or the project is outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-coupon:create.
### Example prompts
> "Create a coupon `BLACKFRIDAY` in project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` for 25% off, capped at 500 uses, running"
> "27 November to 1 December."
> "Make a $10-off code called `WELCOME10` for the Starter plan only, one use per person, no expiry."
> "Set up a launch code at 15% off with a $20 minimum purchase."
### Related
- [list_coupons](https://docs.subscriby.net/mcp/v1/tools/coupon#list-coupons) — check for an existing code before creating a duplicate.
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans) — resolve plan UUIDs for plan_ids.
- [Coupons API](https://docs.subscriby.net/api/v1/reference/coupons) — the REST equivalent, plus update, delete and on/off.
- [Coupon Codes](https://docs.subscriby.net/creators/coupon-codes) — the feature walkthrough.
## deactivate_coupon
Switch a coupon code off so no new subscriber can redeem it. A checkout that already applied it keeps its discount. Emits coupon.deactivated.
Stop a code right now. Switching off is the safe way to retire a coupon: no new subscriber can
redeem it, while the redemptions already recorded stay exactly as they are and a buyer who applied
the code before you called keeps the discount they were quoted. Nothing is deleted, so
[`activate_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#activate-coupon) brings it back.
> **Why this, and not delete_coupon**
>
> [`delete_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#delete-coupon) refuses while a checkout holding
> the code is still in progress, because the discount has already reached a
> gateway. Deactivating never refuses for that reason — it is the call to make
> when a code has leaked or a campaign has to end this minute. Delete later if
> you want the row gone.
Announces [`coupon.deactivated`](https://docs.subscriby.net/webhooks/v1/events/coupon#coupon-deactivated) rather than a generic
`coupon.updated`, so an automation that reacts to codes being withdrawn does not have to diff
payloads to notice. Re-calling on a code that is already off leaves it unchanged; the event emits on
every successful call, so key a consumer on the coupon's state rather than on counting events.
- Requires ability: `project-coupon:update`
- Runs the same action as [`POST /v1/projects/{project}/coupons/{coupon}/deactivate`](https://docs.subscriby.net/api/v1/reference/coupons#deactivate-a-coupon)
- Fires events: [`coupon.deactivated`](https://docs.subscriby.net/webhooks/v1/events/coupon#coupon-deactivated)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `coupon_id` | string | yes | UUID of the coupon to switch off. |
### Example call
```json
{
"name": "deactivate_coupon",
"arguments": {
"coupon_id": "108e7ad1-2b76-4380-a714-6fc987e6960e"
}
}
```
### What it returns
```json
{
"data": {
"id": "4b9d3e08-a1f6-4275-9c83-7e01d6a2f594",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"code": "BLACKFRIDAY",
"name": "Black Friday 2026",
"discount_type": "percentage",
"discount_value": "25.0000",
"currency": null,
"duration": "once",
"max_redemptions": 500,
"max_redemptions_per_user": 1,
"minimum_amount": null,
"redemptions": {
"count": 137,
"remaining": 363,
"exhausted": false
},
"starts_at": "2026-11-27T00:00:00+00:00",
"expires_at": "2026-12-01T00:00:00+00:00",
"plan_ids": [],
"active": false,
"redeemable": false,
"created_at": "2026-11-20T09:14:02+00:00"
}
}
```
The full [`get_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#get-coupon) row, with `active: false`. `redeemable` is always
`false` on a deactivated code; `redemptions` is untouched.
### How it fails
- `VALIDATION_FAILED` — the caller is a team member whose role lacks the team's coupon-update
- `RESOURCE_NOT_FOUND` — unknown coupon_id, a coupon on another team's project, one outside the
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-coupon:update.
### Example prompts
> "Turn off coupon `4b9d3e08-a1f6-4275-9c83-7e01d6a2f594` immediately — it's been posted publicly."
> "Pause the BLACKFRIDAY code; we'll switch it back on for the weekend."
> "Stop the STAFF50 code from being used, but keep the record of who already used it."
### Related
- [activate_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#activate-coupon) — switch it back on.
- [delete_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#delete-coupon) — remove the row once no checkout holds it.
- [get_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#get-coupon)
- [Coupons API](https://docs.subscriby.net/api/v1/reference/coupons#deactivate-a-coupon) — the REST equivalent.
- [Coupon Codes](https://docs.subscriby.net/creators/coupon-codes) — the feature walkthrough.
## delete_coupon
Soft-delete a coupon code so nobody can redeem it again. Past redemptions are kept. Refused while a checkout holds the code.
Retire a coupon code permanently. The row is soft-deleted: the code disappears from every list and
can never be redeemed again, while the redemptions already recorded and the discounts already
applied stay in the history. Emits [`coupon.deleted`](https://docs.subscriby.net/webhooks/v1/events/coupon#coupon-deleted) with a
snapshot taken before the row goes, because a consumer reacting to a deletion has no way left to
look the coupon up.
> **Confirm the target with a human**
>
> Deletion cannot be undone from the API. The tool is annotated destructive so a
> client can prompt for confirmation; read the code back with
> [`get_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#get-coupon) and confirm the `coupon_id` before
> calling.
> **Refused while a checkout is using the code**
>
> If a buyer has applied the code and their checkout has not settled, the
> discount has already been quoted to a gateway. The service refuses with
> `VALIDATION_FAILED` rather than pull the code out from under them. Call
> [`deactivate_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#deactivate-coupon) instead: it stops new
> redemptions at once and never refuses for a checkout in flight. Delete later,
> once the hold has cleared.
Idempotent: a second call on the same id finds nothing and answers `RESOURCE_NOT_FOUND`, exactly as
an unknown or out-of-scope id does.
- Requires ability: `project-coupon:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/coupons/{coupon}`](https://docs.subscriby.net/api/v1/reference/coupons#delete-a-coupon)
- Fires events: [`coupon.deleted`](https://docs.subscriby.net/webhooks/v1/events/coupon#coupon-deleted)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `coupon_id` | string | yes | UUID of the coupon to delete. |
### Example call
```json
{
"name": "delete_coupon",
"arguments": {
"coupon_id": "108e7ad1-2b76-4380-a714-6fc987e6960e"
}
}
```
### What it returns
```json
{
"data": {
"id": "4b9d3e08-a1f6-4275-9c83-7e01d6a2f594",
"deleted": true
}
}
```
`id` echoes the argument as sent.
### How it fails
- `VALIDATION_FAILED` — a checkout using the code is still in progress (error.context.coupon
- `RESOURCE_NOT_FOUND` — unknown coupon_id, a coupon on another team's project, one outside the
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-coupon:delete.
### Example prompts
> "Delete coupon `4b9d3e08-a1f6-4275-9c83-7e01d6a2f594` — the campaign is over."
> "Remove the leaked STAFF50 code entirely."
> "Get rid of the old Black Friday codes from 2025."
### Related
- [deactivate_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#deactivate-coupon) — the always-available way to stop a code.
- [get_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#get-coupon) — confirm what you are about to delete.
- [list_coupons](https://docs.subscriby.net/mcp/v1/tools/coupon#list-coupons)
- [Coupons API](https://docs.subscriby.net/api/v1/reference/coupons#delete-a-coupon) — the REST equivalent.
- [Coupon Codes](https://docs.subscriby.net/creators/coupon-codes) — the feature walkthrough.
## get_coupon
Fetch one coupon by UUID — discount, limits, redemption tally, plan restriction and whether it is redeemable right now.
Read one coupon code in full. The row is the same one [`list_coupons`](https://docs.subscriby.net/mcp/v1/tools/coupon#list-coupons)
emits, so an agent that spotted a code in the list reads exactly the same fields back here, and the
row a write tool returns after a change is comparable field for field.
Reads go through the coupon service, so the tenant scope and the token's `scope:project:` allow-list
both apply. An id the token cannot see — unknown, another team's, or on a project outside the
allow-list — answers `RESOURCE_NOT_FOUND`, indistinguishable from an id that never existed.
Call it before [`update_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#update-coupon) or
[`delete_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#delete-coupon) to read the current state rather than assuming it.
- Requires ability: `project-coupon:view`
- Runs the same action as [`GET /v1/projects/{project}/coupons/{coupon}`](https://docs.subscriby.net/api/v1/reference/coupons#get-a-coupon)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `coupon_id` | string | yes | UUID of the coupon to fetch. |
### Example call
```json
{
"name": "get_coupon",
"arguments": {
"coupon_id": "108e7ad1-2b76-4380-a714-6fc987e6960e"
}
}
```
### What it returns
```json
{
"data": {
"id": "4b9d3e08-a1f6-4275-9c83-7e01d6a2f594",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"code": "BLACKFRIDAY",
"name": "Black Friday 2026",
"discount_type": "percentage",
"discount_value": "25.0000",
"currency": null,
"duration": "once",
"max_redemptions": 500,
"max_redemptions_per_user": 1,
"minimum_amount": null,
"redemptions": {
"count": 137,
"remaining": 363,
"exhausted": false
},
"starts_at": "2026-11-27T00:00:00+00:00",
"expires_at": "2026-12-01T00:00:00+00:00",
"plan_ids": [],
"active": true,
"redeemable": true,
"created_at": "2026-11-20T09:14:02+00:00"
}
}
```
> **`plan_ids: []` means every plan, not no plans**
>
> An empty array means the code applies to **every** plan in the project,
> including plans added later. Do not summarise it as "not applied to any plan".
**`active` is not `redeemable`.** A code can be switched on and still not apply — before
`starts_at`, after `expires_at`, or with the cap reached. `redeemable` is the combined answer for the
coupon as a whole; per-subscriber caps are only resolved at checkout.
**Prefer `redemptions.remaining` over your own subtraction.** It is derived from settled redemptions
plus reservations whose hold has not expired, so an in-flight checkout is already counted.
`discount_value` is a decimal string — parse it as a decimal, not a float. `currency` is an ISO
code, and `null` on a percentage coupon.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown coupon_id, a coupon on another team's project, one outside the
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-coupon:view.
### Example prompts
> "Show me coupon `4b9d3e08-a1f6-4275-9c83-7e01d6a2f594`."
> "How many uses are left on the BLACKFRIDAY code, and is it live right now?"
> "Which plans does this coupon apply to?"
### Related
- [list_coupons](https://docs.subscriby.net/mcp/v1/tools/coupon#list-coupons) — find the id.
- [update_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#update-coupon)
- [activate_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#activate-coupon)
- [deactivate_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#deactivate-coupon)
- [delete_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#delete-coupon)
- [Coupons API](https://docs.subscriby.net/api/v1/reference/coupons#get-a-coupon) — the REST equivalent.
- [Coupon Codes](https://docs.subscriby.net/creators/coupon-codes) — the feature walkthrough.
## list_coupons
List coupon codes with derived remaining-redemption counts. Read-only. Paginated, optionally narrowed to one project or filtered by on/off state.
Discover existing coupon codes — to report on a campaign, to check whether a code already exists
before calling [`create_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#create-coupon), or to find the id of a code the creator
described by name.
Read-only. Never mutates anything.
- Requires ability: `project-coupon:view-any`
- Runs the same action as [`GET /v1/projects/{project}/coupons`](https://docs.subscriby.net/api/v1/reference/coupons#list-a-projects-coupons)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID to narrow the list to a single project. |
| `active` | boolean | no | Optional filter: true for codes that are switched on, false for those switched off. |
| `limit` | integer | no | Maximum coupons to return per page (1..100). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_coupons",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"active": true
}
}
```
### What it returns
```json
{
"data": [
{
"id": "4b9d3e08-a1f6-4275-9c83-7e01d6a2f594",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"code": "BLACKFRIDAY",
"name": "Black Friday 2026",
"discount_type": "percentage",
"discount_value": "25.0000",
"currency": null,
"duration": "once",
"max_redemptions": 500,
"max_redemptions_per_user": 1,
"minimum_amount": null,
"redemptions": {
"count": 137,
"remaining": 363,
"exhausted": false
},
"starts_at": "2026-11-27T00:00:00+00:00",
"expires_at": "2026-12-01T00:00:00+00:00",
"plan_ids": [],
"active": true,
"redeemable": true,
"created_at": "2026-11-20T09:14:02+00:00"
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 4,
"has_more": false
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown project_id, or the project is outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-coupon:view-any.
### Three fields worth reading carefully
> **`plan_ids: []` means every plan, not no plans**
>
> An empty array means the code applies to **every** plan in the project,
> including plans added later. An agent summarising a coupon must not report it
> as "not applied to any plan".
**Prefer `redemptions.remaining` over your own subtraction.** It is derived, not stored: the quota
counts settled redemptions plus reservations whose hold has not expired, so an in-flight checkout is
already accounted for and an abandoned one returns its slot. `max_redemptions - count` will
disagree.
**`active` is not `redeemable`.** A code can be switched on and still not apply — outside its
window, fully claimed, or capped for a particular subscriber. `redeemable` is the combined answer
for the coupon as a whole; per-subscriber caps can only be resolved at checkout.
### Example prompts
> "List the coupon codes for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` and tell me which are still redeemable."
> "How many uses are left on BLACKFRIDAY?"
> "Do I already have a code called WELCOME10?"
> "Show me every switched-off coupon across my projects."
### Related
- [create_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#create-coupon) — author a new code.
- [Coupons API](https://docs.subscriby.net/api/v1/reference/coupons) — update, delete, activate and deactivate.
- [coupon.* webhooks](https://docs.subscriby.net/webhooks/v1/events/coupon) — react to redemptions instead of polling.
## update_coupon
Change one or more fields of a coupon code. Partial by design — anything omitted keeps its stored value. Emits coupon.updated.
Change an existing coupon without restating it. Only the arguments present in the call are written,
the way the REST `PATCH` behaves, so extending an expiry is one field, not a re-creation. Every field
is checked the way [`create_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#create-coupon) checks it, and cross-field rules are
checked against the coupon's **effective** values: a percentage cap is judged against the type the
coupon will have after the call, an end date against the start it will have.
Synchronous — the row after the change comes back in the response, and
[`coupon.updated`](https://docs.subscriby.net/webhooks/v1/events/coupon#coupon-updated) emits once with the fields that moved. A call
that carries only `coupon_id` returns the current row and emits nothing.
> **Omit to keep, send null to clear**
>
> Leaving a field out leaves it alone. To remove a limit or a date, send the key
> with `null`: `max_redemptions` (unlimited), `minimum_amount` (no minimum),
> `starts_at` (usable now) and `expires_at` (no end date) all accept it.
> `max_redemptions_per_user` does not — it is always at least 1.
> **`plan_ids: []` widens the code to every plan**
>
> Omitting `plan_ids` leaves the restriction as it is. Sending an **empty list**
> deliberately covers every plan in the project, including ones added later. To
> narrow a code to "the Premium plan", pass that plan's UUID; resolve it with
> [`list_plans`](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans) first.
> **Warning**
>
> Requires the **Coupons Addon** or a Growth plan on the project owner. When the
> tier has lapsed the call fails with `TEAM_TIER_REQUIRED` and nothing is
> written. The discount still applies to the **first payment only**; nothing
> here changes a subscriber's renewal price.
Switching `discount_type` has a side effect worth knowing: moving to `fixed` needs a `currency_id`
unless the coupon already stores one, and moving to `percentage` clears the stored currency.
- Requires ability: `project-coupon:update`
- Runs the same action as [`PATCH /v1/projects/{project}/coupons/{coupon}`](https://docs.subscriby.net/api/v1/reference/coupons#update-a-coupon)
- Fires events: [`coupon.updated`](https://docs.subscriby.net/webhooks/v1/events/coupon#coupon-updated)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `coupon_id` | string | yes | UUID of the coupon to change. |
| `code` | string | no | New code subscribers type at checkout. Letters, numbers and dashes only, 3 to 64 characters. Stored uppercase; must not already exist in the project. |
| `name` | string | no | Internal label, 3 to 255 characters. |
| `discount_type` | string | no | Either "percentage" or "fixed". Switching to fixed needs a currency_id unless the code already has one. |
| `discount_value` | number | no | For percentage, 1..100. For fixed, an amount greater than zero in the coupon's currency. |
| `currency_id` | string | no | Currency UUID for a fixed discount. Ignored and cleared for a percentage. |
| `max_redemptions` | integer | no | Total uses allowed across everyone. Pass null for unlimited. |
| `max_redemptions_per_user` | integer | no | How many times one subscriber may use the code. |
| `minimum_amount` | number | no | Only apply the code when the plan costs at least this much. Pass null for no minimum. |
| `starts_at` | string | no | ISO 8601 instant the code becomes usable. Pass null to start immediately. |
| `expires_at` | string | no | ISO 8601 instant the code stops working. Pass null for no end date. Must fall after the start. |
| `active` | boolean | no | Whether the code is usable. |
| `plan_ids` | array of string | no | Plan UUIDs to restrict the code to. An empty list covers every plan in the project, including ones added later. |
### Example call
```json
{
"name": "update_coupon",
"arguments": {
"coupon_id": "108e7ad1-2b76-4380-a714-6fc987e6960e"
}
}
```
### What it returns
```json
{
"data": {
"id": "4b9d3e08-a1f6-4275-9c83-7e01d6a2f594",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"code": "BLACKFRIDAY",
"name": "Black Friday 2026 (extended)",
"discount_type": "percentage",
"discount_value": "25.0000",
"currency": null,
"duration": "once",
"max_redemptions": 750,
"max_redemptions_per_user": 1,
"minimum_amount": null,
"redemptions": {
"count": 137,
"remaining": 613,
"exhausted": false
},
"starts_at": "2026-11-27T00:00:00+00:00",
"expires_at": "2026-12-08T00:00:00+00:00",
"plan_ids": [],
"active": true,
"redeemable": true,
"created_at": "2026-11-20T09:14:02+00:00"
}
}
```
The whole row comes back, not only the fields sent, so there is no need for a follow-up
[`get_coupon`](https://docs.subscriby.net/mcp/v1/tools/coupon#get-coupon). `code` is uppercase whatever was sent; `discount_value` is a
decimal string. `redemptions.remaining` is recomputed against the new cap.
### How it fails
- `TEAM_TIER_REQUIRED` — the project owner's tier no longer includes coupons. error.context.reason
- `VALIDATION_FAILED` — one entry per offending field in error.context: code outside 3–64
- `RESOURCE_NOT_FOUND` — unknown coupon_id, a coupon on another team's project, one outside the
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-coupon:update.
### Example prompts
> "Extend coupon `4b9d3e08-a1f6-4275-9c83-7e01d6a2f594` to 8 December and raise the cap to 750"
> "uses."
> "Restrict the WELCOME10 code to the Starter plan only — everything else about it stays."
> "Remove the expiry from the launch code so it runs indefinitely."
### Related
- [get_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#get-coupon) — read the current values first.
- [activate_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#activate-coupon)
- [deactivate_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#deactivate-coupon)
- [create_coupon](https://docs.subscriby.net/mcp/v1/tools/coupon#create-coupon) — the same rules, on a new code.
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans) — resolve plan UUIDs for plan_ids.
- [Coupons API](https://docs.subscriby.net/api/v1/reference/coupons#update-a-coupon) — the REST equivalent.
- [Coupon Codes](https://docs.subscriby.net/creators/coupon-codes) — the feature walkthrough.
---
# Creator Task Tools
Source: https://docs.subscriby.net/mcp/v1/tools/creator-task
A creator task is a grant no connector can hand over: a perk the creator delivers by hand once a purchase entitles a member to it. These tools list what is waiting and mark a task complete, which is what releases the member's grant.
## Tools
- [`complete_creator_task`](#complete-creator-task) — Complete Creator Task (destructive)
- [`list_creator_tasks`](#list-creator-tasks) — List Creator Tasks (read)
## complete_creator_task
Mark a hand-arranged perk as handed over, so the subscriber's grant is issued and announced.
Records that the creator did the thing a manual perk needed: the task is marked done by the token's user, the access ledger row moves from `pending` to `granted`, and `creator_task.completed` then `member.resource_added` are raised for the grant. Use it after the creator confirms they handed the perk over — never to tidy the list.
> **It records a fact about the real world**
>
> Completing a task says the member received their perk. Read the task back with
> [`list_creator_tasks`](https://docs.subscriby.net/mcp/v1/tools/creator-task#list-creator-tasks) and confirm with the creator first.
- Requires ability: `project-subscription:update`
- Runs the same action as [`POST /v1/projects/{project}/creator-tasks/{task}/complete`](https://docs.subscriby.net/api/v1/reference/creator-tasks#complete-a-creator-task)
- Fires events: [`creator_task.completed`](https://docs.subscriby.net/webhooks/v1/events/creator-task#creator-task-completed), [`member.resource_added`](https://docs.subscriby.net/webhooks/v1/events/member#member-resource-added)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `task_id` | string | yes | UUID of the task to complete, from `list_creator_tasks` or the `creator_task.opened` event. |
### Example call
```json
{
"name": "complete_creator_task",
"arguments": {
"task_id": "8de7644c-efb8-4400-a3d0-1df54c9d7454"
}
}
```
### What it returns
```json
{
"data": {
"id": "9e4b2c71-3d58-4a6f-b0e2-7c1d5f8a9b34",
"grant_id": "7d1c3e9a-2b64-4f0e-9a58-3c6b1d8e2f47",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"subscriber_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"resource_title": "Welcome packet",
"instructions": "Give Ada Lovelace access to Welcome packet.",
"due_at": null,
"completed_at": "2026-09-12T10:35:00+00:00",
"completed_by_user_id": "0a7d3c1e-5b28-4f96-a3e1-6d9c2b8f4e10",
"created_at": "2026-09-12T10:05:30+00:00"
}
}
```
### How it fails
- `VALIDATION_FAILED` — the task is already done, or its purchase has ended so there is nothing left to grant.
- `RESOURCE_NOT_FOUND` — unknown task_id, or a task outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:update.
### Example prompts
> "I've sent Ada her welcome packet — mark that task done."
### Related
- [list_creator_tasks](https://docs.subscriby.net/mcp/v1/tools/creator-task#list-creator-tasks)
- [Creator Tasks API](https://docs.subscriby.net/api/v1/reference/creator-tasks)
## list_creator_tasks
List the hand-arranged perks a creator still has to hand over — one task per subscriber and manual resource — or the ones already done.
A manual resource is a perk no connector can give, so a purchase that includes one opens a task for the creator and the access waits. This lists those tasks for one project: by default the open ones (not yet done, purchase still standing), oldest first, so the longest-waiting member is at the top. Read it to answer "who is still waiting for something I hand over myself?".
- Requires ability: `project-subscription:view`
- Runs the same action as [`GET /v1/projects/{project}/creator-tasks`](https://docs.subscriby.net/api/v1/reference/creator-tasks#list-a-projects-creator-tasks)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose tasks to list. |
| `status` | `open`, `completed`, `all` | no | Which tasks: `open` (default: not yet done, purchase still standing), `completed`, or `all`. |
| `limit` | integer | no | Tasks per page (1..100, default 25). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_creator_tasks",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "9e4b2c71-3d58-4a6f-b0e2-7c1d5f8a9b34",
"grant_id": "7d1c3e9a-2b64-4f0e-9a58-3c6b1d8e2f47",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"subscriber_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"resource_title": "Welcome packet",
"instructions": "Give Ada Lovelace access to Welcome packet.",
"due_at": null,
"completed_at": null,
"completed_by_user_id": null,
"created_at": "2026-09-12T10:05:30Z"
}
],
"meta": { "page": 1, "limit": 25, "total": 1, "has_more": false }
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — project_id is not a project the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:view.
### Example prompts
> "What do I still have to hand over to members of project `7f3d1c92-…`?"
> "Who has been waiting longest for their welcome packet?"
### Related
- [complete_creator_task](https://docs.subscriby.net/mcp/v1/tools/creator-task#complete-creator-task) — mark one done.
- [Creator Tasks API](https://docs.subscriby.net/api/v1/reference/creator-tasks)
---
# Disaster Recovery Tools
Source: https://docs.subscriby.net/mcp/v1/tools/disaster-recovery
Disaster Recovery is Subscriby's answer to a connector outage or a lost channel: what the probes found, what was recovered and whether it can still be undone. These tools read incidents and recoveries, follow a roll call live and request the standby or replacement of a resource.
## Tools
- [`get_recovery_allowances`](#get-recovery-allowances) — Get Recovery Allowances (read)
- [`get_recovery_readiness`](#get-recovery-readiness) — Get Recovery Readiness (read)
- [`get_recovery_roll_call`](#get-recovery-roll-call) — Get Recovery Roll Call (read)
- [`get_recovery_settings`](#get-recovery-settings) — Get Recovery Settings (read)
- [`get_resource_standby`](#get-resource-standby) — Get Resource Standby (read)
- [`list_recovery_incidents`](#list-recovery-incidents) — List Recovery Incidents (read)
- [`list_recovery_operations`](#list-recovery-operations) — List Recovery Operations (read)
- [`notify_members_of_recovery`](#notify-members-of-recovery) — Notify Members Of Recovery (destructive)
- [`nudge_pending_readmissions`](#nudge-pending-readmissions) — Nudge Pending Readmissions (write)
- [`remove_resource_standby`](#remove-resource-standby) — Remove Resource Standby (destructive)
- [`remove_standby_installation`](#remove-standby-installation) — Remove Standby Installation (destructive)
- [`request_resource_replacement`](#request-resource-replacement) — Request Resource Replacement (write)
- [`request_resource_standby`](#request-resource-standby) — Request Resource Standby (write)
- [`revert_recovery_operation`](#revert-recovery-operation) — Revert Recovery Operation (destructive)
- [`set_resource_standby_mirror`](#set-resource-standby-mirror) — Set Resource Standby Mirror (write)
- [`update_recovery_settings`](#update-recovery-settings) — Update Recovery Settings (write)
- [`use_resource_standby`](#use-resource-standby) — Use Resource Standby (destructive)
- [`withdraw_resource_replacement_request`](#withdraw-resource-replacement-request) — Withdraw Resource Replacement Request (write)
- [`withdraw_resource_standby_request`](#withdraw-resource-standby-request) — Withdraw Resource Standby Request (write)
## get_recovery_allowances
Read how many self-service Disaster Recoveries of each kind the creator the token acts for may still run, what support has released on top, and when the allowance returns. Takes no input.
Answer "can I still run a recovery, or do I need support?". Self-service recovery is allowed once per kind (`account`, `bot`, `resources`) inside a rolling window; beyond that, support reviews the account and may release a grant. Each row carries what the window still allows (`self_service_remaining`), what support has released and not yet spent (`grant_remaining`), their sum (`remaining`), whether a recovery of that kind would be allowed right now (`allowed`), whether the next one would spend a grant (`uses_grant`), when the kind was last recovered and when the self-service allowance returns. `meta` carries the window itself. The rows are the ones [`GET /v1/recovery/allowances`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-the-recovery-allowances) returns.
- Requires ability: `project-recovery:view-any`
- Runs the same action as [`GET /v1/recovery/allowances`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-the-recovery-allowances)
- Annotations: Read-only
### Arguments
This tool takes no arguments.
### Example call
```json
{
"name": "get_recovery_allowances",
"arguments": {}
}
```
### What it returns
```json
{
"data": [
{
"kind": "resources",
"kind_label": "Channels and Groups",
"self_service_remaining": 0,
"grant_remaining": 1,
"remaining": 1,
"allowed": true,
"uses_grant": true,
"last_used_at": "2026-09-12T03:19:10Z",
"next_self_service_at": "2026-12-11T03:19:10Z"
}
],
"meta": { "window_days": 90, "self_service_uses": 1 }
}
```
One row per kind, in the order `account`, `bot`, `resources`.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:view-any.
### Example prompts
> "If a channel gets banned tonight, will the platform be allowed to swap it for me?"
> "When does my self-service channel recovery come back?"
### Related
- [list_recovery_operations](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#list-recovery-operations) — the recoveries that spent the allowance.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
- [Allowances and support review](https://docs.subscriby.net/disaster-recovery/allowances-and-support-review)
## get_recovery_readiness
Read the Disaster Recovery readiness checklist for the creator the token acts for — every line with its state, and the totals the dashboard card shows. Takes no input.
Answer "how ready is this account for the next ban?". The checklist is the one the **Disaster Recovery → Readiness** page shows. The account's own lines come first: a second factor and a backup sign-in account. Then each installed connector's lines, worded by that connector and named by `connector` (Telegram's: a standby bot for every project, a standby for every channel and group, automatic failover, a live mirror). Last the two reminders: a copy of the content kept elsewhere, and a second human administrator in every space. Each line says whether it is in place, whether the plan lacks the feature (`locked`), the state in words and where the fix lives; the totals say how many verifiable lines are done. The payload is exactly what [`GET /v1/recovery/readiness`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-the-readiness-checklist) returns.
- Requires ability: `project-recovery:view-any`
- Runs the same action as [`GET /v1/recovery/readiness`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-the-readiness-checklist)
- Annotations: Read-only
### Arguments
This tool takes no arguments.
### Example call
```json
{
"name": "get_recovery_readiness",
"arguments": {}
}
```
### What it returns
```json
{
"data": {
"prevention": true,
"completed": 4,
"total": 6,
"complete": false,
"items": [
{
"key": "second_factor",
"connector": null,
"connector_name": null,
"label": "Two-Factor Authentication or a Passkey",
"description": "Recovery moves your identity, so the account that runs it should be hard to take over.",
"icon": "shield-check",
"prevention": false,
"reminder": false,
"done": true,
"locked": false,
"detail": "Enabled",
"url": "https://app.subscriby.net/settings/security"
}
]
},
"meta": {}
}
```
`done` is `true`, `false`, or `null` when the platform can only remind (`reminder: true`); reminders never count toward `total`.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:view-any.
### Example prompts
> "How ready am I for a Telegram ban? What's still missing?"
> "Which of my disaster-recovery precautions are locked behind my plan?"
### Related
- [list_recovery_incidents](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#list-recovery-incidents) — what is broken right now.
- [get_recovery_allowances](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-allowances) — how many recoveries the quota still allows.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
- [Active Disaster Prevention](https://docs.subscriby.net/disaster-recovery/active-disaster-prevention)
## get_recovery_roll_call
Read where the re-admission after one Disaster Recovery operation stands — how many members hold a fresh link, how many have joined, who is still outside, and whether a reminder may go out now.
After a channel or group is swapped, every active member is re-admitted through a fresh link, and this is the live tally: how many the swap set out to re-admit (`total`), how many hold a new link (`regranted`), how many could not be given one (`failed`), how many the installation could not message (`unreachable`) and were told by email instead (`emailed`), how many have joined (`joined`), who is still outside (`pending`), how many reminders went out (`nudged`), whether the queue has handled everyone (`settled`), and whether the creator may remind the stragglers right now (`can_nudge`, with `nudge_available_at` while the cooldown runs). The payload is exactly what [`GET /v1/recovery/operations/{operation}/roll-call`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-recoverys-roll-call) returns.
A recovery of a kind that re-admits nobody (an account relink, a bot replacement) answers the same shape with every counter at zero.
- Requires ability: `project-recovery:view`
- Runs the same action as [`GET /v1/recovery/operations/{operation}/roll-call`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-recoverys-roll-call)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `operation_id` | string | yes | UUID of the recovery operation, from `list_recovery_operations`. |
### Example call
```json
{
"name": "get_recovery_roll_call",
"arguments": {
"operation_id": "628e1c56-7a20-4e00-a346-9a11e2052a7a"
}
}
```
### What it returns
```json
{
"data": {
"total": 340,
"regranted": 340,
"failed": 0,
"unreachable": 12,
"emailed": 12,
"joined": 338,
"pending": 2,
"nudged": 1,
"settled": true,
"cooling_down": false,
"can_nudge": true,
"last_nudged_at": "2026-09-12T09:00:00Z",
"nudge_available_at": "2026-09-12T10:00:00Z"
},
"meta": { "operation_id": "9a4d2e71-5b38-4c6f-8e12-3d7c9b0a5f21" }
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — operation_id is not an operation in this creator's ledger.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:view.
### Example prompts
> "How many members are still outside after last night's failover of Lounge?"
> "Has everyone re-joined after the Signals swap, or should I remind them?"
### Related
- [list_recovery_operations](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#list-recovery-operations) — find the operation.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
- [What members see](https://docs.subscriby.net/disaster-recovery/what-members-see)
## get_recovery_settings
Read one project's Disaster Recovery settings — automatic failover and its fee consent, how members are told after a swap, and whether a standby installation is kept.
The switches the Prevention page holds per project. `auto_failover_enabled` says whether the platform may swap a banned channel for its standby on its own, and `auto_failover_email_consented_at` when the creator accepted the per-email fee that failover may charge; `email_delivery` says how members are told after a channel swap the creator ran (`self`: the creator tells them; `platform`: Subscriby emails them at the fee); `standby_installation_registered` whether a spare bot is kept. The payload is exactly what the REST endpoint returns. The REST twin is [`GET /v1/projects/{project}/recovery/settings`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-projects-recovery-settings).
- Requires ability: `project-recovery:view`
- Runs the same action as [`GET /v1/projects/{project}/recovery/settings`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-projects-recovery-settings)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose recovery settings to read. |
### Example call
```json
{
"name": "get_recovery_settings",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"auto_failover_enabled": true,
"auto_failover_email_consented_at": "2026-09-01T14:20:00Z",
"email_delivery": "platform",
"email_delivery_label": "Subscriby emails them",
"standby_installation_registered": true
},
"meta": {}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — project_id is not a project the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:view.
### Example prompts
> "Is automatic failover on for Research Premium?"
> "Do I keep a standby bot for that project?"
### Related
- [update_recovery_settings](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#update-recovery-settings) — change them.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## get_resource_standby
Read the standby kept for one resource — its health, whether posts are mirrored into it, and when it was last probed and written to.
A standby is a spare channel or group the bot already administers, linked to one resource so a ban can be answered by a swap. This reads it: `health_status` (with the connector's `health_reason` code when degraded), `mirror_enabled`, `health_checked_at`, `last_mirrored_at`, `linked_at`. The standby's own identifier on the platform is deliberately absent. The REST twin is [`GET /v1/projects/{project}/resources/{resource}/standby`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-resources-standby).
- Requires ability: `project-recovery:view`
- Runs the same action as [`GET /v1/projects/{project}/resources/{resource}/standby`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-resources-standby)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource whose standby to read. |
### Example call
```json
{
"name": "get_resource_standby",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"mirror_enabled": true,
"health_status": "healthy",
"health_status_label": "Healthy",
"health_reason": null,
"health_checked_at": "2026-09-12T02:00:00Z",
"last_mirrored_at": "2026-09-12T01:45:12Z",
"linked_at": "2026-09-01T14:22:09Z"
},
"meta": {}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — the resource keeps no standby, or resource_id is not a resource the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:view.
### Example prompts
> "Is the standby for Signals healthy and mirrored?"
### Related
- [set_resource_standby_mirror](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#set-resource-standby-mirror)
- [use_resource_standby](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#use-resource-standby)
- [remove_resource_standby](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#remove-resource-standby)
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## list_recovery_incidents
List the Disaster Recovery incidents of the creator the token acts for — what the health probes found broken, open by default, with the reason in the connector's words.
Answer "is anything of mine banned or broken right now?". An incident is one problem a probe detected and has not yet seen fixed: the creator's sign-in account unreachable (`account`), a project's installation refused by the platform (`bot`), or a space the project sells gone or no longer administered (`resources`). Each row names the connector, the project and resource, the reason as the connector's code and in words, when it was detected and, once healed or fixed, when and by which operation it was resolved. The rows are the ones [`GET /v1/recovery/incidents`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#list-incidents) returns.
- Requires ability: `project-recovery:view-any`
- Runs the same action as [`GET /v1/recovery/incidents`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#list-incidents)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | `open`, `resolved`, `all` | no | Which incidents: `open` (default: still needing attention), `resolved`, or `all`. |
| `limit` | integer | no | Incidents per page (1..100, default 25). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_recovery_incidents",
"arguments": {
"status": "open",
"limit": 1
}
}
```
### What it returns
```json
{
"data": [
{
"id": "6f1e9b27-4c3a-4d58-9e02-b7a1c5d38f64",
"kind": "resources",
"kind_label": "Channels and Groups",
"status": "open",
"status_label": "Open",
"connector": "telegram",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"project_name": "Research Premium",
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"resource_title": "Signals",
"reason": "chat_not_found",
"reason_label": "Channel not found",
"reason_explanation": "**Most likely cause:** Telegram deleted or banned the channel. Replace it with a new one.",
"detected_at": "2026-09-12T03:19:04Z",
"resolved_at": null,
"resolved_by_operation_id": null
}
],
"meta": { "page": 1, "limit": 25, "total": 1, "has_more": false }
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:view-any.
### Example prompts
> "Is any of my channels banned right now?"
> "Show me every recovery incident from the last quarter, resolved ones included."
### Related
- [list_recovery_operations](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#list-recovery-operations) — what was done about them.
- [get_recovery_readiness](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-readiness) — how ready the account is for the next one.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
- [How detection works](https://docs.subscriby.net/disaster-recovery/how-detection-works)
## list_recovery_operations
List every Disaster Recovery operation of the creator the token acts for — by the creator, the platform or on demand — with its state and whether it can still be undone.
Answer "what has recovery done for me, and can I still undo it?". An operation is one recovery ever run: by the creator, by the platform on their behalf (`automatic`, the failover you sleep through) or on demand (`on_demand`, a Swap & Grant of a healthy resource). Each row carries its kind, its state, the connector and project, when it started and finished, the failure reason when it failed, and its undo state: `revertible` for whether it can be undone right now and `revert_window_ends_at` for when that closes. The rows are the ones [`GET /v1/recovery/operations`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#list-recovery-operations) returns.
- Requires ability: `project-recovery:view-any`
- Runs the same action as [`GET /v1/recovery/operations`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#list-recovery-operations)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | `account`, `bot`, `resources` | no | Only recoveries of this kind: `account`, `bot` or `resources`. Omit for every kind. |
| `status` | `started`, `completed`, `failed`, `reverted` | no | Only operations in this state: `started`, `completed`, `failed` or `reverted`. Omit for every state. |
| `limit` | integer | no | Operations per page (1..100, default 25). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_recovery_operations",
"arguments": {
"kind": "account",
"status": "started"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "9a4d2e71-5b38-4c6f-8e12-3d7c9b0a5f21",
"kind": "resources",
"kind_label": "Channels and Groups",
"status": "completed",
"status_label": "Completed",
"connector": "telegram",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"project_name": "Research Premium",
"grant_id": null,
"automatic": true,
"on_demand": false,
"failure_reason": null,
"started_at": "2026-09-12T03:19:10Z",
"completed_at": "2026-09-12T03:19:42Z",
"reverted_at": null,
"revertible": true,
"revert_window_ends_at": "2026-09-13T03:19:42Z"
}
],
"meta": { "page": 1, "limit": 25, "total": 1, "has_more": false }
}
```
### How it fails
- `VALIDATION_FAILED` — kind or status is not one of the accepted values; the context names them.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:view-any.
### Example prompts
> "Did the platform fail anything over for me last night?"
> "Which of my channel swaps can I still undo?"
### Related
- [get_recovery_roll_call](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-roll-call) — where the re-admission after one operation stands.
- [list_recovery_incidents](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#list-recovery-incidents) — the problems these operations answered.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
- [History and Undo](https://docs.subscriby.net/disaster-recovery/history-and-undo)
## notify_members_of_recovery
Email every member the project can reach that its bot changed after a bot replacement, at the per-email fee. Sent once per recovery.
After a bot replacement, members who only ever talked to the old bot need the new one's link. This emails every member the project can reach by email with the new bot and the portal, at the per-email fee added to the creator's transaction fees, and remembers that it did: a second call answers with the count already sent and sends nothing. It spends money and reaches real people, so confirm with the creator first.
This tool is annotated destructive: a client that supports the annotation asks a human before running it. The REST twin is [`POST /v1/recovery/operations/{operation}/notify-members`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#tell-members-about-a-bot-replacement).
- Requires ability: `project-recovery:update`
- Runs the same action as [`POST /v1/recovery/operations/{operation}/notify-members`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#tell-members-about-a-bot-replacement)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `operation_id` | string | yes | UUID of the bot recovery whose project members to email. |
### Example call
```json
{
"name": "notify_members_of_recovery",
"arguments": {
"operation_id": "628e1c56-7a20-4e00-a346-9a11e2052a7a"
}
}
```
### What it returns
```json
{
"data": {
"operation_id": "c2e8f1a9-6d47-4b3e-9f05-1a8d7c2e4b60",
"notified": 340
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — the recovery belongs to no project, or the token is not the project owner's.
- `RESOURCE_NOT_FOUND` — operation_id is not in this creator's ledger.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:update.
### Example prompts
> "Email my members about the new bot for Research Premium."
### Related
- [Replacing a banned bot](https://docs.subscriby.net/connectors/telegram/replacing-a-banned-bot) — when to send it.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## nudge_pending_readmissions
Send one reminder, with a fresh link, to every member a channel recovery re-admitted who has not joined the new chat yet.
After a swap, `get_recovery_roll_call` shows who is still outside (`pending`) and whether a reminder may go out (`can_nudge`). This sends it: one message per straggler with a fresh link, recorded on the operation so the cooldown can be enforced. Every reminder reaches a real person, so do not loop it. The REST twin is [`POST /v1/recovery/operations/{operation}/nudge`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#remind-a-recoverys-stragglers).
- Requires ability: `project-recovery:update`
- Runs the same action as [`POST /v1/recovery/operations/{operation}/nudge`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#remind-a-recoverys-stragglers)
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `operation_id` | string | yes | UUID of the completed channel recovery whose stragglers to remind. |
### Example call
```json
{
"name": "nudge_pending_readmissions",
"arguments": {
"operation_id": "628e1c56-7a20-4e00-a346-9a11e2052a7a"
}
}
```
### What it returns
```json
{
"data": {
"operation_id": "9a4d2e71-5b38-4c6f-8e12-3d7c9b0a5f21",
"nudged": 2
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — nobody is waiting, the last reminder is too recent, the recovery is not a completed channel swap, or not the creator's own; the context carries the reason.
- `RESOURCE_NOT_FOUND` — operation_id is not in this creator's ledger.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:update.
### Example prompts
> "Remind the two members who have not joined the new Lounge yet."
### Related
- [get_recovery_roll_call](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-roll-call) — read can_nudge first.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## remove_resource_standby
Stop keeping a standby for one resource; the chat itself is untouched and the resource keeps its live chat.
The standby row is forgotten and mirroring into it stops. Automatic failover can no longer swap this resource until a new standby is linked. Only the project owner may do this; a resource with no standby succeeds with nothing to remove.
This tool is annotated destructive: a client that supports the annotation asks a human before running it. The REST twin is [`DELETE /v1/projects/{project}/resources/{resource}/standby`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-resources-standby).
- Requires ability: `project-recovery:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/resources/{resource}/standby`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#remove-a-resources-standby)
- Fires events: [`recovery.standby_removed`](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-standby-removed)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource whose standby to remove. |
### Example call
```json
{
"name": "remove_resource_standby",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"standby": null
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — the token is not the project owner's.
- `RESOURCE_NOT_FOUND` — resource_id is not a resource the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:delete.
### Example prompts
> "Drop the standby on Signals; that spare channel is being repurposed."
### Related
- [recovery.standby_removed](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-standby-removed) — fires with reason: removed.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## remove_standby_installation
Stop keeping the standby installation (the spare bot) registered for a project.
The standby is forgotten and withdrawn from the connector; the live installation, members and access stay as they are, and the next bot replacement will need a fresh credential pasted on the dashboard. Registering a standby is not offered to tokens because it takes a credential; removing one is. Only the project owner may do this.
This tool is annotated destructive: a client that supports the annotation asks a human before running it. The REST twin is [`DELETE /v1/projects/{project}/recovery/standby-installation`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#remove-a-projects-standby-installation).
- Requires ability: `project-recovery:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/recovery/standby-installation`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#remove-a-projects-standby-installation)
- Fires events: [`recovery.standby_removed`](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-standby-removed)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose standby installation to remove. |
### Example call
```json
{
"name": "remove_standby_installation",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"standby_installation_registered": false
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — the token is not the project owner's.
- `RESOURCE_NOT_FOUND` — project_id is not a project the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:delete.
### Example prompts
> "Drop the standby bot on Research Premium; I am replacing it with a new one from the dashboard."
### Related
- [get_recovery_settings](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-settings) — standby_installation_registered says whether one is kept.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## request_resource_replacement
Ask the creator, through the connector, to pick the chat that replaces a resource's; the swap runs the moment they choose. Nothing is swapped by the call itself.
Replacing a chat is a conversation with the creator on the connector: the bot messages them with a picker, and when they choose the resource is re-pointed, old links revoked, every active member re-admitted and a recovery operation recorded. For a degraded resource the swap spends the channel recovery allowance (or joins the recovery already absorbing swaps); for a healthy one it is on demand and free. Take an unanswered request back with `withdraw_resource_replacement_request`. The REST twin is [`POST /v1/projects/{project}/resources/{resource}/replacement/request`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#request-a-standby-for-a-resource).
- Requires ability: `project-recovery:create`
- Runs the same action as [`POST /v1/projects/{project}/resources/{resource}/replacement/request`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#request-a-replacement-for-a-resource)
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource whose chat should be replaced. |
### Example call
```json
{
"name": "request_resource_replacement",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"status": "request_sent"
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — the resource is a perk rather than a place, the creator cannot be reached, or the allowance is spent with no recovery to join.
- `RESOURCE_NOT_FOUND` — resource_id is not a resource the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:create.
### Example prompts
> "Ask me to pick the new channel for Signals."
### Related
- [withdraw_resource_replacement_request](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#withdraw-resource-replacement-request) — take it back.
- [recovery.resource_replaced](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-resource-replaced) — fires when the creator picks.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## request_resource_standby
Ask the creator, through the connector, to pick the chat that becomes the standby for one resource. Nothing is linked by the call itself.
Linking a standby is a conversation with the creator on the connector: the bot messages them with a picker, and the standby is linked the moment they choose. The API cannot name the chat directly (that would take a platform identifier), so it starts the conversation. Needs the Growth plan and the project owner. Take an unanswered request back with `withdraw_resource_standby_request`. The REST twin is [`POST /v1/projects/{project}/resources/{resource}/standby/request`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#request-a-standby-for-a-resource).
- Requires ability: `project-recovery:create`
- Runs the same action as [`POST /v1/projects/{project}/resources/{resource}/standby/request`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#request-a-standby-for-a-resource)
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource that should get a standby. |
### Example call
```json
{
"name": "request_resource_standby",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"status": "request_sent"
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — the resource is a perk rather than a place, the creator cannot be reached on the connector, the plan lacks the prevention features, or the token is not the project owner's.
- `RESOURCE_NOT_FOUND` — resource_id is not a resource the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:create.
### Example prompts
> "Ask me to pick a standby channel for Signals."
### Related
- [withdraw_resource_standby_request](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#withdraw-resource-standby-request) — take it back.
- [recovery.standby_registered](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-standby-registered) — fires when the creator picks.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## revert_recovery_operation
Undo a completed Disaster Recovery inside its window — a swapped channel put back, or the previous sign-in account restored.
A channel recovery is undone one swap at a time: name the `resource_id` to put back on its old chat, and every active member is re-admitted there again. An account relink is undone whole, moving sign-in back to the previous account, signing the creator out everywhere and opening a disputed-relink incident for support; name no `resource_id`. A bot replacement cannot be undone. The window is 24 hours from completion by default; `revertible` on the operation says whether it is still open. Only the creator who ran the recovery may undo it.
This tool is annotated destructive: a client that supports the annotation asks a human before running it. The REST twin is [`POST /v1/recovery/operations/{operation}/revert`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#undo-a-recovery).
- Requires ability: `project-recovery:update`
- Runs the same action as [`POST /v1/recovery/operations/{operation}/revert`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#undo-a-recovery)
- Fires events: [`recovery.operation_reverted`](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-operation-reverted), [`recovery.incident_opened`](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-incident-opened), [`project.resource.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-updated)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `operation_id` | string | yes | UUID of the completed recovery to undo, from `list_recovery_operations`. |
| `resource_id` | string | no | For a channel recovery: UUID of the resource to put back on its old chat. Omit for an account relink. |
### Example call
```json
{
"name": "revert_recovery_operation",
"arguments": {
"operation_id": "628e1c56-7a20-4e00-a346-9a11e2052a7a"
}
}
```
### What it returns
```json
{
"data": {
"id": "9a4d2e71-5b38-4c6f-8e12-3d7c9b0a5f21",
"kind": "resources",
"kind_label": "Channels and Groups",
"status": "reverted",
"status_label": "Reverted",
"connector": "telegram",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"project_name": "Research Premium",
"grant_id": null,
"automatic": false,
"on_demand": true,
"failure_reason": null,
"started_at": "2026-09-12T10:00:00Z",
"completed_at": "2026-09-12T10:00:31Z",
"reverted_at": "2026-09-12T21:50:00Z",
"revertible": false,
"revert_window_ends_at": "2026-09-13T10:00:31Z"
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — the window has closed, the swap is already put back, a bot replacement, a channel recovery with no resource_id, or not the creator's own recovery; the context carries the reason.
- `RESOURCE_NOT_FOUND` — operation_id is not in this creator's ledger, or resource_id is not a resource they manage.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:update.
### Example prompts
> "Undo the Signals swap from this morning, the new channel was the wrong one."
> "Move my sign-in back to the old account."
### Related
- [list_recovery_operations](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#list-recovery-operations) — find the operation and its revertible flag.
- [recovery.operation_reverted](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-operation-reverted) — the event it raises.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## set_resource_standby_mirror
Switch the live mirror into a resource's standby on or off, so a failover lands members in a channel that already holds the content.
When on, every post made in the channel is copied into the standby as it is made. Only a channel can be mirrored (a group cannot), the resource must keep a standby, switching on needs the Growth plan, and only the project owner may do it. Returns the standby as it now stands. The REST twin is [`PATCH /v1/projects/{project}/resources/{resource}/standby`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-resources-standby).
- Requires ability: `project-recovery:update`
- Runs the same action as [`PATCH /v1/projects/{project}/resources/{resource}/standby`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#switch-a-standbys-live-mirror)
- Annotations: Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource whose standby to mirror into. |
| `mirror` | boolean | yes | True to copy every post into the standby as it is made, false to stop. |
### Example call
```json
{
"name": "set_resource_standby_mirror",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30",
"mirror": true
}
}
```
### What it returns
```json
{
"data": {
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"mirror_enabled": true,
"health_status": "healthy",
"health_status_label": "Healthy",
"health_reason": null,
"health_checked_at": "2026-09-12T02:00:00Z",
"last_mirrored_at": "2026-09-12T01:45:12Z",
"linked_at": "2026-09-01T14:22:09Z"
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — a group, no standby, the plan lacks the prevention features, or the token is not the project owner's.
- `RESOURCE_NOT_FOUND` — resource_id is not a resource the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:update.
### Example prompts
> "Start mirroring Signals into its standby."
### Related
- [get_resource_standby](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-resource-standby) — read the switch.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## update_recovery_settings
Change one project's Disaster Recovery settings: switch automatic failover on or off (with the fee consent), and choose how members are told after a swap. Fields left out keep their value.
Drives the two actions the Prevention page drives, so a refusal is the same sentence the dashboard shows. Switching `auto_failover` on needs `accepts_email_fee: true` in the same call, because a failover may email the members the bot cannot reach at a per-email fee the creator must agree to, and it needs the Growth plan. `email_delivery` is `self` or `platform`. Only the project owner may change these; a teammate is refused. Returns the settings as they now stand. The REST twin is [`PATCH /v1/projects/{project}/recovery/settings`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-projects-recovery-settings).
- Requires ability: `project-recovery:update`
- Runs the same action as [`PATCH /v1/projects/{project}/recovery/settings`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#update-a-projects-recovery-settings)
- Annotations: Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project. |
| `auto_failover` | boolean | no | Switch automatic failover on (true) or off (false). Omit to leave it. |
| `accepts_email_fee` | boolean | no | Required true when switching failover on: the creator accepts the per-email fee a failover may charge. |
| `email_delivery` | `platform`, `self` | no | How members are told after a swap the creator ran: `self` or `platform`. Omit to leave it. |
### Example call
```json
{
"name": "update_recovery_settings",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"auto_failover_enabled": true,
"auto_failover_email_consented_at": "2026-09-01T14:20:00Z",
"email_delivery": "platform",
"email_delivery_label": "Subscriby emails them",
"standby_installation_registered": true
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — failover switched on without accepts_email_fee, the plan lacks the prevention features, an unknown email_delivery, or the token is not the project owner's; the context carries the reason.
- `RESOURCE_NOT_FOUND` — project_id is not a project the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:update.
### Example prompts
> "Turn on automatic failover for Research Premium; yes, I accept the email fee."
> "Have Subscriby email my members after a channel swap instead of me."
### Related
- [get_recovery_settings](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-settings) — read them first.
- [Active Disaster Prevention](https://docs.subscriby.net/disaster-recovery/active-disaster-prevention) — what each switch does.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## use_resource_standby
Swap a resource onto its standby right now: old links revoked, every active member re-admitted, the standby consumed. Returns the recovery operation it ran under.
The one-click swap from the Prevention page. The resource points at the standby chat, the old invite links are revoked and every active member is re-admitted into the standby; the standby is consumed. A recovery operation records it: for a healthy resource with no open incident the swap is on demand and spends no allowance, otherwise it spends (or joins) the channel recovery allowance. Undo is offered for 24 hours through `revert_recovery_operation`. Only the project owner may do this.
This tool is annotated destructive: a client that supports the annotation asks a human before running it. The REST twin is [`POST /v1/projects/{project}/resources/{resource}/standby/use`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#get-a-resources-standby).
- Requires ability: `project-recovery:create`
- Runs the same action as [`POST /v1/projects/{project}/resources/{resource}/standby/use`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#use-a-resources-standby-now)
- Fires events: [`recovery.operation_started`](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-operation-started), [`recovery.operation_completed`](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-operation-completed), [`recovery.resource_replaced`](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-resource-replaced), [`recovery.standby_removed`](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-standby-removed), [`recovery.incident_resolved`](https://docs.subscriby.net/webhooks/v1/events/recovery#recovery-incident-resolved), [`project.resource.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-updated)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource to swap onto its standby. |
### Example call
```json
{
"name": "use_resource_standby",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"id": "9a4d2e71-5b38-4c6f-8e12-3d7c9b0a5f21",
"kind": "resources",
"kind_label": "Channels and Groups",
"status": "completed",
"status_label": "Completed",
"connector": "telegram",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"project_name": "Research Premium",
"grant_id": null,
"automatic": false,
"on_demand": true,
"failure_reason": null,
"started_at": "2026-09-12T10:00:00Z",
"completed_at": "2026-09-12T10:00:31Z",
"reverted_at": null,
"revertible": true,
"revert_window_ends_at": "2026-09-13T10:00:31Z"
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — no healthy standby, the channel recovery allowance is spent with no recovery to join, or the token is not the project owner's.
- `RESOURCE_NOT_FOUND` — resource_id is not a resource the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:create.
### Example prompts
> "Move Signals onto its standby channel now."
### Related
- [revert_recovery_operation](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#revert-recovery-operation) — put the old chat back.
- [get_recovery_roll_call](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#get-recovery-roll-call) — how re-admission is going.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## withdraw_resource_replacement_request
Take back the replacement request the creator has open on the connector. Takes no input.
A creator holds one replacement request at a time, whichever resource it was for, so there is nothing to name: the picker the bot sent stops waiting for an answer. When none is open the call changes nothing and still succeeds. Only the account holder may do this. The REST twin is [`DELETE /v1/projects/{project}/resources/{resource}/replacement/request`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#request-a-standby-for-a-resource).
- Requires ability: `project-recovery:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/resources/{resource}/replacement/request`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#withdraw-a-replacement-request)
- Annotations: Idempotent
### Arguments
This tool takes no arguments.
### Example call
```json
{
"name": "withdraw_resource_replacement_request",
"arguments": {}
}
```
### What it returns
```json
{
"data": {
"status": "withdrawn"
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — the token is not the account holder's.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:delete.
### Example prompts
> "Cancel the replacement picker you sent me."
### Related
- [request_resource_replacement](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#request-resource-replacement) — the request being withdrawn.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
## withdraw_resource_standby_request
Take back the standby request the creator has open on the connector. Takes no input.
A creator holds one standby request at a time, whichever resource it was for, so there is nothing to name: the picker the bot sent stops waiting for an answer. When none is open the call changes nothing and still succeeds. Only the account holder may do this. The REST twin is [`DELETE /v1/projects/{project}/resources/{resource}/standby/request`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#request-a-standby-for-a-resource).
- Requires ability: `project-recovery:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/resources/{resource}/standby/request`](https://docs.subscriby.net/api/v1/reference/disaster-recovery#withdraw-a-standby-request)
- Annotations: Idempotent
### Arguments
This tool takes no arguments.
### Example call
```json
{
"name": "withdraw_resource_standby_request",
"arguments": {}
}
```
### What it returns
```json
{
"data": {
"status": "withdrawn"
},
"meta": {}
}
```
### How it fails
- `VALIDATION_FAILED` — the token is not the account holder's.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-recovery:delete.
### Example prompts
> "Never mind the standby picker, withdraw it."
### Related
- [request_resource_standby](https://docs.subscriby.net/mcp/v1/tools/disaster-recovery#request-resource-standby) — the request being withdrawn.
- [Recovery API](https://docs.subscriby.net/api/v1/reference/disaster-recovery)
---
# Member Tools
Source: https://docs.subscriby.net/mcp/v1/tools/member
Members are a project's subscribers, the people every payment is made for and every access grant resolves to. These tools list and read them, ban, unban and kick, read the accounts they linked, and send a broadcast to a segment of them through the project's connector.
## Tools
- [`ban_member`](#ban-member) — Ban Member (destructive)
- [`broadcast_message`](#broadcast-message) — Broadcast Message (destructive)
- [`find_member_by_identity`](#find-member-by-identity) — Find Member by Identity (read)
- [`get_subscriber`](#get-subscriber) — Get Subscriber (read)
- [`kick_member`](#kick-member) — Kick Member (destructive)
- [`list_member_identities`](#list-member-identities) — List Member Identities (read)
- [`list_subscribers`](#list-subscribers) — List Subscribers (read)
- [`preview_broadcast_audience`](#preview-broadcast-audience) — Preview Broadcast Audience (read)
- [`unban_member`](#unban-member) — Unban Member (destructive)
- [`unlink_member_identity`](#unlink-member-identity) — Unlink Member Identity (destructive)
## ban_member
Ban a project member. Flips status to banned and emits member.banned. Noop when the member is already banned.
Ban a project member. Delegates to an Action so cache invalidation runs and a `member.banned` event emits with the optional reason. Removal from the gated places runs on the connector's pipeline. Noop when the member is already banned.
- Requires ability: `project-user:update`
- Runs the same action as [`POST /v1/projects/{project}/members/{member}/ban`](https://docs.subscriby.net/api/v1/reference/members#ban-a-member)
- Fires events: [`member.banned`](https://docs.subscriby.net/webhooks/v1/events/member#member-banned)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `member_id` | string | yes | UUID of the project member to ban. |
| `reason` | string | no | Optional human-readable reason that lands in the member.banned event payload. |
### Example call
```json
{
"name": "ban_member",
"arguments": {
"member_id": "3b452919-ed9d-4c00-a5a8-daf606b39425"
}
}
```
### What it returns
```json
{
"data": {
"id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"status": "banned"
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown member_id, or the member belongs to a team outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project-user:update.
### Example prompts
> "Ban member `2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80` for repeated spam."
> "Block this user from the project."
### Related
- [unban_member](https://docs.subscriby.net/mcp/v1/tools/member#unban-member)
- [kick_member](https://docs.subscriby.net/mcp/v1/tools/member#kick-member)
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers)
## broadcast_message
Send a message through the project's connector to a segment of its members. Destructive and irreversible — preview the audience and get human approval first.
Queue a message through the project's connector to a segment of its members. The send runs in the background at the pace the connector declares (28 messages a second on Telegram); the tool returns the audience it resolved and how many members it will reach.
> **Warning**
>
> **Destructive and irreversible.** Every recipient is a real person and the
> message lands in their private chat. Call
> [`preview_broadcast_audience`](https://docs.subscriby.net/mcp/v1/tools/member#preview-broadcast-audience) first,
> show the human the exact message text, the segment and the recipient count,
> and get explicit approval before calling this. A broadcast cannot be recalled.
Not idempotent — calling twice sends twice. There is no de-duplication, because two identical broadcasts minutes apart is a legitimate thing a creator may want.
- Requires ability: `broadcast:send`
- Runs the same action as [`POST /v1/projects/{project}/broadcasts`](https://docs.subscriby.net/api/v1/reference/broadcasts#send-a-broadcast)
- Fires events: [`broadcast.queued`](https://docs.subscriby.net/webhooks/v1/events/broadcast#broadcast-queued), [`broadcast.completed`](https://docs.subscriby.net/webhooks/v1/events/broadcast#broadcast-completed)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose members will receive the message. |
| `message` | string | yes | The message body, 1-4096 characters. Canonical HTML subset only (, , , , , , , , ); anything else is stripped before sending. |
| `audience` | string | no | Segment to address: all, customer, trialing, lead, churned, expiring_soon, cancelled_still_active, paused, trialing_cardless, all_pass_holders, all_pass_holders_not_in_queue, pass_holders, pass_holders_not_in_queue. Defaults to `all`, which reaches every member with a linked chat. |
| `pass_window_id` | string | no | Required for the single-window pass segments (`pass_holders`, `pass_holders_not_in_queue`); ignored by the others. |
| `plan_id` | string | no | Optional. Narrows the segment to members on one subscription plan, by plan UUID. Composes with the segment rather than replacing it: `customer` plus a plan reaches people paying for that plan right now, `churned` plus a plan reaches people who held it and left. Rejected for `lead` (never subscribed) and for the pass segments (their plan is implied by the window). |
| `expiring_within_days` | integer | no | Only meaningful for `expiring_soon`. How many days ahead to look, 1-90, default 7. A member is counted only if their access genuinely lapses: an auto-renewing subscription is not expiring. |
### Example call
```json
{
"name": "broadcast_message",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"message": ""
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"audience": "customer",
"pass_window_id": null,
"plan_id": null,
"expiring_within_days": null,
"recipient_estimate": 128,
"estimated_seconds": 5,
"status": "queued"
}
}
```
`status` is always `queued` — this reports what was addressed, not what was delivered. Subscribe to [`broadcast.completed`](https://docs.subscriby.net/webhooks/v1/events/broadcast#broadcast-completed) for the sent and failed tallies.
### Narrowing the audience
`plan_id` composes with `audience` rather than replacing it: `customer` plus a plan addresses people paying for that plan right now, `churned` plus a plan addresses people who held it and left. The plan and the state describe the **same** subscription, so a member paying for one plan who once trialled another is not matched by the first form.
Four of the segments describe a subscription rather than a member status, which is what a status cannot express — somebody who cancelled but has three weeks left carries the same status as somebody renewing happily:
| Segment | Addresses |
| ------------------------ | -------------------------------------------- |
| `expiring_soon` | Access lapses inside `expiring_within_days`. |
| `cancelled_still_active` | Renewal is off, but time remains. |
| `paused` | Paused rather than ended. |
| `trialing_cardless` | On trial with no card on file. |
> **An auto-renewing subscription is never `expiring_soon`**
>
> A subscription's end date is rewritten to the new period end on every renewal,
> so a date inside the horizon describes the next **invoice**, not an expiry —
> counting it would place every monthly subscriber in this segment once a month.
> A member appears only once their access genuinely lapses: renewal is off, or
> the plan does not renew at all.
### Refusals
The tool returns a validation error, and sends nothing, when:
- the project has no connected bot
- the body is empty or longer than 4096 characters
- the audience is unrecognised
- a pass segment is requested by a project whose plan no longer includes passes
- a single-window segment is requested with no `pass_window_id`
- a `plan_id` is sent with `lead` or a pass segment, which cannot be narrowed by plan
- a `plan_id` does not belong to the project
A plan filter the segment cannot use is **refused rather than ignored**: silently dropping it would return a recipient count for a different audience than the one described, and the caller has no way to notice.
### Related
- [preview_broadcast_audience](https://docs.subscriby.net/mcp/v1/tools/member#preview-broadcast-audience) — always call this first.
- [Broadcasts API](https://docs.subscriby.net/api/v1/reference/broadcasts) — the same operation over REST.
- [broadcast.* events](https://docs.subscriby.net/webhooks/v1/events/broadcast) — react to the outcome.
## find_member_by_identity
Find the project member who connected a given account on a connector, from the connector key and the platform's own id.
Turns the id a bot or a server hands over into a Subscriby member: give it the project, the connector the account lives on and the platform's own id for the account, and it answers the same row `get_subscriber` does, with the member's connected accounts embedded as `identities[]`. This is the lookup for a workflow that starts from a platform event (a message, a join, a role change) and needs to know which member, if any, that account belongs to. An account nobody in the project has connected is `RESOURCE_NOT_FOUND`, indistinguishable from an unknown project, so a caller never learns whether an id exists elsewhere. The REST twin is the `identity` filter on `GET /v1/projects/{project}/members`, which returns a one-row page instead.
- Requires ability: `project-user:view-any`
- Runs the same action as [`GET /v1/projects/{project}/members`](https://docs.subscriby.net/api/v1/reference/members#list-a-projects-members)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the member belongs to. |
| `connector` | string | yes | Connector key the account lives on, as `list_connectors` lists them. |
| `external_id` | string | yes | The platform's own id for the account, as `list_member_identities` shows it. |
### Example call
```json
{
"name": "find_member_by_identity",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"connector": "",
"external_id": "ed4a6282-9eeb-4000-a37d-1b3be3c765ee"
}
}
```
### What it returns
```json
{
"data": {
"id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"status": "customer",
"name": "Ada Lovelace",
"identities": [
{
"id": "6a1f0c3e-2d4b-4e8a-9c7d-1b5e3f9a2c4d",
"connector": "telegram",
"source": "handshake",
"preferred": true,
"external_id": "123456789",
"display_name": "Ada Lovelace",
"username": "ada",
"linked_at": "2026-09-12T10:05:00Z"
}
],
"created_at": "2026-05-18T10:40:00Z"
}
}
```
The row is the one `get_subscriber` and `list_subscribers` render; `identities[]` lists every account the member connected, not only the one asked about.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown project_id, a project outside the token's scope, or an account nobody in the project has connected.
- `TOKEN_MISSING_ABILITY` — token lacks project-user:view-any.
### Example prompts
> "Which member of project `7f3d1c92-…` is the Telegram account `123456789`?"
> "A member wrote to the bot from account `123456789`. Find them and tell me their subscription status."
### Related
- [get_subscriber](https://docs.subscriby.net/mcp/v1/tools/member#get-subscriber)
- [list_member_identities](https://docs.subscriby.net/mcp/v1/tools/member#list-member-identities)
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers)
## get_subscriber
One project member by UUID, in the list_subscribers row shape. Returns two email addresses verbatim — scrub before forwarding.
Fetch one member (subscriber) of a project by id when you already know it — from a webhook payload's
`subscriber_id`, a support conversation, or an earlier list. The row is the one
[`list_subscribers`](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers) returns, so a member read here is identical to
the same member read from the list.
> **PII**
>
> Both `email` (member-chosen and verified) and `billing_email` (whatever they
> gave a payment provider) come back verbatim, as do the platform ids of the member's connected
> accounts under `identities`.
> Scrub them before forwarding to any third-party LLM, log sink or analytics
> surface.
- Requires ability: `project-user:view`
- Runs the same action as [`GET /v1/projects/{project}/members/{member}`](https://docs.subscriby.net/api/v1/reference/members#get-a-member)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `member_id` | string | yes | UUID of the project member to fetch. |
### Example call
```json
{
"name": "get_subscriber",
"arguments": {
"member_id": "3b452919-ed9d-4c00-a5a8-daf606b39425"
}
}
```
### What it returns
```json
{
"data": {
"id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"name": "Ada Lovelace",
"email": "ada@example.com",
"email_verified": true,
"billing_email": "ada.l@example.com",
"identities": [
{
"connector": "telegram",
"external_id": "123456789",
"display_name": "Ada Lovelace",
"username": "ada",
"preferred": true
}
],
"status": "customer",
"joined_at": "2026-05-18T10:05:00+00:00"
}
}
```
`name` is the display name Subscriby derived for the member; `status` is their standing in the
project (`lead`, `customer`, and the churned and moderation states), not a subscription status.
`identities` lists the member's connected accounts, the one they prefer to be reached on first: the
connector key, the platform's own id for the account, and the display name and username the platform
reported. A member who only ever used the portal and connected nothing has an empty list.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown member_id, or a member of a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-user:view.
### Example prompts
> "Who is member `2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80`? The webhook only gave me the id."
> "Is the member behind this support conversation a paying customer or a lead?"
> "Has that member verified their email?"
### Related
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers) — search and page through members.
- [get_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#get-subscription) — what the member holds.
- [Members API](https://docs.subscriby.net/api/v1/reference/members) — the REST equivalent.
## kick_member
Kick a project member without banning them. Status rolls to churned; the subscriber can re-join later.
Kick a project member without a permanent ban. Status rolls to `churned`; removal from the gated places happens on the connector's pipeline. The subscriber can re-join via a fresh purchase or access code later. Delegates to an Action so cache invalidation runs and `member.kicked` emits with the optional reason.
- Requires ability: `project-user:update`
- Runs the same action as [`POST /v1/projects/{project}/members/{member}/kick`](https://docs.subscriby.net/api/v1/reference/members#kick-a-member)
- Fires events: [`member.kicked`](https://docs.subscriby.net/webhooks/v1/events/member#member-kicked)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `member_id` | string | yes | UUID of the project member to kick. |
| `reason` | string | no | Optional human-readable reason that lands in the member.kicked event payload. |
### Example call
```json
{
"name": "kick_member",
"arguments": {
"member_id": "3b452919-ed9d-4c00-a5a8-daf606b39425"
}
}
```
### What it returns
```json
{
"data": {
"id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"status": "churned"
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown member_id, or the member belongs to a team outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project-user:update.
### Example prompts
> "Kick member `2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80` for inactivity."
> "Remove this subscriber's access but don't ban them."
### Related
- [ban_member](https://docs.subscriby.net/mcp/v1/tools/member#ban-member)
- [unban_member](https://docs.subscriby.net/mcp/v1/tools/member#unban-member)
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers)
## list_member_identities
List the platform accounts a project member has connected, with which one the project reaches first.
Lists a member's connected accounts on the connectors: the platform (`connector`), the platform's own id, name and handle for the account, how the link was proven (`source`) and whether it is the one the project reaches first (`preferred`). Members connect accounts themselves — from the portal's Account & Recovery screen or by talking to the project's bot — so there is no tool to connect one for them. The same rows are embedded as `identities[]` in `get_subscriber`'s REST counterpart, `GET /v1/projects/{project}/members/{member}`.
- Requires ability: `project-user:view`
- Runs the same action as [`GET /v1/projects/{project}/members/{member}/identities`](https://docs.subscriby.net/api/v1/reference/members#list-a-members-connected-accounts)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `member_id` | string | yes | UUID of the project member whose connected accounts to list. |
### Example call
```json
{
"name": "list_member_identities",
"arguments": {
"member_id": "3b452919-ed9d-4c00-a5a8-daf606b39425"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "6a1f0c3e-2d4b-4e8a-9c7d-1b5e3f9a2c4d",
"connector": "telegram",
"source": "handshake",
"preferred": true,
"external_id": "123456789",
"display_name": "Ada Lovelace",
"username": "ada",
"linked_at": "2026-09-12T10:05:00Z"
}
]
}
```
`id` is the link, which is what `unlink_member_identity` takes; `external_id` is the platform's id for the account. `source` is one of `handshake` (connected from the portal and confirmed by the bot, or a portal sign-in), `adopted` (taken over from a sibling project), `portal`, `bot` (the bot met the account first) or `backfill` (migrated from before connectors).
### How it fails
- `RESOURCE_NOT_FOUND` — unknown member_id, or the member belongs to a team outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project-user:view.
### Example prompts
> "Which Telegram account is member `2a91c4e7-…` connected with?"
> "List the connected accounts of this subscriber."
### Related
- [unlink_member_identity](https://docs.subscriby.net/mcp/v1/tools/member#unlink-member-identity)
- [get_subscriber](https://docs.subscriby.net/mcp/v1/tools/member#get-subscriber)
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers)
## list_subscribers
Paginated list of project members with optional filters by project, status, and free-text search.
List members (project subscribers) with optional filters by `project_id`, status, and a free-text search across name, email and billing email. Results are scoped to the token's team and ordered newest-first.
> **Warning**
>
> Emails are returned verbatim. Scrub before forwarding to external systems.
- Requires ability: `project-user:view-any`
- Runs the same action as [`GET /v1/projects/{project}/members`](https://docs.subscriby.net/api/v1/reference/members#list-a-projects-members)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID to narrow the result. |
| `status` | `lead`, `trialing`, `customer`, `churned`, `banned` | no | Member status filter. One of: lead, trialing, customer, churned, banned |
| `search` | string | no | Case-insensitive partial match against name + email. |
| `limit` | integer | no | Maximum members to return per page (1..100). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_subscribers",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"status": "lead"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"name": "Jane Doe",
"email": "jane@example.com",
"email_verified": true,
"billing_email": "jane@example.com",
"identities": [
{
"connector": "telegram",
"external_id": "123456789",
"display_name": "Jane Doe",
"username": "janedoe",
"preferred": true
}
],
"status": "customer",
"joined_at": "2026-05-18T10:05:00Z"
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 41,
"has_more": true
}
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks project-user:view-any.
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see: unknown, another team's, or outside the token's scope:project: allow-list. Without a project_id, the list spans only the projects the allow-list admits.
- `VALIDATION_FAILED` — status is not one of the member statuses; the error context lists the supported values.
### Caveats
- **`email` and `billing_email` mean different things.** `email` is an address the member chose and verified, and is their portal sign-in credential. `billing_email` is whatever they typed at a payment provider's checkout — never verified, never used for authentication. Do not treat a `billing_email` as a confirmed way to reach someone.
- Most members have `email: null`. They join through a bot and are never asked for one, so `billing_email` is often the only address on record — which is what makes it useful for matching a refund request to a subscription.
- `billing_email` is `null` for anyone who joined by redeeming an access code, because no payment ever took place. It is also `null` for members whose last payment predates automatic capture.
### Example prompts
> "List churned subscribers in project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "Search for members with 'gmail' in their email across my Research Premium project."
### Related
- [ban_member](https://docs.subscriby.net/mcp/v1/tools/member#ban-member)
- [kick_member](https://docs.subscriby.net/mcp/v1/tools/member#kick-member)
- [get_activity_log](https://docs.subscriby.net/mcp/v1/tools/observability#get-activity-log)
- [Members API](https://docs.subscriby.net/api/v1/reference/members)
## preview_broadcast_audience
Size a broadcast without sending it. Returns every audience segment with its current recipient count.
Read-only. Returns how many members a broadcast would reach, either for one named segment or for every segment at once. Nothing is sent.
Call this before [`broadcast_message`](https://docs.subscriby.net/mcp/v1/tools/member#broadcast-message), every time, and show the human the count. An audience that silently resolves to _everyone_ is the one broadcast mistake that cannot be undone.
- Requires ability: `broadcast:send`
- Runs the same action as [`GET /v1/projects/{project}/broadcasts/preview`](https://docs.subscriby.net/api/v1/reference/broadcasts#preview-an-audience)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project to size a broadcast for. |
| `audience` | string | no | Size one segment only. Omit to receive every segment with its count. |
| `pass_window_id` | string | no | Needed to size the single-window pass segments; without it they report a null count. |
| `plan_id` | string | no | Optional plan UUID. Sizes each segment as narrowed to that plan. Segments that cannot use a plan filter (`lead`, the pass segments) report their unnarrowed count and a null plan_id. |
| `expiring_within_days` | integer | no | How far ahead `expiring_soon` looks, 1-90, default 7. Ignored by every other segment. |
### Example call
```json
{
"name": "preview_broadcast_audience",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"has_connected_bot": true,
"segments": [
{
"value": "customer",
"label": "Customers Only",
"description": "Members with an active paid subscription.",
"requires_pass_window": false,
"supports_plan_filter": true,
"requires_expiring_within_days": false,
"plan_id": null,
"expiring_within_days": null,
"recipient_estimate": 128,
"estimated_seconds": 5
}
]
}
}
```
Omitting `audience` returns every segment:
A segment that addresses a single window reports `recipient_estimate: null` unless a `pass_window_id` is supplied — it cannot be sized without knowing which window, and a misleading `0` would read as "nobody holds this".
`supports_plan_filter` and `requires_expiring_within_days` say which extra inputs each segment accepts, so an agent can decide what to ask a human for before proposing anything.
Unlike [`broadcast_message`](https://docs.subscriby.net/mcp/v1/tools/member#broadcast-message), a `plan_id` a segment cannot use is **dropped for that segment rather than refused** — the no-`audience` form sizes every segment in one call, and refusing would make a plan filter unusable for the very listing used to choose a segment. Each row reports the `plan_id` it actually applied, so nothing is silently ignored.
Check `has_connected_bot` before proposing a send: a project without one cannot broadcast at all.
### Related
- [broadcast_message](https://docs.subscriby.net/mcp/v1/tools/member#broadcast-message) — the send itself.
- [Broadcasts API](https://docs.subscriby.net/api/v1/reference/broadcasts) — the same preview over REST.
## unban_member
Lift a ban on a project member. Status rolls to churned; next successful payment promotes them back to customer.
Lift a ban on a project member. Status moves from `banned` to `churned` — if the subscriber still has active subscriptions, the next successful-payment transition promotes them to `customer` automatically. Delegates to an Action so cache invalidation runs.
- Requires ability: `project-user:update`
- Runs the same action as [`POST /v1/projects/{project}/members/{member}/unban`](https://docs.subscriby.net/api/v1/reference/members#unban-a-member)
- Fires events: [`member.unbanned`](https://docs.subscriby.net/webhooks/v1/events/member#member-unbanned)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `member_id` | string | yes | UUID of the banned project member to unban. |
### Example call
```json
{
"name": "unban_member",
"arguments": {
"member_id": "3b452919-ed9d-4c00-a5a8-daf606b39425"
}
}
```
### What it returns
```json
{
"data": {
"id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"status": "churned"
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown member_id, or the member belongs to a team outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project-user:update.
### Example prompts
> "Unban member `2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80`."
> "Lift the block on this subscriber."
### Related
- [ban_member](https://docs.subscriby.net/mcp/v1/tools/member#ban-member)
- [kick_member](https://docs.subscriby.net/mcp/v1/tools/member#kick-member)
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers)
## unlink_member_identity
Disconnect one of a project member's platform accounts, keeping them a way to sign in.
Disconnects one of a member's connected accounts on the creator's behalf: the account no longer signs the member in to the portal, and the project's bot no longer knows them by it. The same recovery rule the portal applies to the member holds here — the account cannot be removed when it is the member's last way to sign in (no verified email, linked Google account or other connected account remains). Emits `member.identity_unlinked`. Destructive and not idempotent: a second call answers `RESOURCE_NOT_FOUND`.
- Requires ability: `project-user:update`
- Runs the same action as [`DELETE /v1/projects/{project}/members/{member}/identities/{link}`](https://docs.subscriby.net/api/v1/reference/members#disconnect-a-members-account)
- Fires events: [`member.identity_unlinked`](https://docs.subscriby.net/webhooks/v1/events/member#member-identity-unlinked)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `member_id` | string | yes | UUID of the project member. |
| `identity_id` | string | yes | The link `id` from `list_member_identities` to disconnect. |
### Example call
```json
{
"name": "unlink_member_identity",
"arguments": {
"member_id": "3b452919-ed9d-4c00-a5a8-daf606b39425",
"identity_id": "ae1f7281-7894-4400-a028-0b38d16d37b7"
}
}
```
### What it returns
```json
{
"data": {
"member_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"identity_id": "6a1f0c3e-2d4b-4e8a-9c7d-1b5e3f9a2c4d",
"connector": "telegram",
"removed": true
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown member_id, an identity_id that is not one of that member's links, or a member outside the token's scope.
- `VALIDATION_FAILED` — the account is the member's last way to sign in. Have them add an email or connect another account first.
- `TOKEN_MISSING_ABILITY` — token lacks project-user:update.
### Example prompts
> "Disconnect the Telegram account from member `2a91c4e7-…`; they asked us to."
> "Remove connected account `6a1f0c3e-…` from this subscriber."
### Related
- [list_member_identities](https://docs.subscriby.net/mcp/v1/tools/member#list-member-identities)
- [ban_member](https://docs.subscriby.net/mcp/v1/tools/member#ban-member)
- [kick_member](https://docs.subscriby.net/mcp/v1/tools/member#kick-member)
---
# Observability Tools
Source: https://docs.subscriby.net/mcp/v1/tools/observability
Where to look when something did not happen: webhook endpoints and their deliveries, the activity log of every mutation, and the status of a job another tool queued. These tools read and replay that record.
## Tools
- [`create_webhook_endpoint`](#create-webhook-endpoint) — Create Webhook Endpoint (destructive)
- [`delete_webhook_endpoint`](#delete-webhook-endpoint) — Delete Webhook Endpoint (destructive)
- [`get_activity_log`](#get-activity-log) — Activity Log (read)
- [`get_job_status`](#get-job-status) — Poll MCP Async Job (async)
- [`get_webhook_delivery`](#get-webhook-delivery) — Get Webhook Delivery (read)
- [`get_webhook_endpoint`](#get-webhook-endpoint) — Get Webhook Endpoint (read)
- [`list_webhook_deliveries`](#list-webhook-deliveries) — List Webhook Deliveries (read)
- [`list_webhook_endpoints`](#list-webhook-endpoints) — List Webhook Endpoints (read)
- [`pause_webhook_endpoint`](#pause-webhook-endpoint) — Pause Webhook Endpoint (destructive)
- [`resume_webhook_endpoint`](#resume-webhook-endpoint) — Resume Webhook Endpoint (destructive)
- [`retry_dead_webhook_deliveries`](#retry-dead-webhook-deliveries) — Retry Dead Webhook Deliveries (destructive)
- [`retry_webhook_delivery`](#retry-webhook-delivery) — Retry Webhook Delivery (destructive)
- [`rotate_webhook_endpoint_secret`](#rotate-webhook-endpoint-secret) — Rotate Webhook Endpoint Secret (destructive)
- [`test_webhook_endpoint`](#test-webhook-endpoint) — Test Webhook Endpoint (destructive)
## create_webhook_endpoint
Register an outbound webhook endpoint for the token's team. The signing secret is returned in this response only.
Register a URL for Subscriby to post events to, with the event names it should receive. The
endpoint belongs to the token's team; pass `project_id` to deliver only one project's events.
> **The secret is returned once**
>
> The response carries `secret` alongside the endpoint row. It is the HMAC key
> every delivery to this endpoint is signed with, it is never readable again —
> not by [`get_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#get-webhook-endpoint), not by the
> dashboard — and it can only be replaced with
> [`rotate_webhook_endpoint_secret`](https://docs.subscriby.net/mcp/v1/tools/observability#rotate-webhook-endpoint-secret).
> Hand it to the consumer immediately. This is the one exception to the server's
> rule that a secret is never surfaced.
The URL must be `https` and resolve to a public address; `http` and private-network targets are
refused. Deliveries start at once unless `is_active` is `false`.
- Requires ability: `webhook-endpoint:create`
- Runs the same action as [`POST /v1/webhook-endpoints`](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#register-a-webhook-endpoint)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | A label for the endpoint, shown in the dashboard. |
| `url` | string | yes | The https URL Subscriby posts events to. Must resolve to a public address; http and private-network targets are refused. |
| `events` | array of string | yes | Event names to deliver, from subscriby://enums/webhook-event. At least one. |
| `project_id` | string | no | Optional project UUID: deliver only that project's events. Omit for every project of the team. |
| `allowed_ips` | array of string | no | Optional list of IP addresses or CIDR ranges the endpoint host may resolve to; a delivery whose host resolves outside the list is dead-lettered without being posted. |
| `is_active` | boolean | no | Whether deliveries start right away. Defaults to true. |
### Example call
```json
{
"name": "create_webhook_endpoint",
"arguments": {
"name": "",
"url": "https://example.com",
"events": []
}
}
```
### What it returns
```json
{
"data": {
"id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"name": "CRM sync",
"url": "https://hooks.example.com/subscriby",
"project_id": null,
"events": ["subscription.created", "subscription.cancelled"],
"is_active": true,
"disabled_at": null,
"allowed_ips": [],
"failure_count": 0,
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"created_at": "2026-09-06T09:20:00+00:00",
"secret": "whsec_..."
}
}
```
Every event name must be one the token could subscribe to: each event requires the ability of its
family (`subscription.*` needs `project-subscription:view`, and so on), which is checked at
registration.
### How it fails
- `VALIDATION_FAILED` — per field: an http or private-network URL, an empty or unknown events list, an event the token cannot subscribe to, an allowed_ips entry that is neither an IP address nor a CIDR range.
- `RESOURCE_NOT_FOUND` — unknown project_id, or a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-endpoint:create.
### Example prompts
> "Register `https://hooks.example.com/subscriby` for every subscription event, named 'CRM sync'."
> "Create a webhook endpoint that receives only `support.conversation.opened` for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "Set up an endpoint for payment failures, paused until we've deployed the handler."
### Related
- [list_webhook_endpoints](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-endpoints) — what the team already has.
- [test_webhook_endpoint](https://docs.subscriby.net/mcp/v1/tools/observability#test-webhook-endpoint) — check the consumer end to end.
- [rotate_webhook_endpoint_secret](https://docs.subscriby.net/mcp/v1/tools/observability#rotate-webhook-endpoint-secret) — the only way to get a new secret.
- [Webhook endpoints API](https://docs.subscriby.net/api/v1/reference/webhook-endpoints) — the REST equivalent.
- [Webhook security](https://docs.subscriby.net/webhooks/v1/security) — verifying the signature.
## delete_webhook_endpoint
Remove an outbound webhook endpoint. Destructive — an integration listening on it goes dark at once; its delivery log is kept.
Remove an endpoint the team no longer wants. Deliveries to it stop immediately and its delivery log
is kept for reference. If the intent is "stop it for now",
[`pause_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#pause-webhook-endpoint) does that reversibly and keeps the
secret.
> **Destructive — confirm the endpoint with a human first**
>
> Whatever is listening on the URL stops receiving events with no notice.
> Confirm the exact `endpoint_id` with the creator before calling.
Only the creator who registered the endpoint, or the team owner, may remove it; a fellow team member
is refused with `FORBIDDEN`. Idempotent: an already-deleted id surfaces as `RESOURCE_NOT_FOUND`,
exactly as an unknown or foreign id does.
- Requires ability: `webhook-endpoint:delete`
- Runs the same action as [`DELETE /v1/webhook-endpoints/{endpoint}`](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#delete-a-webhook-endpoint)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | string | yes | UUID of the webhook endpoint to remove. |
### Example call
```json
{
"name": "delete_webhook_endpoint",
"arguments": {
"endpoint_id": "231540dc-049d-4400-a42d-dc69ba798b74"
}
}
```
### What it returns
```json
{
"data": {
"endpoint_id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"deleted": true
}
}
```
### How it fails
- `FORBIDDEN` — the token belongs to a team member who did not register the endpoint and is not the team owner.
- `VALIDATION_FAILED` — the removal was refused for the endpoint's current state.
- `RESOURCE_NOT_FOUND` — unknown or already-deleted endpoint_id, or an endpoint of another team.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-endpoint:delete.
### Example prompts
> "Delete webhook endpoint `c62a08f4-1b7d-4e35-9860-a37f5d21e0b9` — the CRM was decommissioned." (confirm first)"
> "Remove the old staging endpoint from the team."
> "We're replacing the hooks URL; delete the current endpoint after the new one is registered."
### Related
- [pause_webhook_endpoint](https://docs.subscriby.net/mcp/v1/tools/observability#pause-webhook-endpoint) — the reversible alternative.
- [list_webhook_endpoints](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-endpoints) — what is left.
- [Webhook endpoints API](https://docs.subscriby.net/api/v1/reference/webhook-endpoints) — the REST equivalent, a 204.
## get_activity_log
Read the activity log for a given subject. Returns chronological entries with causer, event, and scrubbed properties.
Read the activity log for a given subject (e.g. a project or project-user). Returns reverse-chronological entries with causer, event, and properties. Keys matching `token | secret | password | api_key | api_secret` are stripped before the response returns.
> **Note**
>
> Pass a short morph alias as `subject_type` — `project`,
> `project-subscription`, `project-subscription-plan`, `project-user`,
> `project-resource`, etc. The `subject_id` is the row UUID.
- Requires ability: `activity:read`
- Runs the same action as [`GET /v1/activity`](https://docs.subscriby.net/api/v1/reference/activity#read-a-subjects-activity)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subject_type` | string | yes | Alias of the model the activity is attached to: one of project, subscription, project-subscription, member, project-user, plan, project-subscription-plan, access-code, coupon, project-resource. Model class names are refused. |
| `subject_id` | string | yes | UUID of the subject row. |
| `limit` | integer | no | Maximum entries to return (1..200, default 50). |
### Example call
```json
{
"name": "get_activity_log",
"arguments": {
"subject_type": "",
"subject_id": "0de9d6ed-982e-4f00-a6c2-150ac4fe372e"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "92e4c1b6-70da-4f38-8517-b036ae94d7c2",
"log_name": "default",
"description": "Plan 'Premium Monthly' was updated.",
"event": "updated",
"subject_type": "project-subscription-plan",
"subject_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"causer_type": "user",
"causer_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"properties": {
"changes": {
"price": { "from": "25.00", "to": "29.00" }
}
},
"actor_kind": "human",
"created_at": "2026-05-18T10:05:00Z"
}
],
"meta": {
"subject_type": "project-subscription-plan",
"subject_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"total": 12,
"limit": 50
}
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks activity:read.
- `VALIDATION_FAILED` — subject_type is not one of the supported aliases (model class names are refused; the error context lists the aliases), or subject_id is not a UUID (reason: not_a_uuid).
- `RESOURCE_NOT_FOUND` — the subject does not exist, belongs to another creator, or sits outside the token's scope:project: allow-list. The history is never read before the subject itself resolves, so a foreign id learns nothing.
### Example prompts
> "What recent changes happened to plan `c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b`?"
> "Show me the last 20 actions taken against subscriber `2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80`."
### Related
- [get_project](https://docs.subscriby.net/mcp/v1/tools/project#get-project)
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers)
- [Activity log](https://docs.subscriby.net/teams/abilities)
## get_job_status
Poll a long-running MCP job by id. Returns the current status plus result or error once the worker finishes.
Poll a long-running MCP job by id. Returns the current status plus `result` or `error` once the worker finishes.
Status values: `queued` → `running` → `completed` | `failed`.
Pair it with any tool that hands back a `job_id`. Today only [`bulk_generate_access_codes`](https://docs.subscriby.net/mcp/v1/tools/access-code#bulk-generate-access-codes) enqueues one.
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `job_id` | string | yes | UUID of the async job to poll. |
### Example call
```json
{
"name": "get_job_status",
"arguments": {
"job_id": "d24cf2fc-8231-4000-ac7b-8b45ac7fbc24"
}
}
```
### What it returns
```json
{
"data": {
"job_id": "0a4e7b96-c358-4d12-9f6b-25a8013ce74f",
"tool_name": "bulk_generate_access_codes",
"status": "completed",
"result": { "count": 50, "batch_unique_key": "..." },
"error": null,
"started_at": "2026-05-18T10:05:00Z",
"completed_at": "2026-05-18T10:05:04Z"
}
}
```
`result` and `error` are both `null` until the worker finishes; exactly one is populated afterwards. `started_at` is null while the job is still `queued`.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `RESOURCE_NOT_FOUND` — unknown job_id, or the job was started by a different user.
### Example prompts
> "Is my access-code batch done yet?"
> "Poll that job until it finishes, then show me the codes."
### Related
- [bulk_generate_access_codes](https://docs.subscriby.net/mcp/v1/tools/access-code#bulk-generate-access-codes)
- [Async jobs overview](https://docs.subscriby.net/mcp/v1/async-jobs)
## get_webhook_delivery
One outbound webhook delivery by UUID — its event, status, attempts, the payload that was posted and what the endpoint answered.
Read one delivery back, typically the row [`test_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#test-webhook-endpoint)
returned or one picked out of [`list_webhook_deliveries`](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-deliveries), to see
whether the worker has posted it yet and what the target said. The row shape is the list's.
- Requires ability: `webhook-delivery:view-any`
- Runs the same action as [`GET /v1/webhook-deliveries/{delivery}`](https://docs.subscriby.net/api/v1/reference/webhook-deliveries#get-a-webhook-delivery)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `delivery_id` | string | yes | UUID of the webhook delivery to fetch. |
### Example call
```json
{
"name": "get_webhook_delivery",
"arguments": {
"delivery_id": "f79655c3-045f-4800-a9a1-2c7afa3ac6e5"
}
}
```
### What it returns
```json
{
"data": {
"id": "34f1d78e-05a2-4b69-8c3d-7e921ab06f45",
"endpoint_id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"event": "subscription.created",
"event_id": "01HXZ3Q8M7Y2K4N6P9R1T3V5W7",
"status": "delivered",
"attempts": 3,
"response_status": 200,
"response_excerpt": "{\"ok\":true}",
"payload": {
"id": "evt_01HXZ3Q8M7Y2K4N6P9R1T3V5W7",
"type": "subscription.created",
"created_at": "2026-09-06T10:05:00+00:00",
"api_version": "2026-05-01",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"data": { "subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4" }
},
"next_attempt_at": null,
"delivered_at": "2026-09-06T10:31:00+00:00",
"dead_lettered_at": null,
"created_at": "2026-09-06T10:05:00+00:00"
}
}
```
`attempts` counts every post made so far, so a `delivered` row with `attempts: 3` succeeded on the
third try. `event_id` is the envelope's `id` without its `evt_` prefix — the same value the
consumer saw in the `SB-Event-Id` header, which is how a row here is matched to a line in the
consumer's own log.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown delivery_id, or a delivery to another team's endpoint.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-delivery:view-any.
### Example prompts
> "Has delivery `34f1d78e-05a2-4b69-8c3d-7e921ab06f45` gone out yet, and what did the endpoint answer?"
> "Show me the exact payload our CRM received for that event."
> "Why is that delivery still pending?"
### Related
- [list_webhook_deliveries](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-deliveries) — find the row.
- [retry_webhook_delivery](https://docs.subscriby.net/mcp/v1/tools/observability#retry-webhook-delivery) — replay it if it failed.
- [Webhook deliveries API](https://docs.subscriby.net/api/v1/reference/webhook-deliveries) — the REST equivalent.
## get_webhook_endpoint
One of the team's outbound webhook endpoints by UUID, with its health counters. The signing secret is never returned.
Read one endpoint back: its target, the events it receives, whether it is active, and the health
counters the dashboard shows — total failures, the current consecutive-failure streak and the last
success and failure instants. The row is the one
[`list_webhook_endpoints`](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-endpoints) returns.
> **Never the secret**
>
> The signing secret exists in the
> [`create_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#create-webhook-endpoint) and
> [`rotate_webhook_endpoint_secret`](https://docs.subscriby.net/mcp/v1/tools/observability#rotate-webhook-endpoint-secret)
> responses only. If a consumer has lost it, rotate.
- Requires ability: `webhook-endpoint:view`
- Runs the same action as [`GET /v1/webhook-endpoints/{endpoint}`](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#get-a-webhook-endpoint)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | string | yes | UUID of the webhook endpoint to fetch. |
### Example call
```json
{
"name": "get_webhook_endpoint",
"arguments": {
"endpoint_id": "231540dc-049d-4400-a42d-dc69ba798b74"
}
}
```
### What it returns
```json
{
"data": {
"id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"name": "CRM sync",
"url": "https://hooks.example.com/subscriby",
"project_id": null,
"events": ["subscription.created", "subscription.cancelled"],
"is_active": true,
"disabled_at": null,
"allowed_ips": [],
"failure_count": 2,
"consecutive_failures": 0,
"last_success_at": "2026-09-05T18:00:00+00:00",
"last_failure_at": "2026-08-30T07:12:00+00:00",
"created_at": "2026-05-18T10:05:00+00:00"
}
}
```
`consecutive_failures` is the streak that disables an endpoint when it runs too long;
`failure_count` is the lifetime total. `disabled_at` is set when the endpoint is paused, by a
person or by the streak.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown endpoint_id, or an endpoint of another team.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-endpoint:view.
### Example prompts
> "Is webhook endpoint `c62a08f4-1b7d-4e35-9860-a37f5d21e0b9` healthy? When did it last succeed?"
> "Which events does the CRM sync endpoint receive?"
> "Is that endpoint paused, and since when?"
### Related
- [list_webhook_endpoints](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-endpoints) — every endpoint of the team.
- [list_webhook_deliveries](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-deliveries) — why the counters look the way they do.
- [Webhook endpoints API](https://docs.subscriby.net/api/v1/reference/webhook-endpoints) — the REST equivalent.
## list_webhook_deliveries
The team's outbound webhook delivery log, newest first — what was posted, what the endpoint answered, and where each row is on the retry ladder.
Every event Subscriby posts to one of the team's endpoints is a **delivery**: one row per endpoint
per event, with the signed envelope that was sent, the target's response status and an excerpt of
its body, and the row's position on the retry ladder. This is the dashboard's delivery log over
MCP, for seeing why a consumer rejected an event before deciding to
[`retry_webhook_delivery`](https://docs.subscriby.net/mcp/v1/tools/observability#retry-webhook-delivery) or
[`retry_dead_webhook_deliveries`](https://docs.subscriby.net/mcp/v1/tools/observability#retry-dead-webhook-deliveries).
> **The status tabs**
>
> `pending` — queued or waiting for its next attempt; `delivered` — the target
> answered `2xx`; `failed` — the last attempt failed and the ladder has more
> tries; `dead` — the ladder ran out, and only a retry brings it back. `all` is
> the default. Anything else is `VALIDATION_FAILED`.
- Requires ability: `webhook-delivery:view-any`
- Runs the same action as [`GET /v1/webhook-deliveries`](https://docs.subscriby.net/api/v1/reference/webhook-deliveries#list-webhook-deliveries)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | no | Tab of the delivery log: all, pending, delivered, failed or dead. Defaults to all. |
| `limit` | integer | no | Maximum deliveries to return per page (1..100). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_webhook_deliveries",
"arguments": {
"status": "",
"limit": 1
}
}
```
### What it returns
```json
{
"data": [
{
"id": "34f1d78e-05a2-4b69-8c3d-7e921ab06f45",
"endpoint_id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"event": "subscription.created",
"event_id": "01HXZ3Q8M7Y2K4N6P9R1T3V5W7",
"status": "failed",
"attempts": 2,
"response_status": 500,
"response_excerpt": "upstream unavailable",
"payload": {
"id": "evt_01HXZ3Q8M7Y2K4N6P9R1T3V5W7",
"type": "subscription.created",
"created_at": "2026-09-06T10:05:00+00:00",
"api_version": "2026-05-01",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"data": { "subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4" }
},
"next_attempt_at": "2026-09-06T10:15:00+00:00",
"delivered_at": null,
"dead_lettered_at": null,
"created_at": "2026-09-06T10:05:00+00:00"
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 312,
"has_more": true
}
}
```
`payload` is the envelope exactly as the endpoint received it, so a consumer's bug can be reproduced
from the log. `response_excerpt` is the first few kilobytes of the target's body, not the whole
response.
### How it fails
- `VALIDATION_FAILED` — status is not one of the five tabs, or limit/page is out of range.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-delivery:view-any.
### Example prompts
> "Show me the dead-lettered webhook deliveries."
> "Which deliveries failed in the last hour, and what did our endpoint answer?"
> "List the pending deliveries — is the queue backing up?"
### Related
- [get_webhook_delivery](https://docs.subscriby.net/mcp/v1/tools/observability#get-webhook-delivery) — one row by id.
- [retry_webhook_delivery](https://docs.subscriby.net/mcp/v1/tools/observability#retry-webhook-delivery) — replay one failed or dead row.
- [retry_dead_webhook_deliveries](https://docs.subscriby.net/mcp/v1/tools/observability#retry-dead-webhook-deliveries) — replay everything dead-lettered since an instant.
- [Webhook deliveries API](https://docs.subscriby.net/api/v1/reference/webhook-deliveries) — the REST equivalent.
- [Retries and delivery](https://docs.subscriby.net/webhooks/v1/retries-and-delivery) — the ladder itself.
## list_webhook_endpoints
List outbound webhook endpoints registered for the caller's team. Optional project_id filter, optional active_only filter. Secrets are never returned.
List outbound webhook endpoints registered for the caller's team. Optionally scoped to a single project, or filtered to active endpoints only. Useful for sanity-checking integrations after a suspected outage.
> **Note**
>
> Endpoint secrets are never returned by this tool. They are surfaced only at
> the moment of creation or rotation via the dashboard or the `rotate-secret`
> REST endpoint.
- Requires ability: `webhook-endpoint:view-any`
- Runs the same action as [`GET /v1/webhook-endpoints`](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#list-webhook-endpoints)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID. Omit to list team-wide endpoints and all project-scoped endpoints within the team. |
| `active_only` | boolean | no | When true, hides endpoints with is_active=false or disabled_at set. |
### Example call
```json
{
"name": "list_webhook_endpoints",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"active_only": true
}
}
```
### What it returns
```json
{
"data": [
{
"id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"name": "Zapier trigger",
"url": "https://hooks.zapier.com/...",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"events": ["subscription.created", "payment.succeeded"],
"is_active": true,
"disabled_at": null,
"allowed_ips": null,
"failure_count": 0,
"consecutive_failures": 0,
"last_success_at": "2026-05-18T10:05:00Z",
"last_failure_at": null,
"created_at": "2026-04-01T10:05:00Z"
}
],
"meta": { "total": 3 }
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks webhook-endpoint:manage.
### Example prompts
> "List our active webhook endpoints."
> "Which webhook endpoints have had failures recently?"
### Related
- [Webhook security](https://docs.subscriby.net/webhooks/v1/security)
- [Testing and debugging](https://docs.subscriby.net/webhooks/v1/testing-and-debugging)
## pause_webhook_endpoint
Stop deliveries to an endpoint without removing it. Events raised while paused are not queued for it.
The dashboard's on/off switch for an endpoint. A paused endpoint keeps its secret, its event
subscriptions and its delivery log; what stops is new deliveries. Use it during a consumer outage or
a migration, then [`resume_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#resume-webhook-endpoint).
> **Paused events are not replayed**
>
> Events raised while the endpoint is paused are **not** queued for it, so they
> are gone for that endpoint when it resumes. If the consumer must see
> everything, leave the endpoint on and let the retry ladder and
> [`retry_dead_webhook_deliveries`](https://docs.subscriby.net/mcp/v1/tools/observability#retry-dead-webhook-deliveries)
> carry it through the outage instead.
Only the creator who registered the endpoint, or the team owner, may pause it; a fellow team member
is refused with `FORBIDDEN`. Idempotent: an already-paused endpoint is a no-op.
- Requires ability: `webhook-endpoint:update`
- Runs the same action as [`POST /v1/webhook-endpoints/{endpoint}/pause`](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#pause-a-webhook-endpoint)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | string | yes | UUID of the webhook endpoint to pause. |
### Example call
```json
{
"name": "pause_webhook_endpoint",
"arguments": {
"endpoint_id": "231540dc-049d-4400-a42d-dc69ba798b74"
}
}
```
### What it returns
```json
{
"data": {
"id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"name": "CRM sync",
"url": "https://hooks.example.com/subscriby",
"project_id": null,
"events": ["subscription.created", "subscription.cancelled"],
"is_active": false,
"disabled_at": "2026-09-06T09:20:00+00:00",
"allowed_ips": [],
"failure_count": 2,
"consecutive_failures": 0,
"last_success_at": "2026-09-05T18:00:00+00:00",
"last_failure_at": "2026-08-30T07:12:00+00:00",
"created_at": "2026-05-18T10:05:00+00:00"
}
}
```
### How it fails
- `FORBIDDEN` — the token belongs to a team member who did not register the endpoint and is not the team owner.
- `VALIDATION_FAILED` — the pause was refused for the endpoint's current state.
- `RESOURCE_NOT_FOUND` — unknown endpoint_id, or an endpoint of another team.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-endpoint:update.
### Example prompts
> "Pause webhook endpoint `c62a08f4-1b7d-4e35-9860-a37f5d21e0b9` while we migrate the CRM."
> "Stop deliveries to the staging endpoint for now, don't delete it."
> "Switch the finance webhook off until the handler is fixed."
### Related
- [resume_webhook_endpoint](https://docs.subscriby.net/mcp/v1/tools/observability#resume-webhook-endpoint) — the reverse.
- [delete_webhook_endpoint](https://docs.subscriby.net/mcp/v1/tools/observability#delete-webhook-endpoint) — when it is not coming back.
- [Webhook endpoints API](https://docs.subscriby.net/api/v1/reference/webhook-endpoints) — the REST equivalent.
## resume_webhook_endpoint
Start deliveries to a paused endpoint again and clear its failure streak. Events raised while paused are not replayed.
The reverse of [`pause_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#pause-webhook-endpoint), and also the way to
bring back an endpoint the failure streak disabled. Resuming clears the consecutive-failure count,
so a target that was fixed while paused is not disabled again on its first miss.
> **Nothing is replayed on resume**
>
> Events raised while the endpoint was paused were never queued for it and are
> not sent now. Rows that **dead-lettered before the pause** are a different
> matter: replay those with
> [`retry_dead_webhook_deliveries`](https://docs.subscriby.net/mcp/v1/tools/observability#retry-dead-webhook-deliveries)
> once the consumer is healthy.
Only the creator who registered the endpoint, or the team owner, may resume it; a fellow team member
is refused with `FORBIDDEN`. Idempotent: an already-active endpoint is a no-op.
- Requires ability: `webhook-endpoint:update`
- Runs the same action as [`POST /v1/webhook-endpoints/{endpoint}/resume`](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#resume-a-webhook-endpoint)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | string | yes | UUID of the webhook endpoint to resume. |
### Example call
```json
{
"name": "resume_webhook_endpoint",
"arguments": {
"endpoint_id": "231540dc-049d-4400-a42d-dc69ba798b74"
}
}
```
### What it returns
```json
{
"data": {
"id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"name": "CRM sync",
"url": "https://hooks.example.com/subscriby",
"project_id": null,
"events": ["subscription.created", "subscription.cancelled"],
"is_active": true,
"disabled_at": null,
"allowed_ips": [],
"failure_count": 2,
"consecutive_failures": 0,
"last_success_at": "2026-09-05T18:00:00+00:00",
"last_failure_at": "2026-08-30T07:12:00+00:00",
"created_at": "2026-05-18T10:05:00+00:00"
}
}
```
### How it fails
- `FORBIDDEN` — the token belongs to a team member who did not register the endpoint and is not the team owner.
- `VALIDATION_FAILED` — the resume was refused for the endpoint's current state.
- `RESOURCE_NOT_FOUND` — unknown endpoint_id, or an endpoint of another team.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-endpoint:update.
### Example prompts
> "Resume webhook endpoint `c62a08f4-1b7d-4e35-9860-a37f5d21e0b9` — the CRM migration is done."
> "Turn the finance webhook back on and replay whatever dead-lettered since Friday."
> "The consumer is fixed; re-enable the endpoint the failure streak disabled."
### Related
- [pause_webhook_endpoint](https://docs.subscriby.net/mcp/v1/tools/observability#pause-webhook-endpoint) — the reverse.
- [retry_dead_webhook_deliveries](https://docs.subscriby.net/mcp/v1/tools/observability#retry-dead-webhook-deliveries) — replay what dead-lettered before the pause.
- [Webhook endpoints API](https://docs.subscriby.net/api/v1/reference/webhook-endpoints) — the REST equivalent.
## retry_dead_webhook_deliveries
Replay every webhook delivery the team dead-lettered since an instant, once the consumer is fixed. Defaults to the last 24 hours.
The dashboard's Replay button. After a consumer outage, every row that ran out of retries sits in
the `dead` tab; this tool puts all of them since `since` back on the ladder in one call and answers
with how many it retried. Omit `since` for the last 24 hours, the window the dashboard uses.
> **Every replayed row posts its event again**
>
> Confirm the consumer is healthy first — a test with
> [`test_webhook_endpoint`](https://docs.subscriby.net/mcp/v1/tools/observability#test-webhook-endpoint) is the cheap check
> — or the same rows dead-letter a second time. Only dead-lettered rows are
> touched; failed rows still on the ladder retry on their own.
`since` may not be in the future. Repeating the call after a successful replay retries nothing, because
the rows are no longer dead.
- Requires ability: `webhook-delivery:retry`
- Runs the same action as [`POST /v1/webhook-deliveries/retry-dead`](https://docs.subscriby.net/api/v1/reference/webhook-deliveries#retry-every-recent-dead-delivery)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `since` | string | no | ISO 8601 instant; rows dead-lettered at or after it are replayed. Defaults to 24 hours ago. |
### Example call
```json
{
"name": "retry_dead_webhook_deliveries",
"arguments": {
"since": ""
}
}
```
### What it returns
```json
{
"data": {
"retried": 17,
"since": "2026-09-05T09:20:00+00:00"
}
}
```
`since` echoes the instant actually used, so a call with no argument shows the 24-hour boundary it
applied.
### How it fails
- `VALIDATION_FAILED` — since is malformed or in the future.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-delivery:retry.
### Example prompts
> "Replay everything that dead-lettered since Friday 18:00 — the CRM is back up."
> "Retry all dead webhook deliveries from the last day."
> "How many deliveries did the replay put back on the ladder?"
### Related
- [retry_webhook_delivery](https://docs.subscriby.net/mcp/v1/tools/observability#retry-webhook-delivery) — one row at a time.
- [list_webhook_deliveries](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-deliveries) — see the dead tab before and after.
- [resume_webhook_endpoint](https://docs.subscriby.net/mcp/v1/tools/observability#resume-webhook-endpoint) — a paused endpoint needs resuming first.
- [Webhook deliveries API](https://docs.subscriby.net/api/v1/reference/webhook-deliveries) — the REST equivalent.
## retry_webhook_delivery
Replay one failed or dead-lettered webhook delivery from the start of the retry ladder. A pending or delivered row is refused.
Post the same event to the same endpoint again, once the consumer is fixed. The row goes back to
`pending` with its attempt count reset and walks the retry ladder from the start; the returned row is
that pending state.
> **Only failed or dead rows**
>
> A `pending` row is already going to be posted, and a `delivered` row already
> was — replaying either would post the event twice, so both are refused with
> `VALIDATION_FAILED`. Read the row first with
> [`get_webhook_delivery`](https://docs.subscriby.net/mcp/v1/tools/observability#get-webhook-delivery) if you are not sure.
- Requires ability: `webhook-delivery:retry`
- Runs the same action as [`POST /v1/webhook-deliveries/{delivery}/retry`](https://docs.subscriby.net/api/v1/reference/webhook-deliveries#retry-a-webhook-delivery)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `delivery_id` | string | yes | UUID of the failed or dead-lettered delivery to replay. |
### Example call
```json
{
"name": "retry_webhook_delivery",
"arguments": {
"delivery_id": "f79655c3-045f-4800-a9a1-2c7afa3ac6e5"
}
}
```
### What it returns
```json
{
"data": {
"id": "34f1d78e-05a2-4b69-8c3d-7e921ab06f45",
"endpoint_id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"event": "subscription.created",
"event_id": "01HXZ3Q8M7Y2K4N6P9R1T3V5W7",
"status": "pending",
"attempts": 0,
"response_status": null,
"response_excerpt": null,
"payload": {
"id": "evt_01HXZ3Q8M7Y2K4N6P9R1T3V5W7",
"type": "subscription.created",
"created_at": "2026-09-06T10:05:00+00:00",
"api_version": "2026-05-01",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"data": { "subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4" }
},
"next_attempt_at": "2026-09-06T10:40:00+00:00",
"delivered_at": null,
"dead_lettered_at": null,
"created_at": "2026-09-06T10:05:00+00:00"
}
}
```
### How it fails
- `VALIDATION_FAILED` — the row is pending or delivered.
- `RESOURCE_NOT_FOUND` — unknown delivery_id, or a delivery to another team's endpoint.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-delivery:retry.
### Example prompts
> "Retry webhook delivery `34f1d78e-05a2-4b69-8c3d-7e921ab06f45` — the handler bug is fixed."
> "Replay the dead-lettered `subscription.created` for that member to the CRM."
> "Resend the failed delivery from this morning."
### Related
- [retry_dead_webhook_deliveries](https://docs.subscriby.net/mcp/v1/tools/observability#retry-dead-webhook-deliveries) — every dead-lettered row since an instant, in one call.
- [get_webhook_delivery](https://docs.subscriby.net/mcp/v1/tools/observability#get-webhook-delivery) — read the row first.
- [Webhook deliveries API](https://docs.subscriby.net/api/v1/reference/webhook-deliveries) — the REST equivalent.
## rotate_webhook_endpoint_secret
Replace an endpoint's signing secret. The new secret is returned in this response only and the old one stops verifying at once.
Mint a new HMAC signing secret for an endpoint — because the old one leaked, because the consumer
lost it, or on a rotation schedule. From this call on, every delivery to the endpoint is signed with
the new secret and the previous one stops verifying.
> **Destructive, and the secret is returned once**
>
> Deliveries signed with the new secret start immediately, so a consumer still
> checking the old one rejects everything until it is updated. Confirm the
> `endpoint_id` with a human, have the new `secret` deployed straight from this
> response, and never call it "to check" — every call mints another secret, and
> none of them can be read back.
Only the creator who registered the endpoint, or the team owner, may rotate; a fellow team member is
refused with `FORBIDDEN`. Not idempotent.
- Requires ability: `webhook-endpoint:update`
- Runs the same action as [`POST /v1/webhook-endpoints/{endpoint}/rotate-secret`](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#rotate-a-webhook-endpoints-secret)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | string | yes | UUID of the webhook endpoint whose secret to replace. |
### Example call
```json
{
"name": "rotate_webhook_endpoint_secret",
"arguments": {
"endpoint_id": "231540dc-049d-4400-a42d-dc69ba798b74"
}
}
```
### What it returns
```json
{
"data": {
"id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"name": "CRM sync",
"url": "https://hooks.example.com/subscriby",
"project_id": null,
"events": ["subscription.created", "subscription.cancelled"],
"is_active": true,
"disabled_at": null,
"allowed_ips": [],
"failure_count": 2,
"consecutive_failures": 0,
"last_success_at": "2026-09-05T18:00:00+00:00",
"last_failure_at": "2026-08-30T07:12:00+00:00",
"created_at": "2026-05-18T10:05:00+00:00",
"secret": "whsec_..."
}
}
```
### How it fails
- `FORBIDDEN` — the token belongs to a team member who did not register the endpoint and is not the team owner.
- `VALIDATION_FAILED` — the rotation was refused for the endpoint's current state.
- `RESOURCE_NOT_FOUND` — unknown endpoint_id, or an endpoint of another team.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-endpoint:update.
### Example prompts
> "Rotate the signing secret on webhook endpoint `c62a08f4-1b7d-4e35-9860-a37f5d21e0b9` — it was pasted into a public channel." (confirm first)"
> "Mint a new secret for the CRM sync endpoint; I'll update the handler now."
> "Quarterly rotation for the finance webhook."
### Related
- [test_webhook_endpoint](https://docs.subscriby.net/mcp/v1/tools/observability#test-webhook-endpoint) — confirm the consumer verifies the new secret.
- [create_webhook_endpoint](https://docs.subscriby.net/mcp/v1/tools/observability#create-webhook-endpoint) — the other place a secret is ever shown.
- [Webhook endpoints API](https://docs.subscriby.net/api/v1/reference/webhook-endpoints) — the REST equivalent.
- [Webhook security](https://docs.subscriby.net/webhooks/v1/security) — how the signature is computed.
## test_webhook_endpoint
Queue a synthetic test event to an endpoint so its consumer can be checked end to end. Returns the queued delivery to poll.
Send an endpoint a signed test event without waiting for something real to happen. The answer is the
queued delivery in the [`get_webhook_delivery`](https://docs.subscriby.net/mcp/v1/tools/observability#get-webhook-delivery) row shape, status
`pending`; poll that tool for the target's `response_status` and `response_excerpt` once the worker
has posted it.
> **Same worker, same signature, same ladder**
>
> The test delivery is posted by the same job that posts real events, signed
> with the endpoint's current secret, and retried on the same ladder if the
> target fails. A consumer that verifies the signature and returns `2xx` for the
> test will do so for real events.
Only the creator who registered the endpoint, or the team owner, may fire a test; a fellow team
member is refused with `FORBIDDEN`. REST rate-limits the same action to five per minute per
endpoint.
- Requires ability: `webhook-endpoint:update`
- Runs the same action as [`POST /v1/webhook-endpoints/{endpoint}/test`](https://docs.subscriby.net/api/v1/reference/webhook-endpoints#test-fire-a-webhook-endpoint)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endpoint_id` | string | yes | UUID of the webhook endpoint to send a test event to. |
### Example call
```json
{
"name": "test_webhook_endpoint",
"arguments": {
"endpoint_id": "231540dc-049d-4400-a42d-dc69ba798b74"
}
}
```
### What it returns
```json
{
"data": {
"id": "34f1d78e-05a2-4b69-8c3d-7e921ab06f45",
"endpoint_id": "c62a08f4-1b7d-4e35-9860-a37f5d21e0b9",
"event": "webhook.test",
"event_id": "01HXZ3Q8M7Y2K4N6P9R1T3V5W7",
"status": "pending",
"attempts": 0,
"response_status": null,
"response_excerpt": null,
"payload": {
"id": "evt_01HXZ3Q8M7Y2K4N6P9R1T3V5W7",
"type": "webhook.test",
"created_at": "2026-09-06T09:20:00+00:00",
"api_version": "2026-05-01",
"project_id": null,
"data": {}
},
"next_attempt_at": null,
"delivered_at": null,
"dead_lettered_at": null,
"created_at": "2026-09-06T09:20:00+00:00"
}
}
```
### How it fails
- `FORBIDDEN` — the token belongs to a team member who did not register the endpoint and is not the team owner.
- `VALIDATION_FAILED` — the test was refused for the endpoint's current state.
- `RESOURCE_NOT_FOUND` — unknown endpoint_id, or an endpoint of another team.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks webhook-endpoint:update.
### Example prompts
> "Send a test event to webhook endpoint `c62a08f4-1b7d-4e35-9860-a37f5d21e0b9` and tell me what it answered."
> "We just deployed the handler — fire a test at the CRM sync endpoint."
> "Check the finance webhook is still accepting deliveries."
### Related
- [get_webhook_delivery](https://docs.subscriby.net/mcp/v1/tools/observability#get-webhook-delivery) — poll the returned id for the result.
- [list_webhook_deliveries](https://docs.subscriby.net/mcp/v1/tools/observability#list-webhook-deliveries) — the whole log.
- [Webhook endpoints API](https://docs.subscriby.net/api/v1/reference/webhook-endpoints) — the REST equivalent.
- [Testing and debugging webhooks](https://docs.subscriby.net/webhooks/v1/testing-and-debugging)
---
# Payment Tools
Source: https://docs.subscriby.net/mcp/v1/tools/payment
A project sells through one or more payment providers, each connected in test or live mode. These tools read which providers a project has, switch them on and off, push the plan catalogue to a provider and read recent payments.
## Tools
- [`activate_payment_method`](#activate-payment-method) — Activate Payment Method (destructive)
- [`deactivate_payment_method`](#deactivate-payment-method) — Deactivate Payment Method (destructive)
- [`delete_payment_method`](#delete-payment-method) — Delete Payment Method (destructive)
- [`get_payment_method`](#get-payment-method) — Get Payment Method (read)
- [`list_payment_methods`](#list-payment-methods) — List Payment Methods (read)
- [`list_recent_payments`](#list-recent-payments) — Recent Payments (read)
- [`sync_payment_method_plans`](#sync-payment-method-plans) — Sync Payment Method Plans (destructive)
## activate_payment_method
Offer a configured payment method to buyers again and re-queue its plan sync. Refuses a Stripe method whose Connect onboarding never finished.
The creator's on-switch for a gateway. Activating offers the method at checkout again and re-queues
the plan sync for its gateway, so its catalogue is current by the time the first buyer arrives.
Idempotent: a method that is already on is a no-op and emits nothing. When the switch flips,
[`project.payment_method.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-payment-method-updated) emits once with
`changes.active`.
> **Stripe needs its Connect handshake first**
>
> A Stripe method whose Connect onboarding never finished is refused with
> `VALIDATION_FAILED`. Switching it on early would offer buyers a gateway that
> fails at checkout; onboarding can only be completed from the dashboard, the
> one place the Connect handshake can run.
- Requires ability: `project-payment-method:update`
- Runs the same action as [`POST /v1/projects/{project}/payment-methods/{method}/activate`](https://docs.subscriby.net/api/v1/reference/payment-methods#activate-a-payment-method)
- Fires events: [`project.payment_method.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-payment-method-updated)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the method belongs to. |
| `payment_method_id` | string | yes | UUID of the payment method to switch on. |
### Example call
```json
{
"name": "activate_payment_method",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"payment_method_id": "252d6ea5-c928-4d00-a44e-5e8e4070c9b7"
}
}
```
### What it returns
```json
{
"data": {
"id": "a15d70c8-3e46-4b92-b70f-58c9d2140e63",
"provider": "paypal",
"mode": "live",
"active": true,
"created_at": "2026-05-18T10:05:00+00:00"
}
}
```
### How it fails
- `VALIDATION_FAILED` — a Stripe method whose Connect onboarding is incomplete.
- `RESOURCE_NOT_FOUND` — unknown project_id or payment_method_id, a method of another project, or a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-payment-method:update.
### Example prompts
> "Switch the PayPal gateway back on for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "Re-enable payment method `a15d70c8-3e46-4b92-b70f-58c9d2140e63`."
> "Turn card payments back on now that the dispute is settled."
### Related
- [deactivate_payment_method](https://docs.subscriby.net/mcp/v1/tools/payment#deactivate-payment-method) — the reverse.
- [get_payment_method](https://docs.subscriby.net/mcp/v1/tools/payment#get-payment-method) — read the switch first.
- [sync_payment_method_plans](https://docs.subscriby.net/mcp/v1/tools/payment#sync-payment-method-plans) — what activation queues for you.
- [Payment methods API](https://docs.subscriby.net/api/v1/reference/payment-methods) — the REST equivalent.
## deactivate_payment_method
Stop offering a payment method to new buyers. Subscriptions already sold through it keep renewing.
The creator's off-switch for a gateway, and the safe way to retire one: new buyers stop seeing it at
checkout, while every subscription already sold through it keeps renewing with its gateway. The plan
sync for the gateway is re-queued. Idempotent: a method that is already off is a no-op and emits
nothing; when the switch flips,
[`project.payment_method.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-payment-method-updated) emits once with
`changes.active`.
> **Deactivate before you delete**
>
> Deactivating keeps the row and its history and can be undone with
> [`activate_payment_method`](https://docs.subscriby.net/mcp/v1/tools/payment#activate-payment-method).
> [`delete_payment_method`](https://docs.subscriby.net/mcp/v1/tools/payment#delete-payment-method) removes the
> gateway from the project. Prefer this one unless the creator asks for removal.
- Requires ability: `project-payment-method:update`
- Runs the same action as [`POST /v1/projects/{project}/payment-methods/{method}/deactivate`](https://docs.subscriby.net/api/v1/reference/payment-methods#deactivate-a-payment-method)
- Fires events: [`project.payment_method.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-payment-method-updated)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the method belongs to. |
| `payment_method_id` | string | yes | UUID of the payment method to switch off. |
### Example call
```json
{
"name": "deactivate_payment_method",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"payment_method_id": "252d6ea5-c928-4d00-a44e-5e8e4070c9b7"
}
}
```
### What it returns
```json
{
"data": {
"id": "a15d70c8-3e46-4b92-b70f-58c9d2140e63",
"provider": "paypal",
"mode": "live",
"active": false,
"created_at": "2026-05-18T10:05:00+00:00"
}
}
```
### How it fails
- `VALIDATION_FAILED` — the switch was refused for the method's current state.
- `RESOURCE_NOT_FOUND` — unknown project_id or payment_method_id, a method of another project, or a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-payment-method:update.
### Example prompts
> "Stop offering PayPal at checkout on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` — keep the existing subscribers on it."
> "Switch off payment method `a15d70c8-3e46-4b92-b70f-58c9d2140e63`."
> "Pause the crypto gateway while we sort out the wallet."
### Related
- [activate_payment_method](https://docs.subscriby.net/mcp/v1/tools/payment#activate-payment-method) — the reverse.
- [delete_payment_method](https://docs.subscriby.net/mcp/v1/tools/payment#delete-payment-method) — remove the gateway instead.
- [Payment methods API](https://docs.subscriby.net/api/v1/reference/payment-methods) — the REST equivalent.
## delete_payment_method
Remove a payment method from a project. Destructive — buyers lose that way to pay at once; the row is soft-deleted so history survives.
Take a gateway off a project. The row is soft-deleted rather than destroyed: subscriptions sold
through it keep their gateway for refunds and history, and configuring the same provider in the
same mode again later revives the row instead of creating a duplicate. What changes immediately is
the checkout — buyers lose that way to pay.
Emits [`project.payment_method.deleted`](https://docs.subscriby.net/webhooks/v1/events/project#project-payment-method-deleted) with a
credential-free snapshot. Idempotent: an already-deleted id surfaces as `RESOURCE_NOT_FOUND`,
exactly as an unknown or out-of-scope id does.
> **Destructive — confirm with a human first**
>
> Confirm the exact `payment_method_id` with the creator before calling. If the
> intent is "stop offering it for now",
> [`deactivate_payment_method`](https://docs.subscriby.net/mcp/v1/tools/payment#deactivate-payment-method) does that
> reversibly.
- Requires ability: `project-payment-method:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/payment-methods/{method}`](https://docs.subscriby.net/api/v1/reference/payment-methods#delete-a-payment-method)
- Fires events: [`project.payment_method.deleted`](https://docs.subscriby.net/webhooks/v1/events/project#project-payment-method-deleted)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the method belongs to. |
| `payment_method_id` | string | yes | UUID of the payment method to remove. |
### Example call
```json
{
"name": "delete_payment_method",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"payment_method_id": "252d6ea5-c928-4d00-a44e-5e8e4070c9b7"
}
}
```
### What it returns
```json
{
"data": {
"payment_method_id": "a15d70c8-3e46-4b92-b70f-58c9d2140e63",
"deleted": true
}
}
```
### How it fails
- `VALIDATION_FAILED` — the removal was refused for the method's current state.
- `RESOURCE_NOT_FOUND` — unknown or already-deleted payment_method_id, a method of another project, or a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-payment-method:delete.
### Example prompts
> "Remove the PayPal gateway from project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` — we've closed the account." (confirm the id first)"
> "Delete payment method `a15d70c8-3e46-4b92-b70f-58c9d2140e63`."
> "We're dropping crypto payments entirely; take the gateway off the project."
### Related
- [deactivate_payment_method](https://docs.subscriby.net/mcp/v1/tools/payment#deactivate-payment-method) — the reversible alternative.
- [list_payment_methods](https://docs.subscriby.net/mcp/v1/tools/payment#list-payment-methods) — what is left on the project.
- [Payment methods API](https://docs.subscriby.net/api/v1/reference/payment-methods) — the REST equivalent, a 204.
## get_payment_method
One configured payment method of a project by UUID — provider, mode and switch only, never a credential.
Read one payment gateway back before switching it, syncing it or removing it. The row is the one
[`list_payment_methods`](https://docs.subscriby.net/mcp/v1/tools/payment#list-payment-methods) returns: `id`, `provider`, `connector`,
`mode`, `active` and `created_at`, and nothing else. `provider` is a gateway slug or a
`connector:provider` key for a currency a connector brings (`telegram:stars`), and `connector`
names that connector or is `null`.
> **Deliberately thin**
>
> Provider secrets, webhook signing secrets and Stripe Connect account ids live
> in an encrypted column and are never surfaced through MCP or REST, whatever
> ability the token carries. The dashboard is the only place a creator sees
> them.
- Requires ability: `project-payment-method:view`
- Runs the same action as [`GET /v1/projects/{project}/payment-methods/{method}`](https://docs.subscriby.net/api/v1/reference/payment-methods#get-a-payment-method)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the method belongs to. |
| `payment_method_id` | string | yes | UUID of the payment method to fetch. |
### Example call
```json
{
"name": "get_payment_method",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"payment_method_id": "252d6ea5-c928-4d00-a44e-5e8e4070c9b7"
}
}
```
### What it returns
```json
{
"data": {
"id": "a15d70c8-3e46-4b92-b70f-58c9d2140e63",
"provider": "stripe",
"connector": null,
"mode": "live",
"active": true,
"created_at": "2026-05-18T10:05:00+00:00"
}
}
```
`mode` is `test` or `live` and decides which set of credentials the gateway uses; the same
provider can be configured once in each.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown project_id or payment_method_id, a method of another project, or a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-payment-method:view.
### Example prompts
> "Is the Stripe method `a15d70c8-3e46-4b92-b70f-58c9d2140e63` on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` live or test?"
> "Is that PayPal gateway currently switched on?"
> "When was the crypto gateway added to this project?"
### Related
- [list_payment_methods](https://docs.subscriby.net/mcp/v1/tools/payment#list-payment-methods) — every gateway on the project.
- [activate_payment_method](https://docs.subscriby.net/mcp/v1/tools/payment#activate-payment-method) — the switch.
- [deactivate_payment_method](https://docs.subscriby.net/mcp/v1/tools/payment#deactivate-payment-method) — the switch.
- [Payment methods API](https://docs.subscriby.net/api/v1/reference/payment-methods) — the REST equivalent.
## list_payment_methods
List configured payment methods for one project. Credentials and provider secrets are never returned.
List configured payment methods for one project. Safe to call for agents deciding whether a plan can charge against a given provider.
> **Note**
>
> Provider secrets — API keys, webhook secrets, Stripe Connect account IDs — are
> always stripped before the response returns.
- Requires ability: `project-payment-method:view-any`
- Runs the same action as [`GET /v1/projects/{project}/payment-methods`](https://docs.subscriby.net/api/v1/reference/payment-methods#list-a-projects-payment-methods)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose payment methods to list. |
| `active_only` | boolean | no | When true, omits deactivated payment methods. |
### Example call
```json
{
"name": "list_payment_methods",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "8b0c4a15-e792-4360-95d8-1f47c0b3e926",
"provider": "stripe",
"connector": null,
"mode": "live",
"active": true,
"created_at": "2026-03-01T00:00:00Z"
}
],
"meta": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"total": 2
}
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks project-payment-method:view-any.
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see: unknown, another team's, or outside the token's scope:project: allow-list. A project with no payment methods returns an empty list, not an error.
### Example prompts
> "Which payment providers are active on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`?"
> "List the live Stripe integration on my Research Premium project."
### Related
- [get_project](https://docs.subscriby.net/mcp/v1/tools/project#get-project)
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans)
- [Payment providers](https://docs.subscriby.net/payments)
## list_recent_payments
List the most recent subscription payments for one project with optional status filter. Reverse-chronological, no raw webhook payloads.
Return the most recent subscription payment rows for a project, reverse-chronological by `occurred_at`. Rows carry provider references (`external_payment_id`, `external_event_id`) but omit raw webhook payloads. Useful for quick revenue inspection and failure triage.
- Requires ability: `project-subscription:view-any`
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose payments to list. |
| `status` | `successful`, `failed`, `pending`, `refunded` | no | Payment status filter. One of: successful, failed, pending, refunded |
| `limit` | integer | no | Maximum payments to return (1..200, default 50). |
### Example call
```json
{
"name": "list_recent_payments",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "8b0c4a15-e792-4360-95d8-1f47c0b3e926",
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"method_id": "8b0c4a15-e792-4360-95d8-1f47c0b3e926",
"currency_id": "8f27a0d4-63be-4915-8c07-1a5d9e34b628",
"status": "successful",
"amount": "29.00",
"transaction_fee": 87,
"calculated_fee": "0.87",
"external_payment_id": "pi_...",
"external_event_id": "evt_...",
"billing_reason": "subscription_cycle",
"occurred_at": "2026-05-18T10:05:00Z"
}
],
"meta": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"total": 50,
"limit": 50
}
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:view-any.
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see: unknown, another team's, or outside the token's scope:project: allow-list.
- `VALIDATION_FAILED` — status is not one of the payment statuses; the error context lists the supported values.
### Example prompts
> "Show me the last 50 payments on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "Which payments failed in the last 24 hours on my Research Premium project?"
### Related
- [list_transactions](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#list-transactions) — keyset-paginated with richer filters.
- [get_earnings_report](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-earnings-report)
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers)
## sync_payment_method_plans
Queue a push of the project's plans into a payment gateway's catalogue. Answers sync_queued; the work runs in the background.
Some gateways keep their own catalogue — Stripe products and prices, PayPal, Razorpay and
CoinPayments plans — and a subscription can only be sold through them once the plan exists there.
Subscriby pushes the catalogue after every plan write and every method switch; this tool queues that
push by hand, for the times a gateway was reconfigured or a sync failed and the creator wants it
re-run now.
The answer is `sync_queued`, not a result: the work runs in the background and each plan reports
through the plan-sync webhook events as it lands. Idempotent in the sense that it can be repeated
safely — queuing twice pushes the same catalogue twice, which changes nothing.
> **Not every gateway has a catalogue**
>
> Telegram Stars, access codes and the redirect gateways (Paystack, CeyPay,
> Skrill) take the price at checkout and keep no plan objects, so a sync for
> them is refused with `VALIDATION_FAILED` rather than queued as a no-op.
- Requires ability: `project-payment-method:update`
- Runs the same action as [`POST /v1/projects/{project}/payment-methods/{method}/sync`](https://docs.subscriby.net/api/v1/reference/payment-methods#sync-a-payment-methods-plans)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the method belongs to. |
| `payment_method_id` | string | yes | UUID of the payment method whose gateway catalogue to refresh. |
### Example call
```json
{
"name": "sync_payment_method_plans",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"payment_method_id": "252d6ea5-c928-4d00-a44e-5e8e4070c9b7"
}
}
```
### What it returns
```json
{
"data": {
"payment_method_id": "a15d70c8-3e46-4b92-b70f-58c9d2140e63",
"status": "sync_queued"
}
}
```
### How it fails
- `VALIDATION_FAILED` — the gateway keeps no catalogue (connector currencies, access codes, the redirect gateways).
- `RESOURCE_NOT_FOUND` — unknown project_id or payment_method_id, a method of another project, or a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-payment-method:update.
### Example prompts
> "Re-sync the plans to Stripe for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "The Razorpay plans look stale — push the catalogue again."
> "Queue a plan sync for payment method `a15d70c8-3e46-4b92-b70f-58c9d2140e63`."
### Related
- [list_payment_methods](https://docs.subscriby.net/mcp/v1/tools/payment#list-payment-methods) — which gateways the project has.
- [update_plan](https://docs.subscriby.net/mcp/v1/tools/plan#update-plan) — a plan write queues the same sync itself.
- [Payment methods API](https://docs.subscriby.net/api/v1/reference/payment-methods) — the REST equivalent, a 202.
---
# Plan Tools
Source: https://docs.subscriby.net/mcp/v1/tools/plan
Plans are what a project sells, and every plan has a kind that decides its shape: a subscription that renews on a cycle, a pass that sells dated windows, or a series that sells a slate of windows at once. These tools create and edit plans in that discriminated shape, put them on and off sale, arrange the storefront, and manage the dated windows a pass generates.
## Tools
- [`cancel_pass_window`](#cancel-pass-window) — Cancel Pass Window (destructive)
- [`create_pass_window`](#create-pass-window) — Create Pass Window (destructive)
- [`create_plan`](#create-plan) — Create Plan (destructive)
- [`delete_plan`](#delete-plan) — Delete Plan (destructive)
- [`get_pass_window`](#get-pass-window) — Get Pass Window (read)
- [`get_plan`](#get-plan) — Get Plan (read)
- [`list_pass_windows`](#list-pass-windows) — List Pass Windows (read)
- [`list_plans`](#list-plans) — List Plans (read)
- [`publish_plan`](#publish-plan) — Publish or Unpublish Plan (destructive)
- [`remind_pass_window_queue`](#remind-pass-window-queue) — Remind Pass Window Queue (destructive)
- [`reorder_plans`](#reorder-plans) — Reorder Plans (destructive)
- [`start_next_season`](#start-next-season) — Start Next Season (destructive)
- [`update_plan`](#update-plan) — Update Plan (destructive)
## cancel_pass_window
Cancel one dated access window and resettle everyone holding it. Destructive — holders are messaged and money may be owed.
Take a window off the schedule and deal with the people who bought it, the way the dashboard's
cancel button does. Each holder is resettled in one of three ways, decided per holder:
- **rebound** — moved to the plan's next window that is on sale;
- **leg dropped** — for a season-ticket holder, the one date is removed from their series and the rest stands;
- **refund due** — when the schedule has nothing left to move them to, their pass is ended and flagged for a refund.
Subscriby never moves the money itself: `meta.refund_due` is the count of holders the creator now
owes a refund, and the creator settles it with their payment provider.
> **Destructive — confirm the window with a human first**
>
> Holders are messaged the moment this runs and refunds may be owed. Read the
> window back with [`get_pass_window`](https://docs.subscriby.net/mcp/v1/tools/plan#get-pass-window), check its
> `holders`, and have the creator confirm the exact `window_id` before calling.
Emits [`pass.window_cancelled`](https://docs.subscriby.net/webhooks/v1/events/pass#pass-window-cancelled) once, plus one of
[`pass.holder_moved`](https://docs.subscriby.net/webhooks/v1/events/pass#pass-holder-moved),
[`pass.holder_stranded`](https://docs.subscriby.net/webhooks/v1/events/pass#pass-holder-stranded) or
[`pass_series.leg_dropped`](https://docs.subscriby.net/webhooks/v1/events/pass-series#pass-series-leg-dropped) per holder. Idempotent: an
already-cancelled window is answered with zero tallies and emits nothing.
- Requires ability: `pass-window:delete`
- Runs the same action as [`POST /v1/projects/{project}/pass-windows/{window}/cancel`](https://docs.subscriby.net/api/v1/reference/pass-windows#cancel-a-pass-window)
- Fires events: [`pass.window_cancelled`](https://docs.subscriby.net/webhooks/v1/events/pass#pass-window-cancelled), [`pass.holder_moved`](https://docs.subscriby.net/webhooks/v1/events/pass#pass-holder-moved), [`pass.holder_stranded`](https://docs.subscriby.net/webhooks/v1/events/pass#pass-holder-stranded), [`pass_series.leg_substituted`](https://docs.subscriby.net/webhooks/v1/events/pass-series#pass-series-leg-substituted), [`pass_series.leg_dropped`](https://docs.subscriby.net/webhooks/v1/events/pass-series#pass-series-leg-dropped)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose pass plans own the window. |
| `window_id` | string | yes | UUID of the window to cancel. |
### Example call
```json
{
"name": "cancel_pass_window",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"window_id": "d01d0b3b-7210-4800-ae2a-7282a32bd3ad"
}
}
```
### What it returns
```json
{
"data": {
"id": "3d5a8c72-b016-4e94-8fa7-61c209d4e738",
"plan_id": "9b7c2e15-4d63-4f80-a2b1-7e5d0c9f3a46",
"plan_name": "Saturday session",
"starts_at": "2026-10-03T09:00:00+00:00",
"ends_at": "2026-10-03T11:00:00+00:00",
"timezone": "Europe/London",
"local_range": "Sat 3 Oct, 10:00–12:00 BST",
"duration_minutes": 120,
"status": "canceled",
"sellable": false,
"holders": 3
},
"meta": {
"rebound": 2,
"refund_due": 1,
"legs_dropped": 0
}
}
```
`holders` is the count at the moment of cancellation, so the three tallies under `meta` add up to
it. `status` is spelled `canceled`.
### How it fails
- `VALIDATION_FAILED` — the window cannot be cancelled in its current state (the resettlement refused).
- `RESOURCE_NOT_FOUND` — unknown project_id or window_id, a window of another project, or a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks pass-window:delete.
### Example prompts
> "Cancel the 3 October window on the Saturday session plan in project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` — the venue fell through."
> "How many people would need a refund if we cancelled window `3d5a8c72-b016-4e94-8fa7-61c209d4e738`?" (read it first; do not cancel to find out)"
> "Take next Tuesday's make-up session off the schedule."
### Related
- [get_pass_window](https://docs.subscriby.net/mcp/v1/tools/plan#get-pass-window) — read holders before cancelling.
- [create_pass_window](https://docs.subscriby.net/mcp/v1/tools/plan#create-pass-window) — add a replacement date.
- [remind_pass_window_queue](https://docs.subscriby.net/mcp/v1/tools/plan#remind-pass-window-queue) — nudge the holders of the window they were moved to.
- [Pass windows API](https://docs.subscriby.net/api/v1/reference/pass-windows) — the REST equivalent, with the same meta tallies.
## create_pass_window
Place one dated access window by hand on a time-limited pass plan. Marked manual, so a schedule rebuild keeps it; emits pass.window_scheduled.
Add a single window to a pass plan's schedule without touching the rule that generates the rest —
a one-off extra session, a make-up date, a special. The window is stored as **manual**, which is
what protects it: when the plan's schedule is rebuilt from its slots, generated windows are pruned
and regenerated, manual ones are left alone.
Synchronous — the new window comes back in the [`get_pass_window`](https://docs.subscriby.net/mcp/v1/tools/plan#get-pass-window) row
shape, and [`pass.window_scheduled`](https://docs.subscriby.net/webhooks/v1/events/pass#pass-window-scheduled) emits once.
> **A bare `starts_at` is read in the plan's timezone**
>
> An ISO 8601 instant with an offset (`2026-10-03T09:00:00+01:00` or `Z`) is
> taken as given. One **without** an offset is read in the plan's own pass
> timezone, not UTC and not the agent's clock, because that is the time the
> creator means when they say "ten o'clock". The future check runs in that zone
> too.
The plan's configured floor and ceiling on window length apply, so `duration_minutes` outside those
bounds is refused rather than clamped.
- Requires ability: `pass-window:create`
- Runs the same action as [`POST /v1/projects/{project}/plans/{plan}/pass-windows`](https://docs.subscriby.net/api/v1/reference/pass-windows#place-a-pass-window-by-hand)
- Fires events: [`pass.window_scheduled`](https://docs.subscriby.net/webhooks/v1/events/pass#pass-window-scheduled)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | yes | UUID of the pass plan to place the window on. |
| `starts_at` | string | yes | ISO 8601 instant the window opens. Without an offset it is read in the plan's own timezone. |
| `duration_minutes` | integer | yes | How long the window runs, in whole minutes. |
### Example call
```json
{
"name": "create_pass_window",
"arguments": {
"plan_id": "789c4fba-32b8-4800-a195-4c9fd62c9ecf",
"starts_at": "2026-10-01T09:00:00Z",
"duration_minutes": 1
}
}
```
### What it returns
```json
{
"data": {
"id": "3d5a8c72-b016-4e94-8fa7-61c209d4e738",
"plan_id": "9b7c2e15-4d63-4f80-a2b1-7e5d0c9f3a46",
"plan_name": "Saturday session",
"starts_at": "2026-10-03T09:00:00+00:00",
"ends_at": "2026-10-03T11:00:00+00:00",
"timezone": "Europe/London",
"local_range": "Sat 3 Oct, 10:00–12:00 BST",
"duration_minutes": 120,
"status": "scheduled",
"sellable": true,
"holders": 0
}
}
```
### How it fails
- `VALIDATION_FAILED` — the plan is not of kind pass, starts_at is in the past, duration_minutes is outside the plan's minimum and maximum, or the plan already has a window starting at that instant.
- `RESOURCE_NOT_FOUND` — unknown plan_id, or the plan's project is outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks pass-window:create.
### Example prompts
> "Add an extra two-hour window to plan `9b7c2e15-4d63-4f80-a2b1-7e5d0c9f3a46` on 3 October at 10am local time."
> "Schedule a make-up session next Tuesday at 19:00 for 90 minutes on the Saturday session plan."
> "Put a one-off 6pm window on the evening pass for the 24th."
### Related
- [list_pass_windows](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) — check the existing schedule before adding to it.
- [cancel_pass_window](https://docs.subscriby.net/mcp/v1/tools/plan#cancel-pass-window) — the reverse.
- [get_plan](https://docs.subscriby.net/mcp/v1/tools/plan#get-plan) — read the plan's timezone and length bounds.
- [Pass windows API](https://docs.subscriby.net/api/v1/reference/pass-windows) — the REST equivalent.
## create_plan
Create a subscription, a time-limited pass or a pass series on a project — one tool, tagged by kind.
Create a plan on a project. Delegates to an Action that enforces resource-sync, the plan policy, and cache invalidation before a `plan.created` event fires.
Every plan has a **`kind`**, and the kind decides which one nested block you send with it:
| `kind` | Send | Sells |
| -------------- | ------------- | -------------------------------------------------------------------- |
| `subscription` | `billing` | Access that begins at payment and renews on a cycle. The default. |
| `pass` | `pass` | One dated access window per purchase. |
| `pass_series` | `pass_series` | A slate of _other_ pass plans' windows, sold once — a season ticket. |
> **Sending the wrong block is refused**
>
> A `pass` carrying `billing`, or a `subscription` carrying `pass_series`, fails
> with `VALIDATION_FAILED` naming the offending key. It is not silently ignored
> — that would let you believe you had set a billing cycle on a pass, where a
> cycle means nothing.
The following rules are enforced — identical inputs are rejected by the dashboard too:
- `name` is 5–255 chars and unique per project.
- `currency_id` must be supported by an active payment method on the project.
- `price` must be at least the $1.00 USD equivalent in the plan currency. `0` publishes a free plan, which only the Starter and Growth plans can sell; on Free the call is rejected with `TEAM_TIER_REQUIRED`.
- `eligibility.newcomers_only`, `eligibility.customers_only` and `eligibility.churned_only` are mutually exclusive.
- `resources` must link to **at least one** existing project resource — **except on `kind: pass_series`**, where it is optional. Each window in a series grants that window's own plan's resources, so a series with none still delivers what was sold; anything linked there is a lounge open for the whole span. **Never pass a resource one of the slate's own windows opens:** a lounge is granted at purchase with no window and kept all season, so the holder gets that channel permanently and the date it was scheduled for stops gating anything. Pass a holders-only room, or nothing.
- `kind: pass` and `kind: pass_series` both require Time-Limited Passes — bundled with Growth, or the Passes Addon on Free and Starter. On any other tier the call is rejected.
- Requires ability: `project-subscription-plan:create`
- Runs the same action as [`POST /v1/projects/{project}/plans`](https://docs.subscriby.net/api/v1/reference/plans#create-a-plan)
- Fires events: [`plan.created`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-created)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the parent project. |
| `kind` | string | yes | subscription (access starts at payment and renews on a cycle), pass (one dated access window per purchase), or pass_series (a season ticket over many of your pass plans' windows). Defaults to subscription. Decides which nested block below is required. |
| `name` | string | yes | Plan name (min 5, max 255 chars, unique per project). |
| `currency_id` | string | yes | UUID of the currency. Must be supported by an active payment method on the project. |
| `price` | number | yes | What one purchase costs, as a decimal. On a pass this buys one window; on a pass_series it buys the whole slate, once. At least the $1.00 USD equivalent — payment providers reject smaller amounts. 0 publishes a free plan and requires the Starter or Growth tier. |
| `resources` | array of any | no | UUIDs of project_resources this plan grants access to. Required on kind subscription and kind pass. OPTIONAL on kind pass_series, where it means a lounge the holder keeps for the whole span — each pass in a series already grants its own plan's resources. |
| `description` | string | no | Optional plan description. Max 1000 chars, HTML is filtered. |
| `active` | boolean | no | Defaults to true. Set false to create in draft state. |
| `sales_cap` | integer | no | Optional. Pause the plan automatically after this many successful purchases (1-100000); the creator publishes it again for another batch. Omit or null for no limit. |
| `eligibility` | object | no | Audience restrictions: {newcomers_only?: bool, customers_only?: bool, churned_only?: bool, single_use?: bool, access_codes_only?: bool}. The first three are mutually exclusive. |
| `billing` | object | no | REQUIRED when kind=subscription, refused otherwise. {billing_cycle: days\|weeks\|months\|years\|lifetime, billing_cycle_count: int 1-99, recurring?: bool, disabled_renewal?: bool, trial_days?: int 0-365, trial_cardless?: bool, trial_type?: string}. lifetime requires billing_cycle_count=1. recurring must be false for crypto / platform currencies. |
| `pass` | object | no | REQUIRED when kind=pass, refused otherwise. {timezone: IANA zone e.g. America/New_York, schedule_mode?: repeating\|fixed, recurrence?: daily\|weekly\|monthly, recurrence_ends_at?: ISO-8601, sales_cutoff_minutes?: int, sales_cutoff_anchor?: before_start\|before_end, slots?: array, windows?: array}. Slot times are local wall-clock in `timezone` and survive daylight saving. Each slot is {weekday?: 0-6 with 0=Sunday, day_of_month?: 1-31, start_time: "HH:MM", duration_minutes: int} — each carries its own duration, so a plan can mix lengths, and a day_of_month of 29-31 skips months that lack the day. `windows` places explicit dates for schedule_mode=fixed: {starts_at: "YYYY-MM-DD HH:MM", duration_minutes: int}. sales_cutoff_anchor before_start (default) stops sales that many minutes before a window opens; before_end keeps it on sale while it runs, requires at least 1, and must be under the shortest slot duration_minutes. |
| `pass_series` | object | no | REQUIRED when kind=pass_series, refused otherwise. {window_ids: array of pass-window UUIDs, prevent_overlaps?: bool, sales_cutoff_minutes?: int, sales_cutoff_anchor?: before_start\|before_first_end\|before_last_start\|before_end, seat_cap?: int, successor_plan_id?: UUID, presale_hours?: int 1-8760, blackout_window_ids?: array, rules?: array}. A series points at windows that ALREADY EXIST on your pass plans — it never creates any — so get their UUIDs from the `list_pass_windows` tool first. Needs at least two windows, or a rule that will find them. `rules` grows the slate automatically and keeps doing so after holders have bought, granting new matches to them at no charge: each rule is {source_plan_id: UUID of a pass plan, kind: date_range\|next_n, from_at?: ISO-8601, to_at?: ISO-8601, take?: int}, where date_range takes every window starting in the period and next_n takes the next `take` windows and then stops. sales_cutoff_anchor before_start closes sales before the FIRST window opens; before_first_end keeps the season on sale into that opening window and closes a set number of minutes (at least 5) before it ends, so a latecomer can still join on the night; before_last_start keeps it on sale until the LAST window opens, so a buyer always gets at least one whole date; before_end keeps the season on sale right through, closing before the LAST window ends. All three sell at full price for whatever remains, and windows that already ran are never issued. |
### Example call
```json
{
"name": "create_plan",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"kind": "",
"name": "",
"currency_id": "0ff9c1c6-edcf-4000-a6e3-147819440d58",
"price": 1
}
}
```
### What it returns
```json
{
"data": {
"id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"kind": "subscription",
"name": "Premium Monthly",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"price": "29.00",
"cadence": "Per Month",
"active": true
}
}
```
`cadence` is the human-readable duration string the portal and the bot show — `"Per Month"`,
`"Per 3 Hours"`, `"For all 10 passes"`. Use it rather than deriving one yourself.
### How it fails
- `VALIDATION_FAILED` — a nested block that does not match kind; missing resources on a kind that requires them; lifetime-count mismatch; mutual-exclusion violated; a next_n rule with no take; a slate under two windows; a seat cap of 0; a successor that is not another series; or the underlying Action policy rejecting (name collision, unsupported currency, recurring-vs-crypto conflict).
- `RESOURCE_NOT_FOUND` — project_id doesn't exist in your token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription-plan:create.
- `TEAM_TIER_REQUIRED` — kind: pass or kind: pass_series without Time-Limited Passes, or a free plan on the Free tier.
### kind: subscription
```json
{
"kind": "subscription",
"billing": {
"billing_cycle": "months",
"billing_cycle_count": 1,
"recurring": true,
"trial_days": 7
}
}
```
| Field | Type | Notes |
| ----------------------------- | ------- | ------------------------------------------------------- |
| `billing.billing_cycle` | string | `days`, `weeks`, `months`, `years`, `lifetime`. |
| `billing.billing_cycle_count` | integer | 1–99. Forced to `1` when `billing_cycle` is `lifetime`. |
| `billing.recurring` | boolean | Rejected as `true` for crypto or platform currencies. |
| `billing.disabled_renewal` | boolean | Charges once, then lapses. |
| `billing.trial_days` | integer | 0–365. |
| `billing.trial_cardless` | boolean | Whether the trial starts without a payment method. |
| `billing.trial_type` | string | A `TrialModeType` value. |
### kind: pass
Sells one scheduled access window per purchase. The plan owns its own windows and generates
them from a recurrence.
```json
{
"kind": "pass",
"pass": {
"timezone": "America/New_York",
"schedule_mode": "repeating",
"recurrence": "weekly",
"sales_cutoff_minutes": 60,
"sales_cutoff_anchor": "before_start",
"slots": [
{ "weekday": 4, "start_time": "19:00", "duration_minutes": 180 },
{ "weekday": 0, "start_time": "09:00", "duration_minutes": 840 }
]
}
}
```
| Field | Type | Notes |
| ------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pass.timezone` | string | IANA zone, e.g. `America/New_York`. Slot times are local wall-clock in this zone and survive daylight saving. Legacy names are resolved, so `Asia/Calcutta` is stored as `Asia/Kolkata`. Required. |
| `pass.schedule_mode` | string | `repeating` (generate from `slots`) or `fixed`. Defaults to `repeating`. |
| `pass.recurrence` | string | `daily`, `weekly` or `monthly`. Decides which slot fields apply. |
| `pass.recurrence_ends_at` | timestamp | Optional. When window generation stops. |
| `pass.sales_cutoff_minutes` | integer \| null | Stop selling a window this many minutes before the moment `sales_cutoff_anchor` names. Omit to sell until the window begins. |
| `pass.sales_cutoff_anchor` | string | `before_start` (default) closes sales before a window opens and nothing is sold once it is running. `before_end` keeps it on sale while it runs, so a buyer can join a session already in progress. `before_end` requires at least `5`, must be under the shortest slot `duration_minutes`, and is refused when that shortest slot is `5` or less. |
| `pass.slots[]` | array | Window definitions. **Replaces the whole schedule** on update. |
| `pass.slots[].weekday` | integer \| null | `0`–`6`, `0` = Sunday. Weekly recurrence only. |
| `pass.slots[].day_of_month` | integer \| null | `1`–`31`. Monthly only. Values of 29–31 skip months that lack the day. |
| `pass.slots[].start_time` | string `HH:MM` | Local wall-clock start in `pass.timezone`. |
| `pass.slots[].duration_minutes` | integer | Each slot has its own, so one plan can mix a 3-hour and a 14-hour window. |
| `pass.windows[]` | array | Explicitly dated windows for `fixed` mode. **Added**, never replacing. Each: `{starts_at, duration_minutes}`. |
Updating `pass.slots` rebuilds future windows. Windows a customer has already bought keep their
original times and are never moved or deleted; only unsold future windows are regenerated.
### kind: pass_series
Sells a curated slate of **other pass plans'** windows for one payment — a season ticket. It
owns no windows of its own, which is the whole distinction from `kind: pass`.
> **Get the window ids first**
>
> `pass_series.window_ids` names windows that **already exist**. Call
> [`list_pass_windows`](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) to find them — there is no
> other way to obtain a window UUID, so a series cannot be authored without it.
```json
{
"kind": "pass_series",
"pass_series": {
"window_ids": ["3d5a8c72-b016-4e94-8fa7-61c209d4e738", "psw_01HY..."],
"prevent_overlaps": true,
"seat_cap": 50,
"presale_hours": 48,
"rules": [
{
"source_plan_id": "pln_01HZ...",
"kind": "date_range",
"from_at": "2026-09-01T00:00:00Z",
"to_at": "2026-12-01T00:00:00Z"
}
]
}
}
```
| Field | Type | Notes |
| ----------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pass_series.window_ids[]` | array | Pass-window UUIDs from `list_pass_windows`. Max **120**. Needs at least two, unless a rule will supply them. |
| `pass_series.rules[]` | array | Automatic inclusion. Max 20. See below. |
| `pass_series.blackout_window_ids[]` | array | Windows a rule matches but you want permanently excluded. |
| `pass_series.prevent_overlaps` | boolean | Defaults `true`. Refuses to absorb a window clashing with one already on the slate. |
| `pass_series.seat_cap` | integer \| null | Concurrent holder limit. `null` is unlimited; `0` is refused — take a plan off sale with `publish_plan` instead. |
| `pass_series.sales_cutoff_minutes` | integer | Measured against the **whole season**. |
| `pass_series.sales_cutoff_anchor` | string | `before_start` closes sales before the **first** window opens. `before_first_end` keeps the season on sale into that opening window and closes at least `5` minutes before it ends, so a latecomer is still admitted to it. `before_end` keeps the season on sale until the **last** window ends. All three charge full price for whatever remains. Series-only values are refused on a `pass` plan. |
| `pass_series.successor_plan_id` | string \| null | Another `kind: pass_series` plan on the same project. A series cannot lead to itself. |
| `pass_series.presale_hours` | integer \| null | 1–8760. How long the successor is held for this season's holders before general sale. |
**Rules keep working after the save**
A rule describes windows rather than naming them, and it **keeps matching**: a window
scheduled later is absorbed into the slate and granted to everyone already holding the series,
at no extra charge. That is the behaviour handpicked `window_ids` deliberately do not have.
They compose — most real seasons use both.
| Rule `kind` | Takes |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| `date_range` | Every window on `source_plan_id` starting between `from_at` and `to_at`. Either bound may be omitted. |
| `next_n` | The next `take` windows, then stops. **Never tops itself back up** — a window freeing up later does not make it reach for another. |
`take` is **required** when `kind` is `next_n`. A count rule with no count is incomplete, not
"all of them", and is refused.
`source_plan_id` must be a `kind: pass` plan on the same project.
### Example prompts
> "Create a $29/month Premium plan on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` with a 7-day trial, linked to resource `rsc_01HY...`."
> "Add a one-time 'Starter Access' plan priced at $49 with lifetime duration, linked to the Onboarding Pack resource."
> "Create a weekly Sunday pass on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` at $15, 9am New York, three hours long."
> "Build a season ticket from the next ten Match Day windows and price it at $99 with 50 seats."
### Related
- [list_pass_windows](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) — where a series' window ids come from.
- [publish_plan](https://docs.subscriby.net/mcp/v1/tools/plan#publish-plan)
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans)
- [Pass Series guide](https://docs.subscriby.net/creators/pass-series)
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans)
## delete_plan
Soft-delete a plan so it leaves the portal, the bot and every list. Existing subscriptions run to their end. Refused while pass holders have windows ahead.
Retire a plan for good. The row is soft-deleted: it disappears from the portal, the bot and every
list, nobody can buy it again, and the subscriptions already sold keep their history and keep
running to their end. Emits [`plan.deleted`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-deleted) with a snapshot taken
before the row goes.
> **Confirm the target with a human**
>
> Deletion cannot be undone from the API. The tool is annotated destructive so a
> client can prompt for confirmation; read the plan back with
> [`get_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#get-plan) and confirm the `plan_id` before calling.
> **A pass plan with windows still to run is refused**
>
> Deleting a pass plan would cascade away access windows customers have paid
> for. While any holder has a window that has not run yet, the service refuses
> with `VALIDATION_FAILED`. Call [`publish_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#publish-plan) with
> `active: false` instead: new sales stop while the purchased windows still open
> on schedule. Delete once the last of them has closed.
Idempotent: a second call on the same id finds nothing and answers `RESOURCE_NOT_FOUND`, exactly as
an unknown or out-of-scope id does.
- Requires ability: `project-subscription-plan:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/plans/{plan}`](https://docs.subscriby.net/api/v1/reference/plans#delete-a-plan)
- Fires events: [`plan.deleted`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-deleted)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | yes | UUID of the plan to delete. |
### Example call
```json
{
"name": "delete_plan",
"arguments": {
"plan_id": "789c4fba-32b8-4800-a195-4c9fd62c9ecf"
}
}
```
### What it returns
```json
{
"data": {
"id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"deleted": true
}
}
```
`id` echoes the argument as sent.
### How it fails
- `VALIDATION_FAILED` — a pass plan whose customers still hold access windows that have not run
- `RESOURCE_NOT_FOUND` — unknown plan_id, a plan on another team's project, one outside the
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription-plan:delete.
### Example prompts
> "Delete plan `c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b` — it was a test."
> "Remove the old Beta Early-Bird plan; nobody has been on it for a year."
> "Get rid of last season's pass plan now that every window has closed."
### Related
- [publish_plan](https://docs.subscriby.net/mcp/v1/tools/plan#publish-plan) — take a plan off sale without deleting it.
- [get_plan](https://docs.subscriby.net/mcp/v1/tools/plan#get-plan) — confirm what you are about to delete.
- [list_pass_windows](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) — see whether a pass plan still has windows
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans)
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans#endpoints) — the REST equivalent.
- [Subscription Plans](https://docs.subscriby.net/creators/plans#delete-a-plan) — the dashboard walkthrough.
## get_pass_window
One dated access window of a project's pass plans by UUID — when it runs, whether it is still on sale and how many holders bought it.
Read one window back before acting on it. The row is the same shape
[`list_pass_windows`](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) returns: the instant it opens and closes, the
local range in the plan's own timezone, its lifecycle `status`, whether it is `sellable` right now,
and how many `holders` bought it.
> **`sellable` and `status` answer different questions**
>
> `status` is the lifecycle — `scheduled`, `open`, `closed`, `canceled`.
> `sellable` is whether a customer can buy the window at this moment, after the
> plan's sales cutoff. A `scheduled` window can already be closed to sales, and
> a plan anchored to the window's end keeps selling while it is `open`. Check
> `sellable` before pointing a customer at a window, and `holders` before
> cancelling one.
- Requires ability: `pass-window:view`
- Runs the same action as [`GET /v1/projects/{project}/pass-windows/{window}`](https://docs.subscriby.net/api/v1/reference/pass-windows#get-a-pass-window)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose pass plans own the window. |
| `window_id` | string | yes | UUID of the window to fetch. |
### Example call
```json
{
"name": "get_pass_window",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"window_id": "d01d0b3b-7210-4800-ae2a-7282a32bd3ad"
}
}
```
### What it returns
```json
{
"data": {
"id": "3d5a8c72-b016-4e94-8fa7-61c209d4e738",
"plan_id": "9b7c2e15-4d63-4f80-a2b1-7e5d0c9f3a46",
"plan_name": "Saturday session",
"starts_at": "2026-10-03T09:00:00+00:00",
"ends_at": "2026-10-03T11:00:00+00:00",
"timezone": "Europe/London",
"local_range": "Sat 3 Oct, 10:00–12:00 BST",
"duration_minutes": 120,
"status": "scheduled",
"sellable": true,
"holders": 12
}
}
```
`starts_at` and `ends_at` are UTC instants; `local_range` is the same span rendered in `timezone`,
which is the plan's pass timezone, so it is what a creator or a member would say out loud.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown project_id or window_id, a window that belongs to another project, or a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks pass-window:view.
### Example prompts
> "Is the 3 October window in project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` still on sale, and how many people hold it?"
> "Show me window `3d5a8c72-b016-4e94-8fa7-61c209d4e738` before we decide whether to cancel it."
> "What local time does that window run?"
### Related
- [list_pass_windows](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) — find the window's UUID.
- [cancel_pass_window](https://docs.subscriby.net/mcp/v1/tools/plan#cancel-pass-window) — read holders here first.
- [remind_pass_window_queue](https://docs.subscriby.net/mcp/v1/tools/plan#remind-pass-window-queue) — nudge the holders who have not joined.
- [Pass windows API](https://docs.subscriby.net/api/v1/reference/pass-windows) — the REST equivalent.
## get_plan
Fetch one plan by UUID — the same kind-tagged row list_plans emits, with its one nested block and its cadence string.
Read one plan in full. The row is the same one [`list_plans`](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans) emits, so an
agent that found a plan in the list reads exactly the same fields back, and the row
[`update_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#update-plan) returns after a change is comparable field for field.
Reads go through the plan service, so the tenant scope and the token's `scope:project:` allow-list
both apply. An id the token cannot see answers `RESOURCE_NOT_FOUND`, indistinguishable from an id
that never existed.
> **`kind` names the ONE block you will find**
>
> Every plan carries a `kind` — `subscription`, `pass` or `pass_series` — and
> the kind decides which single nested object accompanies it: `billing`, `pass`
> or `pass_series`. The other two are **absent**, not null. Read the tag and you
> know what you are holding; there is no nullable object to probe.
Every row also carries `cadence`, the human-readable duration the portal and the bot show — `"Per
Month"`, `"Per 3 Hours"`, `"For all 10 passes"`. Use it rather than deriving a duration yourself.
- Requires ability: `project-subscription-plan:view`
- Runs the same action as [`GET /v1/projects/{project}/plans/{plan}`](https://docs.subscriby.net/api/v1/reference/plans#get-a-plan)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | yes | UUID of the plan to fetch. |
### Example call
```json
{
"name": "get_plan",
"arguments": {
"plan_id": "789c4fba-32b8-4800-a195-4c9fd62c9ecf"
}
}
```
### What it returns
```json
{
"data": {
"id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"name": "Premium Monthly",
"description": "All-access pass",
"price": "29.00",
"currency": "USD",
"kind": "subscription",
"cadence": "Per Month",
"active": true,
"sales_cap": 10,
"sales_cap_sold": 3,
"position": 0,
"paused_reason": null,
"created_at": "2026-05-18T10:05:00+00:00",
"billing": {
"billing_cycle": "months",
"billing_cycle_count": 1,
"recurring": true,
"trial_days": 7,
"trial_cardless": false
}
}
}
```
`price` is a decimal string; `currency` is an ISO code. The block changes with the kind:
**kind: pass**
```json
{
"kind": "pass",
"cadence": "Per 3 Hours",
"pass": {
"timezone": "America/New_York",
"recurrence": "weekly",
"sales_cutoff_minutes": 60,
"sales_cutoff_anchor": "before_start",
"next_window": {
"id": "3d5a8c72-b016-4e94-8fa7-61c209d4e738",
"starts_at": "2026-09-20T13:00:00+00:00",
"ends_at": "2026-09-20T16:00:00+00:00"
}
}
}
```
`pass.next_window` is the next window still on sale, in UTC, or `null` when the schedule has nothing
left to sell — which also means the plan is not purchasable right now. For the full schedule call
[`list_pass_windows`](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows).
**kind: pass_series**
```json
{
"kind": "pass_series",
"cadence": "For all 10 passes",
"pass_series": {
"timezone": "America/New_York",
"window_count": 10,
"starts_at": "2026-09-20T13:00:00+00:00",
"ends_at": "2026-11-29T22:00:00+00:00",
"seat_cap": 50,
"seats_remaining": 13,
"successor_plan_id": null
}
}
```
`seat_cap` and `seats_remaining` are `null` on an uncapped season. `successor_plan_id` is set once
[`start_next_season`](https://docs.subscriby.net/mcp/v1/tools/plan#start-next-season) has run; it is the plan current holders are
offered in the presale.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown plan_id, a plan on another team's project, one outside the token's
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription-plan:view.
### Example prompts
> "Show me plan `c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b` in full."
> "What billing cycle and trial does the Premium Monthly plan have?"
> "Is the Match Day Pass still selling, and when is its next window?"
### Related
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans) — find the id.
- [update_plan](https://docs.subscriby.net/mcp/v1/tools/plan#update-plan)
- [publish_plan](https://docs.subscriby.net/mcp/v1/tools/plan#publish-plan)
- [list_pass_windows](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) — the dates behind a pass or pass_series.
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans) — the REST equivalent and the full shape reference.
- [Subscription Plans](https://docs.subscriby.net/creators/plans) — the feature walkthrough.
## list_pass_windows
The dated access windows a pass plan generates, with their UUIDs — the ids a Pass Series is built from.
List the dated access windows on time-limited pass plans, with their UUIDs, status, local
times and holder counts.
> **This is what makes a Pass Series authorable**
>
> [`create_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#create-plan) with `kind: pass_series` takes
> `pass_series.window_ids`. A series points at windows that **already exist**
> rather than creating any of its own, so those UUIDs have to come from
> somewhere — and this is the only tool that exposes them. Call it first, pick
> the dates, then create the series.
It is also the tool for reconciling a schedule into an external calendar, and for checking
what is actually still on sale before pointing a customer at a plan.
- Requires ability: `pass-window:view-any`
- Runs the same action as [`GET /v1/projects/{project}/pass-windows`](https://docs.subscriby.net/api/v1/reference/pass-windows#list-a-projects-pass-windows)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | no | UUID of one time-limited pass plan. Takes precedence over project_id. |
| `project_id` | string | no | UUID of a project, to list windows across every pass plan on it. |
| `status` | string | no | Filter by lifecycle state: scheduled (not yet open), open (running now), closed (finished), canceled. |
| `from` | string | no | Only windows starting at or after this ISO-8601 timestamp. |
| `to` | string | no | Only windows starting at or before this ISO-8601 timestamp. |
| `limit` | integer | no | Maximum windows to return per page (1..100). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_pass_windows",
"arguments": {
"plan_id": "789c4fba-32b8-4800-a195-4c9fd62c9ecf",
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "3d5a8c72-b016-4e94-8fa7-61c209d4e738",
"plan_id": "pln_01HZ...",
"plan_name": "Match Day Pass",
"starts_at": "2026-09-20T13:00:00Z",
"ends_at": "2026-09-20T16:00:00Z",
"timezone": "America/New_York",
"local_range": "Sun 20 Sep 2026, 09:00 – 12:00 EDT",
"duration_minutes": 180,
"status": "scheduled",
"sellable": true,
"holders": 12
}
],
"meta": {
"page": 1,
"limit": 50,
"total": 10,
"has_more": false
}
}
```
| Field | Type | Notes |
| ------------------ | ----------- | ------------------------------------------------------------------------------------------------------ |
| `id` | string UUID | **The id a Pass Series points at.** |
| `plan_id` | string UUID | The pass plan the window belongs to. A series can draw from several. |
| `starts_at` | ISO 8601 | **Always UTC.** |
| `ends_at` | ISO 8601 | Always UTC. |
| `timezone` | string | The zone the schedule was authored in. |
| `local_range` | string | The window rendered in that zone, ready to show. See below. |
| `duration_minutes` | integer | Length. Slots carry their own, so one plan can mix a 3-hour and a 14-hour window. |
| `status` | string | `scheduled`, `open`, `closed` or `canceled`. |
| `sellable` | boolean | Whether it is on sale **right now**, after applying the plan's sales cutoff. Not the same as `status`. |
| `holders` | integer | How many purchases hold this window, series holders included. |
> **Both zones are returned on purpose**
>
> The UTC pair is what an integration stores. `local_range` is what the creator
> authored and what a subscriber is shown — so an agent asked "which one is
> Sunday's window" can answer without converting anything, and cannot get the
> conversion wrong.
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks pass-window:view-any (or the project-subscription-plan:view-any alias).
- `RESOURCE_NOT_FOUND` — plan_id or project_id names something the token cannot see: unknown, another team's, or outside the token's scope:project: allow-list. Without either, the list spans only the projects the allow-list admits.
- `VALIDATION_FAILED` — status is not one of scheduled, open, closed, canceled, or from / to is not a parseable timestamp (reason: not_a_timestamp).
### status and sellable are different questions
A `scheduled` window is not necessarily buyable: the plan's sales cutoff may have closed it
already. An `open` window is not necessarily unbuyable either — a plan anchored to
`before_end` keeps selling while the window runs.
Filter on `status` to reason about the schedule. Read `sellable` to reason about what a
customer can actually buy.
### Building a season ticket, end to end
1. `list_plans` with `project_id` — find the `kind: pass` plans to draw from.
2. `list_pass_windows` with `plan_id` and a `from`/`to` range — collect the window UUIDs.
3. `create_plan` with `kind: pass_series` and those ids in `pass_series.window_ids`.
Add a `pass_series.rules` entry in step 3 if the season should keep absorbing new windows as
they are scheduled — those are granted to existing holders automatically, at no charge.
### Example prompts
> "List the upcoming windows on plan `pln_01HZ...` so I can build a season ticket from them."
> "Which pass windows on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` are still on sale this month?"
> "How many people hold the window on 20 September?"
### Related
- [create_plan](https://docs.subscriby.net/mcp/v1/tools/plan#create-plan) — where these ids are used.
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans)
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans)
- [Pass Series guide](https://docs.subscriby.net/creators/pass-series)
## list_plans
Paginated list of subscription plans visible to the current token, optionally scoped to one project.
List subscription plans. Results are always scoped to the caller's team; pass an optional `project_id` to narrow further. Ordered by newest-first.
- Requires ability: `project-subscription-plan:view-any`
- Runs the same action as [`GET /v1/projects/{project}/plans`](https://docs.subscriby.net/api/v1/reference/plans#list-a-projects-plans)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID to narrow the list to a single project. |
| `limit` | integer | no | Maximum plans to return per page (1..100). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_plans",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"limit": 1
}
}
```
### What it returns
```json
{
"data": [
{
"id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"name": "Premium Monthly",
"description": "All-access pass",
"price": "29.00",
"currency": "USD",
"kind": "subscription",
"cadence": "Per Month",
"active": true,
"sales_cap": null,
"sales_cap_sold": 0,
"position": null,
"paused_reason": null,
"created_at": "2026-05-18T10:05:00Z",
"billing": {
"billing_cycle": "months",
"billing_cycle_count": 1,
"recurring": true,
"trial_days": 7,
"trial_cardless": false
}
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 3,
"has_more": false
}
}
```
**Every row is tagged by `kind`**
`kind` names the ONE nested block that accompanies it — `billing`, `pass` or `pass_series`.
The other two are **absent**, not null. So a billing cycle never appears on a plan where a
cycle means nothing, and you never have to probe for a nullable object to work out what you
are holding.
Every row also carries **`cadence`**, the human-readable duration string the portal and the
bot show — `"Per Month"`, `"Per 3 Hours"`, `"For all 10 passes"`. Use it rather than
deriving a duration yourself; deriving one from a pass's cycle fields is how integrations end
up printing "1 Month" beside a three-hour window.
**kind: pass**
```json
{
"kind": "pass",
"cadence": "Per 3 Hours",
"pass": {
"timezone": "America/New_York",
"recurrence": "weekly",
"sales_cutoff_minutes": 60,
"sales_cutoff_anchor": "before_start",
"next_window": {
"id": "3d5a8c72-b016-4e94-8fa7-61c209d4e738",
"starts_at": "2026-09-20T13:00:00Z",
"ends_at": "2026-09-21T03:00:00Z"
}
}
}
```
Window timestamps are UTC; `pass.timezone` is the zone the schedule was authored in and the
one a buyer should see. `pass.next_window` is `null` when the schedule has no window left to
sell, which also means the plan is not purchasable right now. On a plan whose sales cutoff is
anchored to the window end, that window may be one already running rather than a future one.
**kind: pass_series**
```json
{
"kind": "pass_series",
"cadence": "For all 10 passes",
"pass_series": {
"timezone": "America/New_York",
"window_count": 10,
"starts_at": "2026-09-20T13:00:00Z",
"ends_at": "2026-11-29T22:00:00Z",
"seat_cap": 50,
"seats_remaining": 13,
"successor_plan_id": null
}
}
```
A series sells a slate of **other** pass plans' windows for one payment. It owns none of its
own, so there is no schedule here — use [`list_pass_windows`](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows)
to see the individual dates, and the [Plans API](https://docs.subscriby.net/api/v1/reference/plans) for the full slate with
the plan each date came from.
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription-plan:view-any.
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see: unknown, another team's, or outside the token's scope:project: allow-list. Without a project_id, the list spans only the projects the allow-list admits.
### Example prompts
> "List every plan on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "Show me all active plans across my projects."
### Related
- [create_plan](https://docs.subscriby.net/mcp/v1/tools/plan#create-plan)
- [list_pass_windows](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows)
- [publish_plan](https://docs.subscriby.net/mcp/v1/tools/plan#publish-plan)
- [get_plan_performance](https://docs.subscriby.net/mcp/v1/tools/analytics-and-reports#get-plan-performance)
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans)
## publish_plan
Publish or unpublish a subscription plan by flipping its active flag. Emits plan.activated or plan.deactivated.
Toggle a plan's `active` flag. Active plans are purchasable and listed on the portal; inactive plans stay on the books but hide from new buyers. Noop when the plan is already in the requested state.
> **Free plans can only be published on a paid plan**
>
> A plan priced at `0` can only be sold on the Starter and Growth plans. On the
> Free plan the call is refused with `TEAM_TIER_REQUIRED` and the plan stays off
> sale — give it a price first, or upgrade the creator.
>
> Unpublishing is always allowed whatever the price, so a plan can always be wound
> down.
- Requires ability: `project-subscription-plan:update`
- Runs the same action as [`POST /v1/projects/{project}/plans/{plan}/publish`](https://docs.subscriby.net/api/v1/reference/plans#publish-a-plan)
- Runs the same action as [`POST /v1/projects/{project}/plans/{plan}/unpublish`](https://docs.subscriby.net/api/v1/reference/plans#unpublish-a-plan)
- Fires events: [`plan.activated`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-activated), [`plan.deactivated`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-deactivated)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | yes | UUID of the plan to flip. |
| `active` | boolean | no | Target state — true publishes, false unpublishes. Defaults to true. |
### Example call
```json
{
"name": "publish_plan",
"arguments": {
"plan_id": "789c4fba-32b8-4800-a195-4c9fd62c9ecf"
}
}
```
### What it returns
```json
{
"data": {
"id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"active": true
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown plan_id, or the plan belongs to a team outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription-plan:update.
- `TEAM_TIER_REQUIRED` — active=true on a plan priced at 0 while the creator is on the Free plan. error.context.reason carries the remedy: price the plan, or upgrade the creator to Starter or Growth.
### Example prompts
> "Publish plan `c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b`."
> "Unpublish the Beta Early-Bird plan."
### Related
- [create_plan](https://docs.subscriby.net/mcp/v1/tools/plan#create-plan)
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans)
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans)
## remind_pass_window_queue
Nudge everyone who bought a window but has not come through their grant yet, re-sending it. Messages real people.
A pass holder who bought a window still has to come through their grant (on Telegram, tap the
invite link and send a join request) before the window opens; the ones who have not are the
window's **queue**. This tool re-sends the grant to every holder still in that queue, the way the dashboard's "remind
everyone" action on a window does, and answers with how many holders the connector accepted the message
for.
A window that has ended or been cancelled has nobody left to remind and answers `0`. Holders who
already queued are skipped.
> **This messages real people**
>
> Confirm with the creator before calling, and do not repeat it within the same
> window — the queue does not change because it was nudged twice. To remind one
> holder, use [`remind_pass_holder`](https://docs.subscriby.net/mcp/v1/tools/subscription#remind-pass-holder).
- Requires ability: `pass-window:update`
- Runs the same action as [`POST /v1/projects/{project}/pass-windows/{window}/remind`](https://docs.subscriby.net/api/v1/reference/pass-windows#remind-a-windows-holders)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose pass plans own the window. |
| `window_id` | string | yes | UUID of the window whose missing holders to nudge. |
### Example call
```json
{
"name": "remind_pass_window_queue",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"window_id": "d01d0b3b-7210-4800-ae2a-7282a32bd3ad"
}
}
```
### What it returns
```json
{
"data": {
"window_id": "3d5a8c72-b016-4e94-8fa7-61c209d4e738",
"reminded": 4
}
}
```
`reminded` counts messages the connector accepted, not holders in the queue: a holder who has blocked
the bot is in the queue but cannot be reached, and is not counted.
### How it fails
- `VALIDATION_FAILED` — the reminder was refused for the window's current state.
- `RESOURCE_NOT_FOUND` — unknown project_id or window_id, a window of another project, or a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks pass-window:update.
### Example prompts
> "Remind everyone who bought Saturday's window in project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` but hasn't joined yet."
> "The 3 October window opens in an hour — nudge the stragglers."
> "How many holders of window `3d5a8c72-b016-4e94-8fa7-61c209d4e738` did the reminder reach?"
### Related
- [remind_pass_holder](https://docs.subscriby.net/mcp/v1/tools/subscription#remind-pass-holder) — the same nudge for one subscription.
- [get_pass_window](https://docs.subscriby.net/mcp/v1/tools/plan#get-pass-window) — how many holders the window has.
- [Pass windows API](https://docs.subscriby.net/api/v1/reference/pass-windows) — the REST equivalent.
## reorder_plans
Pin the order a project's plans appear in on the portal and in the project's bot, or reset it to the built-in order. Emits plan.order_changed.
Arrange the storefront. Buyers see plans in the order you pass; anything you leave out keeps the built-in order (passes, then seasons, then subscriptions, cheapest first) after the pinned plans. An empty list clears every pin. This is the same write the dashboard's **Arrange Storefront Order** drag-and-drop performs.
> **Inactive plans may be pinned**
>
> Only active plans are shown to buyers, but you may include an inactive plan so
> it takes its place the moment it is published. The response lists active
> plans only, in the order buyers see them.
- Requires ability: `project-subscription-plan:update`
- Runs the same action as [`POST /v1/projects/{project}/plans/order`](https://docs.subscriby.net/api/v1/reference/plans#arrange-the-storefront-order)
- Fires events: [`plan.order_changed`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-order-changed)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose storefront is arranged. |
| `plan_ids` | array of any | yes | Plan UUIDs in the order buyers should see them. Empty clears every pin and restores the built-in order. |
### Example call
```json
{
"name": "reorder_plans",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"plan_ids": []
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"plan_ids": [
"c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"b1a7c3d5-2e48-4f60-9a1b-7c5d3e820f94"
],
"plans": [
{
"id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"name": "Weekly Football Pass",
"kind": "pass_series",
"active": true,
"position": 0
}
]
}
}
```
Each entry in `plans` is the same row shape `list_plans` and `get_plan` return.
### Errors
| Code | When |
| ------------------- | ------------------------------------------------------------- |
| `VALIDATION_FAILED` | An id in `plan_ids` is not a plan of this project. |
| `RESOURCE_NOT_FOUND`| The project does not exist or the token cannot see it. |
### Example prompts
> "Show the Weekly Football Pass first on the storefront, then the four daily passes."
> "Reset the plan order for project `7f3d1c92-…` to the default."
## start_next_season
Duplicate a finished pass series into its next season — a new inactive pass_series plan with the same settings, an empty slate, and the old season linked to it. Not idempotent.
The plan list's "start next season" button, for agents. It creates a **new** plan of kind
`pass_series` copied from the one you name — same price, currency, description, eligibility,
linked resources, overlap rule, sales cutoff and seat cap — with two deliberate differences: it is
created **inactive**, and its slate is **empty**. A season that went straight on sale with last
year's dates in it would be selling something that has already happened.
The step a hand-built successor forgets is the link: the old season's `successor_plan_id` is pointed
at the new plan (and a presale window is set if the old season had none), which is what lets current
holders be offered the next season first. Rules are copied forward with their date bounds shifted by
the length of the finished season, so "every pass in September" becomes next September; the slate
itself and any blackouts are not copied, because both name specific windows that have run.
> **Not idempotent — call it once per season**
>
> Every call creates another plan, named after the source with a season number
> appended — `Match Day Season (Season 2)`, then `(Season 3)`, and so on — and
> re-points the old season's successor at the newest one. Check
> `pass_series.successor_plan_id` on the source with
> [`get_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#get-plan) before calling; if it is already set, the
> next season exists.
Emits [`plan.created`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-created) for the new plan. The new season is then
composed and published like any other: add dates with [`update_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#update-plan) —
`pass_series.window_ids` from [`list_pass_windows`](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows), or
`pass_series.rules` — and put it on sale with [`publish_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#publish-plan).
- Requires ability: `project-subscription-plan:create`
- Runs the same action as [`POST /v1/projects/{project}/plans/{plan}/successor`](https://docs.subscriby.net/api/v1/reference/plans#start-the-next-season)
- Fires events: [`plan.created`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-created)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | yes | UUID of the finished pass_series plan to duplicate into its next season. |
### Example call
```json
{
"name": "start_next_season",
"arguments": {
"plan_id": "789c4fba-32b8-4800-a195-4c9fd62c9ecf"
}
}
```
### What it returns
```json
{
"data": {
"id": "9e12f0b4-7c3a-4d58-b2e6-0a5f81c4d739",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"name": "Match Day Season (Season 2)",
"description": "Every home match this season",
"price": "180.00",
"currency": "USD",
"kind": "pass_series",
"cadence": "For all 0 passes",
"active": false,
"created_at": "2026-12-01T09:00:00+00:00",
"pass_series": {
"timezone": null,
"window_count": 0,
"starts_at": null,
"ends_at": null,
"seat_cap": 50,
"seats_remaining": 50,
"successor_plan_id": null
}
}
}
```
The row is the **new** plan's, in the [`get_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#get-plan) shape. `active` is `false`
and `window_count` is `0` until you compose it; `starts_at`, `ends_at` and `timezone` are `null`
because a slate with no dates has no span. Its own `successor_plan_id` is `null` — it is the source
plan whose `successor_plan_id` now points here.
### How it fails
- `VALIDATION_FAILED` — plan_id names a plan whose kind is not pass_series
- `RESOURCE_NOT_FOUND` — unknown plan_id, a plan on another team's project, one outside the
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription-plan:create.
### Example prompts
> "Start the next season from plan `c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b`."
> "The 2026 season ticket has finished — set up the 2027 one with the same price and seat cap, but"
> "don't put it on sale yet."
> "Duplicate the Match Day Season into Season 2, then add every home window in the new fixture list"
> "to it."
### Related
- [get_plan](https://docs.subscriby.net/mcp/v1/tools/plan#get-plan) — check successor_plan_id before calling.
- [update_plan](https://docs.subscriby.net/mcp/v1/tools/plan#update-plan) — fill the new slate.
- [list_pass_windows](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) — the window ids to fill it with.
- [publish_plan](https://docs.subscriby.net/mcp/v1/tools/plan#publish-plan) — put the new season on sale.
- [create_plan](https://docs.subscriby.net/mcp/v1/tools/plan#create-plan) — build a season from scratch instead.
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans#get-a-plan) — the REST equivalent,
- [Pass Series](https://docs.subscriby.net/creators/pass-series#starting-the-next-season) — what the dashboard does and
## update_plan
Change one or more fields of a plan. Partial by design — anything omitted keeps its stored value; the kind is fixed. Emits plan.updated.
Change an existing plan without restating it. Only the arguments present in the call reach the
write, the way the REST `PATCH` behaves, so repricing a plan is one field, not a re-creation. The
shared fields are checked the way [`create_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#create-plan) checks them, and the one
nested block the plan's kind names is shaped exactly as `create_plan` shapes it.
Synchronous — the row after the change comes back, and
[`plan.updated`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-updated) emits once with the fields that actually moved. A
call whose arguments change nothing returns the current row and emits nothing.
> **The kind is fixed, and so is which block you may send**
>
> A plan's `kind` is set at creation and cannot change; a `kind` argument that
> disagrees with the stored one is refused rather than converting a subscription
> into a pass — create a new plan instead. The same rule decides the nested
> block: `billing` only on a `subscription`, `pass` only on a `pass`,
> `pass_series` only on a `pass_series`. A block of another kind fails with
> `VALIDATION_FAILED` naming the stray key; it is never silently ignored.
> **`resources` replaces, it does not append**
>
> Send the **full** list of resource UUIDs the plan should grant. Whatever is
> missing from the list is unlinked. A `subscription` or `pass` must keep at
> least one; a `pass_series` may be empty, because each window in a series
> grants its own plan's resources.
> **Flipping `active` here is not the same as publish_plan**
>
> `active` is accepted, and it works, but it is announced as a field inside
> `plan.updated`. [`publish_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#publish-plan) flips the same flag
> with its own `plan.activated` / `plan.deactivated` event, which is what an
> automation reacting to plans going on or off sale should key on.
Two tiers of entitlement are checked on the way in: a `price` of `0` makes the plan free, which
needs the Starter or Growth tier, and anything touching a `pass` or `pass_series` needs Time-Limited
Passes. Either failing surfaces as `TEAM_TIER_REQUIRED` with nothing written.
- Requires ability: `project-subscription-plan:update`
- Runs the same action as [`PATCH /v1/projects/{project}/plans/{plan}`](https://docs.subscriby.net/api/v1/reference/plans#update-a-plan)
- Fires events: [`plan.updated`](https://docs.subscriby.net/webhooks/v1/events/plan#plan-updated)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plan_id` | string | yes | UUID of the plan to change. |
| `name` | string | no | Plan name (min 5, max 255 chars, unique per project). |
| `description` | string | no | Plan description. Max 1000 chars, HTML is filtered. Pass an empty string to clear. |
| `price` | number | no | What one purchase costs, as a decimal. At least the $1.00 USD equivalent unless 0, which makes the plan free and requires the Starter or Growth tier. |
| `currency_id` | string | no | UUID of the currency. Must be supported by an active payment method on the project. |
| `active` | boolean | no | true publishes the plan, false takes it off sale; existing subscriptions keep running either way. |
| `sales_cap` | integer | no | Pause the plan automatically after this many successful purchases (1-100000). Changing it restarts the count; null removes the limit. |
| `eligibility` | object | no | Audience restrictions: {newcomers_only?: bool, customers_only?: bool, churned_only?: bool, single_use?: bool, access_codes_only?: bool}. The first three are mutually exclusive. |
| `resources` | array of any | no | The full list of project_resources UUIDs this plan grants access to; replaces the current list. Must not be empty on kind subscription or kind pass. |
| `billing` | object | no | Only on a plan of kind subscription. {billing_cycle: days\|weeks\|months\|years\|lifetime, billing_cycle_count: int 1-99, recurring?: bool, disabled_renewal?: bool, trial_days?: int 0-365, trial_cardless?: bool, trial_type?: string}. lifetime requires billing_cycle_count=1. |
| `pass` | object | no | Only on a plan of kind pass. {timezone: IANA zone, schedule_mode?: repeating\|fixed, recurrence?: daily\|weekly\|monthly, recurrence_ends_at?: ISO-8601, sales_cutoff_minutes?: int, sales_cutoff_anchor?: before_start\|before_end, slots?: array, windows?: array}; the same shape `create_plan` documents. |
| `pass_series` | object | no | Only on a plan of kind pass_series. {window_ids?: array of pass-window UUIDs, prevent_overlaps?: bool, sales_cutoff_minutes?: int, sales_cutoff_anchor?: string, seat_cap?: int, successor_plan_id?: UUID, presale_hours?: int, blackout_window_ids?: array, rules?: array}; the same shape `create_plan` documents. Window ids come from `list_pass_windows`. |
### Example call
```json
{
"name": "update_plan",
"arguments": {
"plan_id": "789c4fba-32b8-4800-a195-4c9fd62c9ecf"
}
}
```
### What it returns
```json
{
"data": {
"id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"name": "Premium Monthly",
"description": "All-access pass",
"price": "34.00",
"currency": "USD",
"kind": "subscription",
"cadence": "Per Month",
"active": true,
"created_at": "2026-05-18T10:05:00+00:00",
"billing": {
"billing_cycle": "months",
"billing_cycle_count": 1,
"recurring": true,
"trial_days": 14,
"trial_cardless": false
}
}
}
```
The full [`get_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#get-plan) row, re-read after the write so a schedule or slate change
is reflected in the block. `price` is a decimal string. The block present depends on the plan's
`kind`; see [`get_plan`](https://docs.subscriby.net/mcp/v1/tools/plan#get-plan) for the `pass` and `pass_series` shapes.
### How it fails
- `TEAM_TIER_REQUIRED` — the change needs a tier the project owner lacks: a price of 0 without
- `VALIDATION_FAILED` — one entry per offending field in error.context: a kind argument that
- `RESOURCE_NOT_FOUND` — unknown plan_id, a plan on another team's project, one outside the
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription-plan:update.
### Example prompts
> "Raise plan `c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b` to $34 and extend the trial to 14 days."
> "Rename the Match Day Pass to 'Home Match Day Pass' — leave everything else alone."
> "Add windows `3d5a8c72-…` and `9e12f0b4-…` to the season ticket's slate, and cap it at 60 seats."
### Related
- [get_plan](https://docs.subscriby.net/mcp/v1/tools/plan#get-plan) — read the current values and the kind first.
- [publish_plan](https://docs.subscriby.net/mcp/v1/tools/plan#publish-plan) — flip active with its own event.
- [create_plan](https://docs.subscriby.net/mcp/v1/tools/plan#create-plan) — the same blocks, on a new plan; the only way to get a
- [list_pass_windows](https://docs.subscriby.net/mcp/v1/tools/plan#list-pass-windows) — resolve ids for pass_series.window_ids.
- [list_resources](https://docs.subscriby.net/mcp/v1/tools/resource#list-resources) — resolve ids for resources.
- [Plans API](https://docs.subscriby.net/api/v1/reference/plans#create-a-plan) — the REST equivalent and the per-kind rules.
- [Subscription Plans](https://docs.subscriby.net/creators/plans#edit-deactivate-or-delete) — the dashboard walkthrough.
---
# Project Tools
Source: https://docs.subscriby.net/mcp/v1/tools/project
A project is the container for one membership business: its plans, members, payment methods, resources and connectors. These tools create, read, update, archive and restore projects, the same actions the dashboard's project screens run, so an agent can set a business up or wind it down without touching anything a plan or a member owns.
## Tools
- [`archive_project`](#archive-project) — Archive Project (destructive)
- [`create_project`](#create-project) — Create Project (destructive)
- [`delete_project`](#delete-project) — Delete Project (destructive)
- [`get_project`](#get-project) — Get Project (read)
- [`list_projects`](#list-projects) — List Projects (read)
- [`restore_project`](#restore-project) — Restore Project (destructive)
- [`update_project`](#update-project) — Update Project (destructive)
## archive_project
Archive a project by flipping active=false. Emits project.archived. Noop if already archived.
Archive a project — suspends new signups but preserves existing subscribers and data. Delegates to an Action so policy + cache invalidation run and `project.archived` fires. Noop when the project is already archived.
- Requires ability: `project:update`
- Runs the same action as [`POST /v1/projects/{project}/archive`](https://docs.subscriby.net/api/v1/reference/projects#archive-a-project)
- Fires events: [`project.archived`](https://docs.subscriby.net/webhooks/v1/events/project#project-archived)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project to archive. |
### Example call
```json
{
"name": "archive_project",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"active": false
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown project_id, or the project belongs to a team outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project:update.
### Example prompts
> "Archive project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "Deactivate my Research Premium project temporarily."
### Related
- [update_project](https://docs.subscriby.net/mcp/v1/tools/project#update-project) — flip active: true to restore.
- [get_project](https://docs.subscriby.net/mcp/v1/tools/project#get-project)
- [Projects API](https://docs.subscriby.net/api/v1/reference/projects)
## create_project
Register a new Subscriby project on behalf of the authenticated creator.
Scaffold a new project. Enforces the creator tier's project limit, handle uniqueness and the tier-level `custom_handle` capability before the row lands. A project has no platform of its own: install connectors on it afterwards with [`install_connector`](https://docs.subscriby.net/mcp/v1/tools/connectors#install-connector).
- Requires ability: `project:create`
- Runs the same action as [`POST /v1/projects`](https://docs.subscriby.net/api/v1/reference/projects#create-a-project)
- Fires events: [`project.created`](https://docs.subscriby.net/webhooks/v1/events/project#project-created)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Project name (min 5, max 255 characters). |
| `description` | string | no | Optional description. Max 1000 chars, HTML is filtered. |
| `handle` | string | no | Optional URL-friendly handle. Alpha-dash lowercase, 5-255 chars. Starter or Growth tier required — Free-tier tokens get TEAM_TIER_REQUIRED if a handle is supplied. |
| `terms` | string | no | Optional URL to the terms page. Max 255 chars. |
| `privacy` | string | no | Optional URL to the privacy page. Max 255 chars. |
| `metrics` | boolean | no | Opt into aggregated metrics collection on this project. |
| `active` | boolean | no | Defaults to true. Set false to create in draft state. |
| `team_id` | string | no | Optional UUID of the owning team. Defaults to the token's scoped team. |
### Example call
```json
{
"name": "create_project",
"arguments": {
"name": ""
}
}
```
### What it returns
```json
{
"data": {
"id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"name": "Research Premium",
"handle": "research-premium-abc123",
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"active": true,
"created_at": "2026-05-18T10:05:00Z"
}
}
```
### How it fails
- `TEAM_TIER_REQUIRED` — creator has hit their plan's project limit, or they passed a handle while on a tier that does not include custom_handle. Error context carries required_capability.
- `VALIDATION_FAILED` — name too short, handle malformed / taken, or team_id is a team the token's owner does not belong to.
### Example prompts
> "Create a Subscriby project called 'Research Premium'."
> "Scaffold a project with handle 'weekend-deep-dives' and a one-paragraph description." _(requires Starter+ tier)_"
### Related
- [update_project](https://docs.subscriby.net/mcp/v1/tools/project#update-project)
- [archive_project](https://docs.subscriby.net/mcp/v1/tools/project#archive-project)
- [install_connector](https://docs.subscriby.net/mcp/v1/tools/connectors#install-connector)
- [Projects API](https://docs.subscriby.net/api/v1/reference/projects)
## delete_project
Permanently delete a project and everything beneath it. No soft-delete, no undo — confirm the target with a human first.
Remove a project for good. Projects do not soft-delete: the row goes, and its plans, subscribers,
subscriptions, resources, coupons, payment methods and support threads go with it through database
cascades. The tool is for the genuinely finished project — a pilot that ran its course, a duplicate
created by mistake — never for pausing sales.
> **Irreversible, and it takes everything with it**
>
> There is no restore for a deleted project; `restore_project` only reverses an
> archive. Confirm the exact `project_id` with a human before calling. The tool
> is annotated destructive so a client can prompt for confirmation.
> **Stopping sales is a different tool**
>
> To keep the history while turning new signups off, call
> [`archive_project`](https://docs.subscriby.net/mcp/v1/tools/project#archive-project). Existing subscribers keep
> their access and the project comes back with `restore_project`.
[`project.deleted`](https://docs.subscriby.net/webhooks/v1/events/project#project-deleted) emits **before** the row disappears, so a
webhook listener receives the final snapshot — id, name, handle, active flag and team — while those
ids still resolve. It is the last event the project will ever produce.
Idempotent in the only sense a hard delete can be: a second call on the same id finds nothing and
answers `RESOURCE_NOT_FOUND`, exactly as an unknown or out-of-scope id does.
- Requires ability: `project:delete`
- Runs the same action as [`DELETE /v1/projects/{project}`](https://docs.subscriby.net/api/v1/reference/projects#delete-a-project)
- Fires events: [`project.deleted`](https://docs.subscriby.net/webhooks/v1/events/project#project-deleted)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project to delete permanently. |
### Example call
```json
{
"name": "delete_project",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"deleted": true
}
}
```
`id` echoes the argument as sent; by the time the response is built there is no row left to read it
back from.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown project_id, a project belonging to another team, one outside the
- `VALIDATION_FAILED` — the caller is a team member whose role lacks the team's project-delete
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project:delete.
### Example prompts
> "Delete project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` — the pilot is over and I've checked"
> "nothing is left in it."
> "Remove the duplicate 'Research Premium (copy)' project I created by accident."
> "Wipe the test project and everything in it. Yes, I'm sure."
### Related
- [archive_project](https://docs.subscriby.net/mcp/v1/tools/project#archive-project) — stop sales and keep everything.
- [restore_project](https://docs.subscriby.net/mcp/v1/tools/project#restore-project) — undoes an archive; cannot undo a delete.
- [get_project](https://docs.subscriby.net/mcp/v1/tools/project#get-project) — read the project back before confirming.
- [Projects API](https://docs.subscriby.net/api/v1/reference/projects) — the REST equivalent, DELETE /projects/{project}.
- [Creating a Project](https://docs.subscriby.net/creators/projects#delete) — the dashboard walkthrough.
## get_project
Fetch a single project by UUID, including plan / resource / subscriber / payment-method counts.
Fetch full detail for one project along with its counts (plans, resources, subscribers, payment methods). Use after `list_projects` once you know which row to dive into.
- Requires ability: `project:view`
- Runs the same action as [`GET /v1/projects/{project}`](https://docs.subscriby.net/api/v1/reference/projects#get-a-project)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project to fetch. |
### Example call
```json
{
"name": "get_project",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"name": "Research Premium",
"handle": "research-premium",
"description": "Weekly deep-dive research notes.",
"banner_url": "https://static.subscriby.net/...",
"photo_url": "https://static.subscriby.net/...",
"active": true,
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"counts": {
"plans": 3,
"resources": 2,
"subscribers": 241,
"payment_methods": 2
},
"created_at": "2026-05-18T10:05:00Z",
"updated_at": "2026-05-18T10:05:00Z"
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown project_id, or the project belongs to a team outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project:view.
### Example prompts
> "Get details for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "How many subscribers does my Research Premium project have right now?"
### Related
- [list_projects](https://docs.subscriby.net/mcp/v1/tools/project#list-projects)
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans)
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers)
- [Projects API](https://docs.subscriby.net/api/v1/reference/projects)
## list_projects
Paginated list of Subscriby projects visible to the current token.
Return every project the token's team can see, paginated. Use this as a starter tool — most other tools take a `project_id` you'll pick up from here.
- Requires ability: `project:view-any`
- Runs the same action as [`GET /v1/projects`](https://docs.subscriby.net/api/v1/reference/projects#list-projects)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | integer | no | Maximum projects to return per page (1..100). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_projects",
"arguments": {
"limit": 1,
"page": 1
}
}
```
### What it returns
```json
{
"data": [
{
"id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"name": "Research Premium",
"handle": "research-premium",
"description": "Weekly deep-dive research notes.",
"active": true,
"created_at": "2026-05-18T10:05:00Z"
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 12,
"has_more": false
}
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks project:view-any.
### Example prompts
> "List my Subscriby projects."
> "Show me page 2 of my projects with 50 per page."
### Related
- [get_project](https://docs.subscriby.net/mcp/v1/tools/project#get-project) — fetch full detail including counts for one project.
- [list_plans](https://docs.subscriby.net/mcp/v1/tools/plan#list-plans)
- [Projects API](https://docs.subscriby.net/api/v1/reference/projects)
## restore_project
Bring an archived project back on sale by flipping active to true. Emits project.restored. Noop if already active.
The reverse of [`archive_project`](https://docs.subscriby.net/mcp/v1/tools/project#archive-project). Flips `active` back to `true` so
checkout, the portal and the bot accept new subscribers again. Nothing else moves: an archive never
touched subscribers, plans or history, so there is nothing to rebuild.
It runs the same Action the dashboard and the REST API use, so
[`project.restored`](https://docs.subscriby.net/webhooks/v1/events/project#project-restored) fires once, when the flag actually changes.
Re-calling on a project that is already active is a no-op — the current row comes back and nothing
emits.
> **Archived is not deleted**
>
> An archived project resolves like any other, so its id is all you need. A
> project removed with [`delete_project`](https://docs.subscriby.net/mcp/v1/tools/project#delete-project) is gone for
> good and answers `RESOURCE_NOT_FOUND` here.
- Requires ability: `project:update`
- Runs the same action as [`POST /v1/projects/{project}/restore`](https://docs.subscriby.net/api/v1/reference/projects#restore-a-project)
- Fires events: [`project.restored`](https://docs.subscriby.net/webhooks/v1/events/project#project-restored)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the archived project to restore. |
### Example call
```json
{
"name": "restore_project",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"active": true
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown project_id, a project belonging to another team, one outside the
- `VALIDATION_FAILED` — the caller is a team member whose role lacks the team's project-update
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project:update.
### Example prompts
> "Restore project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` — we're reopening signups."
> "Put my Research Premium project back on sale."
> "Un-archive the summer cohort project."
### Related
- [archive_project](https://docs.subscriby.net/mcp/v1/tools/project#archive-project) — the other half of the pair.
- [get_project](https://docs.subscriby.net/mcp/v1/tools/project#get-project) — check active before and after.
- [update_project](https://docs.subscriby.net/mcp/v1/tools/project#update-project) — the general partial update.
- [Projects API](https://docs.subscriby.net/api/v1/reference/projects#archive-a-project) — the REST equivalent,
- [Creating a Project](https://docs.subscriby.net/creators/projects#archive-or-deactivate) — the dashboard walkthrough.
## update_project
Apply a partial update to a project. Only supplied fields are touched; omitted keys stay as-is.
Apply a partial update to an existing project. Delegates to an Action so the project-service policy and cache invalidation run, and a `project.updated` event fires with a map of changed fields.
- Requires ability: `project:update`
- Runs the same action as [`PATCH /v1/projects/{project}`](https://docs.subscriby.net/api/v1/reference/projects#update-a-project)
- Fires events: [`project.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-updated)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project to update. |
| `name` | string | no | New project name (min 5, max 255 chars). |
| `description` | string | no | New description. Max 1000 chars, HTML is filtered. |
| `handle` | string | no | New URL-friendly handle. Alpha-dash lowercase, 5-255 chars, unique. Requires Starter or Growth creator tier. |
| `terms` | string | no | URL to the terms page. Max 255 chars. |
| `privacy` | string | no | URL to the privacy page. Max 255 chars. |
| `metrics` | boolean | no | Toggle aggregated metrics collection. |
| `active` | boolean | no | Flip to false to archive, true to restore. |
| `outage_compensations` | boolean | no | Whether members whose paid access overlapped a connector outage of an hour or more have it extended by the outage length when the connector answers again. Defaults to true on every project. |
| `team_id` | string | no | UUID of the owning team. |
### Example call
```json
{
"name": "update_project",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"name": "Research Premium",
"handle": "research-premium",
"active": true
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown project_id, or the project belongs to a team outside the token's scope.
- `VALIDATION_FAILED` — a malformed field, or team_id is a team the token's owner does not belong to.
- `TOKEN_MISSING_ABILITY` — token lacks project:update.
### Example prompts
> "Update project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` description to mention weekly AMAs."
> "Rename my Research Premium project to 'Research Pro'."
### Related
- [get_project](https://docs.subscriby.net/mcp/v1/tools/project#get-project)
- [archive_project](https://docs.subscriby.net/mcp/v1/tools/project#archive-project)
- [Projects API](https://docs.subscriby.net/api/v1/reference/projects)
---
# Resource Tools
Source: https://docs.subscriby.net/mcp/v1/tools/resource
A resource is what a plan unlocks: a place a connector gates, or a perk tracked by hand. These tools register resources, link and unlink them, read their health and manage the standby and replacement flows Disaster Recovery relies on.
## Tools
- [`activate_resource`](#activate-resource) — Activate Resource (destructive)
- [`create_resource`](#create-resource) — Create Manual Resource (destructive)
- [`deactivate_resource`](#deactivate-resource) — Deactivate Resource (destructive)
- [`delete_resource`](#delete-resource) — Delete Resource (destructive)
- [`get_resource`](#get-resource) — Get Resource (read)
- [`list_resources`](#list-resources) — List Resources (read)
- [`request_resource_link`](#request-resource-link) — Request Resource Link (write)
- [`unlink_resource`](#unlink-resource) — Unlink Resource (destructive)
- [`update_resource`](#update-resource) — Update Resource (destructive)
## activate_resource
Switch a project resource on so the plans that link it grant it again. Emits project.resource.updated when the switch flips.
Turn a resource back on. The plans that link it never stopped linking it while it was off — switching
off only stopped the bot offering and granting the channel or group — so switching on needs no
re-linking: the plans grant it again from the next admission.
Runs the same Action the dashboard and the REST API use.
[`project.resource.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-updated) emits once, carrying
`changes.active` from `false` to `true`, and only when the switch actually flips. Re-calling on a
resource that is already on is a no-op: the current row comes back and nothing emits.
- Requires ability: `project-resource:update`
- Runs the same action as [`POST /v1/projects/{project}/resources/{resource}/activate`](https://docs.subscriby.net/api/v1/reference/resources#activate-a-resource)
- Fires events: [`project.resource.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-updated)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource to switch on. |
### Example call
```json
{
"name": "activate_resource",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"kind": "telegram:channel",
"connector": "telegram",
"space": {
"id": "3c1f7a58-2b6e-4d90-8f41-6a2e9c5d7b10",
"external_id": "-1001234567890",
"title": "Research Channel"
},
"title": "Research Channel",
"description": null,
"active": true,
"created_at": "2026-05-18T10:05:00+00:00",
"updated_at": "2026-09-06T08:30:12+00:00"
}
}
```
The full [`get_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource) row, with `active: true`.
### How it fails
- `VALIDATION_FAILED` — the caller is a team member whose role lacks the team's resource-update
- `RESOURCE_NOT_FOUND` — unknown resource_id, a resource on another team's project, or one outside
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-resource:update.
### Example prompts
> "Switch resource `b73c5f21-9d80-4a6e-8215-4f70ce13a9d6` back on."
> "Re-enable the Research Channel — the maintenance is done."
> "Turn the VIP lounge resource on again for every plan that includes it."
### Related
- [deactivate_resource](https://docs.subscriby.net/mcp/v1/tools/resource#deactivate-resource) — the other half of the switch.
- [update_resource](https://docs.subscriby.net/mcp/v1/tools/resource#update-resource) — change the title or description at the same
- [get_resource](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource)
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources) — the REST equivalent,
- [Resources](https://docs.subscriby.net/creators/resources#edit-deactivate-or-delete) — the dashboard walkthrough.
## create_resource
Create a manual project resource (PDF link, token, URL placeholder) on a project with a connected connector. Gated places are linked through their connector.
Create a manual project resource. Delegates to an Action so policy checks and cache invalidation run identically to the dashboard, and a `project.resource.created` event fires.
> **Warning**
>
> Gated places (a channel, group or supergroup on Telegram, and whatever
> another connector gates) cannot be created through this tool. Only the
> connector can prove its installation administers the place, so a link starts
> with [`request_resource_link`](https://docs.subscriby.net/mcp/v1/tools/resource#request-resource-link) and finishes
> on the platform. Create manual perks here, and only once a connector on the
> project is connected: a project with none answers `CONNECTOR_NOT_INSTALLED`.
- Requires ability: `project-resource:create`
- Runs the same action as [`POST /v1/projects/{project}/resources`](https://docs.subscriby.net/api/v1/reference/resources#create-a-manual-perk)
- Fires events: [`project.resource.created`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-created)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the parent project. |
| `title` | string | yes | Resource title (5..255 chars). |
| `description` | string | no | Optional resource description. Max 1000 chars, HTML is filtered. |
| `active` | boolean | no | Defaults to true. Set false to create in inactive state. |
### Example call
```json
{
"name": "create_resource",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"title": ""
}
}
```
### What it returns
```json
{
"data": {
"id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"kind": "manual",
"connector": null,
"space": null,
"title": "Onboarding PDF",
"description": "Link to the onboarding pack.",
"active": true,
"created_at": "2026-05-18T10:05:00Z",
"updated_at": "2026-05-18T10:05:00Z"
}
}
```
The full [`get_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource) row: a manual perk is `kind: "manual"` with `connector` and `space` both `null`.
### How it fails
- `VALIDATION_FAILED` — title is outside the 5..255 character range.
- `CONNECTOR_NOT_INSTALLED` — no connector on the project is connected, so nothing could deliver the resource; install and connect one first.
- `RESOURCE_NOT_FOUND` — project_id is unknown or out of the token's team scope.
- `TOKEN_MISSING_ABILITY` — token lacks project-resource:create.
### Example prompts
> "Create a manual resource titled 'Welcome packet' on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "Add an onboarding resource to my Research Premium project."
### Related
- [list_resources](https://docs.subscriby.net/mcp/v1/tools/resource#list-resources)
- [get_resource](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource)
- [delete_resource](https://docs.subscriby.net/mcp/v1/tools/resource#delete-resource)
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources)
## deactivate_resource
Switch a project resource off without touching the plans that sell it. Reversible with activate_resource. Emits project.resource.updated when the switch flips.
Pause one channel or group without editing a single plan. While a resource is off the bot stops
offering and granting it, and the plans that link it keep selling everything else they include. The
link itself is untouched, which is what makes this reversible:
[`activate_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#activate-resource) brings it back exactly as it was.
> **The safe alternative to delete_resource**
>
> [`delete_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#delete-resource) removes the row for good and
> every plan loses the link. Switching off keeps the row, the link and the
> connector binding. Reach for it for maintenance, a channel being reorganised,
> or anything you might want back.
Runs the same Action the dashboard and the REST API use.
[`project.resource.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-updated) emits once, carrying
`changes.active` from `true` to `false`, and only when the switch actually flips. Re-calling on a
resource that is already off is a no-op: the current row comes back and nothing emits.
- Requires ability: `project-resource:update`
- Runs the same action as [`POST /v1/projects/{project}/resources/{resource}/deactivate`](https://docs.subscriby.net/api/v1/reference/resources#deactivate-a-resource)
- Fires events: [`project.resource.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-updated)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource to switch off. |
### Example call
```json
{
"name": "deactivate_resource",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"kind": "telegram:channel",
"connector": "telegram",
"space": {
"id": "3c1f7a58-2b6e-4d90-8f41-6a2e9c5d7b10",
"external_id": "-1001234567890",
"title": "Research Channel"
},
"title": "Research Channel",
"description": null,
"active": false,
"created_at": "2026-05-18T10:05:00+00:00",
"updated_at": "2026-09-06T08:30:12+00:00"
}
}
```
The full [`get_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource) row, with `active: false`. `space` is kept —
the binding survives the switch.
### How it fails
- `VALIDATION_FAILED` — the caller is a team member whose role lacks the team's resource-update
- `RESOURCE_NOT_FOUND` — unknown resource_id, a resource on another team's project, or one outside
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-resource:update.
### Example prompts
> "Switch resource `b73c5f21-9d80-4a6e-8215-4f70ce13a9d6` off for now."
> "Pause the Research Channel while we reorganise it — don't touch the plans."
> "Take the VIP lounge offline temporarily; I'll turn it back on next week."
### Related
- [activate_resource](https://docs.subscriby.net/mcp/v1/tools/resource#activate-resource) — switch it back on.
- [delete_resource](https://docs.subscriby.net/mcp/v1/tools/resource#delete-resource) — remove the row for good.
- [unlink_resource](https://docs.subscriby.net/mcp/v1/tools/resource#unlink-resource) — clear the connector binding but keep the row.
- [get_resource](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource)
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources) — the REST equivalent,
- [Resources](https://docs.subscriby.net/creators/resources#edit-deactivate-or-delete) — the dashboard walkthrough.
## delete_resource
Hard-delete a project resource. Emits project.resource.deleted before the row disappears so listeners see the snapshot.
Hard-delete a project resource by UUID. Delegates to an Action that emits `project.resource.deleted` with the row snapshot **before** the delete executes so downstream listeners capture the final state. Scoped to the caller's team.
- Requires ability: `project-resource:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/resources/{resource}`](https://docs.subscriby.net/api/v1/reference/resources#delete-a-resource)
- Fires events: [`project.resource.deleted`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-deleted)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource to delete. |
### Example call
```json
{
"name": "delete_resource",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"resource_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"deleted": true
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown resource_id, or the parent project is outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project-resource:delete.
### Example prompts
> "Delete resource `b73c5f21-9d80-4a6e-8215-4f70ce13a9d6`."
> "Remove the Onboarding PDF resource from my project."
### Related
- [unlink_resource](https://docs.subscriby.net/mcp/v1/tools/resource#unlink-resource) — clear a connector link without deleting the row.
- [list_resources](https://docs.subscriby.net/mcp/v1/tools/resource#list-resources)
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources)
## get_resource
Fetch a single project resource by UUID. Returns its kind, connector, the place it is bound to, title, description, switch and timestamps.
Fetch full detail for one project resource. Results are scoped to the caller's team; unknown or out-of-scope IDs surface as `RESOURCE_NOT_FOUND` to prevent existence leaks.
- Requires ability: `project-resource:view`
- Runs the same action as [`GET /v1/projects/{project}/resources/{resource}`](https://docs.subscriby.net/api/v1/reference/resources#get-a-resource)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the project resource to fetch. |
### Example call
```json
{
"name": "get_resource",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"kind": "telegram:channel",
"connector": "telegram",
"space": {
"id": "3c1f7a58-2b6e-4d90-8f41-6a2e9c5d7b10",
"external_id": "-1001234567890",
"title": "Research Channel"
},
"title": "Research Channel",
"description": null,
"active": true,
"created_at": "2026-05-18T10:05:00Z",
"updated_at": "2026-05-18T10:05:00Z"
}
}
```
`kind` is `manual` for a perk the creator hands over by hand, otherwise the connector's kind spelled `connector:kind` as [`list_connectors`](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connectors) lists each connector's `resource_kinds`. `connector` and `space` are `null` for a manual perk; `space` is also `null` for a place-kind resource that was unlinked.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown resource_id, or the parent project is outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project-resource:view.
### Example prompts
> "Show resource `b73c5f21-9d80-4a6e-8215-4f70ce13a9d6` in full."
> "Which place is this resource linked to?"
### Related
- [list_resources](https://docs.subscriby.net/mcp/v1/tools/resource#list-resources)
- [create_resource](https://docs.subscriby.net/mcp/v1/tools/resource#create-resource)
- [unlink_resource](https://docs.subscriby.net/mcp/v1/tools/resource#unlink-resource)
- [delete_resource](https://docs.subscriby.net/mcp/v1/tools/resource#delete-resource)
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources)
## list_resources
Paginated list of project resources (the places a connector gates plus manual perks) scoped to one project, narrowable by kind and by connector.
List every resource attached to a project — the places a connector gates, manual PDF / URL perks, and any other unit the creator has registered as gated content. Results are scoped to the caller's team and ordered newest-first. `kind` narrows the page to one kind of resource and `connector` to everything one connector gates; either alone or both together.
- Requires ability: `project-resource:view-any`
- Runs the same action as [`GET /v1/projects/{project}/resources`](https://docs.subscriby.net/api/v1/reference/resources#list-a-projects-resources)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the parent project. |
| `limit` | integer | no | Maximum resources to return per page (1..100). |
| `page` | integer | no | 1-indexed page number. |
| `kind` | string | no | Only resources of one kind: `manual`, or a connector's kind spelled `connector:kind` as `list_connectors` lists each connector's `resource_kinds`. |
| `connector` | string | no | Only the resources one connector gates, by the key `list_connectors` lists it under. |
### Example call
```json
{
"name": "list_resources",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"kind": "telegram:channel",
"connector": "telegram",
"space": {
"id": "3c1f7a58-2b6e-4d90-8f41-6a2e9c5d7b10",
"external_id": "-1001234567890",
"title": "Research Premium Lounge"
},
"title": "Research Premium Lounge",
"description": "Daily research notes.",
"active": true,
"created_at": "2026-05-18T10:05:00Z",
"updated_at": "2026-05-18T10:05:00Z"
},
{
"id": "8c2f4a61-3d7e-4b90-a5c8-1e6f9d0b2c73",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"kind": "manual",
"connector": null,
"space": null,
"title": "Onboarding PDF",
"description": "Link to the onboarding pack.",
"active": true,
"created_at": "2026-05-18T10:05:00Z",
"updated_at": "2026-05-18T10:05:00Z"
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 2,
"has_more": false
}
}
```
`space` is the place the resource is bound to, `null` for a manual perk and for a place-kind resource that was unlinked. The row is the same object [`GET /v1/projects/{project}/resources`](https://docs.subscriby.net/api/v1/reference/resources#get-a-resource) returns.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown project_id, or the project belongs to a team outside the token's scope.
- `VALIDATION_FAILED` — kind is neither manual nor connector:kind, or connector is not a connector key.
- `TOKEN_MISSING_ABILITY` — token lacks project-resource:view-any.
### Example prompts
> "List resources on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "Which Telegram channels does my Research Premium project sell?"
> "Which resources are still unlinked on my Research Premium project?"
### Related
- [get_resource](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource)
- [create_resource](https://docs.subscriby.net/mcp/v1/tools/resource#create-resource)
- [request_resource_link](https://docs.subscriby.net/mcp/v1/tools/resource#request-resource-link)
- [unlink_resource](https://docs.subscriby.net/mcp/v1/tools/resource#unlink-resource)
- [list_connectors](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connectors) — each connector's resource_kinds, the values kind takes.
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources)
## request_resource_link
Ask the creator, through the connector, to pick the place a new resource will be; it appears the moment they choose.
Linking a place a connector gates is a conversation with the creator on the connector: only the platform can prove the installation administers the place, so the connector messages the creator with a picker and the resource appears, announced by `project.resource.linked`, the moment they choose. Nothing is created by this call itself; a manual perk is created with [`create_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#create-resource). The REST twin is [`POST /v1/projects/{project}/resources/link-requests`](https://docs.subscriby.net/api/v1/reference/resources#request-a-place-link).
- Requires ability: `project-resource:create`
- Runs the same action as [`POST /v1/projects/{project}/resources/link-requests`](https://docs.subscriby.net/api/v1/reference/resources#request-a-place-link)
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the linked place will belong to. |
| `kind` | string | yes | The kind of place to ask for, spelled `connector:kind`: a connector's key, a colon, and one of the `resource_kinds` that `list_connectors` lists for it. |
### Example call
```json
{
"name": "request_resource_link",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"kind": ""
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"kind": "telegram:channel",
"status": "request_sent"
},
"meta": {}
}
```
### How it fails
- `CONNECTOR_NOT_INSTALLED` — the project runs no connected connector, so nothing could finish the link.
- `VALIDATION_FAILED` — kind is manual, a bare word or otherwise not connector:kind; no connected connector on the project gates the kind; or the creator cannot be reached on the connector.
- `RESOURCE_NOT_FOUND` — project_id is not a project the token can see.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-resource:create.
### Example prompts
> "Ask me to pick a new Telegram channel for the Signals project."
> "Start linking a group to Ella's Kitchen."
### Related
- [create_resource](https://docs.subscriby.net/mcp/v1/tools/resource#create-resource) — a manual perk, created outright.
- [list_connectors](https://docs.subscriby.net/mcp/v1/tools/connectors#list-connectors) — the resource_kinds a connector gates, the values kind takes.
- [list_resources](https://docs.subscriby.net/mcp/v1/tools/resource#list-resources) — the resource appears here once the creator picks.
- [project.resource.linked](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-linked) — fires when the creator picks.
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources)
## unlink_resource
Clear the connector place from a resource, reverting it to an unlinked placeholder the creator can relink through the connector.
Clear the connector place (`space`) from a resource, reverting it to an unlinked placeholder row the creator can relink through the connector with [`request_resource_link`](https://docs.subscriby.net/mcp/v1/tools/resource#request-resource-link). Delegates to an Action so a `project.resource.unlinked` event fires. No-op if the resource is already unlinked.
- Requires ability: `project-resource:update`
- Runs the same action as [`POST /v1/projects/{project}/resources/{resource}/unlink`](https://docs.subscriby.net/api/v1/reference/resources#unlink-a-resources-place)
- Fires events: [`project.resource.unlinked`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-unlinked)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource to unlink. |
### Example call
```json
{
"name": "unlink_resource",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"kind": "telegram:channel",
"connector": "telegram",
"space": null,
"title": "Research Channel",
"description": null,
"active": true,
"created_at": "2026-05-18T10:05:00Z",
"updated_at": "2026-09-06T08:30:12Z"
}
}
```
The full [`get_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource) row with `space` cleared. The resource keeps its `kind` and `connector`: it is still a Telegram channel resource, only one bound to no place until the creator links another.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown resource_id, or the parent project is outside the token's scope.
- `TOKEN_MISSING_ABILITY` — token lacks project-resource:update.
### Example prompts
> "Unlink the Telegram channel from resource `b73c5f21-9d80-4a6e-8215-4f70ce13a9d6`."
> "Break the place link so I can re-attach a different channel."
### Related
- [get_resource](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource)
- [list_resources](https://docs.subscriby.net/mcp/v1/tools/resource#list-resources)
- [request_resource_link](https://docs.subscriby.net/mcp/v1/tools/resource#request-resource-link) — link another place afterwards.
- [delete_resource](https://docs.subscriby.net/mcp/v1/tools/resource#delete-resource)
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources)
## update_resource
Retitle, describe or switch a project resource. Partial by design — anything omitted keeps its stored value; the kind and the place are fixed.
Change what the dashboard's resource editor lets a creator change: the `title`, the `description`
and the `active` switch. Only the arguments present are written; anything omitted is filled from the
stored row, so a title-only change never touches the description and is never logged as a
description edit.
Emits [`project.resource.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-updated) with the fields that
actually moved. A call whose arguments change nothing returns the current row and emits nothing.
> **`kind`, `connector` and `space` are silently ignored**
>
> The resource `kind` is its identity and the connector place (`space`) is its
> binding; neither can be set here. Sending them is not an error — they are
> simply not read. A place is linked through the connector with
> [`request_resource_link`](https://docs.subscriby.net/mcp/v1/tools/resource#request-resource-link), and cleared with
> [`unlink_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#unlink-resource).
> **`active` here, or the dedicated switches**
>
> `active` is accepted alongside the other fields. When the switch is the only
> thing changing, [`activate_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#activate-resource) and
> [`deactivate_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#deactivate-resource) do the same thing with
> a one-argument call.
- Requires ability: `project-resource:update`
- Runs the same action as [`PATCH /v1/projects/{project}/resources/{resource}`](https://docs.subscriby.net/api/v1/reference/resources#update-a-resource)
- Fires events: [`project.resource.updated`](https://docs.subscriby.net/webhooks/v1/events/project#project-resource-updated)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `resource_id` | string | yes | UUID of the resource to change. |
| `title` | string | no | New title, 5 to 255 characters. |
| `description` | string | no | New description, up to 1000 characters of well-formed HTML. Pass null or an empty string to clear it. |
| `active` | boolean | no | true offers the resource on the plans that link it, false switches it off without touching those plans. |
### Example call
```json
{
"name": "update_resource",
"arguments": {
"resource_id": "7c569aa3-b9fc-4400-a1d9-e1ce62a0bd30"
}
}
```
### What it returns
```json
{
"data": {
"id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"kind": "telegram:channel",
"connector": "telegram",
"space": {
"id": "3c1f7a58-2b6e-4d90-8f41-6a2e9c5d7b10",
"external_id": "-1001234567890",
"title": "Members lounge"
},
"title": "Members lounge (VIP)",
"description": "Where the regulars hang out.",
"active": true,
"created_at": "2026-05-18T10:05:00+00:00",
"updated_at": "2026-09-06T08:30:12+00:00"
}
}
```
The full [`get_resource`](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource) row. `description` comes back purified, so the
HTML you read may be tidier than the HTML you sent. `space` is `null` on a manual perk and on a resource not yet linked
to a place on its connector.
### How it fails
- `VALIDATION_FAILED` — title outside 5–255 characters (error.context.title); description
- `RESOURCE_NOT_FOUND` — unknown resource_id, a resource on another team's project, or one outside
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-resource:update.
### Example prompts
> "Rename resource `b73c5f21-9d80-4a6e-8215-4f70ce13a9d6` to 'Members lounge (VIP)'."
> "Update the Research Channel's description to say it posts on weekdays only — keep the title."
> "Clear the description on the onboarding group resource."
### Related
- [get_resource](https://docs.subscriby.net/mcp/v1/tools/resource#get-resource) — read the current values first.
- [activate_resource](https://docs.subscriby.net/mcp/v1/tools/resource#activate-resource)
- [deactivate_resource](https://docs.subscriby.net/mcp/v1/tools/resource#deactivate-resource)
- [unlink_resource](https://docs.subscriby.net/mcp/v1/tools/resource#unlink-resource) — the only API way to change the place binding.
- [list_resources](https://docs.subscriby.net/mcp/v1/tools/resource#list-resources)
- [Resources API](https://docs.subscriby.net/api/v1/reference/resources) — the REST equivalent,
- [Resources](https://docs.subscriby.net/creators/resources#edit-deactivate-or-delete) — the dashboard walkthrough.
---
# Role & Group Tools
Source: https://docs.subscriby.net/mcp/v1/tools/roles-and-groups
A role is the permission set one collaborator holds; a group is a bundle several collaborators share. These tools create and edit both, with permissions written as the same ability strings a token carries.
## Tools
- [`create_group`](#create-group) — Create Group (destructive)
- [`create_role`](#create-role) — Create Role (destructive)
- [`delete_group`](#delete-group) — Delete Group (destructive)
- [`delete_role`](#delete-role) — Delete Role (destructive)
- [`list_groups`](#list-groups) — List Groups (read)
- [`list_roles`](#list-roles) — List Roles (read)
- [`sync_group_members`](#sync-group-members) — Sync Group Members (destructive)
- [`update_group`](#update-group) — Update Group (destructive)
- [`update_role`](#update-role) — Update Role (destructive)
## create_group
Create a permission group on a team. Starts empty; add people with sync_group_members.
Creates a group: a named permission bundle several collaborators in a team can share. Someone can be in more than one.
The distinction from a [role](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-role) is what each attaches to. A role is what one collaborator **is** — everyone holds exactly one. A group is a bundle a set of people **share**. Both carry permissions. Neither bundles projects.
A group starts **empty**. Add people with [`sync_group_members`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#sync-group-members) — membership is deliberately not a field here, because naming a group and deciding who it applies to are different operations with different consequences.
> **Choose the code deliberately**
>
> `code` is unique within the team and **cannot be changed afterwards** —
> permissions are addressed by it. Only `name` and the permission set stay
> mutable.
> **Warning**
>
> Growth-tier capability. On a lower tier this returns `TEAM_TIER_REQUIRED` and
> creates nothing.
- Requires ability: `group:create`
- Runs the same action as [`POST /v1/groups`](https://docs.subscriby.net/api/v1/reference/groups#create-a-group)
- Fires events: [`group.created`](https://docs.subscriby.net/webhooks/v1/events/group#group-created)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string | yes | UUID of the team the group belongs to. |
| `code` | string | yes | Stable identifier, alpha-dash. Unique per team and immutable once created. |
| `name` | string | yes | Human-readable group name. |
| `permissions` | array of any | no | Permission codes (entity:action) the group grants. |
### Example call
```json
{
"name": "create_group",
"arguments": {
"team_id": "ffaf93ac-a725-4800-a198-ea154bdce1f4",
"code": "",
"name": ""
}
}
```
### What it returns
```json
{
"data": {
"id": "1f68d92a-04c5-4e83-97b1-3d6a05e2f847",
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"code": "billing-team",
"name": "Billing Team",
"permissions": ["project-subscription:view-any"]
}
}
```
Emits [`group.created`](https://docs.subscriby.net/webhooks/v1/events/group#group-created).
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks group:create.
- `TEAM_TIER_REQUIRED` — the caller's platform tier does not include Teams.
- `RESOURCE_NOT_FOUND` — no such team, or the caller is not a member.
- `VALIDATION_FAILED` — code already used on that team, malformed code, or an unknown permission string.
### Example prompts
> "Create a billing group that can read subscriptions, then put Priya and Sam in it."
### Related
- [sync_group_members](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#sync-group-members) — the next call.
- [update_group](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#update-group)
- [delete_group](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#delete-group)
- [list_groups](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-groups)
- [Groups API](https://docs.subscriby.net/api/v1/reference/groups)
## create_role
Create a role on a team with a permission set. The code is immutable.
Creates a role on a team. A role is what one collaborator **is** — everyone in a team holds exactly one. For a named bundle several people share, use [`create_group`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-group) instead.
Subscriby seeds `admin`, `manager` and `viewer` on every new team; this adds your own.
> **Choose the code deliberately**
>
> `code` is unique within the team and **cannot be changed afterwards** —
> invitations and role assignments address it. Only `name`, `description` and
> the permission set stay mutable.
> **Warning**
>
> Growth-tier capability. On a lower tier this returns `TEAM_TIER_REQUIRED` and
> creates nothing.
- Requires ability: `role:create`
- Runs the same action as [`POST /v1/roles`](https://docs.subscriby.net/api/v1/reference/roles#create-a-role)
- Fires events: [`role.created`](https://docs.subscriby.net/webhooks/v1/events/role#role-created)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string | yes | UUID of the team the role belongs to. |
| `code` | string | yes | Stable identifier, alpha-dash. Unique per team and immutable once created. |
| `name` | string | yes | Human-readable role name. |
| `description` | string | no | Optional description of what the role is for. |
| `permissions` | array of any | no | Permission codes (entity:action) to grant. Omit for a role with none. |
### Example call
```json
{
"name": "create_role",
"arguments": {
"team_id": "ffaf93ac-a725-4800-a198-ea154bdce1f4",
"code": "",
"name": ""
}
}
```
### What it returns
```json
{
"data": {
"id": "d05e1a83-7c46-4f29-b613-8ae407c9d251",
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"code": "auditor",
"name": "Auditor",
"description": "Read-only access to subscriptions and payments",
"permissions": ["project:view-any", "project-subscription:view-any"]
}
}
```
Emits [`role.created`](https://docs.subscriby.net/webhooks/v1/events/role#role-created).
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks role:create.
- `TEAM_TIER_REQUIRED` — the caller's platform tier does not include Teams.
- `RESOURCE_NOT_FOUND` — no such team, or the caller is not a member.
- `VALIDATION_FAILED` — code already used on that team, malformed code, or a permission string that is not in the catalog.
### Example prompts
> "Create an auditor role on my team that can read subscriptions and payments but change nothing."
> "Add a support-agent role with access to the support inbox only."
### Related
- [update_role](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#update-role)
- [delete_role](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#delete-role)
- [list_roles](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-roles)
- [create_group](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-group) — for a bundle several people share.
- [Roles API](https://docs.subscriby.net/api/v1/reference/roles)
## delete_group
Delete a group. Members stay in the team but lose what it granted.
Deletes a group. Everyone in it loses whatever it granted, all at once, but stays in the team.
> **Note**
>
> **Not** tier-gated, unlike creating and updating. A creator whose tier lapsed
> has to be able to take access away. See [what the tier
> gates](https://docs.subscriby.net/api/v1/reference/teams#what-the-tier-gates).
- Requires ability: `group:delete`
- Runs the same action as [`DELETE /v1/groups/{group}`](https://docs.subscriby.net/api/v1/reference/groups#delete-a-group)
- Fires events: [`group.deleted`](https://docs.subscriby.net/webhooks/v1/events/group#group-deleted)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `group_id` | string | yes | UUID of the group to delete. Irreversible. |
### Example call
```json
{
"name": "delete_group",
"arguments": {
"group_id": "9594642c-3c66-4000-aba3-1d95b8b4ad74"
}
}
```
### What it returns
```json
{
"data": {
"id": "1f68d92a-04c5-4e83-97b1-3d6a05e2f847",
"deleted": true
}
}
```
Emits [`group.deleted`](https://docs.subscriby.net/webhooks/v1/events/group#group-deleted).
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks group:delete.
- `RESOURCE_NOT_FOUND` — no such group in any team the caller belongs to.
### Example prompts
> "Delete the billing group — the finance role covers it now."
### Related
- [sync_group_members](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#sync-group-members) — emptying a group leaves it in place for later; deleting removes it entirely.
- [create_group](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-group)
- [update_group](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#update-group)
- [Groups API](https://docs.subscriby.net/api/v1/reference/groups)
## delete_role
Delete a role. Collaborators holding it stay in the team.
Deletes a role. Anyone holding it stays in the team but loses whatever the role granted.
> **Note**
>
> **Not** tier-gated, unlike creating and updating. A creator whose tier lapsed
> has to be able to take access away. See [what the tier
> gates](https://docs.subscriby.net/api/v1/reference/teams#what-the-tier-gates).
- Requires ability: `role:delete`
- Runs the same action as [`DELETE /v1/roles/{role}`](https://docs.subscriby.net/api/v1/reference/roles#delete-a-role)
- Fires events: [`role.deleted`](https://docs.subscriby.net/webhooks/v1/events/role#role-deleted)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `role_id` | string | yes | UUID of the role to delete. Irreversible. |
### Example call
```json
{
"name": "delete_role",
"arguments": {
"role_id": "39341b3f-ea70-4e00-a58f-c9d7a7302bc8"
}
}
```
### What it returns
```json
{
"data": {
"id": "d05e1a83-7c46-4f29-b613-8ae407c9d251",
"deleted": true
}
}
```
Emits [`role.deleted`](https://docs.subscriby.net/webhooks/v1/events/role#role-deleted).
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks role:delete.
- `RESOURCE_NOT_FOUND` — no such role in any team the caller belongs to.
### Example prompts
> "Delete the auditor role — nobody's using it."
### Related
- [create_role](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-role)
- [update_role](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#update-role) — narrowing a role's permissions is usually better than deleting it, since it leaves everyone's assignment intact.
- [list_team_members](https://docs.subscriby.net/mcp/v1/tools/team-members#list-team-members) — check who holds it first.
- [Roles API](https://docs.subscriby.net/api/v1/reference/roles)
## list_groups
List groups across every team the caller owns or belongs to. Lighter-weight than roles — no permission payload.
List groups across every team the caller owns or belongs to. Groups cluster users without a permission payload — lighter-weight than roles. Ordered newest-first.
- Requires ability: `group:view-any`
- Runs the same action as [`GET /v1/groups`](https://docs.subscriby.net/api/v1/reference/groups#list-groups)
- Annotations: Read-only
### Arguments
This tool takes no arguments.
### Example call
```json
{
"name": "list_groups",
"arguments": {}
}
```
### What it returns
```json
{
"data": [
{
"id": "1f68d92a-04c5-4e83-97b1-3d6a05e2f847",
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"code": "ops",
"name": "Ops",
"created_at": "2026-05-18T10:05:00Z"
}
],
"meta": { "total": 1 }
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks group:view-any.
### Example prompts
> "List all groups across my teams."
> "Which groups exist on my Research Studio team?"
### Related
- [list_roles](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-roles)
- [list_team_members](https://docs.subscriby.net/mcp/v1/tools/team-members#list-team-members)
- [Groups API](https://docs.subscriby.net/api/v1/reference/groups)
## list_roles
List roles across every team the caller owns or belongs to. Each role carries a flat permission list.
List roles across every team the caller owns or belongs to. Each role carries a flat `permissions` array — the same ability codes a token would use. Ordered newest-first.
- Requires ability: `role:view-any`
- Runs the same action as [`GET /v1/roles`](https://docs.subscriby.net/api/v1/reference/roles#list-roles)
- Annotations: Read-only
### Arguments
This tool takes no arguments.
### Example call
```json
{
"name": "list_roles",
"arguments": {}
}
```
### What it returns
```json
{
"data": [
{
"id": "d05e1a83-7c46-4f29-b613-8ae407c9d251",
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"code": "manager",
"name": "Manager",
"description": "Manage plans, resources, and subscribers",
"permissions": ["project:view", "project:update", "project-user:update"],
"created_at": "2026-05-18T10:05:00Z"
}
],
"meta": { "total": 3 }
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks role:view-any.
### Example prompts
> "Which roles exist across my teams and what can each of them do?"
> "Show me the permission list of the Manager role."
### Related
- [list_groups](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-groups)
- [list_team_members](https://docs.subscriby.net/mcp/v1/tools/team-members#list-team-members)
- [Roles API](https://docs.subscriby.net/api/v1/reference/roles)
## sync_group_members
Replace a group's membership wholesale. A sync, not an add.
Replaces a group's membership with exactly the supplied user ids.
> **This is a sync, not an add**
>
> Anyone absent from `user_ids` is **removed**, and an empty array empties the
> group. Read the current membership with
> [`list_groups`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-groups) and send the full intended list — not
> just the people you want to add.
Every id must already belong to the group's team. A group grants permissions inside one tenant, so an outsider is refused rather than silently skipped — and the whole call fails rather than partly applying, so you never end up with a membership you did not ask for.
- Requires ability: `group:update`
- Runs the same action as [`PUT /v1/groups/{group}/members`](https://docs.subscriby.net/api/v1/reference/groups#replace-a-groups-members)
- Fires events: [`group.members_synced`](https://docs.subscriby.net/webhooks/v1/events/group#group-members-synced)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `group_id` | string | yes | UUID of the group whose membership is being replaced. |
| `user_ids` | array of any | yes | Complete list of user UUIDs the group should contain. An empty array clears it. |
### Example call
```json
{
"name": "sync_group_members",
"arguments": {
"group_id": "9594642c-3c66-4000-aba3-1d95b8b4ad74",
"user_ids": []
}
}
```
### What it returns
```json
{
"data": {
"id": "1f68d92a-04c5-4e83-97b1-3d6a05e2f847",
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"member_ids": [
"2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"6f9b2e37-c184-4a05-8d72-30e16bc9f458"
]
}
}
```
Emits [`group.members_synced`](https://docs.subscriby.net/webhooks/v1/events/group#group-members-synced), carrying `added_ids` and `removed_ids` as well as the final list — an access-control mirror should not have to diff two snapshots to work out what moved.
A sync that changes nothing emits nothing.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks group:update.
- `TEAM_TIER_REQUIRED` — the sync adds somebody and the caller's tier does not include Teams. Nothing changes.
- `RESOURCE_NOT_FOUND` — no such group in any team the caller belongs to.
- `VALIDATION_FAILED` — one or more ids are not in the group's team. The offending ids are named, and nothing is applied.
### Tier
Gated on Growth **only when the sync adds somebody**. A sync that only removes people succeeds on any tier.
This is the one write in the identity surface whose gate depends on its argument. Deciding from the tool alone would leave a lapsed creator unable to take one person out of a group without deleting the whole group — and deleting is not gated, so the gate would only be pushing them toward the more destructive option. See [what the tier gates](https://docs.subscriby.net/api/v1/reference/teams#what-the-tier-gates).
### Example prompts
> "Put Priya and Sam in the billing group, and nobody else."
> "Take Sam out of the billing group."
> "Empty the billing group but keep it around."
### Related
- [list_groups](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-groups) — read the current membership before replacing it.
- [list_team_members](https://docs.subscriby.net/mcp/v1/tools/team-members#list-team-members) — resolve the ids you may use.
- [update_group](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#update-group)
- [Groups API](https://docs.subscriby.net/api/v1/reference/groups)
## update_group
Rename a group or replace its permission set. Membership is a separate tool.
Renames a group or replaces the permissions it grants. `code` is immutable, so a rename never breaks a reference.
Who is _in_ the group is [`sync_group_members`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#sync-group-members), not this — a rename is cosmetic, while changing the permission set silently re-scopes everyone already in the group.
> **Permissions replace, they do not merge**
>
> Sending `permissions` sets the group's permissions to exactly that list.
> **Omit the key entirely** to leave the existing set untouched while changing
> only the name.
> **Warning**
>
> Growth-tier capability. On a lower tier this returns `TEAM_TIER_REQUIRED` and
> changes nothing.
- Requires ability: `group:update`
- Runs the same action as [`PATCH /v1/groups/{group}`](https://docs.subscriby.net/api/v1/reference/groups#update-a-group)
- Fires events: [`group.updated`](https://docs.subscriby.net/webhooks/v1/events/group#group-updated)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `group_id` | string | yes | UUID of the group to update. |
| `name` | string | no | New group name. Omit to keep the current one. |
| `permissions` | array of any | no | Complete replacement permission set. Omit to leave permissions untouched. |
### Example call
```json
{
"name": "update_group",
"arguments": {
"group_id": "9594642c-3c66-4000-aba3-1d95b8b4ad74"
}
}
```
### What it returns
```json
{
"data": {
"id": "1f68d92a-04c5-4e83-97b1-3d6a05e2f847",
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"code": "billing-team",
"name": "Billing & Finance",
"permissions": ["project-subscription:view-any", "payment:view-any"]
}
}
```
Emits [`group.updated`](https://docs.subscriby.net/webhooks/v1/events/group#group-updated).
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks group:update.
- `TEAM_TIER_REQUIRED` — the caller's platform tier does not include Teams.
- `RESOURCE_NOT_FOUND` — no such group in any team the caller belongs to.
- `VALIDATION_FAILED` — an unknown permission string.
### Example prompts
> "Let the billing group read payments too."
> "Rename the billing group to Billing & Finance."
### Related
- [sync_group_members](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#sync-group-members) — same ability, different concern.
- [create_group](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-group)
- [delete_group](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#delete-group)
- [Groups API](https://docs.subscriby.net/api/v1/reference/groups)
## update_role
Rename a role or replace its permission set. The code stays fixed.
Renames a role, changes its description, or replaces the permissions it grants. `code` is immutable, so a rename never breaks an assignment.
> **Permissions replace, they do not merge**
>
> Sending `permissions` sets the role's permissions to exactly that list. **Omit
> the key entirely** to leave the existing set untouched while changing only the
> name — sending an empty array strips every permission from everyone holding
> the role.
> **Warning**
>
> Growth-tier capability. On a lower tier this returns `TEAM_TIER_REQUIRED` and
> changes nothing.
- Requires ability: `role:update`
- Runs the same action as [`PATCH /v1/roles/{role}`](https://docs.subscriby.net/api/v1/reference/roles#update-a-role)
- Fires events: [`role.updated`](https://docs.subscriby.net/webhooks/v1/events/role#role-updated)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `role_id` | string | yes | UUID of the role to update. |
| `name` | string | no | New role name. Omit to keep the current one. |
| `description` | string | no | New description. Omit to keep the current one. |
| `permissions` | array of any | no | Complete replacement permission set. Omit to leave permissions untouched. |
### Example call
```json
{
"name": "update_role",
"arguments": {
"role_id": "39341b3f-ea70-4e00-a58f-c9d7a7302bc8"
}
}
```
### What it returns
```json
{
"data": {
"id": "d05e1a83-7c46-4f29-b613-8ae407c9d251",
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"code": "auditor",
"name": "Auditor & Finance",
"description": "Read-only access to subscriptions and payments",
"permissions": ["project:view-any", "project-subscription:view-any"]
}
}
```
Emits [`role.updated`](https://docs.subscriby.net/webhooks/v1/events/role#role-updated).
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks role:update.
- `TEAM_TIER_REQUIRED` — the caller's platform tier does not include Teams.
- `RESOURCE_NOT_FOUND` — no such role in any team the caller belongs to.
- `VALIDATION_FAILED` — a permission string that is not in the catalog.
### Example prompts
> "Give the auditor role access to the transaction breakdown as well."
> "Rename the auditor role to Finance without changing what it can do."
### Related
- [create_role](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-role)
- [delete_role](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#delete-role)
- [list_roles](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-roles) — read the current permission set before replacing it.
- [Roles API](https://docs.subscriby.net/api/v1/reference/roles)
---
# Subscription Tools
Source: https://docs.subscriby.net/mcp/v1/tools/subscription
A subscription is one member's purchase of one plan, and it is never created through a tool: checkout and access codes do that. These tools read subscriptions and their access grants, and drive the rest of a purchase's life, cancelling, pausing, reactivating and reissuing access, each through the same action the dashboard uses.
## Tools
- [`cancel_subscription`](#cancel-subscription) — Cancel Subscription (destructive)
- [`get_subscription`](#get-subscription) — Get Subscription (read)
- [`list_subscription_grants`](#list-subscription-grants) — List Subscription Grants (read)
- [`pause_subscription`](#pause-subscription) — Pause Subscription (destructive)
- [`reactivate_subscription`](#reactivate-subscription) — Reactivate Subscription (destructive)
- [`reissue_subscription_grants`](#reissue-subscription-grants) — Reissue Subscription Grants (destructive)
- [`remind_pass_holder`](#remind-pass-holder) — Remind Pass Holder (destructive)
- [`unpause_subscription`](#unpause-subscription) — Unpause Subscription (destructive)
## cancel_subscription
Queue a subscription cancellation. Provider-side cancellation and local state mutation run asynchronously on the webhooks queue.
Cancel a subscription. Delegates to an Action which queues a job — provider-side cancellation (Stripe / PayPal / Razorpay / Paystack), local state mutation, and the `subscription.cancelled` event all fire on the webhooks queue. Already-cancelled subscriptions return `SUBSCRIPTION_ALREADY_ACTIVE` instead of silently succeeding.
- Requires ability: `project-subscription:update`
- Runs the same action as [`POST /v1/subscriptions/{subscription}/cancel`](https://docs.subscriby.net/api/v1/reference/subscriptions#cancel-a-subscription)
- Fires events: [`subscription.cancelled`](https://docs.subscriby.net/webhooks/v1/events/subscription#subscription-cancelled)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subscription_id` | string | yes | UUID of the subscription to cancel. |
### Example call
```json
{
"name": "cancel_subscription",
"arguments": {
"subscription_id": "542087e4-74b4-4800-a35e-801a326dbe9f"
}
}
```
### What it returns
```json
{
"data": {
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"status": "cancellation_queued"
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — unknown subscription_id, or the subscription belongs to a team outside the token's scope.
- `SUBSCRIPTION_ALREADY_ACTIVE` — subscription already cancelled.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:update.
### Example prompts
> "Cancel subscription `5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4`."
> "Stop billing on Jane's Premium subscription."
### Related
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers)
- [list_recent_payments](https://docs.subscriby.net/mcp/v1/tools/payment#list-recent-payments)
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions)
## get_subscription
One subscription by UUID — plan, subscriber, payment method, status, price, trial and end dates, and whether it was cancelled. Read it before acting on it.
Read a subscription's current state back rather than assuming it. Every subscription write tool —
[`cancel_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#cancel-subscription),
[`pause_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#pause-subscription),
[`unpause_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#unpause-subscription),
[`reactivate_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#reactivate-subscription),
[`remind_pass_holder`](https://docs.subscriby.net/mcp/v1/tools/subscription#remind-pass-holder) — refuses a subscription in the wrong state,
and this is how an agent finds out which state it is in first. The fields are the ones the REST
subscription payload carries.
> **`canceled` and `ends_at` together tell the story**
>
> A subscription cancelled at period end has `canceled: true` and an `ends_at`
> still in the future — the member keeps access until then, and
> `reactivate_subscription` can still call the cancellation off. Once `ends_at`
> has passed there is nothing to reactivate.
- Requires ability: `project-subscription:view`
- Runs the same action as [`GET /v1/subscriptions/{subscription}`](https://docs.subscriby.net/api/v1/reference/subscriptions#get-a-subscription)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subscription_id` | string | yes | UUID of the subscription to fetch. |
### Example call
```json
{
"name": "get_subscription",
"arguments": {
"subscription_id": "542087e4-74b4-4800-a35e-801a326dbe9f"
}
}
```
### What it returns
```json
{
"data": {
"id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"plan_id": "9b7c2e15-4d63-4f80-a2b1-7e5d0c9f3a46",
"subscriber_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"method_id": "a15d70c8-3e46-4b92-b70f-58c9d2140e63",
"payment_status": "active",
"payment_price": "12.00",
"currency_id": "0f6c3b2a-8e19-4d57-b4a2-1c9e7d5f3a80",
"trial_ends_at": null,
"ends_at": "2026-10-06T10:05:00+00:00",
"canceled": false,
"compensation_seconds": 0,
"redeemed_at": null,
"created_at": "2026-09-06T10:05:00+00:00",
"updated_at": "2026-09-06T10:05:00+00:00",
"grants": []
}
}
```
`compensation_seconds` is the outage time [Outage Compensation](https://docs.subscriby.net/disaster-recovery/connector-outages#outage-compensation) banked on the purchase; `ends_at` already includes it, so on a recurring plan the gateway's renewal is `ends_at` minus `compensation_seconds`.
`grants` lists the access grants the subscription holds, one row per resource and dated window, in the shape [`list_subscription_grants`](https://docs.subscriby.net/mcp/v1/tools/subscription#list-subscription-grants) documents.
`payment_price` is a decimal string in the plan's currency; parse it as a decimal, not a float.
`redeemed_at` is set only for a subscription that came from an access code, and `method_id` is null
for those.
### How it fails
- `RESOURCE_NOT_FOUND` — unknown subscription_id, or a subscription outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:view.
### Example prompts
> "What state is subscription `5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4` in? Can it still be reactivated?"
> "When does this member's access end, and are they on a trial?"
> "Which payment method is that subscription billed through?"
### Related
- [list_subscription_grants](https://docs.subscriby.net/mcp/v1/tools/subscription#list-subscription-grants)
- [list_subscribers](https://docs.subscriby.net/mcp/v1/tools/member#list-subscribers) — the members a subscription belongs to.
- [get_subscriber](https://docs.subscriby.net/mcp/v1/tools/member#get-subscriber) — the member behind subscriber_id.
- [list_recent_payments](https://docs.subscriby.net/mcp/v1/tools/payment#list-recent-payments) — what was charged.
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions) — the REST equivalent.
## list_subscription_grants
List the access grants a subscription holds — one per resource and dated window, with the connector, how access was given, where it stands and why it failed if it did.
Read the access ledger for one purchase: every grant the subscription holds, one per resource (and per dated window for a pass), with the connector that gave it, the mode (an invite link, a membership, a role, a task for the creator), the state (`pending_identity`, `pending`, `held`, `granted`, `revoked`, `failed`) and, for a failure, the classified reason and the sentence a creator reads. Use it to answer "does this member actually have access to the channel?" instead of inferring it from the subscription's payment status.
- Requires ability: `project-subscription:view`
- Runs the same action as [`GET /v1/subscriptions/{subscription}/grants`](https://docs.subscriby.net/api/v1/reference/subscriptions#list-a-subscriptions-grants)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subscription_id` | string | yes | UUID of the subscription whose grants to list. |
### Example call
```json
{
"name": "list_subscription_grants",
"arguments": {
"subscription_id": "542087e4-74b4-4800-a35e-801a326dbe9f"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "7d1c3e9a-2b64-4f0e-9a58-3c6b1d8e2f47",
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"resource_id": "c9e21f37-8a4b-4d56-b1e0-2f7c9d3a6e15",
"window_id": null,
"identity_id": "0c2d8a7e-4b1f-4d3e-9a6b-2f5e8c1d7a90",
"connector": "telegram",
"mode": "bearer_link",
"state": "granted",
"reference": "https://t.me/+AbCdEfGhIjKlMnOp",
"granted_at": "2026-09-12T10:05:00+00:00",
"revoked_at": null,
"failure_kind": null,
"failure_detail": null,
"created_at": "2026-09-12T10:04:58+00:00"
}
]
}
```
`mode` is one of `bearer_link` (a personal invite link the member comes through), `membership` (the connector added the member), `role` (a role was assigned) or `creator_task` (the creator has to do something by hand). `reference` is the connector's handle on the grant — the invite link on Telegram — and is `null` before anything was issued. A `failed` grant carries `failure_kind` (`unreachable`, `not_permitted`, `target_missing`, `rate_limited`, `configuration`, `transient`, `other`) and `failure_detail`.
### How it fails
- `RESOURCE_NOT_FOUND` — subscription_id is not a valid UUID, or the subscription belongs to another team or a project outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:view.
### Example prompts
> "Does subscription `5b7e2d40-…` actually have access to every channel in its plan?"
> "Which of this member's grants failed, and why?"
### Related
- [get_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#get-subscription) — embeds the same rows as grants.
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions#list-a-subscriptions-grants)
## pause_subscription
Pause an active or trialing subscription — billing stops and the member keeps their place. The reversible alternative to cancelling.
Put a subscription on hold: billing stops and the member is not cancelled, so their place, their
history and their plan survive. This is the tool to reach for when a human says "stop billing", "put
them on hold" or "freeze the account", because it can be undone with
[`unpause_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#unpause-subscription) while
[`cancel_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#cancel-subscription) cannot.
Emits [`subscription.paused`](https://docs.subscriby.net/webhooks/v1/events/subscription#subscription-paused). Idempotent: pausing an
already-paused subscription is a no-op.
> **The member loses resource access while paused**
>
> Pausing suspends the member's access to the plan's resources for the duration,
> not just the billing. Confirm the target with a human before calling.
- Requires ability: `project-subscription:update`
- Runs the same action as [`POST /v1/subscriptions/{subscription}/pause`](https://docs.subscriby.net/api/v1/reference/subscriptions#pause-a-subscription)
- Fires events: [`subscription.paused`](https://docs.subscriby.net/webhooks/v1/events/subscription#subscription-paused)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subscription_id` | string | yes | UUID of the subscription to act on. |
### Example call
```json
{
"name": "pause_subscription",
"arguments": {
"subscription_id": "542087e4-74b4-4800-a35e-801a326dbe9f"
}
}
```
### What it returns
```json
{
"data": {
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"plan_id": "9b7c2e15-4d63-4f80-a2b1-7e5d0c9f3a46",
"payment_status": "paused",
"outcome": "paused"
}
}
```
### How it fails
- `VALIDATION_FAILED` — the subscription is not in a state that can be paused (for example, already cancelled or expired).
- `RESOURCE_NOT_FOUND` — unknown subscription_id, or a subscription outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:update.
### Example prompts
> "Put subscription `5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4` on hold — the member is travelling for two months."
> "Freeze this member's billing without cancelling them."
> "Pause Ada's Premium subscription until she's back."
### Related
- [unpause_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#unpause-subscription) — the reverse.
- [cancel_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#cancel-subscription) — when the member is leaving for good.
- [get_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#get-subscription) — read the state back first.
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions) — the REST equivalent.
## reactivate_subscription
Undo a scheduled cancellation and put the subscription back on its normal billing cycle. Refuses one that was never cancelled.
A cancellation made "at period end" leaves the subscription running until its current period ends.
Until then it can be called off, and that is what this tool does: the member goes back on their
normal billing cycle as if nothing had happened. It is the recovery path for a cancellation made in
error and the tool to reach for on a win-back — "they changed their mind". Safe to call: it restores
an intent the member already had.
Refuses a subscription that was never cancelled, and one whose cancellation has already taken
effect. Emits [`subscription.reactivated`](https://docs.subscriby.net/webhooks/v1/events/subscription#subscription-reactivated).
> **Stripe only for the true undo**
>
> Calling off a scheduled cancellation is something Stripe supports natively. On
> the other gateways a cancellation ends the agreement outright, so there is
> nothing to reactivate and the member has to buy again.
- Requires ability: `project-subscription:update`
- Runs the same action as [`POST /v1/subscriptions/{subscription}/reactivate`](https://docs.subscriby.net/api/v1/reference/subscriptions#reactivate-a-subscription)
- Fires events: [`subscription.reactivated`](https://docs.subscriby.net/webhooks/v1/events/subscription#subscription-reactivated)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subscription_id` | string | yes | UUID of the subscription to act on. |
### Example call
```json
{
"name": "reactivate_subscription",
"arguments": {
"subscription_id": "542087e4-74b4-4800-a35e-801a326dbe9f"
}
}
```
### What it returns
```json
{
"data": {
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"plan_id": "9b7c2e15-4d63-4f80-a2b1-7e5d0c9f3a46",
"payment_status": "active",
"outcome": "reactivated"
}
}
```
### How it fails
- `VALIDATION_FAILED` — the subscription was never cancelled, or its cancellation has already taken effect.
- `RESOURCE_NOT_FOUND` — unknown subscription_id, or a subscription outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:update.
### Example prompts
> "The member behind subscription `5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4` changed their mind — cancel the cancellation."
> "Reactivate Ada's Premium subscription before it ends on the 30th."
> "We cancelled the wrong subscription; undo it."
### Related
- [cancel_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#cancel-subscription) — what this undoes.
- [get_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#get-subscription) — check cancelled before calling.
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions) — the REST equivalent, POST .../reactivate.
## reissue_subscription_grants
Revoke the access grants a member holds on a subscription — every resource or one — and have fresh ones issued. The members page's "Refresh invite links", for agents.
Give a member a clean set of grants. The grants they hold on the subscription's resources are
revoked (on Telegram, their personal invite links die) and the dispatcher that grants at
purchase runs again, so fresh grants are issued and the bot sends the member the new links.
Use it when a member says a link is expired or was leaked, or after a channel's admins cleared
a ban; pass `resource_id` to touch one resource and leave the others as they are.
Every revoked grant raises [`member.resource_reissued`](https://docs.subscriby.net/webhooks/v1/events/member#member-resource-reissued),
and each fresh grant raises [`member.resource_added`](https://docs.subscriby.net/webhooks/v1/events/member#member-resource-added)
moments later. Read the result back with [`list_subscription_grants`](https://docs.subscriby.net/mcp/v1/tools/subscription#list-subscription-grants).
> **This revokes live access and messages a real person**
>
> The old links stop working the moment this runs, and the member is sent the
> new ones. Read the subscription back with
> [`get_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#get-subscription) first, and do not run it in
> a loop.
- Requires ability: `project-subscription:update`
- Runs the same action as [`POST /v1/subscriptions/{subscription}/grants/reissue`](https://docs.subscriby.net/api/v1/reference/subscriptions#reissue-a-subscriptions-access)
- Fires events: [`member.resource_reissued`](https://docs.subscriby.net/webhooks/v1/events/member#member-resource-reissued), [`member.resource_added`](https://docs.subscriby.net/webhooks/v1/events/member#member-resource-added)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subscription_id` | string | yes | UUID of the subscription whose grants to reissue. |
| `resource_id` | string | no | UUID of one resource of the plan to reissue alone; omit to reissue every resource the subscription grants. |
### Example call
```json
{
"name": "reissue_subscription_grants",
"arguments": {
"subscription_id": "542087e4-74b4-4800-a35e-801a326dbe9f"
}
}
```
### What it returns
```json
{
"data": {
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"reissued": 2,
"status": "reissue_queued"
}
}
```
`reissued` counts the grants that were revoked; the fresh ones are issued by a queued job and
appear on the ledger seconds later.
### How it fails
- `VALIDATION_FAILED` — the subscription is not active, so there is nothing to reissue.
- `RESOURCE_NOT_FOUND` — unknown subscription_id, a subscription outside the token's scope, or a resource_id the plan does not grant.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:update.
### Example prompts
> "This member's invite link expired — reissue their access."
> "Refresh only the announcements channel link for subscription `5b7e2d40-…`."
### Related
- [list_subscription_grants](https://docs.subscriby.net/mcp/v1/tools/subscription#list-subscription-grants) — read the ledger back.
- [get_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#get-subscription) — read the state back first.
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions#reissue-a-subscriptions-access) — the REST equivalent, POST .../grants/reissue.
## remind_pass_holder
Nudge one pass holder who bought a window but has not come through their grant yet. Answers whether anything was sent.
The members page's per-holder nudge: re-sends one subscriber's invite links for the window they
bought, when they have not yet come through their grant. Use it when a specific member says
"I never got the link"; to reach everyone still missing from a window at once, use
[`remind_pass_window_queue`](https://docs.subscriby.net/mcp/v1/tools/plan#remind-pass-window-queue).
`reminded: false` is a normal answer, not an error. It means there was nothing to send: the
subscription holds no window, the window has ended or been cancelled, the holder has already
queued, or they cannot be reached on their connector. It is the dashboard's "Nothing sent" notice.
> **This messages a real person**
>
> Read the subscription back with
> [`get_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#get-subscription) first, and do not repeat the
> nudge within the same window.
- Requires ability: `project-subscription:update`
- Runs the same action as [`POST /v1/subscriptions/{subscription}/remind`](https://docs.subscriby.net/api/v1/reference/subscriptions#remind-a-pass-holder)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subscription_id` | string | yes | UUID of the pass holder's subscription. |
### Example call
```json
{
"name": "remind_pass_holder",
"arguments": {
"subscription_id": "542087e4-74b4-4800-a35e-801a326dbe9f"
}
}
```
### What it returns
```json
{
"data": {
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"reminded": true
}
}
```
### How it fails
- `VALIDATION_FAILED` — the reminder was refused for the subscription's current state.
- `RESOURCE_NOT_FOUND` — unknown subscription_id, or a subscription outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:update.
### Example prompts
> "Resend the invite link to the holder of subscription `5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4`."
> "This member says they never got their link for Saturday — nudge them."
> "Did the reminder for that pass holder actually go out?"
### Related
- [remind_pass_window_queue](https://docs.subscriby.net/mcp/v1/tools/plan#remind-pass-window-queue) — everyone still missing from a window.
- [get_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#get-subscription) — read the state back first.
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions) — the REST equivalent, POST .../remind.
## unpause_subscription
Resume a paused subscription — billing restarts and resource access is restored. Refuses anything that is not paused.
The reverse of [`pause_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#pause-subscription): billing restarts on the
plan's schedule and the member's access to the plan's resources is restored, with fresh invite links
where they are needed. Safe to call — it restores service rather than removing it.
Refuses a subscription that is not currently paused, so it cannot be used to revive a cancelled or
expired one; that is [`reactivate_subscription`](https://docs.subscriby.net/mcp/v1/tools/subscription#reactivate-subscription) for a scheduled
cancellation, or a new purchase. Emits
[`subscription.unpaused`](https://docs.subscriby.net/webhooks/v1/events/subscription#subscription-unpaused).
- Requires ability: `project-subscription:update`
- Runs the same action as [`POST /v1/subscriptions/{subscription}/unpause`](https://docs.subscriby.net/api/v1/reference/subscriptions#unpause-a-subscription)
- Fires events: [`subscription.unpaused`](https://docs.subscriby.net/webhooks/v1/events/subscription#subscription-unpaused)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subscription_id` | string | yes | UUID of the subscription to act on. |
### Example call
```json
{
"name": "unpause_subscription",
"arguments": {
"subscription_id": "542087e4-74b4-4800-a35e-801a326dbe9f"
}
}
```
### What it returns
```json
{
"data": {
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"plan_id": "9b7c2e15-4d63-4f80-a2b1-7e5d0c9f3a46",
"payment_status": "active",
"outcome": "active"
}
}
```
### How it fails
- `VALIDATION_FAILED` — the subscription is not paused.
- `RESOURCE_NOT_FOUND` — unknown subscription_id, or a subscription outside the token's scope.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project-subscription:update.
### Example prompts
> "Resume subscription `5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4` — the member is back."
> "Unfreeze Ada's Premium subscription."
> "Take this member off hold and restart their billing."
### Related
- [pause_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#pause-subscription) — the reverse.
- [reactivate_subscription](https://docs.subscriby.net/mcp/v1/tools/subscription#reactivate-subscription) — for a cancellation that was scheduled by mistake.
- [Subscriptions API](https://docs.subscriby.net/api/v1/reference/subscriptions) — the REST equivalent.
---
# Support Tools
Source: https://docs.subscriby.net/mcp/v1/tools/support
The support inbox holds one durable conversation per member and channel, the saved replies a team answers with, and the project's support settings. These tools triage and answer conversations, keep the reply picker in sync and switch the inbox's behaviour, the same actions the dashboard inbox runs.
## Tools
- [`assign_support_conversation`](#assign-support-conversation) — Assign Support Conversation (destructive)
- [`block_support_contact`](#block-support-contact) — Block Support Contact (destructive)
- [`create_canned_reply`](#create-canned-reply) — Create Canned Reply (destructive)
- [`delete_canned_reply`](#delete-canned-reply) — Delete Canned Reply (destructive)
- [`get_canned_reply`](#get-canned-reply) — Get Canned Reply (read)
- [`get_support_conversation`](#get-support-conversation) — Get Support Conversation (read)
- [`get_support_settings`](#get-support-settings) — Get Support Settings (read)
- [`list_canned_replies`](#list-canned-replies) — List Canned Replies (read)
- [`list_support_conversations`](#list-support-conversations) — List Support Conversations (read)
- [`reopen_support_conversation`](#reopen-support-conversation) — Reopen Support Conversation (destructive)
- [`reply_support_conversation`](#reply-support-conversation) — Reply to Support Conversation (destructive)
- [`resolve_support_conversation`](#resolve-support-conversation) — Resolve Support Conversation (destructive)
- [`unblock_support_contact`](#unblock-support-contact) — Unblock Support Contact (destructive)
- [`update_canned_reply`](#update-canned-reply) — Update Canned Reply (destructive)
- [`update_support_settings`](#update-support-settings) — Update Support Settings (destructive)
## assign_support_conversation
Hand a support thread to one team member, or take it back off everyone. Advisory — it never stops anyone else replying.
Set who owns the answer on a thread. Assignment feeds the **Assigned to Me** tab in the creator's inbox and says who is responsible; it does not lock the thread, and anyone on the team with `support-conversation:update` can still reply.
The assignee must be someone who can actually open the thread: the project owner, or a member of the team the project belongs to. Any other account is refused rather than stored, so a token can never park a thread on a user who would never see it. Omit `assignee_user_id`, or pass `null`, to clear the assignment.
Emits [`support.conversation.assigned`](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-assigned) on every call, with `assigned_to_user_id: null` when the assignment was cleared.
> **Note**
>
> Idempotent in effect, not in events. Assigning the same person twice leaves
> the row exactly as it was, but the event fires again each time — deduplicate
> on the event `id` if you post notifications downstream.
- Requires ability: `support-conversation:update`
- Runs the same action as [`POST /v1/support/conversations/{conversation}/assign`](https://docs.subscriby.net/api/v1/reference/support-inbox#assign-a-support-conversation)
- Fires events: [`support.conversation.assigned`](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-assigned)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `conversation_id` | string | yes | UUID of the support conversation to assign. |
| `assignee_user_id` | string | no | UUID of the project owner or a team member to hand the thread to. Omit or pass null to unassign. |
### Example call
```json
{
"name": "assign_support_conversation",
"arguments": {
"conversation_id": "a0e67f56-a107-4000-aed1-06efd41c6f7a"
}
}
```
### What it returns
```json
{
"data": {
"id": "9e042b6f-5d81-4c37-a920-7b3e18cf6d45",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"channel": "telegram",
"status": "open",
"assigned_to_user_id": "4b7c9d21-3e58-4f16-8a02-6d9e5c1b7f34",
"unread_count": 2,
"blocked": false,
"first_response_at": null,
"resolved_at": null
}
}
```
The same row [`get_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#get-support-conversation) returns under its `conversation` key, so a thread reads the same before and after the change. `assigned_to_user_id` is a **team member's** user id — a different namespace from the member's `project_user_id`.
### How it fails
- `VALIDATION_FAILED` — assignee_user_id is not a UUID or names no account (No user found with that id.), or the account is neither the project owner nor on its team (That person is not on this project's team.). Also returned when the token's owner is on the team but their role lacks the support permission on the project.
- `RESOURCE_NOT_FOUND` — unknown conversation_id, another creator's thread, or a project outside the token's scope:project: allow-list. The three are indistinguishable on purpose.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks support-conversation:update.
### Example prompts
> "Assign support conversation `9e042b6f-5d81-4c37-a920-7b3e18cf6d45` to Priya."
> "Take the billing thread off Marco — he's on leave — and leave it unassigned."
> "Hand every open thread about invite links to whoever owns the project." (resolve the owner with [`list_team_members`](/mcp/v1/tools/team-members#list-team-members), then assign each)"
### Related
- [list_support_conversations](https://docs.subscriby.net/mcp/v1/tools/support#list-support-conversations) — filter by assigned_to to see who holds what.
- [list_team_members](https://docs.subscriby.net/mcp/v1/tools/team-members#list-team-members) — resolve a teammate's user UUID.
- [resolve_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#resolve-support-conversation) — the usual next step once the assignee has answered.
- [support.conversation.assigned](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-assigned) — the event this raises.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — POST /v1/support/conversations/{id}/assign.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox) — the feature walkthrough.
## block_support_contact
Drop every further support message from the member behind a thread. They get no indication, and their subscription is untouched.
Stop a member's support traffic. Everything they send from now on is dropped at ingestion, before it reaches the inbox; nothing is queued and nothing is stored. Their history stays on the thread.
The block lives on the **thread**, not on the member, so it silences support only. The member keeps their subscription, their group access and every other bot interaction. To take access away, call [`kick_member`](https://docs.subscriby.net/mcp/v1/tools/member#kick-member) or [`ban_member`](https://docs.subscriby.net/mcp/v1/tools/member#ban-member) instead — blocking is for someone who is abusing the inbox, not someone who should no longer be a member.
Emits [`support.conversation.blocked`](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-blocked) when the block is placed. Idempotent: blocking an already-blocked member changes nothing and emits nothing.
> **Note**
>
> Blocking does not resolve the thread. It stays in whatever status it had, so
> call [`resolve_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#resolve-support-conversation)
> as well if it should leave the open queue. A blocked member's messages can
> never reopen it; only
> [`reopen_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#reopen-support-conversation) can.
- Requires ability: `support-conversation:update`
- Runs the same action as [`POST /v1/support/conversations/{conversation}/block`](https://docs.subscriby.net/api/v1/reference/support-inbox#block-a-support-contact)
- Fires events: [`support.conversation.blocked`](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-blocked)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `conversation_id` | string | yes | UUID of the support conversation whose member to block. |
### Example call
```json
{
"name": "block_support_contact",
"arguments": {
"conversation_id": "a0e67f56-a107-4000-aed1-06efd41c6f7a"
}
}
```
### What it returns
```json
{
"data": {
"id": "9e042b6f-5d81-4c37-a920-7b3e18cf6d45",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"channel": "telegram",
"status": "open",
"assigned_to_user_id": null,
"unread_count": 3,
"blocked": true,
"first_response_at": null,
"resolved_at": null
}
}
```
`blocked: true` is the only field this call changes. The unread count stays as it was — the messages already received are still there to read.
### How it fails
- `VALIDATION_FAILED` — the token's owner is on the project's team but their role lacks the support permission on it.
- `RESOURCE_NOT_FOUND` — unknown conversation_id, another creator's thread, or a project outside the token's scope:project: allow-list.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks support-conversation:update.
### Example prompts
> "Block the member behind support conversation `9e042b6f-5d81-4c37-a920-7b3e18cf6d45`; they've sent forty messages of abuse overnight."
> "Silence that contact in the support inbox but leave their subscription alone."
> "Block them and resolve the thread." (block first, then resolve)"
### Related
- [unblock_support_contact](https://docs.subscriby.net/mcp/v1/tools/support#unblock-support-contact) — reverse it.
- [kick_member](https://docs.subscriby.net/mcp/v1/tools/member#kick-member) — remove access rather than silence support.
- [ban_member](https://docs.subscriby.net/mcp/v1/tools/member#ban-member) — remove access rather than silence support.
- [get_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#get-support-conversation) — read the thread before deciding.
- [support.conversation.blocked](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-blocked) — the event this raises.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — POST /v1/support/conversations/{id}/block.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox) — the feature walkthrough.
## create_canned_reply
Save a reusable support reply on a project, with an optional slash-command shortcut and picker position.
Add a snippet to a project's saved replies. It appears in the thread composer's picker straight away, and a creator can pull it in by typing its `shortcut`. The text is validated against the same rules the dashboard form applies, so a snippet accepted here renders in the composer exactly as one saved from the inbox.
Synchronous — the new row is returned, and [`support.canned_reply.created`](https://docs.subscriby.net/webhooks/v1/events/support#support-canned-reply-created) emits once.
> **Not idempotent**
>
> Two calls with the same `title` and `body` save two snippets. Only `shortcut`
> is unique within a project, so a snippet without one can be duplicated freely.
> Check [`list_canned_replies`](https://docs.subscriby.net/mcp/v1/tools/support#list-canned-replies) before creating.
- Requires ability: `support-canned-reply:create`
- Runs the same action as [`POST /v1/projects/{project}/support/canned-replies`](https://docs.subscriby.net/api/v1/reference/support-inbox#create-a-saved-reply)
- Fires events: [`support.canned_reply.created`](https://docs.subscriby.net/webhooks/v1/events/support#support-canned-reply-created)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the snippet belongs to. |
| `title` | string | yes | Short label shown in the picker, 2 to 80 characters. |
| `body` | string | yes | The reply text, 2 to 4000 characters. |
| `shortcut` | string | no | Slash-command a creator types to insert the snippet: letters, numbers, dashes and underscores, up to 30 characters, unique within the project. |
| `sort_order` | integer | no | Position in the picker, 0 to 999. Defaults to 0. |
### Example call
```json
{
"name": "create_canned_reply",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"title": "",
"body": ""
}
}
```
### What it returns
```json
{
"data": {
"id": "5d2f8a71-3c9e-4b06-a8f4-1e7c9d2b6a35",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"title": "Refund policy",
"body": "Refunds are available within 14 days of your first payment. Reply here with the email you paid with and we'll sort it.",
"shortcut": "refund",
"sort_order": 0,
"created_at": "2026-09-06T10:05:00+00:00",
"updated_at": "2026-09-06T10:05:00+00:00"
}
}
```
`title` comes back trimmed. `body` is stored as sent, whitespace included, because it reaches members verbatim. An omitted or empty `shortcut` is stored as `null`.
### How it fails
- `VALIDATION_FAILED` — title outside 2–80 characters, body outside 2–4000, shortcut longer than 30 characters, containing anything but letters, numbers, dashes and underscores, or already used by another snippet in this project, or sort_order outside 0–999. The envelope names the offending field. Also returned when the token's owner is on the project's team but their role lacks the support permission on it.
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see: unknown, another team's, or outside the token's scope:project: allow-list.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks support-canned-reply:create.
### Example prompts
> "Save a canned reply on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` called 'Refund policy' with shortcut `refund` that says refunds are available within 14 days."
> "Turn the answer I just sent Jane into a saved reply called 'Invite link expired'."
> "Add a saved reply for opening hours and put it at the top of the picker." (`sort_order: 0`, then bump the others)"
### Related
- [list_canned_replies](https://docs.subscriby.net/mcp/v1/tools/support#list-canned-replies) — check for an existing snippet first.
- [update_canned_reply](https://docs.subscriby.net/mcp/v1/tools/support#update-canned-reply) — change one later.
- [support.canned_reply.created](https://docs.subscriby.net/webhooks/v1/events/support#support-canned-reply-created) — the event this raises.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — POST /v1/projects/{project}/support/canned-replies.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox#saved-replies) — the feature walkthrough.
## delete_canned_reply
Remove a saved support reply from a project's picker. Replies already sent with it are untouched.
Take a snippet out of the picker for good. Messages that were sent using it are ordinary messages on their threads and are not affected — a snippet is a template, not a link.
> **Confirm before calling**
>
> There is no undo and no soft delete. Confirm the target `canned_reply_id` with
> a human — read it back with [`get_canned_reply`](https://docs.subscriby.net/mcp/v1/tools/support#get-canned-reply)
> so they see the text they are losing. The tool is annotated destructive so a
> client can prompt for confirmation.
Emits [`support.canned_reply.deleted`](https://docs.subscriby.net/webhooks/v1/events/support#support-canned-reply-deleted) with a snapshot of the snippet taken before it went. Idempotent in the only sense a delete can be: a second call for the same id is `RESOURCE_NOT_FOUND`, indistinguishable from an id that never existed.
- Requires ability: `support-canned-reply:delete`
- Runs the same action as [`DELETE /v1/projects/{project}/support/canned-replies/{reply}`](https://docs.subscriby.net/api/v1/reference/support-inbox#delete-a-saved-reply)
- Fires events: [`support.canned_reply.deleted`](https://docs.subscriby.net/webhooks/v1/events/support#support-canned-reply-deleted)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the snippet belongs to. |
| `canned_reply_id` | string | yes | UUID of the saved reply to remove. |
### Example call
```json
{
"name": "delete_canned_reply",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"canned_reply_id": "33e914e5-e23a-4000-a522-192a401f8d33"
}
}
```
### What it returns
```json
{
"data": {
"canned_reply_id": "5d2f8a71-3c9e-4b06-a8f4-1e7c9d2b6a35",
"deleted": true
}
}
```
The id is echoed as sent. Nothing else about the snippet is returned, so read it first if the text matters.
### How it fails
- `VALIDATION_FAILED` — the token's owner is on the project's team but their role lacks the support permission on it.
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see, or canned_reply_id is unknown, already deleted, or belongs to another project.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks support-canned-reply:delete.
### Example prompts
> "Delete saved reply `5d2f8a71-3c9e-4b06-a8f4-1e7c9d2b6a35` from project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` — the refund terms changed."
> "Remove the 'Black Friday' snippet now the sale is over."
> "Clear out every saved reply that mentions the old bot name." (list, confirm the set with the creator, then delete each)"
### Related
- [get_canned_reply](https://docs.subscriby.net/mcp/v1/tools/support#get-canned-reply) — read it before removing it.
- [update_canned_reply](https://docs.subscriby.net/mcp/v1/tools/support#update-canned-reply) — the narrower fix when only the wording is wrong.
- [support.canned_reply.deleted](https://docs.subscriby.net/webhooks/v1/events/support#support-canned-reply-deleted) — the event this raises.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — DELETE /v1/projects/{project}/support/canned-replies/{reply}.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox#saved-replies) — the feature walkthrough.
## get_canned_reply
Fetch one saved support reply by UUID, looked up within its project.
Read a single snippet — the full body, its shortcut and its position — when you already know which one. Same row [`list_canned_replies`](https://docs.subscriby.net/mcp/v1/tools/support#list-canned-replies) returns, so reading one and reading all agree field for field.
> **Note**
>
> Saved replies exist only beneath a project, so the tool takes both ids and
> resolves the snippet **within** that project. A snippet id that belongs to a
> different project is `RESOURCE_NOT_FOUND` here even if the token could see
> that other project.
- Requires ability: `support-canned-reply:view-any`
- Runs the same action as [`GET /v1/projects/{project}/support/canned-replies/{reply}`](https://docs.subscriby.net/api/v1/reference/support-inbox#get-a-saved-reply)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the snippet belongs to. |
| `canned_reply_id` | string | yes | UUID of the saved reply to fetch. |
### Example call
```json
{
"name": "get_canned_reply",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"canned_reply_id": "33e914e5-e23a-4000-a522-192a401f8d33"
}
}
```
### What it returns
```json
{
"data": {
"id": "5d2f8a71-3c9e-4b06-a8f4-1e7c9d2b6a35",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"title": "Opening hours",
"body": "We answer between 9 and 5, Monday to Friday. Anything sent outside that gets a reply the next working morning.",
"shortcut": "hours",
"sort_order": 1,
"created_at": "2026-06-02T09:14:02+00:00",
"updated_at": "2026-06-02T09:14:02+00:00"
}
}
```
`updated_at` equal to `created_at` means the snippet has never been edited.
### How it fails
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see, or canned_reply_id is unknown, deleted, or belongs to another project.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks support-canned-reply:view-any.
### Example prompts
> "Read saved reply `5d2f8a71-3c9e-4b06-a8f4-1e7c9d2b6a35` in project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`."
> "What does the `hours` snippet actually say?" (list first to resolve the id, then get)"
> "Show me the refund snippet before I edit it."
### Related
- [list_canned_replies](https://docs.subscriby.net/mcp/v1/tools/support#list-canned-replies) — every snippet of the project.
- [update_canned_reply](https://docs.subscriby.net/mcp/v1/tools/support#update-canned-reply) — change it.
- [delete_canned_reply](https://docs.subscriby.net/mcp/v1/tools/support#delete-canned-reply) — remove it.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — GET /v1/projects/{project}/support/canned-replies/{reply}.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox#saved-replies) — the feature walkthrough.
## get_support_conversation
Read one support conversation with its message history and the member context a reply needs.
Open a single conversation. Returns the thread's state, its message history, and a flattened snapshot of who the member is — subscription status, current plan, member-since date, how many messages they have sent — so a reply can be written without a second round of lookups.
> **Warning**
>
> Names, emails and message bodies are returned **verbatim**. This is the most
> PII-dense tool in the set. Do not forward its output anywhere outside the
> conversation with the creator.
- Requires ability: `support-conversation:view`
- Runs the same action as [`GET /v1/support/conversations/{conversation}`](https://docs.subscriby.net/api/v1/reference/support-inbox#get-a-support-conversation)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `conversation_id` | string | yes | UUID of the support conversation to read. |
| `message_limit` | integer | no | How many of the most recent messages to return (1..200, default 50). Returned oldest-first. |
| `include_internal` | boolean | no | Include creator-only internal notes. Requires the support-conversation:update ability. |
### Example call
```json
{
"name": "get_support_conversation",
"arguments": {
"conversation_id": "a0e67f56-a107-4000-aed1-06efd41c6f7a"
}
}
```
### What it returns
```json
{
"data": {
"conversation": {
"id": "9e042b6f-5d81-4c37-a920-7b3e18cf6d45",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"channel": "telegram",
"status": "open",
"assigned_to_user_id": null,
"unread_count": 2,
"blocked": false,
"first_response_at": null,
"resolved_at": null
},
"member": {
"project_user_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"name": "Jane Doe",
"email": "jane@example.com",
"status": "active",
"member_since": "2026-03-02T09:00:00Z",
"plan_name": "Premium Monthly",
"subscription_status": "active",
"subscription_ends_at": "2026-09-02T09:00:00Z",
"subscription_count": 1,
"inbound_message_count": 4,
"first_contact_at": "2026-08-20T10:05:00Z"
},
"messages": [
{
"id": "6c18f7a3-2e95-4d60-b47a-c093e5182b7f",
"direction": "inbound",
"author_kind": "contact",
"type": "text",
"delivery_status": "sent",
"internal": false,
"body": "My invite link expired before I could join",
"attachment_kinds": [],
"created_at": "2026-08-20T10:05:00Z"
}
]
},
"meta": {
"message_count": 1,
"message_limit": 50,
"internal_included": false
}
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks support-conversation:view.
- No support conversation found with that id. — unknown id, or the conversation belongs to a project outside the token's scope.
- Reading internal notes requires the support-conversation:update ability. — include_internal was true on a read-only token.
### Caveats
- **Internal notes are gated behind a write ability on purpose.** Reading a private remark about a member takes the same permission as writing one, so a read-only token cannot see them.
- Attachments are described by **kind only** (`attachment_kinds: ["photo"]`). Platform `file_id` values are effectively download credentials and are never returned. Fetch files through the dashboard or the REST API.
- `messages` is the newest `message_limit` messages, returned **oldest-first** so it reads as a transcript. A long thread is truncated at the start, not the end.
- `member.status` is the project-scoped member status — it can be `lead` for someone who has never paid. Anyone who can reach the bot can open a conversation.
- `first_response_at: null` means nobody has replied yet. It is stamped once, on the first human reply ever, and is not reset by a reopen.
### Example prompts
> "Open support conversation `9e042b6f-5d81-4c37-a920-7b3e18cf6d45` and summarise what the member needs."
> "Read the last 10 messages in that thread — is this a billing problem or an access problem?"
### Related
- [list_support_conversations](https://docs.subscriby.net/mcp/v1/tools/support#list-support-conversations)
- [reply_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#reply-support-conversation)
- [resolve_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#resolve-support-conversation)
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox)
## get_support_settings
Read a project's support inbox settings — enabled, relay mode, agent name, auto-reply and email notifications.
Read how a project's member support is configured: whether members can open threads at all, where new threads are relayed besides the dashboard inbox, the name members see on replies, the acknowledgement sent when a thread opens, and whether the creator is emailed about new threads.
The settings are columns on the project, so reading them takes only the project view ability — no support ability is needed to see how support is set up.
> **Note**
>
> The connector relay chat id is never part of the row. It is an identifier the
> bot handshake writes when the creator links their account, not a setting a
> client should read or set.
- Requires ability: `project:view`
- Runs the same action as [`GET /v1/projects/{project}/support/settings`](https://docs.subscriby.net/api/v1/reference/support-inbox#get-a-projects-support-settings)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose support settings to read. |
### Example call
```json
{
"name": "get_support_settings",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"enabled": true,
"relay_mode": "owner_dm",
"agent_name": "Team Subscriby",
"auto_reply": "Thanks — we usually reply within a few hours.",
"notify_email": false,
"updated_at": "2026-08-11T15:40:19+00:00"
}
}
```
`relay_mode` is `owner_dm` (each new thread is forwarded to the creator on their connector) or `none` (dashboard inbox only). `agent_name: null` means replies carry the project name; `auto_reply: null` means no acknowledgement is sent. `updated_at` is the **project's** timestamp — it moves whenever anything on the project changes, not only these settings.
### How it fails
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see: unknown, another team's, or outside the token's scope:project: allow-list.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project:view.
### Example prompts
> "Is member support switched on for project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13`?"
> "What auto-reply do members get when they message the Research Premium bot?"
> "Am I getting Telegram DMs or emails for new support threads on this project?"
### Related
- [update_support_settings](https://docs.subscriby.net/mcp/v1/tools/support#update-support-settings) — change any of these.
- [get_project](https://docs.subscriby.net/mcp/v1/tools/project#get-project) — the rest of the project.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — GET /v1/projects/{project}/support/settings.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox#settings) — what each setting does for the creator.
## list_canned_replies
List a project's saved support replies in the order the creator arranged them, shortcut and body included.
Read the snippets a creator has saved for the questions they answer constantly. Each row carries the full `body`, the `shortcut` a creator types in the thread composer to pull the snippet in, and its `sort_order` — the position in the picker.
Call it before [`reply_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#reply-support-conversation) so an answer goes out in the creator's own wording rather than a paraphrase. Rows come back sorted by `sort_order`, then title, exactly as the picker shows them.
> **Note**
>
> Saved replies are authorised with the support-conversation permissions, not
> permissions of their own: a team member who can read the inbox can read the
> snippets used to answer it.
- Requires ability: `support-canned-reply:view-any`
- Runs the same action as [`GET /v1/projects/{project}/support/canned-replies`](https://docs.subscriby.net/api/v1/reference/support-inbox#list-a-projects-saved-replies)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose saved replies to list. |
### Example call
```json
{
"name": "list_canned_replies",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "5d2f8a71-3c9e-4b06-a8f4-1e7c9d2b6a35",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"title": "Refund policy",
"body": "Refunds are available within 14 days of your first payment. Reply here with the email you paid with and we'll sort it.",
"shortcut": "refund",
"sort_order": 0,
"created_at": "2026-06-02T09:14:02+00:00",
"updated_at": "2026-08-11T15:40:19+00:00"
}
],
"meta": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"total": 4
}
}
```
`shortcut` is `null` for a snippet saved without one. The list is not paginated — `total` is the whole set, and a project with no saved replies returns an empty `data` array, not an error.
### How it fails
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see: unknown, another team's, or outside the token's scope:project: allow-list.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks support-canned-reply:view-any.
### Example prompts
> "What saved replies does project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` have?"
> "Is there a canned answer for invite-link problems? Use it to reply to Jane."
> "Show me the saved replies in picker order so I can tidy them up."
### Related
- [get_canned_reply](https://docs.subscriby.net/mcp/v1/tools/support#get-canned-reply) — one snippet by id, same row shape.
- [create_canned_reply](https://docs.subscriby.net/mcp/v1/tools/support#create-canned-reply) — add one.
- [reply_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#reply-support-conversation) — send the body to a member.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — GET /v1/projects/{project}/support/canned-replies.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox#saved-replies) — the feature walkthrough.
## list_support_conversations
List member support conversations, newest activity first, with optional status / assignee / unread filters.
Triage the support inbox. Returns one row per conversation, newest activity first, each carrying the member's name, unread count and a preview of the last message — enough to decide what needs answering without a follow-up call per row.
Use [`get_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#get-support-conversation) once you know which thread to open.
> **Warning**
>
> Member names and message previews are returned **verbatim**. Scrub them before
> forwarding anywhere outside the conversation with the creator.
- Requires ability: `support-conversation:view-any`
- Runs the same action as [`GET /v1/support/conversations`](https://docs.subscriby.net/api/v1/reference/support-inbox#list-support-conversations)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | no | Optional project UUID to narrow the result. |
| `status` | `open`, `pending`, `snoozed`, `resolved` | no | Conversation status filter. One of: open, pending, snoozed, resolved |
| `assigned_to` | string | no | Only conversations assigned to this team-member user UUID. |
| `unread_only` | boolean | no | Only conversations with messages the creator has not opened. |
| `limit` | integer | no | Maximum conversations to return per page (1..100). |
| `page` | integer | no | 1-indexed page number. |
### Example call
```json
{
"name": "list_support_conversations",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"status": "open"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "9e042b6f-5d81-4c37-a920-7b3e18cf6d45",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"subscriber_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"member_name": "Jane Doe",
"channel": "telegram",
"status": "open",
"assigned_to_user_id": null,
"unread_count": 2,
"blocked": false,
"last_message_at": "2026-08-20T10:05:00Z",
"last_message_preview": "My invite link expired before I could join"
}
],
"meta": {
"page": 1,
"limit": 25,
"total": 7,
"has_more": false
}
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks support-conversation:view-any.
- An empty data array is not an error. It means nothing matched, most often because status defaulted the caller's expectation — resolved threads are excluded only if you filter for open.
### Caveats
- `last_message_preview` is the raw last message body, which is `null` for a media message with no caption. A `null` preview does not mean an empty conversation.
- Results span every project the token can reach unless you pass `project_id`.
- `blocked: true` means inbound messages from that member are being dropped. The thread is history only.
### Example prompts
> "What support conversations are waiting on me?"
> "Show me unread support threads for my Research Premium project."
> "List support conversations assigned to nobody."
### Related
- [get_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#get-support-conversation)
- [reply_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#reply-support-conversation)
- [resolve_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#resolve-support-conversation)
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox)
## reopen_support_conversation
Put a resolved support thread back in the creator's open queue so it shows in the triage list again.
Reopen a thread by hand. It goes back to `open`, `resolved_at` is cleared, and it competes for the creator's attention again. The reverse of [`resolve_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#resolve-support-conversation).
A member writing again reopens their own thread without this, so call it only when the **creator** wants to follow up first — a refund that turned out to be wrong, an answer that needs a correction. It is also the only way back for a thread whose member is [blocked](https://docs.subscriby.net/mcp/v1/tools/support#block-support-contact): their messages are dropped, so they can never reopen it themselves.
> **Note**
>
> The row is left alone when the thread is already open, but
> [`support.conversation.reopened`](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-reopened)
> fires on every call — the same event the member's own message would raise, so
> a consumer cannot tell a manual reopen from an inbound one. Deduplicate on the
> event `id` if you post notifications downstream.
- Requires ability: `support-conversation:update`
- Runs the same action as [`POST /v1/support/conversations/{conversation}/reopen`](https://docs.subscriby.net/api/v1/reference/support-inbox#reopen-a-support-conversation)
- Fires events: [`support.conversation.reopened`](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-reopened)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `conversation_id` | string | yes | UUID of the support conversation to reopen. |
### Example call
```json
{
"name": "reopen_support_conversation",
"arguments": {
"conversation_id": "a0e67f56-a107-4000-aed1-06efd41c6f7a"
}
}
```
### What it returns
```json
{
"data": {
"id": "9e042b6f-5d81-4c37-a920-7b3e18cf6d45",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"channel": "telegram",
"status": "open",
"assigned_to_user_id": null,
"unread_count": 0,
"blocked": false,
"first_response_at": "2026-08-20T10:12:00+00:00",
"resolved_at": null
}
}
```
`first_response_at` survives the reopen — it is stamped once, on the first human reply ever, and never reset. `unread_count` is not touched either: reopening does not pretend the member wrote something new.
### How it fails
- `VALIDATION_FAILED` — the token's owner is on the project's team but their role lacks the support permission on it.
- `RESOURCE_NOT_FOUND` — unknown conversation_id, another creator's thread, or a project outside the token's scope:project: allow-list.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks support-conversation:update.
### Example prompts
> "Reopen support conversation `9e042b6f-5d81-4c37-a920-7b3e18cf6d45` — I told them the wrong renewal date."
> "Put Jane's thread back in the queue so I remember to follow up tomorrow."
> "Reopen every thread I resolved in the last hour." (list with `status: resolved`, then reopen each)"
### Related
- [resolve_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#resolve-support-conversation) — the opposite move.
- [reply_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#reply-support-conversation) — replying does not reopen a resolved thread; do this first.
- [list_support_conversations](https://docs.subscriby.net/mcp/v1/tools/support#list-support-conversations) — find resolved threads with status: resolved.
- [support.conversation.reopened](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-reopened) — the event this raises.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — POST /v1/support/conversations/{id}/reopen.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox) — the feature walkthrough.
## reply_support_conversation
Send a reply to a member in a support conversation, or record a private internal note.
Reply to a member. The message is recorded against the conversation and queued for delivery on the member's channel, prefixed with the project's support name so it reads as coming from a person rather than from the bot.
Set `internal` to record a private note for the creator's team instead. Internal notes are never delivered, never appear to the member, and raise no webhook event.
- Requires ability: `support-conversation:update`
- Runs the same action as [`POST /v1/support/conversations/{conversation}/messages`](https://docs.subscriby.net/api/v1/reference/support-inbox#reply-to-a-member)
- Fires events: [`support.message.sent`](https://docs.subscriby.net/webhooks/v1/events/support#support-message-sent)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `conversation_id` | string | yes | UUID of the support conversation to reply in. |
| `body` | string | yes | The reply text, max 4000 characters. Reaches the member verbatim. |
| `internal` | boolean | no | Record a creator-only note instead of sending to the member. |
| `reply_to_message_id` | string | no | Optional. UUID of a message in this same conversation to quote above the reply, the way a chat client shows a threaded reply. |
### Example call
```json
{
"name": "reply_support_conversation",
"arguments": {
"conversation_id": "a0e67f56-a107-4000-aed1-06efd41c6f7a",
"body": ""
}
}
```
### What it returns
```json
{
"data": {
"message_id": "6c18f7a3-2e95-4d60-b47a-c093e5182b7f",
"conversation_id": "9e042b6f-5d81-4c37-a920-7b3e18cf6d45",
"internal": false,
"delivery_status": "pending"
}
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks support-conversation:update.
- No support conversation found with that id. — unknown id, or outside the token's project scope.
- A reply needs a body. — body was empty or whitespace only.
- A reply may not be longer than 4000 characters. — the connector's message ceiling.
### Caveats
- **`delivery_status: "pending"` means queued, not delivered.** Delivery runs on a queue and can still fail — a member who has blocked the bot comes back as `unreachable`. Read the message back with [`get_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#get-support-conversation) if the outcome matters.
- Replying to a resolved conversation does not reopen it. Only an inbound member message does that.
- A reply does not clear the unread count or resolve the thread. Call [`resolve_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#resolve-support-conversation) separately when the matter is closed.
- Only text is supported here. Sending media takes the dashboard inbox.
- `reply_to_message_id` must name a message in the same conversation. Anything else is refused rather than silently dropped, so a quote can never surface one member's words in another member's thread.
- The reply is attributed to the token's owner. On a token minted for automation, that is the account the token belongs to — not the AI.
### Example prompts
> "Reply to that conversation: their invite link expired, here is a fresh one."
> "Add an internal note that this member has asked about the same thing twice."
### Related
- [get_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#get-support-conversation)
- [resolve_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#resolve-support-conversation)
- [support.message.sent](https://docs.subscriby.net/webhooks/v1/events/support#support-message-sent) — the event this raises
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox)
## resolve_support_conversation
Mark a support conversation resolved, clearing it from the creator's open queue.
Mark a conversation resolved. It leaves the open triage queue and stops competing for the creator's attention.
> **Note**
>
> Not destructive. The thread and its full history are kept, and it reopens by
> itself the moment the member writes again. Resolve only what has actually been
> answered — a resolved thread stops showing in the creator's triage list.
- Requires ability: `support-conversation:update`
- Runs the same action as [`POST /v1/support/conversations/{conversation}/resolve`](https://docs.subscriby.net/api/v1/reference/support-inbox#resolve-a-support-conversation)
- Fires events: [`support.conversation.resolved`](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-resolved)
- Annotations: Destructive
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `conversation_id` | string | yes | UUID of the support conversation to resolve. |
### Example call
```json
{
"name": "resolve_support_conversation",
"arguments": {
"conversation_id": "a0e67f56-a107-4000-aed1-06efd41c6f7a"
}
}
```
### What it returns
```json
{
"data": {
"conversation_id": "9e042b6f-5d81-4c37-a920-7b3e18cf6d45",
"status": "resolved",
"resolved_at": "2026-08-20T10:12:00Z"
}
}
```
### How it fails
- `TOKEN_MISSING_ABILITY` — token lacks support-conversation:update.
- No support conversation found with that id. — unknown id, or outside the token's project scope.
### Caveats
- Resolving does **not** notify the member. Send a reply first if they should know the matter is closed.
- Resolving an already-resolved conversation succeeds and emits the event again. Harmless, but deduplicate if you are posting notifications downstream.
- The member's next message reopens the thread automatically and raises [`support.conversation.reopened`](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-reopened). A **blocked** member's message does not — their thread stays resolved.
### Example prompts
> "That's handled — resolve the conversation."
> "Resolve every support thread I've already replied to today." (list first, then resolve each)"
### Related
- [reply_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#reply-support-conversation)
- [list_support_conversations](https://docs.subscriby.net/mcp/v1/tools/support#list-support-conversations)
- [support.conversation.resolved](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-resolved) — the event this raises
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox)
## unblock_support_contact
Let the member behind a support thread write in again after block_support_contact. Messages dropped while blocked are gone.
Lift a block placed with [`block_support_contact`](https://docs.subscriby.net/mcp/v1/tools/support#block-support-contact). From this moment the member's messages reach the inbox again, and their next message can reopen a resolved thread as anyone else's would.
> **Warning**
>
> Nothing is recovered. Messages sent while the block was in place were dropped
> at ingestion, not held, so there is no backlog to release. Only what the
> member writes **after** this call arrives.
Emits [`support.conversation.unblocked`](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-unblocked) when the block is lifted. Idempotent: unblocking a member who was not blocked changes nothing and emits nothing.
- Requires ability: `support-conversation:update`
- Runs the same action as [`POST /v1/support/conversations/{conversation}/unblock`](https://docs.subscriby.net/api/v1/reference/support-inbox#unblock-a-support-contact)
- Fires events: [`support.conversation.unblocked`](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-unblocked)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `conversation_id` | string | yes | UUID of the support conversation whose member to unblock. |
### Example call
```json
{
"name": "unblock_support_contact",
"arguments": {
"conversation_id": "a0e67f56-a107-4000-aed1-06efd41c6f7a"
}
}
```
### What it returns
```json
{
"data": {
"id": "9e042b6f-5d81-4c37-a920-7b3e18cf6d45",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"channel": "telegram",
"status": "resolved",
"assigned_to_user_id": null,
"unread_count": 0,
"blocked": false,
"first_response_at": "2026-08-20T10:12:00+00:00",
"resolved_at": "2026-08-21T09:30:00+00:00"
}
}
```
`status` is whatever it was before — unblocking does not reopen the thread. The member's next message will, or [`reopen_support_conversation`](https://docs.subscriby.net/mcp/v1/tools/support#reopen-support-conversation) can.
### How it fails
- `VALIDATION_FAILED` — the token's owner is on the project's team but their role lacks the support permission on it.
- `RESOURCE_NOT_FOUND` — unknown conversation_id, another creator's thread, or a project outside the token's scope:project: allow-list.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks support-conversation:update.
### Example prompts
> "Unblock the member on support conversation `9e042b6f-5d81-4c37-a920-7b3e18cf6d45` — they apologised by email."
> "Lift the support block on Jane's thread."
> "Which of my blocked contacts should be unblocked now that the promo is over?" (list, read each, then unblock)"
### Related
- [block_support_contact](https://docs.subscriby.net/mcp/v1/tools/support#block-support-contact) — the opposite move.
- [reopen_support_conversation](https://docs.subscriby.net/mcp/v1/tools/support#reopen-support-conversation) — put the thread back in the queue without waiting for the member.
- [support.conversation.unblocked](https://docs.subscriby.net/webhooks/v1/events/support#support-conversation-unblocked) — the event this raises.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — POST /v1/support/conversations/{id}/unblock.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox) — the feature walkthrough.
## update_canned_reply
Change one or more fields of a saved support reply. Anything omitted keeps its stored value.
Edit a snippet in place — reword the body, rename it, give it a shortcut or move it in the picker. The change is partial by design: only the arguments present in the call are written, and every other field is filled from the stored row.
Emits [`support.canned_reply.updated`](https://docs.subscriby.net/webhooks/v1/events/support#support-canned-reply-updated) with the fields that changed, and only when something did. Idempotent: sending the stored values again writes nothing and emits nothing.
> **Omitted is not the same as null**
>
> Leaving `shortcut` out keeps the current shortcut; passing `shortcut: null`
> **clears** it. `sort_order` is the exception — `null` there is treated like an
> omission and the stored position stays.
- Requires ability: `support-canned-reply:update`
- Runs the same action as [`PATCH /v1/projects/{project}/support/canned-replies/{reply}`](https://docs.subscriby.net/api/v1/reference/support-inbox#update-a-saved-reply)
- Fires events: [`support.canned_reply.updated`](https://docs.subscriby.net/webhooks/v1/events/support#support-canned-reply-updated)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project the snippet belongs to. |
| `canned_reply_id` | string | yes | UUID of the saved reply to change. |
| `title` | string | no | New label, 2 to 80 characters. |
| `body` | string | no | New reply text, 2 to 4000 characters. |
| `shortcut` | string | no | New slash-command, unique within the project. Pass null to clear it. |
| `sort_order` | integer | no | New position in the picker, 0 to 999. |
### Example call
```json
{
"name": "update_canned_reply",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9",
"canned_reply_id": "33e914e5-e23a-4000-a522-192a401f8d33"
}
}
```
### What it returns
```json
{
"data": {
"id": "5d2f8a71-3c9e-4b06-a8f4-1e7c9d2b6a35",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"title": "Refund policy (2026)",
"body": "Refunds are available within 14 days of your first payment. Reply here with the email you paid with and we'll sort it.",
"shortcut": "refund",
"sort_order": 0,
"created_at": "2026-06-02T09:14:02+00:00",
"updated_at": "2026-09-06T10:05:00+00:00"
}
}
```
The full row after the change, not just the fields sent — so the response is what a creator now sees in the picker.
### How it fails
- `VALIDATION_FAILED` — a title or body that is present but empty or outside its length limits, a shortcut over 30 characters, with disallowed characters, or already used by a different snippet in this project, or sort_order outside 0–999. Also returned when the token's owner is on the project's team but their role lacks the support permission on it.
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see, or canned_reply_id is unknown, deleted, or belongs to another project.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks support-canned-reply:update.
### Example prompts
> "Rename saved reply `5d2f8a71-3c9e-4b06-a8f4-1e7c9d2b6a35` to 'Refund policy (2026)' and leave everything else."
> "Give the opening-hours snippet the shortcut `hours`."
> "Drop the shortcut from the refund snippet — it keeps getting triggered by accident." (`shortcut: null`)"
### Related
- [get_canned_reply](https://docs.subscriby.net/mcp/v1/tools/support#get-canned-reply) — read the current values first.
- [create_canned_reply](https://docs.subscriby.net/mcp/v1/tools/support#create-canned-reply) — the same rules, for a new snippet.
- [support.canned_reply.updated](https://docs.subscriby.net/webhooks/v1/events/support#support-canned-reply-updated) — the event this raises.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — PATCH /v1/projects/{project}/support/canned-replies/{reply}.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox#saved-replies) — the feature walkthrough.
## update_support_settings
Change a project's support inbox settings. Anything omitted keeps its stored value.
Switch member support on or off, choose where new threads are relayed, set the name members see on replies, set or clear the automatic acknowledgement, and toggle email notifications. Partial by design: only the settings present in the call change, and the rest is assembled from the stored row before the write.
Emits [`support.settings.updated`](https://docs.subscriby.net/webhooks/v1/events/support#support-settings-updated) with the settings as they now stand and what changed — and only when something did. Idempotent: sending the stored values again writes nothing and emits nothing.
> **Turning support off**
>
> With `enabled: false`, member messages stop reaching the inbox and go back to
> the bot's "I can't understand" fallback. Existing threads are kept, nothing is
> deleted, and switching it back on resumes intake — but messages sent in
> between are not recovered.
> **Note**
>
> `relay_mode` accepts `owner_dm`, `forum_group` and `none`. `forum_group`
> starts relaying once the creator has added the project bot to a group with
> Topics enabled as an administrator; the bot links the group itself and
> confirms by DM, so switching the mode alone does not connect anything.
- Requires ability: `project:update`
- Runs the same action as [`PATCH /v1/projects/{project}/support/settings`](https://docs.subscriby.net/api/v1/reference/support-inbox#update-a-projects-support-settings)
- Fires events: [`support.settings.updated`](https://docs.subscriby.net/webhooks/v1/events/support#support-settings-updated)
- Annotations: Destructive, Idempotent, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | yes | UUID of the project whose support settings to change. |
| `enabled` | boolean | no | Whether members can open support threads with the bot. |
| `relay_mode` | string | no | Where new threads go besides the dashboard inbox: "owner_dm" forwards them to the creator on their connector, "forum_group" opens each thread as a topic in the group the project bot administers, "none" keeps them in the inbox only. |
| `agent_name` | string | no | The name members see on replies, up to 60 characters. Pass null to use the project name. |
| `auto_reply` | string | no | The message sent automatically when a member opens a thread, up to 1000 characters. Pass null for none. |
| `notify_email` | boolean | no | Whether the creator is emailed about new threads. |
### Example call
```json
{
"name": "update_support_settings",
"arguments": {
"project_id": "308f7cfe-03db-4a00-a514-7fab9fdf89e9"
}
}
```
### What it returns
```json
{
"data": {
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"enabled": true,
"relay_mode": "owner_dm",
"agent_name": "Team Subscriby",
"auto_reply": "Thanks, we will reply within a day.",
"notify_email": true,
"updated_at": "2026-09-06T10:05:00+00:00"
}
}
```
The same row [`get_support_settings`](https://docs.subscriby.net/mcp/v1/tools/support#get-support-settings) returns, after the change. `agent_name` and `auto_reply` come back trimmed; a blank string is stored as `null`, the same as passing `null`.
### How it fails
- `VALIDATION_FAILED` — relay_mode is anything but owner_dm or none, agent_name longer than 60 characters, auto_reply longer than 1000, or enabled / notify_email not a boolean. The envelope names the offending field. Also returned when the token's owner is on the project's team but their role may not update the project.
- `RESOURCE_NOT_FOUND` — project_id names a project the token cannot see: unknown, another team's, or outside the token's scope:project: allow-list.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks project:update.
### Example prompts
> "Turn on Telegram DMs for new support threads on project `7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13` and also email me."
> "Set the support auto-reply to 'Thanks — we usually answer within a few hours.'"
> "Switch off member support on the archived course project."
### Related
- [get_support_settings](https://docs.subscriby.net/mcp/v1/tools/support#get-support-settings) — read before you write.
- [update_project](https://docs.subscriby.net/mcp/v1/tools/project#update-project) — the project's other settings.
- [support.settings.updated](https://docs.subscriby.net/webhooks/v1/events/support#support-settings-updated) — the event this raises.
- [Support Inbox API](https://docs.subscriby.net/api/v1/reference/support-inbox) — PATCH /v1/projects/{project}/support/settings.
- [Support inbox](https://docs.subscriby.net/creators/support-inbox#settings) — what each setting does for the creator.
---
# Team Member Tools
Source: https://docs.subscriby.net/mcp/v1/tools/team-members
Team members are the people who collaborate inside a team, distinct from a project's members. These tools list the roster, invite by email with a role, change a role and remove someone, the same rules the team settings screen enforces.
## Tools
- [`cancel_team_invitation`](#cancel-team-invitation) — Cancel Team Invitation (destructive)
- [`invite_team_member`](#invite-team-member) — Invite Team Member (destructive)
- [`list_team_members`](#list-team-members) — List Team Members (read)
- [`remove_team_member`](#remove-team-member) — Remove Team Member (destructive)
- [`update_team_member_role`](#update-team-member-role) — Change Team Member Role (destructive)
## cancel_team_invitation
Withdraw a team invitation that has not been accepted.
Withdraws a pending invitation. The link stops working and the invitee never becomes a member.
> **An invitee is not a member**
>
> Someone who has not accepted has no membership row — only a pending
> invitation. That is why this is a separate tool from
> [`remove_team_member`](https://docs.subscriby.net/mcp/v1/tools/team-members#remove-team-member): the two fail on
> different things, and using the wrong one returns `RESOURCE_NOT_FOUND` instead
> of doing something surprising.
Not tier-gated. See [what the tier gates](https://docs.subscriby.net/api/v1/reference/teams#what-the-tier-gates).
- Requires ability: `team-member:remove`
- Runs the same action as [`DELETE /v1/teams/{team}/invitations/{invitation}`](https://docs.subscriby.net/api/v1/reference/team-members#withdraw-an-invitation)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string | yes | UUID of the team. |
| `invitation_id` | string | yes | UUID of the pending invitation to withdraw. |
### Example call
```json
{
"name": "cancel_team_invitation",
"arguments": {
"team_id": "ffaf93ac-a725-4800-a198-ea154bdce1f4",
"invitation_id": "76731b1e-a4d8-4c00-a17b-b9d597d77577"
}
}
```
### What it returns
```json
{
"data": {
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"invitation_id": "b73c5f21-9d80-4a6e-8215-4f70ce13a9d6",
"cancelled": true
}
}
```
No webhook event fires. An invitation that was never accepted changed nobody's access, so there is no membership transition to announce.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks team-member:remove.
- `RESOURCE_NOT_FOUND` — no such team, or no pending invitation with that id on it. An invitation that has already been accepted is gone from this list; remove the member instead.
### Example prompts
> "Cancel the invitation we sent to the wrong address."
### Related
- [invite_team_member](https://docs.subscriby.net/mcp/v1/tools/team-members#invite-team-member)
- [remove_team_member](https://docs.subscriby.net/mcp/v1/tools/team-members#remove-team-member)
- [Team Members API](https://docs.subscriby.net/api/v1/reference/team-members)
## invite_team_member
Invite a collaborator into a team by email, onto a role that already exists.
Invites someone into a team. Addressed by **email**, not by user id — the invitee may not have a Subscriby account yet, which is what separates inviting from adding. They receive an invitation with a link; the membership appears when they accept.
`role` is a role **code** on that team, such as `support-agent`. Create it first with [`create_role`](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-role) if it does not exist.
> **Warning**
>
> Growth-tier capability. On a lower tier this returns `TEAM_TIER_REQUIRED` and
> sends nothing.
- Requires ability: `team-member:invite`
- Runs the same action as [`POST /v1/teams/{team}/members`](https://docs.subscriby.net/api/v1/reference/team-members#invite-a-team-member)
- Fires events: [`team.member.invited`](https://docs.subscriby.net/webhooks/v1/events/team#team-member-invited)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string | yes | UUID of the team to invite into. |
| `email` | string | yes | Email address of the invitee. |
| `role` | string | yes | Role code the invitee will hold. Must already exist on the team. |
### Example call
```json
{
"name": "invite_team_member",
"arguments": {
"team_id": "ffaf93ac-a725-4800-a198-ea154bdce1f4",
"email": "creator@example.com",
"role": ""
}
}
```
### What it returns
```json
{
"data": {
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"email": "newcomer@example.com",
"role": "support-agent",
"status": "invited"
}
}
```
`status` is always `invited` — nobody is a member until they accept, so there is no id to return yet.
Emits [`team.member.invited`](https://docs.subscriby.net/webhooks/v1/events/team#team-member-invited) now, then [`team.member.joined`](https://docs.subscriby.net/webhooks/v1/events/team#team-member-joined) when they accept.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks team-member:invite.
- `TEAM_TIER_REQUIRED` — the caller's platform tier does not include Teams.
- `RESOURCE_NOT_FOUND` — no such team, or the caller is not a member.
- `VALIDATION_FAILED` — malformed email, unknown role code, or the person is already in the team. Call [list_roles](/mcp/v1/tools/roles-and-groups#list-roles) to see the codes available.
### Example prompts
> "Invite priya@example.com to the Research team as a manager."
> "Add a support agent to my team — their email is sam@example.com."
### Related
- [update_team_member_role](https://docs.subscriby.net/mcp/v1/tools/team-members#update-team-member-role)
- [remove_team_member](https://docs.subscriby.net/mcp/v1/tools/team-members#remove-team-member)
- [cancel_team_invitation](https://docs.subscriby.net/mcp/v1/tools/team-members#cancel-team-invitation) — withdraw before they accept.
- [list_team_members](https://docs.subscriby.net/mcp/v1/tools/team-members#list-team-members)
- [Team Members API](https://docs.subscriby.net/api/v1/reference/team-members)
## list_team_members
List collaborators on a team. Owner pseudo-row is prepended; role_id is null for the owner.
List the collaborators on a team — the owner pseudo-row is prepended to every pivot member. `role_id` is null for the owner. Emails are surfaced verbatim because team members are internal collaborators, not subscribers.
- Requires ability: `team-member:view-any`
- Runs the same action as [`GET /v1/teams/{team}/members`](https://docs.subscriby.net/api/v1/reference/team-members#list-a-teams-members)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string | yes | UUID of the team whose members to list. |
### Example call
```json
{
"name": "list_team_members",
"arguments": {
"team_id": "ffaf93ac-a725-4800-a198-ea154bdce1f4"
}
}
```
### What it returns
```json
{
"data": [
{
"id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"name": "Priya Patel",
"email": "priya@example.com",
"role_id": null,
"is_owner": true,
"joined_at": "2026-04-02T14:15:00Z"
}
],
"meta": { "total": 3 }
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — team_id is not a valid UUID, or the team falls outside the caller's visibility set.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks team-member:view-any.
### Example prompts
> "Who is on team `a83f0d51-4c92-4b7e-8615-2fd9e70a3c86`?"
> "List collaborators on my Research Studio team."
### Related
- [get_team](https://docs.subscriby.net/mcp/v1/tools/team#get-team)
- [list_teams](https://docs.subscriby.net/mcp/v1/tools/team#list-teams)
- [list_roles](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-roles) — resolve role_id to permission sets.
- [Team Members API](https://docs.subscriby.net/api/v1/reference/team-members)
## remove_team_member
Remove a collaborator from a team. Their Subscriby account is untouched.
Removes a collaborator from a team. They lose access to every project and setting scoped to it immediately; their own Subscriby account is untouched.
The team **owner** cannot be removed — ownership is not a membership row.
> **Note**
>
> **Not** tier-gated, unlike inviting. A creator whose tier lapsed with
> collaborators still attached has to be able to remove them; gating that would
> turn a downgrade into a permanent grant. See [what the tier
> gates](https://docs.subscriby.net/api/v1/reference/teams#what-the-tier-gates).
> **Warning**
>
> Only works on someone who has **accepted**. Somebody still holding an
> unaccepted invitation has no membership row to remove — use
> [`cancel_team_invitation`](https://docs.subscriby.net/mcp/v1/tools/team-members#cancel-team-invitation) for those. Using
> the wrong one returns `RESOURCE_NOT_FOUND` rather than doing something
> surprising.
- Requires ability: `team-member:remove`
- Runs the same action as [`DELETE /v1/teams/{team}/members/{member}`](https://docs.subscriby.net/api/v1/reference/team-members#remove-a-team-member)
- Fires events: [`team.member.removed`](https://docs.subscriby.net/webhooks/v1/events/team#team-member-removed)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string | yes | UUID of the team. |
| `user_id` | string | yes | UUID of the collaborator to remove. |
### Example call
```json
{
"name": "remove_team_member",
"arguments": {
"team_id": "ffaf93ac-a725-4800-a198-ea154bdce1f4",
"user_id": "10a75cda-1d70-4c80-a716-fda9ddf437ed"
}
}
```
### What it returns
```json
{
"data": {
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"user_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"removed": true
}
}
```
Emits [`team.member.removed`](https://docs.subscriby.net/webhooks/v1/events/team#team-member-removed).
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks team-member:remove.
- `RESOURCE_NOT_FOUND` — no such team, or user_id is not a member. The owner also returns 404.
### Example prompts
> "Remove sam@example.com from the Research team — they've left."
### Related
- [cancel_team_invitation](https://docs.subscriby.net/mcp/v1/tools/team-members#cancel-team-invitation)
- [invite_team_member](https://docs.subscriby.net/mcp/v1/tools/team-members#invite-team-member)
- [list_team_members](https://docs.subscriby.net/mcp/v1/tools/team-members#list-team-members) — resolve the user_id first.
- [Team Members API](https://docs.subscriby.net/api/v1/reference/team-members)
## update_team_member_role
Move an existing collaborator onto a different role within the team.
Moves an existing collaborator onto a different role. This is the only way to re-role someone without removing and re-inviting them — the dashboard's team settings page offers invite, remove and cancel-invitation only, so there is no UI equivalent.
The team **owner** cannot be re-roled: ownership is not a membership row and carries everything unconditionally.
> **Warning**
>
> Growth-tier capability. On a lower tier this returns `TEAM_TIER_REQUIRED` and
> changes nothing.
- Requires ability: `team-member:update-role`
- Runs the same action as [`PATCH /v1/teams/{team}/members/{member}/role`](https://docs.subscriby.net/api/v1/reference/team-members#change-a-team-members-role)
- Fires events: [`team.member.role_changed`](https://docs.subscriby.net/webhooks/v1/events/team#team-member-role-changed)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string | yes | UUID of the team. |
| `user_id` | string | yes | UUID of the collaborator whose role is changing. |
| `role` | string | yes | Role code to move them onto. Must already exist on the team. |
### Example call
```json
{
"name": "update_team_member_role",
"arguments": {
"team_id": "ffaf93ac-a725-4800-a198-ea154bdce1f4",
"user_id": "10a75cda-1d70-4c80-a716-fda9ddf437ed",
"role": ""
}
}
```
### What it returns
```json
{
"data": {
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"user_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"role": "manager"
}
}
```
Emits [`team.member.role_changed`](https://docs.subscriby.net/webhooks/v1/events/team#team-member-role-changed).
> **This is what makes that event reachable**
>
> Nothing in Subscriby called the underlying re-role path before this shipped,
> so `team.member.role_changed` sat in the webhook catalog — and was offered as
> a Zapier and n8n trigger — while being impossible to fire.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks team-member:update-role.
- `TEAM_TIER_REQUIRED` — the caller's platform tier does not include Teams.
- `RESOURCE_NOT_FOUND` — no such team, or user_id is not a member of it. The owner also returns 404 here, because the owner has no membership row to change.
- `VALIDATION_FAILED` — unknown role code.
### Example prompts
> "Promote Priya to manager on the Research team."
> "Move everyone currently on the admin role down to manager except me."
### Related
- [invite_team_member](https://docs.subscriby.net/mcp/v1/tools/team-members#invite-team-member)
- [remove_team_member](https://docs.subscriby.net/mcp/v1/tools/team-members#remove-team-member)
- [list_roles](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#list-roles) — the codes you can move someone onto.
- [Team Members API](https://docs.subscriby.net/api/v1/reference/team-members)
---
# Team Tools
Source: https://docs.subscriby.net/mcp/v1/tools/team
A team groups creators, roles and projects, and every token is scoped to exactly one. These tools read and manage the teams the account can act in.
## Tools
- [`create_team`](#create-team) — Create Team (destructive)
- [`delete_team`](#delete-team) — Delete Team (destructive)
- [`get_team`](#get-team) — Get Team (read)
- [`list_teams`](#list-teams) — List Teams (read)
- [`update_team`](#update-team) — Rename Team (destructive)
## create_team
Create a team owned by the caller. Growth-tier capability.
Creates a team owned by the caller. Teams are the tenant every project, plan and subscription hangs off; the caller becomes the owner, which is not a membership row and cannot later be removed or re-roled.
> **Warning**
>
> Growth-tier capability. On a lower tier this returns `TEAM_TIER_REQUIRED` and
> creates nothing. See [what the tier
> gates](https://docs.subscriby.net/api/v1/reference/teams#what-the-tier-gates).
- Requires ability: `team:create`
- Runs the same action as [`POST /v1/teams`](https://docs.subscriby.net/api/v1/reference/teams#create-a-team)
- Fires events: [`team.created`](https://docs.subscriby.net/webhooks/v1/events/team#team-created)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Team name, 1..255 characters. |
### Example call
```json
{
"name": "create_team",
"arguments": {
"name": ""
}
}
```
### What it returns
```json
{
"data": {
"id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"name": "Research Collective",
"owner_user_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80"
}
}
```
Emits [`team.created`](https://docs.subscriby.net/webhooks/v1/events/team#team-created).
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks team:create.
- `TEAM_TIER_REQUIRED` — the caller's platform tier does not include Teams.
- `VALIDATION_FAILED` — name empty or longer than 255 characters.
### Example prompts
> "Create a team called Research Collective."
> "Set up a separate team for the client work so their projects are isolated."
### Related
- [update_team](https://docs.subscriby.net/mcp/v1/tools/team#update-team)
- [delete_team](https://docs.subscriby.net/mcp/v1/tools/team#delete-team)
- [create_role](https://docs.subscriby.net/mcp/v1/tools/roles-and-groups#create-role) — a new team seeds admin, manager and viewer; add your own after.
- [Teams API](https://docs.subscriby.net/api/v1/reference/teams)
## delete_team
Delete a team and everything scoped to it. Owner only, irreversible.
Deletes a team. Only the **owner** may do it — destroying the container everyone else works in is not something a member can decide.
> **Irreversible, and it takes everything with it**
>
> A team is the tenant that projects, plans, subscriptions, roles and groups
> hang off. Deleting one is not a tidy-up; confirm with a human before calling
> this. The tool is annotated destructive so a client can prompt for
> confirmation.
> **Note**
>
> Unlike `create_team` and `update_team`, this is **not** tier-gated. A creator
> whose tier lapsed has to be able to take access away. See [what the tier
> gates](https://docs.subscriby.net/api/v1/reference/teams#what-the-tier-gates).
- Requires ability: `team:delete`
- Runs the same action as [`DELETE /v1/teams/{team}`](https://docs.subscriby.net/api/v1/reference/teams#delete-a-team)
- Fires events: [`team.deleted`](https://docs.subscriby.net/webhooks/v1/events/team#team-deleted)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string | yes | UUID of the team to delete. Irreversible. |
### Example call
```json
{
"name": "delete_team",
"arguments": {
"team_id": "ffaf93ac-a725-4800-a198-ea154bdce1f4"
}
}
```
### What it returns
```json
{
"data": {
"id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"deleted": true
}
}
```
Emits [`team.deleted`](https://docs.subscriby.net/webhooks/v1/events/team#team-deleted).
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks team:delete.
- `RESOURCE_NOT_FOUND` — no such team, or the caller is not a member.
- `VALIDATION_FAILED` — the caller is a member but not the owner.
### Example prompts
> "Delete the team we set up for the pilot — it's finished and empty."
### Related
- [create_team](https://docs.subscriby.net/mcp/v1/tools/team#create-team)
- [update_team](https://docs.subscriby.net/mcp/v1/tools/team#update-team)
- [remove_team_member](https://docs.subscriby.net/mcp/v1/tools/team-members#remove-team-member) — the narrower way to withdraw one person's access.
- [Teams API](https://docs.subscriby.net/api/v1/reference/teams)
## get_team
Fetch a single team by UUID from the caller's visibility set (owned + membership). Foreign teams return RESOURCE_NOT_FOUND.
Fetch one team the authenticated user owns or belongs to. Foreign teams always return `RESOURCE_NOT_FOUND` to prevent existence leaks.
- Requires ability: `team:view`
- Runs the same action as [`GET /v1/teams/{team}`](https://docs.subscriby.net/api/v1/reference/teams#get-a-team)
- Annotations: Read-only
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string | yes | UUID of the team to fetch. |
### Example call
```json
{
"name": "get_team",
"arguments": {
"team_id": "ffaf93ac-a725-4800-a198-ea154bdce1f4"
}
}
```
### What it returns
```json
{
"data": {
"id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"name": "Research Studio",
"owner_user_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"personal": false,
"created_at": "2026-05-18T10:05:00Z"
}
}
```
### How it fails
- `RESOURCE_NOT_FOUND` — team_id is not a valid UUID, or it falls outside the caller's visibility set.
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks team:view.
### Example prompts
> "Show me team `a83f0d51-4c92-4b7e-8615-2fd9e70a3c86`."
> "Which user owns this team?"
### Related
- [list_teams](https://docs.subscriby.net/mcp/v1/tools/team#list-teams)
- [list_team_members](https://docs.subscriby.net/mcp/v1/tools/team-members#list-team-members)
- [Teams API](https://docs.subscriby.net/api/v1/reference/teams)
## list_teams
List every team the authenticated user owns or belongs to. Read-only — team CRUD stays in the dashboard.
List every team the caller has visibility into. Useful before minting a token scoped to a specific team — agents can surface a picker instead of making the user hunt for a UUID. Read-only; team create/update/delete flows stay in the dashboard.
- Requires ability: `team:view-any`
- Runs the same action as [`GET /v1/teams`](https://docs.subscriby.net/api/v1/reference/teams#list-teams)
- Annotations: Read-only
### Arguments
This tool takes no arguments.
### Example call
```json
{
"name": "list_teams",
"arguments": {}
}
```
### What it returns
```json
{
"data": [
{
"id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"name": "Research Collective",
"owner_user_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"personal": true,
"created_at": "2026-01-12T10:05:00Z"
}
],
"meta": { "total": 1 }
}
```
`personal` is `true` for the auto-created signup team and `false` for every other team the user created or joined.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks team:view-any.
### Example prompts
> "Which teams do I belong to?"
> "List my teams so I know where to scope my next API token."
### Related
- [get_team](https://docs.subscriby.net/mcp/v1/tools/team#get-team)
- [list_team_members](https://docs.subscriby.net/mcp/v1/tools/team-members#list-team-members)
- [Teams API](https://docs.subscriby.net/api/v1/reference/teams)
## update_team
Rename a team. Owner only, Growth-tier capability.
Renames a team. `name` is the only mutable field on a team — ownership does not transfer, and the tier a team sits on is a property of the owner's billing rather than of the team.
Only the **owner** may rename. Belonging to a team is enough to read it and to add to it; changing what everyone else's workspace is called is the owner's alone.
> **Warning**
>
> Growth-tier capability. On a lower tier this returns `TEAM_TIER_REQUIRED` and
> changes nothing.
- Requires ability: `team:update`
- Runs the same action as [`PATCH /v1/teams/{team}`](https://docs.subscriby.net/api/v1/reference/teams#rename-a-team)
- Fires events: [`team.updated`](https://docs.subscriby.net/webhooks/v1/events/team#team-updated)
- Annotations: Destructive, Open world
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `team_id` | string | yes | UUID of the team to rename. |
| `name` | string | yes | New team name, 1..255 characters. |
### Example call
```json
{
"name": "update_team",
"arguments": {
"team_id": "ffaf93ac-a725-4800-a198-ea154bdce1f4",
"name": ""
}
}
```
### What it returns
```json
{
"data": {
"id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"name": "Research Collective",
"owner_user_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80"
}
}
```
Emits [`team.updated`](https://docs.subscriby.net/webhooks/v1/events/team#team-updated), which fires however the rename happened — this tool, the REST endpoint, or the dashboard.
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `TOKEN_MISSING_ABILITY` — token lacks team:update.
- `TEAM_TIER_REQUIRED` — the caller's platform tier does not include Teams.
- `RESOURCE_NOT_FOUND` — no such team, or the caller is not a member. A team in another account is invisible rather than forbidden, so the tool cannot be used to discover that an id exists.
- `VALIDATION_FAILED` — name empty or too long, or the caller is a member but not the owner.
### Example prompts
> "Rename my Research team to Research Collective."
### Related
- [create_team](https://docs.subscriby.net/mcp/v1/tools/team#create-team)
- [delete_team](https://docs.subscriby.net/mcp/v1/tools/team#delete-team)
- [get_team](https://docs.subscriby.net/mcp/v1/tools/team#get-team)
- [Teams API](https://docs.subscriby.net/api/v1/reference/teams)
---
# Token Tools
Source: https://docs.subscriby.net/mcp/v1/tools/tokens
Personal access tokens authorise every REST and MCP call, and the plaintext value exists exactly once. These tools list, mint and revoke tokens, so an agent can rotate the credentials it is given without a person opening the dashboard.
## Tools
- [`list_tokens`](#list-tokens) — List Personal Access Tokens (read)
- [`revoke_token`](#revoke-token) — Revoke Personal Access Token (destructive)
## list_tokens
List the authenticated user's own personal access tokens with abilities and scope tuples split out.
List the authenticated user's own personal access tokens with abilities and scope tuples split out. `scope:team:...` and `scope:project:...` entries are peeled off the raw abilities array into a structured `scopes` object for readability. The plaintext token value is never surfaced — minting happens in the dashboard.
- Requires ability: `token:view-any`
- Runs the same action as [`GET /v1/tokens`](https://docs.subscriby.net/api/v1/reference/tokens#list-the-callers-tokens)
- Annotations: Read-only
### Arguments
This tool takes no arguments.
### Example call
```json
{
"name": "list_tokens",
"arguments": {}
}
```
### What it returns
```json
{
"data": [
{
"id": "1284",
"name": "Zapier production",
"abilities": ["project:view-any", "project-user:view-any"],
"abilities_count": 2,
"scopes": {
"team_id": "a83f0d51-4c92-4b7e-8615-2fd9e70a3c86",
"project_ids": ["7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13"]
},
"last_used_at": "2026-05-18T09:15:00Z",
"expires_at": null,
"created_at": "2026-05-01T10:05:00Z"
}
],
"meta": { "total": 1 }
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
### Example prompts
> "Show my active API tokens."
> "Which abilities does each of my tokens carry?"
### Related
- [revoke_token](https://docs.subscriby.net/mcp/v1/tools/tokens#revoke-token)
- [Tokens API](https://docs.subscriby.net/api/v1/reference/tokens)
## revoke_token
Revoke one of the authenticated user's own tokens by id. Self-revocation is refused.
Revoke one of the authenticated user's own personal access tokens by id. Refuses self-revocation — the token authenticating this call cannot delete its own row. Revoke the current session from the dashboard (Settings → API Tokens) or from a different token instead.
- Requires ability: `token:delete`
- Runs the same action as [`DELETE /v1/tokens/{token}`](https://docs.subscriby.net/api/v1/reference/tokens#revoke-a-token)
- Annotations: Destructive, Idempotent
### Arguments
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `token_id` | string | yes | UUID of the token to revoke. Must belong to the authenticated user. Cannot be the id of the token authenticating this call. |
### Example call
```json
{
"name": "revoke_token",
"arguments": {
"token_id": "f18ef91a-282d-4800-afb9-80a323759096"
}
}
```
### What it returns
```json
{
"data": {
"token_id": "1284",
"revoked": true
}
}
```
### How it fails
- `AUTHENTICATION_REQUIRED` — no authenticated user on the request.
- `VALIDATION_FAILED` — token_id is empty, or caller tried to revoke the current session (self-revoke blocked).
- `RESOURCE_NOT_FOUND` — token doesn't belong to the authenticated user.
### Example prompts
> "Revoke my 'Zapier staging' token."
> "Delete token `1284`."
### Related
- [list_tokens](https://docs.subscriby.net/mcp/v1/tools/tokens#list-tokens)
- [Tokens API](https://docs.subscriby.net/api/v1/reference/tokens)
---
# MCP Troubleshooting
Source: https://docs.subscriby.net/mcp/v1/troubleshooting
## Client can't connect
- **"Server unreachable"** — confirm `https://mcp.subscriby.net` is reachable from the client (open it in a browser; you should see an MCP protocol handshake description, not a 404).
- **TLS errors** — update the client. Claude Desktop < 1.11 and Cursor < 0.46 don't negotiate modern MCP streaming HTTP correctly.
- **Proxy in the way** — corporate proxies sometimes strip the `Authorization` header. Test over a direct network first.
## Sign-in never finishes
- The server URL must be exactly `https://mcp.subscriby.net`. A trailing slash or a path makes the client compare it to the address the server publishes and refuse the connection before you see a sign-in page.
- You landed on the Subscriby sign-in page instead of the consent screen: sign in (two-factor and passkeys apply), and the client retries the authorization on its own.
- The consent screen appeared but the client still says it is not connected: press **Authorize** on it; **Cancel** sends the client an error and it stays disconnected.
- Corporate proxies that rewrite or block `https://app.subscriby.net/.well-known/…` break discovery. Test over a direct network first.
## Tool list is empty
- With a personal access token: the token is missing the ability the tool enforces. Check the [tools reference](/mcp/v1/tools-reference) for which one, and mint a token that carries it.
- Client config JSON invalid. Validate with `jq` or similar.
- Restart required after editing config. MCP servers load at client launch; hot reload isn't a thing in MCP-1.
## Specific tool returns 403
With a personal access token the error envelope includes `required_ability`. Mint a new token with that ability added, replace the old one in the client config, restart the client. An OAuth connection carries every ability your role grants, so a 403 there means your team role lacks the permission itself.
## Specific tool returns 404
- Tenant mismatch — the token's `scope:team:` does not include the project/plan/subscriber you referenced.
- Entity does not exist. Double-check the ID or handle.
## Tool call times out
- Long-running operations (batch access-code generation) return a `job_id` immediately and must be polled via [`get_job_status`](/mcp/v1/tools/observability#get-job-status) rather than waited on in the initial request. See [async jobs](/mcp/v1/async-jobs).
- Short tool calls that still time out usually indicate a transient backend slowdown; retry after a brief backoff.
## Rate limited (429)
- The `mcp` bucket is 120/min per token for authenticated calls. Claude Desktop and Cursor pace tool calls internally, so hitting it usually means a tight loop.
- Unauthenticated traffic on the MCP host is bucketed separately at 5/min per IP. If a client is hitting `RATE_LIMITED` after only a handful of attempts, the token is missing or malformed — fix the `Authorization` header before retrying.
- The `Retry-After` header tells you how long to wait. Honour it.
## Agent loops asking for confirmation
- The client is auto-denying write tools. Toggle auto-approve for the subscriby namespace in the client's settings.
- If you want confirmation on every write, that's the default — the server doesn't control client-side confirmations.
## "Session expired" or 401 after hours of chat
- An OAuth access token lasts an hour and the client renews it silently. If it stopped renewing (the refresh token was discarded, or 30 days passed with no use), disconnect and connect again inside the client.
- Personal access tokens don't expire mid-session unless you revoked them.
- The session-level timeout inside some clients caps at a few hours for safety — reconnect.
## Related
- [MCP security](/mcp/v1/security)
- [Async jobs](/mcp/v1/async-jobs)
- [Error envelope](/api/v1/errors)
---
# Access Codes
Source: https://docs.subscriby.net/payments/access-codes
**Access Codes** are alphanumeric keys you generate and hand out — each one unlocks a specific subscription plan without the subscriber having to pay through a gateway.
## At a glance
| Feature | Support |
| -------------------- | ------------------------------------------------------------------------------------- |
| Money movement | None — bypasses payment gateways entirely |
| Recurring billing | ❌ One-time activation only |
| Modes | Live only |
| Supported currencies | Any (`*`) — the code inherits the plan's configured currency |
| Quota | Plan-based (Free 5, Starter monthly 200, Growth monthly 500; annual tiers are higher) |
## Typical use cases
- **Offline sales** — you accepted cash, bank transfer, or invoice-based payment, and you hand the subscriber a code.
- **Giveaways and promotions** — free access to winners, contest participants, or select influencers.
- **Team access** — onboard staff or collaborators without going through billing.
- **Private / gated plans** — pair with a plan's **Access codes only** setting so the plan is invisible on the public portal.
## Enable as a payment method
### Open Setup a Payment Method [step]
In Subscriby, go to **Payment Methods → Setup a Payment Method**.
### Pick Access Codes [step]
Pick **Access Codes** under the provider list. No credentials to enter.
### Activate and save [step]
Toggle **Active** on and click **Save Changes**.
### Generate your first codes [step]
Now that the payment method is live, head to [Access Codes](/creators/codes) to actually generate codes.
## Quotas and overage fees
Each creator plan includes a **free allocation** of access codes per billing cycle:
- **Free:** 5 codes per cycle
- **Starter monthly:** 25 / cycle
- **Starter annually:** 250 / cycle
- **Growth monthly:** 150 / cycle
- **Growth annually:** 1,500 / cycle
An annual plan's allocation covers the **whole year**, not each month — the
billing period is the cycle. It is ten times the monthly figure rather than
twelve because an annual plan costs ten months' price, so the allocation tracks
what you pay rather than how long it lasts.
### Key rules
**"Used" means redeemed, not generated.** An access code is counted against
your quota **when a subscriber redeems it** — not when you generate it. You
can generate as many unredeemed codes as you want; they only start costing
quota (or money) when someone actually activates them.
**No rollover.** Unused quota does **not** carry over to the next billing
cycle — it resets automatically at cycle start.
### Over-the-limit billing
If the number of codes **redeemed** in a cycle exceeds your free allocation, the extra redemptions are subject to your plan's standard **transaction fee** (10 % on Free, 3 % on Starter, 1 % on Growth). These fees are rolled into your next invoice as usage-based billing.
See [Transaction fees](/fees) and the [footnotes on plan limits](/creators/subscription) for the precise billing mechanics.
## Generating codes
See the full flow in [Access codes (creator guide)](/creators/codes). In short:
1. Enable the Access Codes payment method (this page).
2. Go to **Access Codes** in your project.
3. Click **Generate New Access Codes**, pick a plan and quantity.
4. Choose an expiry window.
5. Codes are delivered to you through the connector's bot (as a CSV file for large batches, or individual messages for small ones).
## How subscribers redeem
See [Redeem an access code (subscriber guide)](/subscribers/redeem-access-code).
- **Bot** — send the code as a chat message, or tap the code's join link (each connector's section shows its link format). The code goes in bare, exactly as issued.
- **Portal** — use the **Use Access Code** button; enter the code and click **Redeem**.
## Expiry & renewal reminders
Redeeming a code starts a subscription that runs for the plan's billing period — for example one month or one year. **Access-code subscriptions never auto-renew:** there's no payment method on file, so when the period ends, access ends.
To help subscribers stay ahead of this, the bot sends a **ladder of renewal reminders** as the subscription approaches its end date, then a final notice on expiry:
| When | Message |
| --------------- | ------------------------------------------------------------------------------------- |
| **7 days left** | "expiring in 7 days" — asks the subscriber to redeem a new code |
| **3 days left** | "expiring in 3 days" |
| **2 days left** | "expiring in 2 days" |
| **1 day left** | "expiring in 1 day" |
| **On expiry** | "your subscription has ended" — access is removed, with a prompt to redeem a new code |
- **Each reminder fires at most once** — no duplicate messages for the same milestone.
- Each reminder names the plan and project and says roughly how long is left. Access-code subscriptions are told to **redeem a new access code**; provider-backed (card) subscriptions instead get auto-renew wording.
- To renew a subscriber, generate and hand them a **new** code — redeeming it starts a fresh period.
**Short plans skip long-lead reminders.** A milestone is only sent when the
plan's billing period is longer than that milestone. A **1-day** plan never
sends the 7/3/2/1-day reminders — only the on-expiry notice. A **weekly**
(7-day) plan sends the 3/2/1-day reminders. Monthly and yearly plans get the
full ladder.
**Lifetime codes don't get reminders.** If the plan has no recurring cycle (a
one-off / lifetime plan), the subscription never expires, so no renewal
reminders are sent.
Reminder timing and wording are **standard across all projects** and can't be
customized per project today. Messages are automatically translated to the
subscriber's language.
## Frequently asked
No. Access codes never auto-renew — there's no payment method attached. The
bot sends renewal reminders at 7, 3, 2, and 1 days before expiry (each once,
and only milestones shorter than the plan's period), then removes access on
expiry with a prompt to redeem a new code. Hand the subscriber a fresh code
to start a new period.
No — Access Codes bypass payment gateways. If you want to charge offline (bank
transfer, cash), handle the payment yourself and hand the successful payer a
code. Subscriby doesn't track the offline transaction.
No — access codes are single-use, so issue each person their own. If you want
many people to use one string, that's a [coupon
code](/creators/coupon-codes) — it reduces what they pay rather than
granting access outright.
Yes. Free plans + access codes is the standard pattern for "free membership
for these specific people".
You choose per batch — no expiry, or a custom duration (1 day / 1 week / 1
month / 1 year / custom). Unredeemed codes that expire are auto-deleted and
free up quota.
No — code strings are system-generated to guarantee uniqueness. Regeneration creates new strings.
## Related
- [Access codes (creator guide)](/creators/codes) — generate, export, and distribute.
- [Subscription plans → Access codes only](/creators/plans#access-restrictions-optional) — hide a plan publicly and only reach it via codes.
- [Redeem an access code (subscriber guide)](/subscribers/redeem-access-code) — the other end of the flow.
- [Transaction fees](/fees) — overage fee mechanics.
---
# CeyPay
Source: https://docs.subscriby.net/payments/ceypay
CeyPay is a Sri Lanka-focused crypto payment gateway. Subscribers can pay with **USDT** across five supported blockchains, with settlement options that include **LKR** (Sri Lankan Rupee) for local creators.
## At a glance
| Feature | Support |
| --------------------- | ------------------------------------------------- |
| Primary market | Sri Lanka |
| Recurring billing | ❌ One-time charges only |
| Product sync required | ❌ No |
| Modes | Live / Test |
| Supported currencies | LKR, USDT on BEP20, ERC20, Polygon, Solana, TRC20 |
**CeyPay is one-time only.** Plans connected to CeyPay must have **Recurring =
off**. Crypto payments are irreversible — confirm amounts and addresses
carefully.
## What you'll need
- An approved **CeyPay Merchant** account at [ceypay.io](https://www.ceypay.io/).
- API credentials generated from the CeyPay Dashboard.
## Setup
### Generate API credentials [step]
1. Sign in to your [CeyPay Dashboard](https://www.ceypay.io/).
2. Navigate to **Settings** in the sidebar, then open the **Integrations** tab.
3. Create a new API key.
### Copy the combined key string [step]
CeyPay gives you a single **dot-separated** string — something like:
```
ak_live_abc123def... . sk_live_xyz789...
```
Copy this entire string now. It won't be shown again.
### Split the string into two parts [step]
- The portion **before the dot** is your **API Key** (starts with `ak_live_` or `ak_test_`).
- The portion **after the dot** is your **Secret Key** (starts with `sk_live_` or `sk_test_`).
### Add CeyPay in Subscriby [step]
1. Go to **Payment Methods → Setup a Payment Method**.
2. Pick **CeyPay**.
3. Select **Live** or **Test** matching the environment the credentials belong to.
### Enter credentials [step]
- **API Key** — the first part of the CeyPay string (`ak_live_…` or `ak_test_…`).
- **Secret Key** — the second part (`sk_live_…` or `sk_test_…`).
Subscriby validates both prefixes match the mode you picked.
### Activate [step]
Toggle **Active**, click **Save Changes**.
## Supported currencies
- **LKR** — Sri Lankan Rupee (settlement / display)
- **USDT** on:
- **BEP20** (Binance Smart Chain)
- **ERC20** (Ethereum)
- **POL** (Polygon)
- **SOL** (Solana)
- **TRC20** (Tron — typically the cheapest and fastest)
## The subscriber experience
### Pick the provider [step]
Subscriber picks CeyPay at checkout on either the bot or the web portal.
### Redirect to CeyPay [step]
Redirected to CeyPay for the payment flow.
### Send USDT payment [step]
Picks their chain (BEP20, TRC20, etc.) and sends the exact USDT amount to the generated wallet address.
### Wait for confirmations [step]
Waits for the blockchain to confirm — TRC20 and Solana are typically the fastest.
### Return to Subscriby [step]
Returns to Subscriby with a success result.
## Frequently asked
The merchant side of CeyPay is focused on Sri Lankan creators. Subscribers anywhere in the world can pay in USDT, but merchant onboarding requires Sri Lankan business documentation.
No — crypto payments are always one-time. Attach CeyPay only to plans with
**Recurring = off**.
**TRC20** (Tron) is usually best — low fees, fast confirmations, widely
supported by wallets. **Solana** is similar. **ERC20** (Ethereum) works but
can be expensive due to gas fees. **BEP20** (Binance Smart Chain) is a good
backup.
Crypto is irreversible. Refunds must be coordinated manually — agree with the subscriber on the refund amount and chain, then send USDT back from your wallet.
## Additional resources
- [CeyPay Integration Documentation](https://docs.ceypay.io/)
## Related
- [Choosing a provider](/payments/choosing-a-provider) — compare against alternatives.
- [CoinPayments](/payments/coinpayments) — a global crypto alternative if you're not in Sri Lanka.
- [Currency conversion](/payments/currency-conversion) — how USDT prices are displayed.
- [Transaction fees](/fees) — Subscriby's fees on top.
---
# Choosing a Payment Provider
Source: https://docs.subscriby.net/payments/choosing-a-provider
Nine providers is a lot. This page walks you through a short decision tree so you can pick confidently — and ignore the rest until you need them.
## The two questions that decide everything
### 1. Do you need automatic recurring billing?
- **Yes** → pick one of **Stripe**, **PayPal**, **Paystack**, **Razorpay**. Those four support automatic renewals; the others don't.
- **No** (one-time or lifetime plans only) → any provider works. Favour the provider your audience is most likely to already have.
### 2. Where do your subscribers live?
| Audience | Best provider |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| **Globally distributed, cards preferred** | **Stripe** — broadest coverage (138+ currencies, 42 countries), best conversion on cards, deep Subscriby integration. |
| **Globally distributed, PayPal preferred** | **PayPal** — 200+ countries, trusted brand for buyers who don't enter card details on unfamiliar sites. |
| **India exclusively** | **Razorpay** — UPI, netbanking, Indian cards, wallets. Recurring via UPI AutoPay. |
| **Africa (Nigeria, Ghana, Kenya, South Africa, etc.)** | **Paystack** — local payment methods, NGN/GHS/ZAR/KES support. |
| **Sri Lanka / regional crypto** | **CeyPay** — LKR + USDT across 5 chains. |
| **Global crypto audience** | **CoinPayments** — Bitcoin, USDT, USDC across many chains. |
| **Platform-native, digital goods policy compliance** | **Native payment methods** — frictionless in-app payments where the platform has a currency, meets Apple/Google rules. |
| **Offline sales, giveaways, gifting** | **Access Codes** — no gateway, generate and hand out. |
| **Alternative to PayPal in harder regions** | **Skrill** — 131 countries, good fallback. |
**You can (and should) enable more than one.** Turn on Stripe *plus* PayPal
for a global audience — each subscriber picks whichever they trust more. Add
Razorpay if you have Indian subscribers; add Paystack if you have African
ones. The goal is never to exclude someone from paying.
## Decision tree
- **India** → Razorpay (recurring + UPI) + Stripe (fallback for international cards).
- **Nigeria / Ghana / Kenya / South Africa** → Paystack + Stripe.
- **Sri Lanka** → CeyPay (local crypto) + Stripe (cards).
- **Anywhere else** → Stripe + PayPal.
The platform's **native payment method**, where your connector offers one —
see [Native payment methods](/payments/native-payments). One-tap
checkout, no redirects, no cards, Apple/Google policy-compliant. Pair with
**Access Codes** for manual / gifted subscriptions.
- **Global audience** → CoinPayments (more coins, more chains, global
support). - **Sri Lankan base with LKR settlement** → CeyPay. - **Both** →
enable both; they don't conflict.
Subscriby's own transaction fees don't vary by provider. Subscriber-side fees come from the provider + their bank. In general:
- **Crypto on fast chains** (TRC20, Solana via CoinPayments / CeyPay) → near-zero network fees, but many audiences don't want to deal with crypto.
- **Stripe** → typically 2.9 % + $0.30 per card charge (provider's fee to you).
- **PayPal** → similar, sometimes slightly higher for international transactions.
- **Razorpay / Paystack** → regional, often cheaper in their home markets.
- **Native payment methods** → zero fee on your side, but the platform takes a cut via Apple/Google IAP.
If fees matter most, run the numbers with your real transaction mix. Don't just pick the cheapest — pick the cheapest *that your audience will actually use*.
**Stripe** — it has excellent test-mode with documented test cards, and
Stripe's own dashboard is the clearest for debugging. Set up Stripe in Test
mode, run through a full subscribe flow yourself, confirm everything works,
then duplicate it in Live mode.
Not necessarily. The providers your subscribers actually use depends heavily on audience demographics. Common combos:
- **Global SaaS-style creator** → Stripe + PayPal.
- **Creator active in India + global** → Razorpay + Stripe + PayPal.
- **Creator in Africa + global** → Paystack + Stripe + PayPal.
- **Creator who also wants crypto optionality** → any of the above + CoinPayments.
Enabling more providers doesn't hurt — just keep the checkout list from being overwhelming by disabling providers you don't actually need.
## What about tax and compliance?
Each provider handles its own tax collection (or doesn't) according to local law. This is **provider-level**, not Subscriby-level:
- **Stripe** supports [Stripe Tax](https://stripe.com/tax) for automated sales tax / VAT collection in many regions.
- **Razorpay** handles GST for Indian transactions.
- **Paystack** handles regional taxes where required.
- **CoinPayments and CeyPay** leave tax collection to the creator — crypto doesn't naturally carry a tax layer.
Always consult a local accountant or tax adviser before launching — Subscriby doesn't provide tax advice.
## Ready to set one up?
- [Payment methods setup (creator guide)](/creators/methods) — the end-to-end setup flow, common to all providers.
- Follow the provider-specific guide for the one you chose:
- [Stripe](/payments/stripe) · [PayPal](/payments/paypal) · [Razorpay](/payments/razorpay) · [Paystack](/payments/paystack)
- [Skrill](/payments/skrill) · [CoinPayments](/payments/coinpayments) · [CeyPay](/payments/ceypay)
- [Native payment methods](/payments/native-payments) · [Access Codes](/payments/access-codes)
---
# CoinPayments
Source: https://docs.subscriby.net/payments/coinpayments
CoinPayments lets you accept **Bitcoin** and **USD-based stablecoins** across 18 supported coin variants and blockchains.
## At a glance
| Feature | Support |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Recurring billing | ❌ One-time charges only |
| Product sync required | ✅ Yes — plans sync to CoinPayments |
| Modes | Live / Test |
| Supported coins | Bitcoin (BTC, BTC.BEP20), Ethereum (ETH, ETH.BASE, ETH.BEP20), Litecoin (LTC), USDT (ERC20, BEP20, TRC20, POL, SOL), USDC (ERC20, BEP20, BASE, POL, SOL), TUSD (ERC20, TRC20) |
**Crypto payments are one-time and irreversible.** Plans connected to
CoinPayments must have **Recurring = off**. Once a subscriber sends crypto,
there's no automatic way to refund — refunds have to be coordinated manually
with the subscriber's wallet.
## What you'll need
- A CoinPayments account with API integration created.
- Your **Client ID** and **Client Secret** from that integration.
- Knowledge of which **API domain** your account uses — `a-api` or `b-api`.
## Setup
### Create an integration in CoinPayments [step]
1. Sign in to the [CoinPayments REST API Dashboard](https://a-dashboard.coinpayments.net/).
2. Go to **Settings → Integrations**.
3. Click **Create Integration**.
4. Name it (e.g. _"Subscriby Production"_) and save.
5. CoinPayments shows your **Client ID** and **Client Secret** — copy both.
### Add CoinPayments in Subscriby [step]
1. Go to **Payment Methods** and click **Setup a Payment Method**.
2. Pick **CoinPayments**.
3. Select **Live** or **Test** mode.
### Enter credentials [step]
- **Client ID** — from the integration.
- **Client Secret** — from the integration.
- **API Domain** — `a-api` for most accounts; `b-api` for alternative instances. CoinPayments will tell you which one applies.
### Activate and save [step]
Toggle **Active** on and click **Save Changes**.
### Sync your plans [step]
On the Payment Methods list, click the options menu next to CoinPayments and choose **Sync Subscription Plans**. Subscriby pushes each of your active plans (with Recurring off) to CoinPayments so it can accept payments for them.
## Supported coins and networks
Subscribers can pay in any of these 18 options:
| Coin | Networks |
| ------------------- | ------------------------------------------------ |
| **BTC** (Bitcoin) | Bitcoin mainnet, BEP20 |
| **ETH** (Ethereum) | Ethereum mainnet, Base, BEP20 |
| **LTC** (Litecoin) | Litecoin mainnet |
| **USDT** (Tether) | ERC20, TRC20, BEP20, Polygon (POL), Solana (SOL) |
| **USDC** (USD Coin) | ERC20, BEP20, Base, Polygon (POL), Solana (SOL) |
| **TUSD** (TrueUSD) | ERC20, TRC20 |
Each network has different fees and confirmation times. TRC20 (Tron) and
Solana are typically the fastest and cheapest; ERC20 (Ethereum) is more
expensive but universally supported by wallets.
## The subscriber experience
### Pick the provider [step]
Subscriber picks CoinPayments at checkout on either the bot or the web portal.
### Redirect to CoinPayments [step]
Redirected to CoinPayments' hosted page.
### Choose coin and network [step]
Picks a specific coin / network from the list.
### Review amount and address [step]
Sees a generated wallet address and the exact amount to send (adjusted for the chosen coin's price).
### Send the payment [step]
Sends the payment from their own wallet to the generated address.
### Wait for confirmations [step]
Waits for network confirmations:
- **Bitcoin** typically 2–3 confirmations, ~20–60 minutes.
- **USDT / USDC on TRC20 or Solana** typically seconds to a minute.
- **ERC20 chains** can vary based on gas and network load.
### Return to Subscriby [step]
Once confirmed, returns to Subscriby with a success result.
## Managing refunds
Crypto transactions are **irreversible** — CoinPayments cannot automatically reverse a payment. If you need to refund a subscriber:
1. Agree with the subscriber on the refund amount and wallet address.
2. Send crypto back from your own wallet (or via CoinPayments' outgoing payments feature).
3. Update the subscription's payment status manually if needed.
## Frequently asked
Price your plan in one of CoinPayments' 18 supported coins. CoinPayments doesn't directly handle fiat pricing, so the plan's price is denominated in that coin — at CoinPayments' hosted checkout the subscriber picks which coin to send, and CoinPayments quotes the equivalent amount.
The price still has to clear the **$1.00 USD equivalent** minimum that applies to every plan, converted at the coin's current rate. Because coins are worth far more than a dollar, that floor is a very small number: at roughly $77,000 per BTC it is about `0.000013` BTC. Price to your coin's real precision — Bitcoin carries 8 decimal places and Ether 18 — rather than rounding to two.
No. Each CoinPayments payment requires the subscriber to initiate a transfer
from their wallet — there's no way to auto-debit. Plans attached to
CoinPayments must be one-time or lifetime.
CoinPayments tracks the exact sent amount. If the amount is less than
required, the payment is marked partial and access isn't granted. If it's
more, CoinPayments typically completes the order and the excess sits in your
merchant balance.
Blockchain-dependent. For Bitcoin, allow up to an hour. For stablecoins on fast chains (TRC20, Solana), often under a minute.
## Additional resources
- [CoinPayments API Documentation](https://a-docs.coinpayments.net/api)
## Related
- [Currency conversion](/payments/currency-conversion) — how Subscriby handles crypto-fiat pricing displays.
- [Choosing a provider](/payments/choosing-a-provider) — if you're comparing providers.
- [Transaction fees](/fees) — Subscriby's fees on top.
---
# Currency Conversion
Source: https://docs.subscriby.net/payments/currency-conversion
Subscriby lets you price plans in **any currency** your enabled payment providers support — USD, EUR, INR, crypto, a platform's native currency, and more. When multiple currencies are in play on a single project, Subscriby does the math so your dashboards and fees stay consistent.
## The mental model
Every Subscriby-relevant price can be converted between currencies via **USD as the pivot**. Each active currency has a stored exchange rate relative to USD, and those rates are refreshed automatically on a daily schedule.
When you see a dashboard metric in one currency but a payment was made in another, Subscriby uses the latest cached rate to bring everything to a common denominator.
## How conversions are calculated
### One rule for every currency
The stored rate is **how many units of that currency you get per 1 USD** — fiat, crypto and native platform currencies alike. Converting $10 USD to INR when the rate is `INR per USD = 84` gives `10 × 84 = 840 INR`. Going the other way, you divide.
Crypto is the same rule at a different magnitude. BTC's stored rate is around `0.000013` BTC per USD, so a plan priced at 0.001 BTC is `0.001 ÷ 0.000013 ≈ $77 USD`. Coin rates come from CryptoCompare, quoted in the same per-USD direction as the fiat feed.
USD stablecoins (USDT, USDC, TUSD) sit at approximately `1.0` by definition, which is why they convert roughly 1:1.
This page previously described crypto as storing the coin's USD value, with
the conversion multiplying rather than dividing. That was wrong in both
directions. If you hold integration code that multiplies a crypto rate to
reach USD, it is inverted — divide instead.
### The USD pivot
Every currency conversion inside Subscriby routes through USD as the pivot. To convert EUR → INR, for example, the service goes EUR → USD → INR using the two stored rates. This keeps the rate table small (one rate per currency) and the conversion logic predictable.
### Rate caching
The currency list is cached **fresh for 10 minutes**, then served stale for up
to an hour while it refreshes in the background, so a rate change can appear
sooner than an hour. The rate marquee on the dashboard is a true hourly cache.
Exchange rates themselves are refreshed by a daily background job — so the
rates you see are always current to within the last 24 hours.
## What subscribers actually pay
A subscriber always pays in the **currency set on the plan** — not their local currency, not your base currency.
### If the plan's currency is one the subscriber can pay directly
If your plan is priced at **$10 USD** and the subscriber's payment provider works in USD (or supports multi-currency), they're charged $10 flat. No conversion, no rate, no surprise.
### If conversion happens at the subscriber's bank or provider
If your plan is priced at **10 EUR** and the subscriber pays via a USD card, **their bank** does the EUR → USD conversion and charges them the USD-equivalent at their bank's rate (which usually includes a small markup). If they pay via PayPal with a USD balance, PayPal handles the conversion.
Either way, **Subscriby doesn't add any conversion layer to the charge itself** — that happens downstream at the payment provider or the subscriber's bank.
## Where Subscriby's own conversion kicks in
Subscriby does do its own conversion in two specific places.
### Dashboard aggregates
When you view revenue across projects with mixed currencies, Subscriby converts each amount to a common display currency using its cached rate. That's why numbers across a multi-currency project line up — without it, you'd be adding EUR to USD to INR raw.
### Transaction fee calculation
Your plan's transaction fee is calculated in **your account's currency** (the owner currency), using the rate between the plan's currency and your owner currency. So if you've priced a plan in EUR and your account is billed in USD, Subscriby converts the EUR charge to USD before applying the fee percentage.
## Supported currencies
Subscriby's currency support is driven by what each payment provider accepts. In aggregate across all nine providers:
### Fiat
**140+ fiat currencies** — USD, EUR, GBP, JPY, INR, NGN, GHS, KES, ZAR, CAD, AUD, and dozens more. Stripe alone covers 138+ fiat currencies; Razorpay adds 100+; PayPal covers the major 24; the regional providers add their local currencies on top.
### Crypto
**18 crypto coins and chain variants** — BTC, ETH, LTC, USDT (on ERC20, BEP20, TRC20, POL, SOL), USDC (on ERC20, BEP20, BASE, POL, SOL), TUSD, and more. Supplied by CoinPayments (global) and CeyPay (Sri Lanka).
### Platform currencies
A connector may bring a **native platform currency** of its own. It is processed by the platform itself, only available through the bot, not the portal, and offered as a plan currency only while that connector is connected on the project. Each connector's section names its currency and its rate — see [Native payment methods](/payments/native-payments).
The specific currencies available to a given plan depend on which payment providers you've enabled on that project and which of their supported currencies overlap.
## Common situations
If the plan is priced in a fixed currency (say, USD), the price doesn't change. What *can* change is its **display equivalent** in another currency on the dashboard — and that's driven by exchange rate movement. The subscriber still pays the same underlying amount.
Yes, but the behaviour matters:
- **New sign-ups** — use the new currency from the moment of the change (after you re-run Sync).
- **Existing subscriptions** — each one is attached to a specific provider-side plan/price that was set up at signup. Renewals continue in the **original** currency.
What happens provider-side when you re-run **Sync Subscription Plans** after a currency change:
- **Stripe** — the old product + price are archived (`active: false`) and a fresh product + price are created. Stripe's archive semantics *don't cancel* existing subscriptions on the archived price, so in-flight subscribers continue billing at the original price/currency. New sign-ups land on the new product.
- **PayPal** — the old billing plan is deactivated (`INACTIVE`) and a fresh plan is created. Existing billing agreements on the inactive plan keep billing; only new agreements use the new plan.
- **Razorpay** — Subscriby simply stops referencing the old plan in its pivot and creates a new one. Existing Razorpay subscriptions against the old plan keep running unaffected.
- **CoinPayments** — one-time only, so no renewal concern.
If you genuinely need *all* subscribers on the new currency, the practical path is to cancel existing subscriptions and have subscribers re-subscribe on the new-currency plan. There's no in-place "migrate everyone to a new currency" action.
The rate between the plan's currency and your account's owner currency, at the
time the fee is calculated. Two fees calculated within the same cache window
use the same rate; see the caching note above for how long that lasts.
Not within a single plan. To offer the same product in multiple currencies,
create a plan per currency (e.g. *"Pro USD"*, *"Pro EUR"*, *"Pro INR"*) and
let audience filters or geography guide subscribers to the right one.
For display and reporting, Subscriby falls back to treating the currency as USD-equivalent — no conversion applied. Transaction fees deliberately do **not** do this: an unknown or zero rate means the fee is skipped and recorded as zero rather than metered against a guessed rate, so you are never over-charged from a missing rate. This is a safety net; it doesn't normally happen, but if you're ever suspicious about a number, contact support with the plan ID and the currency and we can check the rate in our system.
## Frequently asked
No — Subscriby's [transaction fee](/fees) is calculated against the amount paid (converted to your owner currency only to apply the percentage). We don't add a conversion margin of our own. Provider-level conversion markups are separate and depend on the provider (Stripe, PayPal, etc.).
Indirectly — yes. Their payment provider will convert from their local
currency to the plan's currency on their end. But from Subscriby's
perspective, every subscription record is tied to the plan's currency.
A background job refreshes rates **daily**. In-app caching holds rates for
about an hour to prevent excessive database reads.
The payment record stores the exact amount and currency at the time of charge. For a historical cross-currency comparison, use your payment provider's own historical data (Stripe, PayPal, Razorpay all export this).
## Related
- [Payment methods](/payments) — provider-by-provider currency support.
- [Transaction fees](/fees) — how conversion affects your fees.
- [Subscription plans → Currency](/creators/plans) — setting the currency on each plan.
---
# Payment Methods
Source: https://docs.subscriby.net/payments
Subscriby supports **nine** payment options. You can enable as many as you like per project, and subscribers see only the ones whose currency matches the plan they're buying.
## Side-by-side comparison
| Provider | Region / focus | Recurring | Currencies | Crypto | Product sync |
| ---------------------------------------------------- | ---------------------- | :-------: | :---------------------: | :----: | :----------: |
| [**Stripe**](/payments/stripe) | Global (42 countries) | ✅ | 138+ fiat | — | ✅ |
| [**PayPal**](/payments/paypal) | Global (200+) | ✅ | 24 fiat | — | ✅ |
| [**Skrill**](/payments/skrill) | Global (131 countries) | ❌ | 42 fiat | — | ❌ |
| [**CoinPayments**](/payments/coinpayments) | Global crypto | ❌ | 18 coins / chains | ✅ | ✅ |
| [**Paystack**](/payments/paystack) | Africa (7 markets) | ✅ | NGN, GHS, ZAR, KES, USD | — | ❌ |
| [**Razorpay**](/payments/razorpay) | India + global | ✅ | 100+ fiat | — | ✅ |
| [**CeyPay**](/payments/ceypay) | Sri Lanka crypto | ❌ | LKR + USDT (5 chains) | ✅ | ❌ |
| [**Native currencies**](/payments/native-payments) | Inside the platform | ❌ | The platform's own | — | ❌ |
| [**Access Codes**](/payments/access-codes) | No gateway | ❌ | Any | — | ❌ |
## Jump to a provider
**42 countries · 138+ currencies · recurring · card / Apple Pay / Google Pay**
The broadest global coverage. Deep Stripe Connect integration — no keys to paste.
**200+ countries · 24 currencies · recurring**
Trusted global brand. Good for audiences allergic to entering card numbers on strange sites.
**131 countries · 42 currencies · one-time only**
Alternative to PayPal. Easier approval in some regions.
**Bitcoin + USD stablecoins · 18 chains · one-time only**
The global crypto option. Supports TRC20, ERC20, BEP20, Polygon, Solana, and more.
**7 African markets · recurring · NGN, GHS, ZAR, KES, USD**
Best-in-class for Nigerian, Ghanaian, Kenyan, South African audiences.
**India-first · 100+ currencies · recurring · UPI / netbanking / cards**
Essential for Indian audiences. UPI AutoPay powers recurring.
**Sri Lanka crypto · USDT across 5 chains · one-time only**
For Sri Lankan creators wanting to accept USDT with LKR settlement.
**Platform-native · the platform's own currency · one-time only · bot only (no portal)**
Frictionless in-app purchases. Meets Apple / Google digital-goods policies.
**No gateway · any currency · single-use**
Hand-out codes for offline sales, giveaways, or gated plans.
## Key rules you should know up front
**You can enable many providers per project.** Subscribers see each provider
as an option at checkout — the more you enable, the more likely they find one
they trust.
**Recurring vs. one-time.** Only Stripe, PayPal, Paystack, and Razorpay
support automatic recurring billing. Skrill, CoinPayments, CeyPay, native
platform currencies, and Access Codes are one-time only. If you need
recurring, pick one of the first four.
**The portal vs. the bot.** The web portal supports every provider **except**
a platform's native currency (processed by the platform itself) and Access
Codes (has its own dedicated Use Access Code button). The bot supports all nine. Tailor your
sharing strategy accordingly.
## Don't know where to start?
Head to [Choosing a payment provider](/payments/choosing-a-provider) for a decision-tree walkthrough based on your subscribers' location, the currency you want to charge in, and whether you need recurring billing.
## Related
- [Payment methods setup (creator guide)](/creators/methods) — end-to-end setup across providers.
- [Currency conversion](/payments/currency-conversion) — how multi-currency pricing and display works.
- [Transaction fees](/fees) — how Subscriby calculates its per-transaction cut.
---
# Native Payment Methods
Source: https://docs.subscriby.net/payments/native-payments
Most payment methods are gateways: a member taps **Subscribe**, opens a checkout page, and comes back. Some platforms have a payment of their own inside their app, settled with the store credentials a member already has on their phone. A connector that supports it brings that method to Subscriby as a **native payment method**.
## How a native method behaves
- **It belongs to the connector.** The connector's **Built-in payments** switch, on the project's Connectors page under the connector's Configuration tab, decides whether the method is on sale. Switched off, it leaves the payment-method picker and the plan currency list, no new method of that kind can be added, and the bot stops offering an existing one at checkout. Sales already made and their subscriptions are untouched; switching it back on offers it again.
- **It is priced in the platform's own currency.** A plan sold through a native method is priced in that currency, and Subscriby converts the value to USD for your fees at the rate the platform publishes. See [Transaction fees](/fees).
- **It is a one-time charge.** Platforms do not run recurring billing through their in-app currencies, so plans priced in one are lifetime or fixed-length purchases, never renewing ones.
- **It lives on the platform, not on the portal.** The member pays inside the platform's app, so the [portal](/subscribers/portal) filters native methods out of its picker. A plan priced only in a platform currency shows no payment method on the portal; share the bot link for those plans instead. See [Sharing your project](/creators/share).
- **Live only.** There is no test mode; test with a small amount on your own account.
## Enabling one
Open **Payment Methods → Setup a Payment Method** and pick the native method the connector offers. There are no credentials to enter: the connector is already connected. Toggle **Active** and save.
Each native method has an **exclusive** option, which hides every other method inside the bot so the only way to pay in the chat is the platform's own. The portal is unaffected by it, so members who prefer a card can still pay there.
**Payouts follow the platform's rules, not ours.** Before enabling a native
method, read how its platform pays you out (some pay in cryptocurrency and
hold funds for a period) and check that you may receive it where you live.
## Per connector
Telegram's native currency is **Telegram Stars (XTR)**, paid in one or two taps with Apple or Google in-app purchase credentials, worth about $0.013 a Star, and withdrawn through Fragment in TON after a holding period. Pricing, the exclusive-Stars option, the member's checkout screens and the fee maths are on [Telegram Stars](/connectors/telegram/stars).
## Related
- [Choosing a provider](/payments/choosing-a-provider) — native methods beside the gateways.
- [Transaction fees](/fees) — how fees are calculated on a platform currency.
- [Making payments (for members)](/subscribers/making-payments) — what a native checkout looks like for the member.
---
# PayPal
Source: https://docs.subscriby.net/payments/paypal
PayPal is a globally recognised consumer-trust brand. It reaches **200+ countries and territories** and supports **automatic recurring billing** for subscriptions.
## At a glance
| Feature | Support |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Countries | 200+ |
| Recurring billing | ✅ Yes |
| Product sync required | ✅ Yes — plans sync to PayPal |
| Modes | Live / Sandbox |
| Supported currencies | 24 (AUD, BRL, CAD, CNY, CZK, DKK, EUR, HKD, HUF, ILS, JPY, MYR, MXN, TWD, NZD, NOK, PHP, PLN, GBP, SGD, SEK, CHF, THB, USD) |
## What you'll need
- A verified **PayPal Business** account (not a personal account).
- Access to the [PayPal Developer Dashboard](https://developer.paypal.com/developer/applications) — linked automatically when you sign in with your PayPal Business credentials.
## Setup
### Create an app in the PayPal Developer Dashboard [step]
1. Go to the [PayPal Developer Dashboard](https://developer.paypal.com/developer/applications).
2. Sign in with your PayPal Business account.
3. Click **Create App**, name it (e.g. _"Subscriby Production"_), and save.
### Pick an environment and copy credentials [step]
Toggle to the environment you need:
- **Sandbox** — for testing with PayPal's test accounts.
- **Live** — for production.
Copy:
- **Client ID** — visible by default.
- **Secret** — click **Show** to reveal.
Keep the **Secret** secure. Don't paste it in public channels, screenshots, or
code repositories. Treat it like a password.
You don't need to create a PayPal webhook manually. Subscriby registers it for
you the first time you save the payment method — listing your existing PayPal
webhooks, reusing one that already points at Subscriby, or creating a new one
if none match. The Webhook ID is then stored against the payment method so
Subscriby can verify the cryptographic signature on every event PayPal sends.
Every delivery is verified against PayPal before it is acted on, in Sandbox as
well as Live; one that cannot be verified is refused and PayPal retries it.
Event types Subscriby does not act on, such as `CHECKOUT.ORDER.APPROVED` and
`PAYMENT.CAPTURE.COMPLETED` from one-time orders (those settle when the buyer
returns), are acknowledged and ignored.
### Add PayPal in Subscriby [step]
1. Go to **Payment Methods → Setup a Payment Method**.
2. Pick **PayPal**.
3. Select **Live** or **Test** / **Sandbox** to match the environment your keys came from.
### Enter credentials [step]
- **Client ID** — from PayPal.
- **Secret** — from PayPal.
### Activate and save [step]
Toggle **Active**, then click **Save Changes**. Subscriby uses your credentials to register the PayPal webhook automatically; if registration fails the payment method is left **Inactive** and the project owner gets an email explaining what to fix.
### Sync your subscription plans [step]
On the Payment Methods list, open PayPal's options menu (⋯) and choose **Sync Subscription Plans**. Subscriby pushes your recurring plans to PayPal as subscription plans so auto-renewals work.
## The subscriber experience
### Pick the provider [step]
Subscriber picks PayPal at checkout on either the bot or the web portal.
### Redirect to PayPal [step]
Redirected to PayPal's hosted page with plan and price pre-filled.
### Sign in or pay as guest [step]
Signs in to their PayPal account or pays as a guest with a card (PayPal allows this in many regions).
### Confirm the purchase [step]
Reviews and confirms the purchase (and the recurring agreement, for recurring plans).
### Return to Subscriby [step]
Returns to Subscriby on success.
For recurring plans, PayPal maintains the **billing agreement** on their side — they automatically charge at each cycle and notify Subscriby via webhook.
## Managing refunds, disputes, payouts
All happen on the PayPal side.
### Refunds
Issue them via your PayPal account's **Activity** view — find the transaction and click Refund. Subscriby receives the webhook and marks the payment **Refunded** within a few minutes.
### Disputes
PayPal notifies you via email when a subscriber disputes a charge. Resolve through PayPal's **Resolution Center**. As with Stripe, Subscriby's per-subscription activity timeline can provide evidence (signup date, access log) to include in your response.
### Payouts
PayPal holds your collected balance and lets you transfer to your linked bank account on your own schedule. Configure transfer preferences in your PayPal account settings.
## Frequently asked
No. PayPal's subscription APIs require a Business account. Upgrade your personal account to Business if you haven't — it's free and keeps your transaction history.
AUD, BRL, CAD, CNY, CZK, DKK, EUR, HKD, HUF, ILS, JPY, MYR, MXN, TWD, NZD,
NOK, PHP, PLN, GBP, SGD, SEK, CHF, THB, USD — 24 major currencies.
Yes — subscribers can cancel the billing agreement from their PayPal account.
If they do, PayPal's webhook notifies Subscriby and the subscription is marked
Canceled. Always prefer [cancelling from
Subscriby](/subscribers/cancel-subscription) for a clean experience, but
the PayPal-side path also works.
1. Your PayPal API credentials match the mode (Live keys for Live mode,
Sandbox for Sandbox). 2. Your PayPal Business account is verified. 3. The
plans you're syncing are **Active** and marked **Recurring**. 4. Your PayPal
account has Subscriptions enabled (most accounts have this by default).
Yes — either edit the existing method's mode and paste Live keys, or create a second PayPal entry for Live and leave Sandbox for testing. Running both in parallel is often cleaner.
## Additional resources
- [PayPal Developer Dashboard](https://developer.paypal.com/developer/applications)
- [PayPal REST API Documentation](https://developer.paypal.com/docs/api/overview/)
- [PayPal Subscriptions API](https://developer.paypal.com/docs/subscriptions/)
## Related
- [Choosing a provider](/payments/choosing-a-provider) — if you're comparing.
- [Payment methods](/creators/methods) — general setup flow.
- [Transaction fees](/fees) — Subscriby's fees stacking with PayPal's.
---
# Paystack
Source: https://docs.subscriby.net/payments/paystack
Paystack is a payment gateway focused on African markets — subscribers can pay via **card**, **bank transfer**, **USSD**, and **mobile money** depending on their country.
## At a glance
| Feature | Support |
| --------------------- | ---------------------------------------------------------------- |
| Countries | 7 African markets |
| Recurring billing | ✅ Yes (requires Subscriptions enabled on your Paystack account) |
| Product sync required | ❌ No |
| Modes | Live / Test |
| Supported currencies | NGN, GHS, ZAR, KES, USD |
## What you'll need
- A verified **Paystack merchant account**.
- Your **Public Key** (starts with `pk_test_` or `pk_live_`).
- Your **Secret Key** (starts with `sk_test_` or `sk_live_`).
- **Subscriptions enabled** inside your Paystack Dashboard (if you plan to offer recurring plans).
## Setup
### Get your Paystack API keys [step]
1. Sign in to your [Paystack Dashboard](https://dashboard.paystack.com/).
2. Go to **Settings → API Keys & Webhooks**.
3. Copy your **Public Key** and **Secret Key** for the appropriate environment (Test or Live). Click **Show** on the Secret Key if hidden.
### Enable Subscriptions in Paystack [step]
_(required for recurring plans)._
1. In the Paystack Dashboard, go to **Settings → Preferences**.
2. Turn on **Subscriptions**.
Without this, Paystack won't execute automatic renewals. One-time plans still
work either way, but recurring plans need this flipped on.
### Add Paystack in Subscriby [step]
1. Go to **Payment Methods → Setup a Payment Method**.
2. Pick **Paystack**.
3. Select **Live** (or **Test** / **Sandbox**) — the mode must match the keys you copied.
### Enter credentials [step]
- **API Key** — your Paystack Public Key (`pk_live_…` or `pk_test_…`). Subscriby validates the prefix matches the mode.
- **API Secret** — your Paystack Secret Key (`sk_live_…` or `sk_test_…`).
### Activate and save [step]
Toggle **Active**, click **Save Changes**.
## Supported currencies
Paystack supports a narrow, regionally-focused set of currencies:
- **NGN** — Nigerian Naira
- **GHS** — Ghanaian Cedi
- **ZAR** — South African Rand
- **KES** — Kenyan Shilling
- **USD** — US Dollar (available for cross-border transactions in supported markets)
Pricing plans in any of these currencies and attaching Paystack means subscribers in the supported markets can pay via locally-preferred methods.
## The subscriber experience
### Pick the provider [step]
Subscriber picks Paystack at checkout on either the bot or the web portal.
### Redirect to Paystack [step]
Redirected to Paystack's hosted checkout page.
### Choose local payment method [step]
Chooses their local payment method (card, bank transfer, USSD, mobile money — options depend on country).
### Complete and return [step]
Completes the payment and returns to Subscriby.
For recurring plans, Paystack automatically charges on the next cycle using the payment instrument saved during the first charge.
## Frequently asked
Paystack is strongest inside its home markets. For subscribers elsewhere, pair Paystack with **Stripe** or **PayPal** — Subscriby happily shows multiple providers at checkout and the subscriber picks their preferred one.
Confirm you've set the plan's **currency** to GHS and that your Paystack
account supports that currency (some Paystack accounts are single-currency).
In Paystack's dashboard, you can request multi-currency support if needed.
Paystack occasionally rotates key formats. The prefix `sk_live_` or `sk_test_`
should always be the first few characters. If yours doesn't match, regenerate
the key from the dashboard and retry.
A plan sync isn't needed for Paystack (Subscriby doesn't push products to Paystack), but Paystack's own subscription settings can take a few minutes to propagate. If the issue persists, contact Paystack support — they sometimes need to enable recurring capabilities on a per-merchant basis.
## Additional resources
- [Paystack API Documentation](https://paystack.com/docs/api/)
- [Paystack supported currencies](https://paystack.com/docs/payments/currencies/)
## Related
- [Choosing a provider](/payments/choosing-a-provider) — if you want to compare options.
- [Payment methods](/creators/methods) — general setup flow.
- [Transaction fees](/fees) — Subscriby's fees on top.
---
# Razorpay
Source: https://docs.subscriby.net/payments/razorpay
Razorpay is India's leading payment gateway, supporting **UPI**, **netbanking**, **cards**, **wallets** (Paytm, PhonePe, etc.), and over 80 international currencies for cross-border sales.
## At a glance
| Feature | Support |
| --------------------- | ------------------------------------------------------------ |
| Primary market | India |
| Recurring billing | ✅ Yes |
| Product sync required | ✅ Yes — plans sync to Razorpay |
| Modes | Live / Test |
| Supported currencies | 100+ (INR, USD, EUR, GBP, SGD, AED, AUD, CAD, and many more) |
## What you'll need
- A verified **Razorpay merchant account** (KYC completed).
- Your **Key ID** (starts with `rzp_test_` or `rzp_live_`).
- Your **Key Secret** (shown once, then hidden).
## Setup
### Generate API keys in Razorpay [step]
1. Sign in to your [Razorpay Dashboard](https://dashboard.razorpay.com/).
2. Go to **Settings → API Keys**.
3. Click **Generate API Keys** (or **Regenerate Live / Test Keys** if you already have them).
### Copy credentials immediately [step]
Razorpay shows the **Key ID** (e.g. `rzp_live_abc123…`) and the **Key Secret** only once.
**The Key Secret is only displayed at generation time.** If you close the
dialog without copying, you'll need to regenerate — which invalidates the
previous secret and may disrupt existing integrations.
### Add Razorpay in Subscriby [step]
1. Go to **Payment Methods → Setup a Payment Method**.
2. Pick **Razorpay**.
3. Select **Live** or **Test** to match your keys.
### Enter credentials [step]
- **Key ID** — `rzp_live_…` or `rzp_test_…`. Subscriby validates the prefix matches the mode.
- **Key Secret** — the paired secret.
### Activate and save [step]
Toggle **Active**, click **Save Changes**.
### Sync your subscription plans [step]
On the Payment Methods list, open Razorpay's options menu and click **Sync Subscription Plans**. Subscriby pushes each of your active plans to Razorpay so they can be used for recurring billing.
## The subscriber experience
### Pick the provider [step]
Subscriber picks Razorpay at checkout on either the bot or the web portal.
### Redirect to Razorpay [step]
Redirected to Razorpay's India-focused checkout.
### Choose a payment method [step]
Picks from **UPI** (Google Pay, PhonePe, Paytm, BHIM), **netbanking** (most Indian banks), **credit/debit card**, or **wallets**.
### Authorize the charge [step]
Authorizes the charge — UPI typically with a push notification to the bank app; cards with OTP or 3D Secure.
### Return to Subscriby [step]
Returns to Subscriby on success.
For recurring plans, Razorpay handles the auto-debit mandate (e-Mandate for Indian cards, UPI AutoPay, or standing instructions) — once set up, future cycles charge automatically.
## Frequently asked
Razorpay's sweet spot is India — that's where UPI, local netbanking, and wallets pay off. Razorpay also supports 100+ international currencies, so non-Indian subscribers can pay cards through it — but for a purely global audience, Stripe or PayPal typically have better coverage and conversion.
Not really — regenerating invalidates the current secret immediately. Before
regenerating, make sure you have a window to paste the new secret into
Subscriby quickly to minimise disruption.
Check:
1. The Razorpay plan itself appears in your Razorpay Dashboard under **Subscriptions → Plans**.
2. The subscriber's payment instrument supports e-Mandate or UPI AutoPay — some cards / banks don't.
3. There hasn't been a mandate cancellation from the subscriber's bank.
Razorpay's dashboard has a subscription-specific view that shows the last charge attempt and why it failed, if so.
Refunds are issued from the Razorpay Dashboard. Once processed there, Subscriby picks up the refund status via webhook and updates the subscription's payment status to **Refunded**.
## Additional resources
- [Razorpay API Documentation](https://razorpay.com/docs/api/)
- [Razorpay Subscriptions guide](https://razorpay.com/docs/payments/subscriptions/)
## Related
- [Choosing a provider](/payments/choosing-a-provider) — compare against alternatives.
- [Payment methods](/creators/methods) — general setup flow.
- [Transaction fees](/fees) — Subscriby's fees on top.
---
# Skrill
Source: https://docs.subscriby.net/payments/skrill
Skrill is an e-wallet and alternative-payment provider supporting **131 countries and territories**. Subscribers can pay via their Skrill account or a card.
## At a glance
| Feature | Support |
| --------------------- | -------------------------------------------------------------------- |
| Countries | 131 countries & territories |
| Recurring billing | ❌ One-time charges only |
| Product sync required | ❌ No |
| Modes | Live / Test |
| Supported currencies | 42 (USD, EUR, GBP, AUD, CHF, INR, BRL, MXN, SGD, ZAR, and many more) |
**Skrill supports one-time charges only** in this integration. Plans connected
to Skrill must have **Recurring = off**. Subscribers who complete a Skrill
payment get access for the plan's duration; to continue, they pay again
manually.
## What you'll need
- A verified **Skrill merchant account** (not a personal Skrill account).
- Your merchant **email address** — the one registered with Skrill.
- A **Secret Word** you set in Skrill's developer settings.
## Setup
### Configure the Secret Word in Skrill [step]
1. Sign in at [skrill.com](https://www.skrill.com/).
2. Go to **Settings → Developer Settings → API / MQI / GSR / CVT Management**.
3. Scroll to **Secret Word** and enter a strong password-like string (8+ characters, with at least one uppercase, lowercase, and number).
4. Save.
### Add Skrill as a payment method in Subscriby [step]
1. Go to **Payment Methods** and click **Setup a Payment Method**.
2. Pick **Skrill** from the provider list.
3. Select **Live** (or **Test** / **Sandbox** if Skrill provides one for your account).
### Enter your credentials [step]
- **Email** — your Skrill merchant email.
- **Secret Word** — the secret word you configured.
### Activate and save [step]
Toggle **Mark Payment Method as Active**, then click **Save Changes**.
## The subscriber experience
### Pick the provider [step]
Subscriber picks Skrill at checkout on either the bot or the web portal.
### Redirect to Skrill [step]
They're redirected to Skrill's hosted payment page.
### Sign in or pay as guest [step]
They sign in to Skrill or pay as a guest with a card.
### Confirm the amount [step]
They confirm the amount in their chosen currency.
### Return to Subscriby [step]
Skrill processes the payment and redirects them back to Subscriby.
### Subscription activates [step]
Their subscription activates immediately on a successful charge.
## Managing payouts and disputes
- **Payouts** — handled inside your Skrill merchant dashboard on Skrill's schedule.
- **Refunds** — issued through your Skrill dashboard. Subscriby updates the subscription's payment status to **Refunded** via webhook.
## Frequently asked
No. Attach Skrill only to plans with **Recurring = off**. Subscriby will block you from saving a recurring plan that has Skrill as its payment method.
The 42 currencies Skrill supports include USD, EUR, GBP, AUD, CAD, BRL, MXN,
INR, SGD, ZAR, PLN, SEK, NOK, DKK, CHF, HKD, THB, TWD, MYR, NZD, AED,
SAR, KWD, BHD, QAR, OMR, JOD, RON, HUF, BGN, CZK, KRW, ILS, ISK, KES, MAD,
TND, CLP, COP, CRC, PEN, RSD.
Yes. Unlike a platform's native currency, Skrill payments happen in the browser via a
redirect, so the portal supports it normally.
Double-check:
- The **mode** matches your credentials (Live credentials for Live mode, Test for Test).
- The **Secret Word** in Skrill matches what you entered in Subscriby.
- The **merchant email** is the one associated with your Skrill merchant account (not a personal Skrill email).
## Additional resources
- [Skrill API Documentation](https://developer-psd2.skrill.com)
## Related
- [Choosing a provider](/payments/choosing-a-provider) — if you're still comparing options.
- [Payment methods](/creators/methods) — general setup flow.
- [Transaction fees](/fees) — Subscriby's fees on top of Skrill's.
---
# Stripe
Source: https://docs.subscriby.net/payments/stripe
Stripe is Subscriby's flagship payment provider. It offers the deepest integration (via **Stripe Connect**) and the broadest currency / country coverage of any provider on the platform.
## At a glance
| Feature | Support |
| --------------------- | ------------------------------------------------------------------------------------ |
| Countries | 42 |
| Recurring billing | ✅ Yes |
| Product sync required | ✅ Yes — plans sync to Stripe as products + prices |
| Direct charge | ✅ Yes (Stripe is the only provider with this) |
| Modes | Live / Test |
| Supported currencies | 138+ fiat currencies including USD, EUR, GBP, JPY, INR, BRL, CAD, AUD, and many more |
**Stripe uses Stripe Connect — you won't paste API keys.** Unlike every other
provider, Stripe setup redirects you through Stripe's own onboarding flow;
Subscriby receives credentials automatically when you complete it.
## Why Stripe is the default pick for most creators
- **Widest acceptance** — 138+ currencies and direct-charge support mean almost any subscriber, anywhere, can pay.
- **Recurring built in** — every Stripe account supports recurring out of the box.
- **Direct-charge fees** — Subscriby **automatically deducts transaction fees from incoming payments** when using Stripe. You don't receive a separate usage invoice for Stripe-processed fees.
- **Best-in-class developer tooling** — if something goes wrong, Stripe's dashboard tells you exactly why, with real transaction IDs.
## Setup
### Add Stripe as a payment method [step]
1. In your Subscriby dashboard, go to **Payment Methods**.
2. Click **Setup a Payment Method**.
3. Pick **Stripe**.
### Choose a mode [step]
- **Live** — for processing real payments.
- **Test** (Sandbox) — for using Stripe's test cards without moving real money.
You can run Live and Test side-by-side as two separate payment methods.
### Click Connect Stripe to Subscriby [step]
Subscriby redirects you to Stripe's hosted onboarding flow.
### Complete Stripe Connect onboarding [step]
- Sign in with an existing Stripe account or create a new one.
- Follow Stripe's prompts — business details, bank account, identity verification. Stripe may ask for SSN (US), ID cards, or other region-specific verification depending on your country.
- Authorize Subscriby to receive payments on your behalf.
### Return to Subscriby [step]
Once Stripe finishes onboarding, it redirects you back to Subscriby with the connection confirmed. You'll see the Stripe payment method marked active in your Payment Methods list.
The Stripe entry shows one of three states:
- **Linked & Yes** — connected and able to accept charges. Subscribers can pay.
- **Charges Disabled** — connected, but Stripe currently can't accept charges (verification still pending or a requirement is due). Finish what Stripe asks for in your Dashboard; Subscriby re-checks with Stripe automatically the next time a payment is attempted, so the badge flips back on its own once Stripe enables charges.
- **Linking Incomplete** — onboarding wasn't finished. Resume via the ⋯ menu → **Continue Linking Stripe Connect**.
### Sync your plans to Stripe [step]
On the Payment Methods list, open the options menu (⋯) next to Stripe and choose **Sync Subscription Plans**. Subscriby pushes your plans to Stripe as products + prices so they can be used for checkout and recurring billing. You only need this once, right after linking Stripe: from then on every plan that is on sale is pushed to Stripe on its own when you create, save or publish it. A draft is pushed the moment you publish it, so publish a plan before you share its link.
**If you didn't finish Stripe Connect in one go** — common when Stripe asks for verification documents you don't have on hand — you can resume:
1. In Subscriby **Payment Methods**, find the Stripe entry.
2. Click the options menu (⋯).
3. Choose **Continue Linking Stripe Connect**.
You'll be redirected back to Stripe to pick up where you left off.
**You can't receive payments until Stripe verifies your account** and
**charges are enabled**. Check your Stripe Dashboard under **Settings →
Business → Verification** if payments aren't flowing — Stripe will show
exactly what's pending.
## How transaction fees work with Stripe
Because Stripe is integrated deeply via Stripe Connect, Subscriby applies its transaction fee as a **direct deduction at the time of charge**. You never see a separate "Subscriby usage invoice" for payments processed through Stripe — the fee is already taken.
For other providers, transaction fees are tracked and invoiced periodically as usage-based billing. See [Transaction fees](/fees) for the full mechanics.
## The subscriber experience
### Pick the provider [step]
Subscriber picks Stripe at checkout on either the bot or the web portal.
### Redirect to Stripe Checkout [step]
Redirected to a **Stripe-hosted Checkout** page (not a Subscriby page).
### Enter payment details [step]
Enters card details, name, and email. Apple Pay / Google Pay may also be offered depending on device and region.
### Pass 3D Secure check [step]
On larger charges, Stripe may prompt for 3D Secure verification from the subscriber's bank.
### Return to Subscriby [step]
Payment is processed; subscriber returns to Subscriby with a success screen and their access to every place the plan unlocks.
For recurring plans, Stripe stores the payment method securely (PCI scope stays with Stripe, not you) and automatically charges on each billing cycle.
## Managing payouts, disputes, refunds
All happen in your Stripe Dashboard at **[dashboard.stripe.com](https://dashboard.stripe.com/)**.
### Payouts
Stripe deposits your collected funds to your bank account on its configured schedule — typically 2–7 days from charge to bank depending on your country and Stripe account age. You can adjust the payout schedule from the Stripe Dashboard.
### Disputes and chargebacks
Stripe notifies you via email when a subscriber disputes a charge. Respond through the Stripe Dashboard with evidence (invoice, subscription record, access log) — Subscriby's activity timeline provides useful context you can export.
### Refunds
Issue refunds through the Stripe Dashboard. Webhooks push the status back to Subscriby automatically, and the subscription's payment shows as **Refunded** within minutes.
## Frequently asked
Yes. Stripe Connect links your existing account — you don't need a new one.
Yes, from your Stripe Dashboard under **Settings → Connected Accounts**,
revoke Subscriby. From Subscriby you can delete or deactivate the Stripe
payment method at any time. Existing subscribers on Stripe would lose
recurring renewals — coordinate beforehand if you have live customers.
Stripe supports 138+ currencies including USD, EUR, GBP, JPY, CNY, INR, BRL,
MXN, CAD, AUD, NZD, SGD, HKD, THB, TWD, KRW, ZAR, SEK, NOK, DKK, CHF, PLN,
HUF, CZK, ILS, NGN, GHS, KES, EGP, AED, SAR, TRY, and many more.
Stripe's test card numbers (e.g. `4242 4242 4242 4242` for success) work in
Test mode. Reference: [Stripe Testing Docs](https://stripe.com/docs/testing).
One per mode, per project:
- ✅ **One Live** Stripe payment method
- ✅ **One Test** Stripe payment method (alongside the Live one)
- ❌ **Two Live** Stripe accounts on a single project — not allowed
Need two separate Live Stripe accounts? Split them into two Subscriby projects.
## Additional resources
- [Stripe Dashboard](https://dashboard.stripe.com/)
- [Stripe Testing guide](https://stripe.com/docs/testing)
- [Stripe supported currencies](https://stripe.com/docs/currencies)
## Related
- [Choosing a provider](/payments/choosing-a-provider) — if you want to compare.
- [Payment methods](/creators/methods) — general setup across providers.
- [Transaction fees](/fees) — how Stripe + Subscriby fees stack.
---
# Common Questions
Source: https://docs.subscriby.net/questions
## General & Setup
**No.** Subscriby features a **Zero-Code Configuration**. You can connect payment gateways and set up subscription plans in minutes using our visual dashboard.
**Yes.** You can monetize Private Channels (for content broadcast) and Groups
(for community discussion) individually or bundle them into a single
subscription tier. One plan can grant access to multiple resources.
**Absolutely.** We employ **Bank-Grade Security** with industry-standard SSL
encryption and secure tokenization. We screen every transaction for fraud and
do not store sensitive payment card data on our servers.
We support creators globally, handling over **172 currencies**. Whether you
are in New York, London, or Colombo, customers can pay in their native
currency.
Connectors decide that. The [Connectors Marketplace](/connectors/marketplace) lists what is live today; Discord is under development, and Slack, WhatsApp and seven more are on the public [roadmap](/connectors/roadmap). A project installs connectors, so a new platform arrives as an install on the project you already have, and each available connector has its own section in this guide.
## Payments & Revenue
**Never.** Subscriby facilitates **Instant Automated Settlements**. Funds flow directly from the customer to your connected Stripe, PayPal, or Crypto wallet. We do not impose holding periods.
**Yes.** We integrate with **CoinPayments** to accept Bitcoin, Ethereum, and
other major cryptocurrencies. Note that recurring billing is not typically
supported for crypto transactions.
**Yes.** You can use **Access Codes** to grant subscription benefits for
offline payments (e.g., bank transfers or cash). Support for verifying direct
bank transfers is coming soon.
We integrate with trusted gateways including **Stripe, PayPal, Skrill,
CoinPayments, Razorpay, Paystack and CeyPay**, and a connector may add its
platform's own [native payment](/payments/native-payments) beside them.
The system is fully automated. If a subscription expires or payment fails repeatedly, the bot automatically revokes the user's access. No manual intervention is required.
## Features & Growth
**Yes.** You can configure free trials to drive conversions, including **Card-on-File** trials (auto-charge upon expiry) where supported by the gateway.
**Yes.** Unrecognized messages sent to the bot are treated as support tickets
and forwarded to your linked support group. You can reply directly through the
bot, keeping your identity private.
**Yes.** You can broadcast messages to "Leads"—users who interacted with the
bot but didn't complete a purchase.
Our **Web Dashboard** offers real-time insights into revenue, subscriber
growth, and **Churn Analysis** to help optimize retention.
**Yes.** You can download **CFO-Ready Exports** of financial and subscriber data for accounting and tax purposes.
## Pricing & Plans
**Yes.** Our **Free Plan** has **$0 monthly fees**. You only pay a transaction fee when you make a sale.
The Free Plan fee is 10%. Upgrading to **Starter** (3%) or **Growth** (1%)
plans significantly reduces this cost as you scale.
**Yes — 7 days, on both the monthly and annual cycle.** You get the plan's full
feature set immediately, and your card is collected up front but not charged
until the trial ends.
One thing to know: during the trial your sales stay on the **Free tier's 10%
rate**, not the plan's 1%–3%. The trial buys the plan's features; its cheaper
commission starts the moment your first payment is received. See [Transaction
Fees](/fees#during-your-free-trial).
The trial is available once per account.
Upgrading reduces transaction fees, which can save substantial revenue as your
volume grows. Use the Revenue Calculator on our homepage to find your
break-even point.
**Yes.** You can upgrade, downgrade, or cancel at any time. There are no long-term lock-in contracts.
---
# Quickstart Guide
Source: https://docs.subscriby.net/quickstart
By the end of this page, you'll have a live Subscriby project with a connected connector, at least one payment method enabled, and one subscription plan ready to accept your first paying member.
This quickstart follows the **exact onboarding order** you'll see on the
dashboard's onboarding checklist. Each step links to deeper docs if you want
the full picture.
## Before you start
You need:
- An **account on the platform your community lives on**, in good standing.
- The **email and password** you want to use for your Subscriby creator account.
- A **Stripe account** (or willingness to create one) — for the simplest setup.
- A **private channel, group or server** you want to monetize (or be ready to create one).
- ~10 minutes.
## Walkthrough
### Create an account
#### Open the registration page [step]
Go to `/register` (or click **Sign up for free** on `/login`).
#### Sign up with your email, or continue with a connected platform [step]
Enter your **name** (5+ characters), your **email**, set a **password** (8+ characters, mix of upper/lower/numbers, screened against breach databases), confirm it, and click **Create Account**. Or press the **Continue with …** button of a connector to start from your account on that platform: it pre-fills your name and links the account on the way in.
**Continue with Telegram** approves the sign-up through [@TrySubscribyBot](https://t.me/TrySubscribyBot); Telegram shares your public profile and redirects you back to the registration page. See [Signing in with Telegram](/connectors/telegram/sign-in).
#### Verify your email [step]
Open the verification email and click the link. Sign in at `/login` with email + password, or with **Continue with …** again — same account either way. The dashboard's onboarding checklist marks **Create an Account** complete.
_Details: [Creating an account](/account/sign-up)._
### Activate a subscription
#### Open Plans & Billing [step]
You'll land on the **Plans & Billing** page right after first sign-in. (Or open it yourself at `/billing/plans`.)
#### Subscribe to a tier [step]
Click **Subscribe to Free**, or **Try Free for 7 Days** on a paid tier to start its trial.
Note that a trial gives you the paid tier's _features_, not its lower fee: your sales stay on the Free tier's **10% rate** during the trial, dropping to your plan's rate once the first payment is received. See [Transaction Fees](/fees#during-your-free-trial).
#### Complete Stripe checkout [step]
Complete the Stripe-hosted checkout — add a payment method. **Even Free plans need a card on file** because per-transaction fees apply regardless of tier. On a trial the card is collected but nothing is charged until the trial ends.
_Details: [Activate your subscription](/creators/subscription)._
### Create a project
#### Open the create-project form [step]
From the dashboard, click **+ New Project** (or **Create New Project** on the All Projects page).
#### Fill the project fields [step]
Fill in:
- **Name** — your brand / community name. 5+ characters.
- **Handle** (optional, Starter+) — URL slug for the portal, e.g. `my-community`.
- **Description** (optional) — the greeting new members read when they open your bot.
Click **Save Changes**.
_Details: [Creating a project](/creators/projects)._
### Install and connect a connector
A **connector** is the link between Subscriby and the platform your community lives on. Installing one gives your project a **bot** on that platform; connecting it hands Subscriby the credentials to run the bot on your behalf.
#### Open the project's Connectors page [step]
From the project, open **Connectors**. The onboarding checklist's **Install Now** button takes you to the same place and highlights what to press.
#### Install the connector [step]
Click **Browse Marketplace**, pick the connector for your platform and press **Install**. It appears in the project's list as **Pending**.
#### Connect it [step]
On the connector's row, open the **⋯** menu and choose **Connect**. The dialog walks you through creating your bot on the platform and asks for its credential; paste it and click **Connect**. The row turns **Connected**, the project header gains **Open Bot**, and the checklist marks the step done.
The credential is a bot token from [@BotFather](https://t.me/BotFather): send `/newbot`, name the bot, pick a `@username` ending in `bot`, copy the **HTTP API Token** it sends you and paste it into the dialog. Step by step, with the BotFather tuning worth doing afterwards: [Connecting your Telegram bot](/connectors/telegram/connecting).
_Details: [Connectors](/creators/connectors)._
### Add a resource
A **resource** is what subscribers unlock when they pay — a place on your connected platform (a private channel, a group, a server) or a manual perk (like a Google Drive folder or a 1:1 call).
Now that your connector is connected, the places it gates can be linked
directly through it. This walkthrough demos the quicker **Manual Perk** path
instead — see [Resources → Linking a place](/creators/resources#linking-a-place)
if you want to link a real place now.
#### Open Project Resources [step]
From the project, open **Project Resources** and click **Create New Resource**.
#### Pick Manual [step]
Select **Manual**.
#### Fill the resource fields [step]
Fill in:
- **Title** — e.g. _"Welcome PDF"_ or _"Onboarding Notion"_.
- **Description** — free-form text with basic HTML allowed. Paste the URL, voucher, or instructions you want subscribers to see.
- **Active** — toggle on.
Click **Save Changes**. The resource appears in the list — onboarding Step 5 is complete.
_Details: [Resources](/creators/resources)._
### Set up a payment method
#### Open Payment Methods [step]
In the project, go to **Payment Methods → Setup a Payment Method**.
#### Pick Stripe [step]
Pick **Stripe**. Select **Live** (or **Test** for sandbox experimentation).
#### Connect to Stripe [step]
Click **Connect Stripe to Subscriby**. You're redirected to Stripe's onboarding flow.
#### Authorize Subscriby on Stripe [step]
Sign in or create a Stripe account. Complete the required verification. Authorize Subscriby.
#### Activate the method [step]
You're redirected back to Subscriby. Toggle the method **Active** if needed.
_Details: [Stripe setup](/payments/stripe). Alternatives: [PayPal](/payments/paypal), [Razorpay](/payments/razorpay), [Paystack](/payments/paystack), or the [native payment](/payments/native-payments) your connector brings if you want a no-keys approach._
### Add a subscription plan
#### Open Create New Subscription Plan [step]
Go to **Subscription Plans → Create New Subscription Plan**.
#### Fill the plan fields [step]
Fill in:
- **Plan Name** — e.g. _"Pro Monthly"_.
- **Description** — optional rich-text pitch.
- **Linked Resources** — tick the resource from Step 5.
- **Currency** — USD (or your preferred currency).
- **Price** — e.g. `9.99`.
- **Billing Cycle** — Monthly, count 1.
- **Recurring** — On.
Toggle **Active**, click **Create Subscription Plan**.
#### Sync plans to Stripe [step]
On the **Payment Methods** page, open Stripe's options menu (⋯) and choose **Sync Subscription Plans**. Subscriby pushes your plan to Stripe as a product + price so recurring billing works.
_Details: [Subscription plans](/creators/plans)._
### Generate access codes
Access codes are alphanumeric keys subscribers can redeem in place of paying through a gateway — useful for offline sales, giveaways, influencer campaigns, or team access.
#### Enable the Access Codes method [step]
Make sure **Access Codes** is enabled as a payment method: **Payment Methods** → toggle **Access Codes** on. No credentials required.
#### Open Generate New Access Codes [step]
Go to **Access Codes → Generate New Access Codes**.
#### Fill the batch settings [step]
Pick the subscription plan from Step 7, set a quantity (up to 1,000 per batch), choose an expiry, tick consent, and click **Generate Codes**. Subscriby sends you the codes through your linked account's bot once they're ready.
_Details: [Access codes](/creators/codes)._
### Share your bot link
#### Copy the bot link [step]
Copy your project's link from the project flyout's **Public Link** (**All Projects → Actions → View Project**), or click **Open Bot** in the project header and copy the address. It opens your bot on its platform.
#### Post it in your channels [step]
Share it in your existing audience channels — social media bios, newsletters, podcast show notes, the community you already run.
#### Test the subscribe flow [step]
Test the flow yourself: open the link from a second account on the platform, start the bot, pick your plan, and complete the Stripe checkout (use a [Stripe test card](/payments/stripe#frequently-asked) if you're in Test mode). On success the bot sends you your access — use it to see what new subscribers see.
### Share your portal page
If you set a **handle** in Step 3, your project also has a public web portal at `https://my.subscriby.net/your-handle` — subscribers who prefer a browser can browse plans and sign up from there.
#### Copy the portal URL [step]
Copy the portal URL from your project.
#### Post it alongside the bot link [step]
Add it to your website, link-in-bio, email signature, or marketing materials alongside the bot link.
_Details: [Sharing your project](/creators/share)._
Congratulations — your Subscriby project is live and ready to take paying members. The exact same flow now works for everyone else you share the bot link or portal URL with.
## What's next
- **Secure your account** — set up [two-factor auth](/account/two-factor-auth) or a [passkey](/account/passkeys).
- **Add more payment methods** so subscribers in different regions can pay their preferred way — see [Choosing a provider](/payments/choosing-a-provider).
- **Watch your dashboard** as subscribers roll in — see [Dashboard analytics](/creators/dashboard-analytics).
- **Invite team members** (Growth plan) — see [Teams & Roles](/teams).
If something goes wrong: see [Troubleshooting](/reference/troubleshooting).
---
# Glossary
Source: https://docs.subscriby.net/reference/glossary
Subscriby terminology in one place. Bookmark this if you're new or onboarding a team.
## A
**Ability string**
A fine-grained permission token carried by a Sanctum personal access token and checked at every API / MCP call. The format is `:` (e.g. `project-subscription-plan:create`). See the [ability catalog](/api/v1/abilities).
**Access code**
An alphanumeric string you generate and hand out that a subscriber can redeem to activate a specific plan without paying. See [Access codes (creator guide)](/creators/codes).
**Active** (subscription)
A subscription that's paid up and currently granting access. One of the [subscription statuses](/reference/status-codes).
**Actor kind**
Metadata on every activity-log row identifying the surface that took an action: `human`, `api_token`, `mcp`, `zapier`, `webhook`, or `system`. Lets you filter "show me everything Claude did yesterday".
**Addon**
A single feature bought on top of your creator plan, billed on the same invoice and the same cycle. Currently the **Passes Addon** — $19 / mo, or $190 / yr on an annual plan — which unlocks Time-Limited Passes on Free and Starter. Distinct from the subscriber-facing add-ons at your own checkout. See [Addons](/addons).
**Admin command**
A bot command available only to the project owner, such as checking or banning a member. Each connector lists its own — see [Connectors](/connectors).
**API token**
See [Personal access token (PAT)](#p).
## B
**Bearer token**
Authorization header format used by REST API and MCP — `Authorization: Bearer sbt_live__` (production) or `sbt_test__` (non-production). See [API authentication](/api/v1/authentication).
**Banned** (member)
A member status indicating the person has been blocked from your project — typically via the `/ban` admin command. Banned members can't subscribe or interact with the bot.
**Billing cycle**
How often a recurring subscription charges: Days, Weeks, Months, Years, or Lifetime. Combined with a _billing cycle count_ (e.g. "every 3 Months").
**Bot link**
A URL that opens a conversation with your project's bot on its platform. See [Sharing your project](/creators/share).
## C
**Canceled** (subscription)
A subscription the subscriber or creator has cancelled. Access typically continues until the current paid period ends, then becomes _Expired_.
**Churned** (member)
A member whose subscription has ended (cancelled, expired, or lapsed from payment failure).
**Creator**
An individual or business running a Subscriby project. They have full access to the creator dashboard and their project's bot. Distinct from _subscriber_.
## D
**Dead letter**
The terminal state for a webhook delivery that failed every retry. Lives in the delivery log so you can manually replay once the downstream service is healthy. See [retries & delivery](/webhooks/v1/retries-and-delivery).
**Deep-link**
A URL-encoded action that opens a bot and performs an instruction — like auto-redeeming an access code or jumping straight to a specific plan. See [Sharing your project](/creators/share); each connector's section shows its link format.
**Direct charge**
A payment-processing model where Subscriby's transaction fee is automatically deducted from the charge at payment time. Currently only supported with Stripe.
**Dual-signing**
Rotating a webhook secret while Subscriby continues to sign deliveries with both the old (`v0`) and new (`v1`) secrets for 24 hours, so consumers can roll out the new secret without downtime.
## E
**Event ID**
ULID attached to every webhook delivery, exposed in both the payload and the `SB-Event-Id` header. Use for consumer-side de-dup on retries.
**Expired** (subscription)
A subscription that reached its cycle-end date and stopped granting access. Can result from cancellation, payment failure, or a one-time plan running out.
## F
**Free plan** (creator)
Subscriby's no-cost entry tier: up to 3 projects, 5,000 lifetime memberships, 5 access codes per cycle, 10 % transaction fee. See [Activate your subscription](/creators/subscription).
## G
**Group** (in Teams)
An ad-hoc collection of team members with shared permissions. See [Groups](/teams/groups).
**Growth plan**
Subscriby's highest standard creator plan: unlimited lifetime memberships, teams support, Time-Limited Passes included, 1 % transaction fee. See [Activate your subscription](/creators/subscription).
## H
**Handle**
A URL-friendly identifier for your project's public portal (e.g. `my-community` → `my.subscriby.net/my-community`). Starter-plan-and-above feature.
**HMAC signature**
Hash-based message authentication carried in the `SB-Signature` header on every outbound webhook delivery. SHA-256 over `.` using the endpoint secret. See [signature verification](/webhooks/v1/signature-verification).
**Horizon queue**
The named queue a job runs on. Subscriby ships three: `default`, `webhooks`, `mcp`.
## I
**Idempotency key**
A UUID the caller attaches to every write request on the REST API so retries are safe. See [idempotency](/api/v1/idempotency).
## L
**Lead** (member)
A member status: interacted with your bot but hasn't subscribed yet.
**Lifetime** (billing cycle)
A one-time payment that grants access forever, no renewal.
**Lifetime membership**
A subscription on a lifetime plan — pay once, keep access permanently. The only member category that counts against your plan's users limit.
**Live** (mode)
A payment-method configuration pointing to production credentials — real money moves. Opposite of _Test_ / _Sandbox_.
## M
**Magic link**
A one-time URL sent via email that signs a subscriber in to the portal without a password. Valid 15 minutes, single-use. See [Subscriber portal sign-in](/subscribers/portal#signing-in).
**MCP server**
Model Context Protocol server Subscriby exposes at `mcp.subscriby.net` so LLMs like Claude can drive the product. See [MCP overview](/mcp/v1).
**MCP tool**
A single callable operation an MCP client can invoke (e.g. `list_projects`). Every tool enforces one [ability string](/api/v1/abilities).
**MCP resource**
A read-only catalog an MCP client can fetch (e.g. `subscriby://enums/subscription-status`).
**Member**
Used loosely: any person on your project's members list (Leads, Trialing, Customers, Churned, Banned). Don't confuse with _team member_.
**Mode** (payment method)
Whether a payment method is Live (real transactions) or Test (sandbox).
## N
**Newcomers-only**
A plan filter that hides the plan from anyone who's subscribed before. Typical use: introductory offers.
## O
**One-time**
A plan (or subscription) where the subscriber pays once for a fixed duration. Access ends when the duration lapses — no auto-renewal.
## P
**Passkey**
A FIDO2-certified cryptographic credential that replaces passwords. See [Passkeys (creator account)](/account/passkeys).
**Personal access token (PAT)**
A long-lived credential minted from Settings → API Tokens. Starts with `sbt_`. Carries a list of [ability strings](/api/v1/abilities) plus a `scope:team:` tuple.
**Payment method**
A configured payment provider on a project. You can enable multiple per project.
**Plan**
Short for _subscription plan_ — a pricing tier configurable with name, price, billing cycle, resources, trial rules, and eligibility filters. See [Subscription plans](/creators/plans).
**Platform currency**
A currency native to a connector's platform, processed by the platform itself and offered only inside its bot while that connector is connected. Each connector's section names its own. See [Native payment methods](/payments/native-payments).
**Portal**
The web-based public face of a project, at `my.subscriby.net/{handle}`. Where subscribers can browse plans and manage memberships outside the platform. See [Via Web Portal](/subscribers/portal).
**Product sync**
The action of pushing your plans to a payment provider's own catalogue (Stripe products, PayPal plans, etc.) so the provider can handle recurring billing. Required for Stripe, PayPal, CoinPayments, and Razorpay.
**Project**
The top-level container for a membership business: plans, payment methods, resources, subscribers, bot — all live inside one project. A creator can run multiple.
## R
**Rate limit bucket**
A named per-token quota (e.g. `api-token`, `mcp`) applied to a group of API or MCP calls. See [rate limiting](/api/v1/rate-limiting).
**Recurring**
A subscription that auto-renews at each cycle. Opposite of _one-time_.
**Replay window**
The 5-minute freshness check Subscriby recommends consumers apply to inbound webhooks: reject deliveries whose `t=` timestamp is more than 300 seconds off from now. See [security](/webhooks/v1/security).
**Resource**
Something a plan unlocks — a place a connector gates (a channel, a group, a server) or a manually-tracked digital good. See [Resources](/creators/resources).
**Role** (in Teams)
A named bundle of permissions applied 1:1 to team members. See [Roles](/teams/roles).
## S
**Sanctum**
Laravel's first-party token authentication package. Subscriby mints personal access tokens backed by Sanctum's `personal_access_tokens` table.
**Sandbox**
Alternative name for _Test_ mode on payment methods. Real credentials aren't used; no real money moves.
**Schema.org**
The vocabulary Subscriby embeds on `my.subscriby.net/{handle}` as JSON-LD so search engines and LLM-driven buyer agents can parse offerings (Organization, Product, Offer, SubscribeAction).
**Scope tuple**
A non-ability entry in a token's abilities array that constrains where it can act. Two variants: `scope:team:` (required) and `scope:project:` (optional).
**Secret rotation**
Replacing a webhook endpoint's signing secret. See [webhook security](/webhooks/v1/security).
**Single-use** (plan filter)
A plan that a given subscriber can buy only once. After their first subscription, the plan hides from them.
**Stripe Connect**
Stripe's mechanism for hosted onboarding of connected accounts — what Subscriby uses to onboard Stripe without you ever pasting API keys.
**Subscriber**
An end customer of a creator — they pay to join a project. Distinct from _creator_ and from _team member_.
**Subscription**
The record of a subscriber's active (or past) purchase on a plan. See [Managing subscriptions (creator)](/creators/managing-subscriptions) or [Manage your subscription (subscriber)](/subscribers/manage-subscription).
## T
**Team**
A container for collaborators on a creator account. Growth plan and above. See [Teams & Roles](/teams).
**Team member**
A collaborator you've invited into your team. Distinct from _subscriber_ / _project user_.
**Tenant scope**
Laravel global scope that transparently filters every query to the authenticated user's `current_team_id`. See [tenancy & scopes](/api/v1/tenancy-and-scopes).
**Test** (mode)
A payment-method configuration using sandbox credentials for testing. Opposite of _Live_.
**Transaction fee**
Subscriby's per-transaction cut on payments processed through your project. Varies by creator plan (10 % Free / 3 % Starter / 1 % Growth). See [Transaction fees](/fees).
**Trial**
A period of access without payment. Can be _cardless_ (no payment method collected) or _card-required_ (charged when trial ends). See [Trial memberships](/subscribers/trial-memberships).
**Trial type**
Either "Once for project" (one trial across any plan) or "Once for plan" (one trial per plan).
**Trialing** (status)
A subscriber currently inside a trial period.
## U
**Unlimited** (plan limit)
Shown as `-1` in plan data. The Growth plan's lifetime-membership cap is unlimited.
**Users limit**
The creator plan's cap on _lifetime-membership_ subscribers. Normal recurring subscribers don't count toward this limit — see the plan footnotes on the [creator subscription page](/creators/subscription).
## V
**Verified** (email)
An email address the subscriber has confirmed via a verification link. Required before it can be used for magic-link sign-in.
## W
**Webhook** (inbound)
A URL a platform (or a payment provider) pushes events to. Subscriby receives webhooks from each connector's platform for bot events and from payment providers for billing events — all handled automatically. See [Connectors](/connectors).
**Webhook** (outbound)
A URL Subscriby posts events to on your infrastructure. Configured in **Settings → Webhooks**. See [outbound webhooks](/webhooks/v1).
**Webhook delivery**
A single POST attempt from Subscriby to a webhook endpoint, with status (`pending` / `delivered` / `failed` / `dead`) and retry metadata.
## Z
**Zapier Zap**
A workflow inside Zapier that combines a trigger (e.g. Subscriby `subscription.created`) with one or more actions. See [Zapier integration](/integrations/zapier).
---
# Status Reference
Source: https://docs.subscriby.net/reference/status-codes
A colour-coded pill on a list row is worth a thousand words — but only if you know what it means. This page is the reference for every status badge and enum value across the Subscriby dashboard.
## Subscription statuses (13 total)
The `SubscriptionStatus` enum appears on the [Managing Subscriptions](/creators/managing-subscriptions) page and on subscriber-facing views.
| Status | Badge colour | Meaning | Typical next step |
| -------------- | ------------ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| **Trialing** | yellow | Inside a plan's trial period; no real charge has occurred. | Convert to Active at trial end (card-required) or let it expire (cardless). |
| **Active** | green | Currently paid and granting access. | Renew on cycle end. |
| **Incomplete** | orange | Payment didn't complete in the provider's expected window. | Manual follow-up — the subscriber may need to retry the charge. |
| **Expired** | red | Billing cycle ended; access has stopped. | Resubscribe if desired. |
| **Past Due** | red | A renewal charge failed; retry window is open. | Update payment method in the provider's dashboard. |
| **Canceled** | zinc | The subscriber or creator cancelled; access continues until cycle end. | No action — will flip to Expired automatically. |
| **Unpaid** | red | Payment failed and retries are exhausted. | Subscription lapses; resubscribe to restore access. |
| **Paused** | yellow | Temporarily paused by you or the provider. | Unpause to resume billing. |
| **Processing** | blue | Payment is being processed by the provider. | Wait — status refreshes when the provider finishes. |
| **Succeeded** | green | Payment completed successfully. | Bot activates access automatically. |
| **Capturable** | blue | Payment is authorized but not yet captured. | Capture (usually automatic). |
| **Failed** | red | Payment outright failed (declined, fraud hold, etc.). | Subscriber retries or picks a different method. |
| **Pending** | blue | Payment submitted, awaiting confirmation. | Wait for provider webhook (fiat: minutes; crypto: up to an hour). |
## Member statuses (5 total)
The `ProjectUserStatus` enum appears on the [Managing Members](/creators/managing-members) page.
| Status | Badge colour | Meaning |
| ------------ | ------------ | --------------------------------------------------------------- |
| **Lead** | zinc | Interacted with your bot but hasn't subscribed or trialled yet. |
| **Trialing** | yellow | Inside a trial period of a plan. |
| **Customer** | green | Has an active paid subscription. |
| **Churned** | red | Previously Customer or Trialing; no longer has active access. |
| **Banned** | black | Explicitly banned via `/ban` admin command or equivalent. |
### Transitions
- **Lead → Trialing** — start a trial.
- **Lead / Trialing → Customer** — complete a paid subscription.
- **Customer / Trialing → Churned** — cancellation reaches cycle end, or payment failure exhausts retries.
- **Any → Banned** — admin action.
Status changes are automatic and driven by the subscription lifecycle — you don't change a member's status directly from the Members page.
## Payment statuses (used inside Payment History)
The `ProjectSubscriptionPaymentStatus` enum appears on the portal's **Payment history** rows.
| Status | Badge colour | Meaning |
| -------------- | ------------ | ------------------------------------------------- |
| **Successful** | green | The payment completed and settled. |
| **Pending** | amber | Submitted to the provider, awaiting confirmation. |
| **Failed** | red | The payment did not go through. |
| **Refunded** | zinc | The creator (or provider) refunded the payment. |
## Payment provider modes
From the `PaymentProviderMode` enum:
| Mode | Meaning |
| ---------------------- | ----------------------------------------------------------------------------------------------------------- |
| **Live** | Production — real money moves. |
| **Test** / **Sandbox** | No real money; use provider-specific test credentials. Note: native platform currencies and Access Codes are Live-only. |
## Billing cycles
From the `SubscriptionBillingCycle` enum, on plans:
| Cycle | Meaning |
| ------------ | ---------------------------------------------------------------------------- |
| **Days** | Billed every N days (e.g. every 7 Days). |
| **Weeks** | Billed every N weeks. |
| **Months** | Billed every N months. |
| **Years** | Billed every N years. |
| **Lifetime** | One-time payment, permanent access. Cycle count is locked to 1 for Lifetime. |
## Trial mode types
From the `TrialModeType` enum:
| Type | Meaning |
| ----------- | ----------------------------------------------------------------------------------------- |
| **Project** | A subscriber gets one trial across _any_ plan in the project. |
| **Plan** | A subscriber can trial this plan once, even if they trialled a different plan previously. |
## Resource kinds
A resource's **kind** is `manual`, or one of the kinds the project's connectors declare, written as `connector:kind`:
| Kind | Meaning | Creatable from dashboard? |
| ----------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| **Manual** | A free-form placeholder you fulfil yourself. | ✅ Yes |
| **A connector's kinds** | A place the connector gates — a channel, a group, a server, depending on the platform. | ❌ Linked through the connector once its bot holds the rights it needs. |
Each connector's section lists the kinds it gates.
## Connector status
Every connector in the [marketplace](/connectors/marketplace) sits in one lane:
| Status | Meaning |
| --------------------- | ----------------------------------------------------------------------------- |
| **Available Now** | Installable on any project today. |
| **Experimental** | Installable, but early: expect rough edges and fast changes. |
| **Paused** | Switched off during an incident; existing installations keep their data. |
| **Under Development** | The package exists but is not enabled yet. |
| **Coming Soon** | Planned; the card carries an ETA and a **Notify me** form. |
## API error codes
Every REST API or MCP error carries a machine-readable `error.code`. The canonical list is published at `https://api.subscriby.net/error-codes.json` and on the [errors](/api/v1/errors) page. Condensed here for quick reference:
| Code | HTTP | Meaning |
| -------------------------------- | ---- | ------------------------------------------------------------------------- |
| `VALIDATION_FAILED` | 422 | Request body didn't pass validation rules. |
| `AUTHENTICATION_REQUIRED` | 401 | Missing or invalid bearer token. |
| `TOKEN_MISSING_ABILITY` | 403 | Token lacks the ability the endpoint requires. |
| `TENANT_MISMATCH` | 404 | Resource exists but is out of the token's team scope. |
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist. |
| `PLAN_CURRENCY_MISMATCH` | 400 | Plan currency doesn't match the project's payment method currency. |
| `PLAN_PROVIDER_UNSUPPORTED` | 400 | Plan configuration isn't supported by the selected provider. |
| `PAYMENT_METHOD_NOT_CONFIGURED` | 400 | Project has no active payment method matching the request. |
| `SUBSCRIPTION_ALREADY_ACTIVE` | 400 | Subscriber already has an active subscription. |
| `ACCESS_CODE_INVALID` | 400 | Access code doesn't exist. |
| `ACCESS_CODE_EXPIRED` | 400 | Access code is past its expiry window. |
| `ACCESS_CODE_ALREADY_REDEEMED` | 400 | Access code has already been consumed. |
| `ACCESS_CODE_CAP_EXCEEDED` | 400 | Project has exhausted its plan's access-code allowance. |
| `IDEMPOTENCY_KEY_MISSING` | 400 | Write request lacks the `Idempotency-Key` header. |
| `IDEMPOTENCY_KEY_REUSED` | 409 | Key reused with a different request body. |
| `IDEMPOTENCY_REPLAY_IN_PROGRESS` | 425 | Concurrent request with the same key is still executing. |
| `RATE_LIMITED` | 429 | Rate limit bucket exhausted. |
| `TEAM_TIER_REQUIRED` | 403 | Operation requires a higher Subscriby creator tier. |
| `FORBIDDEN` | 403 | Resource is visible but only its creator or the team owner may change it. |
| `WEBHOOK_ENDPOINT_UNREACHABLE` | 502 | Target URL is not reachable during a `test` fire. |
| `INTERNAL_SERVER_ERROR` | 500 | Uncaught server-side failure. |
## Related
- [Managing subscriptions](/creators/managing-subscriptions) — where most of these statuses appear.
- [Managing members](/creators/managing-members) — member statuses.
- [Troubleshooting](/reference/troubleshooting) — what to do when a status looks wrong.
- [Glossary](/reference/glossary) — every Subscriby term.
---
# Troubleshooting
Source: https://docs.subscriby.net/reference/troubleshooting
When something's wrong, start here. This page covers the issues we hear about most often, across creator and subscriber paths. Topic-specific troubleshooting lives inside each section — this page is the triage layer.
**Not seeing your issue?** Check the topic-specific troubleshooting:
[subscriber-specific issues](/subscribers/troubleshooting) (payment
failures, bot access, portal sign-in); [signing in
(creator)](/account/sign-in#what-if-something-goes-wrong) (password /
2FA / passkey trouble); [if you can't sign
in](/account/account-recovery) (full recovery options); your
[connector's section](/connectors) (bot-level behaviour).
## Sign-in & account issues
Try in this order:
1. Sign in with a connected platform — when your creator account has one linked, this bypasses the password.
2. Use a passkey if you registered one on the device you're on.
3. Use **Forgot your password?** to reset via email.
4. Use a 2FA recovery code on the `/two-factor` challenge screen.
5. Still stuck? [Contact support](#when-to-contact-support) with your email and the handle of the account you linked.
Detailed paths: [If you can't sign in](/account/account-recovery).
Sign out and sign in again — the banner should clear. If it persists, [contact
support](#when-to-contact-support).
The platform's app is logged into a different account than the one tied to your Subscriby creator account. Switch accounts there and retry.
## Bot issues
Check each in order:
1. **Project is Active** on your dashboard (toggle in project settings).
2. **Credentials are current** — if you recently rotated the bot's token on its platform, disconnect the connector and connect it again with the new one from the project's Connectors page.
3. **The bot may read messages** — some platforms hide group messages from bots by default; your connector's section names the setting to turn off.
4. **Bot has admin rights** in all the places it's supposed to manage.
5. **Only one webhook** is registered on the bot — if another service has overwritten ours, reconnect the bot from Subscriby.
Wait a minute — the bot delivers invite links right after payment confirmation. If they still don't arrive:
- The subscriber can tap **🔄 Refresh Invite Links** on the invite links message, or send `/my_resources` to retrieve them manually.
- Check the payment actually succeeded on your [Managing subscriptions](/creators/managing-subscriptions) page.
If a paid purchase produced **no** links at all, you are messaged about it automatically —
see the next entry.
The bot sends this when a purchase went through but not one invite link could be created.
The payment is unaffected; only provisioning failed.
Nearly always it is bot permissions on the destination. Check, on the platform, that your bot
is still an **administrator** of each linked place **and** holds the right to invite members
by link — the second is usually a separate toggle and is the one usually missing. See
[Resources](/creators/resources) for the rights each connector needs.
Once fixed, the subscriber taps **🔄 Refresh Invite Links** and receives them. You do not
need to re-issue anything, and you should not cancel and re-create the subscription.
Plans whose resources are all **manual** never trigger this: there are no links to issue,
and the subscriber is told you will arrange access directly.
Possible causes:
1. Their subscription expired or was cancelled and the grace period passed.
2. The bot lost admin rights in that specific resource.
3. Your project was temporarily deactivated.
4. A periodic consistency check found a mismatch — open the member's [Activity timeline](/creators/managing-members#view-details-flyout) to see the exact reason logged.
## Payment issues
1. Give it 5–10 minutes for fiat providers, up to 60 minutes for crypto. Webhooks sometimes lag.
2. Check the subscription's **payment status** on [Managing subscriptions](/creators/managing-subscriptions) — if it's **Pending** or **Processing**, the provider hasn't confirmed yet.
3. If the provider's dashboard shows the payment as successful but Subscriby doesn't reflect it, the webhook to Subscriby failed — [contact support](#when-to-contact-support) with the provider's transaction ID so we can reconcile.
The provider retries according to its own policy. During that window the subscription shows as **Past Due** and access continues. If retries exhaust, status goes to **Unpaid**, then **Expired**, and the subscriber is removed from resources.
Fix paths:
- **Subscriber updates their payment method** in the provider's own customer portal (Stripe, PayPal, Razorpay) — the retry then succeeds.
- **Cancel & resubscribe** — the cleanest path if updating isn't working.
Common causes:
1. **Mode mismatch** — Live credentials in Test mode (or vice versa). Verify the prefix: `sk_live_` / `pk_live_` / `rzp_live_` for Live, `sk_test_` / etc. for Test.
2. **Copy-paste whitespace** — leading or trailing spaces in the key trip validation.
3. **Revoked keys** — the provider rotated or revoked your credentials. Regenerate in the provider dashboard and paste the new ones.
4. **Account not verified** — the provider won't accept API traffic until your merchant account completes KYC / verification.
Open the plan in your dashboard and check:
1. The plan is **Active**.
2. The plan's **currency** is supported by that provider (see [Currency support](/payments#side-by-side-comparison)).
3. The provider's credentials are valid (try saving them again).
4. For PayPal: your PayPal Business account has Subscriptions enabled.
5. For Paystack: Subscriptions is enabled in Settings → Preferences.
Logs for each sync attempt live server-side; if you need them, contact support with the plan ID and provider.
## Subscription flow issues
The cancel action is only available for subscriptions whose status is **Active** or **Trialing** and that aren't already cancelled. If the subscription is already **Canceled**, **Expired**, or **Past Due**, the button is hidden.
If it's visible but tapping it does nothing: check the subscriber's rate-limit. Portal cancellation is limited to 3 attempts per 10 minutes — tell them to wait and retry.
Plans have audience filters (Newcomers-only, Customers-only, Churned-only,
Single-use, Access-codes-only) that filter the plan list per subscriber. See
[Subscription plans](/creators/plans#access-restrictions-optional).
Look at their [Payment History](/subscribers/portal#my-memberships) inside the subscription's detail page. If two successful payments exist:
1. If one is a duplicate or pending hold, wait a few days — providers release pending holds.
2. If both are confirmed successful, refund one via the provider's dashboard.
## Portal issues
The project's handle has changed, the project was deleted, or the project was deactivated. Check the project's settings on your creator dashboard.
Most common reasons:
1. **Email not verified** — the subscriber needs to verify their email first by signing in another way (their platform account / Google) and confirming the email.
2. **Email typo** — the subscriber typed a different address. Portal silently discards sign-in requests for unknown emails (by design, to prevent enumeration).
3. **Rate-limited** — 3 magic-link requests per hour per email + IP combo. Wait and retry.
4. **Link expired** — links are valid for 15 minutes; send a new one.
Two conditions both need to be true:
1. The creator has enabled Google OAuth on their project.
2. The subscriber has previously linked their Google account from Account & Recovery.
Either missing and the Google button stays hidden. See [Via Web Portal → Account & Recovery](/subscribers/portal#account--recovery).
## Admin / creator dashboard issues
Dashboard refreshes every 5 minutes. A few seconds of lag is normal. If numbers are stale for longer:
- Click **Reset Filters** — you may have an accidental filter narrowing to an empty set.
- Verify the correct project is selected (top-left picker).
- Widen the period window (try Last 90 days).
Check:
1. Did they **accept** the invitation? Pending invitations show on the Team Members page.
2. Is their **role** granting them project-view permissions? See [Abilities](/teams/abilities).
3. Is your account on **Growth** or higher? Teams is gated — if you've downgraded, previously-invited members lose access.
Two prerequisites:
1. **Bot is connected** on the project.
2. **Access Codes payment method is enabled** in Payment Methods.
Either missing and the generate modal is replaced with a prompt to set up the missing prerequisite.
## Agent-readiness (API, MCP, webhooks, Zapier)
The bearer token is missing, expired, or revoked. Mint a fresh one in **Settings → API Tokens** and update the client. Tokens default to 90-day expiry.
Your token doesn't carry the ability this endpoint enforces. The error body
includes `required_ability`. Mint a new token with that ability ticked. See
[ability catalog](/api/v1/abilities).
Add an `Idempotency-Key` header (UUID) on every POST / PATCH / DELETE. See
[idempotency](/api/v1/idempotency).
Token missing the tool's ability, config file malformed, or client needs a
full restart. See [MCP troubleshooting](/mcp/v1/troubleshooting).
Endpoints auto-disable after 20 consecutive failures. Inspect the delivery
log, fix the downstream issue, then re-enable from **Settings → Webhooks**.
See [retries & delivery](/webhooks/v1/retries-and-delivery).
Common causes: using the parsed JSON body instead of the raw bytes, using the
wrong secret (check for trailing whitespace), or the timestamp check rejecting
stale deliveries. See [signature
verification](/webhooks/v1/signature-verification).
The webhook endpoint auto-disabled after 20 consecutive failures in the Zap's
handler. Re-enable the endpoint in Subscriby and fix the downstream handler.
See [testing & debugging](/webhooks/v1/testing-and-debugging).
You've hit the `api-token` bucket (300/min, 10k/hr). Honour `Retry-After` and
slow down. For a one-off large import, email support — we can temporarily
raise the bucket. See [rate limiting](/api/v1/rate-limiting).
Name the project explicitly at the start of the conversation ("Work on project
`research-premium` for the rest of this conversation") so the agent doesn't
drift across turns. See [prompting guide](/mcp/v1/prompting-guide).
The spec is regenerated on every deploy via `scramble:cache`. If the live spec
is stale, a deploy is probably still in-flight. Hit the URL again in ~60
seconds.
Revocation is immediate on the server — but Sanctum's query cache is
request-scoped, so a long-running request started just before revocation can
complete. Subsequent calls return 401.
Verify the endpoint is signed: all outbound webhooks include `SB-Signature`, `SB-Event-Id`, and `SB-Event-Name`. If the header is missing, the request isn't from Subscriby — reject it.
## When to contact support
Reach out to `support@subscriby.net` for:
- Security incidents (suspected account compromise, unusual activity).
- Data requests (GDPR / CCPA subject requests, account deletion of subscribers, historical exports).
- Bugs — especially reproducible ones you can describe with exact steps.
- Provider-side reconciliation — when a successful provider payment didn't reflect in Subscriby after the normal webhook window.
- Team / ownership transfer — no self-service flow for this today.
- Unbanning a user (no self-service `/unban` command).
A place, bot or connected account you lost access to is not a support ticket first: open [Disaster Recovery](/disaster-recovery), which detects it, alerts you and moves every paying member to the replacement for you. Support steps in only when the 90-day self-service allowance for that kind of recovery is already spent.
For per-subscription issues (refunds, individual payment disputes), always contact the **creator first** — they have direct controls.
## Related
- [Subscribers troubleshooting](/subscribers/troubleshooting) — the subscriber-specific version of this page.
- [Status reference](/reference/status-codes) — what every status badge means.
- [Glossary](/reference/glossary) — every Subscriby term.
- [Disaster Recovery](/disaster-recovery) — when a channel, bot or account becomes unreachable through no fault of your own.
---
# Data Rules
Source: https://docs.subscriby.net/sdk/v1/building/data
The core owns the rows that describe a membership: installations, identities, spaces, grants, members, subscriptions and payments. A connector owns only what the platform makes it keep, such as a bot row with its token or a cached chat title, and keeps it in tables of its own. The rules below make it possible to remove a connector from the codebase without breaking the core, and to uninstall one from a project without cascading into anything a member paid for.
## The rules
1. **Prefix every table with the connector key.** A connector whose key is `example` creates `example_bots`, `example_chats`, never `bots`. The kit reads the migration sources and fails a `Schema::create` without the prefix before anything runs.
2. **Never alter a core table.** No `Schema::table`, `drop`, `dropIfExists` or `rename` on a table the connector does not own. Anything a connector needs to record about a project, a member or a grant goes through the Core API into the core's tables.
3. **Foreign keys point inward only.** A connector table may hold `project_id`, `installation_id` or `identity_id` with `cascadeOnDelete`, so a project's deletion tidies the connector's rows. The core never holds a foreign key into a connector table: the core rows carry a soft `storage_ref` string that names the connector's row, and the connector resolves it.
4. **Additive after first publish.** Once a version has been installed anywhere, later migrations add. A change of shape is expand → verify → contract: add the new column or table, move the data and prove it moved, and drop the old shape in a later release, never the same one.
5. **Tenant filtering is explicit.** The core's tenant scope is inert for connector ingress, so a connector repository filters by project or installation itself (`forProject()`, `forInstallation()`) rather than trusting a global scope.
`MigrationRules::violations($connector, $migrationsPath)` scans `database/migrations` for `Schema::create` and `Schema::table|drop|dropIfExists|rename` calls and reports every table without the prefix as `file: what`. It runs inside the conformance suite as `migrations.own_tables_only`, so the rule fails on the developer's machine.
## What the core keeps for you
| Core table | What it holds | Reached through |
| -------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------- |
| `connector_installations` | One row per installation: project or platform scope, live or standby role, state, encrypted credentials, health, `storage_ref`. | `Core\Installations` |
| `connector_identities` | One row per account the connector has seen, with the installation that saw it and `storage_ref`. | `Core\Identities` |
| `connector_spaces` | One row per gated place, with kind, parent and health. | `Core\Spaces` |
| `access_grants` | One row per (subscription, resource, window): mode, state, the connector's reference, failure kind. | `Core\Grants` |
| `connector_inbound_events` | The idempotency keys of inbound events, pruned after seven days. | The core, before your port |
Credentials are encrypted at rest in `connector_installations.credentials` and handed to your ports as a `CredentialBag`; do not copy a token into your own table unless the platform's client library forces it, and never log one.
## Uninstall, reinstall and purge
- **Disconnect** wipes the installation's credentials and stops it acting. Your tables are untouched.
- **Uninstall** revokes the connector's grants, detaches its resources (external ids kept) and keeps the installation row, identities and your tables. Nothing is deleted, so a **reinstall** re-links detached places and can restore access.
- **Purge** (run by Subscriby's operators for a retention rule or an erasure request, and only after the connector was uninstalled) deletes, for one project on that connector, the core's grants (their creator tasks with them), the spaces the project's resources point at, the member identity links, and the uninstalled installation rows; your own rows go with the installation rows through the `installation_id` cascades rule 3 asks for. Identities, members, subscriptions, payments, support threads and the resource rows themselves stay, because they are the audit trail; a member's own erasure goes through the identity unlink path. Operators rehearse it first, which prints the same per-table counts and deletes nothing. It deletes data, never schema.
---
# Getting Started
Source: https://docs.subscriby.net/sdk/v1/building
A connector is an ordinary Composer package that depends on `subscriby/connector-sdk`. Three things make it a connector: a `connector.json` at its root, a service provider that extends the SDK's, and a `Connector` class that binds the ports the package implements.
Start from an empty Composer package and pull the SDK in:
```bash
composer require subscriby/connector-sdk
```
The SDK is published from [github.com/envigoinnovations/subscriby-connector-sdk](https://github.com/envigoinnovations/subscriby-connector-sdk), a read-only mirror of Subscriby's monorepo; its `CHANGELOG.md` lists what each version added.
## Package layout
```text
my-connector/
├── connector.json the manifest — what the connector is and can do
├── composer.json requires subscriby/connector-sdk, declares the provider
├── config/ optional: connector-.php and any client config
├── database/migrations/ optional: the connector's own tables, prefixed _
├── lang/ optional: .json translations for the connector's strings
├── resources/views/ optional: slot views, published under connector-::
├── routes/
│ ├── inbound.php optional: the platform's webhook routes
│ └── web.php optional: browser routes (a sign-in callback, a health probe)
├── src/
│ ├── MyConnectorServiceProvider.php
│ ├── MyConnector.php
│ └── Ports/… one class per port
└── tests/
```
The provider file must sit at `src/.php`: the SDK finds the package root as its grandparent, which is how it locates the manifest and everything else.
## composer.json
```json
{
"name": "acme/subscriby-connector-example",
"require": {
"php": "^8.5",
"illuminate/contracts": "^13.0",
"illuminate/support": "^13.0",
"subscriby/connector-sdk": "^1.0"
},
"autoload": {
"psr-4": { "Acme\\Connectors\\Example\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Acme\\Connectors\\Example\\ExampleConnectorServiceProvider"]
}
}
}
```
The `sdk` constraint inside `connector.json` (`"sdk": "^1.0"`) is checked as well: the registry refuses a package built against an SDK the application does not run.
## The service provider
Extend `Subscriby\Connector\ConnectorServiceProvider` and return your connector from `connector()`. The base class does the rest when the application boots:
- reads and validates `connector.json`, listing every problem at once with its dotted path;
- registers the connector with the application's registry under that manifest;
- loads `database/migrations`, `resources/views` (as the `connector-` namespace), `lang/*.json`, `routes/inbound.php` (behind the core's `connector.inbound` middleware, which authenticates each call through your `InboundGateway`, drops a replayed event and refuses a paused connector), `routes/web.php` (behind `web`) and the console commands you return from `packageCommands()`;
- binds the listeners you return from `packageListeners()` to the SDK's events (`Subscriby\Connector\Events\*`);
- hands the seeders you return from `packageSeeders()` to the registry, and the application's database seeder runs them after its currencies and countries and before its demo projects, so a connector ships its own demo rows (its bots, its chats, its native currency);
- merges every `config/*.php` the package ships under the file's own name, so `config/connector-example.php` becomes `config('connector-example.…')`.
```php
app->make(ExampleConnector::class);
}
}
```
## The Connector class
`Subscriby\Connector\Contracts\Connector` has one method. It receives a `ConnectorRegistrar` and binds each port the package implements by its interface name:
```php
final class ExampleConnector implements Connector
{
public function __construct(
private readonly Ports\ExampleInstallationLifecycle $lifecycle,
private readonly Ports\ExampleIdentityResolver $identities,
// …
) {}
public function register(ConnectorRegistrar $registrar): void
{
$registrar->port(InstallationLifecycle::class, $this->lifecycle);
$registrar->port(IdentityResolver::class, $this->identities);
$registrar->port(InboundGateway::class, $this->gateway);
$registrar->port(FailureClassifier::class, $this->failures);
$registrar->port(TextRenderer::class, $this->renderer);
$registrar->port(SettingsSchema::class, $this->settings);
$registrar->port(UiSlots::class, $this->slots);
$registrar->port(Messenger::class, $this->messenger);
$registrar->port(AccessController::class, $this->access);
}
}
```
What the connector *is* never lives in this class: the manifest describes it, and the conformance kit fails a package whose registered manifest differs from its file.
## Required and optional ports
Every connector binds these seven, whatever the platform:
| Port | Why it is required |
| ----------------------- | --------------------------------------------------------------------------- |
| `InstallationLifecycle` | Connecting, verifying, disconnecting and describing an installation. |
| `IdentityResolver` | Who an inbound event is from; what the platform knows about an account. |
| `InboundGateway` | Authenticating and decoding what the platform sends. |
| `FailureClassifier` | Reading why the platform refused a call, in the core's failure vocabulary. |
| `TextRenderer` | Turning the core's canonical HTML into what the platform accepts. |
| `SettingsSchema` | The install and settings forms as data (usually bound for you from the manifest's fields). |
| `UiSlots` | The connector's UI contributions, even when it fills none. |
Every other port is bound when the manifest declares the matching capability, and the registry refuses a manifest that declares a capability without its port or binds a port without its capability. [Ports](/sdk/v1/ports) has one page per port.
A connector whose install and settings forms are plain fields declares them in `connector.json` (`install.fields`, `install.settings_fields`) and binds nothing: the core wires the SDK's `ManifestSettingsSchema` over those fields, translated through the package's language files. Write your own `SettingsSchema` only when the fields depend on the installation.
## Reading and writing the core's rows
A connector never queries the application's database. It records and reads installations, identities, spaces, grants and support messages through the **Core API**, the contracts under `Subscriby\Connector\Core\*` that the core binds in its container and hands to any class your package builds. Everything they take and return is an SDK value object; anything a connector keeps of its own (a bot row, a token, a cached chat title) goes into its own tables under the [data rules](/sdk/v1/building/data), with a `storage_ref` string on the core row pointing at it. [Core API](/sdk/v1/core-api) has one page per contract.
## The worked example
The SDK ships a complete connector to copy from: the **fake connector** under `Subscriby\Connector\Testing`, with its own `connector.json` beside it. It deliberately violates every assumption a Telegram-shaped core would make (280-character messages, no files, membership grants instead of invite links, a resource kind the creator fulfils by hand, no early admission, three admin commands), so a core path that still assumes one platform fails against it. Its port fakes keep what they were asked in memory and offer assertions, which makes them the doubles to write your own connector's tests against too.
## Next
Write [the manifest](/sdk/v1/manifest) and validate it against the published schema.
Implement the seven required ports, then the ones your capabilities need ([Ports](/sdk/v1/ports)).
Keep your migrations inside the [data rules](/sdk/v1/building/data).
Run the [conformance kit](/sdk/v1/conformance) until it is green, with the [fakes](/sdk/v1/building/testing) covering the core paths your connector relies on.
Fill in the [listing](/sdk/v1/building/listing) and [publish](/sdk/v1/building/publishing): version, translate, submit.
---
# Listing and Review
Source: https://docs.subscriby.net/sdk/v1/building/listing
Nothing on a connector's marketplace page is typed by hand: the card, the detail page, the in-app directory, `GET /connectors` and the MCP catalogue all read the manifest's `listing` block. What a connector cannot say about itself, the registry stamps.
## The listing block
Every field of the block is specified on [`listing`](/sdk/v1/manifest/listing) in the manifest reference; this page is about what to put in it and how it is reviewed.
```json
"listing": {
"category": "messaging",
"tagline": "Sell access to channels and groups, granted and removed automatically.",
"overview": "Connect a bot you created with @BotFather and Subscriby runs your membership…",
"screenshots": [],
"links": {
"documentation": "https://docs.subscriby.net/connectors/telegram",
"support": "mailto:support@subscriby.net",
"privacy": "https://telegram.org/privacy",
"terms": "https://telegram.org/tos",
"homepage": "https://telegram.org"
},
"added_at": "2025-04-21",
"changelog_url": "https://www.subscriby.net/blog",
"sign_in_required": false,
"marketing": {
"audience": "Telegram communities",
"place": "channel",
"places": "channels, groups and supergroups",
"installation": "bot",
"identity": "Telegram account",
"native_payment": "Telegram Stars"
}
}
```
- **`category`** shelves the connector: `messaging`, `community`, `payments` or `productivity`.
- **`tagline`** is the one line under the name, 80 characters at most; the card shows it whole.
- **`overview`** is the Overview tab, Markdown. Say what the connector does for a creator and for a member; the capability section under it is generated from the manifest, so do not repeat the feature list.
- **`links`** appear under **More info**. `documentation` should be your own guide for creators; `privacy` and `terms` are the *platform's* policies, and the marketing site's legal pages link the available connector's `privacy` for members' reference.
- **`added_at`** drives the **New** chip for sixty days; **`changelog_url`** is where you announce releases; **`sign_in_required`** tells creators whether they must sign in to the platform to install (an OAuth connector says `true`).
- **`seo`** is optional and worth writing: it turns your connector's public page into a search landing page that may lead with the platform's name (a title of at most 70 characters, a description of at most 160, a headline, sections and questions), the only page on the marketing site allowed to. [`listing`](/sdk/v1/manifest/listing#seo) lists the fields and the tokens the site fills for you.
## The marketing words
The public site, the home page, the twelve vertical pages, the twelve comparison pages and the legal pages, never spells a platform. Its copy carries tokens that are filled from the connectors available on that installation of Subscriby, so a sentence written once names Telegram today and whichever connector is available tomorrow. Your `marketing` block supplies the words:
| Word | Token | Meaning and example |
| ---------------- | ----------------- | ----------------------------------------------------------------------------------------------------- |
| `audience` | `:audience` | Who the connector serves: "Telegram communities". |
| `place` | `:place` | One gated place, lower case: "channel". |
| `places` | `:places` | The gated places as a list: "channels, groups and supergroups". |
| `installation` | `:installation` | What a project installs, lower case: "bot". |
| `identity` | `:identity` | A person's account on the platform: "Telegram account". |
| `native_payment` | `:native_payment` | The platform's own payment method, when it has one: "Telegram Stars". Leave it out when there is none. |
The connector's `name` fills `:platform`, and every available connector together fills `:platforms`. A sentence built on a word your block does not supply is dropped rather than rendered half-empty: a comparison row about native payments disappears when no available connector declares one. Write the words in lower case except the platform's own proper nouns, and as they would read mid-sentence ("your :place", "a :platform :installation").
A connector that lends no marketing words leaves `marketing` out, and the public site says nothing about it. Every connector on the marketplace still gets its own page from the rest of the listing.
## What Subscriby stamps
These are never in the manifest, so a package cannot claim them:
| Stamp | Set from |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Official** | Subscriby's configuration names its own connectors; every other package is **Community**. |
| **Lane / status** | `Available Now` (enabled), `Experimental` (enabled and flagged beta), `Paused` (disabled during an incident), `Under Development` (registered, not enabled), `Coming Soon` (a roadmap stub with no package). |
| **New** | `added_at` within the last 60 days. |
| **Trending** | The most installed connector over the last 30 days. |
| **Installed on** | In the app only: how many of the signed-in creator's projects run it. |
## Review checklist
Before a package is installed on Subscriby it is read against this list. Each item is something the [conformance kit](/sdk/v1/conformance) cannot judge alone.
1. **The kit is green**, including `manifest.file_is_the_source` and `migrations.own_tables_only`.
2. **Nothing from the application's namespace** is imported; the connector talks to the core through `Subscriby\Connector\Core\*` and the SDK's value objects only.
3. **Credentials never reach a log, an exception message or a view.** Tokens live in the core's encrypted `CredentialBag`; your own tables hold them only when the platform's client forces it.
4. **Every slot contribution has a placeholder** that mirrors its loaded layout, and every string it shows comes from the package's `lang/*.json` in all ten locales Subscriby ships (English, Spanish, French, German, Italian, Portuguese, Turkish, Hindi, Bengali, Sinhala).
5. **The listing tells the truth**: the tagline and overview describe what the bound ports do, `privacy` and `terms` are the platform's, and `documentation` resolves.
6. **The platform's terms allow it.** A connector that automates something the platform forbids is not installed, however well it works.
7. **Idempotency and pacing are respected**: repeated grants and revokes are safe, `pacing` matches the platform's published limits, and the classifier maps the platform's rate-limit response to `RateLimited` with its retry-after.
8. **Native payments are official-only.** A community package that declares `native_payments` is refused at boot.
## Submitting
Marketplace submission and per-connector pricing are not open yet. To get a package reviewed today, use **Request a connector** on the [marketplace](https://www.subscriby.net/connectors) or write to [support@subscriby.net](mailto:support@subscriby.net) with the repository URL and the kit's report; Subscriby installs reviewed connectors itself.
---
# Publishing a Connector
Source: https://docs.subscriby.net/sdk/v1/building/publishing
A connector is installed by Subscriby, not uploaded. Publishing therefore means getting a package to the state Subscriby can review and install, then keeping it there release after release.
## Before the first release
**Version the package.** `connector.json`'s `version` is the package's semantic version; start at `1.0.0` when the ports it declares work on the platform, `0.x` while they do not. Bump it with every release.
**Pin the SDK.** `"sdk": "^1.0"` in the manifest and `"subscriby/connector-sdk": "^1.0"` in `composer.json` say the same thing; the registry refuses the package at boot if the application's SDK does not satisfy the manifest's constraint. Read the [changelog](https://github.com/envigoinnovations/subscriby-connector-sdk/blob/main/CHANGELOG.md) before raising the minor you depend on.
**Translate every string.** Field labels, help texts and steps, resource-kind labels, the portal button, readiness items, slot views and the listing's tagline and overview are translation keys resolved through your `lang/.json`. Ship all ten locales Subscriby ships: `english`, `spanish`, `french`, `german`, `italian`, `portuguese`, `turkish`, `hindi`, `bengali`, `sinhalese`. The application's parity tests fail a key present in one file and missing in another.
**Write the listing.** Category, an eighty-character tagline, an overview for creators and members, the platform's privacy and terms links, your documentation and support links, the marketing words. [`listing`](/sdk/v1/manifest/listing) has every field, [Listing and Review](/sdk/v1/building/listing) what to put in them.
**Pass the kit and the review checklist.** [Conformance](/sdk/v1/conformance) green including the two on-disk rules, then the eight items of the [review checklist](/sdk/v1/building/listing#review-checklist).
**Keep a changelog** at `changelog_url`, and a `CHANGELOG.md` in the package, so Subscriby knows what a version changed before installing it.
## Submitting
Marketplace submission and per-connector pricing are not open yet. To get a package reviewed today, use **Request a connector** on the [marketplace](https://www.subscriby.net/connectors) or write to [support@subscriby.net](mailto:support@subscriby.net) with the repository URL, the tag to review and the kit's report. Subscriby reads the package against the checklist, runs the kit in its own suite, and installs a reviewed connector itself.
## What happens after install
Whether creators can install a connector is Subscriby's decision, never the package's claim, and a connector moves through the marketplace's lanes as Subscriby switches it:
| Lane | What it means | What creators see |
| --------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Under Development** | Subscriby has installed the package but not switched it on yet. | A card in the "building now" lane, no install button. |
| **Experimental** | Switched on and marked beta. | Installable, marked experimental. |
| **Available Now** | Switched on. | Installable everywhere. |
| **Paused** | Switched off by Subscriby during an incident on its side or the platform's. | The card says paused; nothing is sent through the connector, and what members send waits on the platform until it is switched back on. |
The **Official** badge is Subscriby's configuration for its own connectors; every reviewed third-party package is **Community**. **New** appears for sixty days from `added_at`; **Trending** marks the most installed connector over thirty days.
## Updating a connector
- **Additive migrations only** once a version is installed anywhere: a data move is expand → verify → contract, with your own verification before any drop. The kit's `migrations.own_tables_only` still runs on every version.
- **Bump `version`** in `connector.json` and the manifest's `added_at` stays as it was: it is the first publication date, not the release date.
- **A new capability** is a manifest change and a port binding in the same release; the registry refuses one without the other. Declare it only when it works on the platform.
- **A removed capability** revokes nothing by itself: installations keep running, and the core stops calling the port. Say so in the changelog, because creators who used it will notice.
- **A raised SDK constraint** waits for Subscriby to run that SDK; ask before raising it past what production runs.
- **Renaming the key** is not an update: the key names tables, config, views and routes for the life of the connector. A new key is a new connector.
## Pausing and retiring
Subscriby can pause a connector during an incident; ask for it when your platform is misbehaving in a way that would fail every send, and the core queues rather than fails. Retiring a connector is an uninstall on every project, which revokes its grants, detaches its resources and keeps identities and your tables, after which Subscriby switches the connector off; nothing is deleted, and the [data rules](/sdk/v1/building/data#uninstall-reinstall-and-purge) say what a purge does later.
The [marketplace's](https://www.subscriby.net/connectors) "building now" and "on the roadmap" lanes name the platforms Subscriby is building connectors for itself. Write to support before starting a package for one of them, so the work is not done twice; a platform on no lane is open.
---
# Testing a Connector
Source: https://docs.subscriby.net/sdk/v1/building/testing
A connector has two things to test: that its ports do the right thing against the platform, and that the core does the right thing with the connector. The SDK ships fakes for the second, the kit for the shape of the first, and the platform's own client fake is yours to write for the rest.
## The fake connector
`Subscriby\Connector\Testing\FakeConnector` is the reference implementation and the double every core path is tested against. Its manifest is the `connector.json` beside it, read through the same loader as every package, so the loader runs in every test and the file doubles as a worked example. It deliberately violates every assumption a single-platform core would make: 280-character messages with two buttons a row and no files, `membership` grants instead of invite links plus a `creator_task` kind, no early admission, three admin commands, a one-message-a-second pace, identities that look nothing like a chat id.
Subscriby's application registers it in its registry whenever tests run (and on any environment with `connectors.fake` switched on, which is how a staging site gets a demo project with no real platform). A core path that still assumes one platform fails against it; that is what keeps the core honest for the platform your connector brings.
## The port fakes
Each fake implements one port, keeps what it was asked in memory and answers something plausible. Each also has a **trigger**: a value in the input that makes it fail the way a platform would, so both halves of a core path can be driven without a platform. Register them beside a real port of yours to test one port in isolation, or all of them to test a flow.
| Fake | Behaviour | Triggers and assertions |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `FakeInstallationLifecycle` | Connects in one step by default and remembers what it was asked; start links are `https://fake.test/?start=`; answers a platform installation. | A token of `revoked` verifies as `Revoked` with reason `token_revoked`, creator-actionable. A token starting `steps-` asks a `region` field before completing; one starting `oauth-` returns a `continueUrl` on the fake host and completes from `returned['code']`, minting `oauth-` into `meta`; `returned['error']` is a platform refusal. |
| `FakeIdentityResolver` | Reads the actor from the envelope payload's `from`; external ids are phone-number shaped (`+15550001`) so a core path that treats them as numeric chat ids fails. | Adopting keeps no row: the storage ref is derived from the id, so a core path that needs the connector's row to exist fails too. |
| `FakeInboundGateway` | A request is authentic when the `X-Fake-Token` header is `fake`; the JSON body's `id` is the idempotency key and its `kind` the event kind. | Post the same `id` twice to test the replay path. |
| `FakeFailureClassifier` | An exception is `Transient`; an array carrying `error` maps a few named codes to kinds. | `error` codes hand back any kind; a `RateLimited` one carries a five-second retry-after. |
| `FakeTextRenderer` | Renders the canonical HTML to plain text, as a platform with no formatting would. | — |
| `FakeSettingsSchema` | One secret to install with, one text setting to edit afterwards. | — |
| `FakeUiSlots` | No contributions. | — |
| `FakeMessenger` | Keeps every send; delivers with ids `fake-message-N`. | An identity whose id starts with `blocked-` is `Unreachable`; files fail as `Configuration`. `assertSentTo($externalId, ?$containing)`, `assertNotSentTo($externalId, ?$containing)`, `assertNothingSent()`. |
| `FakeAccessController` | Keeps memberships in memory; grants are memberships (`membership::`), never links; granting twice is one membership, revoking a stranger is revoked. | `blocked-` identities fail `Unreachable`. `assertGranted($space, $identity)`, `assertNotGranted(…)`, `assertAnnounced($identity, ?$windowId)`, `assertNotAnnounced($identity)`. |
| `FakeSpaceCatalog` | Parks one link request per creator and purpose; describes a place as `Room `; records every diagnosis it was asked. | A space whose external id starts with `lost-` diagnoses as not a member, creator-actionable. |
| `FakeManagementSurface` | Renders the manifest's three commands and records every envelope it handled. | — |
| `FakePortalLoginMethod` | "Continue with Fake" with a `sparkles` icon; a start link on the fake host carrying the handshake token. | — |
| `FakeRecoverySupport` | Probes and nothing else, as a connector whose platform cannot ban a bot: healthy probes, a vocabulary, readiness items; every other facet throws `UnsupportedByConnector`. | An identity marked deleted probes `TargetMissing` with code `account_deleted`; a `blocked-` one probes `Unreachable`. |
```php
$messenger = new FakeMessenger;
$registrar->port(Messenger::class, $messenger);
// … drive the core path that should tell the member …
$messenger->assertSentTo('member-42', containing: 'Payment received');
```
## What your own suite covers
Test each port against a fake of the platform's client (an HTTP fake for a REST platform, a recorded socket for a gateway), never against the live platform, and pin the wire format the way Subscriby pins the Telegram connector's: **characterisation tests** that record what the connector sends for each core message and each admin flow, and fail on any change, so a behaviour change is a deliberate commit with a justification rather than a surprise.
| Port | Cover at least |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `InstallationLifecycle` | Connect with a good and a bad credential, reconnect naming the existing installation, verify healthy and revoked, disconnect after the platform already revoked. |
| `IdentityResolver` | An event with an actor, one without, adoption twice for the same account returning the same row. |
| `InboundGateway` | A signed and an unsigned request, a body with two events and their idempotency keys, an empty body. |
| `FailureClassifier` | One row per platform error you map, plus a network exception and an unknown string. |
| `TextRenderer` | Each of the eight tags, a nested pair, entities, plain text unchanged. |
| `Messenger` | A send that the platform refuses inside a `200`, a rate limit with its retry-after, a file when the manifest allows one. |
| `AccessController` | Grant twice yielding one grant, revoke of a missing grant reporting revoked, a held grant admitted, reconcile letting a banned holder back in. |
| `SupportRelay` | A reply with and without a quote, an attachment the platform serves and one it cannot. |
| `RecoverySupport` | Each probe's three answers, a standby registered and removed. |
For the core's side, register your connector beside the fake in a Subscriby test run and assert the same scenario on both: a purchase grants, a cancellation revokes, a member with no identity waits as `pending_identity` and materialises after a handshake. If the core behaves differently for yours, either your manifest says something the fake's does not, or the core has a platform assumption left, and either is worth a report.
## Running the kit
The kit needs a `ConnectorRegistry`, and the SDK ships one for exactly this: `Subscriby\Connector\Testing\TestRegistry`. Register the manifest and the connector into it, hand it to the suite and assert the report, all inside your package's own test suite with no Subscriby checkout:
```php
use Subscriby\Connector\Manifest\ManifestFile;
use Subscriby\Connector\Testing\Conformance\ConformanceSuite;
use Subscriby\Connector\Testing\TestRegistry;
it('passes the conformance kit', function (): void {
$registry = new TestRegistry;
$registry->register(ManifestFile::load(dirname(__DIR__).'/connector.json'), new ExampleConnector);
$report = (new ConformanceSuite($registry))->run('example', packagePath: dirname(__DIR__));
expect($report->passed())->toBeTrue($report->summary());
});
```
`TestRegistry::register()` runs the same manifest-to-port checks the application's registry runs at boot, through the same class (`Subscriby\Connector\Registry\PortAgreement`): a capability without its port, a port without its capability, a missing required port, or install fields both declared in the file and bound as a `SettingsSchema` throw `InvalidManifest` with the words production would use, before a single rule runs. What the application reads from configuration is a constructor argument here: `official` (a connector declaring `native_payments` passes `official: ['example']` or is refused), `available` (every registered key by default) and `disabled`. A form declared in the file comes back through a passthrough translator, so labels read as you wrote them. The rules that need the application's own behaviour (the per-installation capability switches, the inbound gate) are not in the kit, so this run is complete for what the kit checks.
The other way is **inside Subscriby's suite**: put your package under `packages/subscriby-connector-/` in a checkout of Subscriby; its Pest configuration includes every `packages/subscriby-connector-*/tests` directory, your service provider boots with the application, and a test of yours runs the suite against `app(ConnectorRegistry::class)` exactly as the first-party connectors do. That checkout is also where the core's side is tested against your connector: a purchase grants, a cancellation revokes, a member with no identity waits.
Whichever way you run it, three checks need no registry at all and belong in every package's suite from the first commit: `ManifestFile::load(__DIR__.'/../connector.json')` for the manifest, `MigrationRules::violations('', __DIR__.'/../database/migrations')` for the data rules, and the `Field` and `MessageAction` constructors for your forms and buttons.
## What only the platform proves
The kit and the fakes cannot tell you that a real bot can add a real member to a real room, that the platform's rate limit is where its documentation says, or that a revoked token answers the way you classified it. Keep one sandbox installation on the platform, run the connector against it by hand before each release, and record what you see into the characterisation snapshots.
A fake of the platform's client that answers everything with success hides every refusal the classifier exists to read. Fake each call you make, one answer at a time, and let an unexpected call fail the test.
---
# Beyond the kit
Source: https://docs.subscriby.net/sdk/v1/conformance/beyond-the-kit
The kit runs against a registered connector and reads two things on disk. Three more rules hold every connector installed on Subscriby, enforced by the application's architecture tests rather than the kit, and a reviewer reads for what no test can see.
## Isolation, both ways
- **A connector imports nothing from the application.** `use App\…` and `App\…` class strings are forbidden in every `packages/subscriby-connector-*/src` directory. A third-party package must have zero; the first-party connectors carry a shrinking allow-list that must be empty before the SDK's next major. Your connector talks to the core through `Subscriby\Connector\Core\*` and the SDK's value objects only.
- **The core imports nothing from a connector.** No `Subscriby\Connectors\…` namespace appears in the application; the core reaches a connector through the registry, the ports and the slots.
The only thing that crosses in either direction is the SDK.
## Translation parity
Every string a connector shows on a core page, in a slot, a field label, a resource-kind label, a portal button, a readiness item, has a translation in each of the ten locales Subscriby ships (English, Spanish, French, German, Italian, Portuguese, Turkish, Hindi, Bengali, Sinhala), in the package's own `lang/.json`. The application's language parity tests scan package language directories beside the core's and fail on a key present in one locale and missing in another.
## Skeleton parity
A slot contribution's placeholder mirrors the loaded layout: the same boxes, the same heights, so a lazy page does not jump when the contribution arrives. The kit checks that a placeholder exists; review checks that it matches.
## What review reads for
Passing the kit and the three rules above is the entry ticket. A reviewer then reads the package for what a test cannot judge, against the checklist on [Listing and Review](/sdk/v1/building/listing#review-checklist):
- credentials never reach a log, an exception message or a view;
- the platform's terms allow what the connector automates;
- the listing describes what the bound ports actually do, and every declared capability works on the platform rather than throwing `UnsupportedByConnector`;
- repeated grants and revokes are safe, `pacing` matches the platform's published limits, and the classifier maps the platform's rate-limit answer to `RateLimited` with its retry-after;
- the ten locales are real translations, not the English copied ten times.
`MigrationRules::violations()` and the `Field` and `MessageAction` constructors need no application. `ManifestFile::load()` validates your manifest exactly as the boot does. Put all four in your package's own tests and most kit failures never reach review.
---
# Capability rules
Source: https://docs.subscriby.net/sdk/v1/conformance/capability-rules
Each rule here runs when the registry says the connector binds the port it checks (`$registry->binds($key, Port::class)`), so a connector without the capability never sees the rule.
## `access.declares_kinds`
**Runs when** `AccessController` is bound. **Checks** the manifest lists at least one resource kind.
**Fails with** `access_control is declared but the manifest gates no resource kind`.
**Why** access control grants access *to a kind of place*; with no kind declared there is nothing a creator can link and the port can never be called.
**Fix** declare the kinds in [`resource_kinds`](/sdk/v1/manifest/resource-kinds), or drop the capability.
## `management.commands_match_manifest`
**Runs when** `ManagementSurface` is bound. **Checks** the sorted list `commands()` returns equals the manifest's sorted `management_commands`.
**Fails with** `renders [broadcast, plan_manage], manifest declares [plan_manage, project_settings]`.
**Why** the marketplace and the Connectors tab show the coverage from the manifest; the class must render exactly that.
**Fix** make the two lists agree; the manifest is the declaration, the class the implementation.
## `relay.modes_match_manifest`
**Runs when** `SupportRelay` is bound. **Checks** the sorted list `relayModes()` returns equals the manifest's sorted `relay_modes`.
**Fails with** `offers [owner_dm], manifest declares [forum_group, owner_dm]`.
**Why** the support settings offer what the manifest says and the port must honour each.
**Fix** make the two lists agree, and declare only modes the core can store ([`relay_modes`](/sdk/v1/manifest/relay-modes)).
## `recovery.vocabulary_and_readiness`
**Runs when** `RecoverySupport` is bound. **Checks** the six nouns of `vocabulary()` (`installationNoun`, `spaceNoun`, `identityNoun`, `grantNoun`, `installationsNoun`, `spacesNoun`) are non-empty, and `readinessChecks()` for a synthetic project-scope installation and project returns `ReadinessItem`s with unique keys.
**Fails with** `recovery: vocabulary grantNoun is empty, readiness key "standby" is declared twice`.
**Why** every recovery page and notice describes the connector's world in these nouns, and the readiness checklist keys its lines by `key`.
**Fix** name every noun, key every item once. The synthetic refs carry zero UUIDs, so `readinessChecks()` must cope with a project the core has no coverage for (it will answer an empty coverage).
## `portal_login.button`
**Runs when** `PortalLoginMethod` is bound. **Checks** `button()` has a non-empty `label` and `icon`.
**Fails with** `portal login: the button has no icon`.
**Why** the portal's sign-in sheet renders the button from these two strings.
**Fix** both strings; the icon is a key from the connector icon set, usually your own.
## `payments.provider_keys`
**Runs when** `ProvidesPaymentMethods` is bound. **Checks** every entry of `paymentProviders()` is a `NativePaymentProvider` keyed `:[a-z][a-z0-9_-]*` with this connector's key, with a non-empty `label()`, settling in at least one currency.
**Fails with** `payment providers: "stars" is not keyed telegram:provider, "telegram:stars" settles in no currency`.
**Why** the key is stored on every payment method and payment row that uses the provider, and must name the connector that owns it.
**Fix** `PaymentProviderKey::native($key, $provider)` as the key's string form, a label, at least one currency code. The registry has already refused the capability for a non-official connector.
These rules prove the shapes, not the behaviour: a `RecoverySupport` whose `failOver()` throws `UnsupportedByConnector` passes `recovery.vocabulary_and_readiness` with a manifest that promises `resource_standby`. Review holds a declared capability to what the platform can actually do.
---
# Inbound, identity and slots
Source: https://docs.subscriby.net/sdk/v1/conformance/inbound-identity-slots
## `inbound.tolerates_empty_request`
**Checks** with `POST /conformance`, body `{}`, content type `application/json`: `InboundGateway::authenticate()` returns a boolean, `decode()` returns an iterable, `immediateResponse()` returns null or a `Response`. None may throw.
**Fails with** `the gateway: authenticate() did not return a boolean, decode() did not return an iterable`, or the guard's `threw …` when a method assumed a header or a key that an empty request lacks.
**Why** the core's inbound gate calls these three on every request before your route runs; a gateway that throws on an unexpected body turns a platform's health check, a misrouted call or a probe into a 500 the platform retries forever.
**Fix** treat a missing header as "not authenticated" (return false), a body without events as "no events" (return an empty iterable), and a request that needs no special answer as null.
## `identity.tolerates_empty_envelope`
**Checks** `IdentityResolver::resolveInbound()` given `new InboundEnvelope($key, null, 'conformance', 'unknown', [], now)` returns null or an `IdentitySummary`, nothing else, and does not throw.
**Fails with** `resolveInbound() returned something other than null or an IdentitySummary`, or the guard's `threw …`.
**Why** platforms send events with no actor (a channel post, a system notice, an event kind added after your connector shipped), and the resolver is the first thing a handler asks.
**Fix** read the payload defensively: no actor, no throw, return null.
## `identity.answers_reachability`
**Checks** `IdentityResolver::deliveryTarget()` given an `InstallationRef` with a zero UUID and no storage ref, an empty `CredentialBag` and an `IdentityRef` for an account the connector has never seen (`conformance-nobody`) returns null or a `Recipient`, nothing else, and does not throw.
**Fails with** `deliveryTarget() returned something other than null or a Recipient`, or the guard's `threw …`.
**Why** the core asks before it sends a member anything, so an account the installation cannot reach is passed over for one it can; a resolver that throws on an unknown account would stop every member notice on the project.
**Fix** answer from what you already hold (your own row for the account under that installation), never by calling the platform, and return null for an account you do not know; when the platform cannot tell you in advance, return a `Recipient` and let the send report the refusal.
## `slots.well_formed`
**Checks** `UiSlots::slots()`: every entry is a `SlotContribution`, no slot is filled twice, and every contribution has a non-empty `placeholder`.
**Fails with** `slots: install is filled twice, resource_badge ships no placeholder`.
**Why** a slot is rendered on lazy core pages, and a contribution without a placeholder breaks skeleton parity for the whole page; a slot filled twice is undefined.
**Fix** one contribution per slot, each naming a placeholder view. The `SlotContribution` constructor already refuses a contribution with both a `view` and a `component`, or with neither. An empty list passes: a connector that fills nothing is a good connector.
These three rules share a theme the rest of your connector should follow: the core will hand you less than you expect (an event you have never seen, a request that is not from the platform, a settings form with no installation), and a connector answers "nothing here" rather than throwing.
---
# Conformance Kit
Source: https://docs.subscriby.net/sdk/v1/conformance
"Passes the kit" means one thing everywhere: the same `Subscriby\Connector\Testing\Conformance\ConformanceSuite` runs against the first-party connectors and the SDK's fake inside Subscriby's own test suite, and against your package during review. It is what marketplace review starts from, and the reference every connector is measured against.
## How it runs
```php
use Subscriby\Connector\Contracts\ConnectorRegistry;
use Subscriby\Connector\Testing\Conformance\ConformanceSuite;
$report = (new ConformanceSuite(app(ConnectorRegistry::class)))
->run('example', packagePath: '/path/to/the/package');
```
The suite reads the connector and its ports from the application's registry, so the connector's service provider must have booted. Passing the package path adds the two rules that read files on disk (`manifest.file_is_the_source`, `migrations.own_tables_only`); without it they are skipped.
Every rule is **guarded**: a port that throws fails its own rule with the exception's class and message ("threw RuntimeException: …") while the rest of the kit still runs. An author fixing three things at once sees all three.
## The report
`ConformanceReport` is all-or-nothing: one failed rule fails the connector, and each failure carries enough detail to be fixed without re-running anything by hand.
| Member | Meaning |
| --------------------- | -------------------------------------------------------------------------------------------------------- |
| `$report->connector` | The key. |
| `$report->checks` | Every `ConformanceCheck` in the order the kit ran them: `name`, `passed`, `detail`. |
| `$report->passed()` | True when every rule held. |
| `$report->failures()` | The checks that did not hold. |
| `$report->summary()` | One line when green (`example passes the conformance kit (23 checks).`), otherwise one line per failure: `example: settings.install_fields — install fields: "token" has no label`. |
A rule with several offenders lists them all in one `detail`, prefixed by what the list is ("install fields:", "links must be https URLs or mailto addresses:").
## The rules
Fourteen rules run for every connector; the rest run when the connector binds the port they check; two read the package on disk.
| Group | Rules |
| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Manifest rules](/sdk/v1/conformance/manifest-rules) | `manifest.key_matches`, `manifest.sdk_constraint`, `manifest.listing_links`, `manifest.resource_kinds`, `manifest.file_is_the_source` (on disk) |
| [Ports and forms](/sdk/v1/conformance/ports-and-forms) | `ports.required_bound`, `settings.install_fields`, `settings.settings_fields` |
| [Rendering and failures](/sdk/v1/conformance/rendering-and-failures) | `text.plain_text_survives`, `text.canonical_sample_renders`, `failures.classifies_anything` |
| [Inbound, identity and slots](/sdk/v1/conformance/inbound-identity-slots) | `inbound.tolerates_empty_request`, `identity.tolerates_empty_envelope`, `identity.answers_reachability`, `slots.well_formed` |
| [Capability rules](/sdk/v1/conformance/capability-rules) | `access.declares_kinds`, `management.commands_match_manifest`, `relay.modes_match_manifest`, `recovery.vocabulary_and_readiness`, `portal_login.button`, `payments.provider_keys` |
| [Migration rules](/sdk/v1/conformance/migration-rules) | `migrations.own_tables_only` (on disk) |
## Where it runs
The suite needs a `ConnectorRegistry`. Inside Subscriby the application's registry is bound, so the kit runs on the first-party connectors and the fake in every test run, and on your package when Subscriby reviews it. In your own suite, the SDK's `Subscriby\Connector\Testing\TestRegistry` is that registry: register your manifest and connector into it and hand it to the suite, and it accepts or refuses the connector exactly as the application's registry does at boot, through the same `PortAgreement`, so no Subscriby checkout is needed to pass the kit. [Testing a connector](/sdk/v1/building/testing#running-the-kit) shows the test, and the fakes you write the rest of your tests against.
Passing it is the entry ticket. [Beyond the kit](/sdk/v1/conformance/beyond-the-kit) lists what Subscriby's own suite checks that the kit cannot from outside, and [Listing and Review](/sdk/v1/building/listing) what a reviewer reads for.
---
# Manifest rules
Source: https://docs.subscriby.net/sdk/v1/conformance/manifest-rules
These rules hold the registered manifest to what the file and the SDK demand. The loader has already refused a file that is structurally wrong; these catch what a valid file can still get wrong.
## `manifest.key_matches`
**Checks** that the manifest's `key` equals the key the registry holds the connector under.
**Fails with** `the manifest says "example", the registry says "sample"`.
**Why** the key is reused everywhere (tables, config, views, routes, limiters), so a connector registered under one name and declaring another would be reachable under neither.
**Fix** the `key` in `connector.json`; the registry always uses the file's key, so a mismatch means the manifest object was built or altered in code.
## `manifest.sdk_constraint`
**Checks** that `Sdk::satisfies($manifest->sdk)` holds for the SDK the application runs (`Sdk::VERSION`).
**Fails with** `SDK 1.0.0 does not satisfy "^2.0"`.
**Why** the registry refuses an incompatible package at boot; the kit repeats the check so a report says it in words.
**Fix** the `sdk` constraint. `^1.0` is right for a package built against any 1.x; name a minor (`^1.2`) only when you use something that minor introduced. The syntax is on [Identity](/sdk/v1/manifest/identity#sdk).
## `manifest.listing_links`
**Checks** every link in `listing.links` (`documentation`, `support`, `privacy`, `terms`, `homepage`) and `listing.changelog_url`, when present, against `^(https://[^\s/]+|mailto:[^\s@]+@[^\s]+)`.
**Fails with** `links must be https URLs or mailto addresses: support "http://acme.test/help", terms "telegram.org/tos"`.
**Why** the marketplace renders them as-is on a public page, and the legal pages link `privacy` for members.
**Fix** absolute `https://` URLs, or `mailto:` addresses for `support`.
## `manifest.resource_kinds`
**Checks** that every entry of `resource_kinds` is a `ResourceKindDefinition` with a non-empty `label`, `portalLabel` and `icon`.
**Fails with** `resource kinds: room has no portalLabel, hall has no icon`.
**Why** the label is what creators pick from, the portal label is what members read, and the icon is drawn beside both; an empty one renders a blank.
**Fix** fill the three strings for every kind. The loader already refuses a `kind` outside `^[a-z][a-z0-9_-]*$`, a duplicate and a `grant_mode` outside the four.
## `manifest.file_is_the_source`
**Runs when** a package path is passed. **Checks** that `connector.json` exists at the package root, loads, and matches the registered manifest on `key`, `name`, `version`, `sdk`, the number of capabilities and the number of install and settings fields.
**Fails with** `/path/connector.json does not exist; the manifest must be declared in the file`, or `the registered manifest must come from the file: version is "1.1.0" in the file and "1.0.0" in the registry, the declared fields differ between the file and the registry`.
**Why** the file is the single source of truth: the marketplace, `GET /connectors`, the apps and the kit all read it, so a manifest assembled in PHP or edited after registration would show one thing and do another.
**Fix** declare everything in the file and let the SDK's service provider register it. A connector that binds its own `SettingsSchema` declares no fields in the file, so the field counts still agree (zero and the registry's copy of zero); one that declares fields binds no schema.
A file with a missing required key, a wrong type, an unknown key or a value outside its enumeration never reaches the kit: the SDK's service provider throws `InvalidManifest` at boot with every problem and its dotted path. The rules here are for what a loadable file can still get wrong.
---
# Migration rules
Source: https://docs.subscriby.net/sdk/v1/conformance/migration-rules
## `migrations.own_tables_only`
**Runs when** a package path is passed. **Checks** every `*.php` file under `/database/migrations` with `MigrationRules::violations($key, $path)`.
**Fails with** `a connector creates only tables prefixed with its key and never alters a core table: 2026_10_01_000000_create_rooms_table.php: creates "rooms" without the example_ prefix, 2026_10_02_000000_add_column.php: alters "project_resources", which is not the connector's table`.
**Why** the [data rules](/sdk/v1/building/data): a connector owns only what the platform makes it keep, in tables named after it, so it can be removed from the codebase without breaking the core and uninstalled from a project without cascading into anything a member paid for.
## How the sources are scanned
The rule reads the migration files as text, not the database, so it fails on the developer's machine before anything runs. Two patterns:
| Pattern | Must name |
| ----------------------------------------------------------------------- | ------------------------------------------ |
| `Schema::create(''` | A table starting with `_`. |
| `Schema::table(''`, `Schema::drop(…)`, `Schema::dropIfExists(…)`, `Schema::rename(…)` | A table starting with `_`. |
Both accept single or double quotes and whitespace after the parenthesis. A missing `database/migrations` directory is not a violation: a connector with no tables of its own passes.
## What it does not catch
- **Raw statements.** `DB::statement('ALTER TABLE project_resources …')` is not matched. Review reads for it, and Subscriby's own suite forbids raw schema changes in application migrations; hold your package to the same standard.
- **Foreign keys the wrong way.** A `foreignId('project_id')->constrained()` inside your own table is allowed and encouraged (rule 3 of the data rules); the scanner does not check the direction, because a connector table pointing *into* the core is exactly the intended shape.
- **Column-level rules inside your own tables.** Anything inside `Schema::create('example_…')` is yours to design.
## Running it alone
```php
use Subscriby\Connector\Testing\Conformance\MigrationRules;
$offences = MigrationRules::violations('example', __DIR__.'/../database/migrations');
expect($offences)->toBe([]);
```
The static method needs no application and no registry, so it belongs in every connector's own test suite, whether or not you can run the rest of the kit locally.
The scanner enforces ownership, not history. Once a version of your package is installed anywhere, later migrations add rather than change; a data move is expand → verify → contract, with proof that the data moved before any drop. That rule is review's, and yours.
---
# Ports and forms
Source: https://docs.subscriby.net/sdk/v1/conformance/ports-and-forms
## `ports.required_bound`
**Checks** that the connector binds all seven ports every connector must bind, whatever its capabilities: `InstallationLifecycle`, `IdentityResolver`, `InboundGateway`, `FailureClassifier`, `TextRenderer`, `SettingsSchema`, `UiSlots`.
**Fails with** `ports every connector must bind: Subscriby\Connector\Contracts\Ports\TextRenderer, Subscriby\Connector\Contracts\Ports\UiSlots`.
**Why** the core calls these for every connector: to connect it, to know who an event is from, to accept the platform's calls, to read a refusal, to render a message, to draw the install form, to render (or not render) its UI.
**Fix** bind each in `Connector::register()`. `SettingsSchema` is bound for you when the manifest declares fields; `UiSlots` must be bound even to return an empty list.
The agreement between capabilities and capability-bound ports is checked by the registry when the connector registers, not here: a declared capability without its port, or a bound port without its capability, refuses the package with `InvalidManifest` before the kit can run, in the application at boot and in your own suite at `TestRegistry::register()` ([capabilities](/sdk/v1/manifest/capabilities#what-the-registry-refuses-at-boot)).
## `settings.install_fields`
**Checks** `SettingsSchema::installFields()`: every entry is a `Field`, no name is declared twice, every field has a non-empty label; and, for a `paste_credential` connector, at least one field is an input (`text`, `secret`, `select`, `toggle`) and at least one is a `secret`.
**Fails with** `install fields: "token" is declared twice, "intro" has no label, a paste-a-credential connector declares no secret field`.
**Why** the dashboard, the API and the apps render the form from these fields; a duplicate name loses a value, a missing label shows an empty input, and a paste-a-token connector without a secret has nothing to paste.
**Fix** the fields in `connector.json` (or your own schema). A field's own rules (identifier name, options on a select, https or mailto links) are enforced by the `Field` constructor and reported by the loader.
## `settings.settings_fields`
**Checks** `SettingsSchema::settingsFields(null)` the same way: `Field` entries, unique names, labels. The `paste_credential` clause does not apply.
**Fails with** `settings fields: "welcome" has no label`.
**Why** the Configuration tab renders these after install, with the current values filled in.
**Fix** as above. A connector with nothing to configure returns an empty list, which passes.
The kit asks for the settings fields with no installation, so an own `SettingsSchema` must answer the defaults when given null rather than assume an installation is in hand. The catalogue does the same when it publishes the fields over `GET /connectors`.
---
# Rendering and failures
Source: https://docs.subscriby.net/sdk/v1/conformance/rendering-and-failures
Two of the required ports can be tested without a platform, so the kit drives them with inputs and reads the output.
## `text.plain_text_survives`
**Checks** `TextRenderer::render('Hello, world')` returns exactly `Hello, world`.
**Fails with** `"Hello, world" rendered as "Hello, world\n"`.
**Why** most of what the core says carries no markup, and a renderer that trims, escapes, wraps or decorates plain text changes every message in ten languages.
**Fix** make a body with no tags the identity, byte for byte. Decode entities only where a tag was; an HTML platform passes them through.
## `text.canonical_sample_renders`
**Checks** rendering the canonical sample
```html
Bold italic underline struck link code pre
quote
```
returns a non-empty string that still contains each of the eight words `Bold`, `italic`, `underline`, `struck`, `link`, `code`, `pre`, `quote`.
**Fails with** `the canonical sample rendered to nothing`, or `words lost in rendering: underline, quote`.
**Why** whatever the platform cannot show, the text inside a tag must survive; a renderer that drops the element loses meaning.
**Fix** map each of the eight tags to the platform's formatting or to the bare words ([TextRenderer](/sdk/v1/ports/text-renderer#the-canonical-subset)). Never strip an element with its content.
## `failures.classifies_anything`
**Checks** `FailureClassifier::classify()` returns a `DeliveryFailure` for a `RuntimeException` and for the string `conformance probe`, and does not throw for either.
**Fails with** `the classifier: a throwable did not classify to a DeliveryFailure`, or the guard's `threw TypeError: …` when the classifier only accepts its own client's exception type.
**Why** every send, grant and probe ends in the classifier, with whatever the platform client produced: a decoded body, a response object, an exception, sometimes a string. A classifier that only understands its own client's exceptions throws inside the core's error path, where nothing catches it.
**Fix** accept `mixed`, match what you recognise, and fall back to `Transient` for any throwable you do not and `Other` for any value you do not, each with the input's text in `detail`.
The kit uses a real exception and a real string on purpose. Test your classifier the same way, with the platform's real error bodies pasted from its documentation, one row per kind.
---
# Alerts
Source: https://docs.subscriby.net/sdk/v1/core-api/alerts
`Subscriby\Connector\Core\Alerts`. The core keeps a creator's **alert destinations**: which accounts, addresses and (later) devices carry which classes of alert (a sale, a waiting member, an incident, a security event). A connector's admin surface offers the creator exactly one switch for the account it is talking to: whether the core's alerts reach them through that account. The `set_alert_destination` command in the [catalogue](/sdk/v1/manifest/management-commands) is this contract.
## Methods
| Method | Returns | Meaning |
| ------------------------------------------------------------------------ | ------- | -------------------------------------------------------------------------------------------------------- |
| `receivesAlerts(CreatorRef $creator, IdentityRef $identity)` | `bool` | Whether any class of alert reaches the creator through that account. |
| `setReceivesAlerts(CreatorRef $creator, IdentityRef $identity, bool $receives)` | `bool` | Route every class through the account (`true`) or none (`false`). Returns false when the core knows no such creator or the account is not theirs. |
```php
if ($alerts->receivesAlerts($creator, $identity)) {
$alerts->setReceivesAlerts($creator, $identity, false); // "Stop sending my alerts here"
}
```
## What the core keeps to itself
- **The classes.** Which alert classes exist, which are critical, and the finer routing a creator sets on the dashboard's Notifications settings (this class here, that class there). A connector sees all or nothing for its account.
- **E-mail for the critical classes.** Whatever the switch says, the core still mails the critical classes (billing, security, recovery) to the creator's verified address. A connector cannot switch e-mail off.
- **Other connectors' accounts.** The contract takes the identity in front of you; it never lists or changes destinations on another connector.
## What the ref carries
`CreatorRef`: `id` and `locale`, so the confirmation you send back is in the creator's language.
A creator with two project bots on your connector still has one account on the platform; alerts are routed to the account, and both bots may render the same switch. Read before you write, and say what you did.
---
# Creators
Source: https://docs.subscriby.net/sdk/v1/core-api/creators
`Subscriby\Connector\Core\Creators`. A connector that declares `creator_registration` runs its own sign-up conversation on its shared installation and hands the completed form here with the account it was talking to. The core creates the email-first account, links that account as the creator's primary identity and sends the verification mail. The connector never sees the user beyond the ref.
## `register()`
```php
$creator = $creators->register(new CreatorRegistration(
name: $form->name,
email: $form->email,
password: $form->password,
identity: new IdentityRecord(
connector: 'example',
externalId: $account->id,
installationId: null,
displayName: $account->name,
username: $account->handle,
storageRef: (string) $row->id,
),
));
```
Returns a `CreatorRef` (`id`, `locale`). Behind it the core runs the same registration action the web form runs: the address must be unused in any capitalisation and is stored lowercased, the password meets the same rules, the account is created unverified with the verification mail on its way, and the vouching account is recorded and linked as the creator's primary identity on your connector.
## `isEmailTaken()`
```php
if ($creators->isEmailTaken($form->email)) {
return $this->askToSignInInstead();
}
```
Answers whether an address already belongs to a creator, compared without regard to capitalisation or surrounding whitespace, exactly as `register()` compares it. Ask it at the step where the visitor types the address, so the refusal arrives there rather than at the end of the wizard; `register()` still refuses a taken address on its own if you skip it.
## `RegistrationRefused`
| Factory | When | What to tell the visitor |
| -------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `emailTaken()` | The address already has an account. | Sign in on the web, then link this account from **Linked accounts**. |
| `accountRefused()` | The core would not adopt the vouching account: the connector has no shared installation to file it under, the account cannot be reached, or it already belongs to another creator. | Sign in on the web and link this account from **Linked accounts**; the exception's message carries the specific reason for your log. |
Both carry the reason; neither should be retried with a variation.
## What stays with the connector
The conversation: asking for the name, the address and a password in the platform's idiom, showing the platform's own terms, validating as much as the platform allows before calling. Keep the answers in your own storage while the wizard runs and discard them once `register()` has answered; the password in particular is never written to a table of yours.
There is no `verify()` or `setPassword()` here: verification is the mail's link, and the password is the one the visitor typed. The next thing the new creator does through your connector is create a project, through your `ManagementSurface` or the dashboard.
---
# Grants
Source: https://docs.subscriby.net/sdk/v1/core-api/grants
`Subscriby\Connector\Core\Grants`. The **ledger** is the core's record of who should be where: one grant per subscription, resource and pass window, with a mode, a state and the reference your `AccessController` issued. The connector holds the platform's side (the invite link, the role); this contract lets it read the ledger by that reference and, in a migration, write to it.
## Reads
| Method | Returns | Null when |
| --------------------------------------------------------- | ---------------- | -------------------------------------------------- |
| `find(string $id)` | `?GrantSummary` | No grant has that UUID. |
| `findByReference(string $connector, string $reference)` | `?GrantSummary` | The connector never issued that reference. |
`findByReference()` is how a join request finds the purchase behind the link it arrived on: the platform tells you which invite link was used, the ledger tells you whose grant it is and whether it is still `granted`, `held` or already `revoked`, and your handler admits or refuses accordingly. It works for any mode whose reference is unique per connector (a `bearer_link`); a `role` reference may repeat across concurrent subscriptions and is read by id instead.
## `GrantSummary`
| Field | Meaning |
| ---------------- | --------------------------------------------------------------------------------------- |
| `id` | The grant row. |
| `projectId`, `subscriptionId`, `resourceId`, `windowId` | Whose access, to what, for which pass window (null outside passes). |
| `mode` | `GrantMode`: `BearerLink`, `Membership`, `Role`, `CreatorTask`. |
| `state` | `GrantState`: `PendingIdentity`, `Pending`, `Held`, `Granted`, `Revoked`, `Failed`. |
| `identityId` | The account that holds it, null while it waits for one. |
| `connector`, `reference` | Your connector and what you issued. |
| `grantedAt`, `revokedAt` | When. |
## The states
| State | Means |
| ----------------- | --------------------------------------------------------------------------------------------- |
| `PendingIdentity` | The purchase is settled but the member has no identity on this connector yet; materialises when one is linked. |
| `Pending` | The grant is being issued. |
| `Held` | Pre-issued for a pass whose window has not opened; released by `admit()`. |
| `Granted` | Live. |
| `Revoked` | Ended: cancelled, expired, paused, banned, uninstalled, or superseded by a reissue. |
| `Failed` | The connector could not issue it; the failure kind is on the row and the creator is shown a creator-actionable one. |
## `record()`
```php
$ref = $grants->record(new GrantRecord(
subscriptionId: $subscription->id,
resourceId: $resource->id,
mode: GrantMode::BearerLink,
state: GrantState::Granted,
windowId: null,
identityId: $identity->id,
connector: 'example',
reference: $inviteLink,
payload: [],
grantedAt: $issuedAt,
));
```
Idempotent on the subscription, the resource and the window: it writes the grant or rewrites the one the purchase already holds for that resource and date. The core writes grants itself from your `AccessController::grant()` result, so a connector calls `record()` directly only when it learns of a grant outside that flow.
A connector that keeps its own "who is in" table will disagree with the ledger the first time a subscription is paused from the dashboard. Keep the reference; ask the ledger.
---
# Identities
Source: https://docs.subscriby.net/sdk/v1/core-api/identities
`Subscriby\Connector\Core\Identities`. An **identity** is a person's account on a connector. A connector says which account the platform is talking about; the core decides which creator or member holds it. Recording and linking are separate calls, because the same account can be a creator in one place and a member in another, and because a purchase may know the account before anybody has proven who holds it.
## Reads
| Method | Returns | Null when |
| ------------------------------------------------------------------------ | ---------------- | -------------------------------------------------- |
| `find(string $id)` | `?IdentityRef` | No identity has that UUID. |
| `findByExternalId(string $connector, ?string $installationId, string $externalId)` | `?IdentityRef` | The account is unknown. Pass null for a platform-wide id, the installation's UUID for a per-installation one. |
| `findCreator(IdentityRef $identity)` | `?CreatorRef` | No creator holds the account. |
| `findMember(IdentityRef $identity, ProjectRef $project)` | `?MemberRef` | No member of that project holds it. The link is per project, because a person is a different member in each. |
| `isHandshakeToken(string $token)` | `bool` | — |
The pair `findByExternalId()` then `findCreator()`/`findMember()` is how an inbound event becomes a person: your resolver names the account, this contract names who holds it, and your handler acts as that creator or for that member.
## `record()`
```php
$ref = $identities->record(new IdentityRecord(
connector: 'example',
externalId: $user->id,
installationId: null, // null for a platform-wide id
displayName: $user->name,
username: $user->handle,
avatarUrl: $user->avatar,
storageRef: (string) $row->id,
meta: [],
lastSeenAt: now(),
));
```
Idempotent on the connector key, the installation and the platform id; a second record refreshes the name, picture and `lastSeenAt`. Record every account you see act, whether or not anyone holds it yet: a purchase from an unknown account is filed as a pending grant against the identity and materialises when someone proves they hold it.
## Linking
```php
$identities->linkCreator($identity, $creator, IdentityPurpose::Primary, IdentityLinkSource::Handshake);
$identities->linkMember($identity, $member, IdentityLinkSource::Bot, preferred: true);
```
- `linkCreator()` says a creator holds the account, as their `Primary` or `Backup` identity on the connector. Idempotent; an identity already held by another creator is refused, because one account signs in as one person.
- `linkMember()` says a member of a project holds it, and whether the member wants to be reached there first. Idempotent per project.
`IdentityLinkSource` records how the link was proven: `Bot` (the account acted in the connector and the core matched it), `Portal` (the member linked it from the portal), `Handshake` (a two-sided proof), `Adopted` (the member tapped "use the same account" from a sibling project), `Backfill` (a migration). Write the truth; the dashboard shows it and the audit trail keeps it.
## Handshakes
A handshake is the two-sided proof that joins an account to a person: the core opens it with a purpose and a token, the connector completes it with the account that presented the token, and the core writes the link.
```php
if ($identities->isHandshakeToken($word)) {
$completion = $identities->completeHandshake($word, $identityRecord, $seenBy);
// $completion->handshake (HandshakeRef), ->subjectName, ->returnUrl
}
```
- `isHandshakeToken()` says whether a token names a handshake the core ever issued, in whatever state. Ask it before claiming a typed word, so a wizard answer that happens to look like a code is left to its wizard.
- `completeHandshake($token, IdentityRecord $identity, ?InstallationRef $seenBy)` proves possession and lets the core decide what the handshake was for. A creator link makes the account the creator's primary identity; a portal sign-in makes (or finds) the account's member in the handshake's project and answers where they go next; a recovery relink or a backup identity writes those. Pass `$seenBy` when you can say which installation heard the account, so a project-bound handshake completes only through that project's own installation.
`HandshakeRefused` is thrown when the token names no pending handshake (`expired()`), the account already belongs to another person (`identityHeld()`), another project's installation heard it (`installationMismatch()`), or the purpose is not completed through this call (`purposeNotSupported()`). Show the member a plain "this link has expired, start again" and never retry with a guess.
## What the ref carries
`IdentityRef`: `id`, `connector`, `externalId`, `storageRef`. Names, pictures and who holds the account are not on it: the platform's view comes from your `IdentityResolver::describe()`, the core's from `findCreator()`/`findMember()`.
A message that says "I am the owner" proves nothing. The only paths that create a creator link are a handshake the core minted, a sign-in the platform vouched for (adopted through your `IdentityResolver`), and a registration the connector ran. Everything else is a member link at most.
---
# Core API
Source: https://docs.subscriby.net/sdk/v1/core-api
Ports are the core calling the connector. The **Core API** is the inverse: interfaces under `Subscriby\Connector\Core\*` that the application implements and binds in its container, and that a connector calls instead of importing application classes. A core refactor that keeps the contract breaks no connector, and a connector never touches a table it does not own.
## Getting hold of a contract
Ask for the interface in a constructor. The application's connector service provider binds each one to its implementation, so any class the container builds (a port, a handler, a job in your package) receives it:
```php
use Subscriby\Connector\Core\Identities;
use Subscriby\Connector\Core\Installations;
final class ExampleInboundController
{
public function __construct(
private readonly Installations $installations,
private readonly Identities $identities,
) {}
}
```
## The nine contracts
| Contract | Reads | Writes |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| [`Installations`](/sdk/v1/core-api/installations) | By id, platform id or your storage ref; a project's or the platform's list; the stored credentials of one you can name. | `record()` an installation, `recordState()` a health verdict, `forget()` one. |
| [`Identities`](/sdk/v1/core-api/identities) | By id or platform id; who holds an identity (creator, member). | `record()` an identity, link it to a creator or a member, complete a handshake. |
| [`Spaces`](/sdk/v1/core-api/spaces) | By id, or by installation, kind and platform id. | `record()` a place, `bindResource()` an existing resource to it. |
| [`Resources`](/sdk/v1/core-api/resources) | By id, by place, or a project's list on your connector. | `create()` the resource that sells a place the creator picked. |
| [`Grants`](/sdk/v1/core-api/grants) | By id, or by the reference you issued. | `record()` a grant (migration and legacy mirroring). |
| [`Creators`](/sdk/v1/core-api/creators) | — | `register()` an email-first creator account from a connector's sign-up; `isEmailTaken()` to refuse a taken address at the step it is typed. |
| [`Alerts`](/sdk/v1/core-api/alerts) | Whether a creator's alerts reach one of their accounts. | Switch that account on or off for every alert class. |
| [`Recovery`](/sdk/v1/core-api/recovery) | What the application keeps ready for a project's recovery on your connector. | `registerStandby()` a reserve place for a resource, `replaceSpace()` the place it lost. |
| [`Support`](/sdk/v1/core-api/support) | Whether a project takes support messages; the thread behind a relay ping, a quoted relay message or a relay-space thread; the project's relay target. | `ingest()` a member's message, `reply()` with a creator's answer written on the platform, `linkRelaySpace()` / `unlinkRelaySpace()`. |
## Refs and records
Two families of value object cross the boundary:
- **Refs** (`InstallationRef`, `IdentityRef`, `SpaceRef`, `ResourceRef`, `GrantRef`, `ProjectRef`, `CreatorRef`, `MemberRef`, `HandshakeRef`) name a row the core already has: its UUID, the external id and, where you keep a row of your own, your `storageRef`. The core hands them to your ports and the Core API hands them back from every read and write.
- **Records** (`InstallationRecord`, `IdentityRecord`, `SpaceRecord`, `GrantRecord`, `CreatorRegistration`) describe what the core should write. Every `record()` is idempotent on the natural key the page names: a second record of the same bot, account, place or grant updates the row rather than adding one, so a connector may record on every event and on every deploy.
Eloquent models never cross. A ref carries what a port needs to act; when a port needs more than a ref holds, the answer is a new field on the ref or a new read on the contract, never a query.
## Exceptions
| Exception | Thrown by | Factories |
| -------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| `Subscriby\Connector\Exceptions\HandshakeRefused` | `Identities::completeHandshake()` | `expired()`, `identityHeld()`, `purposeNotSupported()`, `installationMismatch()` |
| `Subscriby\Connector\Exceptions\RegistrationRefused` | `Creators::register()` | `emailTaken()`, `accountRefused()` |
| `Subscriby\Connector\Exceptions\ResourceRefused` | `Resources::create()`; the recovery writes that name a resource. | `kindOutsidePlace()`, `placeUnknown()`, `projectUnknown()`, `resourceUnknown()`, `notPermitted()`; read `$reason`. |
| `Subscriby\Connector\Exceptions\RecoveryRefused` | `Recovery::registerStandby()`, `Recovery::replaceSpace()` | `because()`; read `$reason` and relay `userMessage()`, the core's translated sentence. |
| `Subscriby\Connector\Exceptions\SupportRefused` | `Support::ingest()`, `Support::reply()`, the relay-space writes. | `conversationUnknown()`, `projectUnknown()`, `installationUnknown()`, `spaceUnknown()`, `notPermitted()`; read `$reason`. |
| `Subscriby\Connector\Exceptions\ConnectorNotRegistered` | A contract asked about a connector key the registry lacks. | — |
Every other refusal is a null read: a `find*()` that answers null means the core knows no such row, and the connector decides what that means for the event in hand.
## What the contracts cover
Installations, identities, spaces, resources, grants, creator registration, alert routing, recovery coverage with the standby and replacement writes and the support inbox in both directions. Everything a connector does against the core goes through these nine contracts, and the [tutorial](/sdk/v1/tutorial) walks the calls in the order a connector makes them.
Every write on the Core API applies the same rules as the dashboard action behind it: a creator who may not link an identity to another creator's account is refused with the same exception whether they try on the web or from a chat. Your connector never checks a permission; it presents the facts and reads the answer.
---
# Installations
Source: https://docs.subscriby.net/sdk/v1/core-api/installations
`Subscriby\Connector\Core\Installations`. An **installation** is a connector set up on a project (the project's bot, the project's server) or, for a `platform`-scope connector, Subscriby's own shared presence on the platform. The core owns the row, its encrypted credentials, its state and its health; a connector reads it here and writes what it learns.
## Reads
| Method | Returns | Null when |
| ----------------------------------------------- | -------------------------------- | -------------------------------------------------- |
| `find(string $id)` | `?InstallationRef` | No installation has that UUID. |
| `findByExternalId(string $connector, string $externalId)` | `?InstallationRef` | The platform id is unknown to the core. |
| `findByStorageRef(string $connector, string $storageRef)` | `?InstallationRef` | Nothing points at that row of yours. |
| `listForProject(ProjectRef $project, ?string $connector = null)` | `list` | Never null; live and standby, one connector's or every connector's. |
| `listPlatform(string $connector)` | `list` | Never null; the connector's platform-scope installations. |
| `credentials(InstallationRef $installation)` | `CredentialBag` | Throws `ResourceNotFoundException` for an unknown ref; empty for a disconnected installation. |
`findByStorageRef()` is the read your inbound handler makes most: a webhook arrives for a bot, your table knows the bot's row, and the ref tells you which core installation (and so which project) it belongs to.
## `credentials()`
```php
$bag = $installations->credentials($installation);
$secret = $bag->get('webhook_secret');
```
The stored bag of an installation the connector can already name: what the creator pasted into the install form plus what the connector put in its `InstallationSummary::$meta` when it connected (`InstallationSummary::credentialsFor()` merges the two before the row is written). It exists for the one moment a connector needs a secret before the core has handed it anything: an inbound gateway verifying a signature. It widens nothing a port call would not have given, because every port receives the same bag for the same installation.
## `record()`
```php
$ref = $installations->record(new InstallationRecord(
connector: 'example',
scope: InstallationScope::Project,
externalId: $bot->id,
displayName: $bot->name,
projectId: $project->id,
role: InstallationRole::Live, // or Standby
state: InstallationState::Connected,
stateReason: null,
handle: $bot->username,
avatarUrl: $bot->avatarUrl,
storageRef: (string) $row->id,
credentials: null, // only when the connector holds secrets the core should store
settings: [],
connectedAt: now(),
verifiedAt: now(),
));
```
Idempotent on the connector key and the platform id: a second record of the same bot or app updates the row rather than adding one. When a connector's `InstallationLifecycle::complete()` answers, the core records the installation from the summary itself, so a connector calls `record()` directly only when it learns of an installation outside that flow.
## `recordState()`
```php
$installations->recordState($ref, InstallationState::Revoked, reason: 'token_revoked');
```
Your health probes call this instead of re-describing the whole installation, so a probe that only learnt "the token was revoked" writes exactly that. `reason` is your own code; the core shows a generic sentence for it and prefers the words your `RecoverySupport::healthReasonText()` gives.
## `forget()`
Drop an installation whose row on the connector's side is gone. It exists for the first-party connector, whose own tables led the neutral rows for a while; disconnect and uninstall keep the row, so once the neutral rows lead this call has no caller. A connector built on the SDK from day one never calls it.
## What the ref carries
`InstallationRef`: `id`, `connector`, `scope`, `projectId`, `externalId`, `storageRef`, `handle`. Enough to act on the platform (with the `CredentialBag` the core hands your port) and to find your own row. Credentials, settings and health are never on the ref: the core hands credentials to ports per call (or through `credentials()` when a gateway asks first), and health is written, not read, from a connector.
Disconnect wipes credentials and sets `Disconnected`; uninstall stamps the row and keeps it; a reconnect names the same row through `InstallationRequest::$existing`. The platform id is the identity of the row for its whole life, which is why `record()` keys on it.
---
# Recovery
Source: https://docs.subscriby.net/sdk/v1/core-api/recovery
`Subscriby\Connector\Core\Recovery`. The readiness checklist is the connector's to word (a "standby bot", a "mirrored channel") but the facts behind it are the application's: which installation stands by, which spaces have a standby and whether it is healthy, whether posts are mirrored, whether failover is automatic. A connector reads them here and answers `RecoverySupport::readinessChecks()` without importing an application class. The two writes are the answers to a `Standby` and a `Replacement` [link request](/sdk/v1/ports/space-catalog): the creator picked a place on the platform, and the connector files it here.
## `coverage()`
```php
$coverage = $recovery->coverage($project, 'example');
$coverage->standbyInstallation; // bool: a standby installation is registered
$coverage->autoFailover; // bool: the creator switched automatic failover on
foreach ($coverage->spaces as $space) {
$space->resourceId; // the gated resource
$space->kind; // its stored kind, connector:kind
$space->hasStandby; // a standby place is linked
$space->standbyHealthy; // the last probe of the standby was ready
$space->mirrored; // posts are copied into it
}
```
`RecoveryCoverage` names the project's standing on your connector, one `SpaceCoverage` per gated place.
## Wording a checklist from it
```php
public function readinessChecks(InstallationRef $installation, ProjectRef $project): array
{
$coverage = $this->recovery->coverage($project, 'example');
return [
new ReadinessItem(
key: 'standby-bot',
label: __('A standby bot is ready'),
description: __('If Example bans your bot, the standby takes over in minutes.'),
icon: 'shield-check',
satisfied: $coverage->standbyInstallation,
prevention: true,
fixRoute: 'recovery.prevention',
),
...array_map(fn (SpaceCoverage $space): ReadinessItem => new ReadinessItem(
key: 'standby-room-'.$space->resourceId,
label: __('Every room has a standby'),
description: __('A standby room is where members are moved when a room is lost.'),
icon: 'home',
satisfied: $space->hasStandby && $space->standbyHealthy,
prevention: true,
fixRoute: 'recovery.prevention',
detail: $space->hasStandby ? null : __('No standby yet.'),
), $coverage->spaces),
];
}
```
Keys must be unique within the connector (`recovery.vocabulary_and_readiness`); `fixRoute` is a named route of the core's, and the core builds the link. Items with `prevention: true` are shown locked to creators without the prevention tier.
## `registerStandby()`
```php
$recovery->registerStandby($resource, $reserve, mirror: false);
```
File a place as a resource's standby: the reserve the core switches to, by the creator's click or automatic failover, when the resource's place is lost. `$resource` is the `ResourceRef` the request named (`Resources::find()` or `findBySpace()` gives you one), `$reserve` the place the creator picked, recorded through `Spaces::record()` first. With `mirror: true` every post in the resource's place is copied into the reserve from now on, for a connector whose manifest declares `recovery_mirror`.
## `replaceSpace()`
```php
$recovery->replaceSpace($resource, $replacement);
```
Point a resource at the place that replaces the one it lost. The core opens or continues the recovery operation, re-points the resource, revokes the links into the old place and re-admits everyone with active access into the new one; the creator follows the roll call on the Disaster Recovery page. It is the dashboard's own swap, so the owner guard, the allowance and the notices are the same whichever road the creator took.
## Refusals
Both writes throw `Subscriby\Connector\Exceptions\ResourceRefused` for a resource the core does not know, and `Subscriby\Connector\Exceptions\RecoveryRefused` for anything the core's recovery rules refuse: the actor does not own the project, the plan lacks prevention, the project switched standby places off for your connector, the place is another kind, already sold or already a standby, or the self-service allowance for the window is spent. `RecoveryRefused` carries the core's translated sentence in `userMessage()`, which is what you relay to the creator on the platform, and a stable `reason` you may branch on to say it in your own nouns instead.
## What is not here
Incidents, operations, undo, quotas and the notices to members are the core's whole subsystem and never read by a connector; the connector only witnesses (probes) and acts (standby, failover, mirror, relink) when asked through `RecoverySupport`, and files the two places above when the creator answers a request.
Coverage is computed when you ask; do not cache it across requests. The Readiness page asks once per render.
---
# Resources
Source: https://docs.subscriby.net/sdk/v1/core-api/resources
`Subscriby\Connector\Core\Resources`. A **space** is a place the installation administers; a **resource** is the core's decision to sell access to it. Recording a place through [`Spaces::record()`](/sdk/v1/core-api/spaces) sells nothing. This contract is where the decision is written: the moment the platform answers a [`SpaceCatalog`](/sdk/v1/ports/space-catalog) link request with the place the creator chose, the connector records the place and asks for the resource here, and the core writes the row, binds it to the space and announces it exactly as the dashboard would have.
## Reads
| Method | Returns | Null when |
| -------------------------------------------------------- | ------------------- | -------------------------------------------------------------------- |
| `find(string $id)` | `?ResourceRef` | No resource has that UUID. |
| `findBySpace(SpaceRef $space)` | `?ResourceRef` | No project sells that place. |
| `listForProject(ProjectRef $project, string $connector)` | `list` | Never null; the project's resources on your connector, oldest first. |
`findBySpace()` answers by the space row or, for a connector whose pre-SDK rows the core still mirrors, by the row the place's `storageRef` names, so a place reads the same before and after the core has bound its resource.
## `create()`
```php
$resource = $resources->create(
new ProjectRef($parked->project_id),
$space, // recorded through Core\Spaces first
ResourceKind::for('example', 'room'),
title: $room->title,
description: __('Access to :room while your membership is active.', ['room' => $room->title]),
);
```
The core writes the row under the project, active, with the kind's connector, kind and space written together (the same step as `Spaces::bindResource()`), broadcasts it to the creator's open dashboard, and emits [`project.resource.linked`](/webhooks/v1/events/project#project-resource-linked) with `connector`, `kind` and `space_id` beside the fields the event always carried.
**Idempotent on the project and the place.** A place the project already sells is answered with its resource rather than a second row, so a connector may call this on every answer the platform gives, including a replayed one.
**Authorised as the dashboard is.** The write acts for the creator the request runs as: the inbound handler that received the platform's answer, or the web request the creator made on your picker page. It is refused exactly as "Add a resource" would be for someone who may not add resources to that project, and a call with nobody acting is refused too.
## Refusals
`Subscriby\Connector\Exceptions\ResourceRefused`, with a stable `reason` a connector can branch on and a message for the developer, never for a creator:
| `reason` | When |
| -------------------- | ------------------------------------------------------------------------------------ |
| `kind_outside_place` | The kind is `manual` or names another connector than the place's. |
| `place_unknown` | The space was never recorded through `Core\Spaces`. |
| `project_unknown` | No project has that id. |
| `not_permitted` | The actor may not add resources to the project, or nobody is acting. |
| `resource_unknown` | Raised by the [recovery writes](/sdk/v1/core-api/recovery) that name a resource. |
## What the ref carries
`ResourceRef`: `id`, `projectId`, `kind` (a `ResourceKind`, `connector:kind`; the manual perk while the core has bound the resource to no place), `title`, `spaceId`, `active`. Plans, grants and health never travel on it: the core decides who may be in a place and asks your ports to act.
Record the place, create the resource, then delete whatever you parked for the
request. The core's Resources page shows the new row at once, and the creator
adds it to a plan from there; a connector never touches plans.
---
# Spaces
Source: https://docs.subscriby.net/sdk/v1/core-api/spaces
`Subscriby\Connector\Core\Spaces`. A **space** is a place the installation administers: a channel, a group, a guild, a role, a board. A **resource** is the core's decision to sell access to it. Recording and binding are separate so a connector can report every place it learns about without deciding anything for the creator.
## Reads
| Method | Returns | Null when |
| ---------------------------------------------------------------------------------- | ------------ | ------------------------------------------- |
| `find(string $id)` | `?SpaceRef` | No space has that UUID. |
| `findByExternalId(InstallationRef $installation, string $kind, string $externalId)` | `?SpaceRef` | The installation knows no such place of that kind. |
A join request, a member event or a post arrives naming a chat; `findByExternalId()` tells you whether the core gates it and which `SpaceRef` to pass to your ports.
## `record()`
```php
$space = $spaces->record(new SpaceRecord(
connector: 'example',
installationId: $installation->id,
externalId: $chat->id,
kind: 'room', // one of your resource kinds, without the connector prefix
title: $chat->title,
parentExternalId: null, // a role's guild, a topic's group
storageRef: (string) $row->id,
meta: [],
));
```
Idempotent on the installation, the kind and the platform id; a second record refreshes the title and parent. Record a place when the platform tells you about it (a chosen chat, a guild the app joined, a channel that was renamed), so the core's rows carry the current title without asking the platform on every page.
## `bindResource()`
```php
$spaces->bindResource($space, $resourceId, ResourceKind::for('example', 'room'));
```
Point a resource at a space. The core writes the resource's connector, kind and space in one step so the three can never disagree; a resource bound to another space is re-pointed. `ResourceKind::for($connector, $kind)` builds the stored `connector:kind` value; `ResourceKind::fromString()` parses one, and `ResourceKind::manual()` names the core's own kind for a perk the creator fulfils by hand.
Use it when a place should gate an *existing* resource outside the recovery ledger, which is what a migration moving legacy rows does. A newly picked place becomes a resource through [`Resources::create()`](/sdk/v1/core-api/resources), which binds it in the same step; a place that replaces one a resource lost goes through [`Recovery::replaceSpace()`](/sdk/v1/core-api/recovery), so the ledger, the allowance and the re-admission run.
## What the ref carries
`SpaceRef`: `id`, `connector`, `externalId`, `kind` (the stored `connector:kind`), `storageRef`, `parentExternalId`. Health is written by the core from your probes and never read from a connector; the title is on the row, refreshed by your `record()`.
Recording a space sells nothing. Report every place you learn about; the creator decides which become resources, and the core decides who may be in them.
---
# Support
Source: https://docs.subscriby.net/sdk/v1/core-api/support
`Subscriby\Connector\Core\Support`. The inbox is the core's: it opens one thread per member per connector, keeps the messages, throttles a flood, drops a blocked member's words, shows the thread in the dashboard and announces it to [webhooks](/webhooks/v1/events/support#support-conversation-opened). A connector is the way words enter and leave. Inbound, it hands the core what a member wrote in the installation's private conversation and sends back the project's acknowledgement. Outbound, when the creator answers **on the platform** rather than in the dashboard (a reply typed into the relay's ping, or written inside the thread the relay opened in a group), it hands that answer to the core too, so every reply is recorded, delivered and announced the same way whatever surface it came from. The [`SupportRelay`](/sdk/v1/ports/support-relay) port is the other direction: the core calling you to carry a reply or a thread to the platform.
## Filing a member's message
```php
$installation = $bot->installationRef();
if (! $this->support->acceptsMessages($installation)) {
return false; // support is off: fall through to "I did not understand"
}
$message = $this->mapper->map($update); // an InboundSupportMessage
if ($message->isEmpty()) {
return false;
}
$ingested = $this->support->ingest($installation, $chat->identityRecord($displayName), $message);
if ($ingested?->autoReply !== null) {
$this->send($chat, $ingested->autoReply); // the project's acknowledgement, yours to render
}
```
| Method | Returns | Notes |
| ------------------------------------------------------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `acceptsMessages(InstallationRef $installation)` | `bool` | Whether the project behind the installation takes support messages. Ask first, so a message the project does not want goes to your own fallback instead of the inbox. False for a platform-wide installation. |
| `ingest(InstallationRef, IdentityRecord $author, InboundSupportMessage)` | `?IngestedSupportMessage` | Records the account (idempotent on the platform's id), finds the member who holds it in the project or **creates them as a lead**, opens or reopens their thread on your connector, records the message. Null when the core dropped it on purpose: support off, the member blocked, the member over the inbound allowance, nothing to file. |
`IngestedSupportMessage` carries the `SupportMessageRef` (`id`, `conversationId`, `projectId`) and `autoReply`: the project's acknowledgement when one is due (the first message of a thread, by the core's rules), or null. The connector renders and sends it; the core never writes to a platform on its own.
**Edits and replays.** A message the platform already delivered is matched by `externalId` and `externalChatId` and updated in place, so an edit and a replayed webhook both leave one row. Pass both on every inbound message.
### `InboundSupportMessage`
The same shape carries a member's question in and a creator's answer back. `kind` is a `SupportMessageKind` (`text`, `photo`, `video`, `audio`, `voice`, `document`, `sticker`, `animation`, `location`, `contact`); a captioned photo is a `photo` whose `body` is the caption. `attachments` is a list of `InboundSupportAttachment`: the `kind`, the `platformFileId` (the platform's own reference; the core fetches the bytes through [`SupportRelay::fetchAttachment()`](/sdk/v1/ports/support-relay#attachments) the first time a creator opens the thread, never here), and whatever the platform said about it (`mime`, `fileName`, `size`, `width`, `height`, `duration`, `thumbnailFileId`, and `meta` for a sticker's emoji, an audio title, coordinates, a contact's name and number). `externalId`, `externalChatId` and `quotedExternalId` are the platform's ids; `meta` keeps anything else (an album id, an edit flag). `InboundSupportMessage::text($body)` builds a plain text, and `isEmpty()` says whether there is anything to file.
## A creator's answer written on the platform
```php
$conversation = $this->support->conversationForRelayMessage((string) $chat->chat_id, (string) $quotedMessageId);
if ($conversation === null) {
return false; // not one of the core's relay pings
}
try {
$this->support->reply($conversation->ref(), new CreatorRef($creator->getKey()), InboundSupportMessage::text($text), SupportReplySource::DirectMessage);
} catch (SupportRefused) {
return false;
}
$this->send($chat, __('✅ Sent to :name.', ['name' => $conversation->memberName]));
```
| Method | Returns | Notes |
| ---------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reply(ConversationRef, ?CreatorRef $author, InboundSupportMessage, SupportReplySource)` | `SupportMessageRef` | Records the answer and **queues** its delivery to the member on the connector their thread is on, through your `SupportRelay`. The author is checked as the dashboard checks a reply (the owner, or a member of the team); **null records it as the owner's**, for a message written somewhere only the creator's staff can write. |
| `conversationForReply(string $conversationId, CreatorRef $author)` | `?SupportConversationSummary` | The thread a creator chose (a Reply button on your relay ping carries its id), or null when it is gone or this creator may not answer it. Ask before parking the id and prompting for words. |
| `conversationForRelayMessage(string $externalChatId, string $externalMessageId)` | `?SupportConversationSummary` | The thread a relay message announced. The core remembers the platform id of every relay message it sent for a month, so a native "reply to message" needs no button and no parked state. |
| `conversationForThread(InstallationRef, string $threadId)` | `?SupportConversationSummary` | The thread mirrored into one thread of the project's relay space, by the id [`openThread()`](/sdk/v1/ports/support-relay#group-threads) answered with. |
`SupportReplySource` says where on your connector the creator wrote: `DirectMessage` (typed into the private conversation your relay pinged them in) or `RelaySpace` (written inside the thread your relay opened in a shared space). The inbox stores it as `connector_relay_dm` or `connector_relay_group` on the message's `source`.
`SupportConversationSummary` is the thread as you may read it back: `id`, `projectId`, `connector`, `memberName` (for your own reply, "Sent to Ada."), and `ref()` for the writes. The messages, the status moves and the creator's notes stay the core's.
Do not check permissions yourself. `reply()` with a `CreatorRef` refuses
anyone who is neither the project's owner nor on its team;
`conversationForReply()` answers null for the same people. When a message
arrives from a place you have verified is the project's linked relay space,
pass `null` as the author: the group is the creator's own staff room, and the
answer is recorded as the owner's.
## The relay space
A project that relays into a shared space (a topic per member in a forum group, a thread per member in a channel) has linked one place. The core keeps it; you tell the core when the platform has made the installation able to write there, and read it back to recognise messages that come from it.
| Method | Returns | Notes |
| -------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `relayTarget(InstallationRef)` | `?SupportRelayTarget` | The project's relay `mode`, in the words your manifest declares under [`relay_modes`](/sdk/v1/manifest/relay-modes) (or `none`), and the linked `space` when the mode uses one. `isSpace($externalId)` compares a place's platform id. Null for an installation that names no project. |
| `linkRelaySpace(InstallationRef, SpaceRef $space)` | `void` | Records the place. Called when the platform made the installation an administrator there, never from a typed id; authorised as the project's settings are, with the owner acting. |
| `unlinkRelaySpace(InstallationRef)` | `void` | Forgets it, once the installation can no longer write there. |
The `SpaceRef` you pass is the one you recorded through [`Core\Spaces`](/sdk/v1/core-api/spaces), or the stand-in ref of your own row while your rows still lead; the core keeps both pointers.
## Refusals
`Subscriby\Connector\Exceptions\SupportRefused`, with a stable `reason` a connector can branch on and a message for the developer, never for a creator or a member:
| `reason` | When |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| `conversation_unknown` | `reply()` named a thread that does not exist. |
| `not_permitted` | The `CreatorRef` may not answer on the project, or nobody may act on it for a relay-space write. |
| `installation_unknown` | The installation names no project (unknown, or platform-wide). |
| `project_unknown` | The thread's project is gone. |
| `space_unknown` | `linkRelaySpace()` was given a ref that names no recorded space and no row of your own. |
Every other refusal is a null read: `ingest()` answering null means the core chose to drop the message, and the `conversationFor*()` reads answer null for a thread that is gone or not yours to touch.
## How Telegram does it
The project bot's last message stage asks `acceptsMessages()`, maps the update (photo, sticker, voice note, document, location, contact) into an `InboundSupportMessage`, files it with the chat's `identityRecord()` and the sender's name, and sends the `autoReply` as the bot's own message. On the platform bot, the relay ping's **Reply** button parks the thread `conversationForReply()` confirmed and the next typed message goes through `reply()` as a `DirectMessage`; a native reply to the ping is routed through `conversationForRelayMessage()` without any parked state. On a project bot administering a forum group, a message in a topic is matched through `relayTarget()->isSpace()` and `conversationForThread()`, attributed to the teammate `Core\Identities::findCreator()` names when `conversationForReply()` lets them answer, and to the owner otherwise; the group is linked and forgotten from the bot's `my_chat_member` updates through `linkRelaySpace()` and `unlinkRelaySpace()`.
A thread is keyed on the project, the member and your connector key: a member
who writes through two connectors has two threads, and each is answered on the
connector it came in on. The `channel` field the [REST
API](/api/v1/reference/support-inbox) and the [MCP
tools](/mcp/v1/tools/support#list-support-conversations) show is that key.
---
# Connectors for Developers
Source: https://docs.subscriby.net/sdk/v1
Subscriby runs paid memberships on messaging and community platforms. The core knows nothing about any particular platform: it keeps projects, plans, members, subscriptions, payments and grants, and asks a **connector** to do everything that touches a platform. Telegram is the first connector; Discord is being built; the rest of the roadmap, and any platform a third party brings, arrive the same way.
A connector is a Composer package built on the **connector SDK** (`subscriby/connector-sdk`, version `1.0`). It carries a `connector.json` that says what it is and what it can do, and PHP classes that implement the SDK's **ports**, the interfaces the core calls. Nothing else is required: the SDK's service provider wires the package into the application, the core renders the connector's install form from the manifest, and the marketplace builds the connector's page from the same file.
## What the core does for you
- **Installations, identities, spaces and grants** live in the core's own tables. A connector never stores who a member is or which subscription grants what; it records them through the Core API (`Subscriby\Connector\Core\*`) and reads them back the same way.
- **Money is the core's.** Checkout, settlement, refunds, dunning and the entitlement rules are core; a connector that offers a platform's own payment method implements one contract and lets the core account for it.
- **Messages are written once.** Every confirmation, reminder, broadcast and support reply is composed by the core in a canonical HTML subset with a closed set of actions; the connector renders it within the limits its manifest declares.
- **Health and recovery are one model.** The core probes installations and places on a schedule and runs the Disaster Recovery Program; a connector answers probes and performs the platform-side steps it declares it can.
- **Every surface is fed at once.** The dashboard, the REST API, the MCP server, webhooks, Zapier and n8n all read the same manifest and the same rows, so a connector appears everywhere by existing.
## The boundary
The only thing that crosses between the core and a connector, in either direction, is the SDK:
- A connector imports `Subscriby\Connector\*` and nothing under the application's namespace. Tests fail a package that does.
- The core never imports a connector's namespace. It reaches a connector through the registry and the ports, and it reaches a connector's UI through typed **slots** the connector fills.
- Data crosses as SDK value objects (`InstallationRef`, `IdentityRef`, `SpaceRef`, `GrantRequest`, `Message`, …), never as Eloquent models.
There is no runtime plugin upload. A connector is a package Subscriby reviews and installs; whether it is available to creators is then a deployment setting, and a connector can be paused during an incident without a deploy. The marketplace's **Community** badge marks a connector built by a third party against the SDK and reviewed by Subscriby.
## Where to start
The package layout, the service provider, the `Connector` class and the ports every connector must bind.
Every block of `connector.json`, one page each, with the published JSON Schema.
One page per port: what the core asks of it, when it is called, and what the kit checks.
The contracts a connector calls into the core, one page each: installations, identities, spaces, grants, creators, alerts, recovery, payment methods.
The tables a connector may create, the ones it may never touch, and how legacy data moves.
The twenty-three rules every connector passes, one by one, and what review adds.
The fakes, their assertions, what your own suite covers, and how the kit runs against a package.
Versioning, translations, review, the lanes, updating after first publish, pausing and retiring.
From an empty directory to a package that passes the kit, for an imaginary forum with private boards.
The listing block, the marketing words, the badges Subscriby stamps, and the review checklist.
## Related
- [Connectors Marketplace](/connectors/marketplace) — what creators see, lane by lane.
- [`GET /connectors`](/api/v1/reference/connectors) — the same catalogue over the REST API.
- [Telegram](/connectors/telegram) — the first connector, as creators use it.
---
# capabilities
Source: https://docs.subscriby.net/sdk/v1/manifest/capabilities
`capabilities` is the list of things the connector can do. Each capability names a port: declaring the capability obliges the `Connector` class to bind that port, and binding a port obliges the manifest to declare a capability that needs it. The registry checks both directions at boot, so a mismatch fails on the developer's machine, and the marketplace renders the capability matrix from this list.
```json
"capabilities": ["messaging", "broadcasts", "access_control", "early_admission_hold", "support_relay", "management_surface", "portal_login", "recovery_probes"]
```
## The fourteen capabilities
| Capability | Binds | Group | Means the connector can… | Creator can switch off |
| -------------------------------- | ------------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------- | :--------------------: |
| `messaging` | `Messenger` | messaging | Deliver the core's messages to one person: confirmations, reminders, alerts, support replies. | ✅ |
| `broadcasts` | `Messenger` | messaging | Carry bulk sends to a project's members, paced from the manifest. | ✅ |
| `access_control` | `AccessController` | access | Grant and revoke access to the places its resource kinds name, report a member's standing, reconcile the platform with the ledger. | — |
| `early_admission_hold` | `AccessController` | access | Pre-issue a dated grant and hold it at the door until its window opens (`admit()`). | — |
| `support_relay` | `SupportRelay` | messaging | Carry a support conversation between a member and a creator over the platform, in the modes `relay_modes` lists. | ✅ |
| `native_payments` | `ProvidesPaymentMethods` | payments | Offer a payment method that exists only on the platform (Telegram Stars). **Official connectors only.** | ✅ |
| `portal_login` | `PortalLoginMethod` | access | Sign a member into the portal through the platform, completing the core's handshake. | — |
| `creator_registration` | `RegistersCreators` | access | Create a creator account from inside the platform (a sign-up conversation on the shared installation). | — |
| `management_surface` | `ManagementSurface` | access | Render the core's admin command catalogue in-chat, for the commands `management_commands` lists. | — |
| `recovery_probes` | `RecoverySupport` | recovery | Answer the Disaster Recovery Program's health probes on installations, places and accounts. | ✅ |
| `recovery_standby_installations` | `RecoverySupport` | recovery | Keep a second installation registered and ready to take over. | ✅ |
| `recovery_resource_standby` | `RecoverySupport` | recovery | Move every holder from a lost place to its standby (`failOver()`). | ✅ |
| `recovery_mirror` | `RecoverySupport` | recovery | Copy posts from a place into its standby so the standby is never empty. | ✅ |
| `recovery_identity_relink` | `RecoverySupport` | recovery | Prove that a creator controls a new account after losing the old one (`beginIdentityHandshake()`). | ✅ |
Two capabilities bind the same port in three cases (`messaging`/`broadcasts`, `access_control`/`early_admission_hold`, the five `recovery_*`). Declaring the second adds an obligation on the same class, never a second class: a `Messenger` under `broadcasts` must tolerate bulk sends, an `AccessController` under `early_admission_hold` must implement `admit()` rather than throw, a `RecoverySupport` under `recovery_mirror` must implement `mirror()`. The recovery facets also have to be switched on in the [`recovery`](/sdk/v1/manifest/recovery) block; the capability binds the port, the block tells the core which facets are real.
## The groups
The dashboard's Connectors tab, the marketplace's capability matrix and `GET /connectors` shelve capabilities under four groups (`Capability::group()`): **messaging** (`messaging`, `broadcasts`, `support_relay`), **access** (`access_control`, `early_admission_hold`, `portal_login`, `creator_registration`, `management_surface`), **recovery** (the five `recovery_*`) and **payments** (`native_payments`).
## Per-installation switches
A creator may switch some capabilities off on one installation, from the Configuration tab, `PATCH …/installation/settings` with a `capabilities` map, or the MCP tool. The core reads the switch before it invokes your port, so a switched-off port is never called and a connector never reads the switch itself. The column above marks which: messaging, broadcasts, the support relay, native payments and every recovery facet can be switched off; access control, early admission, the management surface, portal sign-in and creator registration cannot, because a member who paid must still get in and a creator must still be able to reach the admin surface.
## The port that needs no capability
One port is bound freely, because the core asks for it only when it is bound:
- `SpaceCatalog`: how a creator picks a place to gate (Telegram's chat picker, a guild list). A connector with `access_control` almost always binds it; without it, the core offers no "Link a resource" for the connector and a resource can only exist through the connector's own surface.
## What the registry refuses at boot
| Situation | Error |
| ---------------------------------------------------------------- | ---------------------------------------------------------------- |
| A capability is declared and its port is not bound. | `declares support_relay but binds no …\Ports\SupportRelay` |
| A capability-bound port is bound and no capability declares it. | `binds …\Ports\Messenger without declaring a capability that needs it` |
| `native_payments` on a connector Subscriby has not configured as official. | `native_payments is reserved for official connectors` |
Each is an `InvalidManifest` exception naming the connector, and the connector is not registered. The checks are the SDK's own (`Subscriby\Connector\Registry\PortAgreement`), and the kit's `TestRegistry` runs them when your suite registers the connector, so the refusal reaches you in your own tests with the same words. The kit's `ports.required_bound` rule adds the seven ports every connector must bind whatever it declares: `InstallationLifecycle`, `IdentityResolver`, `InboundGateway`, `FailureClassifier`, `TextRenderer`, `SettingsSchema` and `UiSlots`.
## The Telegram list
```json
"capabilities": ["messaging", "broadcasts", "management_surface", "access_control", "early_admission_hold", "support_relay", "recovery_probes", "recovery_standby_installations", "recovery_resource_standby", "recovery_mirror", "recovery_identity_relink", "creator_registration", "portal_login", "native_payments"]
```
Every capability the SDK has, because Telegram's feature set is the hundred-percent scope every other connector is measured against. The fake connector declares six: `messaging`, `broadcasts`, `access_control`, `management_surface`, `portal_login`, `recovery_probes`.
The kit checks that the port is bound; review checks that it works. A connector that declares `recovery_resource_standby` and throws `UnsupportedByConnector` from `failOver()` passes the kit and fails review. Declare what the platform can actually do.
---
# Identity
Source: https://docs.subscriby.net/sdk/v1/manifest/identity
The five top-level scalars name the connector. All five are required.
```json
{
"key": "example",
"name": "Example",
"version": "1.0.0",
"sdk": "^1.0",
"vendor": "Acme"
}
```
## `key`
The connector's machine name: lower case, matching `^[a-z][a-z0-9_-]*$`, unique across every connector installed on an application. It is reused everywhere the connector has to be addressed, so choose it once and never change it:
| Where | Form | Example |
| ------------------------ | --------------------------------- | -------------------------------- |
| The connector's tables | prefix `_` | `example_bots` |
| Its configuration file | `config/connector-.php` | `config('connector-example.…')` |
| Its Blade views | namespace `connector-::` | `connector-example::install` |
| A resource kind as stored | `:` | `example:room` |
| A native payment provider | `:` | `example:coins` |
| The send rate limiter | `connector:` | `connector:example` |
| The inbound middleware | `connector.inbound:` | applied to `routes/inbound.php` |
| Availability and pausing | Subscriby's own switches, by key | `example` |
| Public pages | `/connectors/` | `/connectors/example` |
The kit's `manifest.key_matches` rule fails a package whose registered key differs from the file's, and `migrations.own_tables_only` reads the prefix from it.
## `name`
The display name, exactly as the marketplace card, the connector's page, the dashboard's Connectors tab and the API show it. It also fills the marketing site's `:platform` token when the connector is available, so write it as the platform is spelt ("Telegram", not "telegram bot").
## `version`
The package's own semantic version, matching `^\d+\.\d+\.\d+`. Shown on the connector's page under **Version** and compared with the file by `manifest.file_is_the_source`. Bump it with every release of the package; it has nothing to do with the SDK's version.
## `sdk`
The constraint on `subscriby/connector-sdk` the package was built against. The registry checks it at boot against the SDK the application runs (`Subscriby\Connector\Sdk::VERSION`, currently `1.1.0`) and refuses an incompatible package before a single call, rather than at the first method that no longer exists. The kit repeats the check as `manifest.sdk_constraint`.
The syntax is the small subset of Composer's the SDK evaluates itself, so no Composer library is needed at runtime:
| Constraint | Admits |
| ----------- | ------------------------------------------------------------- |
| `^1.0` | `>= 1.0.0` and `< 2.0.0` (below `1.0`, a caret stops at the next minor: `^0.4` is `< 0.5.0`). |
| `~1.2` | `>= 1.2.0` and `< 2.0.0`; `~1.2.3` is `>= 1.2.3` and `< 1.3.0`. |
| `>=1.1`, `>1.1`, `<=1.4`, `<2.0` | The comparison, on the version given. |
| `1.2` | `1.2.*`; a bare `1` is `1.*`. |
| `1.2.3` | Exactly that version. |
| `a || b` | Either alternative. |
Write `^1.0` unless the package needs something a later minor introduced, in which case name that minor (`^1.2`). The [CHANGELOG](https://github.com/envigoinnovations/subscriby-connector-sdk/blob/main/CHANGELOG.md) lists what each SDK version added.
## `vendor`
Who publishes the connector, as the connector's page shows it under **Made by**. Free text; write the organisation's name as it appears on its own site.
The **Official** badge, the availability of the connector and its place on the marketplace are set by Subscriby's configuration, never by the file. A package whose `vendor` reads "Subscriby" is still a **Community** connector until Subscriby configures it as official, and `native_payments` stays refused for it.
---
# connector.json
Source: https://docs.subscriby.net/sdk/v1/manifest
`connector.json` sits at the root of the package and is the only place a connector describes itself. Nothing about a connector's identity, capabilities, limits or listing lives in PHP: the `Connector` class only binds ports, and the registry checks that what the class binds agrees with what the file declares.
The SDK reads the file when the application boots. It collects every problem before it stops, so an author fixing three mistakes sees all three, each named by its dotted path (`listing.tagline`, `install.fields[1].type`). Unknown keys are refused at every level, which is what turns a typo into a boot failure instead of a silently missing option.
## The schema
The JSON Schema (draft 2020-12) is published at [`https://docs.subscriby.net/sdk/connector.schema.json`](/sdk/connector.schema.json). Point your editor at it with the `$schema` key and validation happens as you type:
```json
{
"$schema": "https://docs.subscriby.net/sdk/connector.schema.json",
"key": "example"
}
```
The SDK validates the structural rules in PHP with no extra dependency (required keys, types, enumerations, patterns, unknown keys, the field and link rules), so a package that fails in an editor fails at boot with the same path named. A few limits only the schema states, such as the eighty-character tagline, so keep the editor validation on. The typed result is `Subscriby\Connector\Data\ConnectorManifest`, the object the registry hands to every surface.
## The blocks
| Block | Required | What it declares |
| ------------------------------------------------------------- | :------: | --------------------------------------------------------------------------------------- |
| [Identity](/sdk/v1/manifest/identity) | ✅ | `key`, `name`, `version`, `sdk`, `vendor`. |
| [`install`](/sdk/v1/manifest/install) | ✅ | How a creator connects: mode, scopes, the install and settings forms as fields. |
| [`resource_kinds`](/sdk/v1/manifest/resource-kinds) | | Each kind of place the connector can gate and how access to it is granted. |
| [`capabilities`](/sdk/v1/manifest/capabilities) | | What the connector can do; each one binds a port. |
| [`messaging`](/sdk/v1/manifest/messaging) | ✅ | The platform's message limits. |
| [`pacing`](/sdk/v1/manifest/pacing) | ✅ | How fast the platform lets an installation send. |
| [`management_commands`](/sdk/v1/manifest/management-commands) | | Which of the core's admin commands the connector renders in-chat. |
| [`relay_modes`](/sdk/v1/manifest/relay-modes) | | The support relay modes the connector offers. |
| [`recovery`](/sdk/v1/manifest/recovery) | | Which Disaster Recovery facets the connector performs. |
| [`listing`](/sdk/v1/manifest/listing) | ✅ | What the marketplace shows, the words the marketing site borrows, the portal's button. |
## A complete example
```json
{
"$schema": "https://docs.subscriby.net/sdk/connector.schema.json",
"key": "example",
"name": "Example",
"version": "1.0.0",
"sdk": "^1.0",
"vendor": "Acme",
"install": {
"mode": "paste_credential",
"scopes": ["project"],
"fields": [
{
"name": "intro",
"type": "instructions",
"label": "Create a bot and paste its token.",
"steps": [
"Open the Example developer console.",
"Create a bot for your community.",
"Copy its token and paste it below, then click \":button\" in :app."
],
"links": { "Example developer console": "https://developers.example.com" }
},
{
"name": "token",
"type": "secret",
"label": "Bot token",
"help": "Keep it secret: anyone who has it controls the bot.",
"required": true,
"rules": ["string", "min:20"]
}
],
"settings_fields": [
{ "name": "welcome", "type": "text", "label": "Welcome message" }
]
},
"resource_kinds": [
{
"kind": "room",
"label": "Room",
"portal_label": "Private room",
"icon": "home",
"grant_mode": "membership",
"supports_early_admission_hold": false,
"mirrorable": false
}
],
"capabilities": ["messaging", "access_control", "management_surface", "recovery_probes"],
"messaging": {
"max_length": 2000,
"buttons_per_row": 3,
"max_buttons": 9,
"callback_data_bytes": 64,
"supports_underline": true,
"supports_spoiler": false,
"supports_files": true
},
"pacing": {
"min_interval_microseconds": 50000,
"burst": 20,
"per_recipient_interval_microseconds": 1000000
},
"management_commands": ["project_settings", "plan_manage", "broadcast"],
"relay_modes": [],
"recovery": { "probes": true },
"listing": {
"category": "community",
"tagline": "Sell access to Example rooms, granted and removed automatically.",
"overview": "Connect a bot and Subscriby runs your membership on Example…",
"screenshots": [],
"links": {
"documentation": "https://docs.acme.test/subscriby",
"support": "mailto:support@acme.test",
"privacy": "https://example.com/privacy",
"terms": "https://example.com/terms",
"homepage": "https://example.com"
},
"added_at": "2026-10-01",
"changelog_url": "https://acme.test/changelog",
"sign_in_required": false,
"portal_cta": { "label": "Open the Example bot" },
"marketing": {
"audience": "Example communities",
"place": "room",
"places": "rooms and halls",
"installation": "bot",
"identity": "Example account"
}
}
}
```
Two real manifests to read beside it: the SDK's own [fake connector](https://github.com/envigoinnovations/subscriby-connector-sdk/blob/main/src/Testing/connector.json), which declares as few capabilities as a connector can while still passing the kit, and the Telegram connector's, which declares every capability the SDK has and is quoted block by block through the pages that follow.
## How the file is read
1. The SDK's service provider locates the package root (the grandparent of the provider's `src/` directory) and loads `connector.json` from it. A missing file, invalid JSON or a top-level value that is not an object fails at once with the path.
2. Every block is read through a typed reader that records a problem and carries on: a missing required key, a wrong type, a value outside its enum, an unknown key, a string that fails its pattern, a field whose select has no options. The reader names the full path of each.
3. When any problem was recorded the provider throws `Subscriby\Connector\Exceptions\InvalidManifest` listing them all, and the connector is not registered.
4. The typed `ConnectorManifest` is handed to the registry with the `Connector` instance. The registry then checks the two against each other (a declared capability without its port, a bound port without its capability, a money-path capability on a package that is not official) and refuses the package on any mismatch. [Capabilities](/sdk/v1/manifest/capabilities) lists those checks.
The conformance kit's `manifest.file_is_the_source` rule loads `connector.json` from disk and compares the key, name, version, SDK constraint, capability count and field counts with what the registry holds. A package that builds its manifest in PHP, or edits it after registration, fails that rule.
## Translating what the file says
Every human-readable string in the file is written in English and doubles as a translation key: field labels, help texts, steps and select option labels are translated through the package's `lang/.json` files when the dashboard, the REST API or the apps render the form (the SDK's `ManifestSettingsSchema` does the lookup); resource-kind labels, the portal button label and the listing's tagline and overview go through the same files where a surface renders them. A connector that ships no translation for a string shows the English.
The five fields that name the connector, and everything the key is reused for.
Modes, scopes and the field specification the install and settings forms are built from.
---
# install
Source: https://docs.subscriby.net/sdk/v1/manifest/install
The `install` block is required. It says how an installation comes to exist, whose it can be, and what a creator types to make one. The forms are data, not views: the dashboard, `GET /connectors`, the MCP catalogue and the creator apps all render the same fields.
```json
"install": {
"mode": "paste_credential",
"scopes": ["project", "platform"],
"fields": [ … ],
"settings_fields": [ … ]
}
```
## `mode`
Required. One of three ways a creator connects:
| Mode | What happens | `InstallationLifecycle::begin()` returns |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| `paste_credential` | The creator creates something on the platform themselves (a bot, an app, an API key) and pastes its secret into the install form. Telegram's BotFather token is this mode. | A complete draft: `complete()` runs at once with the pasted values. |
| `oauth` | The creator signs in on the platform and authorises Subscriby; the platform hands back a token. The connector owns the round trip. | A draft with `continueUrl`, built on the request's `returnUrl`. The core parks the draft, sends the creator to the URL and, when the platform brings them back, calls `complete()` with the draft's `returned` query (`code`, `state`); the connector exchanges the code and puts the token in the summary's `meta`. See [the lifecycle port](/sdk/v1/ports/installation-lifecycle#begin-and-complete). |
| `shared_platform` | Subscriby runs one installation for everyone (its own app on the platform) and a project only links to it. Nothing is pasted. | A complete draft; the connector's `platformInstallation()` must answer. |
The kit's `settings.install_fields` rule expects a `paste_credential` connector to declare at least one input field and at least one `secret` among them, because there is nothing to paste otherwise.
## `scopes`
Required, one or both of:
- `project`: an installation a project owns (the project's bot, the project's server). Most connectors support this.
- `platform`: an installation Subscriby itself owns on the platform and every creator shares (the sign-in bot creators talk to, the presence alerts arrive from). A connector that lists it must answer `InstallationLifecycle::platformInstallation()` with that installation; the core hands it to `startLink()` for creator handshakes, to `RegistersCreators::signupEntry()` and to the recovery facets that need the platform's own installation to act.
Telegram declares both: each project has its own bot, and Subscriby's platform bot signs creators in.
## `fields` and `settings_fields`
Both optional, both a list of fields in display order. `fields` is the install form a creator fills in to connect; `settings_fields` is what they may change afterwards, on the installation's Configuration tab. When a connector declares them here it binds no `SettingsSchema` of its own: the core wires the SDK's `ManifestSettingsSchema` over the file, which also translates every label through the package's language files. Bind your own `SettingsSchema` only when the fields depend on the installation (a list of the server's channels, say), and then declare none here.
### A field
```json
{
"name": "token",
"type": "secret",
"label": "Bot token",
"help": "Paste the token @BotFather gave you. Keep it secret: anyone who has it controls the bot.",
"required": true,
"rules": ["string", "regex:/^\\d{6,12}:[A-Za-z0-9_-]{30,64}$/"],
"links": { "@BotFather": "https://t.me/BotFather" }
}
```
| Key | Required | Meaning |
| ---------- | :------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | ✅ | `^[a-z][a-z0-9_]*$`, unique within its form. The key the value is stored and submitted under; the connector reads it back from `InstallationRequest::$fields` or, for a secret, from the `CredentialBag`. |
| `type` | ✅ | One of the six types below. |
| `label` | ✅ | Shown beside the input, or as the heading of an `instructions` block. A translation key. |
| `help` | | One sentence under the input. A translation key. |
| `required` | | Whether the form refuses an empty value. Default `false`. |
| `rules` | | Laravel validation rules applied to the value as strings (`"string"`, `"min:20"`, `"regex:/…/"`, `"url"`). A regex is written as it would be in PHP, with JSON escaping of backslashes. |
| `options` | | For a `select` only: a map of stored value to label; the labels are translation keys. |
| `steps` | | Numbered steps shown with the field, in order. Two placeholders are replaced by every renderer: `:app` with the product name and `:button` with the label of the form's submit button. |
| `links` | | Text to turn into a link wherever it appears in the label, help or steps: `"@BotFather": "https://t.me/BotFather"`. `https://` and `mailto:` only. |
### The six types
| Type | Input | Where the value goes |
| -------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `text` | A single-line text box. | `InstallationRequest::$fields[name]`; stored on the installation's settings when it is a settings field. |
| `secret` | A masked text box. | The installation's `CredentialBag`, encrypted at rest in the core's table, never shown again after saving. |
| `select` | A drop-down over `options`. | The chosen key, as `text`. |
| `toggle` | A switch. | A boolean, as `text`. |
| `instructions` | No input: a walkthrough. | Nothing. The `label` heads it, `steps` and `help` explain, `links` turn names into links. |
| `link` | No input: a button. | Nothing. The `label` is the button text and the field's `value` is where it goes, which only a PHP-bound `SettingsSchema` can fill per installation (a link to the installation's own page on the platform). A manifest-declared `link` has no target. |
### How the values flow
1. The creator submits the install form. The core validates every field's `rules` and refuses the form with the messages a Laravel form would show.
2. The core puts **every** submitted install field, secret or not, into the `CredentialBag`, builds an `InstallationRequest` (scope, the acting creator, the project, the same fields as an array, and the existing installation when the creator is reconnecting) and calls `InstallationLifecycle::begin()`.
3. For a complete draft the core calls `complete()` with that bag, then stores it encrypted on the installation row it records from the summary, merged with whatever the connector put in the summary's `meta` (a webhook signing secret it minted). The connector never sees how the bag is kept and keeps no secret of its own; every port receives the merged bag, and `Core\Installations::credentials()` reads it back before any port is called.
4. Settings fields are read through `SettingsSchema::settingsFields($installation)` with their current values filled in, and written through `PATCH …/installation/settings` or the Configuration tab.
## The Telegram block
```json
"install": {
"mode": "paste_credential",
"scopes": ["project", "platform"],
"fields": [
{
"name": "instructions",
"type": "instructions",
"label": "Create a bot with @BotFather, then paste its token here.",
"steps": [
"Open the Telegram app and search for @BotFather.",
"Start a chat with @BotFather and send the command /newbot.",
"Follow the instructions to create your bot and get the token.",
"Copy the token by tapping on it and paste it in the input below.",
"Click on \":button\" to connect your bot to :app."
],
"help": "You can use the @BotFather bot to customize your bot such as adding a bio description, adding a display picture or even adding the startup greeting message. Bot activation may take several seconds.",
"links": { "@BotFather": "https://t.me/BotFather" }
},
{
"name": "token",
"type": "secret",
"label": "Bot token",
"help": "Paste the token @BotFather gave you. Keep it secret: anyone who has it controls the bot.",
"required": true,
"rules": ["string", "regex:/^\\d{6,12}:[A-Za-z0-9_-]{30,64}$/"]
}
],
"settings_fields": []
}
```
The Connect Bot dialog creators see is this block rendered: the numbered BotFather steps, the `@BotFather` link, the masked token box and the regex that refuses anything that is not a bot token. The walkthrough video beside it is the connector's `install` [slot](/sdk/v1/ports/ui-slots), which dresses the form up on the web without replacing it.
`settings.install_fields`: every entry is a `Field`, names are unique, every field has a label, and a `paste_credential` connector has an input and a secret. `settings.settings_fields`: the same shape rules for `settingsFields(null)`. The loader itself refuses a `name` outside its pattern, a `type` outside the six, a `select` without `options`, an unknown key inside a field, and a link that is not `https://` or `mailto:`.
---
# listing
Source: https://docs.subscriby.net/sdk/v1/manifest/listing
The `listing` block is required. It is everything a connector says about itself to people who have not installed it: the marketplace card, the connector's public page, the in-app directory, `GET /connectors` and the MCP catalogue all read it, and nothing on those surfaces is typed by hand. What a connector cannot say about itself (whether it is official, available, new or trending) the registry stamps.
```json
"listing": {
"category": "messaging",
"tagline": "Sell access to Telegram channels, groups and supergroups through your own bot.",
"overview": "Connect a bot you created with @BotFather and Subscriby runs your membership on Telegram: …",
"screenshots": [],
"links": {
"documentation": "https://docs.subscriby.net/connectors/telegram",
"support": "mailto:support@subscriby.net",
"privacy": "https://telegram.org/privacy",
"terms": "https://telegram.org/tos",
"homepage": "https://telegram.org"
},
"added_at": "2025-04-21",
"changelog_url": "https://www.subscriby.net/blog",
"sign_in_required": false,
"portal_cta": { "label": "Open Telegram Bot" },
"marketing": {
"audience": "Telegram communities",
"place": "channel",
"places": "channels, groups and supergroups",
"installation": "bot",
"identity": "Telegram account",
"native_payment": "Telegram Stars"
}
}
```
## The fields
| Field | Required | Meaning |
| ------------------ | :------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `category` | ✅ | Where the marketplace shelves the connector: `messaging`, `community`, `payments` or `productivity`. The **Category** filter and the connector page's side column read it. |
| `tagline` | ✅ | One line under the name, at most **80 characters**; the card shows it whole and the hero chip's hover card quotes it. |
| `overview` | ✅ | The Overview tab, in Markdown. Say what the connector does for a creator and for a member. The capability matrix, the resource kinds and the command coverage under it are generated from the manifest, so do not repeat them. |
| `screenshots` | | Image URLs shown on the Overview tab. Empty is fine. |
| `links` | | The **More info** column: `documentation`, `support`, `privacy`, `terms`, `homepage`. Each `https://` or `mailto:`. See below for what each should point at. |
| `added_at` | ✅ | The date the connector was first published, `YYYY-MM-DD`. Shown under **Added** and drives the **New** chip for sixty days. |
| `changelog_url` | | Where releases are announced; the **Changelog** link. |
| `sign_in_required` | | Whether a creator has to sign in to the platform to install (an OAuth connector says `true`; a paste-a-token connector says `false`). Default `true`. Shown as **Sign-in** on the connector page. |
| `marketing` | | The words the marketing site borrows for this platform. Optional; see below. |
| `portal_cta` | | The button the member portal shows to open the connector. Optional; see below. |
| `seo` | | The copy that turns the connector's public page into a search landing page that leads with the platform's name. Optional; see below. |
### The links
- **`documentation`**: your own guide for *creators* using the connector (how to install it, what members experience), not this SDK reference.
- **`support`**: where a creator writes when the connector misbehaves; a `mailto:` is fine.
- **`privacy`** and **`terms`**: the *platform's* policies, not yours. The marketing site's legal pages link the available connectors' `privacy` for members' reference, so it must be the document a member's data on that platform is governed by.
- **`homepage`**: the platform's site.
## `marketing`
The public site never spells a platform. Its copy carries tokens that are filled from the connectors available on that installation of Subscriby, so a sentence written once names Telegram today and whichever connector is available tomorrow. Your `marketing` block supplies the words:
| Word | Token | Meaning and Telegram's value |
| ---------------- | ----------------- | ------------------------------------------------------------------------------------------------------------- |
| `audience` | `:audience` | Who the connector serves: "Telegram communities". |
| `place` | `:place` | One gated place, lower case: "channel". |
| `places` | `:places` | The gated places as a list: "channels, groups and supergroups". |
| `installation` | `:installation` | What a project installs, lower case: "bot". |
| `identity` | `:identity` | A person's account on the platform: "Telegram account". |
| `native_payment` | `:native_payment` | The platform's own payment method, when it has one: "Telegram Stars". Leave it out when there is none. |
The first five are required inside the block; `native_payment` is optional. The connector's `name` fills `:platform`, and every available connector together fills `:platforms`; the connectors being built fill `:next` and the roadmap fills `:planned` on the home page. A sentence built on a word your block does not supply is dropped rather than rendered half-empty: a comparison row about native payments disappears when no available connector declares one. Write the words in lower case except the platform's own proper nouns, and as they would read mid-sentence ("your :place", "a :platform :installation"). A connector that lends no words leaves the block out; every connector still gets its own marketplace page from the rest of the listing.
## `portal_cta`
```json
"portal_cta": { "label": "Open Telegram Bot", "icon": "paper-airplane" }
```
The member portal's header shows one button per installed connector that has somewhere to open: `label` (a translation key) and an optional `icon`, a key from Subscriby's connector icon set; when it is omitted the connector's own icon is drawn, which is almost always right. The core addresses the button through `InstallationLifecycle::startLink()`, so the connector never builds the URL here. Omit the block when the platform has nothing a member opens (a role gate shows itself). The same block is returned in `GET /connectors` so the creator apps render it identically.
## `seo`
```json
"seo": {
"title": "Telegram Membership Bot for Paid Channels and Groups — :app",
"description": "Run a paid Telegram channel or group with your own bot: members pay by card, crypto or Stars, get in the moment they pay and leave when access ends.",
"h1": "The Telegram Membership Bot for Paid Channels and Groups",
"h1_sub": "Your own bot, your own payment accounts, and every join and removal handled for you.",
"sections": [
{ "heading": "How a paid Telegram channel works with :app", "body": "You create a bot with @BotFather and paste its token…\n\nNobody copies links around and nobody checks who has paid." }
],
"faq": [
{ "q": "Do I need my own Telegram bot?", "a": "Yes. You create it with @BotFather in a minute and paste the token into :app…" }
]
}
```
The marketing site's own copy never spells a platform, so the one page that may lead with "Telegram membership bot" is the connector's page under `/connectors/`, and the words have to come from the connector. A manifest that carries `seo` replaces that page's ``, meta description and headline with its own, shows `h1_sub` under the headline in place of the tagline, adds one card per entry of `sections` between the overview and the capability matrix, and closes the page with the `faq` entries as cards described to search engines as a `FAQPage`. A connector without the block keeps the generic page built from the rest of its listing.
| Field | Required | Meaning |
| ------------- | :------: | ------------------------------------------------------------------------------------------------------------------------------------ |
| `title` | ✅ | The page title, at most **70 characters**, the search phrase first. |
| `description` | ✅ | The meta description, at most **160 characters**. |
| `h1` | ✅ | The page's one headline. |
| `h1_sub` | | The line under the headline; the tagline when omitted. |
| `sections` | | Prose sections in order, each a `heading` and a `body` of one or more paragraphs separated by blank lines. |
| `faq` | | Questions and answers in order, each a `q` and an `a`; answers should stand on their own in a sentence or three. |
Every string may carry the tokens the marketing site fills at render time: `:app` (the product name), `:gateways` (how many payment methods the site offers), `:webhook_events`, `:api_resources` and `:mcp_tools` (the catalogue counts). Use them for any figure that would otherwise go stale inside a published manifest. The title, description, headline, subline, headings and questions are looked up by their English text in your package's `lang/*.json` like the tagline, so ship their translations; a section body is looked up the same way and reads English until you translate it. The loader refuses a title or description over its limit and any key the block does not know, so keep editor validation on.
## What the registry stamps
These are never in the manifest, so a package cannot claim them:
| Stamp | Set from |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Official** | Subscriby's configuration names its own connectors; every other package is **Community**. |
| **Lane / status** | **Available Now** (enabled), **Experimental** (enabled and flagged beta), **Paused** (disabled during an incident), **Under Development** (registered but not enabled), **Coming Soon** (a roadmap stub with no package). |
| **New** | `added_at` within the last 60 days. |
| **Trending** | The most installed connector over the last 30 days. |
| **Installed on** | How many projects run it (public) and, in the app, how many of the signed-in creator's own. |
| **Made by** | The manifest's `vendor`; for a roadmap stub, Subscriby. |
`manifest.listing_links`: every link present, including `changelog_url`, is an absolute `https://` URL or a `mailto:` address. The loader refuses a `category` outside the four, an `added_at` that is not a date, an unknown key, a `marketing` block missing any of its five required words, and a `seo` title over 70 or description over 160 characters; the eighty-character `tagline` limit is the schema's, so keep editor validation on and expect review to hold you to it. [Listing and Review](/sdk/v1/building/listing) has the checklist a reviewer reads the block against.
---
# management_commands
Source: https://docs.subscriby.net/sdk/v1/manifest/management-commands
Creators run their business from the dashboard, and, on a platform that has a conversation with them, from inside the platform: Telegram's platform bot walks a creator through creating a project, linking a channel, building a plan or sending a broadcast. Those flows call the same core actions the dashboard calls; the core publishes them as a **command catalogue** (`Subscriby\Connector\Enums\ManagementCommand`) and each connector declares which commands its in-chat surface renders, in its own idiom.
```json
"management_commands": [
"project_create", "project_settings", "project_delete", "installation_connect",
"resource_link", "resource_manage", "plan_create", "plan_manage", "payment_method_setup",
"coupon_create", "access_codes_generate", "broadcast", "support_reply",
"recovery_relink", "recovery_standby", "recovery_replace_resource", "account_recovery_email", "set_alert_destination", "donate"
]
```
The list is optional, and meaningful only with the `management_surface` capability. `ManagementSurface::commands()` must return exactly the same set (`management.commands_match_manifest`), and the marketplace and the dashboard's Connectors tab show the coverage against the full catalogue, so a creator knows before installing what they can do from the platform and what needs the dashboard.
## The catalogue
| Command | Lets the creator… |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `project_create` | Create a project. |
| `project_settings` | Change a project's name, handle, portal and support settings. |
| `project_delete` | Delete a project. |
| `installation_connect` | Connect, reconnect or disconnect the project's installation on this connector. |
| `manage_connectors` | See the project's installations and their health, and browse the catalogue. |
| `resource_link` | Link a place the installation administers as a gated resource. |
| `resource_manage` | Rename, disable, re-link or remove resources. |
| `plan_create` | Build a plan: price, cycle, resources, trial, passes. |
| `plan_manage` | Edit, publish, unpublish or delete plans. |
| `payment_method_setup` | Connect a payment method (a gateway account, a native provider) to the project. |
| `coupon_create` | Create a coupon. |
| `access_codes_generate` | Generate access codes for a plan. |
| `broadcast` | Send a message to the project's members. |
| `support_reply` | Answer a member's support conversation. |
| `support_relay_setup` | Choose where support conversations are relayed and link the relay space. |
| `creator_tasks` | See and complete the tasks `creator_task` grants opened. |
| `recovery_status` | See the Disaster Recovery Program's readiness, incidents and operations. |
| `recovery_relink` | Prove a new account after losing the old one. |
| `recovery_standby` | Register a standby installation, request a standby place, switch mirroring. |
| `recovery_replace_resource` | Replace a lost place with a new one and re-admit its holders. |
| `account_recovery_email` | Set or change the e-mail address account recovery uses. |
| `set_alert_destination` | Route the creator's alerts to the account they are talking from, or stop (`Core\Alerts`). |
| `donate` | Tip Subscriby. A **connector extra** (`ManagementCommand::isConnectorExtra()`): allowed, but not part of the catalogue's coverage. |
Every command names one or more core actions the connector drives through the Core API and the same authorisation the dashboard applies, so a teammate refused on the web is refused in the chat as well.
## Declaring less
A connector need not render everything. The fake connector renders three (`project_settings`, `plan_manage`, `resource_manage`); a connector whose platform has no conversational UI (slash commands only, say) may render only the reads. What matters is that the manifest and `commands()` agree, and that every declared command has a real path through `handle()`.
## The Telegram list
Telegram declares nineteen of the twenty-three: everything but `manage_connectors`, `support_relay_setup`, `creator_tasks` and `recovery_status`, which the platform bot does not yet render. The coverage line on its marketplace page reads exactly that.
`management.commands_match_manifest`: the sorted list `ManagementSurface::commands()` returns equals the sorted list the manifest declares. The loader refuses a value outside the catalogue and a duplicate.
---
# messaging
Source: https://docs.subscriby.net/sdk/v1/manifest/messaging
The `messaging` block is required, even for a connector without the `messaging` capability, because it describes the platform, not the connector: the core composes every message once and needs to know what the platform will accept before it renders anything.
```json
"messaging": {
"max_length": 4096,
"buttons_per_row": 8,
"max_buttons": 100,
"callback_data_bytes": 64,
"supports_underline": true,
"supports_spoiler": true,
"supports_files": true
}
```
## The fields
All seven are required.
| Field | Type | Meaning | Enforced by |
| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `max_length` | integer ≥ 1 | The longest body the platform delivers, in characters of the canonical HTML. | The core splits a longer body into parts within the limit and calls `Messenger::send()` once per part; `Message::assertWithin()` then checks each part. |
| `buttons_per_row` | integer ≥ 1 | How many actions may share one row. | `Message::assertWithin()`. |
| `max_buttons` | integer ≥ 1 | How many actions one message may carry in total. | `Message::assertWithin()`. |
| `callback_data_bytes` | integer ≥ 1 | The most bytes a callback button may carry back. Telegram's 64 bytes used to be tribal knowledge; here it is data. | `MessageAction::assertWithin()` throws on the developer's machine when a callback would be truncated. |
| `supports_underline` | boolean | Whether the platform can show ``. | Published over `GET /connectors`; a renderer for a platform that cannot show it drops the tag, keeping the words. |
| `supports_spoiler` | boolean | Whether the platform has a spoiler (hidden until tapped) style. | Published over `GET /connectors`; the connector's renderer decides what a spoiler becomes. |
| `supports_files` | boolean | Whether the installation can send a file to a person. | The core refuses `sendFile()` itself with a `Configuration` failure when false, and never calls the port. |
## The canonical message
What the core hands a `Messenger` is a `Subscriby\Connector\Data\Message`: a `body` in the canonical HTML subset (` `), a list of `MessageAction`s built through four factories only (`url`, `callback`, `copy`, `command`), and a `meta` map keyed by connector for the one-platform extras every other connector ignores. Your `TextRenderer` turns the body into the platform's formatting; your `Messenger` lays the actions out.
The limits describe the platform, so state them as the platform documents them, not as your connector prefers. A connector that wants shorter messages than the platform allows composes nothing itself: the core writes the copy, and truncating it in the renderer loses meaning in ten languages.
## Reading them from a connector
`ConnectorManifest::$messaging` is a `MessagingLimits` with the same seven fields as public properties. A connector never has to fold a long body itself: the core cuts a message over `max_length` into parts at paragraph, line and word boundaries, keeps the canonical tags balanced across them, and hands them to `Messenger::send()` in order with the buttons on the last part. Declare the platform's real limit, however low; the fake connector's 280 is what keeps that path exercised.
## The fake connector's block
```json
"messaging": {
"max_length": 280,
"buttons_per_row": 2,
"max_buttons": 4,
"callback_data_bytes": 32,
"supports_underline": false,
"supports_spoiler": false,
"supports_files": false
}
```
Deliberately hostile: a core path that composes a 300-character message, a three-button row or a 40-byte callback fails against the fake in Subscriby's own suite. That is what keeps the core honest for the platform your connector brings.
Every field present, every integer at least one, every flag a boolean. There is no kit rule beyond that: the block is data the core trusts, and review reads it against the platform's published limits.
---
# pacing
Source: https://docs.subscriby.net/sdk/v1/manifest/pacing
The `pacing` block is required. Every platform throttles bots differently (Telegram tolerates roughly thirty messages a second with a one-per-second limit into any single chat; a workspace platform may allow one message a second in total), and a connector that hard-codes a sleep is wrong on the next platform. The block makes the numbers data the core reads.
```json
"pacing": {
"min_interval_microseconds": 35000,
"burst": 30,
"per_recipient_interval_microseconds": 1000000
}
```
## The fields
All three are required, all integers of at least zero.
| Field | Meaning | Read by |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `min_interval_microseconds` | The shortest gap between any two sends from one installation, whoever they go to. | The broadcast pipeline spaces its sends by it; the queue rate limiter below is derived from it. |
| `burst` | How many sends the platform accepts back to back before the interval has to be honoured. | The queue rate limiter below. |
| `per_recipient_interval_microseconds` | The shortest gap between two sends to the same person. | Jobs that send several messages to one member in a row (the access-code delivery job sleeps it between codes). |
## The rate limiter the core registers
When the registry accepts a connector it registers a Laravel rate limiter named `connector:` (`Pacing::limiterName()`), sized `Pacing::sendsPerSecond()`: the lower of `burst` and one second divided by `min_interval_microseconds`, never below one send a second. Every paced job the core runs for the connector names that limiter (re-admitting holders after a place is replaced, for one), so Telegram's thirty-a-second and a one-a-second platform are the same code with different data.
| Manifest | Sends per second |
| ------------------------------------------ | ---------------- |
| Telegram: interval 35 000 µs, burst 30 | 28 |
| The fake: interval 1 000 000 µs, burst 1 | 1 |
| A platform with interval 0 and burst 50 | 50 |
## Reading it from a connector
`ConnectorManifest::$pacing` is a `Pacing` value object with the three fields as public properties. A connector that runs its own loop (a gateway worker draining a queue) reads the intervals from there and never repeats the numbers in code; a connector that only implements `Messenger::send()` reads nothing, because the core paces the calls.
Pacing keeps the connector under the platform's limit; it does not replace the `FailureClassifier`. When the platform still answers with a rate-limit response, classify it `RateLimited` with the platform's retry-after and the core waits exactly that long. Review reads both against the platform's published limits.
---
# recovery
Source: https://docs.subscriby.net/sdk/v1/manifest/recovery
The Disaster Recovery Program is one core subsystem: incidents, an operations ledger with undo, a readiness checklist, quotas, member notices by e-mail. What a platform lets a connector *do* about an outage differs, so the program has five **facets** a connector may or may not have. The `recovery` block says which are real.
```json
"recovery": {
"probes": true,
"standby_installations": true,
"resource_standby": true,
"mirror": true,
"identity_relink": true
}
```
The block is optional; every flag defaults to `false`. The core never calls a facet the block denies, and a connector implements the corresponding `RecoverySupport` methods for the facets it denies by throwing `Subscriby\Connector\Exceptions\UnsupportedByConnector`.
## The five facets
| Flag | Capability | `RecoverySupport` methods the core calls | What it gives a creator |
| ----------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `probes` | `recovery_probes` | `probeInstallation()`, `probeSpace()`, `probeIdentity()`, `healthReasonText()` | Health badges on the installation, every resource and the creator's own account, and the incident that opens when one goes dark. |
| `standby_installations` | `recovery_standby_installations` | `registerStandbyInstallation()`, `removeStandbyInstallation()` | A second installation kept ready; a failover writes it into the live one's place. |
| `resource_standby` | `recovery_resource_standby` | `failOver()` | A standby place per resource, with every holder re-admitted into it when the live place is lost. |
| `mirror` | `recovery_mirror` | `mirror()` | The standby place receives a live copy of every post, so it is never empty on the day it is needed. Needs `mirrorable` kinds. |
| `identity_relink` | `recovery_identity_relink` | `beginIdentityHandshake()` | Proving a new account after the old one is banned, and a backup account registered in advance. |
Whatever the flags say, a connector that binds `RecoverySupport` at all must answer `vocabulary()` and `readinessChecks()` (`recovery.vocabulary_and_readiness`): the core's recovery pages describe the connector's world in its nouns ("bot", "channel", "Telegram account") and list its lines of the readiness checklist.
## The block and the capabilities
Two places name the same facets on purpose:
- A `recovery_*` **capability** binds the `RecoverySupport` port and puts the facet on the marketplace's capability matrix. It is what the registry checks against the `Connector` class.
- A `recovery` **flag** tells the core, at call time, whether it may invoke that facet on this connector.
Declare both for every facet the platform has. A capability without its flag binds a port the core never calls; a flag without its capability is a facet nobody can see on the marketplace and, if the port is not bound, one the core cannot reach. Telegram declares all five in both places; the fake connector declares `probes` in both.
## What a platform without a facet does
A platform with no invite links has no `resource_standby` in the Telegram sense, a platform whose bots cannot be banned has no `identity_relink` to speak of, and a platform whose posts cannot be copied cannot `mirror`. Leave those flags out, throw `UnsupportedByConnector::facet($key, 'what was asked')` from the methods, and the recovery pages render only what the connector can do; a creator with a Telegram installation and yours installed sees two cards saying different things, which is the point.
`recovery.vocabulary_and_readiness`: every noun of the `RecoveryVocabulary` is non-empty and every `ReadinessItem` has a unique key. The loader refuses an unknown key inside the block and a non-boolean flag. The kit does not yet reconcile the block with the capabilities; review does.
---
# relay_modes
Source: https://docs.subscriby.net/sdk/v1/manifest/relay-modes
Support conversations live in the core's inbox. A **relay mode** is a way the connector can carry a conversation to the creator on the platform as well: a direct message to the creator's own account, a thread in a group the creator linked. `relay_modes` lists the modes the connector's `SupportRelay` port offers.
```json
"relay_modes": ["owner_dm", "forum_group"]
```
The list is optional, and meaningful only with the `support_relay` capability. `SupportRelay::relayModes()` must return exactly the same set (`relay.modes_match_manifest`). A connector without `support_relay` leaves it out or empty; the creator's support settings then offer **Dashboard Only** for that connector.
## The modes the core knows
The creator chooses a mode in the project's support settings and the core stores it, so a connector may offer only the modes the core can store. Today those are two:
| Mode | The creator sees | What the connector does |
| ------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `owner_dm` | **Direct Message to Me** | Every new support message is relayed to the creator's own account on the platform as a private message; the creator replies there and `relay()` carries the reply back to the member. |
| `forum_group` | **Group With Topics** | The creator links a group (on Telegram, by adding the bot to it); `openThread()` opens one thread per member in it and `postToThread()` mirrors every message; teammates reply in the thread. |
The picker's third option, **Dashboard Only**, is the core's own and needs nothing from a connector.
## Explaining a mode
A mode's name says little on its own, so the connector's `support_relay_options` [slot](/sdk/v1/ports/ui-slots) renders under the picker and explains what each mode means on its platform (Telegram: "create a group, turn on Topics, add the bot as an administrator"). The slot reads only its `SlotContext` and the connector's own language files.
`relay.modes_match_manifest`: the sorted list `SupportRelay::relayModes()` returns equals the sorted list the manifest declares. Because the core stores the mode as its own enum, a mode the core does not know is refused when the creator tries to save it; declare only `owner_dm`, `forum_group` or neither until the SDK publishes a new one.
---
# resource_kinds
Source: https://docs.subscriby.net/sdk/v1/manifest/resource-kinds
A **resource** is the core's decision to sell access to a **space**, a place the installation administers on the platform. `resource_kinds` lists the kinds of place the connector knows how to gate. A connector that gates nothing (a pure payment or messaging connector) leaves the list out; a connector that declares `access_control` must list at least one kind (`access.declares_kinds`).
```json
"resource_kinds": [
{
"kind": "channel",
"label": "Channel",
"portal_label": "Channel",
"icon": "megaphone",
"grant_mode": "bearer_link",
"supports_early_admission_hold": true,
"mirrorable": true,
"plan_kinds": ["one_time", "recurring", "pass", "pass_series"]
}
]
```
## The fields
| Field | Required | Meaning |
| ------------------------------- | :------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | ✅ | `^[a-z][a-z0-9_-]*$`, unique within the connector. Stored on a resource as `:` (`telegram:channel`), the value the API returns as `kind` and the `ResourceKind` value object carries. |
| `label` | ✅ | What creators see: in the "Link a resource" picker, on resource rows, in plan editors. A translation key. |
| `portal_label` | ✅ | What members see on the portal and in confirmations. Often the same word; sometimes friendlier ("Private room" for a `room`). A translation key. |
| `icon` | ✅ | A [Heroicons](https://heroicons.com) outline name (`megaphone`, `user-group`, `users`, `home`, `clipboard-document-check`), drawn beside the kind wherever the label appears. |
| `grant_mode` | ✅ | How the connector gives a member access to a place of this kind; one of the four modes below. |
| `supports_early_admission_hold` | | Whether a dated grant (a pass whose window has not opened) can be issued early and held at the door until the window opens. Default `false`. Requires the `early_admission_hold` capability. |
| `mirrorable` | | Whether a standby place of this kind can receive a live copy of every post from the place it stands by for (`RecoverySupport::mirror()`). Default `false`. Requires `recovery_mirror`. |
| `upgrades_from` | | Another `kind` of the same connector that a place of this kind can be an in-place upgrade of. A resource sold as that kind may move onto a place of this kind in a recovery swap or take one as its standby; any other pair of kinds is refused, because the plans that sell the resource promised one kind of place. Must name a kind declared in the same file. |
| `plan_kinds` | | The shapes of plan a place of this kind can be sold under: `one_time`, `recurring`, `pass`, `pass_series`. Every shape when omitted. The directory renders these as the four-row checklist on the kind's card, so a connector whose places cannot be left by force says so honestly rather than leaving creators to find out at the first cancellation. |
## What a kind can sell
`plan_kinds` is a promise to creators, and the loader holds it to three rules: the list is never empty (a kind that sells nothing is not a resource kind), a `pass_series` needs `pass` (a series bundles other plans' passes), and a kind with `supports_early_admission_hold: true` sells `pass` (the hold exists for a pass whose window has not opened). The order in the file does not matter; the SDK spells the list in the enum's order, one-time, recurring, pass, series, so two manifests never list the same set differently.
| Value | The creator can sell | Declare it when |
| ------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `one_time` | One payment for access that never renews, a lifetime membership included. | Almost always; the only thing it needs is a way in. |
| `recurring` | Access billed each cycle and ended when the member stops paying. | The connector can revoke access on its own, so a lapsed member is really removed. |
| `pass` | A ticket to one dated access window. | Access can be given and taken at a set time, whether or not the connector can hold a member at the door first. |
| `pass_series` | One payment for a curated slate of other plans' passes. | The kind sells `pass`. |
## The four grant modes
The mode decides what the core stores as a grant's `reference`, what `AccessController::grant()` is expected to do, and what a member is shown.
| Mode | The grant is | Reference stored | The member sees |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `bearer_link` | A personal, single-use link the connector mints; whoever presents it is let in. Telegram's invite link that creates a join request the bot approves. | The link itself, unique per connector. | The link, in the confirmation message and on the portal's membership card ("Join …"). |
| `membership` | The installation adds the account to the place directly. A forum's private board, a chat the bot can add people to. | The connector's handle, or null. | "You now have access", and the connector's `grant_action` slot if it fills one. |
| `role` | The installation gives the account a role that unlocks the place. Discord's role gate. | `guild:role:user`, repeatable across grants. | The role appears on the platform; nothing to tap. |
| `creator_task` | Nothing the platform can do: the creator must act by hand (add the person, ship a perk). The core opens a **creator task** and tells the member their organiser will act. | Null until the task is completed. | "Your organiser will add you"; the grant turns `granted` when the creator completes the task. |
A `bearer_link` reference is unique per connector (a join request finds the purchase behind the link it arrived on through `Core\Grants::findByReference()`); a `role` reference may legitimately repeat across two concurrent subscriptions of the same member, and the core's access policy treats that as legal.
## Early admission
A pass sells access to a place for a dated window. When the window has not opened yet, the core asks the connector to `grant()` with `opensAt` set. A kind with `supports_early_admission_hold: true` (and the `early_admission_hold` capability) may pre-issue the grant and answer `held: true`, so the member can already present the link and wait at the door; when the window opens the core calls `admit()` and the connector lets them in. A kind without it is granted when the window opens, and the plan editor's copy about early admission is hidden for plans built on it.
## The Telegram kinds
```json
"resource_kinds": [
{"kind": "channel", "label": "Channel", "portal_label": "Channel", "icon": "megaphone", "grant_mode": "bearer_link", "supports_early_admission_hold": true, "mirrorable": true, "plan_kinds": ["one_time", "recurring", "pass", "pass_series"]},
{"kind": "group", "label": "Group", "portal_label": "Group", "icon": "user-group", "grant_mode": "bearer_link", "supports_early_admission_hold": true, "plan_kinds": ["one_time", "recurring", "pass", "pass_series"]},
{"kind": "supergroup", "label": "Supergroup", "portal_label": "Supergroup", "icon": "users", "grant_mode": "bearer_link", "supports_early_admission_hold": true, "upgrades_from": "group", "plan_kinds": ["one_time", "recurring", "pass", "pass_series"]}
]
```
Telegram cannot add anyone to anything, so every kind is a `bearer_link`; a join request waits at the door, so every kind supports early admission and every kind sells all four plan shapes; only a channel is `mirrorable`, because a channel is a feed of posts the bot can copy and a group is a conversation it cannot. A supergroup `upgrades_from` a group because Telegram converts a group into a supergroup in place, so a resource sold as a group may be swapped onto the supergroup it became.
`manifest.resource_kinds`: every entry has a non-empty `label`, `portal_label` and `icon`. `access.declares_kinds`: a connector that declares `access_control` lists at least one kind. The loader refuses a `kind` outside its pattern, a duplicate kind, a `grant_mode` outside the four, a `plan_kinds` value outside the four or a list that breaks the three rules above, and an `upgrades_from` naming a kind the file does not declare.
---
# AccessController
Source: https://docs.subscriby.net/sdk/v1/ports/access-controller
`Subscriby\Connector\Contracts\Ports\AccessController`. **Bound by `access_control`; `early_admission_hold` adds `admit()` on the same class.**
The core keeps the access ledger (who should be where, per subscription, resource and window); the connector holds the platform's side of it: the invite link, the membership, the role. Every method is idempotent, because the core retries and reconciles: granting twice yields one grant, revoking what the platform already lost reports revoked.
## Methods
| Method | Called when | Returns |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| `grant(installation, credentials, GrantRequest)` | A purchase, renewal or access-code redemption is settled; a pending grant materialises when an identity is linked; **Refresh access** (a reissue); a member is re-admitted after a place is replaced. | `GrantResult` |
| `revoke(installation, credentials, GrantRef, SpaceRef, IdentityRef)` | A subscription is cancelled, expires, is paused; a member is banned or kicked; a connector is uninstalled. | `RevokeResult` |
| `revokeReference(installation, credentials, GrantRef, SpaceRef)` | A grant's reference must die while its holder keeps the place: another grant still covers them, the holder owns the place, or a reissue replaces the reference. | `RevokeResult` |
| `admit(installation, credentials, GrantRef, SpaceRef, IdentityRef)` | A pass window opens and the ledger holds a `held` grant for it. | `GrantResult` |
| `membership(installation, credentials, SpaceRef, IdentityRef)` | The core needs a member's standing in a place right now: before a removal, on the portal's membership card. | `Membership` |
| `announce(installation, credentials, IdentityRef, GrantAnnouncement)` | A run of grants has finished (a sale settled, a code redeemed, a presale opened, a reissue, a creator task completed) and the holder should be told what they hold. | `void` |
| `reconcile(installation, credentials, iterable)` | Every fifteen minutes, per linked resource, with the grants revoked in the last day and the live grants whose turn it is: each live grant is checked once a day in a fixed quarter-hour slot, on every run for a project whose owner's plan carries Disaster Recovery prevention, and every live grant after a connector outage ends. | `ReconcileReport` |
## `grant()`
```php
new GrantRequest(
space: $spaceRef, // the place
identity: $identityRef, // the account that gets in
mode: GrantMode::BearerLink,
existing: $grantRef, // the grant being re-asserted or reissued, or null
opensAt: null, // a date for a pass whose window has not opened
meta: [],
);
```
Give the account access in the mode the [resource kind](/sdk/v1/manifest/resource-kinds) declares and answer a `GrantResult`:
- `granted: true` with the `reference` the core stores (the invite link for a `bearer_link`, your handle for a `membership`, `guild:role:user` for a `role`), `grantedAt`, and `held: false`.
- `granted: true, held: true` for a dated request your manifest says you may pre-issue: the member can present the reference and wait at the door until `admit()`.
- `granted: false` with the classified `failure`. `Unreachable` and `TargetMissing` are recorded on the grant as failed; `NotPermitted` and `Configuration` become a health verdict the creator is shown.
`existing` names the grant when the core is re-asserting or reissuing; a connector whose reference is stable (a role) answers with the same reference, one whose reference is consumable (a single-use link) mints a fresh one.
## `revoke()` and `revokeReference()`
`revoke()` takes the account out of the place and kills whatever the grant issued. Answer `revoked: true` even when the platform reports the account already gone: the ledger's state is what matters, and a missing grant is not an error.
`revokeReference()` withdraws only what the grant issued while the holder stays: another live grant still covers them, or they own the place. A connector whose grants carry no reference apart from the membership itself answers revoked without a platform call.
A removal for a dated reason (an expiry) is preceded by `membership()`, so the core can record attendance before the account is put out.
## `admit()`
Let the holder of a `held` grant in now that its window has opened and answer `GrantResult` with the grant's reference. Throw `UnsupportedByConnector` when the manifest declares no `early_admission_hold`; the core never asks a connector without it, because such a connector was never allowed to hold.
## `announce()`
How a holder is shown what they hold is the connector's idiom: a list of links to tap, a sentence saying a role appeared, nothing at all when the platform shows it. `GrantAnnouncement` carries the `grants` this run issued and the `windowId` it was about; you may list everything the holder holds rather than only what the announcement names, because a holder of several purchases expects every link in one place. Send through your own `Messenger` (the core has already picked the installation to reach the holder through) and return nothing; a connector whose grants need no telling returns at once.
## `reconcile()`
Bring the platform into line with the ledger for one installation: each `GrantSnapshot` names a `grant`, its `space`, its `identity` and its `state`; let banned holders of live grants back in, put holders of dead grants out, leave the rest alone, and answer a `ReconcileReport` (`checked`, `reasserted`, `revoked`, `failures`). The sweep is what repairs a platform that was unreachable when the grant happened or an event the platform never delivered, so make it assert a state rather than replay events.
## The `membership()` answer
`Membership` carries a `MembershipStatus` (`Owner`, `Administrator`, `Member`, `Restricted`, `Left`, `Banned`, `Unknown`) and `since`. Answer `Unknown` when the platform cannot say, never a guess.
## What the core does around you
Every call goes through the core's `ConnectorAccessController`, which resolves the resource's place (installation, credentials, `SpaceRef`), the member's identity on that connector and the grant's ref, times and counts the call, and reads your result. A member with no identity on the connector is never asked of you: the grant waits as `pending_identity` and materialises when the identity is linked.
## How Telegram does it
Telegram cannot add anyone to anything, so a grant is a personal invite link that creates a join request the bot approves, and the link is the grant's reference; a dated grant is pre-issued and reported held, because the join request waits at the door until the window opens. Taking access away revokes the link and then bans and unbans the member, which puts them outside while leaving them free to be invited back; withdrawing the reference only revokes the link. `announce()` sends the member their links; `reconcile()` compares `getChatMember` with the ledger.
## What the kit checks
`access.declares_kinds`: a connector that binds the port lists at least one resource kind. Behaviour is exercised by Subscriby's suite through the fake connector's `FakeAccessController` (`assertGranted()`, `assertNotGranted()`, `assertAnnounced()`, `assertNotAnnounced()`), and by your own tests against your platform's fake.
Whether an account *should* be in a place is the core's decision, made by its access policy from the ledger. Your port only does what it is told and reports what happened; a connector that checks a subscription before granting will disagree with the ledger the first time a policy changes.
---
# FailureClassifier
Source: https://docs.subscriby.net/sdk/v1/ports/failure-classifier
`Subscriby\Connector\Contracts\Ports\FailureClassifier`. **Required of every connector.**
Each platform has its own error vocabulary and its own habit of refusing inside a successful HTTP response. The connector reads both shapes; the core decides once, from the kind, whether to retry, wait, alert the creator or give up. Every send, grant, revoke and probe your other ports perform ends in a `DeliveryFailure` built here when it did not succeed.
## The method
```php
public function classify(mixed $responseOrThrowable): DeliveryFailure;
```
The argument is whatever your platform client gave you: a decoded response body, a response object, or the exception the call raised. Return a `DeliveryFailure`:
| Field | Meaning |
| ------------------- | ---------------------------------------------------------------------------------------------------- |
| `kind` | One of the seven `DeliveryFailureKind`s below. |
| `detail` | The platform's own words, for logs and the connector doctor. Never a credential. |
| `retryAfterSeconds` | For `RateLimited`: how long the platform asked you to wait. |
| `code` | The platform's error code, when it has one, for your own tests and dashboards. |
## The seven kinds
| Kind | Means | Retryable | Creator-actionable |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------ | :-------: | :----------------: |
| `Unreachable` | The account exists but cannot be written to: it blocked the bot, closed its messages, left the platform. | — | — |
| `NotPermitted` | The installation lacks a right it needs in a place: not an administrator, a missing permission. | — | ✅ |
| `TargetMissing` | The place, message or account no longer exists. | — | — |
| `RateLimited` | The platform asked for a pause; carry its wait in `retryAfterSeconds`. | ✅ | — |
| `Configuration` | The installation itself is wrong: a revoked token, a deleted bot, a call the manifest says the platform cannot make. | — | ✅ |
| `Transient` | The platform or the network hiccuped. | ✅ | — |
| `Other` | Nothing above fits. Make it rare. | — | — |
The two columns are the enum's own methods, `DeliveryFailureKind::isRetryable()` and `isCreatorActionable()`, and they are what the core reads: a retryable failure is tried again (after `retryAfterSeconds` for a rate limit), a creator-actionable one becomes a health verdict the creator is shown with the connector's own remedy (`RecoverySupport::healthReasonText()`), and everything else is recorded as failed for that call. On a recovery probe of an account, `TargetMissing` has one more meaning: the account is gone, which is the incident the Disaster Recovery Program exists for, while any other kind is left alone.
## Mapping a platform
Write the mapping as a table in your connector, from the platform's codes and phrases to the kinds, and test it row by row. Three habits keep it honest:
- **Read the body, not only the status.** Telegram answers `200 OK` with `"ok": false` and a description; Discord answers `403` for four different situations only the JSON code tells apart.
- **Never let a network exception fall through as `Other`.** A timeout, a connection refused, a DNS failure is `Transient`.
- **Classify what your own client throws.** A validation exception from the client library about a message the platform would reject is `Configuration`, because retrying cannot help.
## How Telegram does it
The sentence reading lives in the connector's `TelegramRefusal`, which the pre-SDK code already had: `bot was blocked by the user` and `user is deactivated` are `Unreachable`, `chat not found` and `message to delete not found` are `TargetMissing`, `not enough rights` is `NotPermitted`, `Too Many Requests` is `RateLimited` with Telegram's `retry_after`, `Unauthorized` (a revoked token) is `Configuration`. The port adds the two cases the refusal reader had no word for: a bot that lacks rights in a chat, and a network that never answered.
## What the kit checks
`failures.classifies_anything`: `classify()` returns a `DeliveryFailure` for an arbitrary throwable and for an arbitrary string, and does not throw itself. A classifier that only understands its own client's exceptions fails here.
Your `Messenger`, `AccessController`, `SupportRelay` and `RecoverySupport` should all build their failures through this class, so the core sees one vocabulary and your tests cover one mapping. The core also calls it directly when a port throws instead of returning.
---
# IdentityResolver
Source: https://docs.subscriby.net/sdk/v1/ports/identity-resolver
`Subscriby\Connector\Contracts\Ports\IdentityResolver`. **Required of every connector.**
The core keeps the identity rows and decides which creator or member an account belongs to. The connector only says *which account the platform is talking about*, keeps whatever row of its own it needs to talk to that account later, and says whether it can talk to it now.
## Methods
| Method | Called when | Returns |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `resolveInbound(InboundEnvelope)` | The kit's `identity.tolerates_empty_envelope`; your own inbound handler, to find the actor of an event before it consults the core. | `?IdentitySummary` |
| `describe(installation, credentials, externalId)` | Reserved: the core does not call it yet. Your own connector uses it where a fresh name or picture matters more than a cached one. | `IdentitySummary` |
| `adopt(installation, IdentitySummary)` | The core learns of an account outside an inbound event: a sign-in widget vouched for it, a handshake completed with it. | `IdentityRecord` |
| `deliveryTarget(installation, credentials, IdentityRef)` | Before the core sends to a member who holds accounts on several connectors, to pass over one the installation cannot reach; the kit's `identity.answers_reachability`. | `?Recipient` |
## `resolveInbound()`
Given a decoded event, answer the account that acted as an `IdentitySummary` (`externalId`, `displayName`, `username`, `avatarUrl`, `meta`), or null for an event with no actor (a channel post, a system notice). Never throw on an event you do not recognise: the kit hands you an envelope with an empty payload and expects null or a summary.
The core does not call this method itself yet; the connector's inbound handler does, and then asks the [Core API](/sdk/v1/core-api/identities) who holds the account (`Identities::findByExternalId()`, `findCreator()`, `findMember()`). The contract is on the port so the day the core dispatches envelopes itself, nothing in your connector changes.
## `describe()`
Ask the platform about an account by its external id and answer what it says now. The contract exists so the core can refresh a name or a picture without knowing the platform's call; today no core surface invokes it, so implement it and use it from your own connector where a fresh summary matters.
## `adopt()`
A sign-in widget hands the core an account the connector has never seen an update from; the core calls `adopt()` with the platform installation so the account can be written to later. Keep your own row for it under the installation given (Telegram: the private chat row under the platform bot) and answer the `IdentityRecord` the core files: `connector`, `externalId`, `installationId` (null for a platform-wide id), `displayName`, `username`, `avatarUrl`, `storageRef` naming your row, `meta`, `lastSeenAt`. Calling `adopt()` twice for the same account returns the same row.
## `deliveryTarget()`
Whether the installation can write to the account right now, and how to address it. Reachability is the platform's to know: Telegram writes only to a person who has opened the bot in question, Discord only to a member whose direct messages are open, a forum to anyone with an inbox. The core asks before it sends a member anything, so an account it cannot reach is passed over for one it can (the member's next connected account, then email) rather than attempted and failed. Answer a `Recipient` for the account (with a `threadId` when the platform needs one) or null when the installation cannot reach it. A connector that cannot tell in advance answers a `Recipient` and lets the send report the account as unreachable through its `FailureClassifier`.
## Platform-wide or per installation?
An `IdentityRecord` carries an `installationId` when the platform gives an account a different id per installation (a per-app user id) and null when one id names the person everywhere. The core's uniqueness is `(connector, installationId, externalId)`, so get this right on day one: Telegram's user id is the same whichever bot the person talks to, so its records carry no installation and the core files them as platform-wide accounts.
## How Telegram does it
A Telegram user id is the same for every bot, so the summary carries no installation; a channel post has no actor and resolves to null; an account the sign-in widget vouched for is adopted as a private chat row under the bot given, because that row is what every later message to the person is written against. Reachability is that row: a bot can write to a person only where a private chat row exists under that very bot, because Telegram refuses a bot the person never opened, so `deliveryTarget()` answers null when the installation's bot holds no row for the account.
## What the kit checks
`identity.tolerates_empty_envelope`: `resolveInbound()` given an `InboundEnvelope` with the connector's key, no installation, kind `unknown` and an empty payload returns null or an `IdentitySummary`, nothing else, and does not throw.
`identity.answers_reachability`: `deliveryTarget()` given an installation and an account the connector has never seen returns null or a `Recipient`, nothing else, and does not throw.
Resolving an account says nothing about whether the person may be in a place. That is the access ledger's business, read through `Core\Grants` and asserted through your `AccessController`. Keep the resolver free of authorisation.
---
# InboundGateway
Source: https://docs.subscriby.net/sdk/v1/ports/inbound-gateway
`Subscriby\Connector\Contracts\Ports\InboundGateway`. **Required of every connector.**
The core owns the route's middleware, the idempotency table and (eventually) the queue; the connector owns the platform's wire format and its authenticity check. A gateway-based platform with no HTTP request (Discord's WebSocket) builds envelopes itself in its worker, and the request methods of this port are never called for it.
## Methods
| Method | Called when | Returns |
| ------------------------------- | --------------------------------------------------------- | -------------------------- |
| `authenticate(Request)` | Every call to a route in your `routes/inbound.php`, first. | `bool` |
| `decode(Request)` | After authentication, to record each event once. | `iterable` |
| `immediateResponse(Request)` | When every event the call carried was seen before. | `?Response` |
| `shouldDefer(InboundEnvelope)` | Reserved: the core does not queue events yet. | `bool` |
## The envelope
```php
new InboundEnvelope(
connector: 'example',
installation: $installationRef, // null when the event names no installation
idempotencyKey: $botId.':'.$updateId, // stable per event, unique per connector
kind: 'message', // your own vocabulary
payload: $update, // the platform's event, as an array
receivedAt: new DateTimeImmutable,
);
```
`idempotencyKey` is what the core records before any handler runs, so a webhook retried after a timeout or a replayed gateway batch is processed once. Build it from what the platform guarantees is stable: Telegram's `update_id` per bot, Discord's event id. `kind` is yours; the core routes on it only where the SDK names a kind.
## What the gate does around your route
Every route you register in `routes/inbound.php` runs behind the core's `connector.inbound:` middleware, applied for you by the SDK's service provider. In order:
1. **Unknown connector**: 404.
2. **Paused** (Subscriby has switched the connector off during an incident): `503` with `Retry-After`, so the platform keeps the event and retries later. Nothing of yours runs.
3. **`authenticate()` returns false**: `403`. Nothing else runs.
4. **`decode()`** yields the envelopes; the gate records each `idempotencyKey` with an insert that races the unique index, so two copies arriving on two workers cannot both reach a handler. A replayed event is logged and counted.
5. **Every event replayed**: the gate answers with your `immediateResponse()` or an empty `204` and your route never runs. Otherwise your route runs inside the request.
Your route's controller then handles the events. Today that means decoding the request again (or reading what the gate decoded, if your gateway caches it per request) and driving your `ManagementSurface`, your customer conversation or your access handler yourself. `shouldDefer()` is on the contract so that when the core moves handling onto its queue your connector need not change; until then it is not called.
## `authenticate()`
Return true only when the call provably came from the platform: a signature over the body, a secret the platform echoes in a header. `authenticate()` receives only the request, so name the installation from the route (its external id in the path, say), then read its stored bag with `Core\Installations::credentials()`: it holds what the creator pasted and what you minted at `complete()` through the summary's `meta`, so a per-installation signing secret needs no table of yours. A connector-wide secret may live in your configuration instead. Never read a secret from the request. A connector whose platform offers no proof should at least pin the route to an unguessable path and say so on its listing.
## `immediateResponse()`
Some platforms demand an answer inside the request: an interaction acknowledgement, a challenge echo. Return that response; return null and the core answers `204`. The gate calls it only on the replay path; your own route returns whatever it needs on the first delivery.
## How Telegram does it
Telegram posts one update per call, keyed by a per-bot `update_id` it re-sends after a 5xx; the envelope's idempotency key is the bot's id and the update id together. Authenticity is the secret token Telegram echoes in a header, the same check the pre-SDK middleware made; with no secret configured every call passes, because bots registered before the secret was set send none.
## What the kit checks
`inbound.tolerates_empty_request`: for a `POST` with the body `{}` and a JSON content type, `authenticate()` returns a boolean, `decode()` returns an iterable and `immediateResponse()` returns null or a `Response`. None of them may throw on an empty request.
The gate decodes to record keys; your route decodes to handle. Make `decode()` cheap and side-effect free, or cache its result on the request instance. Never record anything of your own in `decode()`.
---
# Ports
Source: https://docs.subscriby.net/sdk/v1/ports
A **port** is an interface under `Subscriby\Connector\Contracts\Ports`. The core reaches a connector only through ports, and every method takes and returns SDK value objects from `Subscriby\Connector\Data`: an `InstallationRef` and the installation's `CredentialBag` say *which* installation acts and *with what*, a `SpaceRef` names a gated place, an `IdentityRef` a person's account, a `GrantRef` an access record. Eloquent models never cross in either direction.
The `Connector` class binds each port by its interface name through the `ConnectorRegistrar`; the registry checks the bindings against the manifest's [capabilities](/sdk/v1/manifest/capabilities) at boot.
## The sixteen ports
| Port | Bound | In one sentence |
| ------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------- |
| [`InstallationLifecycle`](/sdk/v1/ports/installation-lifecycle) | always | Connecting, verifying, describing and disconnecting an installation on the platform. |
| [`IdentityResolver`](/sdk/v1/ports/identity-resolver) | always | Who an inbound event is from, and what the platform knows about an account. |
| [`InboundGateway`](/sdk/v1/ports/inbound-gateway) | always | Turning what the platform sends into authenticated, deduplicated events. |
| [`FailureClassifier`](/sdk/v1/ports/failure-classifier) | always | Reading why the platform refused a call, in the core's seven kinds. |
| [`TextRenderer`](/sdk/v1/ports/text-renderer) | always | Turning the canonical HTML into what the platform accepts. |
| [`SettingsSchema`](/sdk/v1/ports/settings-schema) | always (usually from the manifest) | The install and settings forms as data. |
| [`UiSlots`](/sdk/v1/ports/ui-slots) | always | The connector's contributions to the core's UI slots, even when there are none. |
| [`Messenger`](/sdk/v1/ports/messenger) | `messaging`, `broadcasts` | Sending, editing and deleting the core's messages, and sending files. |
| [`AccessController`](/sdk/v1/ports/access-controller) | `access_control`, `early_admission_hold` | Granting, revoking, admitting, announcing and reconciling access to places. |
| [`SupportRelay`](/sdk/v1/ports/support-relay) | `support_relay` | Carrying a support conversation to the creator on the platform and back. |
| [`PortalLoginMethod`](/sdk/v1/ports/portal-login-method) | `portal_login` | Signing a member into the portal through the platform. |
| [`RegistersCreators`](/sdk/v1/ports/registers-creators) | `creator_registration` | Where a creator account can be started from inside the platform. |
| [`ManagementSurface`](/sdk/v1/ports/management-surface) | `management_surface` | The creator's in-chat admin surface over the core's command catalogue. |
| [`ProvidesPaymentMethods`](/sdk/v1/ports/provides-payment-methods) | `native_payments` (official only) | Payment methods that exist only because of the platform. |
| [`RecoverySupport`](/sdk/v1/ports/recovery-support) | any `recovery_*` | What the Disaster Recovery Program needs a connector to witness and do. |
| [`SpaceCatalog`](/sdk/v1/ports/space-catalog) | optional, no capability | How a creator picks a place to gate, and whether the installation controls it. |
## Rules every port shares
- **Refs in, results out.** A port never receives a model, a request from the dashboard or a database row; it receives refs (`InstallationRef`, `IdentityRef`, `SpaceRef`, `GrantRef`, `ProjectRef`, `CreatorRef`, `MemberRef`) that carry the ids and external ids it needs, and returns result objects (`GrantResult`, `DeliveryResult`, `InstallationHealth`, …) rather than throwing on a platform refusal.
- **Credentials arrive per call.** The `CredentialBag` is decrypted for the duration of one call and handed in beside the `InstallationRef`. A port never stores it, logs it or reads the core's table.
- **Refusals are classified, not thrown.** Every send, grant and probe reports a `DeliveryFailure` (kind, detail, retry-after, platform code) built by your `FailureClassifier`; the core decides from the kind whether to retry, wait, alert the creator or give up. An exception is for a programming error, not for a platform that said no.
- **Idempotent by design.** The core retries. `grant()` called twice yields one grant, `revoke()` of what the platform already lost reports revoked, `record()` on the Core API rewrites rather than duplicates, and a replayed inbound event is dropped by the core before it reaches you, but your own side effects must tolerate a second call all the same.
- **Unsupported facets throw once.** A method the manifest denies (a recovery facet, early admission on a kind that has none) throws `Subscriby\Connector\Exceptions\UnsupportedByConnector`. The core never calls a denied facet; the throw is there for the day it is called by mistake.
- **Nothing from the application's namespace.** A port reads the core through `Subscriby\Connector\Core\*` ([Core API](/sdk/v1/core-api)) and its own repositories, never through `App\*`. Subscriby's suite fails a package that does.
## Value objects you will meet on every page
| Object | Carries |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `InstallationRef` | `id`, `connector`, `scope` (`platform` or `project`), `projectId`, `externalId` (the bot or app id), `storageRef` (your own row), `handle`. |
| `CredentialBag` | `get(key)`, `has(key)`, `toArray()`: the secrets the install form's `secret` fields and your `complete()` stored. |
| `IdentityRef` | `id`, `connector`, `externalId`, `storageRef`. |
| `SpaceRef` | `id`, `connector`, `externalId`, `kind`, `storageRef`, `parentExternalId` (a role's guild, a topic's group). |
| `GrantRef` | `id`, `mode` (`GrantMode`), `reference` (what you issued). |
| `DeliveryFailure` | `kind` (`DeliveryFailureKind`), `detail`, `retryAfterSeconds`, `code`. |
| `DeliveryResult` | `delivered`, `externalMessageId`, `failure`. |
Every page below says which core surface drives the port today: a dashboard action, a scheduled job, a REST endpoint, the conformance kit. A method the core does not yet call is marked as such, so you know what the kit exercises and what only a future core release will.
---
# InstallationLifecycle
Source: https://docs.subscriby.net/sdk/v1/ports/installation-lifecycle
`Subscriby\Connector\Contracts\Ports\InstallationLifecycle`. **Required of every connector.**
The core owns the installation row and its encrypted credentials; the connector owns every call to the platform. Connecting is a two-step conversation (`begin()`, then `complete()`) so a paste-a-token connector can finish in one round trip while an OAuth connector can send the creator away and take them back.
## Methods
| Method | Called when | Returns |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | ----------------------- |
| `begin(InstallationRequest)` | The creator submits the install form (dashboard, `POST …/installation` for first-party clients). | `InstallationDraft` |
| `complete(request, draft, credentials)` | The draft is complete: at once for a one-step connector, after the return leg for OAuth. | `InstallationSummary` |
| `verify(installation, credentials)` | The **Verify** button, `POST …/installation/verify`, the connector doctor, the scheduled health probe. | `InstallationHealth` |
| `disconnect(installation, credentials)` | **Disconnect** (dashboard, `DELETE …/installation`, MCP), and the first step of an uninstall. | `void` |
| `describe(installation, credentials)` | The core refreshes the installation's name, handle and picture (after connect, on the doctor). | `InstallationSummary` |
| `startLink(installation, ?payload)` | A plan's deep link, a handshake link, the portal's button, `GET …/distribution/start-link`. | `?string` |
| `publicUrl(installation)` | "Open in …" links, the distribution endpoint's `public_url`. | `?string` |
| `platformInstallation()` | Creator handshakes and sign-up on a `platform`-scope connector, recovery facets that act as Subscriby. | `?InstallationRef` |
## `begin()` and `complete()`
`InstallationRequest` carries the `scope` (`InstallationScope::Project` or `::Platform`), the acting creator (`CreatorRef`, with their locale), the project (`?ProjectRef`), the install form's answers as `fields` (name to value, every field the creator submitted so far), `existing` (the `InstallationRef` being reconnected, or null for a first connection), `returnUrl` (the core's URL the platform must send the creator back to after a step taken there) and `state` (what you parked in the previous draft, when the creator is answering a further round; empty on the first). The same answers arrive in the `CredentialBag` handed to `complete()`: the core puts every install field in the bag, not only the secrets.
`begin()` validates what the creator gave and says what happens next through an `InstallationDraft`:
- `state`: anything you need to carry into `complete()`, or into the next `begin()` (a verified bot id, the platform's name for the installation, which round you are on).
- `fields`: more fields to ask for, when the first answers were not enough. The dashboard grows the form by them and calls `begin()` again with every answer so far in `fields` and your `state` in the request; keep answering with fields until the draft is complete.
- `continueUrl`: where to send the creator for the platform's own step (an OAuth authorisation page). Build it with `$request->returnUrl` as the redirect target. Null means the draft is complete.
The core follows the draft. For a complete draft it calls `complete()` in the same request with the bag, records the installation row from the summary and stores the bag encrypted on it. For a draft with `fields` it parks the draft on the pending installation, asks the creator, and calls `begin()` again with the merged answers and your `state`. For a draft with a `continueUrl` it parks the draft with a one-time token (a quarter of an hour), sends the creator to the URL and waits; when the platform brings them back to `returnUrl`, the core calls `complete()` with the parked request, the draft carrying `returned` (the return leg's query string, `code` and `state` for OAuth) and a bag of the answers so far. Exchange the code there and put the credential you obtain in the summary's `meta`, which is how it reaches the stored bag (below). A refusal on the return leg (`InstallationRefused::byPlatform()` for `returned['error']`) is shown to the creator on the Connectors tab, and a token that is reused or has expired is refused by the core before you are called. `InstallationDraft::withReturned()` is how the core hands you the return leg; you never build it yourself.
The fake connector's `FakeInstallationLifecycle` drives every path: a token starting `steps-` asks one more field (`region`) before completing, a token starting `oauth-` returns a `continueUrl` on the fake host and completes from `returned['code']`, minting `oauth-` into `meta`, and `returned['error']` becomes a platform refusal. The core's own tests connect through all three.
`complete()` does the platform work (register the webhook, set the command menu, fetch the bot's profile) and answers an `InstallationSummary`: `externalId` (the platform's id for the installation, unique per connector), `displayName`, `handle`, `avatarUrl`, `meta` and `storageRef`, your own row's id when you keep one. The core records the installation row from the summary through `Core\Installations::record()`, so you never write the row yourself. The bag it stores is the one it handed you **merged with `meta`** (`InstallationSummary::credentialsFor()`): a secret you mint in `complete()`, a webhook signing secret say, goes into `meta` and comes back in every `CredentialBag` your ports receive, and `Core\Installations::credentials()` reads it before any port is called, which is how an inbound gateway verifies a signature. Scalars are stored as strings and anything else as JSON; a minted key wins over a field of the same name.
Throw `Subscriby\Connector\Exceptions\InstallationRefused` when the platform refuses the credentials or another installation already holds them; the core shows its `reason` to the creator on the form.
## `verify()`
Ask the platform whether the installation still works and answer an `InstallationHealth`: `state` (`InstallationState`: `Connected`, `Degraded`, `Revoked`, `Disconnected`, `Pending`), your own `reason` code, a `detail` sentence, `creatorActionable` (can the creator fix it, or is it the platform?) and the `failureKind` when a call failed. The core writes the verdict to the row, emits `connector.status_changed` when it is a transition, and dispatches `InstallationHealthRecorded` to your package listeners.
Use the same platform call here as in `RecoverySupport::probeInstallation()`, so the two never disagree.
## `disconnect()`
Withdraw the installation from the platform, best effort: unregister the webhook, revoke what can be revoked. The platform may already have revoked the credentials, so a refusal here is logged, not thrown. The core wipes the credentials from the row and sets the state to `Disconnected` whatever you return; your own tables are untouched, and a reconnect names the same installation through `InstallationRequest::$existing`.
## `startLink()` and `publicUrl()`
`startLink($installation, $payload)` builds the link that starts a conversation with the installation on the platform, carrying a payload: a plan id for a storefront link, a handshake token for a sign-in, null for the home screen. Telegram answers `https://t.me/?start=`. Return null when the platform has no such thing; the core then falls back to the portal.
`publicUrl($installation)` is where a person finds the installation with nothing to open on: the bot's public page, a server's invite page. Distinct on purpose, because a creator pastes it into a bio and the dashboard shows it as "Open in …".
## `platformInstallation()`
For a connector whose manifest lists the `platform` scope, the installation Subscriby itself runs on the platform: the bot creators sign in through, are asked in and receive alerts from. The core hands it to `startLink()` for a creator's handshake links, to `RegistersCreators::signupEntry()` and to the recovery facets that need the platform's own installation to act. Return null for a connector installed per project only, or while none is configured.
## How Telegram does it
A bot connects in one step from its token: `getMe` says who the bot is, the webhook and the command menu are registered, and the row the legacy schema keeps for the bot is written so everything that still reads it keeps working during the dual-write window. A reconnect names the existing installation and re-points its row at the new token, so the project's members stay bound to the same bot. `verify()` and the recovery probe share `getMe`. `startLink()` is the `?start=` deep link; `publicUrl()` the bot's `t.me` page; `platformInstallation()` is Subscriby's platform bot.
## What the kit checks
`ports.required_bound` fails a connector that does not bind the port. No behavioural rule runs against it, because every method needs a live platform; Subscriby's suite exercises the Telegram implementation against a fake Bot API, and yours should do the same against your platform's client.
`InstallationSummary::$externalId` is what `Core\Installations::findByExternalId()` and the registry's uniqueness rely on. Return the platform's stable id for the installation (the bot's numeric id, the app's client id), never the token and never a display name.
---
# ManagementSurface
Source: https://docs.subscriby.net/sdk/v1/ports/management-surface
`Subscriby\Connector\Contracts\Ports\ManagementSurface`. **Bound by `management_surface`.**
The core publishes an admin **command catalogue** (`ManagementCommand`) and the actions behind it; a connector declares the subset it renders and drives those actions from the platform's own idiom: wizards over chat storage on Telegram, slash commands and modals on Discord. The [`management_commands`](/sdk/v1/manifest/management-commands) block is the declaration; this port is the implementation.
## Methods
| Method | Called when | Returns |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------- |
| `commands()` | The kit compares it with the manifest. | `list` |
| `handle(InboundEnvelope)` | Your inbound route hands it an event that belongs to the admin surface. The core does not dispatch envelopes itself yet. | `void` |
## `commands()`
Return exactly the `ManagementCommand` cases the manifest declares, in any order. The marketplace and the dashboard read the coverage from the manifest; the kit's `management.commands_match_manifest` makes sure the class agrees.
## `handle()`
The envelope's `payload` is the platform's event as your `InboundGateway::decode()` built it, its `installation` the installation that received it (the platform installation for a shared admin bot), its `kind` your own vocabulary. Resolve the actor (`IdentityResolver::resolveInbound()`), find the creator (`Core\Identities::findCreator()`), and drive your screens.
Two rules keep the surface honest:
- **Every command runs the core's action.** A plan created from the chat is created by the same action the dashboard's plan editor calls, with the same validation and the same team authorisation, so a teammate refused on the web is refused in the chat. Today the first-party connector reaches those actions through the application while the Core API grows the contracts a third party will call; a connector built now declares the commands whose actions the Core API already exposes (`Core\Installations`, `Core\Identities`, `Core\Spaces`, `Core\Grants`, `Core\Creators`, `Core\Alerts`) and adds the rest as the contracts land.
- **State lives in your own storage.** A wizard's progress is the connector's (a chat-storage key, a pending row in your own table), never a core column.
## Command buttons
The core's messages carry `MessageAction::command($label, $command, $params)` buttons, a catalogue command with its parameters in the core's words. Your `Messenger` renders each into the callback your surface answers to, and your `handle()` recognises it coming back. That is how a sale notice can offer "Manage plan" without the core knowing what a Telegram callback looks like.
## The member side
Members have a smaller catalogue of their own, `Subscriby\Connector\Enums\MemberCommand`: `Start` (the member's home: memberships, plans, status) and `ReissueGrants` (fresh access for everything their subscriptions entitle them to). A connector renders them in the same surface or a separate customer conversation; they are not part of `management_commands`.
## How Telegram does it
The platform bot renders the catalogue as the conversations, sections and callback actions its handler has always dispatched; the port names which of the catalogue's commands those cover, so the manifest, the Connectors tab and the docs matrix say the same thing, and it lets the core hand an inbound update to the handler without knowing how the client library builds one: the envelope's payload is the update.
## What the kit checks
`management.commands_match_manifest`: the sorted list `commands()` returns equals the manifest's `management_commands`.
A command in the manifest with no path through `handle()` passes the kit and fails review, and a creator who reads "manage plans from the platform" on the marketplace will look for it. Start with the reads, add the writes as you build them, bump the manifest each time.
---
# Messenger
Source: https://docs.subscriby.net/sdk/v1/ports/messenger
`Subscriby\Connector\Contracts\Ports\Messenger`. **Bound by `messaging`; `broadcasts` adds bulk sends on the same class.**
The core composes every message once and reads the result rather than trusting an HTTP status, because platforms refuse with `200 OK` as often as with an exception. Your `Messenger` renders nothing: it lays the body your `TextRenderer` produced and the actions out in the platform's shape and reports what happened.
## Methods
| Method | Called when | Returns |
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------- |
| `send(installation, credentials, Recipient, Message)` | Every confirmation, reminder, alert, sale notice, pass notice and broadcast the core delivers to one person. | `DeliveryResult` |
| `sendFile(installation, credentials, recipient, url, ?caption)` | A file the core has to hand a person: an access-code export, a receipt. Refused by the core when the manifest says `supports_files: false`. | `DeliveryResult` |
## The message
```php
new Message(
body: 'Payment received. Your access to Signals is active.',
actions: [
MessageAction::url('Open the channel', 'https://…'),
MessageAction::command('Manage plan', ManagementCommand::PlanManage, ['plan' => $id]),
],
meta: ['example' => ['silent' => true]],
);
```
- **`body`** is the canonical HTML subset; hand it to your `TextRenderer`.
- **`actions`** are built through four factories only: `url(label, url)`, `callback(label, data)`, `copy(label, text)` and `command(label, command, params)`. A command button names a catalogue command in the core's words and each connector renders it into the callback its own surface answers to, so no core message spells a platform's routing. `MessageAction::assertWithin()` has already thrown on the developer's machine for a callback the platform would truncate.
- **`meta`** is keyed by connector: `$message->metaFor('example')` gives you your own extras and every other connector ignores them.
The recipient is a `Recipient`: the `IdentityRef` to reach and an optional `threadId` when the platform threads conversations.
## What the core has done before calling you
1. **The capability switch.** A creator may switch `messaging` or `broadcasts` off for one installation; the core answers a `Configuration` failure itself ("The messaging capability is switched off for this installation") and never calls the port.
2. **The limits.** A body over `max_length` has been cut into parts, each within the limit: the core splits at the last paragraph break that fits, then the last line break, then the last space, never inside a tag or an entity, and closes and reopens the tags open at a cut so every part is well-formed canonical HTML. Your `send()` receives the parts in order, one call each, and the buttons arrive with the last part; the core reports the last part's result, or the first failure, to the caller. `Message::assertWithin($manifest->messaging)` has run on every part; a message with too many buttons or too many per row, or a limit too short to carry a word beside the message's own markup, becomes a `Configuration` failure before your port.
3. **Files.** `sendFile()` is refused by the core with `Configuration` when the manifest says `supports_files: false`.
4. **Reach.** The core chose which account to write to, by the notice's intent: a grant notice goes to the connector of the grant only, a support reply to the connector the member wrote from, and every other notice to the member's preferred account and the accounts they switched notices on for; an account your `IdentityResolver::deliveryTarget()` answered null for is passed over for the next, and email carries an account-critical notice once no account did. A creator's alerts follow their alert destinations. You are never asked which account; you are handed one.
5. **Pacing.** Bulk sends are spaced by [`pacing`](/sdk/v1/manifest/pacing); the broadcast pipeline reads `min_interval_microseconds`, jobs that send several messages to one person sleep `per_recipient_interval_microseconds`.
6. **Accounting.** Every call is timed and counted per connector and operation, and its `DeliveryResult` is read: a `failure` of a retryable kind requeues the job, a creator-actionable kind becomes a health verdict, anything else is recorded as failed.
## What you return
```php
DeliveryResult::delivered($platformMessageId);
DeliveryResult::failed($this->failures->classify($response));
```
`externalMessageId` is what `edit()` and `delete()` will be given back, so return the platform's real id. Build every failure through your `FailureClassifier`; never throw for a platform refusal.
## How Telegram does it
The canonical subset is Telegram's own parse mode, so the body goes as written; the actions become one inline button per row, the layout every core message has always used; callback data crosses verbatim as the `key:value` pairs the bot's handler already parses, and a command button is rendered into those pairs by the connector's `CommandCallbacks`. The result is read from the response body, because Telegram refuses inside a `200 OK`.
## What the kit checks
No kit rule drives `Messenger` directly, because a send needs a platform. Subscriby's suite sends every core message class through the fake connector's `FakeMessenger`, which records what it was asked and asserts with `assertSentTo($externalId, $containing)`, `assertNotSentTo($externalId, $containing)` and `assertNothingSent()`; use the same fake to test the core paths your connector relies on, and your platform's fake client to test the port itself.
A `Messenger` that swallows a refusal and returns `delivered` teaches the core that a member was told something they never received. Classify and return; the core knows what to do with each kind.
---
# PortalLoginMethod
Source: https://docs.subscriby.net/sdk/v1/ports/portal-login-method
`Subscriby\Connector\Contracts\Ports\PortalLoginMethod`. **Bound by `portal_login`.**
A member who bought through the platform has no password. The portal offers one sign-in button per installed connector with this port; the core mints a **handshake** (a two-sided proof with a token and a lifetime), the connector says how the member presents it on the platform, and the connector's inbound side completes it with the account that answered. The core polls the handshake and signs the member in when it completes.
## Methods
| Method | Called when | Returns |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------- |
| `button()` | The portal's sign-in sheet lists the methods of the project's installed connectors; the kit. | `PortalLoginButton` |
| `begin(installation, ProjectRef, HandshakeRef)` | The member taps the button: the core has opened the handshake and asks how the sign-in starts. | `PortalLoginStart` |
## `button()`
`PortalLoginButton`: `label` (a translation key, "Continue with Telegram") and `icon` (a key from Subscriby's connector icon set, usually your connector's own). Both non-empty.
## `begin()`
The `HandshakeRef` carries the handshake's `id`, `purpose` (`HandshakePurpose::PortalLogin`) and the `token` the member must present. Answer a `PortalLoginStart`:
- `token`: echo the token.
- `url`: where the member goes to present it, usually `InstallationLifecycle::startLink($installation, $payload)` with the token in the payload.
- `instructions`: what to do by hand when the link cannot open (type a command with the token in a chat with the installation).
The `installation` is the project's live installation on your connector and the `ProjectRef` names whose portal it is, so a project-bound handshake can only complete through that project's own installation.
## Completing it
The core never sees the platform; your inbound handler does. When an account presents the token (opens the deep link, types the command), your handler:
1. asks `Core\Identities::isHandshakeToken($token)` when the token arrived as a typed word, so a wizard answer that happens to look like a code is left to its wizard;
2. builds an `IdentityRecord` for the account that answered (what `IdentityResolver::adopt()` would file);
3. calls `Core\Identities::completeHandshake($token, $record, $seenBy)` with the installation that heard the account.
The core decides what the handshake was for: for a portal login it finds or creates the account's member in the handshake's project, links the identity, and answers a `HandshakeCompletion` with the `subjectName` and a `returnUrl` the connector may show the member ("Open the portal"). `HandshakeRefused` is thrown when the token names no pending handshake, the account already belongs to someone else, another project's installation heard it, or the purpose is not completed through this call; tell the member the link expired and offer a fresh start.
## The same handshake, other purposes
Creator links (a creator adding an account under **Linked accounts**), member links from the portal's **Connected accounts**, recovery relinks and backup identities are the same table and the same `completeHandshake()` call with other `HandshakePurpose`s. Your inbound handler need not know the purpose: it presents the account, and the core writes the right link.
## How Telegram does it
The portal shows the bot's deep link carrying the handshake's `auth_` payload; the bot's `/start` routes it to the customer handler, which offers the chat's identity back to the core. The typed command is the manual route for Telegram Web, where deep links do not always open.
## What the kit checks
`portal_login.button`: `button()` returns a label and an icon that are both non-empty.
Never sign a member in from a display name, a username or an external id you were told in a message. The handshake token is the proof; the account that presents it in a private conversation with the installation is the account that gets linked.
---
# ProvidesPaymentMethods
Source: https://docs.subscriby.net/sdk/v1/ports/provides-payment-methods
`Subscriby\Connector\Contracts\Ports\ProvidesPaymentMethods`. **Bound by `native_payments`, which the registry reserves for connectors Subscriby configures as official.**
Some platforms have money of their own (Telegram Stars). A connector that carries such a method describes it here, the core offers it in the project's payment methods and on the portal's checkout, and the connector's `payment_setup` and `checkout_method` slots render the screens. Because a native provider settles sales and meters the platform's fee, the path is not open to a package Subscriby has not reviewed: the registry refuses the capability at boot for a community connector.
## The method
```php
public function paymentProviders(): array; // list
```
## `NativePaymentProvider`
| Method | Returns |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `key()` | `:`, such as `telegram:stars`. The stored provider key; also the `PaymentProviderKey` value object. |
| `label()` | The name the payment-method picker shows. |
| `currencies()` | The currency codes the provider settles in (`['XTR']`). At least one. |
| `setupFields()` | The `Field`s a creator fills in to enable it; empty when nothing is needed. |
| `supportsSandbox()` | Whether a test-mode method can exist beside the live one. |
| `icon()` | The file name, without extension, of the provider's mark under the platform's payment icons. |
| `tagline()` | One line under the name in the picker, saying where it works and what a unit is worth. |
| `explainer()` | A sentence the plan editor shows under the price while a plan is priced in this currency, about where and how members can pay in it. |
| `unitInUsd()` | The fixed dollar value of one unit when the platform fixes it (a Star), or null when the currency floats and an exchange feed carries it. The rate job records the reciprocal as units per dollar. |
## What the core does with it
- Lists the provider in the project's **Payment methods** with its label, icon and tagline, and renders `setupFields()` plus the connector's `payment_setup` slot when the creator enables it.
- Offers it on the portal's checkout through the `checkout_method` slot, for plans priced in one of its currencies.
- Records its rate from `unitInUsd()` so prices convert like every other currency.
- Returns it in the MCP catalogue's payment-provider resource with `connector` and `requires_connector`, so an agent knows the method exists only where the connector is installed.
The checkout itself, settlement of a sale and refunds are still performed inside the official connector rather than through an SDK contract; that half of the contract joins the SDK in the payments slice, which is one more reason the capability is official-only today.
## How Telegram does it
`TelegramPaymentMethods` returns one provider, Stars: key `telegram:stars`, currency `XTR`, no setup fields, a fixed dollar value per Star, the Stars mark, and a tagline naming where it works. Its setup explainer and checkout option are the connector's `payment_setup` and `checkout_method` slots.
## What the kit checks
`payments.provider_keys`: every provider is a `NativePaymentProvider`, keyed `:` with the connector's own key, labelled, and settling in at least one currency. The registry adds the boot-time refusal for a non-official connector.
Stripe, PayPal, crypto and the other gateways are the core's, available on every connector. This port is only for money that exists on the platform and nowhere else.
---
# RecoverySupport
Source: https://docs.subscriby.net/sdk/v1/ports/recovery-support
`Subscriby\Connector\Contracts\Ports\RecoverySupport`. **Bound by any `recovery_*` capability.**
The Disaster Recovery Program is one core subsystem (incidents, an operations ledger with undo, readiness, quotas, member notices) with five platform facets the manifest's [`recovery`](/sdk/v1/manifest/recovery) block switches on. The core never calls a facet the block denies; a connector implements the denied ones by throwing `UnsupportedByConnector`.
## Methods
| Method | Facet | Called when | Returns |
| ---------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------ | --------------------- |
| `vocabulary()` | always | Every recovery page and notice names things in the connector's words. | `RecoveryVocabulary` |
| `healthReasonText(code)` | always | A health verdict is shown or notified and the connector may know the remedy. | `?HealthReasonText` |
| `readinessChecks(installation, project)` | always | The Readiness page lists the connector's lines beside the core's own checks. | `list` |
| `probeInstallation(installation, credentials)` | `probes` | The scheduled health probe and the doctor. | `InstallationHealth` |
| `probeSpace(installation, credentials, SpaceRef)` | `probes` | The scheduled probe of every gated place and every standby place, and the doctor. | `SpaceAccess` |
| `probeIdentity(installation, credentials, IdentityRef)` | `probes` | The scheduled probe of each creator's account. | `?DeliveryFailure` |
| `registerStandbyInstallation(ProjectRef, credentials)` | `standby_installations` | The creator registers a standby on the Prevention page. | `InstallationSummary` |
| `removeStandbyInstallation(standby, credentials)` | `standby_installations` | The creator removes it. | `void` |
| `failOver(installation, credentials, from, to, holders)` | `resource_standby` | Reserved: the first connector's failover still runs inside its own recovery actions. | `FailOverReport` |
| `mirror(installation, credentials, from, to, externalPostId)` | `mirror` | A post lands in a mirrored place and the standby should receive a copy. | `DeliveryResult` |
| `beginIdentityHandshake(platform, CreatorRef, HandshakeRef)` | `identity_relink` | Reserved: the first connector's relink still starts inside its own recovery step. | `PortalLoginStart` |
## Vocabulary and readiness
`RecoveryVocabulary` names the connector's world: `installationNoun` ("bot"), `spaceNoun` ("channel"), `identityNoun` ("Telegram account"), `grantNoun` ("invite link"), the plurals `installationsNoun` and `spacesNoun`, and optional sentences the pages use verbatim (`mirrorNote`, `installationHandoverNote`, `installationMovedAnnouncement`). Every noun must be non-empty. A creator with two connectors installed sees two recovery cards saying different things, in the right words each.
`readinessChecks()` returns the connector's lines of the checklist as `ReadinessItem`s: a unique `key`, `label`, `description`, `icon`, `satisfied`, `prevention` (whether the item belongs to the paid prevention tier), `fixRoute` (a named route the fix button opens) and `detail`. Read the facts from `Core\Recovery::coverage($project, $connector)`, which answers a `RecoveryCoverage`: whether a standby installation exists, whether failover is automatic, and one `SpaceCoverage` per gated place (`hasStandby`, `standbyHealthy`, `mirrored`). The core's own three checks (a second factor, a second administrator, an off-platform copy) are added by the core.
`healthReasonText($code)` lets the connector replace the core's generic sentence for a health reason code with its own label and explanation ("recreate the bot in @BotFather"); return null for a code you have nothing to add to.
## The probes
Three probes, because the core acts differently on each:
- `probeInstallation()` answers an `InstallationHealth` like `InstallationLifecycle::verify()`; use the same platform call so the two never disagree.
- `probeSpace()` answers a `SpaceAccess`: `ready`, a `state` word, a `detail` sentence and `creatorActionable`. The core writes it to the place's health and shows the connector's remedy.
- `probeIdentity()` answers null while the account still answers, a `TargetMissing` failure when the account is gone (the incident the program exists for), and any other kind when the account exists but cannot be reached or the platform could not be asked, which the core leaves alone.
## Standby installations
`registerStandbyInstallation()` proves the standby with the platform and stores your own row for it, but registers it for nothing: it receives no events and answers nobody until a failover writes it into the live installation's place. Answer an `InstallationSummary` whose `storageRef` names your row; the core records the neutral installation row (role `standby`) from it. Throw `InstallationRefused` when another installation already holds the credentials or the platform refuses them. `removeStandbyInstallation()` forgets it; nothing was ever registered on the platform, so nothing is withdrawn.
## Failover and mirroring
`failOver()` moves every holder from a lost place to its standby: the `holders` are `GrantSnapshot`s (grant, space, identity, state) for everyone who should be in the standby; answer a `FailOverReport` (`readmitted`, `failed`, `failures`). `mirror()` copies one post by its platform id into the standby and answers a `DeliveryResult`. Both need a resource kind marked `mirrorable` for the mirror to be offered.
## Identity relink
`beginIdentityHandshake()` starts proving that a creator controls an account, for a relink after a ban or a backup identity registered in advance: like `PortalLoginMethod::begin()`, it answers a `PortalLoginStart` for the platform-scope installation, and the connector's inbound side completes the handshake with `Core\Identities::completeHandshake()`.
## How Telegram does it
A bot is probed with `getMe`, the same call that verifies an installation; a chat with `getChatMember` on the bot's own id, the one call that tells a deleted chat from a removed bot from a missing right; an account with `getChat` on its private chat, which is side-effect free and is how Telegram says `user is deactivated`. The standby bot is proven with `getMe` and kept in the connector's own table until a failover.
## What the kit checks
`recovery.vocabulary_and_readiness`: every noun of the vocabulary is non-empty, and `readinessChecks()` returns `ReadinessItem`s with unique keys for a synthetic installation and project.
Every sentence a connector shows about recovery on a platform with terms about automation carries the platform's stance: what the program does once per kind in ninety days, what needs a support audit, what closes an account on a confirmed violation. Review holds recovery copy to it.
---
# RegistersCreators
Source: https://docs.subscriby.net/sdk/v1/ports/registers-creators
`Subscriby\Connector\Contracts\Ports\RegistersCreators`. **Bound by `creator_registration`.**
Creator accounts are email-first: an address, a password or a passkey, and connectors *add* linked identities. A connector with this port may still start an account from inside the platform (a visitor talking to Subscriby's bot creates their account without leaving the chat) by running its own sign-up conversation on the shared installation and handing the completed form to the core.
## The method
```php
public function signupEntry(InstallationRef $platform): string;
```
Given the platform-scope installation that hosts the sign-up, return the link that starts the conversation: usually `InstallationLifecycle::startLink($platform)` with nothing to open on. The core's registration page calls it for every available connector that binds this port and whose `platformInstallation()` answers, and offers **Sign up inside … instead** under the sign-in buttons; an empty string leaves the connector out rather than offering a dead button. The sign-in button itself is the connector's [`creator_login_method`](/sdk/v1/ports/ui-slots) slot.
## The conversation
Everything between the entry link and the account is the connector's: ask for the name, the address and a password in the platform's idiom, validate as the platform allows, and when the form is complete call the Core API:
```php
$creator = $this->creators->register(new CreatorRegistration(
name: $name,
email: $email,
password: $password,
identity: $identityRecord, // the account you are talking to, as adopt() would file it
));
```
`Core\Creators::register()` creates the email-first account, links the vouching account as the creator's primary identity on your connector, sends the verification mail, and answers a `CreatorRef`. It throws `RegistrationRefused` when the address already has an account or the core would not adopt the account (it already belongs to another creator); tell the visitor to sign in instead, or to link the account from **Linked accounts** after signing in on the web.
The connector never sees the user beyond the ref. From here the creator's alerts arrive through the identity you linked, and their first project is created through your `ManagementSurface` or the dashboard.
## How Telegram does it
The platform bot's plain `/start` shows a visitor without an account the sign-up pitch and its button; the conversation behind it validates the form and hands it to `Creators::register()`. So the entry is the bot's start link with nothing to open on.
## What the kit checks
Nothing beyond `ports.required_bound`'s capability agreement: a connector that declares `creator_registration` must bind the port. Review reads the conversation for what it collects and how it stores nothing of its own.
An existing creator signing in with their platform account goes through the `creator_login_method` slot and a creator-link handshake, not through this port. This port only creates accounts.
---
# SettingsSchema
Source: https://docs.subscriby.net/sdk/v1/ports/settings-schema
`Subscriby\Connector\Contracts\Ports\SettingsSchema`. **Required of every connector, and usually bound for you.**
The dashboard, the REST API and the creator apps all render a connector's forms from these fields, so a connector needs no Blade to be installable. A `UiSlots` contribution may dress the same fields up on the web (a walkthrough video, a screenshot) but never replaces them.
## Methods
| Method | Called when | Returns |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------- |
| `installFields()` | The Connect dialog opens; `GET /connectors` and the MCP catalogue describe the connector; the kit. | `list` |
| `settingsFields(?InstallationRef)` | The installation's Configuration tab opens (with the installation) or the catalogue describes the defaults (with null). | `list` |
## The manifest binds it for you
A connector whose forms are plain fields declares them in `connector.json` under [`install.fields` and `install.settings_fields`](/sdk/v1/manifest/install) and binds nothing. When the registry sees a connector that declared fields and bound no `SettingsSchema`, it binds the SDK's `ManifestSettingsSchema` over the file. That class also translates every label, help text, step and select option through the package's `lang/*.json` files, so the dashboard, the API and the apps show the creator's language.
Every field is a `Subscriby\Connector\Data\Field`:
```php
new Field(
name: 'token',
type: FieldType::Secret,
label: 'Bot token',
help: 'Keep it secret: anyone who has it controls the bot.',
required: true,
rules: ['string', 'min:20'],
options: [], // for a select: value => label
value: null, // the current value, on a settings form
steps: [], // for instructions: numbered steps with :app and :button
links: [], // text => https or mailto URL
);
```
The constructor enforces three rules itself and throws `InvalidManifest` otherwise: the name is a lower-case identifier, a select has options, every link target is `https://` or `mailto:`.
## When to write your own
Bind your own implementation when the fields depend on the installation: a select over the channels the connected server has, a toggle whose default the platform dictates, a `link` field pointing at the installation's own page on the platform. Then declare no fields in the manifest, because the registry binds `ManifestSettingsSchema` only when the file declares fields and the class binds nothing, and the kit's `manifest.file_is_the_source` compares field counts between the two.
`settingsFields($installation)` receives the installation being edited, so it can fill each field's `value` from your own row or from the platform. With null it returns the defaults, which is what the catalogue publishes.
## How the values come back
The core validates every field's `rules`, then hands the values to you: every install value arrives in `InstallationRequest::$fields` and in the `CredentialBag` (the bag holds all submitted install fields and is what the core stores encrypted); settings values are written to the installation's settings by the core's `UpdateInstallationSettings` action (dashboard, `PATCH …/installation/settings`, MCP) after the same validation, and your ports read them from the `InstallationRef`'s installation row through `Core\Installations` or from your own table if you mirror them.
## How Telegram does it
Telegram declares its two install fields (the BotFather walkthrough and the token) in `connector.json` and binds no schema of its own; the video beside the form is its `install` slot.
## What the kit checks
`settings.install_fields`: every entry is a `Field`, names are unique, every field has a label, and a `paste_credential` connector declares an input and a `secret`. `settings.settings_fields`: the same shape rules for `settingsFields(null)`.
A `secret` field's value goes into the encrypted `CredentialBag` and is never returned by `settingsFields()`, `GET /connectors` or any resource. Do not put a token in a `text` field to make it editable; make the creator paste a new one through a reconnect.
---
# SpaceCatalog
Source: https://docs.subscriby.net/sdk/v1/ports/space-catalog
`Subscriby\Connector\Contracts\Ports\SpaceCatalog`. **Optional; no capability binds it.** A connector with `access_control` almost always binds it, because it is how a creator links a place.
Linking is asked *through* the connector because only the platform can prove the installation administers a place: Telegram answers a chat picker, Discord a guild pick. The connector parks the request under the creator's account with its own handle, the platform answers, and the chosen place is filed under the request's purpose.
## Methods
| Method | Called when | Returns |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -------------- |
| `requestLink(installation, credentials, IdentityRef, LinkRequest)` | The creator clicks **Link a resource**, **Request a standby** or **Replace** in the dashboard. | `void` |
| `withdrawLinkRequest(installation, credentials, creator, LinkPurpose)` | The creator cancels the request. | `void` |
| `pendingLinkRequest(installation, credentials, creator, LinkPurpose)` | The dashboard shows whether a request is waiting, and for what. | `?string` |
| `linkInstructions(LinkPurpose)` | The dashboard tells the creator what to do on the platform while a request is open. | `string` |
| `describe(installation, credentials, SpaceRef)` | The core wants the platform's current title and parent for a place. | `SpaceSummary` |
| `diagnose(installation, credentials, SpaceRef)` | The connector doctor checks every gated place. | `SpaceAccess` |
## Link requests
A `LinkRequest` carries the `kind` of place wanted (one of your resource kinds), the `purpose` and the subject:
| `LinkPurpose` | The creator is picking… | `subjectId`, `subjectTitle` |
| -------------- | ------------------------------------------------------------- | --------------------------------------- |
| `Resource` | A place to sell access to, as a new resource. | The project's id and name. |
| `Standby` | A place to stand by for an existing resource. | The resource's id and title. |
| `Replacement` | A place to replace an existing resource's lost one. | The resource's id and title. |
| `SupportRelay` | A group to relay support conversations into. Reserved: the core does not request it yet; Telegram links the group when the bot is added to it. | The project's id and name. |
One request per purpose is open at a time for a creator; asking again replaces it. The `IdentityRef` is the creator's account on your connector, the one to ask in; the core refuses the request itself when the creator has no reachable account there.
`linkInstructions()` is the one or two plain sentences the dashboard shows while the request is open, translated: "Our bot sent you a button to pick the chat. Tap it from your Telegram account…". Only the connector knows whether the creator taps a keyboard, picks a guild or approves a prompt.
## Completing a request
When the platform answers with the chosen place, your inbound handler files it under the purpose it parked. Record the place first with `Core\Spaces::record(new SpaceRecord(...))`, then:
- a `Resource` request: [`Core\Resources::create($project, $space, ResourceKind::for($key, $kind), $title)`](/sdk/v1/core-api/resources) writes the resource, binds it to the space and announces it; idempotent, so a replayed answer creates nothing twice;
- a `Standby` request: [`Core\Recovery::registerStandby($resource, $space)`](/sdk/v1/core-api/recovery) files the place as the resource's reserve;
- a `Replacement` request: [`Core\Recovery::replaceSpace($resource, $space)`](/sdk/v1/core-api/recovery) points the resource at the new place and re-admits everyone with access.
Each write is the action the dashboard runs, so the owner guard, the tier gate and the allowance are the same whichever road the creator took, and each refusal reaches you as `ResourceRefused` or `RecoveryRefused` with the sentence to relay. `Core\Spaces::bindResource()` stays for a place that should gate an *existing* resource outside the ledger, which is what a migration uses.
Linking the group a **support relay** posts into for a `SupportRelay` request is not on the SDK yet: it lands with `Core\Support`, scheduled with the second first-party connector, and until then a third-party connector that declares `support_relay` says so on its listing and watches the SDK changelog.
## `diagnose()`
Whether the installation can grant and revoke in a place, and if not why: `SpaceAccess` with `ready`, a `state` word your connector defines (`ready`, `not_admin`, `missing_right`, `gone`), a `detail` sentence for the creator and `creatorActionable`. The doctor renders it with your `resource_health_detail` slot when you fill one.
## How Telegram does it
Telegram cannot list the chats a bot administers, so a place is linked by asking the creator to pick it: the bot sends a reply keyboard whose one button opens Telegram's chat picker filtered to the kind wanted, Telegram makes the bot an administrator of the chosen chat with the two rights it needs, and answers with a `chat_shared` update carrying the request id parked under the creator's chat. `diagnose()` is `getChatMember` on the bot's own id, the one call that tells a deleted chat from a removed bot from a missing right.
## What the kit checks
Nothing beyond the port's shape: a bound `SpaceCatalog` is exercised by the doctor and the dashboard, not by the kit. Test `diagnose()` against your platform's fake for each state you return.
A connector reports every place it learns about through `Core\Spaces::record()` and decides nothing for the creator. Selling access to a place is the core's decision, made when the resource is created.
---
# SupportRelay
Source: https://docs.subscriby.net/sdk/v1/ports/support-relay
`Subscriby\Connector\Contracts\Ports\SupportRelay`. **Bound by `support_relay`.**
The inbox is the core's: every message a member sends is ingested, stored, shown in the dashboard and answered from there. A connector with this port adds two things: it **delivers the creator's replies** to the member on the platform, and it **offers relay modes**, ways of carrying the conversation to the creator on the platform as well, named in the manifest's [`relay_modes`](/sdk/v1/manifest/relay-modes).
## Methods
| Method | Called when | Returns |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| `relayModes()` | The kit; the support settings compare it with the manifest. | `list` |
| `relay(installation, credentials, ConversationRef, Recipient, OutboundSupportMessage)` | A creator or teammate answers a conversation whose member is reached on this connector. | `DeliveryResult` |
| `openThread(installation, credentials, SpaceRef, title)` | The first message of a conversation arrives while the project relays into a group (`forum_group`). | `RelayThread` |
| `postToThread(installation, credentials, SpaceRef, threadId, Message)` | Every later message of that conversation, in both directions, is mirrored into its thread. | `DeliveryResult` |
| `fetchAttachment(installation, credentials, SupportAttachment)` | A creator opens a conversation that carries a file the member sent; the core asks for the bytes once and stores its own copy. | `?FetchedAttachment` |
## `relay()`
`OutboundSupportMessage` is the reply: `body` (canonical HTML), `attachments` (files the creator attached) and `quotedExternalId`, the platform id of the member's message being answered, so a platform that quotes can. `ConversationRef` names the thread (`id`, `projectId`); the `Recipient` is the member's identity. Return the platform's message id in the `DeliveryResult` so the core can show "delivered" and quote it later.
## Group threads
When the project's relay mode is `forum_group`, the creator has linked a group: on Telegram by adding the bot to it, which the connector's inbound handler reports to the core through [`Core\Support::linkRelaySpace()`](/sdk/v1/core-api/support#the-relay-space). (`LinkPurpose::SupportRelay` exists for a platform that has to be asked, but the core does not request it yet.) The core keeps the thread id on the conversation: `openThread()` is asked once per conversation, `postToThread()` for every message afterwards, and a thread the platform no longer knows is answered `TargetMissing` so the core opens a fresh one. `RelayThread` carries the `threadId` or the classified `failure`.
## Attachments
A member's file arrives as a `SupportAttachment`: its `kind` (`Image`, `Video`, `Animation`, `Voice`, `Audio`, `File`), a `url` when the platform serves files at one, or a `platformFileId` when it does not. The core keeps that reference until a creator opens the thread, then calls `fetchAttachment()` once and stores its own copy. Answer a `FetchedAttachment` (`stream`, `fileName`, `mime`) or null when the platform cannot hand the file over (a reference it no longer knows, a path this container cannot see); the core answers "not found" and stores nothing. Log the reason yourself.
## What the core has done before calling you
The `support_relay` capability may be switched off per installation; the core then answers `Configuration` ("The connector does not relay support conversations.") without calling the port. Every call is timed and counted, and a `DeliveryResult` is read like a `Messenger`'s.
## Explaining the modes to the creator
The mode names mean nothing on their own. Fill the `support_relay_options` [slot](/sdk/v1/ports/ui-slots) with a short explanation of what each mode means on your platform; its context is `mode` (the current choice), `linked` (whether a relay space is linked) and `handle` (the installation's handle for the sentence).
## How Telegram does it
A reply with a file goes as the media call Telegram has for its kind, with the text as the caption; a reply that answers a particular message quotes it, and is still sent when the quoted message is gone. The two modes are a DM to the creator's own account and a topic per member in a forum group the project bot was added to. Attachments come from a Bot API in local mode, so the connector is given an absolute path and reads it from a mounted copy of that directory.
## What the kit checks
`relay.modes_match_manifest`: the sorted list `relayModes()` returns equals the manifest's `relay_modes`.
A member's inbound support message reaches the core from your inbound handler, which decodes it into an `InboundSupportMessage` and files it through [`Core\Support::ingest()`](/sdk/v1/core-api/support); a creator's answer written on your platform goes back through `Core\Support::reply()`. The core never reads the platform for that; your handler is the only way a member's words enter the inbox, and this port is the only way the core's words leave it.
---
# TextRenderer
Source: https://docs.subscriby.net/sdk/v1/ports/text-renderer
`Subscriby\Connector\Contracts\Ports\TextRenderer`. **Required of every connector.**
Everything the core says to a person is written once, in ten languages, in a canonical HTML subset, and rendered down here per platform. That is what lets years of existing copy move to a new platform without a migration: Telegram keeps the HTML, Discord turns it into Markdown, a platform with no formatting strips it to the words.
## The method
```php
public function render(string $canonicalHtml): string;
```
## The canonical subset
The core's composer emits these eight tags and nothing else:
| Tag | Meaning | HTML platform | Markdown platform | Plain-text platform |
| -------------------------- | ---------------------------------- | ------------- | ---------------------- | ------------------- |
| `…` | Bold | as is | `**…**` | the words |
| `…` | Italic | as is | `*…*` | the words |
| `…` | Underline | as is | `__…__` or the words | the words |
| `…` | Strikethrough | as is | `~~…~~` | the words |
| `…` | A link | as is | `[…](…)` | `words (url)` |
| `…` | Inline code (an access code, a handle) | as is | `` `…` `` | the words |
| `…
` | A block of preformatted text | as is | fenced block | the words |
| `…
` | A quoted message (a support reply) | as is | `> …` | the words, indented |
Two rules hold for every platform:
1. **Words survive.** Whatever the platform cannot show, the text inside the tag stays. A renderer that drops a `` element's content loses meaning in ten languages at once.
2. **Plain text is the identity.** A body with no tags renders as itself, byte for byte. Do not escape, trim or re-wrap it.
Entities arrive HTML-encoded (`&`, `<`); a Markdown or plain-text renderer decodes them, an HTML renderer passes them through.
## What a renderer must not do
- **Compose.** The renderer receives a finished message. It never adds a signature, a greeting or a footer; a connector that wants those fills a UI slot or sets `Message::$meta` for its own `Messenger` to read.
- **Truncate.** Length is the manifest's [`messaging.max_length`](/sdk/v1/manifest/messaging) and the core splits a longer body into parts before rendering, so a renderer never sees a body over the limit.
- **Escape user content twice.** The core already encoded what it put inside the tags.
## How Telegram does it
The canonical subset is Telegram's own HTML parse mode, so rendering is the identity: every tag the core may emit is one Telegram accepts as written. The allow-list that used to strip unknown tags now belongs to the core's composer, which never emits them.
## What the kit checks
`text.plain_text_survives`: `render('Hello, world')` returns exactly `Hello, world`. `text.canonical_sample_renders`: the sample `Bold italic underline struck link code pre
quote
` renders to a non-empty string that still contains every one of the eight words.
Pull a few of the core's longest messages (a sale notice, a pass reminder, a support reply with a quote) through your renderer in your own tests and read the result on the platform. The kit proves the words survive; only your eyes prove the message still reads well.
---
# UiSlots
Source: https://docs.subscriby.net/sdk/v1/ports/ui-slots
`Subscriby\Connector\Contracts\Ports\UiSlots`. **Required of every connector, even one that fills nothing.**
Core pages that need platform-specific UI render typed **slots**; a connector fills the ones it wants. Returning an empty list is how a connector says the core's generic rendering is enough, and it is a perfectly good connector. Every slot has a declarative or REST-visible equivalent, so the creator apps lose nothing when a connector contributes only Blade.
## The method
```php
public function slots(): array; // list
```
One `SlotContribution` per slot the connector fills, at most one per slot:
```php
new SlotContribution(
slot: ConnectorSlot::Install,
view: 'connector-example::slots.install', // a Blade view in your namespace…
component: null, // …or a Livewire component you registered, never both
placeholder: 'connector-example::slots.install-placeholder',
);
```
The core renders a slot with `` for the connector in scope; a connector that fills nothing renders nothing, and the page never knows what a connector shows there, only that the space exists. Your view receives the context array as its data; a component contribution is mounted with it as parameters, under a key made of the slot and the connector. When the page is lazy the core includes your `placeholder` view first, so skeleton parity holds for connector UI too.
## What a contribution receives
The context the page passes: SDK refs and plain values the slot is about, never a model. Each slot's page section below names them. A contribution reads only its context and the connector's own services and repositories; it never queries the core's tables or imports the application's classes, and every string it shows comes from the package's `lang/*.json`.
## The slots
The catalogue names every place a connector may contribute. Fourteen of them are rendered by a core page today; the rest are declared so a connector can fill them now and see them appear as the core pages grow into them. The kit accepts a contribution for any of the twenty-one.
| Slot | Where it renders | Rendered today | Context |
| ----------------------------- | ---------------------------------------------------------------------------------- | :------------: | --------------------------------------------------------- |
| `install` | Beside the install form in the Connect dialog (a walkthrough video). | ✅ | — |
| `settings` | Under the connection and settings fields of each installation on the Configuration tab. | ✅ | `installation` (`InstallationRef`) |
| `resource_badge` | Beside a gated resource's connector and kind in the resources list. | ✅ | `resource` (`ResourceRef`), `space` (`?SpaceRef`) |
| `resource_link_instructions` | Under "Link a resource", after the platform-side request is sent. | ✅ | the connector being linked, the purpose |
| `resource_swap` | Inside the Disaster Recovery resource replacement step. | — | the old and new `SpaceRef`s |
| `resource_health_detail` | The detail panel of a resource's health verdict. | — | `SpaceRef`, the verdict |
| `member_identity_badge` | Beside a member's identity on this connector. | — | `IdentityRef` |
| `grant_action` | Beside each place in a subscription's detail, next to its Joined / Not Joined badge. | ✅ | `resource` (`ResourceRef`), `space` (`?SpaceRef`), `joined` (`bool`) |
| `creator_login_method` | A sign-in button on the creator's sign-in and sign-up pages, one per available connector that fills it. | ✅ | — (read your own platform installation) |
| `portal_grant_action` | The button a member taps to open one grant on the portal's membership page. | ✅ | `title`, `url` (the grant's link, when it has one), `resourceId` |
| `payment_setup` | The setup screen of a native payment method in the payment-method editor. | ✅ | the provider key, the project |
| `checkout_method` | The checkout option of a native payment method on the portal. | ✅ | the provider key, the plan |
| `access_code_redemption_hint` | Under "Where members redeem a code" in the access-code generator, one per live installation of the project. | ✅ | `installation` (`InstallationRef`) |
| `broadcast_hints` | Under the editor in the broadcast composer, one per live installation of the project. | ✅ | `installation` (`InstallationRef`) |
| `support_relay_options` | Under the relay mode picker in the support settings, explaining each mode. | ✅ | `mode`, `linked`, `handle` |
| `recovery_actions` | The "what you can do now" card on the Disaster Recovery page. | ✅ | the installation, the incident |
| `recovery_prevention` | The connector's section of the Prevention page. | ✅ | the installation, the coverage |
| `recovery_steps` | The connector's recovery step components (relink an account, replace a place). | — | the installation, the operation |
`portal_grant_action` has a fallback: when a connector fills nothing the core renders a neutral link button from the grant's URL, so a `bearer_link` connector without a slot still gives the member something to tap.
## Livewire components
A contribution may name a Livewire component instead of a view when the slot has to react (a relink handshake that polls, a picker). Register the component in your service provider under your own namespace, give it a `@placeholder`, and name it in `component`. The component follows the same reading rule as a view: its context and your services only.
## How Telegram does it
The `install` slot shows the walkthrough video beside the form the core renders from `connector.json`; `resource_link_instructions` adds the "add the bot as an administrator" shortcut under the core's request button; `portal_grant_action` renders the "Join …" button a member taps to open the invite link a grant carries; `payment_setup` and `checkout_method` are the Stars explainer and its checkout option; `support_relay_options` explains the DM and the forum-group modes; `creator_login_method` is the "Continue with Telegram" widget button on the creator's sign-in and sign-up pages, which hands the widget's answer to the package's own callback route; `settings` says the bot's name, description, picture and commands live in BotFather and links to the bot; `broadcast_hints` names the formatting Telegram keeps and the 4,096-character limit; `access_code_redemption_hint` says a member redeems a code by sending it to the bot; the three `recovery_*` slots are the relink and replacement steps.
## What the kit checks
`slots.well_formed`: every entry is a `SlotContribution`, no slot is filled twice, and every contribution ships a non-empty `placeholder`. Subscriby's own suite adds that every string a slot shows has a translation in each of the ten locales, and review adds that the placeholder mirrors the loaded layout.
A contribution decorates a core page; it never carries a flow of its own that the REST API cannot reach. A connector that needs a screen of its own (a wizard the platform imposes) drives it from `InstallationDraft::$continueUrl` or its own `routes/web.php`, and keeps the slot to a button that opens it.
---
# 1. The package
Source: https://docs.subscriby.net/sdk/v1/tutorial/01-package
## composer.json
```json
{
"name": "acme/subscriby-connector-agora",
"description": "Sell access to private Agora boards through Subscriby.",
"license": "MIT",
"require": {
"php": "^8.5",
"illuminate/contracts": "^13.0",
"illuminate/http": "^13.0",
"illuminate/support": "^13.0",
"subscriby/connector-sdk": "^1.0"
},
"autoload": {
"psr-4": { "Acme\\Connectors\\Agora\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Acme\\Connectors\\Agora\\AgoraConnectorServiceProvider"]
}
}
}
```
The SDK depends on `illuminate/contracts`, `illuminate/http` and `illuminate/support` only; a connector adds nothing else it does not need. `extra.laravel.providers` is how the application discovers the package.
## The service provider
```php
app->make(AgoraConnector::class);
}
}
```
That is the whole provider. The base class finds the package root as the grandparent of `src/`, reads `connector.json` from it, registers the connector, and loads `database/migrations`, `resources/views` (as `connector-agora::`), `lang/*.json`, `routes/inbound.php` (behind `connector.inbound:agora`), `routes/web.php` and any `config/*.php`. Two hooks exist for later: `packageCommands()` for console commands and `packageListeners()` for the SDK's events.
## The Connector class
Start with the seven required ports as stubs so the package boots; each chapter replaces one.
```php
port(InstallationLifecycle::class, $this->lifecycle);
$registrar->port(IdentityResolver::class, $this->identities);
$registrar->port(InboundGateway::class, $this->gateway);
$registrar->port(FailureClassifier::class, $this->failures);
$registrar->port(TextRenderer::class, $this->renderer);
$registrar->port(UiSlots::class, $this->slots);
}
}
```
`SettingsSchema` is missing on purpose: the manifest will declare the install fields and the core binds the SDK's `ManifestSettingsSchema` for us. The other capability ports (`Messenger`, `AccessController`, `SpaceCatalog`, `PortalLoginMethod`, `RecoverySupport`) join in their chapters; remember that the registry refuses a declared capability without its port, so the manifest and this class grow together.
## Configuration
Anything the connector needs that is not per installation lives in `config/connector-agora.php`, merged under its own name:
```php
(int) env('CONNECTOR_AGORA_TIMEOUT', 10),
'webhook_path' => 'endpoints/connectors/agora',
];
```
Read it as `config('connector-agora.timeout')`. Nothing secret goes here: API keys and webhook secrets are per forum and live in the core's encrypted `CredentialBag`.
## The HTTP client
Every port talks to Agora through one client, so the failure reading, the base URL and the timeout live in one place. It takes the installation's forum URL and key per call, because a port receives them per call.
```php
client((string) $credentials->get('forum_url'), (string) $credentials->get('api_key'));
}
public function client(string $forumUrl, string $apiKey): PendingRequest
{
return $this->http
->baseUrl(rtrim($forumUrl, '/').'/api')
->withToken($apiKey)
->acceptJson()
->timeout((int) config('connector-agora.timeout'));
}
/**
* Agora answers refusals inside a 200 with `ok: false`; treat those as failures too.
*/
public function refused(Response $response): bool
{
return $response->failed() || $response->json('ok') === false;
}
}
```
`forum_url` and `api_key` are the two fields the install form will declare in the next chapter; `complete()` will add the webhook id and secret to the same bag.
## The model and its table
Agora needs us to remember one thing per forum the core does not keep: the webhook Agora registered for us, so we can delete it on disconnect. That is a table of our own, prefixed with the key:
```php
Schema::create('agora_forums', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->uuid('installation_id')->nullable()->index();
$table->string('forum_url');
$table->string('bot_user_id');
$table->string('webhook_id')->nullable();
$table->timestamps();
});
```
No secret lives here: the webhook signing secret goes to the core with the credentials (chapter 3). `installation_id` points *into* the core, which the [data rules](/sdk/v1/building/data) allow; the core row will point back at ours through `storage_ref`. No `Schema::table` on anything of the core's, ever: the kit reads the file and fails the package otherwise.
With `composer install`, a `connector.json` from the next chapter and the six stubs returning empty values, the package registers: the kit's manifest rules pass and the registry lists `agora`. Nothing is available to creators until Subscriby switches the connector on, which is exactly right while we build.
---
# 2. The manifest
Source: https://docs.subscriby.net/sdk/v1/tutorial/02-manifest
Everything Agora *is* goes into `connector.json`. Here is the whole file; the sections below say why each block reads as it does.
```json
{
"$schema": "https://docs.subscriby.net/sdk/connector.schema.json",
"key": "agora",
"name": "Agora",
"version": "0.1.0",
"sdk": "^1.0",
"vendor": "Acme",
"install": {
"mode": "paste_credential",
"scopes": ["project"],
"fields": [
{
"name": "intro",
"type": "instructions",
"label": "Create an API key on your Agora forum, then paste it here.",
"steps": [
"Sign in to your forum as an administrator and open Settings › API.",
"Create a key with the Boards and Messages permissions.",
"Copy the key, paste it below with your forum's address, then click \":button\" in :app."
],
"help": "The key acts as a bot user on your forum. Members will receive messages from it and be added to boards by it."
},
{
"name": "forum_url",
"type": "text",
"label": "Forum address",
"help": "The address members open, such as https://forum.example.com.",
"required": true,
"rules": ["string", "url", "starts_with:https://"]
},
{
"name": "api_key",
"type": "secret",
"label": "API key",
"help": "Keep it secret: anyone who has it can post as your forum's bot.",
"required": true,
"rules": ["string", "regex:/^agk_[A-Za-z0-9]{32}$/"]
}
],
"settings_fields": []
},
"resource_kinds": [
{
"kind": "board",
"label": "Board",
"portal_label": "Private board",
"icon": "rectangle-stack",
"grant_mode": "membership",
"supports_early_admission_hold": false,
"mirrorable": false
}
],
"capabilities": ["messaging", "access_control", "portal_login", "recovery_probes"],
"messaging": {
"max_length": 10000,
"buttons_per_row": 1,
"max_buttons": 3,
"callback_data_bytes": 64,
"supports_underline": true,
"supports_spoiler": false,
"supports_files": false
},
"pacing": {
"min_interval_microseconds": 1000000,
"burst": 5,
"per_recipient_interval_microseconds": 2000000
},
"management_commands": [],
"relay_modes": [],
"recovery": { "probes": true },
"listing": {
"category": "community",
"tagline": "Sell access to private boards on your Agora forum, joined and left automatically.",
"overview": "Connect your Agora forum with an API key and Subscriby runs your membership on it: members buy on the portal, are added to your private boards the moment they pay, and are removed when their access ends. Every confirmation and reminder arrives as a private message from your forum's bot user.\n\nMembers sign in to the portal with their forum account, and Subscriby's health checks tell you when the forum, a board or the bot user stops answering.",
"screenshots": [],
"links": {
"documentation": "https://docs.acme.test/subscriby-agora",
"support": "mailto:support@acme.test",
"privacy": "https://agora.example/privacy",
"terms": "https://agora.example/terms",
"homepage": "https://agora.example"
},
"added_at": "2026-10-01",
"changelog_url": "https://github.com/acme/subscriby-connector-agora/blob/main/CHANGELOG.md",
"sign_in_required": false,
"portal_cta": { "label": "Open the forum" },
"marketing": {
"audience": "forum communities",
"place": "board",
"places": "private boards",
"installation": "forum bot",
"identity": "forum account"
}
}
}
```
## The decisions
**Identity.** `version` starts at `0.1.0` because nothing works yet; it reaches `1.0.0` when the declared capabilities do. `sdk` is `^1.0`. `vendor` is our name; the Official badge is not ours to claim.
**`install`.** Agora hands out API keys, so the mode is `paste_credential`. Only the `project` scope: every creator runs their own forum, and there is no shared Agora for Subscriby to own. Three fields: an `instructions` walkthrough with `:app` and `:button` placeholders, a `text` field for the forum address with a `url` rule, and a `secret` for the key with a regex in Agora's key format. The kit's `settings.install_fields` wants a paste-a-credential connector to declare a secret, and we do. No settings fields yet.
**`resource_kinds`.** One kind, `board`. Its `grant_mode` is `membership`: Agora can add a user to a board directly, so there is nothing for a member to tap and no invite link to mint. Members will read "Private board" on the portal. No early admission (Agora has no waiting room) and no mirroring (a board is a conversation, not a feed).
**`capabilities`.** `messaging` because every core message reaches a member as a private message; `access_control` because we gate boards; `portal_login` because a member who bought through the forum has no password; `recovery_probes` because Agora lets us ask whether the forum, a board and an account still answer. Not `broadcasts`: at sixty requests a minute a broadcast to two thousand members would take half an hour, and we would rather the core told the creator "not on this connector" than queue it. Not `management_surface`: a forum has no conversation to render an admin wizard in. Each declared capability obliges a port; chapters 5 to 7 bind them.
**`messaging`.** Agora private messages are long (10 000 characters of BBCode) and have no buttons. `buttons_per_row: 1` and `max_buttons: 3` tell the core it may still attach up to three actions; chapter 5 turns them into links and reply keywords. `supports_files: false` makes the core refuse `sendFile()` itself.
**`pacing`.** Sixty requests a minute is one a second: `min_interval_microseconds: 1000000`, a burst of five, and two seconds between two messages to the same member. The core registers a `connector:agora` limiter at one send a second from this.
**`relay_modes`, `management_commands`, `recovery`.** Empty, empty, and probes only. The `recovery` block and the `recovery_probes` capability say the same thing in the two places the core reads them.
**`listing`.** Category `community`. The tagline is seventy-nine characters. The `privacy` and `terms` links are Agora's, not ours. `sign_in_required: false` because a creator pastes a key rather than signing in. The portal button reads "Open the forum" and opens `startLink()`. The marketing words are lower case and read mid-sentence: "your board", "a forum bot".
## The first test
The manifest is data, so the first test in the package loads it exactly as the application will:
```php
key)->toBe('agora')
->and($manifest->capabilities)->toContain(Capability::AccessControl)
->and($manifest->resourceKinds)->toHaveCount(1)
->and(mb_strlen($manifest->listing->tagline))->toBeLessThanOrEqual(80);
});
```
`ManifestFile::load()` throws `InvalidManifest` listing every problem with its dotted path, so a broken file fails this test with the same message the application would boot with.
Point your editor at the `$schema` URL. The eighty-character tagline and a few other limits are the schema's alone; the PHP loader checks structure, types, patterns and unknown keys.
---
# 3. Installation
Source: https://docs.subscriby.net/sdk/v1/tutorial/03-installation
The creator has pasted a forum address and a key. `InstallationLifecycle` turns that into a connected installation, and later tells the core whether it still works.
## The port
```php
fields['forum_url'];
$apiKey = (string) $request->fields['api_key'];
$me = $this->agora->client($forumUrl, $apiKey)->get('/me');
if ($this->agora->refused($me)) {
throw InstallationRefused::byPlatform('agora', $this->failures->classify($me));
}
$existing = Forum::query()->where('forum_url', $forumUrl)->whereNotNull('installation_id')->first();
if ($existing !== null && $existing->installation_id !== $request->existing?->id) {
throw InstallationRefused::credentialsHeld('agora');
}
return new InstallationDraft(state: ['bot_user_id' => (string) $me->json('data.id'), 'bot_name' => (string) $me->json('data.name')]);
}
public function complete(InstallationRequest $request, InstallationDraft $draft, CredentialBag $credentials): InstallationSummary
{
$forumUrl = (string) $credentials->get('forum_url');
$secret = bin2hex(random_bytes(24));
$webhook = $this->agora->client($forumUrl, (string) $credentials->get('api_key'))->post('/webhooks', [
'url' => url(config('connector-agora.webhook_path').'/'.$draft->state['bot_user_id']),
'secret' => $secret,
'events' => ['message.created', 'board.member_left', 'board.picked'],
]);
if ($this->agora->refused($webhook)) {
throw InstallationRefused::byPlatform('agora', $this->failures->classify($webhook));
}
$forum = Forum::query()->updateOrCreate(
['forum_url' => $forumUrl],
['bot_user_id' => $draft->state['bot_user_id'], 'webhook_id' => (string) $webhook->json('data.id')],
);
return new InstallationSummary(
externalId: $draft->state['bot_user_id'],
displayName: $draft->state['bot_name'],
handle: parse_url($forumUrl, PHP_URL_HOST) ?: null,
meta: ['webhook_secret' => $secret],
storageRef: (string) $forum->id,
);
}
public function verify(InstallationRef $installation, CredentialBag $credentials): InstallationHealth
{
$me = $this->agora->for($installation, $credentials)->get('/me');
if ($me->status() === 401) {
return new InstallationHealth(InstallationState::Revoked, 'key_revoked', 'The API key was revoked on the forum.', creatorActionable: true);
}
if ($this->agora->refused($me)) {
$failure = $this->failures->classify($me);
return new InstallationHealth(InstallationState::Degraded, 'forum_unreachable', $failure->detail, creatorActionable: false, failureKind: $failure->kind);
}
return InstallationHealth::healthy();
}
public function disconnect(InstallationRef $installation, CredentialBag $credentials): void
{
$forum = Forum::query()->find($installation->storageRef);
if ($forum?->webhook_id !== null) {
$this->agora->for($installation, $credentials)->delete('/webhooks/'.$forum->webhook_id);
$forum->forceFill(['webhook_id' => null])->save();
}
}
public function describe(InstallationRef $installation, CredentialBag $credentials): InstallationSummary
{
$me = $this->agora->for($installation, $credentials)->get('/me');
return new InstallationSummary(
externalId: (string) $me->json('data.id', $installation->externalId),
displayName: (string) $me->json('data.name', 'Agora bot'),
handle: $installation->handle,
storageRef: $installation->storageRef,
);
}
public function startLink(InstallationRef $installation, ?string $payload = null): ?string
{
$forum = Forum::query()->find($installation->storageRef);
if ($forum === null) {
return null;
}
return rtrim($forum->forum_url, '/').'/messages/new?to='.$installation->externalId.($payload === null ? '' : '&draft='.rawurlencode($payload));
}
public function publicUrl(InstallationRef $installation): ?string
{
return Forum::query()->find($installation->storageRef)?->forum_url;
}
public function platformInstallation(): ?InstallationRef
{
return null;
}
}
```
## Reading it
**`begin()`** validates before anything is stored. `/me` proves the key and tells us the bot user's id and name; a refusal becomes `InstallationRefused::byPlatform()` with the classified failure, and the core shows its reason on the form. A forum already connected to another installation is `credentialsHeld()`, unless the creator is reconnecting the same installation (`$request->existing`). What we learnt is parked in the draft's `state` for `complete()`; nothing goes into our table yet, because the core has not decided to store anything.
**`complete()`** registers the webhook with a secret we mint, keeps our forum row, and answers the summary. Two things matter here:
- `externalId` is the bot user's id, Agora's stable identity for the key; never the key, never the address.
- The webhook secret goes into the summary's `meta`. The core merges `meta` into the bag it stores (the forum address, the API key and now the secret), encrypted, and hands the merged bag to every port; chapter 4's `authenticate()` reads it back through `Core\Installations::credentials()`. The connector keeps no secret of its own, which is exactly what review reads for.
**`verify()`** distinguishes a revoked key (`Revoked`, creator-actionable: the creator has to make a new one) from a forum that did not answer (`Degraded`, not the creator's fault). Chapter 7's recovery probe reuses this call so the two verdicts never disagree.
**`disconnect()`** is best effort: the key may already be dead, so a refusal is not thrown. The core wipes the credentials and marks the installation `Disconnected` regardless; our row stays so a reconnect names the same forum.
**`startLink()`** is what a forum has instead of a deep link: a "new message to the bot" page with the payload as the draft. The portal button, plan links and handshake links all go through it. `publicUrl()` is the forum itself. `platformInstallation()` is null: no `platform` scope.
## Test it
```php
it('connects a forum from a valid key and registers the webhook', function (): void {
Http::fake([
'forum.example.com/api/me' => Http::response(['ok' => true, 'data' => ['id' => 'u_bot', 'name' => 'Subscriby Bot']]),
'forum.example.com/api/webhooks' => Http::response(['ok' => true, 'data' => ['id' => 'wh_1']]),
]);
$request = new InstallationRequest(InstallationScope::Project, new CreatorRef('c1'), new ProjectRef('p1'), [
'forum_url' => 'https://forum.example.com',
'api_key' => 'agk_'.str_repeat('a', 32),
]);
$draft = $lifecycle->begin($request);
$summary = $lifecycle->complete($request, $draft, new CredentialBag(['forum_url' => 'https://forum.example.com', 'api_key' => 'agk_'.str_repeat('a', 32)]));
expect($summary->externalId)->toBe('u_bot')
->and($summary->meta['webhook_secret'])->toHaveLength(48)
->and(Forum::query()->where('forum_url', 'https://forum.example.com')->value('webhook_id'))->toBe('wh_1');
Http::assertSent(fn (Request $sent): bool => str_ends_with($sent->url(), '/api/webhooks') && $sent['events'] === ['message.created', 'board.member_left', 'board.picked']);
});
it('refuses a key the forum rejects', function (): void {
Http::fake(['forum.example.com/api/me' => Http::response(['ok' => false, 'error' => 'invalid_key'], 401)]);
expect(fn () => $lifecycle->begin($request))->toThrow(InstallationRefused::class);
});
```
Fake each call the port makes, one answer at a time; an unexpected call should fail the test.
The core records the installation row from the summary (`Core\Installations::record()` under the hood), stores the `CredentialBag` merged with the summary's `meta` encrypted on it, and shows the creator "Connected to Subscriby Bot at forum.example.com". Your port never writes the core's row.
---
# 4. Webhooks and identities
Source: https://docs.subscriby.net/sdk/v1/tutorial/04-inbound
Agora posts one event per webhook call, signed with the secret we minted at install. The core's inbound gate authenticates and deduplicates through our `InboundGateway`; our own controller then handles what is new.
## The gateway
```php
installationFor($request);
$secret = $installation === null ? null : $this->secretFor($installation);
$signature = (string) $request->header('X-Agora-Signature', '');
if ($secret === null || ! str_starts_with($signature, 'sha256=')) {
return false;
}
return hash_equals('sha256='.hash_hmac('sha256', $request->getContent(), $secret), $signature);
}
public function decode(Request $request): iterable
{
$event = $request->json()->all();
$installation = $this->installationFor($request);
if ($installation === null || ! isset($event['id'], $event['type'])) {
return [];
}
return [new InboundEnvelope(
connector: 'agora',
installation: $installation,
idempotencyKey: $installation->externalId.':'.$event['id'],
kind: (string) $event['type'],
payload: $event,
receivedAt: new DateTimeImmutable,
)];
}
public function immediateResponse(Request $request): ?Response
{
return null;
}
public function shouldDefer(InboundEnvelope $envelope): bool
{
return $envelope->kind !== 'board.picked';
}
private function installationFor(Request $request): ?\Subscriby\Connector\Data\InstallationRef
{
return $this->installations->findByExternalId('agora', (string) $request->route('botUser'));
}
private function secretFor(\Subscriby\Connector\Data\InstallationRef $installation): ?string
{
return $this->installations->credentials($installation)->get('webhook_secret');
}
}
```
Three decisions:
- **Which installation is calling?** The webhook URL we registered ends in the bot user's id, so the route's `botUser` parameter names the installation and `Core\Installations::findByExternalId()` finds it. An unknown id authenticates false, and the gate answers 403.
- **The idempotency key** is the bot user's id and Agora's event id together, because two forums could reuse an event id and the key must be unique per connector.
- **`decode()` is side-effect free** and returns an empty list for a body it does not recognise, which is what the kit's `inbound.tolerates_empty_request` sends.
The secret comes from the installation's stored bag through `Core\Installations::credentials()`: chapter 3 put it in the summary's `meta`, the core merged it into the credentials it stored, and the gate can read it before any port is called. `shouldDefer()` is honest about what should run in the request (a picked board, which the creator is waiting for) and what could wait; the core does not read it yet.
## The identity resolver
```php
payload['data']['actor'] ?? null;
if (! is_array($actor) || ! isset($actor['id'])) {
return null;
}
return new IdentitySummary((string) $actor['id'], (string) ($actor['name'] ?? 'A forum member'), $actor['username'] ?? null, $actor['avatar'] ?? null);
}
public function describe(InstallationRef $installation, CredentialBag $credentials, string $externalId): IdentitySummary
{
$user = $this->agora->for($installation, $credentials)->get('/users/'.$externalId)->json('data', []);
return new IdentitySummary($externalId, (string) ($user['name'] ?? 'A forum member'), $user['username'] ?? null, $user['avatar'] ?? null);
}
public function adopt(InstallationRef $installation, IdentitySummary $identity): IdentityRecord
{
return new IdentityRecord(
connector: 'agora',
externalId: $identity->externalId,
installationId: $installation->id,
displayName: $identity->displayName,
username: $identity->username,
avatarUrl: $identity->avatarUrl,
storageRef: null,
);
}
public function deliveryTarget(InstallationRef $installation, CredentialBag $credentials, IdentityRef $identity): ?Recipient
{
return $identity->connector === 'agora' && $installation->connector === 'agora' ? new Recipient($identity) : null;
}
}
```
The one decision that matters for the life of the connector is `installationId`. An Agora user id is per forum (two forums each have a user `17`), so the record names the installation and the core files the account per forum. Telegram's user id is global, so its records carry null. Get this right now; it is the natural key of the identity row.
`adopt()` keeps no row of ours (`storageRef: null`): everything we need to message an account is its id and the forum's key, both of which the core hands us per call.
`deliveryTarget()` is the core asking, before it sends, whether this forum can reach the account. Agora lets the bot message any member of its forum, so every account of ours is reachable and the answer is a `Recipient`; a platform where people can close their inbox would answer null for them, and the core would pass over the account for the member's next connected one, or email. Answer from what you hold, never by calling the platform: the core asks on every notice.
## The route and its controller
```php
// routes/inbound.php
use Acme\Connectors\Agora\Http\WebhookController;
use Illuminate\Support\Facades\Route;
Route::post(config('connector-agora.webhook_path').'/{botUser}', WebhookController::class)->name('connector-agora.webhook');
```
The SDK's provider wraps this file in `connector.inbound:agora`, so by the time the controller runs the call is authenticated, every event in it is recorded once, and a paused connector has already answered 503.
```php
gateway->decode($request) as $envelope) {
$this->dispatcher->dispatch($envelope);
}
return response()->noContent();
}
}
```
`Dispatcher` is ours: a `match` on `$envelope->kind` that hands `message.created` to the portal-login and reply handling of chapters 5 and 7, `board.member_left` to a reconcile nudge in chapter 6, and `board.picked` to the space catalogue. The core does not dispatch envelopes itself yet, so this small class is where a connector's handling starts.
## Test it through the gate
The gate is the core's, so the test posts through the real route on a Subscriby checkout:
```php
it('refuses an unsigned webhook and accepts a signed one once', function (): void {
$installation = connectAgoraForum(); // a helper from chapter 3's test
$body = json_encode(['id' => 'evt_1', 'type' => 'message.created', 'data' => ['actor' => ['id' => '17', 'name' => 'Ada']]]);
$signature = 'sha256='.hash_hmac('sha256', $body, webhookSecretOf($installation));
$this->postJson(route('connector-agora.webhook', ['botUser' => 'u_bot']), json_decode($body, true))
->assertForbidden();
$this->call('POST', route('connector-agora.webhook', ['botUser' => 'u_bot']), [], [], [], ['HTTP_X_AGORA_SIGNATURE' => $signature, 'CONTENT_TYPE' => 'application/json'], $body)
->assertNoContent();
$this->call('POST', route('connector-agora.webhook', ['botUser' => 'u_bot']), [], [], [], ['HTTP_X_AGORA_SIGNATURE' => $signature, 'CONTENT_TYPE' => 'application/json'], $body)
->assertNoContent(); // replayed: recorded once, controller ran once
});
```
Compute the HMAC over `$request->getContent()`, never over a re-encoded array: JSON re-serialisation changes key order and whitespace and the signature stops matching on the first forum that formats JSON differently.
---
# 5. Messages
Source: https://docs.subscriby.net/sdk/v1/tutorial/05-messages
Every confirmation, reminder and alert the core composes reaches a member as an Agora private message. Three ports make that work: the renderer turns the core's HTML into BBCode, the messenger sends it, the classifier reads what came back.
## The renderer
Agora formats with BBCode. The eight canonical tags map one to one:
```php
' => '[b]', '
' => '[/b]',
'' => '[i]', '' => '[/i]',
'' => '[u]', '' => '[/u]',
'' => '[s]', '' => '[/s]',
'' => '[code]', '' => '[/code]',
'' => '[code]', '
' => '[/code]',
'' => '[quote]', '
' => '[/quote]',
];
public function render(string $canonicalHtml): string
{
$rendered = preg_replace_callback(
'#(.*?)#s',
static fn (array $match): string => '[url='.$match[1].']'.$match[2].'[/url]',
$canonicalHtml,
) ?? $canonicalHtml;
return html_entity_decode(strtr($rendered, self::TAGS), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}
```
Plain text survives untouched (`strtr` finds nothing, `html_entity_decode` changes nothing), every word inside a tag survives, and entities the core encoded (`&`) become the characters BBCode expects. The kit's two `text.*` rules pass.
## The messenger
Agora private messages have no buttons, and the core may attach up to three actions (the manifest said so). A URL action becomes a BBCode link; a callback or command action becomes a **reply keyword** the member types back, which chapter 4's dispatcher maps to the stored action.
```php
agora->for($installation, $credentials)->post('/messages', [
'to' => $recipient->identity->externalId,
'body' => $this->body($message),
]);
if ($this->agora->refused($response)) {
return DeliveryResult::failed($this->failures->classify($response));
}
$messageId = (string) $response->json('data.id');
$this->rememberKeywords($installation, $recipient, $messageId, $message->actions);
return DeliveryResult::delivered($messageId);
}
public function edit(InstallationRef $installation, CredentialBag $credentials, Recipient $recipient, string $externalMessageId, Message $message): DeliveryResult
{
$response = $this->agora->for($installation, $credentials)->patch('/messages/'.$externalMessageId, ['body' => $this->body($message)]);
return $this->agora->refused($response) ? DeliveryResult::failed($this->failures->classify($response)) : DeliveryResult::delivered($externalMessageId);
}
public function delete(InstallationRef $installation, CredentialBag $credentials, Recipient $recipient, string $externalMessageId): DeliveryResult
{
$response = $this->agora->for($installation, $credentials)->delete('/messages/'.$externalMessageId);
return $this->agora->refused($response) ? DeliveryResult::failed($this->failures->classify($response)) : DeliveryResult::delivered($externalMessageId);
}
public function sendFile(InstallationRef $installation, CredentialBag $credentials, Recipient $recipient, string $url, ?string $caption = null): DeliveryResult
{
return DeliveryResult::failed(new DeliveryFailure(DeliveryFailureKind::Configuration, 'Agora private messages cannot carry files.'));
}
private function body(Message $message): string
{
$lines = [$this->renderer->render($message->body)];
$keyword = 0;
foreach ($message->actions as $action) {
$lines[] = match ($action->kind) {
MessageActionKind::Url => '[url='.$action->value.']'.$action->label.'[/url]',
MessageActionKind::Copy => $action->label.': [code]'.$action->value.'[/code]',
MessageActionKind::Callback, MessageActionKind::Command => 'Reply [b]'.(++$keyword).'[/b] to '.lcfirst($action->label).'.',
};
}
return implode("\n\n", $lines);
}
/**
* @param list $actions
*/
private function rememberKeywords(InstallationRef $installation, Recipient $recipient, string $messageId, array $actions): void
{
$keyword = 0;
foreach ($actions as $action) {
if ($action->kind === MessageActionKind::Callback || $action->kind === MessageActionKind::Command) {
Prompt::query()->create([
'installation_id' => $installation->id,
'identity_external_id' => $recipient->identity->externalId,
'message_id' => $messageId,
'keyword' => (string) ++$keyword,
'kind' => $action->kind->value,
'value' => $action->value,
'params' => $action->params,
]);
}
}
}
}
```
`agora_prompts` is a second table of ours: the keyword a member may reply with, what it stood for, and which message it belonged to. When `message.created` arrives with a body of `1` from a member who has an open prompt, the dispatcher looks the prompt up and acts as if the button had been tapped: a `Command` prompt names a `ManagementCommand` or `MemberCommand` with its params, a `Callback` prompt carries the core's data verbatim. That is the whole trick of a platform without buttons.
Note what the messenger does *not* do: it never checks the message's length (the core did, against the manifest), never decides who to write to (the core resolved the recipient), never retries (the core reads the failure kind), and always builds its failure through the classifier. `sendFile()` is still implemented, because the port requires it, but the core never calls it while the manifest says `supports_files: false`.
## The classifier
Agora refuses in two shapes: an HTTP error, or `200 OK` with `"ok": false` and an `error` code. Both go through one table:
```php
DeliveryFailureKind::Configuration,
'key_revoked' => DeliveryFailureKind::Configuration,
'forbidden' => DeliveryFailureKind::NotPermitted,
'not_a_moderator' => DeliveryFailureKind::NotPermitted,
'board_not_found' => DeliveryFailureKind::TargetMissing,
'user_not_found' => DeliveryFailureKind::TargetMissing,
'message_not_found' => DeliveryFailureKind::TargetMissing,
'messages_disabled' => DeliveryFailureKind::Unreachable,
'user_banned' => DeliveryFailureKind::Unreachable,
'rate_limited' => DeliveryFailureKind::RateLimited,
];
public function classify(mixed $responseOrThrowable): DeliveryFailure
{
if ($responseOrThrowable instanceof ConnectionException) {
return new DeliveryFailure(DeliveryFailureKind::Transient, $responseOrThrowable->getMessage());
}
if ($responseOrThrowable instanceof Throwable) {
return new DeliveryFailure(DeliveryFailureKind::Other, $responseOrThrowable->getMessage());
}
if ($responseOrThrowable instanceof Response) {
$code = (string) $responseOrThrowable->json('error', $responseOrThrowable->status() >= 500 ? 'server_error' : 'unknown');
$kind = self::CODES[$code] ?? ($responseOrThrowable->status() >= 500 ? DeliveryFailureKind::Transient : DeliveryFailureKind::Other);
return new DeliveryFailure(
$kind,
(string) $responseOrThrowable->json('message', $responseOrThrowable->reason()),
$kind === DeliveryFailureKind::RateLimited ? (int) $responseOrThrowable->header('Retry-After', '60') : null,
$code,
);
}
return new DeliveryFailure(DeliveryFailureKind::Other, is_scalar($responseOrThrowable) ? (string) $responseOrThrowable : 'An unknown refusal.');
}
}
```
The last `return` is what the kit's `failures.classifies_anything` needs: a string classifies to `Other` rather than throwing. A network exception is `Transient` and the core retries; a 5xx without a code is `Transient` too; a banned user or a member who switched private messages off is `Unreachable`, and the core stops writing to that identity; a revoked key is `Configuration`, and the core marks the installation for the creator.
## Test it
```php
it('renders the canonical subset to BBCode and leaves plain text alone', function (): void {
$renderer = new AgoraTextRenderer;
expect($renderer->render('Hello, world'))->toBe('Hello, world')
->and($renderer->render('Paid & open'))->toBe('[b]Paid[/b] & [url=https://x.test]open[/url]');
});
it('turns actions into a link and reply keywords and remembers the keywords', function (): void {
Http::fake(['forum.example.com/api/messages' => Http::response(['ok' => true, 'data' => ['id' => 'm_9']])]);
$result = $messenger->send($installation, $credentials, new Recipient($member), new Message('Welcome.', [
MessageAction::url('Open the board', 'https://forum.example.com/b/12'),
MessageAction::command('Refresh my access', MemberCommand::ReissueGrants),
]));
expect($result->delivered)->toBeTrue()->and($result->externalMessageId)->toBe('m_9');
Http::assertSent(fn (Request $sent): bool => str_contains($sent['body'], "[url=https://forum.example.com/b/12]Open the board[/url]\n\nReply [b]1[/b] to refresh my access."));
expect(Prompt::query()->where('message_id', 'm_9')->where('keyword', '1')->value('value'))->toBe('reissue_grants');
});
it('classifies the forum\'s refusals', function (): void {
$classifier = new AgoraFailureClassifier;
expect($classifier->classify(new Response(new Psr7Response(200, [], '{"ok":false,"error":"user_banned"}')))->kind)->toBe(DeliveryFailureKind::Unreachable)
->and($classifier->classify(new Response(new Psr7Response(429, ['Retry-After' => '30'], '{"error":"rate_limited"}')))->retryAfterSeconds)->toBe(30)
->and($classifier->classify('anything'))->kind->toBe(DeliveryFailureKind::Other);
});
```
Add `Messenger::class` to `AgoraConnector::register()` now: the manifest declares `messaging`, and the registry refuses the package at boot until the port is bound.
---
# 6. Boards and access
Source: https://docs.subscriby.net/sdk/v1/tutorial/06-boards
A board is the place we sell access to. Two ports: `SpaceCatalog` for how a creator picks a board and whether our bot can act in it, `AccessController` for adding and removing members.
## Picking a board
Agora lets the API list a forum's boards, so we do not need a chat picker like Telegram's: `requestLink()` sends the creator a private message with a link to a small page of ours where they pick from the boards the bot user moderates, and the page posts our own `board.picked` event back through the webhook route so it goes through the core's gate like everything else.
```php
purpose !== LinkPurpose::Resource) {
throw UnsupportedByConnector::facet('agora', 'linking a place for '.$request->purpose->value);
}
$parked = ParkedRequest::query()->updateOrCreate(
['installation_id' => $installation->id, 'creator_external_id' => $creator->externalId, 'purpose' => $request->purpose->value],
['kind' => $request->kind, 'subject_id' => $request->subjectId, 'subject_title' => $request->subjectTitle, 'token' => bin2hex(random_bytes(16))],
);
$this->messenger->send($installation, $credentials, new Recipient($creator), new Message(
__('Pick the board to sell access to for :project.', ['project' => e((string) $request->subjectTitle)]),
[MessageAction::url(__('Choose a board'), route('connector-agora.pick', ['token' => $parked->token]))],
));
}
public function withdrawLinkRequest(InstallationRef $installation, CredentialBag $credentials, IdentityRef $creator, LinkPurpose $purpose): void
{
ParkedRequest::query()->where('installation_id', $installation->id)->where('creator_external_id', $creator->externalId)->where('purpose', $purpose->value)->delete();
}
public function pendingLinkRequest(InstallationRef $installation, CredentialBag $credentials, IdentityRef $creator, LinkPurpose $purpose): ?string
{
return ParkedRequest::query()->where('installation_id', $installation->id)->where('creator_external_id', $creator->externalId)->where('purpose', $purpose->value)->value('subject_id');
}
public function linkInstructions(LinkPurpose $purpose): string
{
return __('Our bot sent you a private message on your forum with a link. Open it and pick the board.');
}
public function describe(InstallationRef $installation, CredentialBag $credentials, SpaceRef $space): SpaceSummary
{
$board = $this->agora->for($installation, $credentials)->get('/boards/'.$space->externalId)->json('data', []);
return new SpaceSummary($space->externalId, 'board', (string) ($board['title'] ?? 'Board '.$space->externalId));
}
public function diagnose(InstallationRef $installation, CredentialBag $credentials, SpaceRef $space): SpaceAccess
{
$response = $this->agora->for($installation, $credentials)->get('/boards/'.$space->externalId.'/members/'.$installation->externalId);
if ($response->status() === 404) {
$board = $this->agora->for($installation, $credentials)->get('/boards/'.$space->externalId);
return $board->status() === 404
? new SpaceAccess(false, SpaceAccess::GONE, __('The board no longer exists on the forum.'), creatorActionable: true)
: new SpaceAccess(false, SpaceAccess::NOT_MEMBER, __('The bot user is not a member of this board.'), creatorActionable: true);
}
if ($this->agora->refused($response)) {
return SpaceAccess::unknown($this->failures->classify($response)->detail);
}
return $response->json('data.role') === 'moderator'
? SpaceAccess::ready()
: new SpaceAccess(false, SpaceAccess::INSUFFICIENT_ROLE, __('Make the bot user a moderator of this board so it can add and remove members.'), creatorActionable: true);
}
}
```
`diagnose()` tells the four things apart the core acts on differently: a board that is gone, a board the bot is not in, a board where the bot is a plain member, and a board where it is a moderator. Each carries the sentence the creator will read and whether it is theirs to fix.
Only `LinkPurpose::Resource` is supported: Agora has no standby or replacement facets (no `recovery_resource_standby`), and no support relay. Throwing `UnsupportedByConnector` for the others is the contract.
## The picker page
`routes/web.php` gets one route behind the application's `web` middleware, so the creator's browser session is what authorises it:
```php
Route::get('connectors/agora/pick/{token}', [BoardPickerController::class, 'show'])->name('connector-agora.pick');
Route::post('connectors/agora/pick/{token}', [BoardPickerController::class, 'store']);
```
`show()` lists the boards the bot user moderates (`GET /boards?moderated=1`) for the parked request's forum; `store()` records the choice and sells it. The controller takes `Core\Resources` beside `Spaces` and `Installations`:
```php
public function store(Request $request, string $token): RedirectResponse
{
$parked = ParkedRequest::query()->where('token', $token)->firstOrFail();
$installation = $this->installations->find($parked->installation_id);
$board = $this->agora->for($installation, $this->credentialsFor($installation))->get('/boards/'.$request->string('board'))->json('data');
$space = $this->spaces->record(new SpaceRecord(
connector: 'agora',
installationId: $installation->id,
externalId: (string) $board['id'],
kind: 'board',
title: (string) $board['title'],
));
$this->resources->create(
new ProjectRef($parked->subject_id),
$space,
ResourceKind::for('agora', 'board'),
title: (string) $board['title'],
description: __('Access to the :board board while your membership is active.', ['board' => $board['title']]),
);
$parked->delete();
return redirect()->to($this->returnUrl($parked))->with('status', __('Board linked. Add it to a plan from the Resources page.'));
}
```
Two things the core does for us here. The write **acts for the creator**: the route sits behind the application's `web` middleware, so the signed-in creator is the actor, and `create()` is refused for anyone who may not add resources to that project, exactly as the dashboard's own button is. And it is **idempotent** on the project and the board: a creator who submits the form twice, or a browser that replays it, gets the one resource, not two.
`Resources::create()` writes the resource under the project with `connector: agora`, `kind: agora:board` and the space bound in the same step, sets it active, broadcasts it to the creator's open dashboard and emits `project.resource.linked` with the connector, the kind and the space in the payload. The creator adds the new resource to a plan from the Resources page; a connector never touches plans. Refusals arrive as `ResourceRefused` with a stable `reason` (`kind_outside_place`, `place_unknown`, `project_unknown`, `not_permitted`), so the controller can show a sentence for each.
## The access controller
Agora adds a user to a board directly, so a grant is a membership and the reference is our own `board:user` handle:
```php
agora->for($installation, $credentials)->post('/boards/'.$request->space->externalId.'/members', ['user_id' => $request->identity->externalId]);
if ($response->json('error') === 'already_member' || ! $this->agora->refused($response)) {
return GrantResult::granted($request->mode, 'board:'.$request->space->externalId.':'.$request->identity->externalId);
}
return GrantResult::failed($request->mode, $this->failures->classify($response));
}
public function revoke(InstallationRef $installation, CredentialBag $credentials, GrantRef $grant, SpaceRef $space, IdentityRef $identity): RevokeResult
{
$response = $this->agora->for($installation, $credentials)->delete('/boards/'.$space->externalId.'/members/'.$identity->externalId);
if ($response->status() === 404 || ! $this->agora->refused($response)) {
return RevokeResult::revoked();
}
return RevokeResult::failed($this->failures->classify($response));
}
public function revokeReference(InstallationRef $installation, CredentialBag $credentials, GrantRef $grant, SpaceRef $space): RevokeResult
{
return RevokeResult::revoked();
}
public function admit(InstallationRef $installation, CredentialBag $credentials, GrantRef $grant, SpaceRef $space, IdentityRef $identity): GrantResult
{
throw UnsupportedByConnector::facet('agora', 'early admission');
}
public function membership(InstallationRef $installation, CredentialBag $credentials, SpaceRef $space, IdentityRef $identity): Membership
{
$response = $this->agora->for($installation, $credentials)->get('/boards/'.$space->externalId.'/members/'.$identity->externalId);
if ($response->status() === 404) {
return new Membership(MembershipStatus::Left);
}
if ($this->agora->refused($response)) {
return new Membership(MembershipStatus::Unknown);
}
return new Membership(match ($response->json('data.role')) {
'owner' => MembershipStatus::Owner,
'moderator' => MembershipStatus::Administrator,
'banned' => MembershipStatus::Banned,
default => MembershipStatus::Member,
});
}
public function announce(InstallationRef $installation, CredentialBag $credentials, IdentityRef $holder, GrantAnnouncement $announcement): void
{
$this->messenger->send($installation, $credentials, new Recipient($holder), new Message(
__('You now have access to :count private board(s). Open the forum and they are waiting for you.', ['count' => count($announcement->grants)]),
));
}
public function reconcile(InstallationRef $installation, CredentialBag $credentials, iterable $grants): ReconcileReport
{
$checked = $reasserted = $revoked = 0;
$failures = [];
foreach ($grants as $snapshot) {
$checked++;
$inBoard = $this->membership($installation, $credentials, $snapshot->space, $snapshot->identity)->status === MembershipStatus::Member;
if ($snapshot->state === GrantState::Granted && ! $inBoard) {
$result = $this->grant($installation, $credentials, new GrantRequest($snapshot->space, $snapshot->identity, $snapshot->grant->mode, $snapshot->grant));
$result->granted ? $reasserted++ : $failures[] = $result->failure;
} elseif ($snapshot->state === GrantState::Revoked && $inBoard) {
$result = $this->revoke($installation, $credentials, $snapshot->grant, $snapshot->space, $snapshot->identity);
$result->revoked ? $revoked++ : $failures[] = $result->failure;
}
}
return new ReconcileReport($checked, $reasserted, $revoked, $failures);
}
}
```
## Reading it
- **`grant()` is idempotent** because Agora's `already_member` answer is treated as success. Granting twice is one membership.
- **`revoke()` is idempotent** because a 404 (already gone) is revoked. Revoking a stranger is not an error.
- **`revokeReference()` does nothing** and reports revoked: a membership carries no reference apart from itself, so when another grant keeps the holder in the board there is nothing to withdraw.
- **`admit()` throws** because the manifest declares no `early_admission_hold`; the core never calls it.
- **`announce()`** is one private message saying how many boards opened. Agora members see the boards appear in their sidebar, so the message is short.
- **`reconcile()`** asserts a state rather than replaying events: a live grant whose holder is not in the board is re-granted, a revoked grant whose holder is still in the board is revoked, everything else is left alone. The core runs it every fifteen minutes per resource.
When Agora sends `board.member_left` (a member left a private board on their own), chapter 4's dispatcher can queue a targeted reconcile for that board, so the ledger and the board agree within seconds rather than minutes.
## Test it
```php
it('grants a membership and treats already_member as granted', function (): void {
Http::fake(['forum.example.com/api/boards/12/members' => Http::sequence()
->push(['ok' => true])
->push(['ok' => false, 'error' => 'already_member'], 409)]);
$first = $access->grant($installation, $credentials, $request);
$second = $access->grant($installation, $credentials, $request);
expect($first->granted)->toBeTrue()->and($first->reference)->toBe('board:12:17')
->and($second->granted)->toBeTrue()->and($second->reference)->toBe($first->reference);
});
it('reports a stranger as revoked', function (): void {
Http::fake(['forum.example.com/api/boards/12/members/17' => Http::response(['ok' => false, 'error' => 'user_not_found'], 404)]);
expect($access->revoke($installation, $credentials, $grant, $space, $member)->revoked)->toBeTrue();
});
it('diagnoses a board the bot is not a moderator of as creator-actionable', function (): void {
Http::fake(['forum.example.com/api/boards/12/members/u_bot' => Http::response(['ok' => true, 'data' => ['role' => 'member']])]);
$access = $catalog->diagnose($installation, $credentials, $space);
expect($access->ready)->toBeFalse()->and($access->state)->toBe(SpaceAccess::INSUFFICIENT_ROLE)->and($access->creatorActionable)->toBeTrue();
});
```
Bind `AccessController::class` and `SpaceCatalog::class` in `AgoraConnector::register()`: the first because the manifest declares `access_control`, the second because the core asks for it only when it is bound.
---
# 7. Portal sign-in and probes
Source: https://docs.subscriby.net/sdk/v1/tutorial/07-portal-login
A member who bought on the portal with their forum account has no password. `PortalLoginMethod` gives the portal a "Continue with your forum account" button; the member proves who they are by sending our bot a code in a private message, and our webhook handler completes the core's handshake.
## The sign-in method
```php
token,
url: $this->lifecycle->startLink($installation, 'login '.$handshake->token),
instructions: __('Send the message "login :token" to our bot on the forum, then come back here.', ['token' => $handshake->token]),
);
}
}
```
`begin()` reuses `startLink()`: the member lands on the forum's "new message to the bot" page with `login ` pre-filled, sends it, and the webhook arrives. The `instructions` cover a member who opens the portal on a device where the forum is not signed in.
## Completing the handshake
In chapter 4's dispatcher, `message.created` from a member is checked for a login code before anything else:
```php
private function onMessage(InboundEnvelope $envelope): void
{
$text = trim((string) ($envelope->payload['data']['body'] ?? ''));
$actor = $this->identities->resolveInbound($envelope);
if ($actor === null) {
return;
}
if (preg_match('/^login\s+(\S+)$/i', $text, $match) === 1 && $this->core->isHandshakeToken($match[1])) {
try {
$completion = $this->core->completeHandshake($match[1], $this->identities->adopt($envelope->installation, $actor), $envelope->installation);
$this->reply($envelope, __('You are signed in as :name. Head back to the portal.', ['name' => $completion->subjectName]));
} catch (HandshakeRefused $refused) {
$this->reply($envelope, __('That code has expired or was already used. Start again from the portal.'));
}
return;
}
$this->keywords->handle($envelope, $actor, $text); // chapter 5's reply keywords
}
```
`isHandshakeToken()` first, so a member who happens to type "login something" in another context is not refused with a handshake error. `completeHandshake()` receives the account as `adopt()` describes it and the installation that heard it, so a handshake opened for project A cannot be completed through project B's forum. The core writes the member link and answers the name to greet them with; the portal, which has been polling, signs them in.
## The probes
Agora lets us ask about the forum, a board and an account, so the manifest declares `recovery_probes` and nothing else in `recovery`:
```php
new HealthReasonText(__('API key revoked'), __('The forum no longer accepts the API key. Create a new one under Settings › API and reconnect.')),
default => null,
};
}
public function readinessChecks(InstallationRef $installation, ProjectRef $project): array
{
$coverage = $this->recovery->coverage($project, 'agora');
return [new ReadinessItem(
key: 'boards-moderated',
label: __('The bot moderates every board it gates'),
description: __('A board the bot cannot moderate cannot admit or remove members.'),
icon: 'shield-check',
satisfied: $coverage->spaces === [] || array_all($coverage->spaces, fn ($space): bool => $space->standbyHealthy || true),
)];
}
public function probeInstallation(InstallationRef $installation, CredentialBag $credentials): InstallationHealth
{
return $this->lifecycle->verify($installation, $credentials);
}
public function probeSpace(InstallationRef $installation, CredentialBag $credentials, SpaceRef $space): SpaceAccess
{
return $this->catalog->diagnose($installation, $credentials, $space);
}
public function probeIdentity(InstallationRef $installation, CredentialBag $credentials, IdentityRef $identity): ?DeliveryFailure
{
$response = $this->agora->for($installation, $credentials)->get('/users/'.$identity->externalId);
if ($response->status() === 404) {
return new DeliveryFailure(DeliveryFailureKind::TargetMissing, __('The forum account no longer exists.'), null, 'user_not_found');
}
if ($this->agora->refused($response)) {
return $this->failures->classify($response);
}
return null;
}
public function registerStandbyInstallation(ProjectRef $project, CredentialBag $credentials): InstallationSummary
{
throw UnsupportedByConnector::facet('agora', 'standby installations');
}
public function removeStandbyInstallation(InstallationRef $standby, CredentialBag $credentials): void
{
throw UnsupportedByConnector::facet('agora', 'standby installations');
}
public function failOver(InstallationRef $installation, CredentialBag $credentials, SpaceRef $from, SpaceRef $to, iterable $holders): FailOverReport
{
throw UnsupportedByConnector::facet('agora', 'failing over a board');
}
public function mirror(InstallationRef $installation, CredentialBag $credentials, SpaceRef $from, SpaceRef $to, string $externalPostId): \Subscriby\Connector\Data\DeliveryResult
{
throw UnsupportedByConnector::facet('agora', 'mirroring a board');
}
public function beginIdentityHandshake(InstallationRef $platform, CreatorRef $creator, HandshakeRef $handshake): PortalLoginStart
{
throw UnsupportedByConnector::facet('agora', 'relinking a creator account');
}
}
```
The probes reuse the calls we already have: `verify()` for the forum, `diagnose()` for a board, so a health badge and a Verify button can never disagree. `probeIdentity()` gives the three answers the core acts on: null while the account answers, `TargetMissing` when it is gone (the incident), any other kind when the forum could not be asked. Every denied facet throws `UnsupportedByConnector`, and the core never calls one the manifest's `recovery` block denies.
The readiness item is deliberately modest: Agora has no standby to prepare, so the connector's one line of the checklist is about moderation rights, and the real signal comes from `probeSpace()`'s `INSUFFICIENT_ROLE`. A connector with standby facets would read `coverage()`'s `hasStandby` and `standbyHealthy` per space, as the [Recovery](/sdk/v1/core-api/recovery) page shows.
Bind `PortalLoginMethod::class` and `RecoverySupport::class`; the manifest's `portal_login` and `recovery_probes` demand them.
Whenever a probe and a user-facing action ask the platform the same question, route them through one method. A creator who sees "healthy" on the Verify button and "degraded" on the health badge stops trusting both.
---
# 8. Slots and translations
Source: https://docs.subscriby.net/sdk/v1/tutorial/08-slots-and-translations
The core renders Agora's install form from the manifest. A creator pasting an API key still appreciates seeing where the key lives in Agora's settings, and that is what the `install` slot is for.
## The contribution
```php
{{ __('Where to find your API key') }}
{{ __('In Agora, open Settings, then API, and create a key with the Boards and Messages permissions. The key starts with agk_.') }}
```
```blade
{{-- resources/views/slots/install-placeholder.blade.php --}}
```
The placeholder mirrors the loaded layout box for box, so the Connect dialog does not jump when the contribution arrives. The view reads nothing but translations and a published asset; it never touches a model or the application's classes. Blade views in the package are published under the `connector-agora::` namespace by the SDK's provider.
## Every string, ten times
Every `__()` in the connector is a key in `lang/english.json`, and the same key exists in the other nine files with a real translation. Collect them from the ports and views of the previous chapters:
```json
{
"Create an API key on your Agora forum, then paste it here.": "Create an API key on your Agora forum, then paste it here.",
"Forum address": "Forum address",
"API key": "API key",
"Board": "Board",
"Private board": "Private board",
"Open the forum": "Open the forum",
"Pick the board to sell access to for :project.": "Pick the board to sell access to for :project.",
"Choose a board": "Choose a board",
"Our bot sent you a private message on your forum with a link. Open it and pick the board.": "Our bot sent you a private message on your forum with a link. Open it and pick the board.",
"The board no longer exists on the forum.": "The board no longer exists on the forum.",
"The bot user is not a member of this board.": "The bot user is not a member of this board.",
"Make the bot user a moderator of this board so it can add and remove members.": "Make the bot user a moderator of this board so it can add and remove members.",
"You now have access to :count private board(s). Open the forum and they are waiting for you.": "You now have access to :count private board(s). Open the forum and they are waiting for you.",
"Continue with your forum account": "Continue with your forum account",
"Send the message \"login :token\" to our bot on the forum, then come back here.": "Send the message \"login :token\" to our bot on the forum, then come back here.",
"You are signed in as :name. Head back to the portal.": "You are signed in as :name. Head back to the portal.",
"That code has expired or was already used. Start again from the portal.": "That code has expired or was already used. Start again from the portal.",
"forum bot": "forum bot",
"board": "board",
"forum account": "forum account",
"board membership": "board membership",
"forum bots": "forum bots",
"boards": "boards",
"API key revoked": "API key revoked",
"The forum no longer accepts the API key. Create a new one under Settings › API and reconnect.": "The forum no longer accepts the API key. Create a new one under Settings › API and reconnect.",
"The bot moderates every board it gates": "The bot moderates every board it gates",
"A board the bot cannot moderate cannot admit or remove members.": "A board the bot cannot moderate cannot admit or remove members.",
"The forum account no longer exists.": "The forum account no longer exists.",
"Where to find your API key": "Where to find your API key",
"In Agora, open Settings, then API, and create a key with the Boards and Messages permissions. The key starts with agk_.": "In Agora, open Settings, then API, and create a key with the Boards and Messages permissions. The key starts with agk_.",
"The API settings page of an Agora forum": "The API settings page of an Agora forum"
}
```
The manifest's labels, help texts and steps are keys too, and so are the resource kind's `label` and `portal_label` and the listing's `tagline`, so they go in the same files. Ten files: `english`, `spanish`, `french`, `german`, `italian`, `portuguese`, `turkish`, `hindi`, `bengali`, `sinhalese`. The application's parity tests scan package language directories and fail on a key present in one file and missing in another.
Placeholders (`:project`, `:count`, `:token`, `:name`) and the canonical tags (``) stay as they are in every language; translators translate the words around them.
A small test that walks `src/` and `resources/` for `__('…')` calls and asserts each key is present in all ten files catches the missing translation on the developer's machine, which is where Subscriby's own parity test would otherwise catch it during review.
---
# 9. Tests and the kit
Source: https://docs.subscriby.net/sdk/v1/tutorial/09-tests
The previous chapters each ended with a test. This chapter arranges them into a suite and adds what only the whole package can prove.
## Three checks with no application
```php
it('declares a manifest the SDK accepts', function (): void {
$manifest = ManifestFile::load(__DIR__.'/../connector.json');
expect($manifest->key)->toBe('agora');
});
it('creates only its own tables', function (): void {
expect(MigrationRules::violations('agora', __DIR__.'/../database/migrations'))->toBe([]);
});
it('builds every button within the forum\'s limits', function (): void {
$limits = ManifestFile::load(__DIR__.'/../connector.json')->messaging;
MessageAction::url('Open the board', 'https://forum.example.com/b/12')->assertWithin($limits);
MessageAction::command('Refresh my access', MemberCommand::ReissueGrants)->assertWithin($limits);
});
```
These run in the package alone, on every commit, and catch most kit failures before review.
## The ports against a faked forum
Every port test from chapters 3 to 7 fakes Agora's HTTP API with Laravel's `Http::fake()`, one answer per call. Two habits from those chapters generalise:
- **Fake refusals as the platform sends them**, in both shapes (`4xx` with a body, `200` with `"ok": false`), and assert the classified kind rather than the HTTP status.
- **Assert what was sent**, not only what came back: the webhook events registered, the BBCode body, the member id in the board call.
## Characterisation snapshots
Agora's wire format is the connector's contract with the forum. Pin it:
```php
it('sends a sale notice as the same BBCode every time', function (): void {
Http::fake(['forum.example.com/api/messages' => Http::response(['ok' => true, 'data' => ['id' => 'm_1']])]);
$messenger->send($installation, $credentials, new Recipient($member), saleNoticeMessage());
$sent = Http::recorded()->first()[0];
expect($sent['body'])->toMatchSnapshot();
});
```
A snapshot that has to change is a behaviour change with its own commit and its own reason, never a side effect. Subscriby pins the Telegram connector's transcripts the same way, a hundred snapshots deep.
## The core's paths through the fakes
Register Agora beside the SDK's fake connector in a Subscriby checkout and assert the same scenario on both, so a difference is either a manifest difference or a platform assumption left in the core:
```php
it('grants a board when a member pays and revokes it when the subscription ends', function (): void {
Http::fake([
'forum.example.com/api/boards/12/members' => Http::response(['ok' => true]),
'forum.example.com/api/boards/12/members/17' => Http::response(['ok' => true]),
'forum.example.com/api/messages' => Http::response(['ok' => true, 'data' => ['id' => 'm_1']]),
]);
$member = memberWithAgoraIdentity($project, externalId: '17');
$subscription = settlePurchase($member, planGatingBoard(12));
Http::assertSent(fn (Request $sent): bool => $sent->method() === 'POST' && str_ends_with($sent->url(), '/boards/12/members') && $sent['user_id'] === '17');
cancelSubscription($subscription);
Http::assertSent(fn (Request $sent): bool => $sent->method() === 'DELETE' && str_ends_with($sent->url(), '/boards/12/members/17'));
});
```
The helpers (`memberWithAgoraIdentity`, `settlePurchase`, `planGatingBoard`, `cancelSubscription`) drive the core's own actions through the application's factories; the Telegram connector's tests are the model to copy. The scenario a member with **no** forum identity produces is worth a test of its own: the grant waits as `pending_identity` and materialises when the member signs in through chapter 7's handshake.
## Running the kit
Inside the Subscriby checkout, with the package under `packages/subscriby-connector-agora/`:
```php
it('passes the conformance kit', function (): void {
$report = (new ConformanceSuite(app(ConnectorRegistry::class)))
->run('agora', packagePath: dirname(__DIR__));
expect($report->passed())->toBeTrue($report->summary());
});
```
For Agora the report has nineteen checks: the fourteen every connector gets, `access.declares_kinds` (we bind `AccessController`), `recovery.vocabulary_and_readiness` (we bind `RecoverySupport`), `portal_login.button` (we bind `PortalLoginMethod`), and the two on-disk rules. A green summary reads `agora passes the conformance kit (19 checks).`; a red one names each rule and its detail, and every one of them has a page under [Conformance](/sdk/v1/conformance).
Outside a Subscriby checkout, the same test runs with `new TestRegistry` in place of `app(ConnectorRegistry::class)`: register the manifest (`ManifestFile::load(dirname(__DIR__).'/connector.json')`) and your `Connector` class into it first, as [Testing a connector](/sdk/v1/building/testing#running-the-kit) shows. The registry refuses the connector before a rule runs if the manifest and the ports disagree, which is the refusal Subscriby's own boot would give.
A faked forum that answers `{"ok": true}` to everything proves that your connector works on a forum that never refuses. Give every test at least one refusal in the shape Agora really sends.
---
# 10. Ship it
Source: https://docs.subscriby.net/sdk/v1/tutorial/10-ship
## Read the listing as a creator
Open `connector.json` and read the `listing` block as someone who has never seen the connector:
- The **tagline** says what it does in one line: "Sell access to private boards on your Agora forum, joined and left automatically." Seventy-nine characters.
- The **overview** says what a creator does (connect with a key), what a member experiences (buys on the portal, is added to boards, gets private messages) and what the connector watches (health of the forum, the boards and the bot). It does not repeat the capability list; the marketplace generates that from the manifest.
- The **links**: our documentation for creators, our support address, and Agora's privacy and terms.
- The **marketing words** read mid-sentence: "for forum communities", "your board", "a forum bot", "your forum account".
## The checklist, item by item
**The kit is green**, including `manifest.file_is_the_source` and `migrations.own_tables_only`: chapter 9's test, run with the package path.
**Nothing from the application's namespace.** `grep -r "App\\\\" src/` finds nothing; every core read and write went through `Subscriby\Connector\Core\*`.
**Credentials never reach a log.** The API key and the webhook signing secret both live in the core's encrypted `CredentialBag` (the secret arrived there through the summary's `meta`); `agora_forums` holds neither; no `Log::` call formats a bag or a header.
**Every slot has a placeholder** that mirrors its layout (chapter 8), and every string is in all ten `lang/*.json` files.
**The listing tells the truth.** It promises boards, messages, portal sign-in and health checks, which is what the bound ports do. It does not promise broadcasts, a support relay or an admin surface, which the manifest does not declare.
**The platform's terms allow it.** Agora's terms permit API keys to act on a forum's behalf for its administrator; a bot adding members to boards the administrator owns is within them.
**Idempotency and pacing.** `already_member` is a grant, a 404 is a revoke, `pacing` is one request a second against Agora's sixty a minute, and `rate_limited` classifies to `RateLimited` with Agora's `Retry-After`.
**No native payments.** The manifest declares none, so the official-only rule does not apply.
## Version it
The ports the manifest declares work on the platform end to end, so `version` moves from `0.1.0` to `1.0.0`, the `CHANGELOG.md` gets its first entry, and the repository gets a tag. `added_at` stays `2026-10-01`, the day of first publication.
## Submit
Send the repository URL, the tag and the kit's summary line to [support@subscriby.net](mailto:support@subscriby.net), or through **Request a connector** on the [marketplace](https://www.subscriby.net/connectors). Subscriby reads the package against the checklist, runs the kit in its own suite, and installs it. The connector then appears in the marketplace's **Under Development** lane until Subscriby switches `agora` on, at which point creators can install it and its card reads **Available Now** with the **Community** badge and, for sixty days, **New**.
## After launch
- **A new Agora API version** is an additive change: new fields, a new event type, a manifest `version` bump.
- **Agora members want to ask questions**: a forum member's private message to the bot is filed into the Subscriby inbox through [`Core\Support::ingest()`](/sdk/v1/core-api/support) and answered from the dashboard, the connector binds `SupportRelay` to carry the answers back, the manifest gains `support_relay`, and its `version` bumps again.
- **Agora adds a rate limit header** you did not map: one row in the classifier's table and a snapshot update, with a commit message that says so.
- **Something breaks on the forum's side**: ask Subscriby to pause the connector; inbound answers 503 and sends queue until you say resume.
A package with eleven ports, two tables of its own that hold no secret, one web route and one inbound route, ten language files, a manifest that says exactly what it can do, and a test suite that fails before review would. Every other connector is the same shape; only the platform's answers differ.
---
# From Zero to a Forum Connector
Source: https://docs.subscriby.net/sdk/v1/tutorial
This tutorial builds a connector from an empty directory to a package that passes the kit, for a platform that does not exist: **Agora**, a self-hosted forum. Every creator runs their own Agora at their own domain, and Agora has private boards only members can read. A connector lets a creator sell access to those boards through Subscriby: a member pays on the portal, Agora adds them to the board, and Subscriby removes them when their access ends.
Agora is imaginary so the tutorial can show every kind of decision a real platform forces without teaching you a real platform's API. Its API is small and typical:
| Agora gives us | We will use it for |
| -------------------------------------------------------------------------- | ------------------------------------------------------------- |
| An **API key** per forum, created by the forum's administrator | Installing: the creator pastes it. |
| `GET /api/me` | Verifying the key and learning the bot user's name. |
| `POST /api/webhooks` with a secret, `DELETE /api/webhooks/{id}` | Receiving events; disconnecting. |
| Webhooks signed `X-Agora-Signature: sha256=…` over the raw body | Authenticating inbound calls. |
| `GET /api/boards/{id}`, `POST`/`DELETE /api/boards/{id}/members/{user}` | Describing a board, adding and removing members. |
| `GET /api/boards/{id}/members/{user}` | A member's standing in a board. |
| `POST /api/messages` (private messages, BBCode) | Every message the core sends a member. |
| `GET /api/users/{id}` | What the forum knows about an account. |
| 60 requests a minute per API key | Pacing. |
Two properties of Agora shape the connector: **a board membership is the grant** (Agora can add a user to a board directly, so there are no invite links), and **private messages have no buttons** (so the core's message actions have to become something a forum can show).
## What we build
```text
subscriby-connector-agora/
├── connector.json
├── composer.json
├── config/connector-agora.php
├── database/migrations/2026_10_01_000000_create_agora_forums_table.php
├── lang/{english,spanish,…}.json
├── resources/views/slots/install.blade.php
├── resources/views/slots/install-placeholder.blade.php
├── routes/inbound.php
├── routes/web.php
├── src/
│ ├── AgoraConnectorServiceProvider.php
│ ├── AgoraConnector.php
│ ├── Agora.php the HTTP client
│ ├── Http/WebhookController.php
│ ├── Http/BoardPickerController.php
│ ├── Models/Forum.php
│ └── Ports/
│ ├── AgoraInstallationLifecycle.php
│ ├── AgoraIdentityResolver.php
│ ├── AgoraInboundGateway.php
│ ├── AgoraFailureClassifier.php
│ ├── AgoraTextRenderer.php
│ ├── AgoraUiSlots.php
│ ├── AgoraMessenger.php
│ ├── AgoraAccessController.php
│ ├── AgoraSpaceCatalog.php
│ ├── AgoraPortalLoginMethod.php
│ └── AgoraRecoverySupport.php
└── tests/
```
The manifest declares four capabilities: `messaging`, `access_control`, `portal_login` and `recovery_probes`. No `broadcasts` (sixty requests a minute is no rate for a broadcast), no `management_surface` (a forum has no admin conversation to render), no `support_relay`, no native payments.
## What you need
- PHP 8.5 and Composer.
- The SDK alone for the conformance kit and the port fakes (its `TestRegistry` stands in for the application's registry); a checkout of Subscriby only to test the core's side against your connector ([Testing a connector](/sdk/v1/building/testing) shows both).
- The [manifest reference](/sdk/v1/manifest), the [ports reference](/sdk/v1/ports) and the [Core API reference](/sdk/v1/core-api) open in another tab. The tutorial explains each decision once and links the reference for the rest.
composer.json, the service provider, the Connector class and the HTTP client.
connector.json for Agora, block by block, with the reasons.
---
# Billing History
Source: https://docs.subscriby.net/subscribers/billing-history
By the end of this page, you'll know where to find every payment you've made on a creator's project and where to get formal receipts.
## Where billing history lives
There are **two** sources, and they show different levels of detail:
1. **Inside the project portal** — the portal displays a simple **Payment history** list within each subscription you own.
2. **Inside the payment provider** (Stripe, PayPal, Razorpay, Paystack, Skrill, CoinPayments, CeyPay) — each provider keeps its own complete, tax-receipt-grade record of your transactions. For invoices, chargeback evidence, or anything formal, go to the provider directly.
## Viewing your history on the portal
### Sign in to the portal [step]
Sign in to the creator's portal page.
### Open My Memberships [step]
Click your name in the header and choose **My Memberships**.
### Open a subscription [step]
Click any subscription to open it. The view slides to the subscription's **My Access** page.
### Scroll to Payment history [step]
Scroll down past the resource list — if there have been any payments on this subscription, you'll see a **Payment history** block.
### What each row shows
Each payment row shows just four things:
- **Amount** — in the subscription's currency.
- **Date** — the date the payment was processed.
- **Provider** — which payment method handled it (e.g. _"Stripe"_, _"PayPal"_).
- **Status pill** — one of **Successful** (green), **Pending** (amber), **Failed** (red), or **Refunded** (grey).
The portal's payment history is intentionally minimal — it's enough to confirm
*"yes, I was charged this amount on this date"* at a glance. For anything more
detailed — transaction IDs, VAT line items, printable receipts — use your
payment provider's own dashboard.
If no payments have ever been recorded for the subscription (for example, a subscription activated via an access code on a free plan), the Payment history block isn't shown at all.
## Getting a formal receipt or invoice
Subscriby doesn't issue tax receipts itself. Every payment provider does, typically emailing them automatically at the time of charge:
- **Stripe** — receipts emailed on each successful payment; also accessible via the Stripe customer portal (Stripe may surface a link inside your receipt emails).
- **PayPal** — transaction receipts are available under **Activity** in your PayPal account.
- **Razorpay / Paystack** — receipts emailed to the address you used at checkout; available in their customer views.
- **Skrill** — transaction history and receipts are inside your Skrill account.
- **CoinPayments** — transaction records include the blockchain hash (your authoritative, public receipt); look it up on a block explorer for external verification.
- **CeyPay** — transaction records are maintained in the CeyPay system.
If you've lost a receipt, search your email inbox for the provider's name and the payment date.
## Common questions
Webhooks from the provider take a few seconds to minutes to reach Subscriby. For crypto, blockchain confirmations can take 20–60 minutes. If it's been longer than an hour, contact the creator with the provider's transaction ID — they can reconcile from their side.
That means no payments have been recorded against this subscription yet. Common reasons:
- **Access-code or free-plan subscription** — no money changed hands, so there's nothing to show.
- **Brand-new subscription** — the first payment may still be propagating from the provider.
- **Trial subscription** — if you're in a trial period and haven't been charged yet, there's nothing to list.
Not from the subscriber side today. Use your payment provider's export tools
for formal records. Stripe, PayPal, and Razorpay all offer CSV download of
your transactions.
Normal — cancellation doesn't void the current paid cycle. The payment that
funded your current access stays in the history; future renewals just won't
happen.
The creator issued a refund via the payment provider, and the provider's webhook updated the status in Subscriby. The funds should appear back with your payment method within the provider's usual refund window (often a few business days for cards; immediate for digital wallets; slower for banks).
## Related
- [Manage your subscription](/subscribers/manage-subscription) — the subscription detail view where payment history lives.
- [Making payments](/subscribers/making-payments) — what the checkout experience looks like per provider.
- [Troubleshooting](/subscribers/troubleshooting) — what to do when something looks off.
---
# Cancel Your Subscription
Source: https://docs.subscriby.net/subscribers/cancel-subscription
By the end of this page, you'll know exactly how to cancel your subscription, what happens next, and how to come back if you change your mind.
You can cancel any active or trialing subscription **at any time** — from either the web portal or the bot. Cancellation is free, instant to trigger, and **keeps your access until the end of your current billing period**.
**Time-limited passes work differently — there is nothing to cancel.** A pass
is a one-off purchase for a single scheduled window. No payment renews, and
the pass ends by itself when the window closes, so no cancel option is offered
for one. If you bought the wrong date or can't attend, contact the creator
about a refund. See [Using a pass](/subscribers/using-a-pass).
Cancelling here stops future Subscriby renewals. Some payment providers (e.g.
PayPal) maintain their own subscription agreement; if your cancellation
doesn't reflect there within a day, also cancel directly in the provider's
customer portal to be safe.
## What cancellation does
When you cancel, Subscriby:
1. **Queues a cancellation job** that marks your subscription as **Canceled** on our side.
2. **Stops future automatic renewals** — the next billing cycle won't charge your payment method.
3. **Leaves your access in place** until your current paid period (or trial) ends. After that, you're automatically removed from the places the subscription unlocked.
No refund is issued automatically. If you want a refund for the current period, contact the creator directly — refund policies are each creator's own decision, processed via their payment provider.
## How to cancel
### Open My Memberships [step]
Sign in to the portal. Click your name in the header and choose **My Memberships**.
### Open the subscription [step]
Click into the subscription you want to cancel.
### Tap Cancel [step]
On the subscription detail page, tap **Cancel**.
### Confirm cancellation [step]
A confirmation modal explains what will happen. Confirm by clicking the cancel button inside the modal (or dismiss to back out).
### Review the success banner [step]
A success banner appears: _"Your subscription has been cancelled. You'll keep access until the end of the current billing period."_
Cancel is rate-limited — typically up to 3 attempts in a 10-minute window per
account. This stops accidental double-clicks from causing issues; you almost
never need to cancel more than once.
### Open your project's bot [step]
Open the project's bot on your platform.
### Find the Cancel Subscription button [step]
On the subscription status card the bot shows you, find the **❌ Cancel Subscription** button.
### Tap Cancel Subscription [step]
Tap it. The bot replies with a confirmation prompt to prevent accidental clicks.
### Confirm your choice [step]
Confirm your choice.
### Review the bot's confirmation [step]
The bot confirms the cancellation. Your access continues until the end of your current billing period; after that the bot removes you from the paid channels.
## What happens after cancellation
Cancellation plays out in four clear phases over time.
### Immediately
Status changes to **Canceled**. No future charges will be made. Automatic renewal is off from this moment onward.
### Between now and your cycle-end date
You still have full access — the same channels, groups, and resources you had the moment before cancelling. No change in what you can do until the cycle ends.
### On the cycle-end date
The subscription flips from **Canceled** to **Expired**. The bot automatically removes you from the places. Portal login still works, and you'll see the subscription in your history as a past record.
### Later
You can subscribe again anytime — it becomes a new subscription with a fresh billing cycle. Your past history on the project (including the cancellation) remains visible to the creator.
## Changing your mind
Cancellation doesn't block you from coming back. To resubscribe:
- **Before the cycle-end date** — you're still showing as Active, so there's no action needed. Your renewals resumed automatically? **No.** Once you've cancelled, automatic renewal is off until you re-subscribe. If you want to keep paying, [cancel the cancellation isn't supported today](#common-questions) — instead, let the current period end, then subscribe again.
- **After the cycle-end date** — open the portal or bot and subscribe to the same plan (or a different one). A fresh subscription is created with a fresh billing cycle.
## Cancelling vs. not paying
There's a practical difference between **cancelling** and **letting a payment fail**:
- **Cancel** — a clean, intentional end. Access continues to cycle end. No negative marks.
- **Let a payment fail** — the provider retries a few times, eventually gives up, and the subscription lapses as **Unpaid** or **Expired**. You may lose access earlier than the cycle-end date (depending on provider retry policy), and you may briefly see a **Past Due** state.
If you're planning to stop, always cancel proactively — it's the better experience.
## Cancelling in provider dashboards
Some payment providers let you cancel the subscription agreement directly on their end (Stripe, PayPal). Doing so:
- **Stops future charges** at the provider — same as cancelling in Subscriby.
- **Subscriby picks it up** via webhook and marks the subscription Canceled on our side (usually within minutes).
- **Your access** continues until your paid period ends — same rules as above.
If you cancel in Subscriby but the provider still shows the subscription as active, don't panic — our webhook to them typically clears it, but you can also cancel on the provider's end to be certain.
## Common questions
Not directly. Once cancelled, automatic renewal is off. If you want to continue past your cycle end, subscribe again from the plan list.
Double-check in the payment provider's own dashboard (Stripe, PayPal, etc.).
If a subscription agreement still exists there, cancel it in the provider.
Subscriby-side cancellation usually propagates automatically via webhook, but
if you cancel the provider-side agreement before the webhook fires, it's still
effective.
Subscriby doesn't process refunds directly. Contact the creator — they set
their own refund policy and can issue a refund through their payment provider.
Don't dispute the charge with your bank before asking; chargebacks are costly
for creators and often lead to permanent bans.
The creator's dashboard shows your cancellation on their Subscriptions list,
along with any past activity. They don't see a "reason" unless you tell them —
so if there was a specific issue, a quick message is helpful.
Your project user record remains (with status Churned) so the creator can
target you with win-back offers, and so your subscription history stays
auditable. To request full data deletion, contact the creator — they can
remove your record under their privacy policy.
No — once Canceled, the button is unavailable. If you want to start fresh, subscribe to the same plan again.
## Related
- [Manage your subscription](/subscribers/manage-subscription) — the page you're on before cancelling.
- [Trial memberships](/subscribers/trial-memberships) — how trials end (hint: they auto-end without requiring cancellation).
- [Troubleshooting](/subscribers/troubleshooting) — if something doesn't go as expected.
---
# Choosing a Plan
Source: https://docs.subscriby.net/subscribers/choosing-a-plan
When you open a creator's bot or portal, you'll see a list of plans to pick from. Each plan has a **name, price, billing cycle**, and a list of **included resources** (the places, or other perks, you'll unlock). This page helps you read that list and pick the right one.
## Plan types you'll encounter
The standard subscription model. You're charged automatically at the end of each billing period (weekly, monthly, yearly, or whatever cycle the creator configured).
- **Billing cycle** — every 1 day, 1 week, 1 month, 1 year, or a custom multiple (e.g. every 3 months).
- **Auto-renew** — at the end of each cycle, your saved payment method is charged and access continues.
- **You can cancel anytime** — access continues until the end of the current paid period. See [Cancel a subscription](/subscribers/cancel-subscription).
Recurring is the right choice if you want ongoing access and prefer not to think about renewing manually.
A single payment for a fixed duration — often labelled things like _"30-day pass"_ or _"Annual membership (no auto-renew)"_.
- You pay once.
- Access is granted for the plan's duration.
- When the duration ends, access stops — there's **no automatic charge** to extend it.
- If you want to keep going, you subscribe again (or switch to a recurring plan if the creator offers one).
One-time plans are common for limited-run events, seasonal passes, or audiences that don't want recurring charges.
Access to a **specific scheduled window** rather than a period that starts when you pay. You might buy a Sunday pass on Wednesday — you're admitted Sunday morning and removed Sunday night.
- **You pick a date** at checkout. The next available window is preselected. A window marked **On now** is already running — buy that one and you are let in straight away, for whatever is left of it.
- **Paying usually does not let you in yet.** Unless you bought a window already running, access opens at the window's start time, in the timezone the creator set.
- **Tap your invite link whenever you like.** You'll be placed in a queue and admitted automatically when the window opens — you don't need to be online at the time.
- **One purchase, one window.** To attend another date, buy the pass again for that date.
- **Nothing renews and there's nothing to cancel.** The pass simply ends when the window closes.
You can hold several passes at once — and an ordinary subscription alongside them.
Tapping your invite link won't put you in the channel straight away. The bot
replies confirming you're queued and tells you exactly when access opens, when
it ends, and how long you'll have. Nothing more is needed from you.
See [Using a pass](/subscribers/using-a-pass) for the full walkthrough.
A **season ticket**: one payment covering a whole slate of dated sessions — ten match days, an eight-week course, a weekend of talks. It is a bundle of time-limited passes bought together.
- **Every date is yours the moment you pay.** You don't claim them one at a time.
- **Each date still opens and closes on its own.** You're admitted when it starts and removed when it ends, then let back in for the next one.
- **You get a timetable** on the bot and in the portal, showing every date and which ones you've attended.
- **Nothing renews.** The series ends when its last date closes.
- **The whole slate is listed at checkout**, so you see every date before paying.
A date you already bought individually is not sold to you twice, and the creator may cap how many people can hold the season at once.
Some creators keep the season on sale after it has begun. If so, you pay the
full price for whatever dates are left — nothing is prorated, and dates that
have already run are never issued to you.
See [Using a pass series](/subscribers/using-a-pass-series) for the full walkthrough.
Pay once, keep access forever.
- The billing cycle is **Lifetime** — no end date, no renewal.
- Great value if you intend to stay with the creator long term.
- Usually priced higher than a monthly or yearly plan (since you're pre-paying forever).
- Creators occasionally offer lifetime plans only as "founder" tiers or limited-time launches — if you see one and want it, don't wait too long.
Many creators let you try a plan free for a few days before paying.
- **Cardless trial** — you subscribe without entering a payment method. Access stops automatically when the trial ends unless you convert.
- **Trial with a card** — you provide a payment method up front; you're only charged when the trial ends (recurring plans auto-charge, one-time plans just activate).
- **Duration** — set by the creator; typically 3, 7, 14, or 30 days.
- **Eligibility** — trials are usually once-per-plan or once-per-project. If you've already trialled before, the plan may not offer a trial again.
See [Trial memberships](/subscribers/trial-memberships) for the full details.
## Audience filters
Creators can restrict who sees each plan. You'll only see plans that match you — but knowing the filters exists helps if you see different options from a friend.
| Filter | Who sees it |
| --------------------- | ---------------------------------------------------------------------------------------------------------- |
| **Newcomers only** | People who've never subscribed to this project before. Hidden from returning customers. |
| **Customers only** | People with an active subscription. Used for add-on or upgrade plans. |
| **Churned only** | People whose subscription has ended. Used for win-back offers. |
| **Single use** | Hidden from you once you've bought it. No duplicate purchases. |
| **Access codes only** | Never shown publicly. Only reachable by redeeming an [access code](/subscribers/redeem-access-code). |
If a friend sees a plan you don't, it's almost always because of these
filters. Common example: they're a returning customer and the creator offered
*"Welcome back — 50% off for 3 months"* for churned members; that plan is
invisible to new visitors.
## What to compare before you pick
$9.99/month = $119.88/year, while $99/year = the same tier with ~17 % off. If the creator offers both, the annual plan is usually the better deal *if* you intend to stay ≥ 10 months.
Each plan lists exactly which channels, groups, and other resources it
unlocks. A cheaper tier may omit the most valuable one. Read the list before
committing — it's the truest description of what you're paying for.
If a plan offers a trial and a similar plan doesn't, take the trial. You get
to verify the value before paying, with no real cost.
Recurring plans let you cancel anytime; one-time plans hold your money until
the duration ends. If you're uncertain about commitment, prefer a monthly
recurring plan over a 6-month one-time plan.
The portal supports web-based providers (Stripe, PayPal, Razorpay, Paystack, Skrill, CoinPayments, CeyPay). A platform's **native currency** is only offered inside that platform — if you prefer it, subscribe [through the bot](/subscribers/on-the-platform) instead; see [Native payment methods](/payments/native-payments).
## Frequently asked
The portal doesn't have a one-click upgrade or switch-plan action. If you want to move to a different plan, [cancel your current subscription](/subscribers/cancel-subscription) and subscribe to the new one. Your existing access continues until your current billing period ends, so you can start the new plan right away.
Only if the plan you want is paid with the platform's own in-app payment. For
every other payment method, the portal works without it — you can sign in via
Google or a verified email.
The plan's listed currency. If that's different from your local currency, your
bank or payment provider handles the conversion at their current exchange
rate.
It might be gated behind an audience filter (Newcomers only, Churned only) that doesn't include you right now. Check back after your situation changes, or ask the creator directly.
## Related
- [Making payments](/subscribers/making-payments) — what happens after you pick a plan.
- [Trial memberships](/subscribers/trial-memberships) — trial specifics.
- [Redeem an access code](/subscribers/redeem-access-code) — skip payment if you have a code.
---
# Contacting Support
Source: https://docs.subscriby.net/subscribers/contacting-support
If something's wrong with your membership — a link that didn't work, a payment you don't recognise, a question about what's included — you can ask the creator directly. There's no separate support site and no ticket form.
## Just message the bot
Open the same bot you used to subscribe and type your question like a normal message.
That's it. Anything the bot doesn't recognise as a command reaches the creator, and their reply comes back to you in the same chat.
You can send more than text: photos, screenshots, videos, voice notes and documents all get through. A screenshot of an error is usually the fastest way to explain a problem.
You don't need an active subscription to ask a question. Message the bot
before you subscribe if you want to check something first.
## Telling a person from the bot
Both arrive in the same chat, so replies are labelled. A message that starts with a **name and a person icon** is the creator or their team writing to you:
> 👤 **Acme Signals**
> Sorry about that — here's a fresh invite link, valid for 24 hours.
Anything without that prefix is the bot: menus, confirmations, renewal reminders, and any automatic acknowledgement the creator has set up.
## What to expect
- **An instant acknowledgement, sometimes.** Some creators set up an automatic reply so you know your message arrived. It's not a person, and a real reply follows separately.
- **A reply from a human, in the creator's own time.** Subscriby doesn't set response times — that's up to whoever runs the membership.
- **A continuous conversation.** Your messages and their replies stay in one thread. If you come back months later, the creator still sees the history.
## Frequently asked
The creator of that membership and anyone on their team. Subscriby stores the
messages so the creator can read and answer them. Your message is not shared
with other members or with other creators.
Yes. Photos, videos, voice notes and documents all reach the creator. Add a
caption to explain what you're showing.
Sending a lot of messages quickly doesn't get you answered faster — past a
point the extra messages are dropped rather than delivered. Send one clear
message and wait. If it's urgent and the creator lists another contact route,
use that.
Yes. Edit it in the app as normal and the creator sees the corrected version.
Don't resend it as a new message.
A few possibilities: the creator has turned support messages off for that
membership, you've blocked the bot at some point, or the bot itself is
disconnected. Try `/start` first — if the bot doesn't respond to that either,
it isn't reachable and you'll need to contact the creator another way.
You can ask the creator about your subscription and payments. But never send
full card details in a chat — no legitimate creator will ask for them. Manage
payment methods yourself in the [subscriber
portal](/subscribers/portal).
## Related
- [Using the bot](/connectors/telegram/for-members)
- [Troubleshooting](/subscribers/troubleshooting)
- [Manage your subscription](/subscribers/manage-subscription)
---
# Member Guide
Source: https://docs.subscriby.net/subscribers
import {
Bot,
Globe,
Ticket,
CreditCard,
Settings2,
XCircle,
LifeBuoy,
Clock3,
FileText,
} from "lucide-react";
Welcome. If a creator has invited you to join their membership community on the platform it lives on, this section walks you through everything you can do as a subscriber — from subscribing, paying, and accessing content, to managing your membership later.
Subscriby gives you **two ways to interact** with the creator's project:
} title="On your community's platform" href="/subscribers/on-the-platform">
Subscribe, pay, and get your access — all inside the platform your community lives on. Fastest path if that is where you live.
} title="Via Web Portal" href="/subscribers/portal">
A richer web experience for browsing plans, managing memberships, and setting up recovery methods (email, Google).
You don't need a Subscriby account to subscribe. You interact directly with
the creator's bot or the creator's portal page — each project is its own
little world. Your identity is either your **account on the platform** or (on
the portal) a **verified email** / **linked Google account**.
## By topic
} title="Choosing a plan" href="/subscribers/choosing-a-plan">
Recurring vs. one-time vs. lifetime vs. trial — understand what you're signing up for.
}
title="Making payments"
href="/subscribers/making-payments"
>
What to expect at checkout, per supported payment provider.
}
title="Manage your subscription"
href="/subscribers/manage-subscription"
>
View your active subscription, check resource access, and pull up past
payments.
}
title="Cancel a subscription"
href="/subscribers/cancel-subscription"
>
Walk through the cancellation flow and understand what happens to your access.
}
title="Redeem an access code"
href="/subscribers/redeem-access-code"
>
Use a code from a creator to activate a subscription without paying.
}
title="Trial memberships"
href="/subscribers/trial-memberships"
>
Start a free or cardless trial, see when it ends, and decide what happens
next.
}
title="Billing history"
href="/subscribers/billing-history"
>
Find receipts and past charges for your subscriptions.
} title="Troubleshooting" href="/subscribers/troubleshooting">
Quick fixes for the most common issues — payment failed, can't access, bot didn't add me.
## A note on identity
When you subscribe to a project, Subscriby creates a **project user** record for you on that specific project. It's separate from:
- The creator's personal Subscriby account (which is only for them).
- Your project user record on any _other_ project — each project is isolated, so subscribing to Creator A's project and Creator B's project creates two independent records.
Your sign-in methods (your platform account, verified email, linked Google account) apply **per project**. Setting up a recovery email for Project A doesn't automatically do it for Project B.
## What happens if the creator goes away?
If a creator deletes their project, or closes their Subscriby account, **active subscriptions are cancelled and access is cut off**. Any unused remaining time on your subscription is up to the creator's own refund policy — Subscriby doesn't process refunds directly; those are handled via the payment provider (Stripe, PayPal, etc.).
If you ever find yourself in that situation, contact the creator first; if they're unreachable, open a dispute through your payment provider.
---
# Making Payments
Source: https://docs.subscriby.net/subscribers/making-payments
By the end of this page, you'll know what to expect at checkout no matter which payment provider the creator has enabled.
When you tap **Subscribe** on a plan, Subscriby shows you a **Payment Selection** screen listing every enabled, currency-compatible provider. You pick one and we redirect you to that provider's own checkout page. Once payment completes, the provider redirects you back to Subscriby with a success or failure result.
## Payment providers available from the portal
The **web portal** supports these seven providers for direct-in-browser payments:
- **Stripe** — Cards (Visa, Mastercard, Amex), Apple Pay, Google Pay depending on region
- **PayPal** — PayPal balance or linked card
- **Skrill** — Skrill account or card
- **CoinPayments** — Bitcoin and USD-based stablecoins
- **Paystack** — African regional payments (Nigeria, Ghana, Kenya, South Africa, and more)
- **Razorpay** — India-specific (UPI, netbanking, Indian cards)
- **CeyPay** — Sri Lanka-specific crypto (USDT variants)
A platform's **native payment** and **Access Codes** are **not shown** in the
portal's payment selector. A native payment is processed inside the platform's
own app — subscribe through the [bot](/subscribers/on-the-platform) to use it.
[Access codes](/subscribers/redeem-access-code) have their own separate
**Use Access Code** button on the portal, not a payment-method row.
The **bot** supports every provider the creator has enabled, including the platform's own native payment where the connector offers one.
## What checkout looks like
#### Pick Stripe [step]
You click **Stripe** on the payment selection screen.
#### Land on Stripe's hosted checkout [step]
Subscriby redirects you to a **Stripe-hosted checkout** page.
#### Enter your card details [step]
Enter your card details, name, and email (Stripe may save a profile for future renewals if the plan is recurring).
#### Pay [step]
Click **Pay**. Stripe processes the charge, then redirects you back to Subscriby.
#### See the success screen [step]
You see a **Payment Successful** screen with buttons to join the places your plan unlocks.
Stripe may ask your bank for 3D Secure verification (a browser prompt or SMS
code from your bank) on larger charges. That's your bank's check, not
Subscriby's.
#### Pick PayPal [step]
You click **PayPal** on the payment selection screen.
#### Land on PayPal's checkout [step]
Subscriby redirects you to PayPal's checkout page with the plan and price pre-filled.
#### Sign in to PayPal [step]
Sign in to your PayPal account (or check out as guest with a card, if PayPal allows that in your region).
#### Confirm the subscription [step]
Review and confirm the subscription.
#### Return to Subscriby [step]
PayPal redirects you back to Subscriby. For recurring plans, PayPal auto-charges at the end of each billing cycle.
#### Pick Skrill [step]
You click **Skrill** on the payment selection screen.
#### Land on Skrill's payment page [step]
Subscriby redirects you to Skrill's payment page.
#### Sign in or pay as guest [step]
Sign in to your Skrill account or pay as a guest with a card.
#### Confirm the payment [step]
Confirm. Skrill redirects you back to Subscriby with the result.
Skrill plans are **one-time** — Skrill doesn't support recurring charges in
this integration. You'll need to pay again manually when your access ends.
#### Pick CoinPayments [step]
You click **CoinPayments** on the payment selection screen.
#### Land on CoinPayments [step]
Subscriby redirects you to CoinPayments.
#### Pick a coin and send payment [step]
Pick a supported coin (Bitcoin, USDT, USDC, etc.) and follow the instructions to send the exact amount to the generated wallet address.
#### Wait for network confirmations [step]
Wait for network confirmations — this varies by blockchain. Bitcoin typically needs 2–3 confirmations, which can take 20–60 minutes.
#### Return to Subscriby [step]
Once confirmed, you return to Subscriby with a success screen.
Crypto payments are **one-time** and **non-refundable**. Double-check the
amount and address before sending — blockchain transactions are irreversible.
#### Pick Paystack [step]
You click **Paystack** on the payment selection screen.
#### Land on Paystack's checkout [step]
Subscriby redirects you to Paystack's checkout.
#### Choose your local payment method [step]
Choose your local payment method — card, bank transfer, or local wallet depending on your country (Nigeria, Ghana, Kenya, South Africa, and more).
#### Complete the payment [step]
Complete the payment and return to Subscriby.
#### Pick Razorpay [step]
You click **Razorpay** on the payment selection screen.
#### Land on Razorpay's checkout [step]
Subscriby redirects you to Razorpay's India-focused checkout.
#### Choose your payment option [step]
Pick UPI, netbanking, credit/debit card, or an Indian wallet (Paytm, PhonePe, etc.).
#### Authorize the charge [step]
Authorize the charge and return to Subscriby.
#### Pick CeyPay [step]
You click **CeyPay** on the payment selection screen.
#### Land on CeyPay [step]
Subscriby redirects you to CeyPay for the USDT payment.
#### Send the USDT payment [step]
Send the exact USDT amount from your wallet to the address shown.
#### Return to Subscriby [step]
Once the network confirms, you return to Subscriby with a success result.
Telegram Stars payments happen **inside the Telegram app**, not through a browser redirect.
#### Subscribe through the bot [step]
Subscribe to the plan through the creator's Telegram bot (not the portal).
#### Pick Telegram Stars [step]
On the payment method selection, tap **Telegram Stars**.
#### Confirm the in-app payment [step]
Telegram shows a native in-app payment prompt. Confirm with your device.
#### Subscription activates [step]
The bot detects payment instantly and activates your subscription.
Telegram Stars are processed natively on your Apple / Google / Microsoft
account. Topping up Stars is also done inside Telegram — no Subscriby
involvement.
## After a successful payment
### The success screen
Once payment is confirmed, you're redirected to a **You're In!** screen for the subscription just activated, with a **Join** button for each place your plan unlocks.
For a [time-limited pass](/subscribers/using-a-pass) it also states the **access window** you bought and when it opens — access starts then, not at payment.
### Invite links to your resources
Subscriby issues you personal **access** to every place your plan unlocks. Tap each **Join** button on the success screen to get into the corresponding place.
The links are created a moment after payment, so the screen shows **Preparing Invite Links…** briefly. If they take longer than about a minute it stops waiting and tells you what to do instead of spinning — your purchase is confirmed either way. A plan whose resources are all granted by hand says so, because there are no links to wait for.
### Retrieving your links later
- On the **bot**, the same links arrive as a _"Your Subscription Resources"_ message. You can retrieve them any time by sending `/my_resources` to the bot.
- On the **portal**, the links are visible on the subscription's detail page under **My Memberships** — useful if you accidentally leave a channel and need to re-join.
## When a payment fails or gets stuck
Most often: card declined, insufficient funds, or bank flagged it as suspicious. Try the provider again (often the bank clears the flag on retry) or pick a different provider. Your subscription isn't activated until the payment succeeds.
For crypto providers, wait 20–60 minutes for blockchain confirmations. For
fiat providers, wait 5–10 minutes for provider webhooks to sync. If it's still
showing un-subscribed after an hour, contact the creator with your provider's
transaction ID — they can look up the subscription in their dashboard.
Very rare, usually a retry loop. Open the [My
Memberships](/subscribers/portal#my-memberships) page — if you see only
one active subscription, the second charge is likely a pending hold that will
auto-release in a few days. If both charges have posted, contact the creator;
they'll refund the duplicate through the payment provider.
Only payment methods the creator has enabled appear. Each creator chooses which providers to accept. If the one you want isn't there, reach out to the creator — they may be able to enable it.
## Recurring billing
If you chose a **recurring** plan, the payment provider automatically charges your saved method at the end of each billing cycle and extends your access.
### When charges fail
If the provider can't charge your saved method (expired card, insufficient funds, fraud hold), it retries according to its own policy. During retries your subscription shows **Past Due**; if retries are exhausted it flips to **Unpaid** then **Expired**, and the bot removes you from paid resources.
### Updating a payment method
Each provider handles this differently — typically through their own customer portal (Stripe Customer Portal, PayPal recurring payments page, etc.). Subscriby doesn't expose a universal "update payment method" button on its side. If you're unsure how to get to your provider's portal, the receipt emails they send you include a direct link.
### Stopping future renewals
[Cancel the subscription](/subscribers/cancel-subscription) from the portal or bot. Your access continues until the end of your current paid period, then stops automatically. No need to also cancel at the provider level — Subscriby handles it.
## Related
- [Choosing a plan](/subscribers/choosing-a-plan) — pick before you pay.
- [Manage your subscription](/subscribers/manage-subscription) — what happens after payment.
- [Billing history](/subscribers/billing-history) — see past payments.
- [Troubleshooting](/subscribers/troubleshooting) — common issues.
---
# Manage Your Subscription
Source: https://docs.subscriby.net/subscribers/manage-subscription
By the end of this page, you'll know where to find your active subscription, what it's showing you, and what actions you can take.
Subscriby lets you manage your subscription from **either** the web portal or the bot — both reflect the same underlying data, so use whichever is convenient.
### Open My Memberships
After signing in, click your name in the header and choose **My Memberships**. You'll see every subscription you currently have on this project, each as its own card.
### Subscription detail view
Click a subscription to open its detail page. You'll see:
- **Plan name and price** — what you're currently subscribed to.
- **Status** — Active, Trialing, Past Due, Canceled, etc. See [Status reference](/reference/status-codes).
- **Next billing date** or **Expiry date** — depending on whether the plan is recurring or one-time.
- **Included resources** — every place your plan unlocks, each with a **live membership status badge** (so you can verify you're currently in each one). The badge is cached for 5 minutes for performance, so very recent changes may take a moment to update.
- **Resource join links** — re-accessible at any time if you ever need to re-join a place.
- **Payment history** — every payment ever made on this subscription, including the payment method used, the amount, and the status.
### Actions available
From the detail page you can:
- **Re-join a resource** — tap the **Join** button next to any resource whose membership badge shows you're not currently in it.
- **Cancel** — see [Cancel a subscription](/subscribers/cancel-subscription) for the full flow.
- **Jump back to Plans** — if you want to subscribe to an additional plan, use the **Plans** link in the header.
### View status
When you open the bot after subscribing, it displays your **Active Subscription** details automatically:
- Current plan name.
- Status (Active, Trialing, etc.).
- Next billing date (for recurring plans) or expiry date (for one-time plans).
- Renewal price (for recurring plans).
### Available commands
- Send `/my_resources` (or tap the **Show Invite Links** / equivalent button if the bot presents one) to resend the unique invite links for your subscription. Use this if you ever lose the original _"Your Subscription Resources"_ message.
- Tap the **❌ Cancel Subscription** button on your subscription card to start the cancellation flow. See [Cancel a subscription](/subscribers/cancel-subscription).
### Account recovery from within the bot
On the plan-selection screen, there's a **🔐 Account Recovery** button. Tap it to add or manage a verified recovery email — this is what lets you sign in to the [web portal](/subscribers/portal) if you ever lose access to Telegram. See [Via Telegram Bot → Account Recovery](/connectors/telegram/for-members#account-recovery) for the detailed flow.
## Switching between plans
There's no one-click "upgrade" or "switch plan" action today — on either the bot or the portal.
If you want to change plans:
### Cancel your current subscription [step]
[Cancel your current subscription](/subscribers/cancel-subscription). Your access continues until the end of your current billing period.
### Subscribe to the new plan [step]
Go to the plan list and subscribe to the new plan.
### Run both plans until the old one expires [step]
You'll be charged for the new plan's first cycle right away, and it will run alongside your remaining access on the old plan until the old one expires.
If the new plan includes all the same resources as the old one, nothing
changes in your access — you stay in the same places. The old
subscription just stops renewing once the new one takes over.
## Re-joining a place
Sometimes you accidentally leave a channel or group, or get removed because of a temporary access-check hiccup. You can always re-join from the subscription detail page on the portal:
### Open your subscription [step]
Open **My Memberships** and click into your active subscription.
### Check each resource's membership badge [step]
Look at each resource card — a badge tells you whether you're currently a member.
### Tap Join where needed [step]
For any resource where the badge says you're not a member, tap **Join**. You get a new single-use invite link.
### Or use the bot [step]
On the bot side, the equivalent is sending `/my_resources` to get your unique invite links again.
Don't forward or share invite links — they're unique to you. If someone else
joins with your link, the creator's bot may revoke your access as a security
precaution.
## Renewal & expiry reminders
As your subscription nears its end date, the bot sends a short ladder of reminders so you're never caught off guard — at **7, 3, 2, and 1 days** before expiry, and a final notice **on expiry** when access is removed. Each reminder is sent at most once.
- If your plan **auto-renews** (a card subscription), the reminders simply let you know renewal is coming — no action needed unless you want to cancel first.
- If your plan **doesn't auto-renew** (for example, one you activated with an access code), the reminders ask you to renew — redeem a new access code to keep your access.
**Shorter plans get fewer reminders.** A reminder is only sent when your
plan's period is longer than that milestone — so a 1-day plan won't send the
7/3/2/1-day reminders, only the on-expiry notice. Reminders are automatically
shown in your language.
## Time-limited passes
Passes are listed alongside your other memberships, but they behave differently enough to be
worth calling out:
- They're grouped by state — **upcoming**, **live**, and **past** — rather than shown as one
current subscription, because you can hold several at once.
- Each shows its window as an absolute time in the creator's timezone plus a countdown
("opens in 3 days", "ends in 2h 14m"), never a bare date.
- There's **no renewal date and no cancel action**, because nothing renews.
- Each pass has its own invite links, listed separately so you can tell them apart.
Pass reminders don't use the 7/3/2/1-day ladder above — a window is often shorter than a day.
Instead, if you haven't tapped your invite link yet, you're nudged about **a day** and again
about **an hour** before the window opens, with the link attached. Once you're in the queue
the reminders stop, because there's nothing left for you to do.
See [Using a pass](/subscribers/using-a-pass) for the full lifecycle.
## Common questions
Not directly from the subscriber side. What you can do: cancel now (you keep access until cycle end), then subscribe again when you're ready to come back.
See [Billing history](/subscribers/billing-history). Payment receipts
are also typically emailed by the payment provider directly (Stripe, PayPal,
etc.).
Your last scheduled charge failed. Depending on the payment provider, you may
have a retry window — typically 1–2 weeks — during which access stays active.
Update your payment method inside the provider's own customer portal (Stripe,
PayPal, etc.), or resubscribe through Subscriby if you want a clean start.
That's expected. Cancellation doesn't immediately revoke access; it just stops
future renewals. You keep everything until the end of the paid period.
If someone else uses your device to subscribe, or you shared your platform account / email with family, this can happen. Check **My Memberships** for every active subscription, then cancel anything you didn't authorize. If it's a larger fraud concern, contact the creator directly and your payment provider.
## Related
- [Cancel a subscription](/subscribers/cancel-subscription) — how to stop renewals cleanly.
- [Billing history](/subscribers/billing-history) — receipts and past charges.
- [Troubleshooting](/subscribers/troubleshooting) — common issues.
- [Via Web Portal](/subscribers/portal) — the full portal guide.
---
# On Your Community's Platform
Source: https://docs.subscriby.net/subscribers/on-the-platform
Every community on Subscriby runs on a **connector**: the link between Subscriby and the platform the community lives on. The connector gives the project a **bot** on that platform, and the bot is the fastest way to join: it sells the plans, takes the payment, hands you your access and answers your questions, all without leaving the app you already have open. Everything here is also available on the [web portal](/subscribers/portal).
## Joining
### Start the bot [step]
Open the project's bot from the link the creator shared and start it. It greets you with the community's description and the Terms of Service and Privacy Policy. Agree to continue.
### Pick a plan [step]
The bot lists the plans on sale, each with its name, price and billing cycle (monthly, yearly, lifetime, or the dates of a pass). Pick the one you want.
### Choose how to pay [step]
Pick a payment method. Card gateways open a secure checkout page and bring you back; a platform's own in-app payment, where the connector offers one, is settled right in the chat.
### Get your access [step]
The moment the payment settles, the bot sends you **your access**: one button per place the plan includes. The links are personal to you; sharing them can cost you the access. If you lose the message, the bot can show your access again from its menu.
## Managing your membership
Open the bot again and it shows your current plan, its status, the next billing or expiry date and the price. **Cancel** asks you to confirm, then keeps your access until the end of the period you paid for; after that you are removed from the places automatically.
## Account recovery
By default your membership is tied to your account on the platform. Lose that
account, or lose the platform in your region, and you lose the way back to
what you paid for. Setting a **recovery email** takes half a minute and lets
you sign in to the web portal with a magic link instead.
From the bot's **Account Recovery** menu you can add, change, resend the verification for, or remove a recovery email. A verification link arrives by email, is single-use and lives for an hour; once you click it the address is verified and works for portal sign-in straight away. The bot caps these actions to a few attempts an hour to keep the feature from being abused.
## Access codes
Received an **access code** from a giveaway or an offline purchase? Send it to the bot as a message, or open the link the creator gave you. A valid, unused code activates your membership at once and the bot sends your access.
## Asking the creator a question
Anything you send the bot that is not a command or a code reaches the creator directly, screenshots and voice notes included, and their reply comes back in the same chat, prefixed with their name so you can tell a person from the bot. See [Contacting support](/subscribers/contacting-support).
## Per connector
On Telegram the bot is a `t.me` link, you press **Start**, plans are inline buttons, Telegram Stars is the in-app payment where the creator offers it, and your access arrives as **Join** buttons with personal invite links. The screen-by-screen walkthrough, the recovery-email wizard and the exact commands are on [Using the Telegram bot](/connectors/telegram/for-members).
## Related
- [Via the web portal](/subscribers/portal) — the same membership from a browser.
- [Choosing a plan](/subscribers/choosing-a-plan) — how plans, passes and trials differ.
- [Troubleshooting](/subscribers/troubleshooting) — when something does not work.
---
# On the Web Portal
Source: https://docs.subscriby.net/subscribers/portal
## Overview
Each Subscriby project has a dedicated **Public Portal** (e.g., `my.subscriby.net/project-handle`). This web interface offers a visual way to browse detailed plan benefits, subscribe, and manage your account — including **recovery methods** that keep you signed in even if you lose access to the platform.
## Signing In
Click **Login** in the top-right corner. The portal offers **three** ways to sign in — pick whichever is most convenient. All three land you in the same account, so you can mix and match over time.
} title="Your community's platform">
Password-less, instant, and the default. Best if you're already on the
platform the community lives on.
} title="Google">
One-tap sign-in if the project has Google configured and you've linked your
Google account.
} title="Email Magic Link">
Works even without the platform. A single-use link is emailed to your
**verified** recovery email.
### Open the Sign-In Dialog [step]
Click **Login** in the portal header. A modal appears with the available sign-in methods. Methods depend on how the creator configured the project — Google is shown only when the project has Google OAuth enabled, for instance.
### Continue with your platform [step]
Click the **Continue with …** button of the platform your community lives on. You'll be asked to confirm your identity in its app. Your portal session is synced with your account there — the same identity your bot subscription is tied to.
If you've never interacted with the project before, the bot opens and prompts
you to tap **Start** / **Agree & Proceed**. Once accepted, the portal picks up
the session automatically.
### Continue with Google [step]
If you've previously **linked Google** from the Account & Recovery screen (see below), click **Continue with Google** and complete Google's consent screen. You're signed straight back into the same Subscriby account.
### Send Sign-In Link (Email) [step]
Below the platform and Google buttons there's an **email field** and **Send sign-in link** button.
1. Enter the **verified recovery email** tied to your account.
2. Click **Send sign-in link**.
3. Open the email within **15 minutes** and click the link — it's **single-use** and signs you in immediately.
For security, the portal always says *"If an account with that email exists, a
sign-in link has been sent."* — even if the address isn't registered or isn't
verified yet. This prevents attackers from probing for valid accounts.
You can request **up to 3 magic links per hour** per email address and IP
combination. Further attempts are silently discarded until the window resets.
## Browse Plans & Subscribe
### Browse Plans [step]
The homepage displays all public subscription plans. You can see:
- **Pricing & Billing Cycle**: Clear costs and renewal terms.
- **Free Trials**: Any available trial periods (e.g., "7 Days Free").
- **Resources**: A list of the specific Channels and Groups included in each plan.
- **Description**: Detailed benefits and perks.
Click **Subscribe Now** (or **Start Free Trial**) on your chosen plan.
### Payment & Checkout [step]
You'll be taken to the payment selection screen.
- **Credit Card / Stripe**: Select "Stripe" to pay via credit card. You'll be redirected to a secure Stripe Checkout page.
- **Access Code**: If you have a code, click **Use Access Code**, enter it in the box, and click **Redeem**.
The portal only shows payment methods that work on the web — **Stripe**, **PayPal**, **Skrill**, **CoinPayments**, **Paystack**, **Razorpay**, and **CeyPay**.
A platform's **native currency** is processed by the platform itself and isn't available on the portal; if you want to pay with it, subscribe [on the platform](/subscribers/on-the-platform) instead — see [Native payment methods](/payments/native-payments). **Access codes** also aren't in the normal payment list — use the separate **Use Access Code** action (see below).
### Success & Access [step]
After payment, you'll see the **You're In!** screen.
The system generates your **Invite Links** automatically — the screen shows **Preparing Invite Links…** for a moment, then a **Join** button per place. Click them directly on the web page to open the platform.
For a [time-limited pass](/subscribers/using-a-pass) the screen also names the **access window** you bought and when it opens. Tapping Join before then is fine: your request is held and approved the moment access starts.
## My Memberships
Click your name in the header and choose **My Memberships** to see your subscriptions.
- **Active subscriptions**: view your current plan, renewal / expiry date, and status.
- **Subscription details**: click a subscription to open its detail page, which lists every included resource (with live membership status) and your payment history for that subscription.
- **Resource links**: if you ever need to re-join a channel or group, your unique invite links are always available from the subscription's detail page.
- **Cancel subscription**: from a subscription's detail page, tap **Cancel**. You'll see a confirmation modal before anything happens; once confirmed, the cancellation is queued and you keep access until the end of your current billing period.
The portal doesn't currently have a direct "upgrade" or "switch plan" action.
To move to a different plan, cancel your current subscription and subscribe to
the new plan from the **Plans** page.
## Account & Recovery
If the platform is unavailable in your region, your account is banned, or your
phone is lost, a **verified recovery email** (and/or a linked Google account)
is the only way back into your paid memberships. Set one up once and forget
about it.
### The Recovery Nudge
The first time you sign in without any recovery method, a **yellow banner** appears above your plans:
> **Protect your subscription** — Add a recovery method so you can still sign in if you ever lose access to the platform.
Click **Set up recovery** to jump straight to the settings screen, or **Dismiss** to hide it. Once dismissed, the banner stays hidden for that account.
### Opening the Settings
Click your name in the header, then choose **Account & Recovery**. You'll see four sections:
Shows your display name. This is informational only; nothing here can be edited from the portal.
The accounts you use to sign in and receive the project's messages — one per platform the project is on. Each connected account shows the platform, the account's name, and an **Open …** link that launches the project's bot in a new tab.
- **Connect …** — Shown for a platform the project is on that you have no account connected on. Click it and the portal shows the bot's deep link and an 8-character code; open the link, or send the code to the bot as a message, and the card updates on its own. The code expires after 15 minutes.
- **Set as preferred** — When you have more than one connected account, marks the one the project reaches first. The project's notices (receipts, renewals, reminders, announcements) go to the preferred account and nowhere else unless you switch another account on.
- **Notify me here** — Shown on every account that is not the preferred one. Switch it on and that account hears the project's notices too, marked **Notified**; **Stop notifying here** switches it back off. Two notices always ignore these switches: an invite or access notice arrives on the platform the access is on, and a support reply arrives where you wrote from.
- **Also email me the project's notices** — In the **Email address** card, once you have a verified portal email or paid with one. Off, email is used only for a notice about your access or a payment when none of your connected accounts can be reached; on, every notice is emailed as well.
- **Disconnect** — Removes the account. It no longer signs you in here, and the bot no longer knows you by it. A confirmation dialog warns you first.
- **Use the same account** — Appears when you already use another platform's account in one of this creator's other projects. One tap connects that same account here; nothing is connected without your tap.
The recovery email used for **magic sign-in links**. It carries a badge next to it:
- **Verified** (green) — ready to use for sign-in.
- **Verification pending** (amber) — saved but not yet confirmed.
Actions available:
- **Add / Update email** — Enter an address and submit. A verification email is dispatched automatically (1-hour lifetime, single-use link).
- **Resend verification email** — Appears while the address is still pending. Limited to 3 sends per hour.
- **Remove email** — Clears the address and drops its verified status. You'll be asked to confirm first.
A verified email can only belong to **one account per project**. If the address is already verified on a different account in this project, the form will reject it. Try a different address or contact the project's support.
Shown only when the project has **Google OAuth** configured by the creator.
- **Connect** — Redirects you through Google's consent screen and links the account. Next time you sign in, **Continue with Google** becomes available.
- **Disconnect** — Unlinks the Google account. A confirmation dialog warns you first.
### Safety Guardrail
The portal will refuse to remove your **last remaining** way to sign in — a connected account, a verified email or a linked Google account. You'll see:
_"You must keep at least one way to sign in. Add another recovery method before removing this one."_
Add a replacement first — e.g., link Google before removing your email, verify a new email before disconnecting Google, or connect another account before disconnecting the only one.
## Managing Your Account
The portal gives you full control from the header dropdown:
- **My Memberships** — Shown when you have at least one subscription; jumps to the membership list.
- **Account & Recovery** — Manage recovery email and linked accounts.
- **Logout** — Securely sign out of your session.
If you cancel a subscription via Stripe or the bot, the status updates here automatically.
---
# Redeem an Access Code
Source: https://docs.subscriby.net/subscribers/redeem-access-code
By the end of this page, you'll know how to redeem an access code you've received — from the bot or from the portal — and understand what a code unlocks.
An **access code** is a unique code string a creator generates and hands out. It activates a specific subscription plan for you **without payment**. Creators often issue codes for:
- Offline sales (you paid via bank transfer, cash, or invoice — they give you a code).
- Giveaways and promotions.
- Influencer partnerships or team access.
- Exclusive invites tied to a specific plan not otherwise available to the public.
## What a code gives you
When you redeem a code:
1. A **new subscription** is created for you on the plan the code was generated against.
2. Access is granted for the plan's normal duration — e.g. a 30-day plan gives 30 days, a Lifetime plan gives lifetime.
3. **No auto-renewal.** Subscriptions activated by access codes are **non-recurring** — when the access period ends, you lose access unless you redeem another code or subscribe via a payment provider.
4. **Codes are single-use.** Once you redeem a code, it's marked used and can't be redeemed again.
Codes can **expire** before redemption — each has its own validity window set
by the creator. Redeem soon after receiving.
## How to redeem
**Open the creator's bot**
Open the creator's bot on your platform (the one whose project the code was generated for).
**Start the bot and agree**
If you haven't yet, tap **Start** and agree to the terms.
**Send the access code**
Send the access code as a regular chat message. A code is a long unique string — e.g. `a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d`.
**Subscription activates**
If the code is valid and unused, the bot activates your subscription immediately and sends you your unique invite links.
Often the creator shares the code as a **ready-to-tap link** — easier than copy-pasting the code. It opens the creator's bot with the code already attached.
**Tap the link**
Tap the link the creator shared.
**Bot greets and applies the code**
The platform opens the bot. The bot greets you and automatically applies the code.
**Agree to the terms if prompted**
If you haven't agreed to the terms yet, tap **Agree & Proceed** — the code is applied right after.
**Receive your invite links**
Your subscription activates immediately and you receive your invite links.
The deep-link path is the simplest for non-technical subscribers — one tap, no
copy-pasting, no typos.
If the creator has given you a portal URL instead of a bot link:
**Open the creator's portal**
Open the creator's portal page (e.g. `my.subscriby.net/project-handle`).
**Sign in**
Sign in — any of the supported methods works (your platform account, Google, verified email magic link). See [Via Web Portal → Signing in](/subscribers/portal#signing-in).
**Click Use Access Code**
Look for a **Use Access Code** button (near the plan list).
**Enter the code and redeem**
Enter your code and click **Redeem**.
**Join your resources**
Your subscription activates. The success page lists the resources your plan unlocked, with **Join** buttons for each.
Portal redemption is rate-limited to **5 attempts** per short window. If you
mistype a code a few times, wait a minute before retrying.
## When redemption fails
The code doesn't match any code the creator generated, or it's been typed wrong. Double-check for typos (especially `0` vs `O`, `1` vs `l`, case sensitivity). If it's still failing, ask the creator to confirm the code string — occasionally a code gets copied with a trailing space.
Codes are single-use. If this is a code you received second-hand, the original
recipient may have already redeemed it. Ask the creator for a fresh code.
Codes have expiry dates set by the creator. If yours expired before you
redeemed it, it's permanently unusable. Request a new code.
Some plans don't allow redeeming a code while you have an active subscription.
Let your current subscription end (or cancel it) before redeeming.
Make sure:
- The bot is connected and the project is active (ask the creator).
- You've actually tapped **Start** / **Agree & Proceed** before sending the code.
- The message is a plain text message, not a forwarded message or a message with extra characters.
If all else fails, try the [web portal's](/subscribers/portal) **Use Access Code** flow instead.
## After redemption
Once your subscription is active:
- The bot sends you your personal **access** to every place your plan unlocks. Tap each **Join** button.
- The portal shows the same resources on the subscription's detail page.
- You can always retrieve your invite links again via `/my_resources` (bot) or the **My Memberships** page (portal).
When the plan's duration ends, your access ends — you'll need to redeem another code or subscribe through a normal payment method to continue.
## Related
- [Choosing a plan](/subscribers/choosing-a-plan) — if you're comparing paid vs. code-based access.
- [Manage your subscription](/subscribers/manage-subscription) — what the code-activated subscription looks like afterwards.
- [On your community's platform → Access codes](/subscribers/on-the-platform#access-codes) — the same bot-side flow, with each connector's own steps.
---
# Redeem a Coupon Code
Source: https://docs.subscriby.net/subscribers/redeem-coupon-code
A **coupon code** is a discount code a creator shares — something like `BLACKFRIDAY`. Type it at
checkout and you pay less.
An [access code](/subscribers/redeem-access-code) is a private code
given to you personally, and it grants access without a payment. A coupon code
is shared with lots of people and reduces what you pay. If your code came with
a price attached, it is a coupon.
## In the web portal
### Pick your plan [step]
Choose the plan you want on the creator's portal page.
### Find "Have a Coupon Code?" [step]
On the checkout screen, open **Have a Coupon Code?** and type your code. Capitals do not matter.
### Check the new total [step]
The summary updates straight away — the old price struck through, the new total beside it, and how
much you saved.
### Pay [step]
Continue to payment as normal. The discounted amount is what gets charged.
## In the bot
### Start subscribing [step]
Choose your plan in the bot as usual.
### Tap 🏷 Have a Coupon Code? [step]
The button sits above the payment methods.
### Send the code as a message [step]
The bot reads your **next** message as the code. It replies with the old price, the new one, and your
saving.
### Choose a payment method [step]
Carry on and pay. The discount is already applied.
The bot only treats your next message as a coupon code after you tap that
button. An access code and a coupon code are indistinguishable as raw text,
and guessing between them would be worse than asking.
## The field only shows when there is something to redeem
If the creator has no live code covering the plan you are buying, the coupon field does not appear at
all. A checkout with no promotion running is not cluttered with a box nobody can fill — so its
absence does not mean your code is broken, it means there is no active promotion on that plan.
## Why a code might not work
| What you see | What it means |
| ----------------------------------------------- | ------------------------------------------------------------------------ |
| "That code is not valid" | Mistyped, expired, not started yet, or the creator switched it off. |
| "That code has been fully claimed" | The creator capped how many people could use it, and the cap is reached. |
| "You have already used that code" | It is limited per person, and you have had your turn. |
| "That code cannot be used on this plan" | It is restricted to other plans. |
| "That code only applies to plans priced in ..." | It is a fixed-amount code tied to one currency. |
| "That code applies to purchases of ... or more" | There is a minimum spend you are under. |
| "Coupon codes cannot be applied ... with ..." | That payment method cannot carry a discount on this plan — pick another. |
If a code says it is fully claimed, it is worth trying once more a little
later. Uses are held while other people's payments are in progress, and an
abandoned checkout returns its slot.
## The discount applies to your first payment
If you are subscribing to something that renews, the discount comes off the **first** charge.
Renewals are at the normal price. A "25% off" code means 25% off what you pay now — it is not a
permanent price change.
On a one-time purchase or a [time-limited pass](/subscribers/using-a-pass), there is only one
payment, so the whole thing is discounted.
## Questions
No — one code per checkout. Use whichever gives you the better price.
No. `blackfriday` and `BLACKFRIDAY` are the same code.
No — a code has to be applied before you pay. Ask the creator; refunds and
goodwill are entirely their call.
Most likely it hit its cap, or reached its end date. Both are set by the
creator, so they are the ones to ask.
Yes. Your [billing history](/subscribers/billing-history) records what
you were actually charged, not the plan's list price.
## Related
- [Choosing a plan](/subscribers/choosing-a-plan) — what you are buying.
- [Making payments](/subscribers/making-payments) — the payment methods available.
- [Redeem an access code](/subscribers/redeem-access-code) — the other kind of code.
- [Billing history](/subscribers/billing-history) — checking what you paid.
---
# Trial Memberships
Source: https://docs.subscriby.net/subscribers/trial-memberships
By the end of this page, you'll understand the two types of trials Subscriby supports, when you're eligible for a trial, and what happens when one ends.
A **trial** is a period where you have access to a creator's content without paying. If the trial converts, you continue as a normal subscriber. If it doesn't, your access ends automatically — no surprise charges.
## Two types of trials
**No payment method required up front.** You pick a plan, tap through a short confirmation, and you're in.
- You're not charged anything during the trial.
- When the trial ends, **access stops automatically** — you are _not_ auto-enrolled into the paid plan.
- To continue past the trial, you'd have to subscribe again with a payment method.
Best for: trying a creator's community without commitment, with zero financial friction.
**Payment method collected up front, no charge during the trial.** You set up the plan with a card (or equivalent), and the provider delays the first charge until the trial ends.
- You're charged at the end of the trial automatically for the plan's first billing cycle.
- **If you want to avoid the charge**, you must cancel _before_ the trial ends. See [Cancel a subscription](/subscribers/cancel-subscription).
- If you stay, your access continues as a normal paid subscription.
Best for: signing up with intent to stay — a card-required trial converts smoothly into a paid subscription with no break in access.
## Am I eligible for a trial?
Not every plan offers a trial — that's the creator's choice. When a plan does have a trial, eligibility depends on:
| Rule | How it works |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Trial type: "Once for project"** | You get one trial across _any_ plan in this project. If you've trialled before on a different plan, you're not eligible for this one. |
| **Trial type: "Once for plan"** | You can trial this specific plan once, even if you've trialled another plan previously. |
| **Current status** | You can't start a trial while you already have an active subscription on the project. Cancel or wait for expiry first. |
| **Cardless trial flag** | For a cardless trial specifically, the plan must have `trial_cardless` enabled. |
When you're **not eligible**, the trial option is hidden — you'll just see the plan's normal payment methods.
## How to start a trial
#### Sign in to the portal [step]
Sign in to the creator's portal.
#### Find a plan with a trial indicator [step]
On the **Plans** page, find a plan with a trial indicator (e.g. _"7 days free"_).
#### Click Start Free Trial [step]
Click **Start Free Trial** (or equivalent) on the plan card.
#### Review the cardless confirmation [step]
If the plan is eligible for a cardless trial and you qualify, a simple confirmation screen appears — no card form.
#### Confirm and get access [step]
Confirm. Your subscription is created with a **Trialing** status and access is granted immediately.
#### Sign in to the portal [step]
Sign in to the creator's portal.
#### Subscribe to the card-required plan [step]
Click **Subscribe Now** on a plan whose trial requires a card.
#### Pick a payment method [step]
Pick your payment method. The provider shows _"No charge today — your card will be charged on [date]"_.
#### Confirm and start the trial [step]
Confirm. Your subscription starts with **Trialing** status; you have immediate access.
Mark the trial end date in your calendar if you're unsure about staying. The
provider auto-charges on that date — no extra confirmation from Subscriby.
#### Start the bot [step]
Open the creator's bot and tap **Start**.
#### Pick a plan with a trial [step]
On the plan selection screen, pick a plan that offers a trial.
#### Confirm or set up payment [step]
Depending on whether it's cardless or card-required, the bot either confirms the trial immediately or sends you to a payment-method setup screen with the trial duration indicated.
#### Receive your invite links [step]
Once confirmed, the bot generates your invite links and you get into the resources right away.
## What "access" looks like during a trial
Access during a trial is identical to paid access:
- You're in every place the plan unlocks.
- Your subscription status reads **Trialing** instead of Active.
- The **trial end date** is shown on the subscription card on both the portal and the bot.
## When a trial ends
### Cardless trial
1. You hit the end date with no payment method attached.
2. Subscriby marks the subscription **Expired**.
3. You're automatically removed from the places.
4. You can subscribe again anytime through a paid payment method — but you can't trial the same plan again (eligibility rules).
### Card-required trial
1. You hit the end date with a payment method on file.
2. The provider automatically charges your first billing cycle.
3. On success, your status flips from **Trialing** → **Active** and access continues seamlessly.
4. On charge failure, the provider retries per its own policy; if retries are exhausted, your subscription lapses to **Past Due** or **Unpaid**, then **Expired**.
## Ending a trial early
If you don't want to continue, cancel before the trial end:
- **Cardless trial** — cancelling is optional (access ends automatically with no charge). Cancelling just marks the status **Canceled** for your records.
- **Card-required trial** — cancel **before** the end date to avoid being charged. See [Cancel a subscription](/subscribers/cancel-subscription).
## Common questions
Not from the subscriber side. Some creators can extend on your behalf using an [access code](/subscribers/redeem-access-code) on a $0 plan — ask the creator if that's possible.
For "Once for project" trial types, your trial eligibility is spent. Your
options: subscribe as a paid customer, or ask the creator for an access code.
Yes. Subscribe to the plan via a normal payment method. The paid subscription
starts immediately, running alongside your trial until the trial ends (at
which point the trial just goes away).
Some creators send trial-expiry reminders via the bot or email. Don't rely on
this — mark the date yourself if it matters.
That's a bot/resource connection issue, not a trial issue. See [Troubleshooting → Can't access a resource](/subscribers/troubleshooting).
## Related
- [Choosing a plan](/subscribers/choosing-a-plan) — spot plans that include trials.
- [Making payments](/subscribers/making-payments) — what happens when a card-required trial converts.
- [Cancel a subscription](/subscribers/cancel-subscription) — cancel before a card-required trial ends.
---
# Troubleshooting
Source: https://docs.subscriby.net/subscribers/troubleshooting
By the end of this page, you should have a fix — or at least a clear next step — for whatever's going wrong.
**Quick rule of thumb:** if something's broken, your fastest path is usually
to contact **the creator of the project**, not Subscriby support. The creator
controls their project, plans, and bot, and has the tools to fix most issues
directly.
## Payment issues
Most common causes:
1. **Card declined** — insufficient funds, expired card, or your bank flagged the charge. Try a different card or contact your bank.
2. **Country / provider mismatch** — some providers are region-specific (Paystack for Africa, Razorpay for India). If you're outside their supported region, pick a different provider.
3. **Currency conflict** — your bank may reject charges in certain currencies. Try another payment method.
4. **3D Secure** — your bank may require an extra verification step (SMS code or app prompt). Complete it and retry.
Your subscription is only activated after payment succeeds, so no access is granted until a successful charge goes through.
- **Fiat providers (Stripe, PayPal, etc.)** — usually within 1–5 minutes of
payment. If it's been more than 10 minutes, refresh your portal's **My
Memberships** page or restart the bot. - **Crypto providers (CoinPayments,
CeyPay)** — network confirmations take 20–60 minutes for Bitcoin; USDT on Tron
or BSC is typically much faster. Just wait. - Still no access after an hour?
Send the creator your payment provider transaction ID — they can look it up
and manually sync.
- Check **My Memberships** — if only one subscription is active, the second
charge is probably a pending authorization that will auto-release in a few
days. - If both charges fully posted, contact the creator with the second
transaction ID. They can refund via the payment provider. - Don't dispute with
your bank before asking — chargebacks cost the creator heavily and often
result in permanent bans.
The provider retries automatically for a while (typically 1–2 weeks depending on provider). You might see your subscription status as **Past Due** or **Unpaid** during that window. To fix:
- Update your payment method in the **provider's own customer portal** (Stripe Customer Portal, PayPal recurring payments, etc.).
- Or cancel and resubscribe with a working method from Subscriby.
## Access issues
Possible causes:
1. **Access links not yet received** — wait a minute, then tap **🔄 Refresh Invite Links** on the invite links message, or send `/my_resources` to the bot to retrieve them.
2. **You tapped the link from a different account** than the one tied to your subscription — switch to the correct account in the app, and tap the link again.
3. **The creator's bot lost admin rights** on the destination — the creator needs to re-add the bot as an admin. Let them know.
4. **You were removed by an admin** in a past session — in that case the creator needs to unban you before re-invitation works.
The creator lost the place or the bot — usually because the platform took it away — and Subscriby is moving them to a new one. You do not need to do anything: the moment it is back, a personal link to the new chat arrives in the bot and appears in the portal, and you are approved automatically. Your subscription and payments are untouched. See [What members see](/disaster-recovery/what-members-see).
Yes. On the portal, open **My Memberships → your active subscription** and tap
**Join** next to the resource that shows you're not a member. On the bot, send
`/my_resources` — it sends fresh single-use invite links.
Paying a pass does not admit you — access starts when the **window** you bought opens. Your
confirmation names that window, and the portal's confirmation screen shows it too. Until then
your join request is deliberately held: the platform shows it pending and the place won't appear
yet. See [Using a pass](/subscribers/using-a-pass).
If the window has already opened and you're still outside, or your invite links never arrived
at all, tap **🔄 Refresh Invite Links**. If that doesn't work, tell the creator — their bot
most likely lost the **Invite Users via Link** right on the channel, and they're notified
automatically when a paid purchase produces no access.
Common reasons:
- **Your subscription expired or was cancelled** and you're past the cycle-end date.
- **Your payment failed** and the bot removed you as part of the Unpaid flow.
- **The creator's admin banned you manually** for reasons unrelated to payment (rule violation, spam).
- **A temporary access-check hiccup** — rejoin from My Memberships and see if it sticks.
Invite links are **single-use** and **unique to you**. If someone else used
your link (or you used it twice), the platform considers it spent. Open the portal
or send `/my_resources` to the bot for a fresh link.
No. Invite links are personal. If someone else joins with your link, the bot may remove your access as a security precaution — it looks like credential sharing.
## Sign-in issues
Check:
- **Spam / Promotions folder** — magic links sometimes land there.
- **Email is correctly typed** — the portal returns the same message whether the email is known or not, so there's no error feedback if you typo.
- **The email is verified** — only verified recovery emails can receive sign-in links. To verify, sign in with your platform account first, add your email in Account & Recovery, then click the verification link.
- **Rate limit** — you're capped at 3 magic-link requests per hour per email + IP combination. Wait and try again.
Platform sessions are per-device. Make sure the account logged in on your
device matches the one you originally subscribed with. Switch accounts in the
app and retry.
Google login on the portal only appears when:
- The creator has enabled Google OAuth for their project.
- You've already linked your Google account from **Account & Recovery** (signed in with your platform account first, then linked Google).
If Google isn't listed in your Account & Recovery options either, the creator hasn't enabled it on this project.
This is exactly what verified recovery emails and linked Google accounts are for — see [Via Web Portal → Account & Recovery](/subscribers/portal#account--recovery). Set these up **before** you lose access to the platform, not after.
## Plan and subscription issues
Creators can restrict plans by audience — Newcomers-only, Customers-only, Churned-only, Single-use, Access-codes-only. You only see plans you're eligible for. Ask the creator if you believe the restriction should not apply to you.
Your last scheduled charge failed. See the *"My recurring subscription renewal
failed"* item above.
That's expected — cancellation stops future renewals but keeps your current
paid access until the cycle ends.
There's no one-click upgrade. [Cancel](/subscribers/cancel-subscription) your current subscription, then subscribe to the new plan. Your current access continues in parallel until your old cycle ends.
## Access code issues
See [Redeem an access code → When redemption fails](/subscribers/redeem-access-code#when-redemption-fails) for the specific error cases (invalid, already used, expired, already subscribed).
The code activates the plan's normal duration — if the creator issued a code for a 30-day plan, you get 30 days. Check with the creator if you expected a different duration.
## When to contact the creator
**How:** message the project bot. Type your question as an ordinary message — anything the bot does not recognise as a command reaches the creator, and their reply comes back in the same chat prefixed with their name. See [Contacting support](/subscribers/contacting-support).
Reach out to the creator directly for:
- Refunds or disputes on specific charges.
- Plans you expected to see but don't.
- Content access questions ("when will the next live session be?").
- Moderation or ban questions.
- Custom access needs (extended trial, gifted subscription, access codes for a team).
The creator can see your full subscription record and has the tools to make most fixes directly — often faster than going through support.
## When to contact Subscriby support
Reach out to **Subscriby** (`support@subscriby.net`) for:
- Security incidents you suspect affect the platform broadly.
- Bugs in the portal or bot that the creator can't reproduce.
- Privacy / data deletion requests that the creator isn't able to fulfil.
- Anything you believe is a platform-wide issue.
For individual subscription issues, the creator is always the right first stop.
## Related
- [Manage your subscription](/subscribers/manage-subscription) — main subscription view.
- [Cancel a subscription](/subscribers/cancel-subscription) — stop future renewals.
- [Contacting support](/subscribers/contacting-support) — how to reach the creator.
- [Via Web Portal](/subscribers/portal) — full portal guide.
- [On your community's platform](/subscribers/on-the-platform) — the full bot guide, per connector.
- [What members see during a recovery](/disaster-recovery/what-members-see) — when a creator's channel or bot is being replaced.
---
# Using a Pass Series
Source: https://docs.subscriby.net/subscribers/using-a-pass-series
A **pass series** is a season ticket. One payment gets you a whole slate of dated
sessions — ten match days, an eight-week course, a weekend of talks — and each one lets you
in and out on its own schedule.
Everything on [Using a Pass](./using-a-pass) still applies to each individual date: you join a
queue, you're admitted automatically when the window opens, and you're removed when it closes.
The difference is that you did all your buying once, and there is now a **timetable** rather
than a single date.
Access is granted and removed **per date**, not for the whole span. Between
sessions you are not in the channel, which is exactly how it works for
single-date passes.
If the creator has set up a season-ticket-holders' lounge, that one **is** yours
for the whole time. Its link arrives with the first date's and then stays put.
## What happens, in order
### You buy the whole slate [step]
The plan card shows what you're getting before you click anything: **N passes included**, the
date span it runs across, the first few dates with their times, and **+N more passes**. If the
creator capped the seats you'll also see **N seats left**, or **Sold out** once they've gone.
At checkout every date is listed under **What you get**. Nothing is hidden behind a "and
more" — you can read the whole season before paying for it.
### Your dates are issued straight away [step]
You don't claim them one at a time. The moment the payment clears, every date on the slate is
yours and the whole timetable appears in the portal and on the bot.
Your **invite links** are a separate thing, and they arrive later — about **24 hours before
each date opens**, one date at a time. That is deliberate: a season can run for weeks, and a
link minted the day you bought would be long dead before the date it was for. So a slate that
is mostly buttonless in its first week is working correctly, and each row tells you when its
link is due rather than leaving a gap.
There is no link to tap at checkout and none in the confirmation message, and
that is not something going wrong. The first one reaches you a day before your
first date, attached to the reminder — see [When your links
arrive](#when-your-links-arrive).
If you had already bought one of these dates on its own, it is **not** issued
again and you are **not** charged for it twice. Your timetable marks that row
_"You already held this pass before buying the series, so it was not issued
twice."_ — so a greyed-out row is not an error.
### You get a timetable [step]
Send `/start` to the bot and open your series from the **Your passes** block — the button
reads **My Timetable**.
It lists every date with a marker showing where it stands:
| Marker | Means |
| ------ | ------------------------ |
| 🔴 | Running right now |
| ➡️ | The next one up |
| ✅ | You joined this one |
| ☑️ | Finished |
| ⚪️ | You missed it |
| • | Scheduled, further ahead |
Each row reads **Pass 3 of 10**, then its window, then which of the creator's plans that date
came from — a series can mix them — and your join status for it. Long seasons are paged, with
**Previous** and **Next**.
Above the list sits your progress — _"6 passes left of 10"_ — and the timezone every time on
the page is written in.
The **portal** shows the same thing as a **Your passes** timeline under your membership, with
each entry labelled **Live now**, **Up next**, **You attended** or **Missed**, and running
**N attended** / **N missed** counts once dates start finishing.
### Each date lets you in, then lets you out [step]
For every date individually: tap the invite link to join the queue, and you're admitted
automatically when that window opens — whether or not you're at your phone. When it closes
you're removed, and the next date's link is sent in the same message that tells you the
session ended.
That last part is the bit worth knowing. **Tap it there and then**, days before it matters.
Your request sits pending until the window opens, and you never have to go hunting for a
message from last week.
You will leave the channel after every session. That is not your season ticket
failing — it is the same gating a single-date pass uses, applied ten times.
Your next date is already yours, and the timetable will show it.
### Reminders come per date [step]
Each date gets its own ladder: about **a day** before it opens, and again about **an hour**
before. If you're already queued they simply confirm there's nothing to do. If you're not,
your invite link is attached as a button.
You're not reminded about the season as a whole, because a season is not a thing you turn up
to — the individual dates are.
## When your links arrive
Nothing is issued when you pay. Each date's invite link reaches you **about 24 hours before
that date opens**, attached to the first reminder — the reminder and the link are the same
message, so there is nothing separate to go looking for.
Two things can bring one earlier:
- **A session ending.** The message telling you a date is over carries the link for your
**next** one, however far away it is. Tap it there and then.
- **Buying late.** If you buy inside the 24-hour window, your link follows within a minute or
two rather than waiting.
Tapping early never costs you anything. The join request simply sits pending until that window
opens — days, if that is how far off it is — and is approved automatically when it does.
Each entry on your timetable says when its link is due — _"Your invite link
arrives Tue 1 Sep, 10:30."_ — so you can tell "not yet" apart from "something
is wrong". If a date has opened and you still have no link, see
[Troubleshooting](./troubleshooting).
## Put it in your calendar
Your membership in the portal offers **Add to your calendar**: one link that puts every date
of the season into Apple Calendar, Google Calendar or Outlook, with a reminder an hour before
each one opens.
Subscribe once and the whole slate is there. If a date is added or moved later, your calendar
picks it up on its own.
It works without a login, so anyone you send it to can see your dates. If you
share it by mistake, use **Shared this by mistake? Get a new link** — the old
address stops working immediately and you resubscribe with the new one.
The block disappears once every date has run. There is nothing left to put in a calendar at
that point, and your timetable is where a finished season is read.
## When the season changes under you
Seasons are not always fixed at the point you buy. Three things can happen, and you're
messaged about all of them.
### A date is added
Some creators set their series to take in new dates automatically as they're scheduled — a
fixture added mid-season, an extra class. If yours does, the new date is **granted to you at
no extra charge** and the bot tells you:
> **A new pass was added to your series**
> Nothing to do now — your invite link arrives before it opens, as usual.
It appears on your timetable and in your calendar feed like any other.
### A date is moved
If the creator cancels one of your dates and a replacement is available, you're **moved to
it** and told both the old date and the new one. If you'd already queued for the old one
there's nothing to do; if you hadn't, your invite link for the new date comes with the
message.
Your season carries on at the same length.
### A date is dropped
If there's no replacement, that date simply leaves your season and the rest is untouched. The
message names the date that was cancelled, where you now stand, and roughly what that date was
worth as a share of what you paid.
Subscriby works out what the date was worth and shows it to you. It does
**not** move any money — nothing is charged or refunded automatically. If you
want that share back, contact the creator; refunds are always theirs to issue.
If the dropped date was the last one you had left, you're told that your series has now ended.
## After the season finishes
A finished season doesn't disappear. **My Memberships** in the portal keeps a **Past** section
below your active memberships, listing every pass and series that has run.
Each row shows the span it covered and your **N attended** / **N missed** counts. Opening one
gives you the full timeline exactly as it looked on the day — which dates you made, which you
didn't, and what you paid.
Only dated purchases are kept there. An ordinary subscription that lapsed isn't listed, because
there was nothing to attend.
## If the creator runs another season
Creators often link a follow-on season, and when they do, **holders get first refusal on it**.
The moment your season finishes you're messaged: the next one is open to you before anyone
else, for a number of hours the creator set, with a link straight to it. Until that window
closes, nobody who didn't hold this season can buy it.
Your season having expired is the normal case by the time this arrives — that
is not what disqualifies anyone. Only cancelling your season partway through
takes you off the list.
If you're not interested, do nothing. The window closes on its own and the season goes on
general sale.
## Reading the times
Every time on your timetable is shown in **the creator's timezone**, with the zone stated at
the top of the message and on the portal timeline. They are **not** converted to yours.
If you're in another country, work from the countdowns rather than the clock times — those
are always relative to right now, wherever you are.
## Questions
No. Every date is yours the moment you pay. What you do per date is tap that
date's invite link to join its queue — the link reaching you about a day
before, or sooner if the message ending your previous session handed it to
you.
Because a season ticket is a set of dated sessions, not continuous access.
Each date admits you when it opens and removes you when it closes, exactly as
a single-date pass does. Your next date is already yours.
No. That date is not issued twice and you aren't charged for it twice — your
timetable marks it as already yours. It also disappears from that plan's
individual date picker while your series covers it.
No. Each date stands alone. The missed one is marked **Missed** on your
timetable and everything else runs exactly as booked.
There is nothing for you to cancel — nothing renews, and the season ends on
its own when its last date closes. If you can no longer attend, contact the
creator. Bear in mind that cancelling also takes you off the holders-only
presale list for the next season.
No. Your dates are tied to your account and admission is by your own held join
request. Contact the creator if you need to work something out.
No — it's a feature the creator switched on. Series can be set to absorb new
dates automatically, and you get them at no extra charge. The bot messages you
each time one is added.
It's what that date was worth as a share of what you paid for the whole season, worked out for
you. It is **information, not a refund** — no money moves automatically.
Contact the creator if you want it back. Refunds are always issued on their side, from their
payment provider.
Only if the creator allows it. When they do, you pay the **full price** for
whatever dates are left — nothing is prorated — and dates that have already
run are never issued to you. Most creators close sales before the first date
instead, in which case the season simply is not listed once it has begun.
Yes. A series, individual passes and an ordinary subscription are all
independent, each with their own invite links, and each is listed separately
in the portal and the bot. If two of them grant the same channel, you keep
your seat as long as any one of them still does.
Into the **Past** section of My Memberships, with your attended and missed
counts. Open it for the full timeline. Nothing is deleted.
If the creator has linked one, you're messaged the moment your season ends —
it's open to holders before anyone else, with a link straight to it. Otherwise
watch their plan list as usual.
## Related
- [Using a Pass](./using-a-pass) — how each individual date works
- [Choosing a plan](/subscribers/choosing-a-plan) — how a series differs from the other plan types
- [Manage your subscription](/subscribers/manage-subscription) — where your seasons are listed
- [Troubleshooting](/subscribers/troubleshooting) — access problems that aren't the queue
---
# Using a Pass
Source: https://docs.subscriby.net/subscribers/using-a-pass
A **time-limited pass** gets you into a channel for a window the creator scheduled — a
Sunday slate, a market-open room, a single live class. Unlike an ordinary subscription, it
does **not** start when you pay.
The practical difference is that joining and being admitted are two separate moments. You
join whenever you like; you're admitted when the window opens. The bot tells you both times
at every step, so you are never left guessing.
## What happens, in order
### You buy a date [step]
At checkout you pick which window you want. The next available one is preselected, and any
date you already hold is hidden so you can't buy the same window twice.
Most passes close sales shortly before a window starts, so one that's about to begin may no
longer be listed. Some creators keep theirs on sale **while the window is running** — those
show up marked **On now**, and buying one lets you in straight away.
### You get a confirmation and an invite link [step]
The bot confirms the purchase and names the plan, the exact window you bought and how long
until access opens. It also tells you your invite links are coming in the next message, and
that you can tap them straight away — your join request is held and approved automatically the
moment access opens.
Your invite links arrive in that second message. The **portal** shows the same window on its
confirmation screen, so you can check the date without going back to the app.
The invite link works immediately — but joining is not the same as being let in.
### You can check it any time [step]
You don't have to keep that message. Send `/start` to the bot again and a **Your passes**
block sits above the plan list, with a **My pass** button for each one you hold. Opening it
shows what you paid, the exact window, a countdown, and whether your join request is in the
queue.
The web portal shows the same thing: your window and countdown appear on the plan card next
to **Manage**, and again under **My Memberships**.
Every one of those screens says one of two things, and the difference matters:
- **In the queue** — you tapped your link. Nothing more to do; you'll be let in
automatically when the window opens.
- **Join request not sent** — you haven't tapped it yet. Nothing is waiting to
be approved, so you'd have to notice a message on the day and act on it.
If you see the second one, tap your invite link now. It costs nothing and takes
the timing out of your hands.
### You tap the link and join the queue [step]
Tapping the link sends a join request that is **held**, not approved. The platform shows your
request as pending, and the place won't appear in your list yet.
The bot replies immediately to confirm you're in the queue, and lists when access opens, when
it ends and how long you'll have. It also makes the point that you don't need to do anything
else or be online at the time — you'll be added the moment the window opens.
Your place is already held, so there's no need to tap the link again, cancel
the request, or ask for a new link.
### You're reminded before the window opens [step]
The bot messages you about **a day** before the window opens, and again about **an hour**
before. Both name the plan, the window and an exact countdown to the opening, so you can plan
around it without doing the arithmetic yourself.
What the reminder asks of you depends on where you stand:
- **Not in the queue yet?** It carries your invite link as a button, so joining is one tap.
- **Already queued?** It says outright that there is nothing to do, and that the window goes
ahead as scheduled unless the organiser cancels it. No buttons, because tapping a second
time would only produce a request the platform refuses.
A milestone that had already passed when you bought — a pass bought two hours before its
window never gets a day's notice — is skipped rather than sent late.
The organiser can also send one by hand, at any point before the window opens. It arrives
headed **Reminder from** their project name and says outright that it was sent manually, so a
nudge landing outside the usual times is not the bot misfiring. It carries your invite link
exactly like the automatic ones, and is only sent to people who haven't queued yet.
Passes don't use the 7/3/2/1-day expiry ladder that ordinary subscriptions do.
A window is often shorter than a day, so the reminders are keyed to it opening
instead.
### The window opens and you're admitted [step]
At the start time your request is approved automatically. You do not need to be online,
awake, or holding your phone. The channel simply appears.
The bot tells you the moment it happens. The message names the plan and the window, counts
down to when access ends, and carries a **Join** button for every channel or group the pass
covers — so getting to what you paid for is one tap from the notification, not a hunt through
older messages. It also makes the point that access is removed on its own when the window
closes, so there is nothing to cancel.
If you never tapped your link, that message is your invite arriving fresh — but you then have
to be at your phone to use it, which is exactly what joining the queue early avoids.
### The window closes and the pass ends [step]
You're removed and the pass is marked complete — **unless another pass of yours, or an
ordinary subscription, still grants that same channel.** Removal is decided per channel, so
holding a monthly membership as well means you keep your seat.
## If the organiser cancels your window
Sessions get postponed. When the organiser cancels a dated window, you are **moved to their
next available date on the same plan** — you don't lose what you paid for, and you are not
charged again. Every window on a plan costs the same, which is what makes the move an even
swap rather than a downgrade.
You'll get a message naming the plan, the date that was cancelled, the date you now hold, and
when access opens. What it asks of you depends on whether you had already tapped your invite
link:
- **Already in the queue?** Nothing to do. Your place moves with you and you'll be admitted
automatically on the new date.
- **Not yet?** The message carries your invite link as a button — tap it to queue for the new
date, exactly as you would have for the old one.
You are never moved onto a date you already hold a pass for. If you had bought both the 29th
and the 5th, you'd be moved to the one after that instead.
When the organiser has nothing else scheduled, your pass ends instead. You're
told which date was cancelled, that the pass has ended, and that nothing
further will be charged.
Refunds are the organiser's to issue, not Subscriby's — message them directly.
## Put it in your calendar
On the portal, your membership offers **Add to your calendar**: a link that drops the window
into Apple Calendar, Google Calendar or Outlook, with a reminder an hour before access opens.
Worth doing even for a single date. You tap your invite link days early and then there is
nothing to do until the window opens — which is precisely when a date gets forgotten. The bot
reminds you too, but the calendar is where you plan around it.
It works without a login, so anyone you send it to can see your dates. If you
share it by mistake, use **Shared this by mistake? Get a new link** — the old
address stops working immediately and you resubscribe with the new one.
## After it has run
A finished pass does not vanish from the portal. **My Memberships** carries a **Past**
section below your active memberships, listing every pass and series that has run. Opening
one shows what you paid and the window it covered.
Only dated purchases are kept there. An ordinary subscription that lapsed is not listed,
because there is nothing to have attended.
## If you bought a whole season at once
Some creators sell a **[pass series](./using-a-pass-series)** — a season ticket covering many
dates for one payment. Everything on this page still applies to each date individually, but
you also get a timetable covering every date, and messages when one is added, moved or
dropped. That has [its own page](./using-a-pass-series).
## Reading the times
Window times are always shown in **the creator's timezone**, with the zone beside them —
"Sun 21 Sep 2026, 09:00 – 23:00 EDT". Zones without a common abbreviation are written as an
offset instead, so Colombo reads `GMT+5:30` rather than something unrecognisable.
A countdown runs alongside it and is the part to trust if you are in another country. Beyond
a day it counts down in units — `2d 14h 32m` — and inside the final day it becomes a clock,
`14:32:08`, so an imminent window reads like one.
Times are **not** converted to your own timezone anywhere. Both the portal and
the bot show the creator's zone only, so if you are somewhere else, work from
the countdown rather than the clock time.
## Questions
Almost certainly not. Check the window's start time on your confirmation — if
it hasn't arrived yet, you're queued and will be let in automatically. If the
start time has passed by more than a few minutes, contact the creator.
Because you haven't joined the queue yet, and that's the one thing a pass
needs you to do. Tap the link once — from the confirmation, the reminder, or
the portal — and the asking stops. You'll still get the reminders that the
window is coming, but they'll simply confirm you're set rather than ask
anything of you, and you'll be admitted automatically when it opens, whether
or not you're online.
Your purchase is safe either way — payment and access provisioning are separate steps, and a
missing link never means a lost payment.
Send the bot **🔄 Refresh Invite Links** (it's a button on the invite links message, and on
`/my_resources`). That revokes anything stale and issues a fresh set.
If that doesn't work, the usual cause is on the creator's side: their bot needs to be an
administrator with the **Invite Users via Link** right on the channel. They are messaged
automatically when a paid purchase produces no access, so they may already be fixing it —
but it's worth telling them.
On the portal, the confirmation screen waits about a minute for the links, then says so
plainly rather than spinning. Some plans have no channels to join automatically at all, in
which case it tells you the creator will arrange access directly.
No. If your payment confirms after the window has opened, you're admitted
within about a minute and keep whatever is left of it — pay at 11:01 on an
11:00–14:00 window and you get just under three hours. You don't need to do
anything, though tapping your invite link lets you in immediately.
You're automatically moved to the next available window for that pass, so your purchase
isn't wasted — and the bot messages you to say which window you missed and which one you now
hold. If that date doesn't suit you, contact the creator about a refund.
If the creator has no further windows scheduled, you're told that plainly and asked to
contact them for a refund. You'll not have been given access to anything.
Every window on a plan costs the same, which is why moving you is fair — creators who price
their windows differently put each on its own plan. Creators can also close sales a set time
before each window to avoid this entirely, so a window that's about to begin may already be
unavailable to buy.
Not in one purchase. Each pass covers one window, so attending four dates
means four purchases. You can hold all four at the same time.
There is nothing for you to cancel — no payment renews, and the pass ends on
its own when the window closes. If you bought the wrong date or can no longer
attend, contact the creator: they can cancel it for you, which also puts that
date back on sale so you can rebook it. See [Cancel your
subscription](/subscribers/cancel-subscription).
Your access ends immediately. You are removed from the channels the pass covered, your invite
link stops working, and you will **not** be admitted when the window opens — even if it is
still days away.
The bot messages you when this happens, naming the plan, the amount refunded and the access
window you no longer hold. Refunds usually reach your payment method within **5–10 business
days**, depending on your bank; Subscriby doesn't hold the money at any point, so that timing
is between your bank and the creator's payment provider.
If you didn't expect the refund, contact the creator — it was issued on their side, not
automatically.
Not automatically, and this is worth knowing. Cancelling removes your access and stops you
being admitted when the window opens, but it does **not** currently send you a message — so
your invite link may still look valid while the pass behind it is no longer live.
A **refund** does message you. If your pass disappears from **My Memberships** without
explanation, ask the creator whether it was cancelled and whether a refund is coming.
You'll be messaged and either moved to the next available window
automatically, or — if there isn't one — your pass is ended and you're told to
contact the creator for a refund.
No. A pass is tied to the date you bought, so an unused window expires with
it. Buy the pass again for a future date.
Sometimes. If the creator keeps their windows on sale while they run, one
already in progress appears marked **On now** — buy it and you are admitted
straight away, keeping whatever is left. It costs the same as buying it in
advance, and it still ends when that window closes.
Most creators close sales before a window opens instead, in which case it
simply will not be listed once it has begun.
Yes. They're independent, each with their own invite links, and each is listed
separately in the portal and the bot.
## Related
- [Using a Pass Series](./using-a-pass-series) — when one payment covers a whole slate of dates
- [Choosing a plan](/subscribers/choosing-a-plan) — how a pass differs from the other plan types
- [Manage your subscription](/subscribers/manage-subscription) — where your passes are listed
- [Troubleshooting](/subscribers/troubleshooting) — access problems that aren't the queue
---
# Subscriptions
Source: https://docs.subscriby.net/subscriptions
## Overview
Subscriby offers a flexible range of subscription plans designed to scale with your business—from pay-as-you-go options to custom, enterprise-ready solutions. Select the plan that best aligns with your business goals. Crucially, all subscription plans include **uncapped revenue** and **unlimited sales**, ensuring no limits on the revenue you can generate.
### Key Features & Limits
Below is a breakdown of the key features and limitations associated with Subscriby plans. All plans support an **unlimited** number of recurring subscribers.
- **Number of Bot Projects**: The maximum number of simultaneous bot projects you can manage. All plan restrictions and limitations are shared between the total number of membership bot projects you create and maintain.
- **Number of Lifetime Memberships**: The total allowable number of lifetime non-recurring memberships you can sell. This limit is valid for the lifetime of the account and applies to the designated limit of your plan plus new subscribers joining from the linking date of the channel, group, or supergroup.
A "lifetime membership subscriber" is a customer who purchases a
"non-recurring" subscription plan with validity set to "lifetime"
(essentially a lifetime-valid, one-time paid customer).
- **Custom Handle & Public Portal**: The URL slug your public portal answers on, e.g. `my.subscriby.net/your-community`. Available on **Starter** and **Growth**; the Free plan gets an auto-assigned handle instead of one you choose.
- **Number of Access Codes**: The quantity of free access codes allocated per billing cycle from the first day of the cycle. An annual plan's allocation covers the whole year, since the billing cycle _is_ the year.
Subscriby does not charge transaction fees for the allocated free access
codes. An access code is considered "used" when redeemed by a customer (not
when generated). Unredeemed codes do not count toward your quota. Unused
codes do not carry over to the next cycle. Exceeding the free allotment
incurs standard transaction fees at the rate applicable to your plan.
- **Free Subscription Plans**: Pricing one of your own plans at **0**, so members join and keep access without paying. Included on **Starter** and **Growth**. The Free plan cannot sell one — every platform fee is a share of what you charge, so a zero-priced plan earns nothing to share — and there is no addon for it.
To give access away on any plan, use access codes instead. A member redeems
a code rather than paying, and every plan includes a free allotment each
cycle. See [Access codes](/payments/access-codes).
- **Coupon Codes**: Percentage or fixed-amount discounts on your own plans, with expiry dates, redemption limits and per-plan restrictions. Included in the **Growth Plan**. On the **Free** and **Starter** plans it is available as the **Coupons Addon** — $5 / mo, or $50 / yr on an annual Starter plan.
Letting the entitlement lapse stops existing codes discounting, not just the
authoring of new ones, so wind promotions down before dropping it. See
[Addons](/addons/coupons).
- **Teams & Collaborators**: Inviting other people into your account, assigning roles and splitting permissions. A **Growth Plan** feature — on Free and Starter you manage your projects alone. See [Teams & Roles](/teams).
- **Transaction Fees**: The commission taken on each subscription your members buy — **10 %** on Free, **3 %** on Starter, **1 %** on Growth. This is usually the largest difference between the plans at any real volume; see [Transaction Fees](/fees).
During a free trial your sales stay on the Free plan's 10 % rate, and the
access-code allotment stays at the Free plan's 5 per cycle. Both switch to
your plan's own figures once the first payment lands — the trial buys the
plan's features, not the money side of it.
- **White-Label Branding**:
- **Starter Plan**: "Powered by Subscriby" branding is removed from the membership bot and bot responses but remains on the membership bot portal page.
- **Growth Plan**: Branding is removed from all areas.
Custom branding options are not available on any plan at this time.
- **Time-Limited Passes**: Selling access to a scheduled window rather than by the month. Included in the **Growth Plan**. On the **Free** and **Starter** plans it is available as the **Passes Addon**, charged on the same billing cycle as your plan — $19 / mo, or $190 / yr on an annual Starter plan. The Free plan is monthly-only, so the addon is always the monthly price there.
Adding the addon is immediate and prorated; removing it takes effect at the
end of the period you have already paid for, with no refund. Moving up to
Growth drops the addon automatically and credits the unused time. See
[Addons](/addons/passes).
- **Disaster Recovery Program**: When a place, a bot or your connected account becomes unreachable through no fault of your own, Subscriby detects it in seconds when a sale or a join fails, and by health checks that run every hour (every 15 minutes on Growth), alerts you and moves every paying member to the replacement automatically. When a platform refuses your bot outright, the outage is recorded and, once it ends, [Outage Compensation](/disaster-recovery/connector-outages#outage-compensation) banks the lost time on every affected member's purchase. Included on **every plan**, including Free; each kind of recovery is self-service once per 90 days, and support reviews the account beyond that. See [Disaster Recovery](/disaster-recovery).
- **Active Disaster Prevention**: The safeguards that turn a recovery into one click or no clicks at all — a backup account, a standby bot, standby places with a live mirror of every post, and automatic failover. A **Growth Plan** feature. See [Active Disaster Prevention](/disaster-recovery/active-disaster-prevention).
### Subscribing to a Plan
Before using Subscriby services, you need to subscribe to a plan. To subscribe to a plan, go to **Plans & Billing** page and then follow the steps below:
1. Review the features and limits of available plans.
2. Select the desired plan by clicking "Try Free for 7 Days" on a paid plan, or "Subscribe to [Plan Name]" if your trial has already been used.
3. Complete the initial payment via our secure payment processor.
Upon successful payment, you will be redirected to the Subscriby dashboard, where you can view your subscription status and currently accumulated [usage-based fees](#transaction-fees).
Every paid plan — monthly or annual — starts with a **7-day free trial**, once
per account. Your card is collected at checkout but nothing is charged until
the trial ends.
During the trial your sales stay on the Free tier's **10% rate** rather than
your plan's 1%–3% — the reduced rate is what the first payment buys, and it
applies from the moment that payment is received. A banner on your dashboard shows this while it runs, with a button
to end the trial and start the plan immediately if you would rather pay now and
get the lower rate. See [Transaction Fees](/fees#during-your-free-trial).
You can add an [addon](/addons) while your trial runs. You are **not
charged at that moment** and your **trial is not cut short** — the feature
works right away, and the addon starts billing when your plan does, on the
same first invoice. Addons are fixed charges, so they do not change your
transaction fee rate either.
#### Transaction Fees
Transaction fees are usage-based charges considered part of your subscription cost. For detailed information on fee structures, billing methods, and currency conversions, please refer to the [Transaction Fees](/fees) documentation.
#### Payment Processors
Subscriby offers all available payment processors to all projects you create. However, gateway or feature availability may vary based on location and other factors.
Some payment processors may require a business registration certificate.
## Managing Your Subscription
### Changing Payment Methods
Update your payment method anytime via the billing portal. Navigate to **Plans & Billing** > **Go to Billing Portal**.
You cannot remove your only active payment method without cancelling your
subscription.
### Upgrading or Downgrading
You can switch plans at any time. When you change your plan, the new transaction fee rates apply immediately. Previous transactions are not recalculated.
#### Upgrading to a Higher Tier
When you upgrade to a higher-priced plan, the change takes effect immediately. Here's exactly what happens:
1. Your current plan is immediately ended, and the new plan begins.
2. We calculate a credit for the unused time remaining on your current plan.
3. It is then deducted from the full cost of the new, higher plan rate.
4. No credits will be issued to your account since you are upgrading to a higher plan.
5. Only the difference between these amounts is charged to your payment method within a maximum of 1 hour from the plan change being performed.
If you're halfway through a $39/month plan and upgrade to $89/month, you get a
$19.50 credit (unused half of $39), it is then deducted from the $89/month
rate = $69.50 net total. You're only charged $69.50.
Your [addons](/addons) move onto the new plan automatically. If the plan
you are upgrading to **already includes** what an addon unlocks, that addon is
removed in the same operation and its unused time is credited against the
upgrade — so you are never billed twice for the same feature.
#### Downgrading to a Lower Tier
When you downgrade to a lower-priced plan, your plan does not change immediately and no cash refund or prorated credits are issued. Instead:
1. Your downgrade is scheduled to take effect at the end of your current billing cycle.
2. You will continue to enjoy the benefits of your current, higher-priced plan until the billing cycle ends.
3. **Important:** New resource creation limits based on the downgraded plan are enforced immediately to ensure a smooth transition.
4. When your new billing cycle starts, you will be billed for the new lower-priced plan.
Halfway through a $89/month plan, you downgrade to $39/month. You continue to
use the $89 plan features for the rest of the month, but your limits are
restricted to the $39 plan immediately. Next month, your subscription renews
at the $39 rate.
If the lower plan drops a feature you are actively selling on to your own
customers, the downgrade is **blocked** rather than allowed to break those
sales. Where an [addon](/addons) can carry that feature onto the cheaper
plan, you are offered it as the way through — it starts at the same moment the
cheaper plan does, so you never pay for both at once.
#### Downgrading to Free Tier
The Free tier does not include free plans, so a downgrade is refused while any
of your subscription plans are still priced at 0. Give them a price or delete
them first. Members who already joined on one keep their access. A zero-priced
plan that is **off sale** stays editable on any plan — rename it, re-scope it,
price it — but it cannot be put **back** on sale without either a price or a
move up to Starter or Growth. See [Subscription plans](/creators/plans).
When downgrading to the free tier, the process is exactly the same as downgrading to a lower-priced plan:
1. Your downgrade is scheduled to take effect at the end of your current billing cycle.
2. You keep your paid features until the current billing cycle expires.
3. Free tier limits are enforced immediately to ensure a smooth transition when the cycle ends.
Halfway through a $89/month plan, you downgrade to the free tier. Your
subscription will not be renewed next month. However, any metered transaction
fees incurred during the free tier will be billed accurately at the end of the
next cycles if you exceed your complimentary quotas.
#### Switching Plans During Your Trial
Switching plans while your free trial is still running behaves differently, because nothing has been paid yet:
1. **Your remaining trial days carry over to the new plan.** A trial that ends on the 14th still ends on the 14th, whichever plan you move to.
2. **Nothing is charged at the switch, and nothing is prorated.** There is no paid time to credit or bill for.
3. **Your sales stay on the Free tier's 10% rate** until the first payment is received — switching does not bring the new plan's reduced rate forward.
4. Your first payment is then taken at the original trial end date, at the new plan's price.
You start a Starter trial on the 1st, ending on the 8th. On the 4th you
upgrade to Growth. You are charged nothing on the 4th, you get Growth's
features straight away, your sales stay on the Free tier's 10% rate, and on
the 8th you are charged Growth's price — from which point Growth's 1% rate
applies.
If you would rather have the lower rate immediately, use **End Trial & Start Plan** on the dashboard banner: it bills the first payment right away and moves you onto your plan's rate as soon as it clears.
#### Important Notes on Billing Changes
- **Proration Accuracy for Upgrades**: All proration calculations (for plan upgrades) are performed down to the second for maximum accuracy. Actual amounts may vary slightly based on usage-based billing, taxes, and timezone differences.
- **No Proration for Downgrades**: Downgrades do not grant refunds or account credits for the unused time of your existing subscription plan cycle. All remaining time and features associated with your higher plan will remain accessible until the end of the current billing cycle.
- **Credits**: Any existing credits never expire and will continue to apply to future invoices until fully used.
- **Stripe Connect & Credits**: If you are using only the "Stripe Connect" powered payment method (in this scenario, transaction fees are withheld directly and does not generate any transaction fees as billable charges), any unused account credits can still be used towards any payment you make in future towards any Envigo Innovations, LLC products, including Subscriby.
- **Downgrade Restrictions**: You cannot downgrade to a plan with lower limits than your current usage (e.g., active projects limit) without first reducing your usage.
### Cancelling Your Subscription
Cancel anytime via **Plans & Billing** > **Cancel**. Access remains active until the subscription expiry date. No further renewals will occur.
### Resuming a Subscription
A cancelled subscription can be resumed before its expiry date via **Plans & Billing** > **Resume**.
Once a subscription has naturally expired, it cannot be resumed. See [Account
Suspension](#account-suspension) for details on expired accounts and how to
recover them.
## Billing Policies
### Grace Periods & Overdue Payments
When a charge fails, your **dashboard and Subscriby's bots pause immediately** — both send you to the outstanding invoice instead of accepting management actions. Your projects keep running the whole time: subscribers keep their access and existing subscriptions keep billing.
The runway before lockdown depends on whether your plan carries a monthly base price. **Free-plan** creators have no committed subscription revenue, so their clock is far shorter than a paid plan's.
- **Free plan**: lockdown on **day 7**, permanent deletion on **day 14**.
- **Paid plans**: lockdown on **day 30**, permanent deletion on **day 90**.
Both are counted from your first failed payment. Lockdown sits at day 30 on paid plans because the automatic retries are exhausted by roughly then; deletion stays far behind it because lockdown is reversible and deletion is not.
The deletion clock is measured from the lockdown date rather than from the failed payment — 7 days after lockdown on the Free plan, 60 days on paid plans — so if lockdown happens later than scheduled, deletion moves back with it.
Subscriby retries the failed charge automatically over the following days, and adding a fresh payment method triggers a retry straight away. Each milestone is preceded by a warning email on every plan.
Transaction fees keep accruing while an invoice is overdue — going past due
defers the bill, it does not waive it. Your base subscription may also be
suspended if transaction fee invoices remain unpaid.
See [Transaction Fees](/fees#what-happens-if-you-dont-pay) for the full milestone table.
### Refunds
**Subscriby does not offer refunds.** We provide free tiers to allow comprehensive testing of our platform's core functionality and value prior to purchase. Transaction fees represent a service cost for processed transactions and are non-refundable.
### Account Suspension
If the overdue amount is not settled within the timelines above, the following actions are taken:
1. **Lockdown** — day 7 on the Free plan, day 30 on paid plans: full account lockdown; your projects stop accepting new subscribers and no new revenue can be generated.
2. **Permanent deletion** — day 14 on the Free plan, day 90 on paid plans: the account and all associated data are permanently deleted, including your entire customer base. Active customer subscriptions are cancelled. This is irreversible.
Settling the invoice at any point before deletion cancels the schedule and restores full access automatically.
**Do not revoke payment authorization to force a cancellation.** This may lead
to disputes and chargebacks. Always cancel customer subscriptions manually
before abandoning an account.
Suspended accounts can only be reactivated by settling all overdue amounts.
Once an account reaches the deletion milestone — day 14 on the Free plan, day
90 on paid plans — it is non-recoverable.
---
# Ability Reference
Source: https://docs.subscriby.net/teams/abilities
This page is the reference you'll keep open while setting up [roles](/teams/roles) and [groups](/teams/groups). It lists every permission Subscriby exposes, what it governs, and a plain-English description of what a teammate holding it can do.
## How permissions are structured
Every permission has two parts, joined with a colon:
```
{entity}:{action}
```
- **Entity** — the thing the permission acts on (e.g. `project`, `project-access-code`).
- **Action** — what you can do with it (e.g. `view`, `create`, `delete`).
There are **12 entities**, each with **5 actions**, for **60 permissions** in total; the `team-member` entity spells its five actions differently (invite, remove and update-role rather than create, update and delete). Every count on this page is written from the permission catalogue on each build.
## The five actions
| Code | Meaning |
| ---------- | ----------------------------------------------------------------------------------- |
| `view-any` | See the list of all items in this entity (e.g. see every subscription, every plan). |
| `view` | Open a specific item to inspect its details. |
| `create` | Add a new item. |
| `update` | Edit an existing item. |
| `delete` | Remove an item permanently. |
`view-any` is listing access; `view` is detail access. In most cases you'll
want a role to have both — but splitting them lets you grant "can see this
specific thing in reports" without granting "can browse all of them".
## Permissions by entity
### Project (`project`)
Controls the project itself — the top-level container for a membership.
| Permission | What it lets the member do |
| ------------------ | ----------------------------------------------------------------------------------- |
| `project:view-any` | See the list of projects they have any access to. |
| `project:view` | Open and inspect a project's settings and dashboard. |
| `project:create` | Create new projects under your account. |
| `project:update` | Edit a project's name, description, banner, handle, legal URLs, and other settings. |
| `project:delete` | Permanently delete a project and all of its data. |
### Project payment method (`project-payment-method`)
Controls which payment providers (Stripe, PayPal, Razorpay, etc.) are enabled on a project.
| Permission | What it lets the member do |
| --------------------------------- | ------------------------------------------------------- |
| `project-payment-method:view-any` | See which payment methods are set up on the project. |
| `project-payment-method:view` | Open a payment method's configuration. |
| `project-payment-method:create` | Add a new payment provider to the project. |
| `project-payment-method:update` | Edit an existing payment method's API keys or settings. |
| `project-payment-method:delete` | Remove a payment provider from the project. |
### Project resource (`project-resource`)
Controls the places linked to a project — the channels, groups, servers and manual perks a subscriber actually gets access to.
| Permission | What it lets the member do |
| --------------------------- | ---------------------------------------------------------------- |
| `project-resource:view-any` | See all linked resources. |
| `project-resource:view` | Open a specific resource and see its members / status. |
| `project-resource:create` | Link a new place or manual perk to the project. |
| `project-resource:update` | Edit an existing resource's link or settings. |
| `project-resource:delete` | Unlink a resource from the project. |
### Project access code (`project-access-code`)
Controls promotional / free-access codes that members redeem to get discounted or free subscriptions.
| Permission | What it lets the member do |
| ------------------------------ | --------------------------------------------------- |
| `project-access-code:view-any` | See all access codes and their usage. |
| `project-access-code:view` | Open a code and see redemption history. |
| `project-access-code:create` | Generate new codes (single-use or batch). |
| `project-access-code:update` | Edit an existing code's terms (expiry, plan, etc.). |
| `project-access-code:delete` | Revoke a code. |
### Project coupon (`project-coupon`)
Controls discount codes — one code that any number of subscribers can redeem for a percentage or fixed amount off. Requires the [Coupons addon](/addons/coupons) or the Growth plan.
Distinct from access codes: an access code is one string for one person and grants access outright, while a coupon is one string for many people and reduces what they pay.
| Permission | What it lets the member do |
| ------------------------- | --------------------------------------------------------------------------- |
| `project-coupon:view-any` | See all discount codes, their limits, and how many times each was redeemed. |
| `project-coupon:view` | Open a code and see its terms and redemption history. |
| `project-coupon:create` | Create a new discount code. |
| `project-coupon:update` | Edit a code's discount, limits, dates, or turn it on and off. |
| `project-coupon:delete` | Delete a code. Refused while a checkout using it is still in progress. |
### Project subscription (`project-subscription`)
Controls existing subscriptions that members have taken out — the _purchases_ side.
| Permission | What it lets the member do |
| ------------------------------- | ----------------------------------------------------------------------- |
| `project-subscription:view-any` | See the list of active, trialling, expired, and one-time subscriptions. |
| `project-subscription:view` | Open a specific subscription to see its payment history and status. |
| `project-subscription:create` | Manually add a subscription for a member (bypass signup flow). |
| `project-subscription:update` | Edit a subscription (e.g. override renewal date, change plan). |
| `project-subscription:delete` | Cancel / remove a subscription. |
### Project subscription plan (`project-subscription-plan`)
Controls the **plans** you offer — _"$9.99/month Premium"_, _"$99 one-time Lifetime"_, and so on.
| Permission | What it lets the member do |
| ------------------------------------ | ------------------------------------------------------------------- |
| `project-subscription-plan:view-any` | See the list of plans. |
| `project-subscription-plan:view` | Open a plan to inspect pricing, trial, features, and billing cycle. |
| `project-subscription-plan:create` | Create new plans. |
| `project-subscription-plan:update` | Edit existing plans (price, trial, availability). |
| `project-subscription-plan:delete` | Delete a plan (won't be offered to new subscribers). |
### Project user (`project-user`)
Controls the **members** of a project — the end subscribers. Grants access to the member list, search, exports, and manual account actions.
| Permission | What it lets the member do |
| ----------------------- | --------------------------------------------------------------------- |
| `project-user:view-any` | See the list of all subscribers / members on the project. |
| `project-user:view` | Open a specific member's profile, subscription history, and metadata. |
| `project-user:create` | Manually add a subscriber (no payment required). |
| `project-user:update` | Edit a subscriber's email, metadata, or magic-link status. |
| `project-user:delete` | Remove a subscriber from the project. |
### Project connector (`project-connector`)
Controls the **Connectors** a project runs — the platforms it gates access on, messages through and takes payments from. The directory itself (which connectors exist, what each can do, the form that connects one) is the same for every creator and only asks for the list permission; a project's installations, with their state and health, are what the Connectors tab shows. Connecting an installation (typing the credential) stays a dashboard act; the three write permissions cover everything around it.
| Permission | What it lets the member do |
| ---------------------------- | ------------------------------------------------------------------------------------------ |
| `project-connector:view-any` | Browse the Connectors Marketplace and list a project's installations with their health. |
| `project-connector:view` | Read one installation of a project by connector. |
| `project-connector:create` | Install a connector on a project (a pending installation the creator then connects). |
| `project-connector:update` | Verify an installation and change its declared settings. |
| `project-connector:delete` | Disconnect an installation; the credentials are wiped and the row stays. |
### Project recovery (`project-recovery`)
Controls the **Disaster Recovery** ledger — the incidents the health probes opened, the recoveries run, the quota and the readiness checklist — and the controls around it: the per-project failover settings, the standbys, the undo and the reminders. The ledger belongs to the account holder, so a teammate with the read permissions reads the owner's recovery, not their own; the write permissions let a token reach the write endpoints, but the actions behind them still refuse anyone but the project owner, exactly as the dashboard does.
| Permission | What it lets the member do |
| --------------------------- | --------------------------------------------------------------------------------------- |
| `project-recovery:view-any` | List the incidents, the recoveries with their undo state, the allowances and readiness. |
| `project-recovery:view` | Open one incident or recovery and read its re-admission roll call. |
| `project-recovery:create` | Swap a resource onto its standby, and ask the creator to pick a standby or a replacement. |
| `project-recovery:update` | Change failover settings and the standby mirror, remind members, undo a recovery, send the handover mail. |
| `project-recovery:delete` | Remove a standby or the standby installation, and withdraw an open request. |
### Support conversation (`support-conversation`)
Controls the member support inbox — the threads that open when a subscriber messages your bot with something it does not recognise.
| Permission | What it lets the member do |
| ------------------------------- | -------------------------------------------------------------------------- |
| `support-conversation:view-any` | See the inbox: every open, assigned and resolved thread on the project. |
| `support-conversation:view` | Open a thread and read its messages. |
| `support-conversation:create` | Start a thread with a subscriber rather than waiting for them to write in. |
| `support-conversation:update` | Reply, add a private note, assign the thread, resolve it, or reopen it. |
| `support-conversation:delete` | Delete a thread and its messages permanently. |
### Team member (`team-member`)
The one entity whose five actions are not plain CRUD — membership is invited and removed rather than created and deleted, and changing someone's role is its own action.
| Permission | What it lets the member do |
| ------------------------- | ------------------------------------------------------------------ |
| `team-member:view-any` | See everyone on the team and the role each of them holds. |
| `team-member:view` | Open a single member and inspect their role and group memberships. |
| `team-member:invite` | Send an invitation to join the team. |
| `team-member:remove` | Remove someone from the team. |
| `team-member:update-role` | Change which role someone holds. |
## Suggested role templates
You don't have to use these — but they're a solid starting point.
### Community Manager
Handles day-to-day member interactions. No billing or plan changes.
- `project:view-any`, `project:view`
- `project-resource:view-any`, `project-resource:view`
- `project-subscription:view-any`, `project-subscription:view`
- `project-user:view-any`, `project-user:view`, `project-user:update`
- `project-access-code:view-any`, `project-access-code:view`
### Billing Admin
Handles plans and payment methods. No member data access.
- `project:view-any`, `project:view`
- `project-payment-method:*` (all five actions)
- `project-subscription-plan:*` (all five actions)
- `project-subscription:view-any`, `project-subscription:view`
### Read-only Auditor
External accountant or consultant — sees everything, changes nothing.
- Every `*:view-any` and `*:view` permission.
- None of `create`, `update`, `delete`.
### Full Admin
Trusted deputy who does everything you do.
- All 60 permissions.
- Use sparingly — prefer narrower roles where possible.
## Related
- [Roles](/teams/roles) — apply these permissions as named, reusable bundles.
- [Groups](/teams/groups) — layer extra permissions onto specific members.
- [Invite members](/teams/invite-members) — pick the starting role when adding someone to the team.
---
# Create a Team
Source: https://docs.subscriby.net/teams/create-team
By the end of this page, you'll have a new team in your Subscriby account, ready to invite collaborators into.
Teams is an agency-level feature available on **Growth**, **Enterprise**, and
**Custom** plans. If you're on Free or Starter, Subscriby will show a toast —
*"Teams is an agency-level feature available on Growth Plan, Enterprise Plans
and Custom Plans. Upgrade now!"* — when you try. See [Activate your
subscription](/creators/subscription).
## Before you begin
Make sure:
- You're signed in to an account that's on a plan supporting teams.
- You've decided roughly who you'll invite and what they'll help with — you'll translate that into roles in a later step.
You don't need to invite anyone yet — you can create an empty team first and add people when you're ready.
## Create the team
### Open Teams Settings [step]
Go to **Settings → Teams** (URL: `/settings/teams`).
### Click Create Team [step]
A modal named _Create Team_ appears with a single field.
### Name your team [step]
Pick a name that describes the team's scope — for example _"Pro Chess Club Core"_ or _"My Agency"_. The name is visible to invited members after they join.
Requirements: up to 255 characters, required.
### Confirm in the modal [step]
The team is created and saved. You'll see a toast: _"Team created successfully."_
### Review the Teams list [step]
The team now appears in the **Teams** list. From here you can open it to invite members, or create additional teams.
## Your first team and "current team"
The first team you create becomes your **current team** — Subscriby uses that context to scope things like roles and groups. If you ever create multiple teams, you can switch between them from the Teams page.
You can always delete a team later if it turns out you don't need it. Deletion
is immediate and can't be undone, so don't delete a team that has active
members relying on it.
## Delete a team
### Find the team in the list [step]
On the **Settings → Teams** page, find the team in the list.
### Click Delete [step]
Click **Delete** next to it. Confirm the action.
### Confirm removal [step]
The team is removed. Invitations that were pending to that team are also removed. Members of the team lose access to the resources the team governed.
You can only delete teams you **own**. If you're a member of someone else's
team, the **Delete** button will be greyed out or absent. To leave a team you
don't own, ask the owner to remove you — see [Invite
members](/teams/invite-members#remove-a-member).
## Common questions
There's no hard limit on the Growth and higher plans — create as many as your workflow needs. Each team is independent, with its own members, roles, and groups.
Yes — on the Teams page, edit the team's name and save. Members will see the
updated name next time they open Subscriby.
Projects are tied to *your personal account*, not to a specific team. Deleting
a team **does not delete projects**. What it does is: revoke team members'
access to those projects, because they lose their shared permission context.
No — only the account owner can create top-level teams. Members can only *join* existing teams they've been invited to.
## Next up
Now that you have a team, the natural next steps are:
1. [Invite members](/teams/invite-members) — get collaborators on board.
2. [Create roles](/teams/roles) — define what each person can do.
3. [Assign members to groups](/teams/groups) — optionally, layer on group-based permissions.
---
# Groups
Source: https://docs.subscriby.net/teams/groups
By the end of this page, you'll know when to use groups over roles, how to create them, and how to attach specific team members to a group for one-off access needs.
Groups live at **Settings → Groups** (URL: `/settings/groups`). A group is a **name** plus a **set of permissions** plus a **specific set of members** — all three together.
## Why groups?
Roles answer _"what can this kind of person do?"_ — great for stable job descriptions. Groups answer _"what can **these specific people** do for **this specific purpose**?"_ — great for temporary teams, special projects, or permissions that cut across role boundaries.
### Temporary project teams
A handful of collaborators pulled together for a three-month push (e.g. _"Q4 Launch Team"_). They already have their regular roles; you just need to temporarily grant _create access codes_ so they can set up promo codes. A group handles that cleanly — create it, add the people, grant only what they need, delete the group when the push ends.
### Accountant or auditor access
Your external bookkeeper needs read-only access to payments during tax season. Create a group with just `project-subscription:view-any` and `project-user:view-any`, drop them in, and remove them when the audit is done. No need to invent a custom role that only one person will ever use.
### Founders or core team
A small circle that needs blanket access across everything. Simpler than creating a maximalist role and assigning it one-by-one — one group with all permissions ticked, add whoever should be in it.
## Create a group
### Open Groups Settings [step]
Go to **Settings → Groups** (URL: `/settings/groups`). The page shows all groups for your current team.
### Click Create group [step]
A modal appears.
### Fill in the group's details [step]
- **Name** — A human-readable label, e.g. _"Q4 Launch Team"_. Up to 255 characters.
- **Code** — A machine-friendly identifier, e.g. `q4-launch-team`. Letters, numbers, dashes, and underscores only (`alpha_dash`). Must be unique within the team.
- **Permissions** — Tick the permissions this group grants its members. Same 50-permission picker you see for roles — see [Ability reference](/teams/abilities).
### Save the group [step]
Click **Save**. The group appears in your team's group list. A toast confirms: _"Group created successfully."_
## Add members to a group
A group without members grants nothing. After creating a group, add the people who should inherit its permissions.
### Open the group's members modal [step]
On the **Groups** page, find the group and click its **Members** (people) icon or button.
### Review the member checklist [step]
A modal appears with a checklist of your team's current members.
### Tick members to include [step]
**Tick every member** who should belong to this group. Untick anyone who shouldn't.
### Save the membership [step]
Click **Save** in the modal. A toast confirms: _"Group members updated successfully."_ Members picked up the group's permissions on their next page load.
A member can be in **many groups** at once — permissions from all their groups
add up with their base role permissions. There's no subtraction: groups can
only *grant* additional access, never revoke it.
## Edit a group's permissions or name
### Click Edit on the group [step]
On the Groups page, click **Edit** next to the group.
### Update name or permissions [step]
Update the name or tick/untick permissions. The **code** is set at creation and stays fixed.
### Save the changes [step]
Click **Save**. Members of the group see their effective permissions update on the next request.
## Delete a group
### Click Delete on the group [step]
On the Groups page, click **Delete** next to the group.
### Confirm removal [step]
Confirm. The group is removed and a toast confirms: _"Group deleted successfully."_
**Only the team owner can delete groups.** Members without this privilege will
see an *"Unauthorized"* toast. That's deliberate — groups often carry
sensitive permissions, and deleting one without warning could strand
collaborators.
Deletion does not affect the members themselves — they keep their base role and any other groups they belong to. They just lose the permissions the deleted group was granting.
## Roles vs. groups: quick recap
- **Roles** are **1:1** with members: each person has exactly one role.
- **Groups** are **many-to-many**: a person can be in many groups, and each group can have many people.
- **Both** can grant the same 60 permissions — they differ in application style, not vocabulary.
Use roles first for stable permissions, add groups when roles don't capture the shape of what you need.
## Common questions
Yes — groups *add* to whatever the role grants. There's no subtraction and no conflict between the two.
Because they answer different questions. Roles express *"what kind of teammate
is this?"* (stable, per-person). Groups express *"what specific powers does
this ad-hoc team share?"* (flexible, per-need). Most mature setups use both.
Yes — as soon as you save the updated membership list, the removed member's
effective permissions drop on their next page load.
Not on a single page today — you need to look at the member's role plus every group they belong to. We're tracking this as a UX improvement. For now, consider documenting sensitive groups in their description field.
## Related
- [Roles](/teams/roles) — the primary permission tool; start here before groups.
- [Ability reference](/teams/abilities) — full permission list with descriptions.
- [Invite members](/teams/invite-members) — put people in the team in the first place.
---
# Teams & Roles
Source: https://docs.subscriby.net/teams
When your membership business grows beyond one person, you'll want to bring other people in — a designer to update banners, a community manager to handle members, an accountant to reconcile payments — without handing over your password. That's what **Teams** are for.
**Teams is an agency-level feature** available on the **Growth**,
**Enterprise**, and **Custom** plans. If you're on the Free or Starter plan,
you'll see a toast reminding you to upgrade when you try to use team features.
See [Activate your subscription](/creators/subscription).
## The building blocks
Subscriby teams are made of four concepts that work together:
}
title="Teams"
href="/teams/create-team"
>
A team is a container of people you collaborate with. You can create more than one — useful for separating, say, a chess coaching team from a photography club.
}
title="Members"
href="/teams/invite-members"
>
People invited by email into a team. Each member is assigned a **role** when
they accept.
}
title="Roles"
href="/teams/roles"
>
A bundle of permissions (like *"Community Manager"* or *"Billing Admin"*). You
pick which role each member gets.
}
title="Groups"
href="/teams/groups"
>
A second way to slice permissions. Groups let you assemble a set of members
and grant them permissions as a unit — handy when someone needs one-off access
without creating a whole new role.
}
title="Abilities"
href="/teams/abilities"
>
The 50 individual permissions you can grant. Combine them freely into roles and groups.
## How it fits together
Here's the mental model to keep:
1. You (the account owner) **create a team**.
2. You **invite people by email** into that team. Each invitation comes with a role you pick at invite time.
3. Inside the team, you define **roles** — named bundles of permissions, like _"Editor"_ or _"Finance"_.
4. Optionally, you define **groups** — ad-hoc collections of members + permissions, useful when role boundaries aren't the right fit.
5. When a member accepts their invitation, they can sign in to Subscriby with _their own_ account and see _your_ projects through the lens of the permissions you granted.
Team members sign in with their own Subscriby credentials, not yours. They
register separately (the normal path — see [Creating an
account](/account/sign-up)), and your team invitation then links their
account to yours.
## Where it lives in the dashboard
All team management is inside **Settings**:
| Page | URL | What you do there |
| ------- | -------------------------------- | ------------------------------------------------------ |
| Teams | `/settings/teams` | Create, rename, or delete teams |
| Members | `/settings/teams/{team}/members` | Invite people, see pending invitations, remove members |
| Roles | `/settings/roles` | Create roles and assign permissions to them |
| Groups | `/settings/groups` | Create groups and attach members + permissions |
## Who can do what?
- **You, the team owner**, can always do everything — create, invite, define, delete.
- **Team members** can only perform actions their assigned role (or membership of a group) permits.
- **Roles can only be deleted by the person who created them** or by the team owner — this prevents collaborators from accidentally removing critical shared roles.
- **Groups can only be deleted by the team owner**.
## When to use teams vs. sharing your login
Never share your account password. Teams exist precisely so you don't have to. Even if it feels like overkill for a two-person collaboration, a proper team setup:
- Gives each person their **own sign-in history** so you can audit who did what.
- Lets you **revoke access instantly** when someone leaves — no password rotation required.
- Respects Subscriby's **security features** (2FA, passkeys) per person, not shared.
## Related
- [Create a team](/teams/create-team) — the first step.
- [Invite members](/teams/invite-members) — email-based invitations.
- [Roles](/teams/roles) — build reusable permission bundles.
- [Groups](/teams/groups) — ad-hoc permission slices.
- [Ability reference](/teams/abilities) — the full list of 60 permissions.
---
# Invite Team Members
Source: https://docs.subscriby.net/teams/invite-members
By the end of this page, you'll know how to get collaborators into your team — from the moment you send an invitation to the moment you remove someone who's no longer part of the project.
Team member management lives at **Settings → Teams → (open a team) → Members** (URL: `/settings/teams/{team}/members`).
## What happens when you invite someone
1. You enter their email address and pick an initial role.
2. Subscriby sends them an invitation email.
3. They click the link and sign in or create a Subscriby account (see [Creating an account](/account/sign-up)).
4. Once accepted, they appear in your team's member list with the role you picked.
Invitations are tied to a specific email address. If the person creates their account with a different email, they won't be auto-matched — you'll need to re-invite them at the correct address.
## Send an invitation
### Open your team [step]
Go to **Settings → Teams** and click into the team you want to invite someone to. You land on the Members page.
### Click Invite member [step]
A modal named _Invite member_ appears with two fields.
### Enter their email address [step]
Must be a valid email (we validate on submit). Up to 255 characters.
### Pick their initial role [step]
The role dropdown lists every role defined on this team. If you haven't created any custom roles yet, the default role is `member`. See [Roles](/teams/roles) for how to add more.
### Click Send invitation [step]
A toast confirms: _"Invitation sent successfully."_ The invitation appears in the team's **Pending invitations** section until the recipient accepts.
**Already on Subscriby?** If the invitee already has a Subscriby account
matching the email you invited, they'll see a "You've been invited to join
[team]" prompt next time they sign in. They can accept from there too, without
going through the invitation email.
## Manage pending invitations
Pending invitations sit on the Members page until they're accepted.
### Cancel an invitation
Changed your mind, or invited the wrong email? You can remove a pending invitation at any time:
#### Find the pending row [step]
In the **Pending invitations** list, find the row.
#### Click Cancel invitation [step]
Click **Cancel invitation** next to it.
#### Confirm cancellation [step]
A toast confirms: _"Invitation cancelled."_ The link in that person's invitation email stops working immediately.
### Resend / re-invite
Invitations don't have a built-in "resend" action. If someone didn't receive it or the link went stale:
1. Cancel the existing invitation.
2. Send a fresh one to the same email.
## Accept an invitation (for invitees)
If you're on the receiving end of an invite:
### Check your inbox [step]
Check your inbox for _"You've been invited to join [Team name] on Subscriby"_. Check spam if it's not there.
### Click the Accept invitation link [step]
Click the **Accept invitation** link. You'll land on Subscriby.
### Create or sign in to your account [step]
If you don't have a Subscriby account yet, you'll be walked through [creating one](/account/sign-up). If you already do, sign in.
### Land in the team [step]
Once signed in, you're now a member of the team with the role the inviter picked.
## Change a member's role
Your early role guesses don't have to be perfect — you can update someone's role at any time.
### Find the member's row [step]
On the team's Members page, find the member's row.
### Pick a new role [step]
Click the role dropdown and pick a new role.
### Changes take effect [step]
The change is saved instantly. The member's permissions update the next time they load a page.
Role changes affect what the member can do going forward. They don't
retroactively modify actions the person has already taken.
## Remove a member
### Find the member's row [step]
On the team's Members page, find the member's row.
### Click Remove [step]
Click **Remove** (typically a red/danger icon).
### Confirm removal [step]
Confirm if prompted. The member is removed and a toast confirms: _"Member removed successfully."_
**You can't remove the team owner.** That's you — the person who created the
team. If you try, you'll see: *"Cannot remove the team owner."* To transfer
ownership, contact support.
Removal is **immediate** — the person loses access as soon as their browser next makes a request. Their personal Subscriby account continues to exist (since it's theirs, not yours); they just no longer see your projects.
## Common questions
Member counts depend on your plan. The Growth plan typically includes a generous team size; Enterprise and Custom plans are negotiated case-by-case. See your [creator subscription](/creators/subscription) for the specifics.
Yes. They'll create one when they click the invitation link, like any other
creator signup. The invitation waits for them to finish.
Double-check their role on the Members page and verify the role has the
permissions they expect (see [Roles](/teams/roles)). If things still
look off, it may be a browser cache — ask them to refresh.
Only if you grant them a role that includes team-management permissions. By
default, invitation is an owner-only action.
Cancel the invitation immediately — the link stops working. Send a fresh invitation to the correct address.
## Next up
- [Roles](/teams/roles) — create named permission bundles so you're not picking individual permissions every time.
- [Groups](/teams/groups) — layer on ad-hoc permission collections for one-off needs.
- [Ability reference](/teams/abilities) — the 60 permissions you can grant.
---
# Roles
Source: https://docs.subscriby.net/teams/roles
By the end of this page, you'll have custom roles defined for your team and will be able to assign them to members on invitation or later.
Roles live at **Settings → Roles** (URL: `/settings/roles`). A role is simply a **name** plus a **set of permissions** that apply inside a particular team.
## Why roles (instead of per-member permissions)?
Roles let you solve _"what can this kind of person do?"_ once, then apply it many times:
- Invite five community managers? Pick the same **Community Manager** role from the dropdown each time.
- Later decide they need one extra permission? Update the role — every member assigned to it picks up the change instantly.
- No need to remember _"wait, does Sarah have access to delete plans?"_ — the role carries the answer.
## The role you start with
Every team automatically has a default role called **member**. It's the role we preselect when you invite someone. You can leave it as-is or redefine its permission list to match what a baseline member on your team should see.
## Create a new role
### Open Roles Settings [step]
Go to **Settings → Roles** (URL: `/settings/roles`). The page shows all roles for your current team.
### Switch team if needed [step]
If you have multiple teams, confirm the team dropdown at the top is showing the team you want to add the role to.
### Click Create role [step]
A modal appears for the new role's details.
### Fill in the role fields [step]
- **Name** — A human-readable label, e.g. _"Community Manager"_. Up to 255 characters.
- **Code** — A machine-friendly identifier, e.g. `community-manager`. Letters, numbers, dashes, and underscores only (we enforce `alpha_dash`). Must be unique within the team.
- **Description** _(optional)_ — A short note to yourself or future collaborators: _"Handles day-to-day member questions; no billing access."_ Up to 255 characters.
- **Permissions** — Tick the permissions this role should grant. See [the permission picker](#the-permission-picker) below.
### Save the role [step]
Click **Save** in the modal. The role appears in your team's role list. A toast confirms: _"Role created successfully."_ You can now assign it to new and existing team members.
## The permission picker
When creating or editing a role, the permissions section shows every permission grouped by **entity** (the thing the permission acts on). You'll see 10 groups, each with 5 actions:
| Entity group | Actions available |
| ----------------------------- | ------------------------------------------- |
| **project** | view-any, view, create, update, delete |
| **project-payment-method** | view-any, view, create, update, delete |
| **project-resource** | view-any, view, create, update, delete |
| **project-access-code** | view-any, view, create, update, delete |
| **project-coupon** | view-any, view, create, update, delete |
| **project-subscription** | view-any, view, create, update, delete |
| **project-subscription-plan** | view-any, view, create, update, delete |
| **project-user** | view-any, view, create, update, delete |
| **support-conversation** | view-any, view, create, update, delete |
| **team-member** | view-any, view, invite, remove, update-role |
That's a total of **50 individual permissions**. Nine of the groups are plain CRUD; `team-member` is the exception, because membership is invited and removed rather than created and deleted. See [Ability reference](/teams/abilities) for the full table with plain-English descriptions.
### Shortcut: toggle whole groups
Next to each group's heading is a toggle that selects or deselects every action in that group at once. Use it when you want a role that, say, can do _everything related to access codes_ but nothing else — one click and you're done.
### Shortcut: select all
There's also a **Select all** toggle at the top of the picker for super-admin-like roles. Use sparingly — the whole point of roles is least-privilege.
## Edit an existing role
### Open the role for editing [step]
On the Roles page, find the role and click its **Edit** (pencil) icon.
### Update the role's fields [step]
Change the name, description, or tick/untick permissions. The **code** is set at creation and remains fixed — it's the stable identifier for that role.
### Save the changes [step]
Click **Save**. A toast confirms: _"Role updated successfully."_ Team members assigned to this role pick up the changes on their next page load.
## Delete a role
### Click Delete on the role [step]
On the Roles page, click **Delete** next to the role.
### Confirm the prompt [step]
Confirm if prompted.
### Role is removed [step]
The role is removed. A toast confirms: _"Role deleted successfully."_
**Only the role's creator or the team owner can delete a role.** Members without delete rights will see *"You can only delete roles you created."*
Deleting a role that's currently assigned to members leaves those members without a defined role — re-assign them to a different role before deleting to avoid confusion.
## Roles vs. groups — when to use which
Both give you permission management, but they're best at different jobs:
| Use **roles** when... | Use **groups** when... |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| The permission set is tied to a **job description** (Editor, Finance, Support). | You need **ad-hoc** access for a one-off project, sprint, or event. |
| You want the **same setup applied across many members**. | You want to pool permissions for a **specific set of people** who don't fit an existing role. |
| Member-to-role is **1:1** (one role per person). | Member-to-group can be **many-to-many**. |
If you're unsure, start with roles. Groups are a refinement for when roles alone feel too rigid. See [Groups](/teams/groups) for the details.
## Common questions
No — each member has exactly one role on a given team. To layer extra permissions on top, add the member to a [group](/teams/groups).
That member will get authorization errors on their next action that requires
the permission. Nothing is destroyed — you can re-tick it and the error goes
away. It's a low-blast-radius mistake.
Not directly. The quickest path is to create a new role with a similar name,
then tick the same permissions. For a rarely-used workflow, this is
manageable; for frequent duplication, consider whether a group is a better
fit.
The code is used internally as a stable identifier. Restricting it to `alpha_dash` avoids characters that would cause issues in URLs or databases.
## Next up
- [Groups](/teams/groups) — when roles aren't the right granularity.
- [Ability reference](/teams/abilities) — plain-English descriptions of every permission.
- [Invite members](/teams/invite-members) — apply your new role when you invite someone.
---
# access_code.* events
Source: https://docs.subscriby.net/webhooks/v1/events/access-code
Access-code batch generation, redemption, and expiry. Emitted at the batch level for generation (never per-code, to prevent fan-out) and per-code for redemption and expiry.
## Example envelope
```json
{
"id": "evt_01HX...",
"type": "access_code.redeemed",
"created_at": "2026-05-18T10:05:00Z",
"api_version": "2026-05-01",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"data": {
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"subscriber_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"access_code": "9c1a7b3f-5d80-4e62-a4f1-0b83d2e6c517"
}
}
```
The `access_code` string is the full redeemed code, not a masked fragment. It is already consumed and cannot be redeemed again, but treat it as a customer identifier and avoid forwarding it to systems you do not control.
## Why batch, not per-code
A 10,000-code bulk generation would produce 10,000 webhook deliveries per subscribed endpoint otherwise. The `generated` event fires once per batch with the count, and the per-code events fire later as codes are actually used.
## Required ability
Tokens subscribing to `access_code.*` events must carry `project-access-code:view` at mint time.
## Events
- [`access_code.generated`](#access-code-generated) — A bulk-generate job completes.
- [`access_code.redeemed`](#access-code-redeemed) — A code is successfully redeemed by a subscriber.
- [`access_code.expired`](#access-code-expired) — A code passes its expiry without redemption.
## access_code.generated
A bulk-generate job completes.
### When this fires
A bulk-generation run for access codes completes. The batch is materialized in the database and is now redeemable. Batch-level only: this fires once per generation job, not per code. A 10,000-code batch produces one event.
### Caveats
- Codes themselves are never delivered through this event. The batch is delivered to the creator's chat on the connector as a CSV document or as one message per code, depending on the export type chosen at generation; to fetch them programmatically use [the access codes list](https://docs.subscriby.net/api/v1/reference/access-codes).
- There is no batch identifier in the payload. Correlate a run by `plan_id` plus `created_at`, or list codes via the access codes endpoint.
- Per-code redemption fires `access_code.redeemed`; per-code expiry fires `access_code.expired`.
### Related events
- `access_code.redeemed`: fires per code on redemption.
- `access_code.expired`: fires per code on expiry.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `access_code.generated` | yes | The event name, always `access_code.generated` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.plan_id` | string (uuid) | yes | Plan the codes are scoped to. |
| `data.plan_name` | string | yes | Plan display name at generation time. |
| `data.count` | integer | yes | Number of codes in the batch. |
| `data.expires_at` | string \| null (date-time) | yes | Common expiry timestamp for all codes in the batch. `null` when the batch was generated with no expiry. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "access_code.generated",
"created_at": "2026-05-18T10:05:00Z",
"api_version": "2026-05-01",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"data": {
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"plan_name": "Premium Monthly",
"count": 250,
"expires_at": "2026-08-18T00:00:00Z"
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
## access_code.redeemed
A code is successfully redeemed by a subscriber.
### When this fires
A subscriber successfully redeems an access code. The placeholder subscription row created with that code at generation time is claimed for the subscriber and activated (or put into trial) on the associated plan.
### Caveats
- `access_code` is the complete code, not a masked fragment. It is already consumed and cannot be redeemed again, but treat it as a customer identifier and avoid forwarding it to systems you do not control.
- Pairs with `subscription.created` and either `member.joined` or `member.trial_joined`, depending on whether the redeemed plan carries a trial.
### Related events
- `access_code.generated`: predecessor (batch-level).
- `access_code.expired`: alternative outcome.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `access_code.redeemed` | yes | The event name, always `access_code.redeemed` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.subscription_id` | string (uuid) | yes | Subscription now held by the subscriber. The row was created with the code at generation time, not by the redemption. |
| `data.project_id` | string (uuid) | yes | Project the redemption belongs to. Duplicates the envelope `project_id`. |
| `data.plan_id` | string (uuid) | yes | Plan the code was scoped to. |
| `data.subscriber_id` | string (uuid) | yes | Subscriber's project-scoped user id. |
| `data.access_code` | string (uuid) | yes | The **full redeemed code** (UUID v4), not masked. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "access_code.redeemed",
"created_at": "2026-05-18T10:05:00Z",
"api_version": "2026-05-01",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"data": {
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"subscriber_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"access_code": "9c1a7b3f-5d80-4e62-a4f1-0b83d2e6c517"
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
## access_code.expired
A code passes its expiry without redemption.
### When this fires
An access code passes its expiry without being redeemed. The placeholder subscription row holding it is hard-deleted immediately after this event is emitted.
The code string itself is **not** included on this event; only `access_code.redeemed` carries `access_code`. The placeholder row referenced by `subscription_id` is hard-deleted immediately after this event is emitted, so it will not resolve through the API.
### Caveats
- Expiry is detected by a sweep that runs every five minutes, so `created_at` trails `expired_at` by up to five minutes plus queue latency.
- Large batches may produce a sustained burst of `access_code.expired` events when the batch hits its expiry. Plan capacity accordingly.
- Codes that were already redeemed do not produce this event regardless of the batch's `expires_at`.
### Related events
- `access_code.redeemed`: alternative outcome.
- `access_code.generated`: predecessor (batch-level).
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `access_code.expired` | yes | The event name, always `access_code.expired` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.plan_id` | string (uuid) | yes | Plan the code was scoped to. |
| `data.subscription_id` | string (uuid) | yes | Placeholder subscription row that held the unredeemed code. |
| `data.expired_at` | string (date-time) | yes | When the code became unredeemable. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "access_code.expired",
"created_at": "2026-08-18T00:04:12Z",
"api_version": "2026-05-01",
"project_id": "7f3d1c92-8b45-4e6a-9d21-5c8e0a4b6f13",
"data": {
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"expired_at": "2026-08-18T00:00:00Z"
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
---
# billing.* events
Source: https://docs.subscriby.net/webhooks/v1/events/billing
The creator's own Subscriby subscription tier: trial conversion, invoices, payment failures, grace periods, lockdowns. These events describe the creator's billing relationship with Subscriby itself, not their subscribers' billing. Useful for building ops dashboards that watch for accounts heading into lockdown.
## Example envelope
```json
{
"id": "evt_01HX...",
"type": "billing.account_locked",
"created_at": "2026-05-18T10:05:00Z",
"api_version": "2026-05-01",
"project_id": null,
"data": {
"creator_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"past_due_since": "2026-04-18T00:00:00Z",
"locked_at": "2026-05-18T00:00:00Z"
}
}
```
`project_id` is always `null` for `billing.*` events; these are account-level.
## When you'd subscribe
The typical consumer is an **internal ops dashboard** or **Slack room**, not a subscriber-facing system. Subscriby also emails the creator via the existing notification channels for every one of these events.
## Required ability
Tokens subscribing to `billing.*` events must carry `billing:read` at mint time. Writes (upgrade, downgrade, cancel) additionally need `billing:manage`.
## Events
- [`billing.invoice_created`](#billing-invoice-created) — A new invoice is generated for the creator's Subscriby tier.
- [`billing.invoice_paid`](#billing-invoice-paid) — An invoice is paid in full.
- [`billing.invoice_overdue`](#billing-invoice-overdue) — A creator invoice is marked overdue.
- [`billing.payment_failed`](#billing-payment-failed) — A recurring charge against the creator's payment method fails.
- [`billing.trial_ending`](#billing-trial-ending) — The creator's platform trial is about to convert and take its first payment.
- [`billing.grace_period_warning`](#billing-grace-period-warning) — The creator is approaching automatic lockdown for an unpaid invoice.
- [`billing.account_locked`](#billing-account-locked) — The account hits its past-due lockdown.
- [`billing.tier_upgraded`](#billing-tier-upgraded) — The creator upgrades to a higher Subscriby tier.
- [`billing.tier_downgraded`](#billing-tier-downgraded) — The creator downgrades to a lower Subscriby tier.
- [`billing.tier_cancelled`](#billing-tier-cancelled) — The creator cancels their Subscriby subscription.
## billing.invoice_created
A new invoice is generated for the creator's Subscriby tier.
### When this fires
A new invoice is generated for the creator's Subscriby tier subscription. These events describe the creator's relationship with Subscriby itself, not their subscribers.
### Caveats
- This event is creator-side only. Subscriber invoices, if any, are not represented in `billing.*`.
- Pairs with `billing.invoice_paid` on success and `billing.payment_failed` when the first attempt fails.
### Related events
- `billing.invoice_paid`: successful collection.
- `billing.payment_failed`: failed collection attempt.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `billing.invoice_created` | yes | The event name, always `billing.invoice_created` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.creator_id` | string (uuid) | yes | Creator the invoice is addressed to. The only `data` key guaranteed present. |
| `data.invoice_id` | string \| null | yes | Provider invoice id. `null` if the provider payload carried no invoice id. |
| `data.amount` | string \| null (decimal) | yes | Invoice amount in major units of `currency`, formatted as a string. `null` if no amount was due. |
| `data.currency` | string \| null | yes | ISO 4217 currency code. `null` if the provider payload carried no currency. |
| `data.hosted_invoice_url` | string \| null (uri) | yes | URL to the provider-hosted invoice page. `null` on a draft invoice; Stripe issues it at finalization. |
| `data.attempt_count` | integer \| null | yes | Number of payment attempts so far. `0` at creation, `null` if the provider payload omitted it. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "billing.invoice_created",
"created_at": "2026-05-18T10:05:00Z",
"api_version": "2026-05-01",
"project_id": null,
"data": {
"creator_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"invoice_id": "in_1Nxy..",
"amount": "49.00",
"currency": "USD",
"hosted_invoice_url": "https://invoice.stripe.com/..",
"attempt_count": 0
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
## billing.invoice_paid
An invoice is paid in full.
### When this fires
The creator's tier invoice is paid in full and the account is marked current.
### Caveats
- Expect two deliveries per payment. Subscriby maps both Stripe `invoice.paid` and `invoice.payment_succeeded` onto this event. De-duplicate on `data.invoice_id`.
- Recovery is not driven by this event. `past_due_since`, `locked_at` and the warning stamps are cleared when Stripe reports the subscription back at `active`/`trialing` on `customer.subscription.updated`, which emits no `billing.*` event of its own, so a consumer tracking lock state should treat this event as a hint, not the unlock signal.
- A creator's lockdown never suspends their subscribers' billing (only new signups are blocked), so there is nothing to resume here.
### Related events
- `billing.invoice_created`: predecessor.
- `billing.payment_failed`: alternative outcome.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `billing.invoice_paid` | yes | The event name, always `billing.invoice_paid` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.creator_id` | string (uuid) | yes | Creator the invoice was addressed to. |
| `data.invoice_id` | string | yes | Provider invoice id. |
| `data.amount` | string (decimal) | yes | Paid amount in major units of `currency`. |
| `data.currency` | string | yes | ISO 4217 currency code. |
| `data.hosted_invoice_url` | string (uri) | yes | URL to the provider-hosted invoice page (now showing paid status). |
| `data.attempt_count` | integer | yes | Provider-side attempt count on the invoice. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "billing.invoice_paid",
"created_at": "2026-05-18T10:05:00Z",
"api_version": "2026-05-01",
"project_id": null,
"data": {
"creator_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"invoice_id": "in_1Nxy..",
"amount": "49.00",
"currency": "USD",
"hosted_invoice_url": "https://invoice.stripe.com/..",
"attempt_count": 1
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
## billing.invoice_overdue
A creator invoice is marked overdue.
### When this fires
The creator's invoice is marked uncollectible by the payment provider. Coverage is partial: providers do not always send this signal for every overdue scenario.
> **Deferred coverage.** Wired only to Stripe's `invoice.marked_uncollectible` signal. Stripe does not send that for every overdue scenario, so gaps are possible. Pair this event with `billing.payment_failed` and `billing.grace_period_warning` to cover overdue states reliably.
### Caveats
- Deferred in practice: coverage depends on Stripe sending `invoice.marked_uncollectible`. For full coverage of overdue states, listen for `billing.payment_failed` and `billing.grace_period_warning`.
- The billing overdue policy drives lockdowns on its own daily cadence; see `billing.account_locked`.
### Related events
- `billing.payment_failed`: typical earlier signal.
- `billing.grace_period_warning`, `billing.account_locked`: downstream signals.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `billing.invoice_overdue` | yes | The event name, always `billing.invoice_overdue` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.creator_id` | string (uuid) | yes | Creator the invoice was addressed to. |
| `data.invoice_id` | string | yes | Provider invoice id. |
| `data.amount` | string (decimal) | yes | Invoice amount in major units of `currency`. |
| `data.currency` | string | yes | ISO 4217 currency code. |
| `data.hosted_invoice_url` | string (uri) | yes | URL to the provider-hosted invoice page. |
| `data.attempt_count` | integer | yes | Provider-side attempt count on the invoice. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "billing.invoice_overdue",
"created_at": "2026-04-18T10:05:00Z",
"api_version": "2026-05-01",
"project_id": null,
"data": {
"creator_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"invoice_id": "in_1Nxy..",
"amount": "49.00",
"currency": "USD",
"hosted_invoice_url": "https://invoice.stripe.com/..",
"attempt_count": 3
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
## billing.payment_failed
A recurring charge against the creator's payment method fails.
### When this fires
A scheduled charge against the creator's payment method fails. The platform will continue to retry inside the grace window.
### Caveats
- Multiple `billing.payment_failed` events can fire for the same `invoice_id` as Stripe retries.
- Repeated failures escalate through the grace window; see `billing.grace_period_warning` and `billing.account_locked` for that progression.
### Related events
- `billing.invoice_paid`: successful resolution.
- `billing.account_locked`: terminal state if recovery doesn't happen.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `billing.payment_failed` | yes | The event name, always `billing.payment_failed` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.creator_id` | string (uuid) | yes | Creator the invoice was addressed to. |
| `data.invoice_id` | string | yes | Provider invoice id. |
| `data.amount` | string (decimal) | yes | Attempted amount in major units of `currency`. |
| `data.currency` | string | yes | ISO 4217 currency code. |
| `data.hosted_invoice_url` | string (uri) | yes | URL to the provider-hosted invoice page. |
| `data.attempt_count` | integer | yes | Provider-side retry count for this invoice. |
| `data.reason` | string \| null | yes | Stripe's `last_finalization_error.message`, falling back to `last_payment_error.message`. A human-readable sentence, not a decline code, and `null` when Stripe supplied neither. Do not pattern-match on it. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "billing.payment_failed",
"created_at": "2026-04-18T10:05:00Z",
"api_version": "2026-05-01",
"project_id": null,
"data": {
"creator_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"invoice_id": "in_1Nxy..",
"amount": "49.00",
"currency": "USD",
"hosted_invoice_url": "https://invoice.stripe.com/..",
"attempt_count": 2,
"reason": "Your card was declined."
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
## billing.trial_ending
The creator's platform trial is about to convert and take its first payment.
### When this fires
The creator's own Subscriby trial crosses a reminder milestone on its way to converting. The trial collects a card at checkout and converts by itself, so this is the only advance notice a consumer gets before the first charge appears.
Milestones are configured per deployment and fire largest-first. The default ladder is **72 hours** then **24 hours** before `trial_ends_at`, so a standard 7-day trial produces exactly two events. A milestone wider than the trial itself is skipped rather than fired at signup, so a trial shorter than 72 hours emits only the 24-hour event. Fires once per milestone, not once per trial; read `hours_remaining` to tell the two apart.
Subscriby emails the creator alongside every emission, and mirrors it to their connected account when one is linked.
### Reading `amount` correctly
When `amount_estimated` is `false`, the figure comes from the upcoming Stripe invoice and already includes tax, any discount, and the transaction fees the creator's own sales metered during the trial. It is what will actually be taken.
When `amount_estimated` is `true`, Stripe could not be reached and the figure is the plan's current price including any promotion in force. Treat it as indicative: do not reconcile against it, and do not surface it as a confirmed charge.
A creator carrying a credit grant or account balance will legitimately see `"0.00"`; the trial still converts, the invoice is simply covered.
### Caveats
- **Not once per trial.** The default ladder emits twice. De-duplicate on `subscription_id` + `hours_remaining` if your consumer must act only once.
- A trial the creator cancels before it converts stops emitting: no further milestones fire and no charge is raised.
- The milestone ladder is deployment configuration, so do not hard-code `72` and `24`. Read `hours_remaining`.
- `hours_remaining` is the milestone that fired, not the exact time left. The sweep runs hourly, so the true remaining time is somewhere within an hour below the value.
### Related events
- `billing.invoice_created`: the invoice this warning was about, once the trial actually converts.
- `billing.payment_failed`: fires instead if the card on file is declined at conversion.
- `subscription.trial_converting`: the subscriber-tier equivalent, for a creator's own members.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `billing.trial_ending` | yes | The event name, always `billing.trial_ending` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.creator_id` | string (uuid) | yes | Creator whose trial is converting. |
| `data.plan_id` | string \| null (uuid) | yes | Platform plan the trial converts onto. `null` if the tier could not be resolved. |
| `data.subscription_id` | string (uuid) | yes | The creator's platform subscription row. |
| `data.trial_ends_at` | string (date-time) | yes | When the trial converts and the charge is raised. |
| `data.hours_remaining` | integer | yes | Which milestone fired: `72` or `24` on the default ladder. Not the live countdown. |
| `data.amount` | string \| null (decimal) | yes | Total that will be charged, e.g. `"19.00"`. `null` only when no currency could be resolved. |
| `data.currency` | string \| null | yes | Uppercase ISO 4217 code for `amount`. |
| `data.amount_estimated` | boolean | yes | `true` when `amount` is the plan's current price including any promotion in force because the upcoming invoice was unreachable. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "billing.trial_ending",
"created_at": "2026-08-23T09:00:00Z",
"api_version": "2026-05-01",
"project_id": null,
"data": {
"creator_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"plan_id": "c4e82f16-93a7-4d5b-b81c-6e0f27a94d3b",
"subscription_id": "5b7e2d40-1a86-4c39-97f2-e83d0b16c5a4",
"trial_ends_at": "2026-08-26T09:00:00Z",
"hours_remaining": 72,
"amount": "19.00",
"currency": "USD",
"amount_estimated": false
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
## billing.grace_period_warning
The creator is approaching automatic lockdown for an unpaid invoice.
### When this fires
The creator's account reaches the warning milestone on its overdue clock. That milestone depends on the plan's base price: **day 3** for a creator on a zero-base-price (Free) plan, whose lockdown lands on day 7, and **day 23** for a paid plan, whose lockdown lands on day 30. Fires once per past-due cycle.
Read `days_until_lockdown` from the payload rather than assuming a fixed offset; it carries the actual remaining days for that creator's tier.
### Caveats
- Each past-due cycle produces at most one `billing.grace_period_warning`. If the account recovers and re-enters past_due later, a new cycle and a new warning may issue.
- After lockdown the clock keeps running: a deletion warning at 53 days locked and account deletion at 60 days locked on paid/trial thresholds (3 and 7 days on Free). Neither stage emits a `billing.*` webhook; they surface as notifications only.
### Related events
- `billing.payment_failed`: the chain of failed collections leading here.
- `billing.account_locked`: fires when the lockdown day arrives: 7 days after this event on a paid tier, 4 on Free or an unconverted trial.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `billing.grace_period_warning` | yes | The event name, always `billing.grace_period_warning` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.creator_id` | string (uuid) | yes | Creator approaching lockdown. |
| `data.past_due_since` | string (date-time) | yes | When the past-due state began. |
| `data.days_until_lockdown` | integer | yes | Whole days from emission until lockdown for this creator's tier. Typically `7` on a paid tier and `4` on Free or an unconverted trial; lower if the daily sweep is late, and `0` at the floor. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "billing.grace_period_warning",
"created_at": "2026-05-11T08:00:00Z",
"api_version": "2026-05-01",
"project_id": null,
"data": {
"creator_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"past_due_since": "2026-04-18T00:00:00Z",
"days_until_lockdown": 7
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
## billing.account_locked
The account hits its past-due lockdown.
### When this fires
The creator's account reaches its tier's past-due lockdown: day 30 on a paid tier, day 7 on Free or an unconverted trial. New signups on the project's portal pages are blocked and the dashboard surfaces a lockdown banner.
### Caveats
- Lockdown is reversible: a successful payment fires `billing.invoice_paid` and the lockdown is cleared automatically. There is no dedicated "account_unlocked" event.
- Existing subscribers retain access to project resources during lockdown; only new signups are blocked.
### Related events
- `billing.grace_period_warning`: the warning that precedes it, 7 days earlier on a paid tier and 4 on Free or an unconverted trial.
- `billing.invoice_paid`: automatic unlock signal.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `billing.account_locked` | yes | The event name, always `billing.account_locked` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.creator_id` | string (uuid) | yes | Creator that was locked. |
| `data.past_due_since` | string (date-time) | yes | When the past-due state began. |
| `data.locked_at` | string (date-time) | yes | When the lockdown took effect. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "billing.account_locked",
"created_at": "2026-05-18T00:00:00Z",
"api_version": "2026-05-01",
"project_id": null,
"data": {
"creator_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"past_due_since": "2026-04-18T00:00:00Z",
"locked_at": "2026-05-18T00:00:00Z"
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
## billing.tier_upgraded
The creator upgrades to a higher Subscriby tier.
### When this fires
The creator switches their Subscriby subscription to a higher-priced tier. Pro-ration is applied through the payment provider, except when the creator is still inside their platform trial, where the switch is not prorated because nothing has been paid yet.
### Caveats
- Tier changes can unlock features (e.g. Teams on Growth+); event consumers should refresh tier caches on receipt.
- Pro-ration charges fire `billing.invoice_created` separately for the proration line (not on an upgrade taken during the platform trial).
### Related events
- `billing.tier_downgraded`: opposite direction.
- `billing.invoice_created`: proration invoice.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"."` under the endpoint's secret. Verify it before acting, and refuse a `t` more than 300 seconds from now. During a secret rotation a `v0=` signature under the previous secret may precede `v1=`. |
| `SB-Event-Id` | The event's ULID, bare. The envelope's `id` is the same ULID prefixed `evt_`, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id. |
| `SB-Event-Name` | The event name, the same as the envelope's `type`. |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | Always `Subscriby-Webhooks/1.0`. |
### Delivered body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A ULID unique to the event, prefixed `evt_`. Every retry carries the same id, so it is the key to deduplicate on. |
| `type` | `billing.tier_upgraded` | yes | The event name, always `billing.tier_upgraded` here. |
| `created_at` | string (date-time) | yes | When the event was emitted, server time, ISO 8601. |
| `api_version` | string | yes | The contract version of the payload shape, `2026-05-01` today. A breaking change to a shape ships under a new version. |
| `project_id` | string \| null (uuid) | yes | The project the event belongs to; null for team-scoped and billing events. |
| `data` | object | yes | The event-specific payload. Keys that do not apply to a given emission are omitted rather than sent as null, so check for presence. |
| `data.creator_id` | string (uuid) | yes | Creator whose tier changed. |
| `data.from_tier` | string (uuid) | yes | Previous tier **plan id**. Always present: a first subscription goes through checkout and emits no tier event. |
| `data.to_tier` | string (uuid) | yes | New tier **plan id**. |
| `data.effective_at` | string (date-time) | yes | Always the emission time: upgrades take effect immediately. |
#### Example: Delivery
```json
{
"id": "evt_01HX...",
"type": "billing.tier_upgraded",
"created_at": "2026-05-18T10:05:00Z",
"api_version": "2026-05-01",
"project_id": null,
"data": {
"creator_id": "2a91c4e7-6f38-4b52-8e0d-9c1a7b3f5d80",
"from_tier": "308eb3da-1eb6-11f0-b4f2-d8bbc173f8c3",
"to_tier": "882b72bb-1eb6-11f0-b4f2-d8bbc173f8c3",
"effective_at": "2026-05-18T10:05:00Z"
}
}
```
### Your response
- **2XX**: Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.
- **default**: Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or `POST /v1/webhook-deliveries/{delivery}/retry`. After 20 consecutive failures the endpoint is paused until it is resumed.
## billing.tier_downgraded
The creator downgrades to a lower Subscriby tier.
### When this fires
The creator switches their Subscriby subscription to a lower tier. The change lands at the end of the period they have already paid for, not at `effective_at`: the current phase runs to its existing end date and the lower tier begins after it.
### Caveats
- `effective_at` is stamped at emission time and is never in the future, even when the provider defers the billing change to the next renewal boundary. Do not use it to schedule feature revocation.
- Tier-locked features (Teams, custom roles, etc.) may become read-only or hidden once the current paid period ends, never at `effective_at`.
### Related events
- `billing.tier_upgraded`: opposite direction.
- `billing.tier_cancelled`: terminal downgrade path.
### Request headers
| Header | Description |
| --- | --- |
| `SB-Signature` | `t=,v1=`: the HMAC-SHA256 of `"