# PlanVortex API — full reference in plain text > The complete PlanVortex REST API, flattened for reading. Same source as https://planvortex.com/openapi.json, which is the machine-readable version. Base URL: https://api.planvortex.com/v1.0.0 Operations: 136 Auth: OAuth 2.0 client credentials. POST /oauth/token with grant_type=client_credentials, client_id and client_secret, then send the access token as `Authorization: Bearer `. Apps are available on all four plans, the free one included — no paid plan is required to integrate. Routes marked `current_user` only accept a signed-in user's token. Errors: every error is {code, message, data} with a numbered code from a shared catalogue. Official libraries: `npm i planvortex` (Node), `pip install planvortex` (Python), `npx -y planvortex-mcp` (MCP server for AI agents — see https://planvortex.com/en/agents). This file is generated from swagger/ by scripts/build-llms-full.js. Do not edit by hand. ## accounts Social networks accounts connected to the organization ### GET /organizations/{id_organization}/connect_links Get the connection links of every connectable network The authorization URL of each network, so the user can connect an account to this organization. **A network that cannot produce a link simply does not appear.** That is a legitimate answer, not a failure: it is what happens with `discord` in an organization that has not saved its own bot credentials yet (see `PUT /organizations/{id_organization}/social_credentials/{social_network}`). **An app cannot call this.** Connecting a social account is an OAuth flow with a person in front of it, so this endpoint only accepts a user token or a temporal connect token; with app credentials it answers error 519. The way an integration does it is to issue a temporal connect token with `GET /organizations/{id_organization}/temporal_connect_token` and hand it to its end user. **Read `authorization`, not `link`.** Eleven of the thirteen networks are `redirect` and you send the person to `link`. Two are not, and neither of them fails visibly if you treat it as one: • **WhatsApp is not a URL at all.** Its sign-up is Meta's Embedded Signup, a popup you raise with the Facebook JavaScript SDK, so its `link` is an empty string and everything you need to open that popup travels in `authorization`. A client that loops over the list and redirects to `link` sends its user to its own page. • **Telegram has a link and still is not a redirect.** It opens a chat with the PlanVortex bot, and nobody comes back from it: the account is born minutes later, from the bot being added to a channel, and it is announced over the WebSocket. Open it in another tab and keep listening; redirect to it and there is nobody left to tell. operationId: getConnectLinks callers: current_user, temporal_token - id_organization (path, string, required): Organization identifier - social_network (query, array of SocialNetwork, optional): Only these networks. Repeat the parameter for several. Omit it and every connectable network comes back. - redirect_uri (query, string, optional): Which PlanVortex front the network sends the user back to, for a white-labelled deployment. It has to be one of the fronts the server has registered (`FRONT_URL_REDIRECT`) or the call answers error 532 — it is **not** your app's `redirect_urls`, and it cannot be a URL of yours: the networks only accept redirect URIs registered in their own app settings. Where your user goes once they have finished is the `redirect_uri` of `GET /organizations/{id_organization}/temporal_connect_token`. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | | `532` | `redirect_uri` is not one of the fronts registered on the server | 500 Unhandled error by the server ### GET /organizations/{id_organization}/accounts Get social accounts Get social accounts list for the current organization paginated operationId: getAccountList callers: current_user, current_app - id_organization (path, string, required): Organization identifier - offset (query, integer, optional): Number of records from which you will get roles (pagination) - limit (query, integer, optional): The limit of records will be retrieved (pagination) - name (query, string, optional): Free-text search over the account's name and username. - accounts (query, array of string, optional): Only these accounts, by identifier. Repeat the parameter for more than one. - social_network (query, array of SocialNetwork, optional): Only accounts of these networks. Repeat the parameter for more than one. - capability (query, "publications" | "messages" | "products" | "webhooks" | "persistent_menu" | "comments", optional): Only accounts whose network can do this. It is the same matrix `GET /social_capabilities` publishes, applied server-side: the way to ask for 'the accounts I can publish with' without keeping your own table of which network does what. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/accounts/{id_account} Get social accounts by identifier Get social account by the received identifier operationId: getAccount callers: current_user, current_app - id_account (path, string, required): Account identifier - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `701` | Account doesn't exists or invalid | | `523` | Invalid application | 500 Unhandled error by the server ### PUT /organizations/{id_organization}/accounts/{id_account} Update social accounts by identifier Update social account by the received identifier operationId: updateAccount callers: current_user, current_app - id_organization (path, string, required): Organization the account belongs to - id_account (path, string, required): Connected social account identifier body (application/json) — required - name (string): Display name for the connected account inside PlanVortex. It does not rename anything on the social network. If omitted, the current name is kept. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `701` | IAccount doesn't exists or invalid | | `523` | Invalid application | 500 Unhandled error by the server ### DELETE /organizations/{id_organization}/accounts/{id_account} Delete social accounts by identifier Delete social account by the received identifier operationId: deleteAccount callers: current_user, current_app - id_account (path, string, required): Account identifier - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `701` | Account doesn't exists or invalid | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/accounts/{id_account}/metric_list Retrieve a list of possible metrics operationId: getMetricList callers: current_user, current_app - id_account (path, string, required): Account identifier - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `701` | Account doesn't exists or invalid | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/accounts/{id_account}/metrics Retrieve a list of possible metrics operationId: getAccountStats callers: current_user, current_app - id_account (path, string, required): Account identifier - id_organization (path, string, required): Organization identifier - from_date (query, string (date-time), optional): Start of the range. Defaults to a day before `to_date`. - to_date (query, string (date-time), optional): End of the range. Defaults to now. - names (query, array of string, optional): Only these metrics, by their RAW name — the ones `GET .../metric_list` returns. Repeat the parameter for more than one. Without it, every measured metric comes back. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `701` | Account doesn't exists or invalid | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/account-connect/{social_network} Complete the connection of a social account The endpoint the social network sends the user back to after they authorize. It turns the network's callback into one or more PlanVortex accounts. **You do not build this URL, the network does.** It is the `redirect_uri` inside the link that `GET /organizations/{id_organization}/connect_links` handed out, so the query parameters are whatever the network appends — typically `code` and `state`. Pass them through untouched. **One authorization can produce several accounts.** A Facebook user with four pages ends up with four; a Discord authorization produces the channel that was picked. **It answers 200 even when it fails.** The result carries `errorCode` and `errorMsg` instead of an error body, because the browser lands here from a redirect and a raw 400 would be a broken page. Check `errorCode`: empty means everything went well. **An app cannot call this** — it needs a user token or a temporal connect token (error 519 otherwise). See `GET /organizations/{id_organization}/temporal_connect_token`. **A temporal connect token is spent here.** Once this call succeeds, that token cannot connect anything else and answers error 543; the `enable` calls that finish the same connection still work until it expires. And if the token was issued for one network, calling this for another answers error 544. **Telegram does not come through here, and cannot be made to.** That network has no callback: the account is created by PlanVortex when the bot is added to a channel, and what authorizes it is a single-use voucher minted at that moment and spent in the same breath — it never leaves the server, so calling this endpoint for `telegram` answers error 700. It is deliberate: the bot is shared, so without it anyone could hang any channel where that bot is an admin off their own organization by passing a chat id by hand. What an integration listens for instead is the `new_account` webhook notification. **And a Telegram account arrives already active.** In the other twelve networks this endpoint hands you accounts that are still off, and you pick which ones spend a plan slot with `POST .../accounts/{id_account}/enable`. Here there is nothing to call: one channel arrives — the one the person picked in Telegram — and PlanVortex takes the slot for it right then. If the plan has no free slot the account is still created, off, and the person is told so in the bot chat; `GET /organizations/{id_organization}/accounts` will not list it, because that listing only returns active accounts, but naming it in the `accounts` filter does return it. operationId: connectAccount callers: current_user, temporal_token - id_organization (path, string, required): Organization identifier - social_network (path, SocialNetwork, required): The network being connected. responses: 200 The result of the connection. **Read `errorCode` before trusting `accounts`.** 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `519` | This endpoint does not accept app credentials | | `543` | This temporal connect token has already connected an account. Issue a new one | | `544` | This temporal connect token was issued for a different social network | | `700` | The connection cannot be completed through this endpoint. It is what `telegram` always answers here: that network's accounts are created from the bot being added to a channel, never from a call of yours | | `1101` | Invalid organization, or a temporal token for a different one | 500 Unhandled error by the server ### POST /organizations/{id_organization}/accounts/{id_account}/enable Re-enable a disconnected account Brings back an account that was removed, without going through the authorization again — as long as its stored token still works. It takes a **slot of the plan**, so it fails with error 706 when the organization is already at its account limit. It also turns the network's webhooks back on, on any plan but the free one. **On `telegram` you normally never call this.** That network's accounts are born active — there is no authorization return in which to choose them, so PlanVortex takes the slot when the bot is added to the channel. The one case left is a channel that arrived while the plan was full: it stays off until a slot frees up, and this is what turns it on. **On `slack` this is also what puts the PlanVortex app inside the channel**, with `conversations.join`. It works on a **public** channel and it cannot work on a private one: Slack has no API for joining one. The account is enabled all the same and the connection looks healthy, and then the first publication fails with error 980 — so a private channel has to be told to the person **before** they pick it, together with the `/invite @PlanVortex` they have to run in that channel. Whether the app is inside travels in `extra_data.is_member`, and it can turn `false` later, the day somebody removes the app from an already connected channel. operationId: enableAccount callers: current_user, temporal_token - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier responses: 200 Enabled. When the call came from a temporal connect token with a `redirect_uri`, the answer is that URL instead of `{success: true}`. 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `700` | The account has no usable token: it has to be connected again | | `706` | The organization has no account slots left in its plan | | `519` | This endpoint does not accept app credentials | | `544` | This temporal connect token was issued for a different social network | 500 Unhandled error by the server ### GET /organizations/{id_organization}/accounts/{id_account}/persistent_menu Read the account's persistent menu The fixed menu shown inside the network's chat. **Only Meta messaging accounts have one** — ask `persistent_menu` in `GET /social_capabilities`; anywhere else this answers error 710. It is read live from the network. operationId: getPersistentMenu callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier responses: 200 The menu 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `710` | This network has no persistent menu | | `1101` | Invalid organization | 500 Unhandled error by the server ### POST /organizations/{id_organization}/accounts/{id_account}/persistent_menu Replace the account's persistent menu **Replaces the whole menu.** There is no partial update: what you send is what the chat shows, and an empty array removes it. One entry per locale. `default` is the fallback and has to be there. operationId: setPersistentMenu callers: current_user, current_app, temporal_token - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier body (application/json) — required - persistent_menu (AccountsPersistentMenu) responses: 200 The menu as the network stored it 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `710` | This network has no persistent menu | | `1101` | Invalid organization | 500 Unhandled error by the server ## ai_plans AI-generated weekly publication plans ### GET /clients/{id_client}/organizations/{id_organization}/ai_plans List AI publication plans Return the organization's AI plans ordered by creation date (desc), paginated. Requires the ai_plans:read permission. **Active plans by default, archived ones with `archived=true`** — never both at once. Archiving is visibility only (see `POST .../archive`): the plan leaves this listing and keeps every publication it had. **Cancelled plans are not listed either.** Deleting a plan sets it to `cancelled` rather than removing it, and this listing filters those out — so a plan you deleted simply stops appearing, while `GET` by id still returns it. Without `limit` the whole list comes back. operationId: getAiPlans callers: current_user, current_app - id_client (path, string, required): Client identifier - id_organization (path, string, required): Organization identifier - offset (query, integer, optional): Number of records to skip (pagination) - limit (query, integer, optional): Maximum number of records to return (pagination) - archived (query, boolean, optional): `true` returns the archived plans instead of the active ones. Anything other than the literal string `true` is read as false. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ### POST /clients/{id_client}/organizations/{id_organization}/ai_plans Create an AI publication plan Create a weekly AI publication plan. The plan is queued in state 'pending' and generated asynchronously by the generate-ai-plans job; poll GET by id while state is pending or generating. Validations at creation: the client plan allows AI (it contracts AI credits; Free does not), the accounts belong to the organization, there are enough AI credits for the deterministic base cost (orchestration + target texts), and there is room in the organization's monthly publication limit. Returns the created plan together with the deterministic cost estimate. Requires the ai_plans:create permission. Since the plan can be generated from a SOURCE (`template` + `source`), part of that validation is the source itself: the article is downloaded, the catalogue is read live and the product pictures are copied — all of it inside this request, so what does not work fails with the user in front of it. A plan whose source did not fit in the week is still created, and says so in ai_plan.warnings (2117). operationId: addAiPlan callers: current_user, current_app - id_client (path, string, required): Client identifier - id_organization (path, string, required): Organization identifier body (application/json): AiPlansAiPlanCreateRequest — required - prompt (string, required): Theme prompt written by the user. - template ("standard" | "from_images" | "from_text" | "from_catalog" | "campaign"): What the plan is generated FROM. Optional; defaults to `standard`, which is exactly what every plan did before templates existed — send nothing and nothing changes. A template is the **source** of the content, not a different flow: `shared`, `publish_days`, `language`, `tone` and the images stay cross-cutting options, and each template declares which of them it accepts. Sending one it does not accept is a 2106, not a silent ignore. Read the list, the costs and the fields from `GET /planner_templates`; do not hardcode them. - source (AiPlansAiPlanSourceInput): The source itself. Which fields it carries depends on `template`. Required for every template except `standard`, and validated at creation — 2112, 2113, 2114, 2115 or 2116 come back while the user is still there. - accounts (array of string, required): Account ids (belonging to the organization) to generate the plan for. - options (AiPlansAiPlanOptionsInput) responses: 200 Plan created and queued. estimated_cost is the deterministic upper bound in AI credits; the actual credits_spent may be lower if the model produces fewer publications/images. 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `806` | Invalid upload identifier. One of the gallery_uploads, or one of the `source.images`, does not belong to the organization. | | `924` | Max publications reached per month. There is no room in the organization's monthly publication limit for the generated posts. | | `941` | AI credits exhausted for this month. The base cost of the plan exceeds the remaining AI credits. The response data carries { used, limit, cost }. | | `2101` | This functionality requires a plan with artificial intelligence (available from the basic plan). | | `2103` | All accounts must belong to the organization. Also when `source.id_account_catalog` is not one of them. | | `2105` | AI plan requires at least one account. | | `2106` | Invalid AI plan options (missing prompt, invalid timezone, invalid week_start, invalid max_images or invalid publish_days). Also when an option is not accepted by the chosen template: `shared` on one whose `allows_shared` is false, or `gallery_uploads` on one whose `allows_gallery` is false. | | `2108` | The selected publish days leave no available slot in the plan week: every slot of the chosen days is already in the past. Pick more days or a later week_start. The response data carries { publish_days, week_start, timezone }. | | `2111` | Invalid planner template: `template` is not one of the ones published by `GET /planner_templates`. | | `2112` | Invalid or missing `source` for the chosen template: a field is missing, an id is not valid, there are more units than `max_source_items`, an image has no description, a product is no longer in the catalogue, or the event date falls before the plan week or more than 60 days after it. `data` says which one and why. | | `2113` | Could not read the source URL (`from_text`): the download failed, answered something that is not text or HTML, or the page carried no usable text. Paste the article in `source.text` instead. | | `2114` | The source URL points to a non-public address (`from_text`): loopback, private, link-local or CGNAT. Checked before every redirect and again when the socket opens. | | `2115` | The selected account has no usable product catalogue (`from_catalog`): its network has no products, or the catalogue could not be read — expired token, deleted catalogue, missing permission — or came back empty. | | `2116` | The source has no usable items: no images, no products, or no text at all (neither `url` nor `text`, or a pasted text under 200 characters). | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ### GET /clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan} Get an AI publication plan by id Return a single AI plan with its generated publications populated. This is the endpoint clients poll while state is pending or generating to detect when generation finishes. Requires the ai_plans:read permission. operationId: getAiPlan callers: current_user, current_app - id_client (path, string, required): Client identifier - id_organization (path, string, required): Organization identifier - id_ai_plan (path, string, required): AI plan identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `2100` | AI plan doesn't exist. The identifier is invalid or doesn't exist. | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ### DELETE /clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan} Delete an AI publication plan and its pending publications Delete the plan **and every publication of it that has not gone out yet**: the generated drafts and, if the plan was already validated, whatever was still scheduled. Two states are deliberately left alone: `sended`, because it is already live on the network and deleting it here would only lose its history while the post stays up, and `publishing`, which the publishing job holds at that very moment. AI credits already spent on generation are NOT refunded (same criterion as X: if the provider charged, we charge). A plan being generated cannot be deleted (state `generating`, error 2102): wait for the job to finish. To keep the plan and its publications and only take it out of the listing, archive it instead (`POST .../archive`). Requires the ai_plans:delete permission. operationId: deleteAiPlan callers: current_user, current_app - id_client (path, string, required): Client identifier - id_organization (path, string, required): Organization identifier - id_ai_plan (path, string, required): AI plan identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `2100` | AI plan doesn't exist. The identifier is invalid or doesn't exist. | | `2102` | Invalid AI plan state for this operation. | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ### POST /clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/validate Validate an AI publication plan Validate a generated plan: its draft publications without errors are re-validated and moved to 'ready', and the plan moves to 'validated'. From that point the normal publish-pending flow publishes them at their publish_date. Only valid from state 'generated'. Requires the ai_plans:update permission. operationId: validateAiPlan callers: current_user, current_app - id_client (path, string, required): Client identifier - id_organization (path, string, required): Organization identifier - id_ai_plan (path, string, required): AI plan identifier responses: 200 Successful operation. Returns the plan (state validated) with its publications populated. 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `2100` | AI plan doesn't exist. The identifier is invalid or doesn't exist. | | `2102` | Invalid AI plan state for this operation. Only a plan in state 'generated' can be validated. | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ### POST /clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/retry Retry a failed AI publication plan Re-queue a failed plan: it goes back to state 'pending' (attempts and error reset) and the background job regenerates it with the SAME data (prompt, accounts, options). Poll the plan while state is pending or generating, as after creation. Only valid from state 'failed'. Already-spent credits are NOT refunded and a new generation spends again; drafts left over from the failed attempt are discarded by the generation itself. If the failure was a business error (no AI credits, no monthly publication slots), retrying will fail the same way. Requires the ai_plans:update permission. operationId: retryAiPlan callers: current_user, current_app - id_client (path, string, required): Client identifier - id_organization (path, string, required): Organization identifier - id_ai_plan (path, string, required): AI plan identifier responses: 200 Successful operation. Returns the plan back in state 'pending'. 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `2100` | AI plan doesn't exist. The identifier is invalid or doesn't exist. | | `2102` | Invalid AI plan state for this operation. Only a plan in state 'failed' can be retried. | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ### POST /clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/archive Archive an AI publication plan Take the plan out of the default listing and move it to the archived one (`GET .../ai_plans?archived=true`). It is **visibility only**: no publication is touched — anything scheduled keeps publishing — no credits are refunded, and nothing is cancelled. It is reversible with `POST .../unarchive` and valid in **any state**, `generating` included, because it does not interrupt the generation job. Archiving an already archived plan just refreshes `archived_date`. Requires the ai_plans:update permission. operationId: archiveAiPlan callers: current_user, current_app - id_client (path, string, required): Client identifier - id_organization (path, string, required): Organization identifier - id_ai_plan (path, string, required): AI plan identifier responses: 200 Successful operation. Returns the plan with `archived_date` set. 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `2100` | AI plan doesn't exist. The identifier is invalid or doesn't exist. | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ### POST /clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/unarchive Unarchive an AI publication plan Return the plan to the default listing: `archived_date` is cleared. Unarchiving a plan that was not archived does nothing and answers 200. Requires the ai_plans:update permission. operationId: unarchiveAiPlan callers: current_user, current_app - id_client (path, string, required): Client identifier - id_organization (path, string, required): Organization identifier - id_ai_plan (path, string, required): AI plan identifier responses: 200 Successful operation. Returns the plan without `archived_date`. 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `2100` | AI plan doesn't exist. The identifier is invalid or doesn't exist. | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ### POST /clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/publications/{id_publication}/regenerate Regenerate the text or the image of one publication of the plan Asks the model for another version of a single publication, for when the generated one does not work. **It costs credits, and they are charged per use.** The balance is checked before the call and the real cost is charged afterwards, accumulating in the plan's `credits_spent`. As a reference: regenerating a text is billed around 2 credits and an image around 70, but what is actually charged is the provider's real cost. It only works **while the plan is under review** (state `generated`) and on its drafts: a publication already validated or published is not a draft any more. **What can be regenerated depends on the plan template**, and it is published in `regenerate` (`GET /planner_templates`). `from_images` and `from_catalog` cannot regenerate the image: nobody regenerates the photo they took themselves, and offering the button anyway charges the user 70 credits to replace their own photo with an invented one. Asking for it anyway is a 2102. operationId: regenerateAiPlanPublication callers: current_user, current_app - id_client (path, string, required): Client identifier - id_organization (path, string, required): Organization identifier - id_ai_plan (path, string, required): AI plan identifier - id_publication (path, string, required): Identifier of the plan's publication to regenerate body (application/json) — required - target ("text" | "image", required): What to regenerate. `image` needs the plan to have been created with images allowed. responses: 200 The regenerated publication and what the plan has spent so far 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `2102` | The plan is not under review (only a `generated` plan can be regenerated), or this plan template does not allow regenerating that target — read `regenerate` in `GET /planner_templates` | | `2106` | Invalid `target`: it has to be `text` or `image` | | `935` | That publication does not belong to this plan, or is no longer a draft | | `941` | AI credits exhausted for this month | | `2101` | This functionality requires a plan with artificial intelligence | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ## apps Client apps: the credentials an integration authenticates with ### GET /clients/{id_client}/apps List the client's apps The apps of a client. Secrets never travel here: to read one, ask for it explicitly with the `secret` endpoint. **Needs a USER token, not an app token.** With client credentials it answers `ERROR_CODE_512`. **No plan is required**: apps are on all four plans, the free one included — what the plan decides is how many you can hold at once and how fast you can call. operationId: getClientApps callers: current_user - id_client (path, string, required): Client identifier - offset (query, integer, optional): Records to skip (pagination) - limit (query, integer, optional): Records per page (pagination) responses: 200 Apps 400 Error. Classify by `code` in the body, never by the HTTP status. ### POST /clients/{id_client}/apps Create an app Creates the app and its credentials. **How many apps fit is decided by the plan**, not by a fixed limit of one: 1 on free, 2 on basic, 5 on pro and 10 on custom. Going over answers `ERROR_CODE_1308`, which carries `limit` and `used` in its `data`. Deleting an app frees its slot immediately, because only live apps count. If you just need to rotate credentials, update the existing app rather than creating another. **The user's email must be verified** (`ERROR_CODE_546`). This is the only route in `/apps` that asks for it: an app is a key to the whole public API, and this is what stops a throwaway address from minting them in bulk. Reading, updating and deleting an app you already own do not check it. **The identifier has a format** (error 533): lowercase letters, numbers, `.`, `-` and `_`, starting with a letter or a number, 3 to 64 characters. Pick it carefully — it cannot be changed later (error 547). Every URL is validated: a bad entry in `allowed_domains` answers 531, in `redirect_urls` 532, and a bad `webhook_url` answers 535. **The secret is not in the response.** Read it with `GET /clients/{id_client}/apps/{id_app}/secret`. **Needs a USER token, not an app token.** With client credentials it answers `ERROR_CODE_512`. **No plan is required**: apps are on all four plans, the free one included — what the plan decides is how many you can hold at once and how fast you can call. operationId: createClientApp callers: current_user - id_client (path, string, required): Client identifier body (application/json): AppsClientAppInput — required - name (string, required): A name for the app. Cannot be blank (error 525). - keycloak_client_idenfifier (string, required): The app's `client_id`, which is what you send to `POST /oauth/token`. **Format is enforced** (error 533): lowercase letters, numbers, `.`, `-` and `_`, starting with a letter or a number, between 3 and 64 characters — `shop-integration`, not `Shop Integration`. Keycloak itself accepts anything, so an identifier with a space in it used to create an app that could never get a token. It also has to be unique across PlanVortex (error 534), and **it cannot be changed once the app exists** (error 547): it is what your integration authenticates with, so renaming it would lock out everything already using the old one. Send it unchanged on an update, or leave it out. The spelling of the field is historical and kept for compatibility. - allowed_domains (array of string): Origins allowed to call the API with this app's identity. Every entry has to be a valid URL (error 531). - redirect_urls (array of string): URLs the connect flow is allowed to come back to. A `redirect_uri` that is not in this list is refused when issuing a temporal connect token (error 532). - webhook_url (string): Where PlanVortex posts events. Has to be a valid URL (error 535). See `ClientApp.webhook_url` for what arrives and how it is signed. responses: 200 The created app 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /clients/{id_client}/apps/{id_app} Get one app The app's record, without its secret. Note the envelope: `{client_app}` here, `{client_apps, total}` in the list. **An app token works here.** This is the one part of `/apps` that does: an app can read and update its own record, but it cannot list, create, delete, or read the secret. **No plan is required**: apps are on all four plans, the free one included. operationId: getClientApp callers: current_user, current_app - id_client (path, string, required): Client identifier - id_app (path, string, required): App identifier responses: 200 The app 400 Error. Classify by `code` in the body, never by the HTTP status. ### PUT /clients/{id_client}/apps/{id_app} Update an app Replaces the app's configuration. **Every field is overwritten with what you send**, so a `redirect_urls` you leave out becomes an empty list — send the whole object, not a patch. Changing `webhook_url` takes effect on the next event; there is no verification handshake. **It replaces every field with what the body carries.** `name`, `keycloak_client_idenfifier`, `allowed_domains`, `redirect_urls` and `webhook_url` are all written as sent, so omitting one erases it — sending an update without `webhook_url` turns the webhook off. Read the app first and send it back whole. **`keycloak_client_idenfifier` is the one field that is NOT overwritten.** Sending a different one answers `ERROR_CODE_547` and nothing is written: it is the `client_id` your integration authenticates with, and renaming it would lock out everything already asking for tokens with the old one. Send it unchanged, or leave it out. If you need a different identifier, create another app. **An app token works here.** This is the one part of `/apps` that does: an app can read and update its own record, but it cannot list, create, delete, or read the secret. **No plan is required**: apps are on all four plans, the free one included. operationId: updateClientApp callers: current_user, current_app - id_client (path, string, required): Client identifier - id_app (path, string, required): App identifier body (application/json): AppsClientAppInput — required - name (string, required): A name for the app. Cannot be blank (error 525). - keycloak_client_idenfifier (string, required): The app's `client_id`, which is what you send to `POST /oauth/token`. **Format is enforced** (error 533): lowercase letters, numbers, `.`, `-` and `_`, starting with a letter or a number, between 3 and 64 characters — `shop-integration`, not `Shop Integration`. Keycloak itself accepts anything, so an identifier with a space in it used to create an app that could never get a token. It also has to be unique across PlanVortex (error 534), and **it cannot be changed once the app exists** (error 547): it is what your integration authenticates with, so renaming it would lock out everything already using the old one. Send it unchanged on an update, or leave it out. The spelling of the field is historical and kept for compatibility. - allowed_domains (array of string): Origins allowed to call the API with this app's identity. Every entry has to be a valid URL (error 531). - redirect_urls (array of string): URLs the connect flow is allowed to come back to. A `redirect_uri` that is not in this list is refused when issuing a temporal connect token (error 532). - webhook_url (string): Where PlanVortex posts events. Has to be a valid URL (error 535). See `ClientApp.webhook_url` for what arrives and how it is signed. responses: 200 The updated app 400 Error. Classify by `code` in the body, never by the HTTP status. ### DELETE /clients/{id_client}/apps/{id_app} Delete an app Revokes the credentials and marks the app as deleted. **Tokens already issued stop working**: the token endpoint checks that the app still exists before answering, and requests carrying an old token no longer resolve to an app. **Needs a USER token, not an app token.** With client credentials it answers `ERROR_CODE_512`. **No plan is required**: apps are on all four plans, the free one included — what the plan decides is how many you can hold at once and how fast you can call. operationId: deleteClientApp callers: current_user - id_client (path, string, required): Client identifier - id_app (path, string, required): App identifier responses: 200 Deleted 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /clients/{id_client}/apps/{id_app}/secret Read the app's secret Returns the `client_secret` in clear. It is a separate endpoint precisely so that the secret never travels inside a listing that gets logged, cached or drawn on a screen by accident. The same secret is what signs the outgoing webhooks (`x-hub-signature`, `x-hub-signature-256`). **Needs a USER token, not an app token.** With client credentials it answers `ERROR_CODE_512`. **No plan is required**: apps are on all four plans, the free one included — what the plan decides is how many you can hold at once and how fast you can call. It comes straight from Keycloak, so it is the live secret and not a copy: treat the response as a credential and never log it. operationId: getClientAppSecret callers: current_user - id_client (path, string, required): Client identifier - id_app (path, string, required): App identifier responses: 200 The secret 400 Error. Classify by `code` in the body, never by the HTTP status. ### POST /clients/{id_client}/apps/{id_app}/secret Regenerate the app's secret Rotates the `client_secret` and returns the new one. **The previous secret stops working the moment this answers** — there is no grace period and no overlap. Anything still holding it gets `invalid_client` from `POST /oauth/token` on its very next call, so roll it out before you rotate, not after. **It also changes how your webhooks are signed.** `x-hub-signature` and `x-hub-signature-256` are HMACs of the body computed with this same secret, so a receiver that verifies the signature will start rejecting legitimate deliveries until it is updated too. The new value comes back only here. If you lose it, read it again with `GET /clients/{id_client}/apps/{id_app}/secret` — it is not stored anywhere on our side except Keycloak. **Needs a USER token, not an app token** (`ERROR_CODE_512`), and the `client_app:update` permission — reading the secret changes nothing, this invalidates what was there. operationId: regenerateClientAppSecret callers: current_user - id_client (path, string, required): Client identifier - id_app (path, string, required): App identifier responses: 200 The new secret 400 Error. Classify by `code` in the body, never by the HTTP status. ## authentication Exchanging credentials for an access token ### POST /oauth/token Exchange client credentials for an access token The way an integration authenticates. Send the app's `client_id` and `client_secret` and get back a bearer token to put in `Authorization` on every other call. **This endpoint is different from the rest of the API in three ways, and all three matter:** 1. **It takes no token** — it is the one that gives you one. It is also the only endpoint with no authentication at all. 2. **It accepts `application/x-www-form-urlencoded`**, which is what OAuth 2 says and what a generic OAuth client sends. JSON works too. 3. **Its errors are OAuth 2 errors** — `{error, error_description}` with a 400 or a 401 — and *not* the `{code, message, data}` of everywhere else. This is the single exception in the whole API, and it exists so a generic OAuth client understands the answer. Credentials travel either in the body (`client_secret_post`) or in `Authorization: Basic base64(client_id:client_secret)` (`client_secret_basic`). Both are standard and both are accepted. **Cache the token until it expires.** The endpoint is rate limited per `client_id` and IP — 30 attempts a minute, and 10 *failures* in five minutes — so asking for a fresh token on every call will eventually get a 429 with a `Retry-After`. A correct integration asks once and refreshes shortly before `expires_in`. What comes back is an access token and nothing else: **no refresh token and no id token**. When it expires, ask again. operationId: issueToken body (application/x-www-form-urlencoded): AppsTokenRequest — required - grant_type ("client_credentials", required): Only `client_credentials` is supported. - client_id (string): The app's identifier. Can travel here or in `Authorization: Basic`. - client_secret (string): The app's secret. Can travel here or in `Authorization: Basic`. - scope (string): Optional. Passed through to the identity provider; there are no PlanVortex-defined scopes today. body (application/json): AppsTokenRequest — required - grant_type ("client_credentials", required): Only `client_credentials` is supported. - client_id (string): The app's identifier. Can travel here or in `Authorization: Basic`. - client_secret (string): The app's secret. Can travel here or in `Authorization: Basic`. - scope (string): Optional. Passed through to the identity provider; there are no PlanVortex-defined scopes today. responses: 200 The access token. The response is never cached (`Cache-Control: no-store`). 400 `invalid_request` when `client_id` or `client_secret` is missing, `unsupported_grant_type` when `grant_type` is not `client_credentials`. 401 `invalid_client`: the credentials are not valid, or the app no longer exists. **The message does not say which**, on purpose — telling the two apart is exactly what somebody enumerating clients wants to know. 429 `slow_down`: too many attempts, or too many failures. The `Retry-After` header says how many seconds to wait. ## catalog Supported networks, their capabilities and their limits ### GET /social_networks List the supported social networks Every network PlanVortex can connect, as a flat array of identifiers. This is the list every other enumeration in the API is built from. It grows several times a year, so a client that hardcodes it will reject a value it should have accepted. operationId: getSocialNetworks callers: current_user, current_app, temporal_token responses: 200 Supported networks 400 Error ### GET /allowed_social_publications List the networks that can publish The subset of `/social_networks` that accepts publications. `google_business` is not in it: a local listing receives reviews, it does not receive posts. operationId: getAllowedSocialPublications callers: current_user, current_app, temporal_token responses: 200 Networks that accept publications 400 Error ### POST /allowed_social_messages List the networks with direct messages The subset of `/social_networks` whose conversations can be read and answered from PlanVortex. **It is a POST and it takes no body.** That is historical, not a design decision, and it is kept as it is because changing the verb would break every integration already calling it. Send an empty body. operationId: getAllowedSocialMessages callers: current_user, current_app, temporal_token responses: 200 Networks with conversations 400 Error ### GET /social_limits Per-network publication limits Every limit a publication is validated against, indexed by limit and then by network. Read it before building a composer. A few traps worth knowing: • **`characters` is not always the only text limit.** Bluesky counts 300 *graphemes* **and** 3.000 *bytes*; the second one travels in `max_post_bytes`, where `0` means "this network does not measure text in bytes". A family emoji is one grapheme and eleven UTF-16 units, so counting with `String.length` is wrong in both directions. • **`0` in `title_characters` means the network has no title field**, not a title of zero length. • **`comment_characters` is a different limit from `characters`.** Facebook takes 63.206 in a post and 8.000 in a comment. • **`characters` is not one number per network either.** Telegram takes 4.096 in a text post and **1.024** in the caption of a photo or a video, and it is the same composer field: the second number is the key `telegram_media`. Over the limit the publication is created in state `withErrors` with `publication_errors[].code = 967`, which carries `characters`, `max_characters` and `has_media`. • Every network in `/social_networks` appears in every map. A missing key is a bug, and the backend's conformance suite fails on it. operationId: getSocialLimits callers: current_user, current_app, temporal_token responses: 200 Limits per network 400 Error ### GET /publication_limits Publication limits that do not depend on the network Limits that apply to a publication whatever network it goes to. Today there is one: how many times a failed publication can be retried by hand with `POST /organizations/{id_organization}/publish/{id_publication}/retry`. It is published for the same reason as `/social_limits`: a 3 written into a client stays a 3 the day the server raises it to 5, and the button would promise a retry the API refuses. operationId: getPublicationLimits callers: current_user, current_app, temporal_token responses: 200 Publication limits 400 Error ### GET /allowed_aspect_ratios Accepted aspect ratios per network The crops each network accepts, as the numeric ratio used to validate and the label to show, side by side at the same index. The keys are **not** always a network name: some networks have a different list per publication type, and those appear as `_` (`facebook_reels`, `facebook_stories`). A network missing from the map does not validate the ratio. operationId: getAllowedAspectRatios callers: current_user, current_app, temporal_token responses: 200 Aspect ratios per network 400 Error ### GET /planner_templates AI planner templates What an AI plan can be generated FROM, and what each source allows. A template is the **source** of the content, not the wizard's flow: sharing, publish days, language, tone and images stay cross-cutting options, and every template declares which of them it accepts. Send the chosen one as `template` when creating a plan (`POST /ai_plans`), together with its `source`. • **`generates_images: false` means the pictures come from the source** — your own photos, your catalogue — so the plan spends **no image credits at all**. A week of 7 publications with a picture on each goes from 519 credits to 48, and it is worth saying out loud before the plan is created. • **`regenerate` is per template.** A template that did not generate the image cannot regenerate it: offer that button anyway and the user pays 70 credits to replace their own photo with an invented one. • **`orchestration_cost` is an ESTIMATE, not the bill.** The real charge is per use (what the provider reports). `orchestration_cost_per_source_item` is what each unit of the source adds on top — one vision pass per image. • **A plan is WEEKLY, and the source does not extend it.** With `max_source_items` photos but fewer slots left in the week, the extra ones are dropped and the plan carries warning 2117 in `ai_plan.warnings`. Say it in your UI *before* creating the plan, not after charging for it. • **`source_fields` is what the source step is made of.** The simple types are ordinary controls; `uploads_with_description` and `catalog_products` are the signal that the step needs a component of its own. • **The fields carry their own limits.** `max` and `min` are in the field's own units — characters of a text, items of a list, **days** for a date — and `source_requires_any` names the fields of which at least one is needed (`from_text`: the URL or the pasted text). Read them from here; hardcoding them is how a wizard ends up rejecting at a number the server does not use. operationId: getPlannerTemplates callers: current_user, current_app, temporal_token responses: 200 Available planner templates 400 Error ### GET /social_capabilities What every network supports The full matrix network → capabilities. It is the **single source of truth** about what a network can do: the catalogue publishes it, the backend checks it before acting, and your integration should filter its account pickers with it rather than keeping a list of its own. `comments` is the coarse gate — whether the network has comments at all. Which *actions* it allows on one is a finer question and lives in `GET /social_comment_actions`, because the shape here is `{[capability]: boolean}` and nesting an object inside would break it. Today ten networks answer `comments: true`: Facebook, Instagram, Threads, LinkedIn, X, YouTube, Google Business, Bluesky, Discord and Telegram. TikTok and WhatsApp answer `false`, for reasons of theirs and not ours. A `true` here is about the **network**, not about one account of it: a Telegram channel with no linked discussion group answers 965 on its comments even though the network has them. Only needs authentication. operationId: getSocialCapabilities callers: current_user, current_app, temporal_token responses: 200 Successful operation 400 Error. Classify by `code` in the body, never by the HTTP status: every domain error travels with HTTP 400. ### GET /social_comment_actions What each network lets you do to a comment The fine-grained matrix: reply, hide, delete your own, delete somebody else's — network by network. Read it before painting controls. Networks without comments answer `false` to all four. The differences are real and each has a reason in the network's own API: | Network | reply | hide | delete_own | delete_others | | --- | --- | --- | --- | --- | | `facebook` | yes | yes | yes | yes | | `instagram` | yes | yes | yes | **no** — Instagram only lets you hide someone else's | | `youtube` | yes | yes | yes | yes — the channel owner really does moderate | | `linkedin` | yes | **no** — there is no hide endpoint | yes | yes | | `twitter` | yes | yes | yes | **no** — you cannot delete another account's post | | `google_business` | yes | **no** | yes — **your reply**, never the review | **no** | | `bluesky` | yes | yes — through the post's `threadgate` | yes | **no** — the reply lives in somebody else's repository | | `discord` | yes | **no** — Discord has no hide, only delete | yes | yes | | `telegram` | yes | **no** — Telegram has no hide either | yes | yes — with the bot as an admin of the discussion group, otherwise error 969 | | `threads` | yes | yes | yes | **no** — deleting somebody else's reply is not a permission you lack, it is a call that does not exist in the API | | `tiktok`, `whatsapp` | no | no | no | no | Only needs authentication. operationId: getSocialCommentActions callers: current_user, current_app, temporal_token responses: 200 Successful operation 400 Error. Classify by `code` in the body, never by the HTTP status: every domain error travels with HTTP 400. ## clients Everything about the clients you have access ### GET /clients Return all available clients Return a list of clients ordered by creation date (desc) operationId: getClients callers: current_user, current_app, temporal_token - getUse (query, boolean, optional): Boolean determine if the server will response with the actual use of each client - offset (query, integer, optional): Number of records from which you will get clients (pagination) - limit (query, integer, optional): The limit of records will be retrieved (pagination) responses: 200 Successful operation 400 Error. Classify by `code` in the body, never by the HTTP status: every domain error travels with HTTP 400. 500 Unhandled error by the server ### GET /clients/{id_client} Return a client by identifier operationId: getClient callers: current_user, current_app - id_client (path, string, required): Client identifier - getUse (query, boolean, optional): Boolean determine if the server will response with the actual use of each client responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### PUT /clients/{id_client} Update a client by identifier operationId: updateClient callers: current_user, current_app - id_client (path, string, required): Client identifier body (application/json) — required - name (string): Client name. - client_type ("personal" | "professional" | "enterprise" | "agency"): Type of client. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### GET /clients/{id_client}/organizations Get root organizations inside client Return a list of organizations inside the client operationId: getClientOrganizations callers: current_user, current_app - id_client (path, string, required): Client identifier - getUse (query, boolean, optional): Boolean determine if the server will response with the actual use of each client - offset (query, integer, optional): Number of records from which you will get clients (pagination) - limit (query, integer, optional): The limit of records will be retrieved (pagination) - name (query, string, optional): The limit of records will be retrieved (pagination) responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### POST /clients/{id_client}/organizations Create a new root organization inside a client Within a client, organizations can be created to separate and isolate data between them operationId: addParentOrganization callers: current_user, current_app - id_client (path, string, required): Client the organization will belong to body (application/json): ClientsOrganizationCreate — required - name (string, required): Organization name. - actual_plan (PlanData): Resources assigned to this organization, taken from what the client has contracted. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1400` | Account limit reached for the organization plan | | `1401` | Publication limit reached for the organization plan | | `1402` | Users limit reached for the organization plan | | `1403` | Shared space limit reached for the organization plan | | `1407` | Twitter credits limit reached for the organization plan. The sum of twitter_credits assigned to the client's organizations cannot exceed the client's plan twitter_credits. | | `1408` | AI credits limit reached for the organization plan. The sum of ai_credits assigned to the client's organizations cannot exceed the client's plan ai_credits. | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### PUT /clients/{id_client}/organizations/{id_organization} Update a root organization by identifier Update data from the received organization operationId: updateParentOrganization callers: current_user, current_app - id_client (path, string, required): Client the organization belongs to - id_organization (path, string, required): Organization identifier body (application/json): ClientsOrganizationUpdate — required - actual_plan (PlanData): New resource assignment. Validated against what is left of the client's contracted plan; lowering it below what the organization already uses is rejected. - stats_settings (StatsSettings): Statistics collection settings. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `1400` | Account limit reached for the organization plan | | `1401` | Publication limit reached for the organization plan | | `1402` | Users limit reached for the organization plan | | `1403` | Shared space limit reached for the organization plan | | `1407` | Twitter credits limit reached for the organization plan. The sum of twitter_credits assigned to the client's organizations cannot exceed the client's plan twitter_credits. | | `1408` | AI credits limit reached for the organization plan. The sum of ai_credits assigned to the client's organizations cannot exceed the client's plan ai_credits. | | `1301` | Client plan invalid. Min accounts must be X | | `1302` | Client plan invalid. Min publications must be X | | `1303` | Client plan invalid. Min users must be at least X | | `1306` | Client plan invalid. Min shared space must be at least X | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### DELETE /clients/{id_client}/organizations/{id_organization} Delete a root organization by identifier Delete the root organization and all its resources operationId: deleteClientOrganization callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_client (path, string, required): Client identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### PUT /clients/{id_client}/ai-settings Configure the client's own AI models (BYOK) Set the client's Bring-Your-Own-Key AI provider configuration per scope. The request body is an object keyed by scope (orchestrator, text, image); a scope set to null clears it. Providers and capabilities are validated against a fixed catalogue. When a scope uses BYOK, its calls are billed to the client's own provider and do NOT consume PlanVortex AI credits, but the plan still has to include AI credits to use the feature. API keys are stored encrypted and never returned; the response client only exposes provider, model and has_api_key per scope. Requires the client:update permission. operationId: updateAiSettings callers: current_user, current_app - id_client (path, string, required): Client identifier body (application/json): ClientsAiSettings — required - orchestrator (ClientsAiScopeSetting | null): Configuration of the `orchestrator` scope. **`null` clears it** and returns the scope to PlanVortex credits. - text (ClientsAiScopeSetting | null): Configuration of the `text` scope. **`null` clears it** and returns the scope to PlanVortex credits. - image (ClientsAiScopeSetting | null): Configuration of the `image` scope. **`null` clears it** and returns the scope to PlanVortex credits. responses: 200 Successful operation. Returns the client with the sanitized ai_settings (no API keys). 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `2104` | Invalid AI provider, scope or model. Returned when the scope is unknown, the provider is not in the catalogue, the provider does not support the scope's capability, or model/api_key are missing. | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ### GET /clients_organizations Every client with its organizations, in one call The whole tree the caller can see: their clients, and inside each one the organizations they have access to. It exists to save the N+1 that building a client picker otherwise costs. **An app always gets exactly one client** — its own — with every organization of it. The two pairs of pagination parameters are independent: `offset`/`limit` page the clients, `offsetOrganizations`/`limitOrganizations` page the organizations inside each one. operationId: getClientsWithOrganizations callers: current_user, current_app, temporal_token - offset (query, integer, optional): Clients to skip - limit (query, integer, optional): Clients per page - offsetOrganizations (query, integer, optional): Organizations to skip inside each client - limitOrganizations (query, integer, optional): Organizations per page inside each client - getUse (query, boolean, optional): Send `true` to add each client's plan consumption. It costs several aggregations per client, so leave it off for a picker. responses: 200 Clients, each with its organizations 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `521` | Invalid client. The identifier is invalid or doesn't exist | 500 Unhandled error by the server ## comments The public conversation hanging off what you published — comments and reviews ### GET /organizations/{id_organization}/comments The inbox: first-level comments across the whole organization Served from PlanVortex's database, so it costs nothing and calls no social network. It is a **snapshot**: `collected_date` says when each row was last read. Open a thread to see what the network says right now. Ordered by `creation_date` descending — the date on the network, not the date we collected it — so rows from ten networks interleave correctly. **On `telegram` the inbox starts the day the channel was connected.** The Bot API has no way to read the past: a bot only learns what happens while it is inside, so nothing written before the connection exists here and never will. Say so in your UI — an inbox that opens empty on a busy channel reads like a failure. **Your own replies are not in here.** Anything with `author.is_own: true` is filtered out: what you wrote is not incoming mail. They are still stored, and they do show up in the thread. Requires the `comments:read` permission (`client_organization_comments:read` for apps) and a paid plan. operationId: getComments callers: current_user, current_app - id_organization (path, string, required): Organization identifier - social_network (query, array of CommentsCommentNetworkName, optional): Filter by network. **Repeat the parameter** for several (`?social_network=youtube&social_network=instagram`); a comma-separated string is read as one network name and silently matches nothing. - id_account (query, string, optional): Only comments on this connected account - id_publication (query, string, optional): Only comments on this publication of yours. Reviews never match it: a review hangs off the listing and has no publication. - unread (query, boolean, optional): Only unread ones. Any value other than the literal `false` turns the filter on. - search (query, string, optional): Case-insensitive substring match on the comment text - rating (query, array of integer, optional): Only comments with these star ratings. **Repeat the parameter** for several (`?rating=1&rating=2`), which is the usual way to work a listing: show me the one- and two-star reviews first. Only review networks carry a rating, so this filter leaves everything else out — it is not a way to select "comments without a rating". - offset (query, integer, optional) - limit (query, integer, optional) responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `516` | Comments require a paid plan. Every endpoint here returns this on the free plan. | | `700` | The account is not connected — no valid token, or nothing to ask the network about. | | `936` | The publication was never sent, so it has no thread to read. | | `940` | The client's monthly X credits are exhausted. Only X. | | `945` | This network has no comments (TikTok, WhatsApp). Carries `social_network` in `data`. | | `946` | The network has comments but does not allow **this action**: hiding on LinkedIn or Google Business, deleting someone else's on Instagram or X, deleting a review. Carries `social_network` and `action` in `data`. See `GET /social_comment_actions`. | | `947` | The comment no longer exists on the network. What is stored is a photograph; the network always wins. Carries `external_id` in `data`. | | `948` | The reply is empty or longer than the network allows. Carries the limit in `data.max`; the same number is published in `comment_characters` of `GET /social_limits`. | | `951` | The Google Business listing is not verified, so it cannot reply to its reviews. It is the state of the customer's profile, not of your token. | | `952` | PlanVortex's Google Cloud project has no approved access to the Google Business API yet. Until it does, reviews cannot be read or replied to. | | `965` | This Telegram channel has no linked discussion group, so it has no comments. The network gate says `true`; what is missing is a setting of **that channel**, which its owner fixes in two taps. Carries `chat_id` in `data`. | | `969` | The PlanVortex bot is not allowed to delete messages in this Telegram discussion group. The network allows it; this installation does not. Carries `chat_id` in `data`. | | `970` | Telegram is rate limiting the bot and the wait was longer than PlanVortex is willing to hold the request for. Carries `retry_after_seconds` in `data`: retry after it. | | `1101` | Invalid organization. | | `1501` | The comment's account could not be resolved. | ### GET /organizations/{id_organization}/unread_comments How many comments are waiting The badge number. Counts unread, non-deleted comments of the organization, leaving out the ones you wrote yourself. Requires the `comments:read` permission (`client_organization_comments:read` for apps) and a paid plan. operationId: getTotalUnreadComments callers: current_user, current_app - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `516` | Comments require a paid plan. Every endpoint here returns this on the free plan. | | `700` | The account is not connected — no valid token, or nothing to ask the network about. | | `936` | The publication was never sent, so it has no thread to read. | | `940` | The client's monthly X credits are exhausted. Only X. | | `945` | This network has no comments (TikTok, WhatsApp). Carries `social_network` in `data`. | | `946` | The network has comments but does not allow **this action**: hiding on LinkedIn or Google Business, deleting someone else's on Instagram or X, deleting a review. Carries `social_network` and `action` in `data`. See `GET /social_comment_actions`. | | `947` | The comment no longer exists on the network. What is stored is a photograph; the network always wins. Carries `external_id` in `data`. | | `948` | The reply is empty or longer than the network allows. Carries the limit in `data.max`; the same number is published in `comment_characters` of `GET /social_limits`. | | `951` | The Google Business listing is not verified, so it cannot reply to its reviews. It is the state of the customer's profile, not of your token. | | `952` | PlanVortex's Google Cloud project has no approved access to the Google Business API yet. Until it does, reviews cannot be read or replied to. | | `965` | This Telegram channel has no linked discussion group, so it has no comments. The network gate says `true`; what is missing is a setting of **that channel**, which its owner fixes in two taps. Carries `chat_id` in `data`. | | `969` | The PlanVortex bot is not allowed to delete messages in this Telegram discussion group. The network allows it; this installation does not. Carries `chat_id` in `data`. | | `970` | Telegram is rate limiting the bot and the wait was longer than PlanVortex is willing to hold the request for. Carries `retry_after_seconds` in `data`: retry after it. | | `1101` | Invalid organization. | | `1501` | The comment's account could not be resolved. | ### GET /organizations/{id_organization}/publish/{id_publication}/comments The thread of a publication, read live Asks the social network and reconciles with what is stored: the network wins on text, counters and existence; the stored copy only contributes `_id`, `read` and `replied`. Comments the network no longer returns are marked deleted and stop appearing in the inbox. **On X this call costs money.** X bills per unit read, so the response carries `credits_consumed` with what this particular read spent from the client's monthly pool. It is `0` on every other network. Charging happens after the read and by real units: a failed call charges nothing, and a page with three replies is not charged for fifty. Use this one when the comment has an `id_publication`. When it does not — a review hangs off a listing — use the per-account endpoint instead. **On `telegram` it is not live**, and that is the one exception to everything above: there is no endpoint in the Bot API that lists the replies to a post, so this returns PlanVortex's own inbox — what the bot has seen since the channel was connected. Nothing is reconciled and nothing is swept as deleted, because there is nothing to compare against. Requires the `comments:read` permission (`client_organization_comments:read` for apps) and a paid plan. operationId: getPublicationComments callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_publication (path, string, required): Publication identifier - limit (query, integer, optional): How many to read from the network. **On X it is capped** regardless of what you send: a page there is a spending ceiling, not a pagination preference. - offset (query, string, optional): Page cursor. Pass back the `next_cursor` of the previous response — it is an opaque token from the network, not a numeric offset. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `516` | Comments require a paid plan. Every endpoint here returns this on the free plan. | | `700` | The account is not connected — no valid token, or nothing to ask the network about. | | `936` | The publication was never sent, so it has no thread to read. | | `940` | The client's monthly X credits are exhausted. Only X. | | `945` | This network has no comments (TikTok, WhatsApp). Carries `social_network` in `data`. | | `946` | The network has comments but does not allow **this action**: hiding on LinkedIn or Google Business, deleting someone else's on Instagram or X, deleting a review. Carries `social_network` and `action` in `data`. See `GET /social_comment_actions`. | | `947` | The comment no longer exists on the network. What is stored is a photograph; the network always wins. Carries `external_id` in `data`. | | `948` | The reply is empty or longer than the network allows. Carries the limit in `data.max`; the same number is published in `comment_characters` of `GET /social_limits`. | | `951` | The Google Business listing is not verified, so it cannot reply to its reviews. It is the state of the customer's profile, not of your token. | | `952` | PlanVortex's Google Cloud project has no approved access to the Google Business API yet. Until it does, reviews cannot be read or replied to. | | `965` | This Telegram channel has no linked discussion group, so it has no comments. The network gate says `true`; what is missing is a setting of **that channel**, which its owner fixes in two taps. Carries `chat_id` in `data`. | | `969` | The PlanVortex bot is not allowed to delete messages in this Telegram discussion group. The network allows it; this installation does not. Carries `chat_id` in `data`. | | `970` | Telegram is rate limiting the bot and the wait was longer than PlanVortex is willing to hold the request for. Carries `retry_after_seconds` in `data`: retry after it. | | `1101` | Invalid organization. | | `1501` | The comment's account could not be resolved. | ### GET /organizations/{id_organization}/accounts/{id_account}/comments The thread of an account, read live The twin of the publication endpoint, for networks whose comments **do not hang off a publication of yours**. Today that is Google Business: a review hangs off the **listing**, so the account has no publication to ask about. Its `publication_external_id` is the listing itself (`locations/{id}`) and `id_publication` is always absent. Same reconciliation, same permissions, same shape of response as the publication thread. Pick between the two by looking at the comment: if it has an `id_publication`, use that one; if it does not, use this. One behaviour differs underneath, and it matters if you are reasoning about deletions: **reviews are exempt from the deletion sweep**. Google's review listing cannot be ordered by creation date (only by last update or by rating), so the rule that says "newer than the oldest row on this page and missing from it means it is gone" does not hold, and applying it would empty most of a busy listing's inbox in one pass. The trade is deliberate: a review Google removes may linger, which is rare, rather than good rows disappearing, which is not recoverable. Requires the `comments:read` permission (`client_organization_comments:read` for apps) and a paid plan. operationId: getAccountComments callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - limit (query, integer, optional): How many to read from the network. **On X it is capped** regardless of what you send: a page there is a spending ceiling, not a pagination preference. - offset (query, string, optional): Page cursor. Pass back the `next_cursor` of the previous response — it is an opaque token from the network, not a numeric offset. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `516` | Comments require a paid plan. Every endpoint here returns this on the free plan. | | `700` | The account is not connected — no valid token, or nothing to ask the network about. | | `936` | The publication was never sent, so it has no thread to read. | | `940` | The client's monthly X credits are exhausted. Only X. | | `945` | This network has no comments (TikTok, WhatsApp). Carries `social_network` in `data`. | | `946` | The network has comments but does not allow **this action**: hiding on LinkedIn or Google Business, deleting someone else's on Instagram or X, deleting a review. Carries `social_network` and `action` in `data`. See `GET /social_comment_actions`. | | `947` | The comment no longer exists on the network. What is stored is a photograph; the network always wins. Carries `external_id` in `data`. | | `948` | The reply is empty or longer than the network allows. Carries the limit in `data.max`; the same number is published in `comment_characters` of `GET /social_limits`. | | `951` | The Google Business listing is not verified, so it cannot reply to its reviews. It is the state of the customer's profile, not of your token. | | `952` | PlanVortex's Google Cloud project has no approved access to the Google Business API yet. Until it does, reviews cannot be read or replied to. | | `965` | This Telegram channel has no linked discussion group, so it has no comments. The network gate says `true`; what is missing is a setting of **that channel**, which its owner fixes in two taps. Carries `chat_id` in `data`. | | `969` | The PlanVortex bot is not allowed to delete messages in this Telegram discussion group. The network allows it; this installation does not. Carries `chat_id` in `data`. | | `970` | Telegram is rate limiting the bot and the wait was longer than PlanVortex is willing to hold the request for. Carries `retry_after_seconds` in `data`: retry after it. | | `1101` | Invalid organization. | | `1501` | The comment's account could not be resolved. | ### GET /organizations/{id_organization}/comments/{id_comment}/replies The replies to a comment, read live Second level, read against the network like the thread — and, on X, charged the same way. Your own replies written in the last few minutes are injected into the first page even when the network does not list them yet: networks index what you just wrote with a delay, and without this you would post a reply, refresh, and see nothing. On Google Business a review has **at most one** reply — yours — so this returns zero or one row and there is no tree to walk. Requires the `comments:read` permission (`client_organization_comments:read` for apps) and a paid plan. operationId: getCommentReplies callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_comment (path, string, required): Comment identifier (PlanVortex's `_id`, not the network's `external_id`) - limit (query, integer, optional): How many to read from the network. **On X it is capped** regardless of what you send: a page there is a spending ceiling, not a pagination preference. - offset (query, string, optional): Page cursor. Pass back the `next_cursor` of the previous response — it is an opaque token from the network, not a numeric offset. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `516` | Comments require a paid plan. Every endpoint here returns this on the free plan. | | `700` | The account is not connected — no valid token, or nothing to ask the network about. | | `936` | The publication was never sent, so it has no thread to read. | | `940` | The client's monthly X credits are exhausted. Only X. | | `945` | This network has no comments (TikTok, WhatsApp). Carries `social_network` in `data`. | | `946` | The network has comments but does not allow **this action**: hiding on LinkedIn or Google Business, deleting someone else's on Instagram or X, deleting a review. Carries `social_network` and `action` in `data`. See `GET /social_comment_actions`. | | `947` | The comment no longer exists on the network. What is stored is a photograph; the network always wins. Carries `external_id` in `data`. | | `948` | The reply is empty or longer than the network allows. Carries the limit in `data.max`; the same number is published in `comment_characters` of `GET /social_limits`. | | `951` | The Google Business listing is not verified, so it cannot reply to its reviews. It is the state of the customer's profile, not of your token. | | `952` | PlanVortex's Google Cloud project has no approved access to the Google Business API yet. Until it does, reviews cannot be read or replied to. | | `965` | This Telegram channel has no linked discussion group, so it has no comments. The network gate says `true`; what is missing is a setting of **that channel**, which its owner fixes in two taps. Carries `chat_id` in `data`. | | `969` | The PlanVortex bot is not allowed to delete messages in this Telegram discussion group. The network allows it; this installation does not. Carries `chat_id` in `data`. | | `970` | Telegram is rate limiting the bot and the wait was longer than PlanVortex is willing to hold the request for. Carries `retry_after_seconds` in `data`: retry after it. | | `1101` | Invalid organization. | | `1501` | The comment's account could not be resolved. | ### POST /organizations/{id_organization}/comments/{id_comment}/reply Reply in public Posts a **public** reply on the social network. This is not the same as replying privately to whoever commented — that is a Messenger feature and lives in the messaging endpoints. The reply is stored immediately, with `author.is_own: true`, without waiting for the network to index it, and the comment it answers is left `replied: true` and `read: true` with `our_reply_external_id` pointing at it. **On Google Business this is an upsert.** A review has at most one reply, so replying again does not add a second one: it replaces the text of the one that is there, and the stored row is updated in place. Label your button accordingly — "reply" the first time, "edit reply" afterwards. The text is validated against that network's limit before anything is sent (`comment_characters` in `GET /social_limits`); an empty or over-long text returns error `948` with the limit in `data.max`. **On X this costs credits**: 15, or 200 if the text contains a link, charged only on success. **On `telegram` the reply is signed by the PlanVortex bot**, not by the channel. It is written in the channel's linked discussion group, which is where Telegram keeps the comments on a channel post — and a channel with no discussion group has no comments at all, which is error 965. Publishing is not affected: a channel post is signed by the channel. Requires the `comments:create` permission (`client_organization_comments:create` for apps) and a paid plan. operationId: replyComment callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_comment (path, string, required): Comment identifier (PlanVortex's `_id`, not the network's `external_id`) body (application/json) — required - text (string, required): The public reply. Must not be empty and must fit the network's `comment_characters` limit. responses: 200 Reply published 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `516` | Comments require a paid plan. Every endpoint here returns this on the free plan. | | `700` | The account is not connected — no valid token, or nothing to ask the network about. | | `936` | The publication was never sent, so it has no thread to read. | | `940` | The client's monthly X credits are exhausted. Only X. | | `945` | This network has no comments (TikTok, WhatsApp). Carries `social_network` in `data`. | | `946` | The network has comments but does not allow **this action**: hiding on LinkedIn or Google Business, deleting someone else's on Instagram or X, deleting a review. Carries `social_network` and `action` in `data`. See `GET /social_comment_actions`. | | `947` | The comment no longer exists on the network. What is stored is a photograph; the network always wins. Carries `external_id` in `data`. | | `948` | The reply is empty or longer than the network allows. Carries the limit in `data.max`; the same number is published in `comment_characters` of `GET /social_limits`. | | `951` | The Google Business listing is not verified, so it cannot reply to its reviews. It is the state of the customer's profile, not of your token. | | `952` | PlanVortex's Google Cloud project has no approved access to the Google Business API yet. Until it does, reviews cannot be read or replied to. | | `965` | This Telegram channel has no linked discussion group, so it has no comments. The network gate says `true`; what is missing is a setting of **that channel**, which its owner fixes in two taps. Carries `chat_id` in `data`. | | `969` | The PlanVortex bot is not allowed to delete messages in this Telegram discussion group. The network allows it; this installation does not. Carries `chat_id` in `data`. | | `970` | Telegram is rate limiting the bot and the wait was longer than PlanVortex is willing to hold the request for. Carries `retry_after_seconds` in `data`: retry after it. | | `1101` | Invalid organization. | | `1501` | The comment's account could not be resolved. | ### PUT /organizations/{id_organization}/comments/{id_comment} Mark as read, or hide on the network Two different kinds of change, accepted together because a panel changes them from the same place: - `read` is **yours**. It touches no social network, costs nothing and can always be set. - `hidden` **touches the network**, and not every network allows it — LinkedIn and Google Business have no hide endpoint at all and return error `946`. Check `GET /social_comment_actions` before offering the control. Requires the `comments:create` permission (`client_organization_comments:create` for apps) and a paid plan. operationId: updateComment callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_comment (path, string, required): Comment identifier (PlanVortex's `_id`, not the network's `external_id`) body (application/json) — required - read (boolean): Your own state. Only the literal `true` marks it read. - hidden (boolean): Hide or show it **on the social network**. Only the literal `true` hides. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `516` | Comments require a paid plan. Every endpoint here returns this on the free plan. | | `700` | The account is not connected — no valid token, or nothing to ask the network about. | | `936` | The publication was never sent, so it has no thread to read. | | `940` | The client's monthly X credits are exhausted. Only X. | | `945` | This network has no comments (TikTok, WhatsApp). Carries `social_network` in `data`. | | `946` | The network has comments but does not allow **this action**: hiding on LinkedIn or Google Business, deleting someone else's on Instagram or X, deleting a review. Carries `social_network` and `action` in `data`. See `GET /social_comment_actions`. | | `947` | The comment no longer exists on the network. What is stored is a photograph; the network always wins. Carries `external_id` in `data`. | | `948` | The reply is empty or longer than the network allows. Carries the limit in `data.max`; the same number is published in `comment_characters` of `GET /social_limits`. | | `951` | The Google Business listing is not verified, so it cannot reply to its reviews. It is the state of the customer's profile, not of your token. | | `952` | PlanVortex's Google Cloud project has no approved access to the Google Business API yet. Until it does, reviews cannot be read or replied to. | | `965` | This Telegram channel has no linked discussion group, so it has no comments. The network gate says `true`; what is missing is a setting of **that channel**, which its owner fixes in two taps. Carries `chat_id` in `data`. | | `969` | The PlanVortex bot is not allowed to delete messages in this Telegram discussion group. The network allows it; this installation does not. Carries `chat_id` in `data`. | | `970` | Telegram is rate limiting the bot and the wait was longer than PlanVortex is willing to hold the request for. Carries `retry_after_seconds` in `data`: retry after it. | | `1101` | Invalid organization. | | `1501` | The comment's account could not be resolved. | ### DELETE /organizations/{id_organization}/comments/{id_comment} Delete the comment on the social network Deletes it **on the network** and marks the row `deleted: true` here. The row is kept on purpose rather than removed: if it were removed, the next read of the thread — or the webhook, which repeats — would create it again. Which comments you may delete depends on the network and on whose comment it is, and the two cases use different permissions of the network's own: `delete_own` for yours, `delete_others` for somebody else's. Instagram and X refuse the second; **on Google Business the only thing that can be deleted is your own reply**, never a review. Reading `GET /social_comment_actions` first is the difference between a button that works and one that always errors. On X this costs credits. On `telegram` both are allowed by the network and both depend on a permission the customer controls: the bot has to be an administrator of the discussion group with the right to delete. When it is not, the answer is error 969 — which is the difference between "this network cannot" and "this particular channel cannot". Requires the `comments:delete` permission (`client_organization_comments:delete` for apps) and a paid plan. operationId: deleteComment callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_comment (path, string, required): Comment identifier (PlanVortex's `_id`, not the network's `external_id`) responses: 200 Deleted on the network 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `516` | Comments require a paid plan. Every endpoint here returns this on the free plan. | | `700` | The account is not connected — no valid token, or nothing to ask the network about. | | `936` | The publication was never sent, so it has no thread to read. | | `940` | The client's monthly X credits are exhausted. Only X. | | `945` | This network has no comments (TikTok, WhatsApp). Carries `social_network` in `data`. | | `946` | The network has comments but does not allow **this action**: hiding on LinkedIn or Google Business, deleting someone else's on Instagram or X, deleting a review. Carries `social_network` and `action` in `data`. See `GET /social_comment_actions`. | | `947` | The comment no longer exists on the network. What is stored is a photograph; the network always wins. Carries `external_id` in `data`. | | `948` | The reply is empty or longer than the network allows. Carries the limit in `data.max`; the same number is published in `comment_characters` of `GET /social_limits`. | | `951` | The Google Business listing is not verified, so it cannot reply to its reviews. It is the state of the customer's profile, not of your token. | | `952` | PlanVortex's Google Cloud project has no approved access to the Google Business API yet. Until it does, reviews cannot be read or replied to. | | `965` | This Telegram channel has no linked discussion group, so it has no comments. The network gate says `true`; what is missing is a setting of **that channel**, which its owner fixes in two taps. Carries `chat_id` in `data`. | | `969` | The PlanVortex bot is not allowed to delete messages in this Telegram discussion group. The network allows it; this installation does not. Carries `chat_id` in `data`. | | `970` | Telegram is rate limiting the bot and the wait was longer than PlanVortex is willing to hold the request for. Carries `retry_after_seconds` in `data`: retry after it. | | `1101` | Invalid organization. | | `1501` | The comment's account could not be resolved. | ## contacts People an organization talks to ### GET /organizations/{id_organization}/contacts List the contacts of an organization Newest first. `search` is a full-text search over the name, and `extra_data` filters by your own fields — which is what makes it possible to ask for "the contacts of this campaign" without keeping a second database. operationId: getContacts callers: current_user, current_app - id_organization (path, string, required): Organization identifier - offset (query, integer, optional): Records to skip (pagination) - limit (query, integer, optional): Records per page (pagination) - search (query, string, optional): Full-text search over the contact's name. - social_network (query, SocialNetwork, optional): Only the contacts reachable on this network. It matches on `social_identifiers[].social_network`, so a contact with several channels comes back through any of them. It used to return an empty list always, because the server compared the whole array of objects against the network name; that was fixed on 2026-08-24. - extra_data (query, array of object, optional): Filter by your own fields, as `key`/`value` pairs. A `value` that is an array matches any of its entries. responses: 200 Contacts 400 Error. Classify by `code` in the body, never by the HTTP status. ### POST /organizations/{id_organization}/contacts Create a contact Creates a contact by hand, which is how you bring people in from your own system. **Creating a contact does not let you write to them.** To send the first message the network has to allow it: WhatsApp needs an approved template, and Meta only allows an answer inside 24 hours of the contact writing first. What `social_identifiers` gives you is the link, not the permission. operationId: createContact callers: current_user, current_app - id_organization (path, string, required): Organization identifier body (application/json): ContactsContactCreate — required - name (string) - profile_image (string) - social_identifiers (array of ContactsSocialIdentifierInput, required) - extra_data (ContactExtraData) responses: 200 The created contact 400 Error. Classify by `code` in the body, never by the HTTP status. ### DELETE /organizations/{id_organization}/contacts Delete every contact of an organization Deletes all the contacts of the organization **and every message exchanged with them**. There is no confirmation step and no undo. Nothing is deleted on the social networks: the conversations are still there, and reading an account again re-imports the contacts. operationId: deleteOrganizationContacts callers: current_user, current_app - id_organization (path, string, required): Organization identifier responses: 200 Deleted 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /organizations/{id_organization}/contacts/{id_contact} Get one contact The contact's record. Note the envelope: this one answers `{contact}`, while the list answers `{contacts, total}`. operationId: getContact callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_contact (path, string, required): Contact identifier responses: 200 The contact 400 Error. Classify by `code` in the body, never by the HTTP status. ### PUT /organizations/{id_organization}/contacts/{id_contact} Update a contact Updates the contact's fields. **It answers `{success: true}`, not the updated contact** — read it back if you need the new state. operationId: updateContact callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_contact (path, string, required): Contact identifier body (application/json): ContactsContactUpdate — required - name (string) - profile_image (string) - social_identifiers (array of ContactsSocialIdentifierInput): Replaces the whole list, it does not merge into it. Omit it to keep the current one. - extra_data (ContactExtraData): Written as sent. **Omitting it erases the contact's custom fields.** responses: 200 Updated. **It does not return the contact**: read it again if you need the new state. 400 Error. Classify by `code` in the body, never by the HTTP status. ### DELETE /organizations/{id_organization}/contacts/{id_contact} Delete a contact Deletes the contact **and every message exchanged with them**. Nothing is deleted on the social network. operationId: deleteContact callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_contact (path, string, required): Contact identifier responses: 200 Deleted 400 Error. Classify by `code` in the body, never by the HTTP status. ## dashboard Aggregates of an organization and its children ### GET /organizations/{id_organization}/dashboard The whole home screen in one call Composes every block of the home screen in a single round trip: operational health, publications, publication metrics, account metrics, plan use, AI plans and unread messages. **A block the caller cannot read is omitted, not refused.** The endpoint only demands the minimum permission — reading the organization — and then checks each block on its own: somebody who can only see publications still gets their home instead of a 403. `available_blocks` says which blocks were allowed, so an absent block for lack of permission and an absent block for lack of data can be drawn differently. The messages block is also omitted when the client's plan has no messaging. operationId: getDashboard callers: current_user, current_app, temporal_token - id_organization (path, string, required): Organization identifier - from_date (query, string (date-time), optional): Start of the range, ISO 8601. Defaults to 30 days before `to_date`. A range longer than 366 days answers error 1003. - to_date (query, string (date-time), optional): End of the range, ISO 8601. Defaults to now. responses: 200 The dashboard 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /organizations/{id_organization}/metrics Account metrics of the organization Account statistics added up across the organization, on the axis asked for in `group_by`. **Not every metric adds up the same way.** `impressions` over a week is the sum of the seven days, but `followers` is the *last* value of each account: adding up seven snapshots of "1.200 followers" would report 8.400. The aggregation already knows which is which — do not add up the series yourself. A metric that a network does not publish is simply absent. It is not a zero. operationId: getOrganizationMetrics callers: current_user, current_app - id_organization (path, string, required): Organization identifier - from_date (query, string (date-time), optional): Start of the range, ISO 8601. Defaults to 30 days before `to_date`. A range longer than 366 days answers error 1003. - to_date (query, string (date-time), optional): End of the range, ISO 8601. Defaults to now. - group_by (query, "day" | "network" | "account" | "total", optional): The axis of the answer. `day` for a series, `network` or `account` for a breakdown, `total` for one number per metric. - names (query, array of DashboardMetricName, optional): Only these metrics. Repeat the parameter for several. An unknown name answers error 1000 with the list of valid ones. responses: 200 Metrics 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /organizations/{id_organization}/publications/summary Publication counts of the range How many publications there are by state, by network and by day. **`by_day` and `published_by_day` answer different questions over different sets.** `by_day` counts by *creation* date — work done — and `published_by_day` counts by *publish* date and only what actually went out. A publication written last month and published yesterday appears in the second and not in the first, which is the normal case for anybody scheduling two weeks ahead. operationId: getPublicationsSummary callers: current_user, current_app - id_organization (path, string, required): Organization identifier - from_date (query, string (date-time), optional): Start of the range, ISO 8601. Defaults to 30 days before `to_date`. A range longer than 366 days answers error 1003. - to_date (query, string (date-time), optional): End of the range, ISO 8601. Defaults to now. responses: 200 Counts 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /organizations/{id_organization}/publications/top Best publications of the range The publications with the highest value of one metric. **A publication whose network does not report that metric does not compete**, it is left out of the ranking rather than ranked as a zero. That is why asking for `saves` can return fewer rows than asking for `engagement`. operationId: getTopPublications callers: current_user, current_app - id_organization (path, string, required): Organization identifier - from_date (query, string (date-time), optional): Start of the range, ISO 8601. Defaults to 30 days before `to_date`. A range longer than 366 days answers error 1003. - to_date (query, string (date-time), optional): End of the range, ISO 8601. Defaults to now. - metric (query, DashboardMetricName, optional): Metric to rank by. - limit (query, integer, optional): How many publications to return. responses: 200 Top publications 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /organizations/{id_organization}/use Plan consumption of the organization What this organization has been assigned, what it is using, and the limits in force, in one call. It exists so that drawing a progress bar does not mean asking for the client's whole organization list — which is what it used to take, and which needs a client-level permission an organization user has no reason to have. An organization with no plan of its own inherits the closest parent that has one. operationId: getOrganizationUse callers: current_user, current_app, temporal_token - id_organization (path, string, required): Organization identifier responses: 200 Consumption 400 Error. Classify by `code` in the body, never by the HTTP status. ## integrations Connections between an organization and a third-party tool (Google Drive, RSS feeds) ### GET /integration_providers Catalogue of available integration providers What every provider is and what it can do: whether it connects through OAuth or a form, whether it contributes files to the library, whether it is polled for content, which file formats it accepts and which fields its configuration form takes. Consume this instead of hardcoding a provider list: a new provider shows up here without any client change. operationId: getIntegrationProviders callers: current_user, current_app, temporal_token responses: 200 Successful operation 400 Error. Classify by `code` in the body, never by the HTTP status: every domain error travels with HTTP 400. ### GET /organizations/{id_organization}/integrations List the integrations of an organization Requires the `integrations:read` permission (`client_organization_integrations:read` for apps). operationId: getIntegrations callers: current_user, current_app - id_organization (path, string, required): Organization identifier - provider (query, IntegrationsIntegrationProviderName, optional): Filter by provider - offset (query, integer, optional) - limit (query, integer, optional): How many to return. **Without it there is no limit at all** and the whole list comes back — unlike every other listing in this API, which caps at 10. responses: 200 Successful operation 400 Error. Classify by `code` in the body, never by the HTTP status: every domain error travels with HTTP 400. ### POST /organizations/{id_organization}/integrations Connect an integration Creates the connection. The body depends on the provider: - `google_drive`: `{ provider, code }`, where `code` is the OAuth code returned to the redirect of `connect_link`. It is single-use, so the plan allowance is checked **before** the exchange. - `rss`: `{ provider, url, id_accounts, template?, publication_type?, auto_publish?, import_image? }`. The feed is read once to validate it and to take its title, and every item it already has is recorded as seen — connecting a blog never publishes its back catalogue. Requires the `integrations:create` permission. operationId: addIntegration callers: current_user, current_app - id_organization (path, string, required): Organization identifier body (application/json) — required responses: 200 Integration connected 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1404` | Integrations limit reached for the organization plan. On the free plan the allowance is 0, so this always fires. | | `2201` | Integration provider not supported (or the payload is missing what that provider needs). | | `2202` | Integration already connected for this organization and provider. Applies to OAuth providers; the body carries its `id_integration` in `data`, and the `code` you sent is still unused — post it to that integration's `/reconnect` to renew it. | | `2203` | Integration token expired or revoked, reconnect required. | | `2205` | Feed URL unreachable or not a valid feed. | | `2206` | Feed has no target accounts configured. | ### GET /organizations/{id_organization}/integrations/{id_integration} Get one integration Requires the `integrations:read` permission. operationId: getIntegration callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_integration (path, string, required): Integration identifier responses: 200 Successful operation 400 `2200` Integration not found, or `1102` if it belongs to another organization. ### PUT /organizations/{id_organization}/integrations/{id_integration} Update an integration Only what the user owns: its name, whether it is enabled and its `config` (merged, not replaced — `seen_guids` and `last_checked` belong to the job). Credentials are never updated here; for that, `POST .../reconnect`. Changing a feed's `url` here keeps the items it had already seen, so the new feed's back catalogue would be published on the next sweep — change a feed's URL through `/reconnect`, which revalidates it and marks its current items as seen. A disabled integration stops consuming plan allowance. Enabling one or changing its config clears `error_code`, so the job retries it. Requires the `integrations:update` permission. operationId: updateIntegration callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_integration (path, string, required): Integration identifier body (application/json) — required - name (string) - enabled (boolean) - config (IntegrationsRssConfig) responses: 200 Integration updated 400 Error. Classify by `code` in the body, never by the HTTP status: every domain error travels with HTTP 400. ### DELETE /organizations/{id_organization}/integrations/{id_integration} Disconnect an integration Revokes the credentials at the provider and deletes the connection. Files already imported are **not** touched: they are your files, in your library, counting against your storage. Disconnecting Drive never empties anyone's library. Requires the `integrations:delete` permission. operationId: deleteIntegration callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_integration (path, string, required): Integration identifier responses: 200 Integration deleted 400 Error. Classify by `code` in the body, never by the HTTP status: every domain error travels with HTTP 400. ### GET /organizations/{id_organization}/integrations/{provider}/connect_link Authorization URL of an OAuth provider Only for providers whose `requires_oauth` is true; anything else returns `2201`. Send the user to the returned URL; the provider will come back to `{FRONT_APP}/connect/integration/{provider}` with a `code` to post to `POST /organizations/{id_organization}/integrations`. Requires the `integrations:create` **or** `integrations:update` permission: the consent link is needed both to connect a new integration and to reconnect one that already exists (`POST /organizations/{id_organization}/integrations/{id_integration}/reconnect`). operationId: getIntegrationConnectLink callers: current_user, current_app - id_organization (path, string, required): Organization identifier - provider (path, IntegrationsIntegrationProviderName, required) - redirect_uri (query, string, optional): Alternative return URL. It must be one of the redirects allowed for the installation, otherwise the request fails with `532`. responses: 200 Successful operation 400 Error. Classify by `code` in the body, never by the HTTP status: every domain error travels with HTTP 400. ### POST /organizations/{id_organization}/integrations/{id_integration}/reconnect Reconnect an integration Renews the credentials of an integration that already exists — the OAuth token expired or the provider revoked it (`2203`) — or revalidates a feed's configuration, **on the same document**. The body is the same one you would send to connect: `{ provider, code }` for an OAuth provider, or the form fields for a feed. It is handled by the same provider logic, so a feed that still cannot be read is still a `2205`. Use this instead of deleting and connecting again. Reconnecting keeps the `_id` (publications created by a feed reference it), the connection date and the feed's seen items — deleting and reconnecting a feed would republish the blog's whole back catalogue on the next sweep. What it does not touch: `enabled` (reconnecting never switches on a disabled integration, because that consumes plan allowance) and the creation date. `error_code` is cleared, so the background jobs pick it up again. `name` is kept unless the connection now points somewhere else (another Google account, another feed URL), in which case the new one is taken. No allowance check: nothing new is created, so it works even with the plan full. Requires the `integrations:update` permission. operationId: reconnectIntegration callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_integration (path, string, required): Integration identifier body (application/json) — required responses: 200 Integration reconnected 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `2200` | Integration not found. | | `2201` | The `provider` in the body is not the one of this integration, or the payload is missing what that provider needs. | | `2203` | The provider refused the new credentials. | | `2205` | Feed URL unreachable or not a valid feed. | | `2206` | Feed has no target accounts configured. | ### GET /organizations/{id_organization}/integrations/{id_integration}/picker_config Short-lived configuration for the provider's own file picker Everything the browser needs to open Google's Picker: a fresh access token for the connected account (scope `drive.file`, ~1 hour), the browser API key and the project number that acts as the Picker's `appId`. Behind `files:create` and **not** `files:read`: whoever cannot put files into the library has no use for a Drive token. Returns `2203` if the connection needs reconnecting, and `2201` if the provider has no picker. operationId: getIntegrationPickerConfig callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_integration (path, string, required): Integration identifier responses: 200 Successful operation 400 Error. Classify by `code` in the body, never by the HTTP status: every domain error travels with HTTP 400. ## message templates Pre-approved templates, the only way to open a conversation on WhatsApp ### GET /organizations/{id_organization}/accounts/{id_account}/message_templates List the account's message templates The templates the **network** has approved for this account. They are read live from the network, not from our database, so a template approved a minute ago shows up here. The shape of a template is the network's own: PlanVortex does not normalise it. **WhatsApp only.** No other network implements message templates: asking any of them returns HTTP 500 with `code: 500` (an unhandled server error), not the 1502 you would expect. Check `messages` in `GET /social_capabilities` and that the account is WhatsApp before calling. operationId: getMessageTemplates callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - offset (query, integer, optional): Records to skip (pagination) - limit (query, integer, optional): Records per page (pagination) responses: 200 Templates 400 Error. Classify by `code` in the body, never by the HTTP status. ### POST /organizations/{id_organization}/accounts/{id_account}/message_templates Create a message template Sends a template to the network for approval. The body travels to the network as it is, so its fields are the network's (for WhatsApp: `name`, `language`, `category`, `components`). **Approval is not immediate.** The template comes back in a pending state and only becomes usable when the network approves it, which takes minutes or hours and can be refused. **WhatsApp only.** No other network implements message templates: asking any of them returns HTTP 500 with `code: 500` (an unhandled server error), not the 1502 you would expect. Check `messages` in `GET /social_capabilities` and that the account is WhatsApp before calling. operationId: createMessageTemplate callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier body (application/json) — required responses: 200 The created template 400 Error. Classify by `code` in the body, never by the HTTP status. ### DELETE /organizations/{id_organization}/accounts/{id_account}/message_templates Delete a message template Deletes the template on the network. **The template is identified by query parameters, not by a path segment**, and WhatsApp needs both the id and the name. **WhatsApp only.** No other network implements message templates: asking any of them returns HTTP 500 with `code: 500` (an unhandled server error), not the 1502 you would expect. Check `messages` in `GET /social_capabilities` and that the account is WhatsApp before calling. operationId: deleteMessageTemplate callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - template_id (query, string, required): Identifier of the template on the network. - template_name (query, string, required): Name of the template on the network. responses: 200 Deleted 400 Error. Classify by `code` in the body, never by the HTTP status. ## messages Conversations and messages of a connected account ### GET /organizations/{id_organization}/unread_messages Unread messages in the organization How many incoming messages are still unread across every account of the organization. It is the number for a badge, and it costs one query: do not compute it by walking the conversations. operationId: getUnreadMessages callers: current_user, current_app - id_organization (path, string, required): Organization identifier responses: 200 Unread count 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /organizations/{id_organization}/accounts/{id_account}/conversations Conversations of an account One entry per contact that has talked with this account, most recent first, with the contact already resolved and its unread count. **The first call can be slow.** If PlanVortex has no message stored for the account yet, it goes to the network, imports the recent conversations and their messages, and only then answers. From then on it reads from our database. operationId: getConversations callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - offset (query, integer, optional): Records to skip (pagination) - limit (query, integer, optional): Records per page (pagination) responses: 200 Conversations 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /organizations/{id_organization}/accounts/{id_account}/conversations_total Conversation counts of one account How many conversations the account had in a date range, either as a single number or grouped into a series. A *conversation* here is one participant on one day: the same contact writing three times in an afternoon counts once, and counts again tomorrow. It is the unit the plan is measured in, not the number of messages. operationId: getAccountConversationsTotal callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - from_date (query, string (date-time), optional): Start of the range, ISO 8601. Defaults to the start of the current month. - to_date (query, string (date-time), optional): End of the range, ISO 8601. Defaults to the end of the current month. - group_by (query, "day" | "month" | "year", optional): Group the count into a series. Omit it and the answer is a single `total` instead. responses: 200 Conversation counts 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /organizations/{id_organization}/conversations_total Conversation counts of the whole organization The same count as the per-account endpoint, adding up every account of the organization. Use it for the plan-consumption screen; use the per-account one to compare channels. operationId: getOrganizationConversationsTotal callers: current_user, current_app - id_organization (path, string, required): Organization identifier - from_date (query, string (date-time), optional): Start of the range, ISO 8601. Defaults to the start of the current month. - to_date (query, string (date-time), optional): End of the range, ISO 8601. Defaults to the end of the current month. - group_by (query, "day" | "month" | "year", optional): Group the count into a series. Omit it and the answer is a single `total` instead. responses: 200 Conversation counts 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /organizations/{id_organization}/accounts/{id_account}/messages/{id_contact} Messages exchanged with one contact The thread with one contact, newest first. **Reading the first page marks the thread as read** (`offset=0` only), which is what makes `unread_messages` go down. If PlanVortex has nothing stored for that contact it imports the thread from the network first. operationId: getMessages callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - id_contact (path, string, required): Contact identifier - offset (query, integer, optional): Records to skip (pagination) - limit (query, integer, optional): Records per page (pagination) responses: 200 Messages 400 Error. Classify by `code` in the body, never by the HTTP status. ### POST /organizations/{id_organization}/accounts/{id_account}/messages/{id_contact} Send a message to a contact Writes to the contact through the account's network. What is valid depends on `message_type` and on the network: • `simple_message` needs `text`, and it is validated against `characters` in `GET /social_limits`. • **WhatsApp only allows a free-form message inside 24 hours** of the contact's last message. Outside that window the only thing that goes through is a `template_message` with a pre-approved template, and anything else answers an error. • `file_message` needs at least one entry in `message_options.files` (an upload of this organization) or in `files_urls`. • Facebook and Instagram accept **one** file per message. • `quick_reply_message`, `button_message`, `elements_message` and `interactive_message` are Meta-only shapes and each one requires its own block inside `message_options`. operationId: createMessage callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - id_contact (path, string, required): Contact identifier body (application/json): MessagesMessageInput — required - message_type (MessageType, required) - text (string): Required for the text-based types. Validated against `characters` in `GET /social_limits`. - message_options (MessageOptions) - in_response_external_id (string): Required by `comment_message` and `publication_message`, and ignored by every other type (error `1510` when it is missing). It is the identifier the NETWORK gives: a comment's `external_id` or a publication's `external_identifier`, never a PlanVortex `_id`. Only Facebook and Instagram do anything with it: `comment_message` sends a private reply to a public comment (Meta's `recipient.comment_id`) and `publication_message` attaches the post as a `MEDIA_SHARE`. responses: 200 The message that was sent 400 Error. Classify by `code` in the body, never by the HTTP status. ### DELETE /organizations/{id_organization}/accounts/{id_account}/messages Delete every message of an account Removes the whole message history PlanVortex stores for this account. **It does not delete anything on the social network**: the conversation is still there, and reading the account again re-imports it. operationId: deleteMessagesByAccount callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier responses: 200 Deleted 400 Error. Classify by `code` in the body, never by the HTTP status. ## organization_roles Roles and user membership inside an organization ### GET /organizations/{id_organization}/roles List the roles of an organization Returns the roles defined in the organization, paginated. Access: the caller needs either the client-level permission `client_organization_roles:read` or the organization-level permission `organization_roles:read`. Every endpoint under this organization also requires the client plan to allow at least 2 users (error `511` otherwise). operationId: getOrganizationRoles callers: current_user, current_app - id_organization (path, string, required): Organization identifier - offset (query, integer, optional): Number of records to skip (pagination) - limit (query, integer, optional): Maximum number of records returned (pagination) responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `501` | Invalid authorization token | | `511` | This functionality requires a plan with at least 2 users | | `518` | User or application deleted | | `522` | Invalid token: expired | | `1101` | Invalid organization | 401 Insufficient permissions. The body carries error code `520` with the permissions that were required. 500 Unhandled error by the server (PlanVortex error code `500`). ### POST /organizations/{id_organization}/roles Create a role in an organization Creates a role with its permission list. The name must be unique within the organization (error `1205`) and every permission must be a valid organization permission (error `1201`). Access: the caller needs either the client-level permission `client_organization_roles:create` or the organization-level permission `organization_roles:create`. Every endpoint under this organization also requires the client plan to allow at least 2 users (error `511` otherwise). operationId: createOrganizationRole callers: current_user, current_app - id_organization (path, string, required): Organization identifier body (application/json): OrganizationsRolesOrganizationRoleInput — required - name (string, required): Role name. Must be unique inside the organization. - permissions (array of "organization:create" | "organization:update" | "organization:read" | "organization:delete" | "organization_roles:create" | "organization_roles:update" | "organization_roles:read" | "organization_roles:delete" | "organization_users_roles:read" | "organization_users_roles:create" | "organization_users_roles:delete" | "accounts:create" | "accounts:update" | "accounts:read" | "accounts:delete" | "products:create" | "products:update" | "products:read" | "products:delete" | "files:create" | "files:update" | "files:read" | "files:delete" | "publications:create" | "publications:update" | "publications:read" | "publications:delete" | "account_stats:read" | "publication_stats:read" | "messages:create" | "messages:read" | "messages:delete" | "contacts:create" | "contacts:read" | "contacts:update" | "contacts:delete" | "ai_plans:create" | "ai_plans:read" | "ai_plans:update" | "ai_plans:delete", required): Complete list of organization permissions granted by this role. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `501` | Invalid authorization token | | `511` | This functionality requires a plan with at least 2 users | | `518` | User or application deleted | | `522` | Invalid token: expired | | `1101` | Invalid organization | | `1201` | Invalid organization permission detected | | `1205` | Role name already exists in this organization | 401 Insufficient permissions. The body carries error code `520` with the permissions that were required. 500 Unhandled error by the server (PlanVortex error code `500`). ### PUT /organizations/{id_organization}/roles/{id_rol} Update a role Replaces the name and the permission list of the role. **Both properties are overwritten**, so send the complete permission list and not just the ones you are adding. Default roles cannot be updated (error `1207`). Access: the caller needs either the client-level permission `client_organization_roles:update` or the organization-level permission `organization_roles:update`. Every endpoint under this organization also requires the client plan to allow at least 2 users (error `511` otherwise). operationId: updateOrganizationRole callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_rol (path, string, required): Role identifier body (application/json): OrganizationsRolesOrganizationRoleInput — required - name (string, required): Role name. Must be unique inside the organization. - permissions (array of "organization:create" | "organization:update" | "organization:read" | "organization:delete" | "organization_roles:create" | "organization_roles:update" | "organization_roles:read" | "organization_roles:delete" | "organization_users_roles:read" | "organization_users_roles:create" | "organization_users_roles:delete" | "accounts:create" | "accounts:update" | "accounts:read" | "accounts:delete" | "products:create" | "products:update" | "products:read" | "products:delete" | "files:create" | "files:update" | "files:read" | "files:delete" | "publications:create" | "publications:update" | "publications:read" | "publications:delete" | "account_stats:read" | "publication_stats:read" | "messages:create" | "messages:read" | "messages:delete" | "contacts:create" | "contacts:read" | "contacts:update" | "contacts:delete" | "ai_plans:create" | "ai_plans:read" | "ai_plans:update" | "ai_plans:delete", required): Complete list of organization permissions granted by this role. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `501` | Invalid authorization token | | `511` | This functionality requires a plan with at least 2 users | | `518` | User or application deleted | | `522` | Invalid token: expired | | `1101` | Invalid organization | | `1201` | Invalid organization permission detected | | `1202` | Invalid organization role | | `1205` | Role name already exists in this organization | | `1207` | Default roles cannot be updated or deleted | 401 Insufficient permissions. The body carries error code `520` with the permissions that were required. 500 Unhandled error by the server (PlanVortex error code `500`). ### DELETE /organizations/{id_organization}/roles/{id_rol} Delete a role Deletes the role. Users that belonged to it lose the permissions it granted. Default roles cannot be deleted (error `1207`). Access: the caller needs either the client-level permission `client_organization_roles:delete` or the organization-level permission `organization_roles:delete`. Every endpoint under this organization also requires the client plan to allow at least 2 users (error `511` otherwise). operationId: deleteOrganizationRole callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_rol (path, string, required): Role identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `501` | Invalid authorization token | | `511` | This functionality requires a plan with at least 2 users | | `518` | User or application deleted | | `522` | Invalid token: expired | | `1101` | Invalid organization | | `1202` | Invalid organization role | | `1207` | Default roles cannot be updated or deleted | 401 Insufficient permissions. The body carries error code `520` with the permissions that were required. 500 Unhandled error by the server (PlanVortex error code `500`). ### GET /organizations/{id_organization}/roles/{id_rol}/users List the users of a role Returns the users that belong to the role, with the roles each of them holds in this organization. Paginated. A user that no longer exists in the identity provider is skipped instead of breaking the listing, so `users` may be shorter than `total`. Access: the caller needs either the client-level permission `client_organization_users_roles:read` or the organization-level permission `organization_users_roles:read`. Every endpoint under this organization also requires the client plan to allow at least 2 users (error `511` otherwise). operationId: getUsersInOrganizationRole callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_rol (path, string, required): Role identifier - offset (query, integer, optional): Number of records to skip (pagination) - limit (query, integer, optional): Maximum number of records returned (pagination) responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `501` | Invalid authorization token | | `511` | This functionality requires a plan with at least 2 users | | `518` | User or application deleted | | `522` | Invalid token: expired | | `1101` | Invalid organization | | `1202` | Invalid organization role | 401 Insufficient permissions. The body carries error code `520` with the permissions that were required. 500 Unhandled error by the server (PlanVortex error code `500`). ### POST /organizations/{id_organization}/roles/{id_rol}/users/{id_user} Add an existing user to a role Adds a user who already exists to the role. Idempotent: adding the same user twice does nothing. The organization cannot exceed the number of users its plan allows (error `1109`). To bring in somebody who is not a PlanVortex user yet, use the invite endpoint instead. Access: the caller needs either the client-level permission `client_organization_users_roles:create` or the organization-level permission `organization_users_roles:create`. Every endpoint under this organization also requires the client plan to allow at least 2 users (error `511` otherwise). operationId: addUserToOrganizationRole callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_rol (path, string, required): Role identifier - id_user (path, string, required): User identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `501` | Invalid authorization token | | `511` | This functionality requires a plan with at least 2 users | | `518` | User or application deleted | | `522` | Invalid token: expired | | `1101` | Invalid organization | | `1109` | Max users per organization reached | | `1202` | Invalid organization role | 401 Insufficient permissions. The body carries error code `520` with the permissions that were required. 500 Unhandled error by the server (PlanVortex error code `500`). ### DELETE /organizations/{id_organization}/roles/{id_rol}/users/{id_user} Remove a user from a role Removes the user from the role. The last user of a default role can be removed too: an organization's `Admin` role is allowed to end up with nobody in it. Access: the caller needs either the client-level permission `client_organization_users_roles:delete` or the organization-level permission `organization_users_roles:delete`. Every endpoint under this organization also requires the client plan to allow at least 2 users (error `511` otherwise). operationId: removeUserFromOrganizationRole callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_rol (path, string, required): Role identifier - id_user (path, string, required): User identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `501` | Invalid authorization token | | `511` | This functionality requires a plan with at least 2 users | | `518` | User or application deleted | | `522` | Invalid token: expired | | `1101` | Invalid organization | | `1202` | Invalid organization role | 401 Insufficient permissions. The body carries error code `520` with the permissions that were required. 500 Unhandled error by the server (PlanVortex error code `500`). ### POST /organizations/{id_organization}/roles/{id_rol}/invite Invite somebody to a role by email Sends an invitation email with a temporary link and assigns the invited user to the role. If nobody exists with that address, a disabled user is created and enabled when the invitation is accepted. If the address is already a disabled invited user, the invitation is re-sent. Returns `609` if the address is not valid and `508` if the email could not be sent. Access: the caller needs either the client-level permission `client_organization_roles:create` or the organization-level permission `organization_roles:create`. Every endpoint under this organization also requires the client plan to allow at least 2 users (error `511` otherwise). operationId: inviteUserToOrganizationRole callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_rol (path, string, required): Role identifier body (application/json) — required - email (string (email), required): Address to invite. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `501` | Invalid authorization token | | `508` | Error sending invitation | | `511` | This functionality requires a plan with at least 2 users | | `518` | User or application deleted | | `522` | Invalid token: expired | | `609` | Invalid email | | `1101` | Invalid organization | | `1202` | Invalid organization role | 401 Insufficient permissions. The body carries error code `520` with the permissions that were required. 500 Unhandled error by the server (PlanVortex error code `500`). ### GET /organizations_permissions List every organization permission that exists Returns the catalogue of organization permissions accepted in the `permissions` property of a role. Static: it does not depend on the caller or on any organization. Only a valid token is required. operationId: getOrganizationPermissions callers: current_user, current_app, temporal_token responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `501` | Invalid authorization token | | `518` | User or application deleted | | `522` | Invalid token: expired | 401 Insufficient permissions. The body carries error code `520` with the permissions that were required. 500 Unhandled error by the server (PlanVortex error code `500`). ## orgnaizations Organizations management. Define new organizations and isolate resources between them ### GET /organizations/{id_organization} Get an organization by identifier Get one organization by its identifier operationId: getOrganization callers: current_user, current_app, temporal_token - id_organization (path, string, required): Organization identifier - getUse (query, boolean, optional): Determine if the query will return the actual use of the organization plan responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | 500 Unhandled error by the server ### PUT /organizations/{id_organization} Update a child organization by identifier Update child organization by its identifier operationId: updateOrganizationInsideOrganization callers: current_user, current_app - id_organization (path, string, required): Organization identifier body (application/json): OrganizationsOrganizationUpdate — required - actual_plan (PlanData): New resource assignment. Validated against what is left of the client's contracted plan; lowering it below what the organization already uses is rejected. - stats_settings (StatsSettings): Statistics collection settings. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | 500 Unhandled error by the server ### DELETE /organizations/{id_organization} Delete a child organization by identifier Delete child organization by its identifier operationId: deleteChildOrganization callers: current_user, current_app - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/limits Retrieve actual limitations for the received organization etrieve actual limitations for the received organization operationId: getOrganizationLimits callers: current_user, current_app, temporal_token - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/users Retrieve all users in the received organization Retrieve all users with any role in the received organization operationId: getUserInOrganizationRole callers: current_user, current_app - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/organizations Get child organizations of the received organization Return a list of child organizations inside the received organization operationId: getChildOrganizations callers: current_user, current_app, temporal_token - id_organization (path, string, required): Organization identifier - getUse (query, boolean, optional): Boolean determine if the server will response with the actual use of each client - offset (query, integer, optional): Number of records from which you will get clients (pagination) - limit (query, integer, optional): The limit of records will be retrieved (pagination) - name (query, string, optional): The limit of records will be retrieved (pagination) responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | 500 Unhandled error by the server ### POST /organizations/{id_organization}/organizations Create a child organization inside the received organization Create a new child organization with its own resources inside the received parent organization operationId: createOrganizationInsideOrganization callers: current_user, current_app - id_organization (path, string, required): Parent organization. The new organization hangs from this one body (application/json): OrganizationsOrganizationCreate — required - name (string, required): Organization name. - actual_plan (PlanData): Resources assigned to this organization, taken from what the client has contracted. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `1107` | Organizations doesn't allow set parent_organization as his own _id | | `1100` | Max deep of parents allowed | | `1400` | Account limit reached for the organization plan | | `1401` | Publication limit reached for the organization plan | | `1402` | Users limit reached for the organization plan | | `1403` | Shared space limit reached for the organization plan | | `1407` | Twitter credits limit reached for the organization plan. The sum of twitter_credits assigned to the client's organizations cannot exceed the client's plan twitter_credits. | | `1408` | AI credits limit reached for the organization plan. The sum of ai_credits assigned to the client's organizations cannot exceed the client's plan ai_credits. | | `1301` | Client plan invalid. Min accounts must be X | | `1302` | Client plan invalid. Min publications must be X | | `1303` | Client plan invalid. Min users must be at least X | | `1306` | Client plan invalid. Min shared space must be at least X | | `523` | Invalid application | 500 Unhandled error by the server ### PUT /organizations/{id_organization}/social_credentials/{social_network} Save the organization's own application credentials for a network **Bring your own bot.** On every network but one, the application talking to the social network is PlanVortex's. On `discord` it is the client's: the organization creates its own application in Discord's developer portal and saves its credentials here. The reason is not technical. The permission that lets an application read the **text** of messages (`MESSAGE_CONTENT`) is reviewed **per application** once it is reachable by more than 10.000 users, so one shared application would drag every client on the platform into an annual review the first time a single community grew. This way the exposure stays with the client that caused it. Three consequences worth building against: • **Until this is saved, `discord` does not appear in `GET /organizations/{id_organization}/connect_links` at all** and every Discord call answers error 960. That is the initial state of every organization, not a failure. • **Secrets are write-only.** They are stored encrypted and never come back: the organization object returns `client_id`, `application_name`, `verified_date` and the two booleans `has_client_secret` / `has_bot_token`, never the values. **What you do not send is kept**, so the `client_id` can be corrected without resending the token. • **Saving validates against Discord first** (`GET /oauth2/applications/@me`) and stores nothing if that fails. It also catches the two portal switches that would otherwise break the OAuth flow with no explanation: *Public Bot* must be **on** and *Requires OAuth2 Code Grant* must be **off**. Requires the `organization:update` permission (`client_organization:update` for apps). There is no GET on purpose: everything readable already travels inside the normal organization object, in `social_credentials`. operationId: updateOrganizationSocialCredentials callers: current_user, current_app - id_organization (path, string, required): Organization identifier - social_network (path, "discord", required): The network the credentials belong to. Only `discord` has its own application today; any other value answers error 702. body (application/json): OrganizationsSocialCredentialsInput — required - client_id (string): The Discord application's id (its *Application ID*, which is also its client id). - client_secret (string): The application's OAuth2 secret. Stored encrypted. - bot_token (string): The bot's token. Stored encrypted, and validated against Discord before anything is saved. responses: 200 The organization, with the credentials it now has. Never with their values. 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `702` | That network has no own-application credentials | | `1406` | A required field is missing: `client_id`, `client_secret` or `bot_token` | | `959` | Discord refused the credentials, or the application is misconfigured in the portal | | `1101` | Invalid organization | 500 Unhandled error by the server ### DELETE /organizations/{id_organization}/social_credentials/{social_network} Remove the organization's own credentials for a network The organization stops being able to connect accounts on that network, and the network disappears from `connect_links` again. **Accounts already connected are not touched.** Publishing through a Discord channel goes out through that channel's incoming webhook, which does not depend on these credentials — what stops working is reading comments and statistics, which needs the bot. operationId: deleteOrganizationSocialCredentials callers: current_user, current_app - id_organization (path, string, required): Organization identifier - social_network (path, "discord", required): The network whose credentials are removed. responses: 200 The organization without those credentials 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `702` | That network has no own-application credentials | | `1101` | Invalid organization | 500 Unhandled error by the server ### GET /organizations/{id_organization}/temporal_connect_token Issue a temporal connect token for an end user Returns a URL that lets **a person** connect a social account to this organization, without that person having a PlanVortex account and without your `client_secret` ever leaving your server. It exists because **an app cannot connect accounts**: `connect_links` and `account-connect` refuse app credentials with error 519, since authorizing Instagram is an OAuth flow with a human in front of it. The shape of the integration is therefore: 1. Your server asks for this token — **this endpoint is the one that requires app credentials**, and a user token is refused. So is a temporal connect token: it cannot renew itself, which is what keeps its life a real limit rather than the first link of a chain. 2. You send your end user to the `url` that comes back, by redirect or in an iframe. The bare `token` comes back too, so a server-side client can authenticate with it directly instead of parsing it out of that URL. 3. With that token the browser completes `connect_links` and `account-connect`. The token lasts **fifteen minutes**, is tied to **this** organization — using it against another answers error 1101 — and carries only two permissions: create accounts and read the organization. It is the piece designed for a browser, and the only credential of ours that belongs there. **It connects once.** As soon as an `account-connect` succeeds, the token stops being able to connect anything else and answers error 543; the `enable` calls that finish that same connection keep working until it expires. Issue a new one per connection — they are free and immediate. **If you pass `social_network`, the token is bound to that network** and will not connect any other (error 544). Leave it out to let the person pick. If you send `redirect_uri` it has to be one of the app's `redirect_urls`, or the call answers error 532. operationId: createTemporalConnectToken callers: current_app - id_organization (path, string, required): Organization identifier - social_network (query, SocialNetwork, optional): Network the user is going to connect. It travels inside the returned URL **and inside the token itself**, so the token will only connect that network (error 544 otherwise). A network outside the allowed list is rejected here with error 702. - redirect_uri (query, string, optional): Where the user comes back after connecting. Has to be one of the app's `redirect_urls` (error 532). responses: 200 The URL to send the user to, the bare token, and when it expires. 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `514` | This endpoint needs app credentials: neither a user token nor a temporal connect token can issue one | | `532` | `redirect_uri` is not one of the app's registered `redirect_urls` | | `702` | `social_network` is not one of the networks PlanVortex supports | | `1101` | Invalid organization | 500 Unhandled error by the server ### PUT /organizations/{id_organization}/ai-context Replace the organization's brand context for AI The brand context every AI plan of this organization starts from: what the business does, who it talks to, the tone, what must never appear. **It replaces the whole context, it is not a patch.** A field you leave out is erased, and an empty body removes the context entirely — after which plans run on their own prompt alone. There is no GET: the current context already travels inside the organization object, in `ai_context`. operationId: updateOrganizationAiContext callers: current_user, current_app - id_organization (path, string, required): Organization identifier body (application/json): AiContext — required - brand_name (string): Commercial name, when it differs from the organization's. - description (string): What the organization does. - sector (string) - audience (string): Who the content is aimed at. - value_proposition (string) - website (string) - shop_url (string) - blog_url (string) - social_urls (array of string): Social profiles or other reference links. - default_tone (string): Default tone. A tone set on the plan itself wins over this one. - keywords (array of string): Recurring keywords and hashtags. - products (string): Products or services that may be promoted. - avoid (string): Topics, expressions or competitors that must never appear. - notes (string): Anything else the model should know. responses: 200 The organization with its new context 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | 500 Unhandled error by the server ## products Commerce catalogues and products of a connected account ### GET /organizations/{id_organization}/accounts/{id_account}/products_catalogs List the account's product catalogues The catalogues owned by the business behind this account. **`total` is the size of the page, not of the collection.** Meta paginates with cursors and does not return a count, so there is nothing better to report: keep asking with a larger `offset` until a page comes back short. **Facebook and Instagram only.** They are the only two networks with `products: true` in `GET /social_capabilities`; WhatsApp does not have it either, despite its own catalogue. Everything here speaks Meta Commerce, with Meta's own field names. operationId: getProductCatalogs callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - offset (query, integer, optional): Records to skip (pagination) - limit (query, integer, optional): Records per page (pagination) responses: 200 Catalogues 400 Error. Classify by `code` in the body, never by the HTTP status. ### POST /organizations/{id_organization}/accounts/{id_account}/products_catalogs Create a product catalogue Creates a catalogue under the business behind this account. Only `name` is required. **The answer carries the identifier, not the object**, even though the field is called `product_catalog`. Read the list back if you need the full catalogue. **Facebook and Instagram only.** They are the only two networks with `products: true` in `GET /social_capabilities`; WhatsApp does not have it either, despite its own catalogue. Everything here speaks Meta Commerce, with Meta's own field names. operationId: createProductCatalog callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier body (application/json): ProductsProductCatalogInput — required - name (string, required) responses: 200 Identifier of the created catalogue 400 Error. Classify by `code` in the body, never by the HTTP status. ### GET /organizations/{id_organization}/accounts/{id_account}/products List products The products of a catalogue, or a single product when `product_id` is sent. As with the catalogues, `total` counts the page. **Facebook and Instagram only.** They are the only two networks with `products: true` in `GET /social_capabilities`; WhatsApp does not have it either, despite its own catalogue. Everything here speaks Meta Commerce, with Meta's own field names. operationId: getProducts callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - product_catalog_id (query, string, optional): The catalogue to list. **Effectively required**: without it the request fails with `ERROR_CODE_2000`. Get one from `GET .../products_catalogs`. - product_id (query, string, optional): One product, by its identifier on the network. It takes precedence over `product_catalog_id`, and one of the two has to travel or the call answers `ERROR_CODE_2000`. It used to be forwarded under a name the SDK does not read, so it never reached the network; that was fixed on 2026-08-24. **The answer is shaped differently.** Asking for one product goes to that product's node, so the network answers with the product itself and `items` carries an object rather than an array. Handle both shapes, or use `product_catalog_id` and pick the product out of the page. - offset (query, integer, optional): Records to skip (pagination) - limit (query, integer, optional): Records per page (pagination) responses: 200 Products 400 Error. Classify by `code` in the body, never by the HTTP status. ### POST /organizations/{id_organization}/accounts/{id_account}/products Create or update a product **One endpoint does both.** Send `id` in the body and the existing product is updated; leave it out and a new product is created inside `product_catalog_id`. `product_catalog_id` travels as a **query parameter** and is required when creating. Without it the call answers error 2000. **Facebook and Instagram only.** They are the only two networks with `products: true` in `GET /social_capabilities`; WhatsApp does not have it either, despite its own catalogue. Everything here speaks Meta Commerce, with Meta's own field names. operationId: createProduct callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - product_catalog_id (query, string, optional): Catalogue the product is created in. Required unless the body carries an `id`. body (application/json): ProductsProductInput — required - id (string): Send it to update an existing product. Leave it out to create one. - retailer_id (string, required): **Your** identifier for the product. It is what ties the Meta catalogue to your system. - name (string, required) - description (string) - price (integer, required): In **cents** of `currency`. 1250 is 12,50. - currency (string, required): ISO 4217 code, for example `EUR`. - image_url (string, required) - additional_image_urls (array of string) - url (string): The product's page on your site. - availability ("in stock" | "out of stock" | "preorder" | "available for order" | "discontinued" | "pending" | "mark_as_sold") - condition ("new" | "refurbished" | "used" | "used_like_new" | "used_good" | "used_fair" | "cpo" | "open_box_new") - brand (string) - category (string) - color (string) responses: 200 Identifier of the created or updated product 400 Error. Classify by `code` in the body, never by the HTTP status. ## publications Publications on social networks ### GET /organizations/{id_organization}/accounts/{id_account}/publish Retrieve list of publications Retrieve list of publications from one account (paginated) operationId: getAccountPublications callers: current_user, current_app - id_account (path, string, required): Account identifier - id_organization (path, string, required): Organization identifier - offset (query, integer, optional): Number of records from which you will get publications (pagination) - limit (query, integer, optional): The limit of records will be retrieved (pagination) - from_date (query, string (date-time), optional): Date from which records will be returned - to_date (query, string (date-time), optional): Date until which records will be returned - search (query, string, optional): String used to search results - orderByPublish (query, boolean, optional): Determine if the results will be ordered by publication_date instead of creation_date - social_network (query, array of SocialNetwork, optional): Only publications of these networks. Repeat the parameter for more than one. - accounts (query, array of string, optional): Retrieve publications based on a list of identifier accounts - state (query, array of "ready" | "withErrors" | "sended" | "draft" | "publishing", optional): Retrieve publications based on a list of states responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | 500 Unhandled error by the server ### POST /organizations/{id_organization}/accounts/{id_account}/publish Create new publications Create new publications. For X (Twitter) accounts, paid actions consume the client's monthly X credit pool (shared across organizations). When a publication is sent immediately, the required credits are checked before publishing and only charged on success. A tweet costs 15 credits, or 200 credits if its text contains a link. Link detection uses the same rule the backend applies: an http(s):// URL, a www. host, or a bare domain with a recognized TLD (regex TWITTER_LINK_REGEX). A scheduled publication does not consume credits when created; it is charged when the background job publishes it. If there are not enough credits at publish time the scheduled publication is left in state 'withErrors' with publication_errors.code = 940 and the client-app webhook is fired (it is never thrown). Publishing immediately without enough credits returns error 940. operationId: addPublication callers: current_user, current_app - id_organization (path, string, required): Organization the publication belongs to - id_account (path, string, required): Connected social account the publication will be published to body (application/json) — required - social_network ("facebook" | "instagram" | "twitter" | "linkedin" | "tiktok" | "whatsapp" | "youtube" | "bluesky" | "discord" | "telegram" | "threads" | "slack", required): Network the publication targets. **Required when creating**: the request fails with error 702 if it is missing or not one of these values. It must match the network of the account in the path. Not every connectable network publishes — a local business listing receives reviews, not posts — so this list is shorter than the one in `GET /social_networks`. Ask `GET /allowed_social_publications` rather than hardcoding it, because it grows. - text (string): Body text of the publication. Either `text` or at least one entry in `files` is required: if both are empty the publication is still created, but in state `withErrors` with `publication_errors[].code = 915`. Maximum length depends on the network. On YouTube this is the video **description** (5,000 characters), and the publication must carry exactly one video file and no images — otherwise it is created in state `withErrors` with `publication_errors[].code = 943`. For X (Twitter), a text containing a link costs 200 credits instead of 15. **On Telegram the limit depends on what else the publication carries**: 4.096 characters while it is text only, and **1.024** the moment it has an image or a video, because then the text is the caption of a photo, a video or an album and no longer a message. Over the limit it is created in state `withErrors` with `publication_errors[].code = 967`, whose `data` carries `characters`, `max_characters` and `has_media`. Both numbers are published, as `characters.telegram` and `characters.telegram_media` in `GET /social_limits`. **On Slack the limit is 4.000 characters** and it is counted over the text you send, not over what travels: `&`, `<` and `>` are escaped before publishing, so a text made of ampersands grows on the wire and is still measured here. Over the limit the publication is created in state `withErrors` with `publication_errors[].code = 981`. And because the escaped text is what is measured on the wire, a text that passed at 4.000 characters and is full of `&` is **trimmed** before going out — Slack does not reject a long `text`, it truncates it or splits it into several messages, and one publication showing up as two posts is worse. The text goes out **plain**: Slack speaks *mrkdwn* and not Markdown, and PlanVortex sends no `blocks`, so `**bold**` is published literally. - title (string): Title for the publication. Only some networks use it: optional on LinkedIn, and **required on YouTube**, where it is the video title and must be 100 characters or fewer — a publication without it, or with a longer one, is created in state `withErrors` with `publication_errors[].code = 944`. - files (array of string): Identifiers of uploads previously created through the uploads endpoints, attached to this publication. **On Slack the files travel inside the message**, not as publications of their own: up to 10 attachments counting images and videos together (`publication_errors[].code = 982` over it), each one under the `max_file_size_mb.slack` ceiling (code 983), and anything the upload itself refuses comes back as code 986. - publish_date (string (date-time)): When the publication must go out. If omitted, it is published immediately. An invalid date returns error 938. - name (string): Internal name for the publication. Useful for grouping; never shown on the social network. - publication_type ("profile" | "page" | "group" | "reels" | "stories"): Defaults to `profile`. Not every network accepts every type, and an unsupported combination returns error 923. Allowed values are: facebook and instagram -> profile, reels, stories; twitter, linkedin, tiktok and youtube -> profile; whatsapp -> stories. YouTube has no separate type for Shorts: any vertical video of 3 minutes or less is classified as one automatically. - state ("ready" | "withErrors" | "sended" | "draft" | "publishing"): Send `draft` to store the publication without publishing it. If omitted, the state is resolved automatically: `ready` when everything validates, `withErrors` otherwise. Forcing `sended` marks it as published without actually sending it. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `924` | Max publication reached per month | | `918` | Publications must be attached to an account. Missing property: id_account | | `913` | Invalid publication type | | `914` | Invalid publication state | | `923` | Social network doesn't allow this publication type | | `700` | Account not connected, missing authorizations tokens | | `915` | You should write a text or, at least, upload some image or video | | `940` | X (Twitter) credits exhausted. Not enough monthly credits to publish this tweet. Response data: { used, limit }. | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/publish Retrieve list of publications Retrieve all publications of the organization (paginated) operationId: getPublications callers: current_user, current_app - id_organization (path, string, required): Organization identifier - offset (query, integer, optional): Number of records from which you will get publications (pagination) - limit (query, integer, optional): The limit of records will be retrieved (pagination) - from_date (query, string (date-time), optional): Date from which records will be returned - to_date (query, string (date-time), optional): Date until which records will be returned - search (query, string, optional): String used to search results - orderByPublish (query, boolean, optional): Determine if the results will be ordered by publication_date instead of creation_date - social_network (query, array of SocialNetwork, optional): Only publications of these networks. Repeat the parameter for more than one. - accounts (query, array of string, optional): Retrieve publications based on a list of identifier accounts - state (query, array of "ready" | "withErrors" | "sended" | "draft" | "publishing", optional): Retrieve publications based on a list of states responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/publish/{id_publication} Retrieve publication by identifier Retrieve publication by identifier. A **deleted** publication answers error 917, the same as one that never existed: the delete is soft on our side, but it is a delete for the API. operationId: getPublication callers: current_user, current_app - id_publication (path, string, required): Publication identifier - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `917` | Publication doesn't exists | | `935` | Invalid publication | | `523` | Invalid application | 500 Unhandled error by the server ### PUT /organizations/{id_organization}/publish/{id_publication} Update publication by identifier operationId: updatePublication callers: current_user, current_app - id_organization (path, string, required): Organization the publication belongs to - id_publication (path, string, required): Publication identifier body (application/json): PublicationsPublicationInput — required - social_network ("facebook" | "instagram" | "twitter" | "linkedin" | "tiktok" | "whatsapp" | "youtube" | "bluesky" | "discord" | "telegram" | "threads" | "slack"): Network the publication targets. **Required when creating**: the request fails with error 702 if it is missing or not one of these values. It must match the network of the account in the path. Not every connectable network publishes — a local business listing receives reviews, not posts — so this list is shorter than the one in `GET /social_networks`. Ask `GET /allowed_social_publications` rather than hardcoding it, because it grows. - text (string): Body text of the publication. Either `text` or at least one entry in `files` is required: if both are empty the publication is still created, but in state `withErrors` with `publication_errors[].code = 915`. Maximum length depends on the network. On YouTube this is the video **description** (5,000 characters), and the publication must carry exactly one video file and no images — otherwise it is created in state `withErrors` with `publication_errors[].code = 943`. For X (Twitter), a text containing a link costs 200 credits instead of 15. **On Telegram the limit depends on what else the publication carries**: 4.096 characters while it is text only, and **1.024** the moment it has an image or a video, because then the text is the caption of a photo, a video or an album and no longer a message. Over the limit it is created in state `withErrors` with `publication_errors[].code = 967`, whose `data` carries `characters`, `max_characters` and `has_media`. Both numbers are published, as `characters.telegram` and `characters.telegram_media` in `GET /social_limits`. **On Slack the limit is 4.000 characters** and it is counted over the text you send, not over what travels: `&`, `<` and `>` are escaped before publishing, so a text made of ampersands grows on the wire and is still measured here. Over the limit the publication is created in state `withErrors` with `publication_errors[].code = 981`. And because the escaped text is what is measured on the wire, a text that passed at 4.000 characters and is full of `&` is **trimmed** before going out — Slack does not reject a long `text`, it truncates it or splits it into several messages, and one publication showing up as two posts is worse. The text goes out **plain**: Slack speaks *mrkdwn* and not Markdown, and PlanVortex sends no `blocks`, so `**bold**` is published literally. - title (string): Title for the publication. Only some networks use it: optional on LinkedIn, and **required on YouTube**, where it is the video title and must be 100 characters or fewer — a publication without it, or with a longer one, is created in state `withErrors` with `publication_errors[].code = 944`. - files (array of string): Identifiers of uploads previously created through the uploads endpoints, attached to this publication. **On Slack the files travel inside the message**, not as publications of their own: up to 10 attachments counting images and videos together (`publication_errors[].code = 982` over it), each one under the `max_file_size_mb.slack` ceiling (code 983), and anything the upload itself refuses comes back as code 986. - publish_date (string (date-time)): When the publication must go out. If omitted, it is published immediately. An invalid date returns error 938. - name (string): Internal name for the publication. Useful for grouping; never shown on the social network. - publication_type ("profile" | "page" | "group" | "reels" | "stories"): Defaults to `profile`. Not every network accepts every type, and an unsupported combination returns error 923. Allowed values are: facebook and instagram -> profile, reels, stories; twitter, linkedin, tiktok and youtube -> profile; whatsapp -> stories. YouTube has no separate type for Shorts: any vertical video of 3 minutes or less is classified as one automatically. - state ("ready" | "withErrors" | "sended" | "draft" | "publishing"): Send `draft` to store the publication without publishing it. If omitted, the state is resolved automatically: `ready` when everything validates, `withErrors` otherwise. Forcing `sended` marks it as published without actually sending it. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `917` | Publication doesn't exists | | `935` | Invalid publication | | `921` | Can't update a publication already sended | | `940` | X (Twitter) credits exhausted. Not enough monthly credits to publish this tweet (same link detection and 15/200 tariff as on create). Response data: { used, limit }. | | `523` | Invalid application | 500 Unhandled error by the server ### DELETE /organizations/{id_organization}/publish/{id_publication} Delete publication by identifier Delete publication by identifier. It stops being readable by identifier afterwards, so deleting twice answers error 917. For an already-sent X (Twitter) publication, removing the tweet on X is a paid action that consumes 15 X credits; it is only removed on X when there are enough credits. Returns error 940 when the X credit pool is exhausted. **On Telegram there is a 48-hour window.** Past it the Bot API refuses to delete a message whatever the bot's role is, and the answer is error 966 with `published_date` and `max_hours` in `data` — so the sensible thing is to grey the button out rather than offer it and fail. Error 969 is the other case: the bot is no longer allowed to delete there. And an album is several messages: all of them go, or the post would be left half-published in the channel. operationId: deletePublication callers: current_user, current_app - id_publication (path, string, required): Publication identifier - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `917` | Publication doesn't exists | | `935` | Invalid publication | | `940` | X (Twitter) credits exhausted. Deleting the tweet on X could not be charged. Response data: { used, limit }. | | `966` | The Telegram message is older than 48 hours and the Bot API will not delete it any more. Response data: { published_date, max_hours }. | | `969` | The PlanVortex bot is not allowed to delete messages in that Telegram chat. Response data: { chat_id }. | | `523` | Invalid application | 500 Unhandled error by the server ### POST /organizations/{id_organization}/publish/{id_publication}/retry Retry a failed publication Sends a publication that ended in state `withErrors` to its social network again, WITHOUT changing its content. The publication is sent during the request, so the response already carries the outcome: `state: "sended"` if it went out, or `state: "withErrors"` with the errors of THIS attempt if it failed again. A scheduled publication whose date has not arrived yet goes back to `ready` and the scheduler picks it up at its time. Every accepted call spends one retry, even when the publication fails again — what the endpoint promises is an attempt, not a success. The limit is `max_retries`, also served by `GET /publication_limits`. Once it is spent the way to try again is to update the publication (`PUT`), which resets the counter to 0, or to create a new one. operationId: retryPublication callers: current_user, current_app - id_organization (path, string, required): Organization the publication belongs to - id_publication (path, string, required): Publication identifier responses: 200 The attempt ran. `publication.state` and `publication.publication_errors` carry the outcome. 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `917` | Publication doesn't exists | | `935` | Invalid publication | | `949` | Only a publication with errors can be retried. Response data: { state }. | | `950` | Publication retry limit reached. Response data: { retries, max_retries }. Update the publication to reset the counter. | | `940` | X (Twitter) credits exhausted. Nothing was sent and no retry was spent. Response data: { used, limit }. | | `942` | Social network doesn't allow publications | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/publish/{id_publication}/metrics Get metrics from the received publication Get metrics from the received publication. Retrieve only metrics associated to the social network of the publication. For X (Twitter) publications, fetching metrics on demand is a paid owned-read that consumes 1 X credit from the client's monthly pool; returns error 940 when it is exhausted. Fields the network did not report are **omitted from the response**, which is not the same as a zero — see the `PublicationStats` schema. operationId: getPublicationMetrics callers: current_user, current_app - id_publication (path, string, required): Publication identifier - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `917` | Publication doesn't exists | | `935` | Invalid publication | | `940` | X (Twitter) credits exhausted. Not enough monthly credits to fetch these metrics. Response data: { used, limit }. | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/publish/{id_publication}/stats Get the measured history of a publication Time series of everything already measured for this publication: one row per day, from oldest to newest. Unlike `/metrics`, this endpoint **does not call the social network** and therefore **never consumes X (Twitter) credits** — it only reads what the collector already stored. Use it to draw a publication's evolution; use `/metrics` only when you deliberately want a fresh read. Statistics are collected for **30 days** after publishing, on a decreasing cadence (every 6 h for the first 48 h, then daily, then weekly). Past that window `next_stats_update` is omitted and the series stops growing. Each row is the publication's **running total** at that date, not that day's increment: to get the daily delta, subtract consecutive rows. An empty `series` is a valid answer (publication just sent, or a network that reports no statistics for that content), not an error. operationId: getPublicationStatsHistory callers: current_user, current_app - id_publication (path, string, required): Publication identifier - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `917` | Publication doesn't exists | | `935` | Invalid publication | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/publications/stats List publications with their metrics Publications sent in the range, each with its last known normalized metrics, ordered by the requested metric. Covers the organization **and its children**. Two things to keep in mind when reading the response: - `summary` always covers the **whole organization**; `social_network` and `accounts` filter only the listing. Pass `summary=false` when paging so the aggregates are not recomputed on every page — they only depend on the date range. - Publications that have **not been measured yet** are included, with `metrics` absent. That is the difference with `/publications/top`, which can only rank what has already been measured. An absent metric is not a zero. Only `sended` publications are listed: a draft or a scheduled publication has nothing to measure. operationId: getPublicationsStats callers: current_user, current_app - id_organization (path, string, required): Organization identifier - from_date (query, string (date-time), optional): Start of the range, filtered by publication date. Defaults to 30 days before `to_date`. The range cannot be longer than 366 days. - to_date (query, string (date-time), optional): End of the range, filtered by publication date. Defaults to now. - metric (query, "impressions" | "reach" | "engagement" | "likes" | "comments" | "shares" | "saves" | "clicks" | "video_views" | "profile_views", optional): Normalized metric the listing is ordered by, descending. Publications that lack it are not dropped: they sort last. - social_network (query, array of string, optional): Filter the listing by network. Repeat the parameter for several. Does not affect `summary`. - accounts (query, array of string, optional): Filter the listing by account identifier. Repeat the parameter for several. Does not affect `summary`. - summary (query, boolean, optional): Set to `false` to skip the aggregates and return only the listing page. They depend on the range alone, so paging with `summary=false` avoids recomputing them. - limit (query, integer, optional): Publications per page - offset (query, integer, optional): Publications to skip responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `1000` | Invalid `from_date` or `metric` | | `1001` | Invalid `to_date` | | `1003` | Range longer than 366 days. Response data: { max_days } | | `523` | Invalid application | 500 Unhandled error by the server ### GET /organizations/{id_organization}/accounts/{id_account}/social_publications Read the account's timeline on the network What the account has actually published **on the social network**, whoever published it — including posts written outside PlanVortex. This is not the same question as `GET /organizations/{id_organization}/publish`, which lists what PlanVortex has scheduled or sent. Use this one to show the real feed; use that one to manage your own publications. **On X (Twitter) this costs credits.** Reading the timeline is billed at one credit per post read, and the budget is checked against `limit` before the call — the most it could cost — and charged afterwards for what actually came back. Ask for less than you need and you pay less. operationId: getSocialPublications callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - offset (query, integer, optional): Records to skip (pagination) - limit (query, integer, optional): Records per page. On X this is also the ceiling of what the call can cost. responses: 200 The timeline 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1305` | The client has no X credits left | | `1405` | The organization has no X credits left | | `1101` | Invalid organization | 500 Unhandled error by the server ### PUT /organizations/{id_organization}/accounts/{id_account}/publish/{id_publication} Update a publication (from its account) Update publication by identifier This is the same operation as `PUT /organizations/{id_organization}/publish/{id_publication}`, reachable from the account. Both accept the same body and do the same thing; use whichever path you already have the identifiers for. operationId: updatePublicationByAccount callers: current_user, current_app - id_organization (path, string, required): Organization identifier - id_account (path, string, required): Connected account identifier - id_publication (path, string, required): Publication identifier body (application/json): PublicationsPublicationInput — required - social_network ("facebook" | "instagram" | "twitter" | "linkedin" | "tiktok" | "whatsapp" | "youtube" | "bluesky" | "discord" | "telegram" | "threads" | "slack"): Network the publication targets. **Required when creating**: the request fails with error 702 if it is missing or not one of these values. It must match the network of the account in the path. Not every connectable network publishes — a local business listing receives reviews, not posts — so this list is shorter than the one in `GET /social_networks`. Ask `GET /allowed_social_publications` rather than hardcoding it, because it grows. - text (string): Body text of the publication. Either `text` or at least one entry in `files` is required: if both are empty the publication is still created, but in state `withErrors` with `publication_errors[].code = 915`. Maximum length depends on the network. On YouTube this is the video **description** (5,000 characters), and the publication must carry exactly one video file and no images — otherwise it is created in state `withErrors` with `publication_errors[].code = 943`. For X (Twitter), a text containing a link costs 200 credits instead of 15. **On Telegram the limit depends on what else the publication carries**: 4.096 characters while it is text only, and **1.024** the moment it has an image or a video, because then the text is the caption of a photo, a video or an album and no longer a message. Over the limit it is created in state `withErrors` with `publication_errors[].code = 967`, whose `data` carries `characters`, `max_characters` and `has_media`. Both numbers are published, as `characters.telegram` and `characters.telegram_media` in `GET /social_limits`. **On Slack the limit is 4.000 characters** and it is counted over the text you send, not over what travels: `&`, `<` and `>` are escaped before publishing, so a text made of ampersands grows on the wire and is still measured here. Over the limit the publication is created in state `withErrors` with `publication_errors[].code = 981`. And because the escaped text is what is measured on the wire, a text that passed at 4.000 characters and is full of `&` is **trimmed** before going out — Slack does not reject a long `text`, it truncates it or splits it into several messages, and one publication showing up as two posts is worse. The text goes out **plain**: Slack speaks *mrkdwn* and not Markdown, and PlanVortex sends no `blocks`, so `**bold**` is published literally. - title (string): Title for the publication. Only some networks use it: optional on LinkedIn, and **required on YouTube**, where it is the video title and must be 100 characters or fewer — a publication without it, or with a longer one, is created in state `withErrors` with `publication_errors[].code = 944`. - files (array of string): Identifiers of uploads previously created through the uploads endpoints, attached to this publication. **On Slack the files travel inside the message**, not as publications of their own: up to 10 attachments counting images and videos together (`publication_errors[].code = 982` over it), each one under the `max_file_size_mb.slack` ceiling (code 983), and anything the upload itself refuses comes back as code 986. - publish_date (string (date-time)): When the publication must go out. If omitted, it is published immediately. An invalid date returns error 938. - name (string): Internal name for the publication. Useful for grouping; never shown on the social network. - publication_type ("profile" | "page" | "group" | "reels" | "stories"): Defaults to `profile`. Not every network accepts every type, and an unsupported combination returns error 923. Allowed values are: facebook and instagram -> profile, reels, stories; twitter, linkedin, tiktok and youtube -> profile; whatsapp -> stories. YouTube has no separate type for Shorts: any vertical video of 3 minutes or less is classified as one automatically. - state ("ready" | "withErrors" | "sended" | "draft" | "publishing"): Send `draft` to store the publication without publishing it. If omitted, the state is resolved automatically: `ready` when everything validates, `withErrors` otherwise. Forcing `sended` marks it as published without actually sending it. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `917` | Publication doesn't exists | | `935` | Invalid publication | | `921` | Can't update a publication already sended | | `940` | X (Twitter) credits exhausted. Not enough monthly credits to publish this tweet (same link detection and 15/200 tariff as on create). Response data: { used, limit }. | | `523` | Invalid application | 500 Unhandled error by the server ## client_roles ### GET /clients/{id_client}/roles Get a list of roles Return a list of client roles inside the received client operationId: getClientRoles callers: current_user, current_app - id_client (path, string, required): Client identifier - offset (query, integer, optional): Number of records from which you will get roles (pagination) - limit (query, integer, optional): The limit of records will be retrieved (pagination) responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### POST /clients/{id_client}/roles Create a new client role Allow the creation of new client roles operationId: createClientRole callers: current_user, current_app - id_client (path, string, required): Client the role belongs to body (application/json): ClientsRolesRoleInput — required - name (string, required): Role name, shown when inviting a user. - permissions (array of "client:update" | "client:read" | "client:delete" | "client_app:create" | "client_app:update" | "client_app:read" | "client_app:delete" | "client_roles:create" | "client_roles:update" | "client_roles:read" | "client_roles:delete" | "client_roles_users:read" | "client_roles_users:create" | "client_roles_users:delete" | "client_organization:create" | "client_organization:update" | "client_organization:read" | "client_organization:delete" | "client_organization_roles:read" | "client_organization_roles:create" | "client_organization_roles:update" | "client_organization_roles:delete" | "client_organization_users_roles:read" | "client_organization_users_roles:create" | "client_organization_users_roles:delete" | "client_organization_accounts:read" | "client_organization_accounts:create" | "client_organization_accounts:update" | "client_organization_accounts:delete" | "client_organization_products:create" | "client_organization_products:update" | "client_organization_products:read" | "client_organization_products:delete" | "client_organization_files:read" | "client_organization_files:create" | "client_organization_files:update" | "client_organization_files:delete" | "client_organization_publications:read" | "client_organization_publications:create" | "client_organization_publications:update" | "client_organization_publications:delete" | "client_organization_publications_stats:read" | "client_organization_account_stats:read" | "client_organization_messages:create" | "client_organization_messages:read" | "client_organization_messages:delete" | "client_organization_contacts:create" | "client_organization_contacts:read" | "client_organization_contacts:update" | "client_organization_contacts:delete" | "client_organization_ai_plans:create" | "client_organization_ai_plans:read" | "client_organization_ai_plans:update" | "client_organization_ai_plans:delete", required): Permissions granted by this role. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### PUT /clients/{id_client}/roles/{id_rol} Update a client rol Update the client role received in parameters operationId: updateClientRole callers: current_user, current_app - id_client (path, string, required): Client the role belongs to - id_rol (path, string, required): Role identifier body (application/json): ClientsRolesRoleInput — required - name (string, required): Role name, shown when inviting a user. - permissions (array of "client:update" | "client:read" | "client:delete" | "client_app:create" | "client_app:update" | "client_app:read" | "client_app:delete" | "client_roles:create" | "client_roles:update" | "client_roles:read" | "client_roles:delete" | "client_roles_users:read" | "client_roles_users:create" | "client_roles_users:delete" | "client_organization:create" | "client_organization:update" | "client_organization:read" | "client_organization:delete" | "client_organization_roles:read" | "client_organization_roles:create" | "client_organization_roles:update" | "client_organization_roles:delete" | "client_organization_users_roles:read" | "client_organization_users_roles:create" | "client_organization_users_roles:delete" | "client_organization_accounts:read" | "client_organization_accounts:create" | "client_organization_accounts:update" | "client_organization_accounts:delete" | "client_organization_products:create" | "client_organization_products:update" | "client_organization_products:read" | "client_organization_products:delete" | "client_organization_files:read" | "client_organization_files:create" | "client_organization_files:update" | "client_organization_files:delete" | "client_organization_publications:read" | "client_organization_publications:create" | "client_organization_publications:update" | "client_organization_publications:delete" | "client_organization_publications_stats:read" | "client_organization_account_stats:read" | "client_organization_messages:create" | "client_organization_messages:read" | "client_organization_messages:delete" | "client_organization_contacts:create" | "client_organization_contacts:read" | "client_organization_contacts:update" | "client_organization_contacts:delete" | "client_organization_ai_plans:create" | "client_organization_ai_plans:read" | "client_organization_ai_plans:update" | "client_organization_ai_plans:delete", required): Permissions granted by this role. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1203` | Invalid client role | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### DELETE /clients/{id_client}/roles/{id_rol} Delete a client rol Delete the client role received in parameters operationId: deleteRole callers: current_user, current_app - id_rol (path, string, required): Rol identifier - id_client (path, string, required): Client identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1203` | Invalid client role | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### POST /clients/{id_client}/roles/{id_rol}/users/{id_user} Adds the received client role to the received user Adds the received client role to the received user. The user is assigned to he received role operationId: addUserRole callers: current_user, current_app - id_user (path, string, required): User identifier - id_rol (path, string, required): Rol identifier - id_client (path, string, required): Client identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1203` | Invalid client role | | `605` | User id invalid | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### DELETE /clients/{id_client}/roles/{id_rol}/users/{id_user} delete the received user from the received client role operationId: removeUserRole callers: current_user, current_app - id_user (path, string, required): User identifier - id_rol (path, string, required): Rol identifier - id_client (path, string, required): Client identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1203` | Invalid client role | | `605` | User id invalid | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### GET /clients/{id_client}/roles/{id_rol}/users Get all users of the client role Retrieve all users of the received client role operationId: getUsersInClientRole callers: current_user, current_app - id_rol (path, string, required): Rol identifier - id_client (path, string, required): Client identifier - offset (query, integer, optional): Number of records from which you will get users (pagination) - limit (query, integer, optional): The limit of records will be retrieved (pagination) responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1203` | Invalid client role | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### GET /clients/{id_client}/users Get all users of the client in any role Retrieve all users of the received client in any role operationId: getClientUsers callers: current_user, current_app - id_client (path, string, required): Client identifier - offset (query, integer, optional): Number of records from which you will get users (pagination) - limit (query, integer, optional): The limit of records will be retrieved (pagination) responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `521` | Invalid client. The identifier is invalid o doesn't exists | 500 Unhandled error by the server ### POST /clients/{id_client}/roles/{id_rol}/invite Invite an user to a client role Invite an user to a client role by the email. The user will recieve an invitation to the email operationId: inviteRole callers: current_user, current_app - id_client (path, string, required): Client the role belongs to - id_rol (path, string, required): Role the invited user will be assigned body (application/json) — required - email (string (email), required): Email address to invite. Returns error 609 if it is not a valid address. If no user exists with that address, a disabled one is created and an invitation email is sent with a temporary token; the account is enabled when the invitation is accepted. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1203` | Invalid client role | | `609` | Invalid email | | `521` | Invalid client. The identifier is invalid o doesn't exists | | `508` | Error sending the invitation | | `509` | User already invited to the rol | 500 Unhandled error by the server ### GET /clients_permissions Retrieve the list of client permissions Retrieve all client permissions that are allowed in client role creation operationId: getClientPermissions callers: current_user, current_app, temporal_token responses: 200 Successful operation 400 Error. Classify by `code` in the body, never by the HTTP status: every domain error travels with HTTP 400. 500 Unhandled error by the server ## uploads ### GET /organizations/{id_organization}/uploads Get all uploads (paginated) from received organization Return a list of files from the received organization operationId: getUploads callers: current_user, current_app - id_organization (path, string, required): Organization identifier - offset (query, integer, optional): Number of records from which you will get files (pagination) - limit (query, integer, optional): The limit of records will be retrieved (pagination) responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `523` | Invalid application | 500 Unhandled error by the server ### POST /organizations/{id_organization}/uploads Upload new file Upload new file to the received organization operationId: addUpload callers: current_user, current_app - id_organization (path, string, required): Organization identifier body (multipart/form-data) — required - file (string (binary), required): file uploaded as multipart/formdata responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `804` | Max folder size reached per organization | | `800` | Invalid image format file, only allow: JPEG, PNG, BMP, GIF, JPG | | `801` | Invalid video format, only mp4 allowed | | `802` | Invalid image size, max allowed 5mb | | `803` | Invalid video size, max allowed 512mb | | `805` | Invalid file type, only allow videos and images | | `809` | Only videos con have a thumbnail image | | `810` | Thumbnail must be an image | | `523` | Invalid application | 500 Unhandled error by the server ### POST /organizations/{id_organization}/uploads/import Import files into the library from a connected integration Brings files the user picked in the provider (Google Drive's own Picker) into the organization's library. See the Integrations schema for how to connect one and get the picker token. The checks run in this order, and the order matters: format first (before touching the network — downloading 180 MB only to reject the format would be absurd), then the organization's storage quota, then a streamed download that counts the bytes as they arrive and aborts on the limit (the size the provider declares is informative and is never trusted), then HEIC→JPEG conversion where needed. **The response is deliberately partial**: `uploads` carries what got in and `errors` what did not, so picking six files and getting five plus one named failure is the normal, expected outcome — not an error. Requires the `files:create` permission (`client_organization_files:create` for apps). operationId: importUploads callers: current_user, current_app - id_organization (path, string, required): Organization identifier body (application/json) — required - id_integration (string, required): A connected integration of this organization whose provider has file_import: true. - files (array of object, required) responses: 200 Import finished. Partial by design: check `errors` even on 200. 400 `2200` the integration does not exist or belongs to another organization; `2201` its provider does not contribute files; `807` the body carries no files. ### GET /organizations/{id_organization}/uploads/{id_upload} Retrieve a file by identifier operationId: getUpload callers: current_user, current_app - id_upload (path, string, required): Upload identifier - id_organization (path, string, required): Organization identifier responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `806` | Invalid upload identifier | | `523` | Invalid application | 500 Unhandled error by the server ### PUT /organizations/{id_organization}/uploads/{id_upload} Update file by identifier operationId: updateUpload callers: current_user, current_app - id_organization (path, string, required): Organization the upload belongs to - id_upload (path, string, required): Upload identifier body (application/json) — required - cover_image (string): Identifier of ANOTHER upload, which must be an image, to use as the cover. Returns error 810 if it does not exist or is not an image. Replacing an existing cover deletes the previous file. - cover_offset (integer): Point of the video, in milliseconds, used as the cover frame. Used by TikTok videos. WARNING: this property is written unconditionally, so omitting it clears the stored value - always send it back if you want to keep it. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `806` | Invalid upload identifier | | `809` | Only videos con have a thumbnail image | | `810` | Thumbnail must be an image | | `523` | Invalid application | 500 Unhandled error by the server ### DELETE /organizations/{id_organization}/uploads/{id_upload} Delete file by identifier operationId: deleteUpload callers: current_user, current_app - id_upload (path, string, required): Upload identifier - id_organization (path, string, required): Organization identifier - forceDelete (query, boolean, optional): Delete the file even if a publication still points at it. Without it, a file in use is kept and only unlinked from the library. responses: 200 Successful operation 400 The request failed. The body carries the PlanVortex error code in `code`: | Code | Meaning | | --- | --- | | `1101` | Invalid organization | | `806` | Invalid upload identifier | | `523` | Invalid application | 500 Unhandled error by the server ## webhooks Requests PlanVortex sends to the URL your app registers, not routes you call. ### POST webhook:comments A change happened (notification to your app) PlanVortex `POST`s to the `webhook_url` of your client app when something happens. **The body is an array of changes**, and each one carries `field` telling you what it is — switch on it and ignore what you do not handle, because the list grows with the product. Two shapes travel in that array. A change on an **account** (`WebhookChange`) always carries `id_account`, `id_organization` and `social_network`. A change on an **integration** (`IntegrationWebhookChange`) carries none of those, because an integration hangs off the organization and not off any account. The events delivered today are `new_account`, `change_state_account`, `messages`, `messaging_postbacks`, `messaging_seen`, `messaging_error`, `comments` and `integration_error`. ### Comments `field: "comments"` is the one for this section, and the comment itself travels in **`commentObj`** — a property of its own, never in `messageObj`. That is deliberate: a comment is not a message, it has no contact and it hangs off a publication. A consumer that only understands messages sees `commentObj` as absent and ignores the change, which is exactly what should happen. **Which networks arrive this way:** Facebook and Instagram, because Meta pushes them. YouTube, LinkedIn and Google Business have no comment webhook, so those are polled by a background job and appear in the inbox within a few hours rather than instantly. X is polled too, and only when the organization turns it on, because reading replies there is billed per unit. **Meta repeats deliveries.** The same comment can arrive more than once; the backend is idempotent about it and so should you be — deduplicate on `commentObj.external_id`. ### Verifying the signature Every delivery carries the body signed with your app's secret, twice: - `x-hub-signature`: `sha1=` - `x-hub-signature-256`: `sha256=` Compute the HMAC over the **raw** request body — not over a re-serialized copy of the parsed JSON — and compare in constant time. Prefer the sha256 header. Delivery is best effort: PlanVortex does not retry a webhook that fails. operationId: onCommentsWebhook body (application/json) — required responses: 200 Your endpoint acknowledged the delivery. PlanVortex does not retry: a delivery that fails is logged and dropped, so treat the inbox endpoints as the way to catch up on anything you missed.