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. |
| `2600` | The network has comments but **this account** has none: a LinkedIn personal profile. LinkedIn does not let any app read a member's comments, so only pages have an inbox. Carries `social_network` and `id_account` in `data`. |
A social account connected to an organization.
On `discord`, on `telegram` and on `slack` an account is a **channel**, not a profile: publishing to two Discord channels of the same server — or to two Telegram channels of the same brand, or to `#anuncios` and `#general` of the same Slack workspace — costs two accounts of the plan.
On `linkedin` one authorization brings two kinds of account: the **personal profile** of whoever authorizes and each **page** they manage. Both publish and both have statistics, but only pages have a comment inbox: LinkedIn does not let any app read the comments on a member's posts, so on a profile every comment endpoint returns error `2600`. `extra_data.is_personal_profile` tells them apart.
`error_code` other than `0` means the connection is broken — an expired token, a permission taken away — and the account has to be connected again. On `telegram` nothing expires, because there is no account token: what breaks the connection is the bot being removed from the channel or losing its permission to post there (error 968). On `slack` the bot token does not expire either: what breaks it is the app being removed from the channel (error 980) or the channel being archived or deleted (error 985).
Format: date-time
Optionalextra_data?: { is_personal_profile?: boolean } & { [key: string]: unknown }Optionalfollowers_count?: numberFollowers the network reports. Absent on an account that has never been measured. On telegram and on slack it is the channel's member count, and it is the only audience figure either network publishes: there are no views, no impressions and no reach anywhere in the Bot API nor in the Slack Web API.
Optionalimage?: stringOptionalnext_comments_update?: stringFormat: date-time
Optionalnext_stats_update?: stringFormat: date-time
Optionalprivate_message_link?: stringOptionalusername?: stringThe handle, when the network has one. Absent on the networks that do not (a Discord channel, a WhatsApp number, a Google Business listing) and on a private Telegram channel, which has no @name at all — only public ones do. That is also why a private channel's publications come back with no url.
Format: date-time
Optionalextra_data?: { is_personal_profile?: boolean } & { [key: string]: unknown }Optionalfollowers_count?: numberFollowers the network reports. Absent on an account that has never been measured. On telegram and on slack it is the channel's member count, and it is the only audience figure either network publishes: there are no views, no impressions and no reach anywhere in the Bot API nor in the Slack Web API.
Optionalimage?: stringOptionalnext_comments_update?: stringFormat: date-time
Optionalnext_stats_update?: stringFormat: date-time
Optionalprivate_message_link?: stringOptionalusername?: stringThe handle, when the network has one. Absent on the networks that do not (a Discord channel, a WhatsApp number, a Google Business listing) and on a private Telegram channel, which has no @name at all — only public ones do. That is also why a private channel's publications come back with no url.
Optionalcreation_date?: stringFormat: date-time
Format: date-time
Optionalid_client?: stringOptionalid_organization?: stringOptionalkeycloak_client_idenfifier?: stringOptionalredirect_uri?: stringHow an account of this network is authorized, which is not always "send the user to this URL".
Almost every network is `redirect`: open `link` and the network sends the person back to PlanVortex with a code. Two are not:
• **WhatsApp.** Its sign-up is Meta's *Embedded Signup*: a popup raised by the Facebook JavaScript SDK from your own page, which returns — over `postMessage` — session data (`waba_id`, `phone_number_id`) that no query string carries. Its `link` is therefore an empty string.
• **Telegram.** There is no OAuth here: no consent screen, no `code`, no account token. `link` opens a private chat with the PlanVortex bot, the person then adds that bot to their channel, and **the account is created from that event**, not from any request of yours. Which means the connection cannot be finished by calling `GET /organizations/{id_organization}/account-connect/telegram` — see that endpoint.
Branch on `authorization.type`, never on whether `link` is empty.
Optionaladd_to_group_link?: stringtelegram_bot only. The second step, and it does not follow from the first: link opens the list of channels and this one opens the list of groups. It adds the bot to the channel's linked discussion group, which is what turns comments on — a Telegram channel with no discussion group has no comment inbox at all (error 965). Optional for the user: publishing and statistics work without it.
Optionalapp_id?: stringOptionalbot_username?: stringOptionalconfig_id?: stringOptionalfeature_type?: stringOptionalgraph_version?: stringOptionalsession_info_version?: stringOptionalstate?: stringOptionalaudience?: stringOptionalavoid?: stringOptionalblog_url?: stringOptionalbrand_name?: stringOptionaldefault_tone?: stringOptionaldescription?: stringOptionalkeywords?: string[]Optionalnotes?: stringOptionalproducts?: stringOptionalsector?: stringOptionalshop_url?: stringOptionalsocial_urls?: string[]Optionalvalue_proposition?: stringOptionalwebsite?: stringWhere an AI-generated image came from. Only images a model generated carry it: a photo the organization uploaded, a product photo pulled from a connected shop or an image imported from an integration never does.
It is set when the image is generated and cannot be edited. Use it to show your own AI label: under article 50(4) of the EU AI Act, telling the audience that an image is AI-generated is the job of whoever publishes it.
Images made with the default model also carry a signed C2PA manifest, IPTC metadata and a SynthID watermark in the file itself. PlanVortex keeps the IPTC marker when it crops or recompresses an image for a network; the C2PA signature does not survive a change of pixels.
Format: date-time
Optionalid_ai_plan?: stringWhat ONE AI plan achieved with what it published.
**Where the numbers come from.** A plan keeps its publications, and each publication keeps the last known value of its metrics (the same ones `GET /organizations/{id}/publications/stats` returns). This adds those up — nothing here is measured twice or asked to the network again.
**Only the publications that went out and were measured count** (`publications.measured`). A post scheduled for tomorrow, one that failed or one published an hour ago that nobody has measured yet does not lower the average: it is simply not in it.
**A missing metric is not a zero**, as everywhere else: if no publication of the plan reports `reach`, the plan has no `reach` key.
Optionalarchived_date?: stringFormat: date-time
Format: date-time
Optionalcredits_per_engagement?: numberAI credits per interaction: what each interaction cost. Lower is better. Absent with no interactions, with no credits spent (your own AI key) and whenever social_network is filtered — the cost belongs to the whole plan, and dividing it by one network's interactions would overprice every one of them.
Optionalengagement_per_publication?: numberInteractions per measured publication — the number plans are ranked by. The total rewards size (seven accounts for seven days beat one account even if each post does half as well), and the engagement rate divides by reach on some networks and by followers on others, which makes two plans on different networks incomparable. Absent when nothing is measured yet.
Optionalengagement_vs_average?: numberengagement_per_publication divided by expected_engagement_per_publication: 1 means like your average, 2 twice as much, 0.5 half. This is the number that says whether a plan is good, not just first — the ranking still orders by engagement_per_publication, so the first plan can be below 1. Absent with nothing measured or no average to compare with.
Optionalexpected_engagement_per_publication?: numberWhat your usual posts would get with the same mix of networks: the organization's average interactions per measured post on each network, weighted by how many measured posts the plan has there. Two measured posts on Instagram (average 5) and two on LinkedIn (average 1) expect (2·5 + 2·1) / 4 = 3.
The average covers **every** measured post of the organization in the range, AI ones included, on the same networks. Weighting by network is what keeps a LinkedIn-only plan from always reading as below average just because LinkedIn moves less than Instagram.
Optionallast_publish_date?: stringFormat: date-time
Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberFormat: date-time
Optionalcredits_per_engagement?: numberOptionalengagement_per_publication?: numberOptionalengagement_vs_average?: numberOptionalexpected_engagement_per_publication?: numberOptionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalarchived_date?: stringFormat: date-time
When the plan was archived. Absent means it is active, which is how every plan created before archiving existed comes back.
It is a field of its own and NOT a value of `state` on purpose: `state` is the generation lifecycle and archiving is orthogonal to it — a `validated` plan that already published gets archived just like a `failed` one. Inside the enum you would have to decide which state it returns to when unarchived, and that question has no answer.
Archiving is visibility only: it takes the plan out of the default listing and touches no publication. Anything it had scheduled keeps publishing.
Format: date-time
Optionaldestinations?: {Optionalerror?: { code: number; data?: { [key: string]: unknown }; message: string }Optionalgeneration_end_date?: stringFormat: date-time
Optionalkeycloak_identifier?: stringOptionallink?: stringOptionalmax_images?: numberDays of the week the plan publishes on, in ISO 8601 numbering (1 = Monday ... 7 = Sunday). Defaults to the whole week. There is still at most ONE publication per day and account, so this is what bounds the size and the cost of the plan: the number of generated posts is (selected days x accounts). Must be a non-empty array of unique integers between 1 and 7, or the request is rejected with 2106. The 7-day window starts at week_start, so each ISO day appears exactly once: with a week_start in mid-week, day 1 (Monday) is the FOLLOWING Monday. If the selected days leave no future slot at all, the request is rejected with 2108.
Optionaltone?: stringFormat: date-time
Optionalorchestrator_result?: Record<string, never>Optionalorganization_context?: {Optionalaudience?: stringOptionalavoid?: stringOptionalblog_url?: stringOptionalbrand_name?: stringOptionaldefault_tone?: stringOptionaldescription?: stringOptionalkeywords?: string[]Optionalnotes?: stringOptionalproducts?: stringOptionalsector?: stringOptionalshop_url?: stringOptionalsocial_urls?: string[]Optionalvalue_proposition?: stringOptionalwebsite?: stringOptionalsource?: {Optionalevent?: { date?: string; name?: string }Optionaldate?: stringFormat: date-time
Optionalname?: stringOptionalid_account_catalog?: stringOptionalid_integration_catalog?: stringOptionalimages?: { description?: string; id_upload?: string }[]Optionalproducts?: {Optionaltext?: stringOptionalurl?: stringOptionaltemplate?: "standard" | "from_images" | "from_text" | "from_catalog" | "campaign"Optionalwarnings?: { code: number; data?: { [key: string]: unknown }; message: string }[]Non-blocking notices about the LAST attempt (they are cleared when a new one starts). The plan is generated and perfectly usable; your UI just has to say what happened.
Today there are two.
**2117 — some source items did not fit in the plan week.** A plan is weekly and the source does not extend it, so 12 photos with 6 slots left publish 6 and the rest are dropped. `data` carries `{ source_items, capacity }`. Better said BEFORE creating the plan (the slots are the publish days x the accounts) than after charging for it.
**922 — a publication of a network that cannot publish without an image was left without one.** On Pinterest a pin is an image or a video, never text. The plan cannot be created with a configuration that would cause this (2119), but an image generation that fails halfway through the plan cannot be foreseen. `data` carries `{ step, social_network, id_account, id_publication }`: that draft will not publish until it has an image — regenerate it or attach one.
Mandatory cost (orchestration + target texts). The orchestration half depends on the TEMPLATE and on the size of its source: orchestration_cost + orchestration_cost_per_source_item x units, both published in GET /planner_templates. The plan is rejected if this exceeds the available credits.
How many images the plan will generate. 0 when the template does not generate them (from_images, from_catalog): the pictures come from the source, 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. That is worth saying out loud before the plan is created.
Optionaldestinations?: {Where each account publishes, for the networks that answer destinations: true in GET /social_capabilities — today pinterest alone, where it is the board of every pin of that account.
**On Pinterest it is required, one entry per Pinterest account of the plan.** A plan without it — or with a board id that is not a string of digits — is rejected with **2118**, listing EVERY account that fails in `data.accounts[]` with the same `reason` as the publication's 987 (`missing`, `invalid_id`, `invalid_section_id`), so your UI can mark them all at once. Without this check the plan would be created, charged, and leave a week of drafts in `withErrors` with the 987.
Entries for accounts on networks without destinations are dropped, not rejected. The board is **not** checked against Pinterest at creation — that would make creating a plan depend on Pinterest answering; a board that does not exist fails when the pin is published. Read the account's boards with `GET /organizations/{id_organization}/accounts/{id_account}/destinations`.
Optionaloptions?: {Optionalallow_images?: booleanOptionalgallery_uploads?: string[]Upload ids from the organization's gallery used as visual reference for the generated images. They are references for the images the model GENERATES, so a template that does not generate them does not accept them either (allows_gallery): sending them to from_images or from_catalog is a 2106.
Optionallanguage?: stringOptionallink?: stringThe destination link of the plan's publications — the pin's link, the same for every publication of the plan. Optional: a pin without a link is legitimate, it just takes nobody anywhere. On from_catalog, each pin leads to its product's page (permalink) when this is absent; when it is present, it wins.
It only reaches the publications of the networks that answer `link: true` in `GET /social_capabilities` (today `pinterest`); with none of them in the plan it is dropped. With one, it is validated **at creation**: a URL Pinterest would reject is a **994** now (`data.reason` `invalid_url` or `too_long`), not a week of pins failing at publish time.
Optionalmax_images?: numberOptionalpublish_days?: number[]Days of the week the plan publishes on, in ISO 8601 numbering (1 = Monday ... 7 = Sunday). Defaults to the whole week. There is still at most ONE publication per day and account, so this is what bounds the size and the cost of the plan: the number of generated posts is (selected days x accounts). Must be a non-empty array of unique integers between 1 and 7, or the request is rejected with 2106. The 7-day window starts at week_start, so each ISO day appears exactly once: with a week_start in mid-week, day 1 (Monday) is the FOLLOWING Monday. If the selected days leave no future slot at all, the request is rejected with 2108. Optional; defaults to [1,2,3,4,5,6,7].
Optionalshared?: booleanGenerate ONE piece of content per day and replicate it across every account, each scheduled at the best hour for ITS network, instead of one publication per account and day. Cheaper — one text and one image per day — and it caps images at 7. Optional; defaults to false. Not every template accepts it (allows_shared in GET /planner_templates): from_images and from_catalog do not, because each of their publications carries a photo of its own, and sending true to them is rejected with 2106.
Optionaltimezone?: stringOptionaltone?: stringOptionaluse_organization_context?: booleanOptionalweek_start?: stringFormat: date-time
Optionalsource?: {Optionalevent_date?: stringFormat: date
campaign. The day of the event. Send a calendar day, YYYY-MM-DD — never an ISO instant. A bare date is read in the plan's options.timezone, which is the whole point: 2026-09-15T00:00:00Z is midnight UTC, that is the 14th in the afternoon in New York — a whole day off in a countdown, for half of America, with no error anywhere. It cannot fall before the plan week (options.week_start) nor more than 60 days after it (2112). And it does not move publish_days: if the event lands on a day you did not choose, the plan respects your choice and it is up to you to say so.
Optionalevent_name?: stringOptionalid_account_catalog?: stringfrom_catalog, from a Meta catalogue. Which account's catalogue the products come from. It has to belong to the organization (2103) and be on a network that supports products (2115). It only chooses the catalogue: the publications still go to every account in accounts — a LinkedIn account can publish a product from a Facebook catalogue. Exclusive with id_integration_catalog (both at once is a 2112).
Optionalid_integration_catalog?: stringfrom_catalog, from a connected store. An integration of the organization whose provider has catalog: true (a WooCommerce store), enabled; another organization's is a 2200, without confirming it exists. The products are its external_ids, from GET .../integrations/{id_integration}/products. Like the account, it only chooses the catalogue: the publications go to every account in accounts. Exclusive with id_account_catalog.
Optionalimages?: { description: string; id_upload: string }[]from_images. Your own photos, each with its own description, in the order that tells the story: the orchestrator picks one per publication and keeps its position in source_index, so photo 3 can be the "before" and photo 7 the "after". Up to 20 (max_source_items), and each one has to be an image upload of this organization (806 otherwise).
Optionalproduct_catalog_id?: stringOptionalproducts?: string[]from_catalog. Ids of the chosen products, in the order they should tell the week. Up to 12 — a unit here is not an id already in the database: it is a live read plus a real download inside this request, with the user waiting. Repeated ids are deduplicated. They are ALL checked against the catalogue before a single picture is downloaded (2112 naming the missing one, or the one that is out of stock), and each picture is copied into an upload of the organization: a catalogue's picture URL expires or changes, and a plan published weeks later would carry a broken file. Those uploads count against the storage quota.
Optionaltext?: stringfrom_text. The article pasted by hand. It wins over url when both come: pasting is what a user does when the download did not work (a paywall, a page that needs JavaScript), so re-downloading to ignore what they wrote would take away their only way out. Truncated to 12.000 characters — the cap is what keeps the estimate honest, since the real charge is per use. Under 200 characters it is not an article, it is the theme, which is prompt and another field (2116).
Optionalurl?: stringFormat: uri
from_text. The article the plan is written from. It is downloaded at creation, and only http/https addresses that resolve to a public IP are accepted — checked again before every redirect (2114). A page that answers something that is not text or HTML, or that carries no usable text once stripped, is a 2113: paste the text in text instead.
Optionaltemplate?: "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.
Optionalarchived_date?: stringFormat: date-time
When the plan was archived. Absent means it is active, which is how every plan created before archiving existed comes back.
It is a field of its own and NOT a value of `state` on purpose: `state` is the generation lifecycle and archiving is orthogonal to it — a `validated` plan that already published gets archived just like a `failed` one. Inside the enum you would have to decide which state it returns to when unarchived, and that question has no answer.
Archiving is visibility only: it takes the plan out of the default listing and touches no publication. Anything it had scheduled keeps publishing.
Format: date-time
Optionaldestinations?: {Optionalerror?: { code: number; data?: { [key: string]: unknown }; message: string }Optionalgeneration_end_date?: stringFormat: date-time
Optionalkeycloak_identifier?: stringOptionallink?: stringOptionalmax_images?: numberDays of the week the plan publishes on, in ISO 8601 numbering (1 = Monday ... 7 = Sunday). Defaults to the whole week. There is still at most ONE publication per day and account, so this is what bounds the size and the cost of the plan: the number of generated posts is (selected days x accounts). Must be a non-empty array of unique integers between 1 and 7, or the request is rejected with 2106. The 7-day window starts at week_start, so each ISO day appears exactly once: with a week_start in mid-week, day 1 (Monday) is the FOLLOWING Monday. If the selected days leave no future slot at all, the request is rejected with 2108.
Optionaltone?: stringFormat: date-time
Optionalorchestrator_result?: Record<string, never>Optionalorganization_context?: {Optionalaudience?: stringOptionalavoid?: stringOptionalblog_url?: stringOptionalbrand_name?: stringOptionaldefault_tone?: stringOptionaldescription?: stringOptionalkeywords?: string[]Optionalnotes?: stringOptionalproducts?: stringOptionalsector?: stringOptionalshop_url?: stringOptionalsocial_urls?: string[]Optionalvalue_proposition?: stringOptionalwebsite?: stringOptionalsource?: {Optionalevent?: { date?: string; name?: string }Optionaldate?: stringFormat: date-time
Optionalname?: stringOptionalid_account_catalog?: stringOptionalid_integration_catalog?: stringOptionalimages?: { description?: string; id_upload?: string }[]Optionalproducts?: {Optionaltext?: stringOptionalurl?: stringOptionaltemplate?: "standard" | "from_images" | "from_text" | "from_catalog" | "campaign"Optionalwarnings?: { code: number; data?: { [key: string]: unknown }; message: string }[]Non-blocking notices about the LAST attempt (they are cleared when a new one starts). The plan is generated and perfectly usable; your UI just has to say what happened.
Today there are two.
**2117 — some source items did not fit in the plan week.** A plan is weekly and the source does not extend it, so 12 photos with 6 slots left publish 6 and the rest are dropped. `data` carries `{ source_items, capacity }`. Better said BEFORE creating the plan (the slots are the publish days x the accounts) than after charging for it.
**922 — a publication of a network that cannot publish without an image was left without one.** On Pinterest a pin is an image or a video, never text. The plan cannot be created with a configuration that would cause this (2119), but an image generation that fails halfway through the plan cannot be foreseen. `data` carries `{ step, social_network, id_account, id_publication }`: that draft will not publish until it has an image — regenerate it or attach one.
Mandatory cost (orchestration + target texts). The orchestration half depends on the TEMPLATE and on the size of its source: orchestration_cost + orchestration_cost_per_source_item x units, both published in GET /planner_templates. The plan is rejected if this exceeds the available credits.
How many images the plan will generate. 0 when the template does not generate them (from_images, from_catalog): the pictures come from the source, 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. That is worth saying out loud before the plan is created.
The destination of ONE account of the plan: the Pinterest board where all of that account's publications of the plan go.
One per ACCOUNT, not one per plan, because a plan can carry three Pinterest profiles and each one has its own boards. And it travels with the account, not with the content: in a `shared` plan the same content replicated to three profiles lands on three boards with nothing else to touch.
There is no default destination stored on the account: it lives in the plan alone.
Optionalid?: stringThe board id. Always a string, never a number: Pinterest's ids are long integers, so a client that sends one as a JSON number has already lost digits to rounding before the request arrives. That is refused — publication_errors[].code = 987 with data.reason = "invalid_id" — rather than published to some other board. Sending the board's name instead of its id fails the same way, which is the likeliest first mistake of an integration.
Optionalname?: stringOptionalsection_id?: stringOptionalsection_name?: stringOptionalarchived_date?: stringFormat: date-time
When the plan was archived. Absent means it is active, which is how every plan created before archiving existed comes back.
It is a field of its own and NOT a value of `state` on purpose: `state` is the generation lifecycle and archiving is orthogonal to it — a `validated` plan that already published gets archived just like a `failed` one. Inside the enum you would have to decide which state it returns to when unarchived, and that question has no answer.
Archiving is visibility only: it takes the plan out of the default listing and touches no publication. Anything it had scheduled keeps publishing.
Format: date-time
Optionaldestinations?: {Optionalerror?: { code: number; data?: { [key: string]: unknown }; message: string }Optionalgeneration_end_date?: stringFormat: date-time
Optionalkeycloak_identifier?: stringOptionallink?: stringOptionalmax_images?: numberDays of the week the plan publishes on, in ISO 8601 numbering (1 = Monday ... 7 = Sunday). Defaults to the whole week. There is still at most ONE publication per day and account, so this is what bounds the size and the cost of the plan: the number of generated posts is (selected days x accounts). Must be a non-empty array of unique integers between 1 and 7, or the request is rejected with 2106. The 7-day window starts at week_start, so each ISO day appears exactly once: with a week_start in mid-week, day 1 (Monday) is the FOLLOWING Monday. If the selected days leave no future slot at all, the request is rejected with 2108.
Optionaltone?: stringFormat: date-time
Optionalorchestrator_result?: Record<string, never>Optionalorganization_context?: {Optionalaudience?: stringOptionalavoid?: stringOptionalblog_url?: stringOptionalbrand_name?: stringOptionaldefault_tone?: stringOptionaldescription?: stringOptionalkeywords?: string[]Optionalnotes?: stringOptionalproducts?: stringOptionalsector?: stringOptionalshop_url?: stringOptionalsocial_urls?: string[]Optionalvalue_proposition?: stringOptionalwebsite?: stringOptionalsource?: {Optionalevent?: { date?: string; name?: string }Optionaldate?: stringFormat: date-time
Optionalname?: stringOptionalid_account_catalog?: stringOptionalid_integration_catalog?: stringOptionalimages?: { description?: string; id_upload?: string }[]Optionalproducts?: {Optionaltext?: stringOptionalurl?: stringOptionaltemplate?: "standard" | "from_images" | "from_text" | "from_catalog" | "campaign"Optionalwarnings?: { code: number; data?: { [key: string]: unknown }; message: string }[]Non-blocking notices about the LAST attempt (they are cleared when a new one starts). The plan is generated and perfectly usable; your UI just has to say what happened.
Today there are two.
**2117 — some source items did not fit in the plan week.** A plan is weekly and the source does not extend it, so 12 photos with 6 slots left publish 6 and the rest are dropped. `data` carries `{ source_items, capacity }`. Better said BEFORE creating the plan (the slots are the publish days x the accounts) than after charging for it.
**922 — a publication of a network that cannot publish without an image was left without one.** On Pinterest a pin is an image or a video, never text. The plan cannot be created with a configuration that would cause this (2119), but an image generation that fails halfway through the plan cannot be foreseen. `data` carries `{ step, social_network, id_account, id_publication }`: that draft will not publish until it has an image — regenerate it or attach one.
Optionallink?: stringOptionalmax_images?: numberDays of the week the plan publishes on, in ISO 8601 numbering (1 = Monday ... 7 = Sunday). Defaults to the whole week. There is still at most ONE publication per day and account, so this is what bounds the size and the cost of the plan: the number of generated posts is (selected days x accounts). Must be a non-empty array of unique integers between 1 and 7, or the request is rejected with 2106. The 7-day window starts at week_start, so each ISO day appears exactly once: with a week_start in mid-week, day 1 (Monday) is the FOLLOWING Monday. If the selected days leave no future slot at all, the request is rejected with 2108.
Optionaltone?: stringFormat: date-time
Optionalallow_images?: booleanOptionalgallery_uploads?: string[]Upload ids from the organization's gallery used as visual reference for the generated images. They are references for the images the model GENERATES, so a template that does not generate them does not accept them either (allows_gallery): sending them to from_images or from_catalog is a 2106.
Optionallanguage?: stringOptionallink?: stringThe destination link of the plan's publications — the pin's link, the same for every publication of the plan. Optional: a pin without a link is legitimate, it just takes nobody anywhere. On from_catalog, each pin leads to its product's page (permalink) when this is absent; when it is present, it wins.
It only reaches the publications of the networks that answer `link: true` in `GET /social_capabilities` (today `pinterest`); with none of them in the plan it is dropped. With one, it is validated **at creation**: a URL Pinterest would reject is a **994** now (`data.reason` `invalid_url` or `too_long`), not a week of pins failing at publish time.
Optionalmax_images?: numberOptionalpublish_days?: number[]Days of the week the plan publishes on, in ISO 8601 numbering (1 = Monday ... 7 = Sunday). Defaults to the whole week. There is still at most ONE publication per day and account, so this is what bounds the size and the cost of the plan: the number of generated posts is (selected days x accounts). Must be a non-empty array of unique integers between 1 and 7, or the request is rejected with 2106. The 7-day window starts at week_start, so each ISO day appears exactly once: with a week_start in mid-week, day 1 (Monday) is the FOLLOWING Monday. If the selected days leave no future slot at all, the request is rejected with 2108. Optional; defaults to [1,2,3,4,5,6,7].
Optionalshared?: booleanGenerate ONE piece of content per day and replicate it across every account, each scheduled at the best hour for ITS network, instead of one publication per account and day. Cheaper — one text and one image per day — and it caps images at 7. Optional; defaults to false. Not every template accepts it (allows_shared in GET /planner_templates): from_images and from_catalog do not, because each of their publications carries a photo of its own, and sending true to them is rejected with 2106.
Optionaltimezone?: stringOptionaltone?: stringOptionaluse_organization_context?: booleanOptionalweek_start?: stringFormat: date-time
Format: date-time
Optionalprevious_from_date?: stringFormat: date-time
Optionalprevious_to_date?: stringFormat: date-time
Format: date-time
Optionalcredits_per_engagement?: numberOptionalengagement_per_publication?: numberOptionalengagement_vs_average?: numberOptionalexpected_engagement_per_publication?: numberOptionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalevent?: { date?: string; name?: string }Optionaldate?: stringFormat: date-time
Optionalname?: stringOptionalid_account_catalog?: stringOptionalid_integration_catalog?: stringOptionalimages?: { description?: string; id_upload?: string }[]Optionalproducts?: {Optionaltext?: stringOptionalurl?: stringThe plan's source, as you SEND it. One shape per template: send only the fields of the template you chose — what it does not read is ignored, and what it needs and does not get is a 2112.
It is validated when the plan is **created**, not when it is generated: the article is downloaded, the catalogue or the store is read live and the product pictures are copied. So a source that does not work fails while the user is still there and can fix it, and what gets stored is a SNAPSHOT — a retry three days later does not depend on the article still being online or the product still being in the catalogue.
| Template | Fields |
| --- | --- |
| `standard` | none — no source |
| `from_images` | `images` |
| `from_text` | `url` **or** `text` |
| `from_catalog` | `id_account_catalog` + `product_catalog_id` (a Meta catalogue) **or** `id_integration_catalog` (a connected store), and `products` |
| `campaign` | `event_name`, `event_date` |
Optionalevent_date?: stringFormat: date
campaign. The day of the event. Send a calendar day, YYYY-MM-DD — never an ISO instant. A bare date is read in the plan's options.timezone, which is the whole point: 2026-09-15T00:00:00Z is midnight UTC, that is the 14th in the afternoon in New York — a whole day off in a countdown, for half of America, with no error anywhere. It cannot fall before the plan week (options.week_start) nor more than 60 days after it (2112). And it does not move publish_days: if the event lands on a day you did not choose, the plan respects your choice and it is up to you to say so.
Optionalevent_name?: stringOptionalid_account_catalog?: stringfrom_catalog, from a Meta catalogue. Which account's catalogue the products come from. It has to belong to the organization (2103) and be on a network that supports products (2115). It only chooses the catalogue: the publications still go to every account in accounts — a LinkedIn account can publish a product from a Facebook catalogue. Exclusive with id_integration_catalog (both at once is a 2112).
Optionalid_integration_catalog?: stringfrom_catalog, from a connected store. An integration of the organization whose provider has catalog: true (a WooCommerce store), enabled; another organization's is a 2200, without confirming it exists. The products are its external_ids, from GET .../integrations/{id_integration}/products. Like the account, it only chooses the catalogue: the publications go to every account in accounts. Exclusive with id_account_catalog.
Optionalimages?: { description: string; id_upload: string }[]from_images. Your own photos, each with its own description, in the order that tells the story: the orchestrator picks one per publication and keeps its position in source_index, so photo 3 can be the "before" and photo 7 the "after". Up to 20 (max_source_items), and each one has to be an image upload of this organization (806 otherwise).
Optionalproduct_catalog_id?: stringOptionalproducts?: string[]from_catalog. Ids of the chosen products, in the order they should tell the week. Up to 12 — a unit here is not an id already in the database: it is a live read plus a real download inside this request, with the user waiting. Repeated ids are deduplicated. They are ALL checked against the catalogue before a single picture is downloaded (2112 naming the missing one, or the one that is out of stock), and each picture is copied into an upload of the organization: a catalogue's picture URL expires or changes, and a plan published weeks later would carry a broken file. Those uploads count against the storage quota.
Optionaltext?: stringfrom_text. The article pasted by hand. It wins over url when both come: pasting is what a user does when the download did not work (a paywall, a page that needs JavaScript), so re-downloading to ignore what they wrote would take away their only way out. Truncated to 12.000 characters — the cap is what keeps the estimate honest, since the real charge is per use. Under 200 characters it is not an article, it is the theme, which is prompt and another field (2116).
Optionalurl?: stringFormat: uri
from_text. The article the plan is written from. It is downloaded at creation, and only http/https addresses that resolve to a public IP are accepted — checked again before every redirect (2114). A page that answers something that is not text or HTML, or that carries no usable text once stripped, is a 2113: paste the text in text instead.
Optionaldescription?: stringOptionalexternal_id?: stringOptionalid_upload?: stringOptionalname?: stringOptionalpermalink?: stringThe product's public page (a store's product page, or the url of the Meta catalogue item), kept only when it is an http(s) address. The texts use it on the networks where a link in the text can be clicked, never on Instagram or TikTok; and a Pinterest pin leads there unless the plan has its own options.link. Absent on plans created before the field existed.
Optionalprice?: stringThe price exactly as the source gave it: Meta's formatted price ("9,99 €"), or a store's displayed price with its tax, symbol and range ("14,52 € IVA incluido"). It is never converted: the same field is a number in other paths of Meta's API and there is no way to tell units from cents, and dividing by 100 "just in case" is precisely how a 10 € product gets advertised at 0,10 €. The prompt is told to copy it verbatim or to say nothing. Absent when the product has no price, and on a store's taxable products while its config.tax_location_missing is true.
Format: date-time
Optionaldeleted_date?: stringFormat: date-time
Optionalid_user?: stringOptionalwebhook_url?: stringWhere PlanVortex posts events, when one is configured.
**The body is an array of changes, not a single object**, and it carries two signature headers computed with this app's secret over the **raw** body: `x-hub-signature` (`sha1=<hex>`) and `x-hub-signature-256` (`sha256=<hex>`). Verify against the bytes you received — parsing the JSON and re-serialising it changes them and the signature will not match.
The events delivered today are `new_account`, `change_state_account`, `messages`, `messaging_postbacks`, `messaging_seen`, `messaging_error`, `comments`, `integration_error`, `ai_plan_generated` and `ai_plan_failed`. The payload is documented in the `comments` specification. **Every** app of the client that has a `webhook_url` receives every event, not only the app whose call caused it: with two apps, each event arrives twice. Delivery is best effort: PlanVortex does not retry a webhook that fails.
Optionalallowed_domains?: string[]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.
Optionalredirect_urls?: string[]Optionalwebhook_url?: stringOptionalclient_id?: stringOptionalclient_secret?: stringOptionalscope?: stringOptionalscope?: stringOptionalallows_gallery?: booleanOptionalallows_shared?: booleanOptionalgenerates_images?: booleanOptionalmax_source_items?: numberOptionalorchestration_cost?: numberOptionalorchestration_cost_per_source_item?: numberOptionalregenerate?: { image?: boolean; text?: boolean }Optionalsource_fields?: {Optionalsource_requires_any?: string[]Fields of which AT LEAST ONE is needed, even though none of them is required on its own: from_text takes the URL or the pasted text, never both empty (2116); from_catalog takes id_account_catalog or id_integration_catalog, and exactly one (2112 with both).
It exists because a field's `required` cannot say "one or the other", and without it your UI would have to hardcode that rule — exactly the copy this catalogue exists to avoid. Absent = there is nothing of the sort to resolve.
Optionaltemplate?: "standard" | "from_images" | "from_text" | "from_catalog" | "campaign"What the plan is generated FROM, and what you send as template when creating it:
• **`standard`** — a theme prompt, images generated by the model. What every plan was before templates existed.
• **`from_images`** — the user's own photos, each with its own description. One vision pass over ALL of them at once, so the model can sequence a narrative (photo 3 the "before", photo 7 the "after") instead of writing seven independent posts. It generates no images.
• **`from_text`** — an article: a URL that is downloaded at creation, or the text pasted by hand.
• **`from_catalog`** — products read LIVE from a connected catalogue (a Meta catalogue through a connected account, or a connected store such as WooCommerce), with their name, their description, their price, their picture and the link to their page. The one template that cannot be copied by a generic AI tool, because it needs the catalogue connection.
• **`campaign`** — a countdown towards a date, with a narrative arc: teaser, announcement, reminder, today, thank you. The only plan that is a story instead of seven loose posts.
Optionalunsupported_networks?: (Networks whose accounts cannot be in a plan of this template: creating one is refused with 2120, before anything is read or charged. The ones that do not publish (whatsapp, google_business) on every template; and on the templates where every publication is the photo of its source (from_images, from_catalog), also the ones that do not publish images: youtube, which only uploads video.
It is computed from the same data that validates publishing, not from a list: a new network that does not publish photos drops out on its own. Filter the accounts you offer with it.
Optionaldefault?: unknownOptionalmax?: numberOptionalmin?: numberFloor in the field's own units. Today only from_text: under 200 characters it is not an article, it is the theme, which is prompt and another field. Published for the same reason as max — without it your UI hardcodes the 200 and the user gets a 2116 AFTER pasting the text instead of while pasting it.
Optionalname?: stringOptionaloptions?: string[]Optionalrequired?: booleanOptionaltype?: Maximum length of a publication's text. Bluesky and Threads count GRAPHEMES, everyone else counts characters — Telegram included, where String.length is exactly the right unit. The difference is not academic: an emoji is one grapheme and two String.length units, so counting a Threads post with .length rejects at 250 emojis what the network publishes happily at 500.
On Telegram there are **two numbers for the same field**: `telegram` (4.096) while the publication is text only, and `telegram_media` (1.024) the moment it carries an image or a video, because then the text is a media caption and not a message. Switch the counter when the file is attached, not when publish is pressed.
Maximum size of one file, in megabytes.
**On `slack` this one is a ceiling, not a promise.** 1.024 MB is what the network allows; the real limit is the lesser of that and the storage the client's own workspace plan still has, which no API exposes. A file inside this number can still come back as error 986. It is the only key in this map with that property.
**On `pinterest` this number is the IMAGE one** (~20 MB); a video is allowed two orders of magnitude more. It is the one entry in this map that does not apply to every file of its network, so a size warning shown against it would be wrong on every video. Over whichever ceiling applies, the publication is created in state `withErrors` with `publication_errors[].code = 996` and `data.max_mb`.
How many images one publication accepts. 0 means images are not a publication on that network.
**On `discord`, `threads` and `slack` it counts images and videos together**, because there the carousel is one message carrying several attachments and not several publications: what is validated is the total number of files. Over it, the publication is created in state `withErrors` — on `slack` with `publication_errors[].code = 982`.
**On `pinterest` it is a ceiling with a floor under it.** Several images are a carousel of 2 to 5: one image is not a small carousel (it is a plain image pin, which is a different call) and six is not a trimmed one. Outside that range the publication is created in state `withErrors` with `publication_errors[].code = 990`.
One number per network. Every network in /social_networks is present.
**And a few keys are not a network.** Some limits depend on the *kind* of publication rather than on the network alone, and those get a compound key next to the plain one: `instagram_story`, `facebook_reel`, `telegram_media`. Read the plain key by default and the compound one when it applies.
Optionalapi_key?: stringOptional Readonlyhas_api_key?: booleanPer-scope AI provider configuration (BYOK). Only the scopes you send are touched; a scope set to null clears its configuration and returns that scope to PlanVortex credits. orchestrator and text need a text-capable provider, image needs an image-capable one. video is reserved for a later phase.
Optionalimage?: Optionalorchestrator?: Optionaltext?: Optionalactual_asigned?: {Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalcurrent_period_end?: stringFormat: date-time
Optionalcurrent_period_start?: stringFormat: date-time
Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalrequire_action?: booleanOptionalstripe_customer_id?: stringOptionalactual_use?: { publications?: number; users?: number } & {Optionalai_settings?: {Optionalimage?: Optionalorchestrator?: Optionaltext?: Optionalclient_type?: "personal" | "company"Format: date-time
Optionaltrial_tested?: booleanOptionalactual_asigned?: {Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalcurrent_period_end?: stringFormat: date-time
Optionalcurrent_period_start?: stringFormat: date-time
Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalrequire_action?: booleanOptionalstripe_customer_id?: stringOptionalactual_use?: { publications?: number; users?: number } & {Optionalai_settings?: {Optionalimage?: Optionalorchestrator?: Optionaltext?: Optionalclient_type?: "personal" | "company"Format: date-time
Optionaltrial_tested?: booleanOptionalactual_plan?: {Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalactual_asigned?: {Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalactual_plan?: {The slice of the client's plan assigned to this organization. Absent when nothing was assigned, and then the organization shares whatever its nearest parent with a plan has — or, failing that, the client's unassigned remainder. Ask GET /organizations/{id_organization}/limits for the effective numbers instead of reading this.
Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalactual_use?: { publications?: number; users?: number } & {Optionalai_context?: {Optionalaudience?: stringOptionalavoid?: stringOptionalblog_url?: stringOptionalbrand_name?: stringOptionaldefault_tone?: stringOptionaldescription?: stringOptionalkeywords?: string[]Optionalnotes?: stringOptionalproducts?: stringOptionalsector?: stringOptionalshop_url?: stringOptionalsocial_urls?: string[]Optionalvalue_proposition?: stringOptionalwebsite?: stringFormat: date-time
Optionalparent_organization?: stringOptionalsocial_credentials?: {Optionaldiscord?: {Optionalapplication_name?: stringOptionalclient_id?: stringOptionalhas_bot_token?: booleanOptionalhas_client_secret?: booleanOptionalverified_date?: stringFormat: date-time
Optionalstats_settings?: { auto_refresh_twitter: boolean }Optionalactual_plan?: {Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalstats_settings?: { auto_refresh_twitter: boolean }Optionalcurrent_period_end?: stringFormat: date-time
Optionalcurrent_period_start?: stringFormat: date-time
Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalrequire_action?: booleanOptionalstripe_customer_id?: stringOne change in the array PlanVortex posts to your app's webhook_url, when an AI plan finished: ai_plan_generated when it came out, ai_plan_failed when it gave up.
Like `IntegrationWebhookChange`, it carries no `id_account` and no `social_network`: a plan can span several networks, and it hangs off the organization. It carries ids and numbers only. Read the plan itself with `GET /clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}`.
Optionalerror?: { code: number; message: string }Optionalname?: stringOptionalprofile_pic?: stringFormat: date-time
Format: date-time
The comment's id on the network. Unique per account, and what makes repeated webhook deliveries idempotent.
Two exceptions worth knowing, and both are composite ids you should treat as opaque:
• A Google Business reply has no id of its own — it is a *field* of the review — so PlanVortex fabricates a stable one, `{reviewId}/reply`.
• A Telegram comment lives in a **different chat** from the post it answers (the channel's linked discussion group), so it needs two ids at once and travels as `{thread}/{message}`: replying wants the first, deleting wants the second.
The connected account it arrived on.
**It is not always the same shape.** The inbox listing (`GET /organizations/{id}/comments`) returns the whole account resolved; every other operation — the live threads, the reply, the update — returns its identifier as a string. Check before using it.
Optionalid_publication?: Your publication, when there is one — and there often is not: a video uploaded to the channel by hand, a post that predates PlanVortex, and every Google Business review have comments with no publication of ours behind them. What always identifies the target is publication_external_id.
Same asymmetry as `id_account`: the inbox listing resolves it into the whole publication, every other operation returns the identifier as a string.
Optionallike_count?: numberOptionalour_reply_external_id?: stringOptionalparent_external_id?: stringOptionalrating?: numberOptionalreply_count?: numberOptionalname?: stringOptionalprofile_pic?: stringOptionalnext_cursor?: stringOne change in the array PlanVortex posts to your app's webhook_url, when an integration stopped working: a revoked Google Drive token, a feed that no longer answers, a publication quota that ran out.
It carries neither `id_account` nor `social_network`, because an integration hangs off the organization and not off any account — which is exactly why it is a type of its own. A consumer that only understands account changes sees a `field` it does not know and ignores it, which is what should happen.
The network publishes into places inside the account, and every publication has to name one: a Pinterest board. When this is true, read GET /organizations/{id_organization}/accounts/{id_account}/destinations and send one back in destination — a publication created without it lands in state withErrors with publication_errors[].code = 987. Today only pinterest.
The publication can say who can reply to it (reply_control): anyone, the account's followers, the profiles it follows or the profiles mentioned in the text. It is set when the publication goes out and cannot be changed afterwards. On a network that answers false the field is deleted on save, so do not offer it. Today only threads.
One change in the array PlanVortex posts to your app's webhook_url, when the change concerns a social account.
An integration that stopped working has a shape of its own (`IntegrationWebhookChange`), and so does an AI plan that finished (`AiPlanWebhookChange`). One delivery can mix them. Switch on `field`, and ignore what you do not handle.
OptionalcommentObj?: {Optionalname?: stringOptionalprofile_pic?: stringFormat: date-time
Format: date-time
The comment's id on the network. Unique per account, and what makes repeated webhook deliveries idempotent.
Two exceptions worth knowing, and both are composite ids you should treat as opaque:
• A Google Business reply has no id of its own — it is a *field* of the review — so PlanVortex fabricates a stable one, `{reviewId}/reply`.
• A Telegram comment lives in a **different chat** from the post it answers (the channel's linked discussion group), so it needs two ids at once and travels as `{thread}/{message}`: replying wants the first, deleting wants the second.
The connected account it arrived on.
**It is not always the same shape.** The inbox listing (`GET /organizations/{id}/comments`) returns the whole account resolved; every other operation — the live threads, the reply, the update — returns its identifier as a string. Check before using it.
Optionalid_publication?: Your publication, when there is one — and there often is not: a video uploaded to the channel by hand, a post that predates PlanVortex, and every Google Business review have comments with no publication of ours behind them. What always identifies the target is publication_external_id.
Same asymmetry as `id_account`: the inbox listing resolves it into the whole publication, every other operation returns the identifier as a string.
Optionallike_count?: numberOptionalour_reply_external_id?: stringOptionalparent_external_id?: stringOptionalrating?: numberOptionalreply_count?: numberWhat kind of change this is. Treat it as an open list and ignore what you do not handle: it grows with the product.
- `new_account` / `change_state_account`: an account was connected, or its state changed — it stopped working, its token was refreshed, it was disconnected. On `telegram` this is the **only** way to hear about a connection: there is no callback there, so no request of yours ever returns that account.
- `messages`: a message came in. It travels in `messageObj`.
- `messaging_postbacks`: the contact pressed a button or a quick reply. Also in `messageObj`.
- `messaging_seen`: the contact read the conversation. `messageObj` carries the message they read, when we still have it.
- `messaging_error`: the network refused a message we sent. The reason is in `messageObj.message_errors`.
- `comments`: a comment came in. It travels in `commentObj`, never in `messageObj`.
Optionalid_contact?: stringOptionalmessageObj?: {The message. Present for the messaging fields, never for a comment — a comment is not a message and does not travel in here.
It arrives **populated**: `contact_id`, `from_contact_id` and `message_options.files` carry whole objects. On `messaging_seen` and `messaging_error` it can be absent, because the message being acknowledged may not be one of ours.
Optionalcontact_id?: Format: date-time
Optionalelement_external_id?: stringOptionalfrom_contact_id?: Optionalin_response_external_id?: stringOptionalin_response_to?: stringOptionalmetaElements?: { [key: string]: unknown }[]OptionalmetaQuickReplies?: { [key: string]: unknown }[]Optionalpayload?: stringOptionaltemplate_language?: stringOptionaltemplate_name?: stringOptionaltemplate_parameters?: string[]For a template_message whose body has variables: their values, in order. The first one fills {{1}}, the second {{2}}, and so on. Without them WhatsApp rejects a template that has variables, so a reminder with the customer's name and time needs this. Each value is a non-empty string with no line breaks, no tabs and no more than four consecutive spaces; anything else answers 1511 with data.field = template_parameters and data.index pointing at the bad value. Only positional body variables are supported: named variables ({{name}}) and variables in the header or in a button URL are not sent yet.
OptionalwhatsappInteractive?: { [key: string]: unknown }Optionaltext?: stringOptionaloriginalChange?: { [key: string]: unknown }Format: date-time
Optionalcreator_keycloak_identifier?: stringOptionalextra_data?: {Optionaladdress?: stringOptionalboolean_property?: booleanOptionalboolean_property2?: booleanOptionalbuilding?: string | numberOptionalcity?: stringOptionalcoords?: number[]Optionalcountry?: stringOptionalcountry_code?: stringOptionaldoor?: string | numberOptionalfloor?: string | numberOptionalnumber?: string | numberOptionalnumber_property?: numberOptionalnumber_property2?: numberOptionalplace_id?: stringOptionalstate?: stringOptionalstring_property?: stringOptionalstring_property2?: stringOptionalzip_code?: string | numberFormat: date-time
Optionalname?: stringOptionalprofile_image?: stringOptionaladdress?: stringOptionalboolean_property?: booleanOptionalboolean_property2?: booleanOptionalbuilding?: string | numberOptionalcity?: stringOptionalcoords?: number[]Optionalcountry?: stringOptionalcountry_code?: stringOptionaldoor?: string | numberOptionalfloor?: string | numberOptionalnumber?: string | numberOptionalnumber_property?: numberOptionalnumber_property2?: numberOptionalplace_id?: stringOptionalstate?: stringOptionalstring_property?: stringOptionalstring_property2?: stringOptionalzip_code?: string | numberA new contact. At least one identifier is mandatory (ERROR_CODE_1601 otherwise): a contact with no channel is a contact nobody can write to.
Creating is idempotent on the FIRST identifier: if the organization already has a contact with that channel and that `external_identifier`, you get the existing one back untouched — `name`, `profile_image` and `extra_data` of the request are ignored. There is no "already exists" error.
Optionalextra_data?: {Optionaladdress?: stringOptionalboolean_property?: booleanOptionalboolean_property2?: booleanOptionalbuilding?: string | numberOptionalcity?: stringOptionalcoords?: number[]Optionalcountry?: stringOptionalcountry_code?: stringOptionaldoor?: string | numberOptionalfloor?: string | numberOptionalnumber?: string | numberOptionalnumber_property?: numberOptionalnumber_property2?: numberOptionalplace_id?: stringOptionalstate?: stringOptionalstring_property?: stringOptionalstring_property2?: stringOptionalzip_code?: string | numberOptionalname?: stringOptionalprofile_image?: stringChanges to a contact. name, profile_image and social_identifiers are left alone when you omit them.
**`extra_data` is the exception and it is destructive**: it is written with whatever the body carries, so omitting it ERASES every custom field on the contact. Read the contact, change what you need and send the whole block back.
Optionalextra_data?: {Optionaladdress?: stringOptionalboolean_property?: booleanOptionalboolean_property2?: booleanOptionalbuilding?: string | numberOptionalcity?: stringOptionalcoords?: number[]Optionalcountry?: stringOptionalcountry_code?: stringOptionaldoor?: string | numberOptionalfloor?: string | numberOptionalnumber?: string | numberOptionalnumber_property?: numberOptionalnumber_property2?: numberOptionalplace_id?: stringOptionalstate?: stringOptionalstring_property?: stringOptionalstring_property2?: stringOptionalzip_code?: string | numberOptionalname?: stringOptionalprofile_image?: stringOptionalsocial_identifiers?: {Optional_id?: stringOptionalexternal_identifier?: stringOptionalimage?: stringOptionalusername?: stringEverything the home screen needs, in ONE round trip.
**A missing block is not an error.** Each one is checked against its own permission and omitted when the caller cannot read it, instead of failing the whole request; `available_blocks` says which ones were allowed. A block that is `true` in `available_blocks` and absent from the body means there was no data — except `messages`, which also turns to `false` when the plan does not include chat.
Optionalaccount_metrics?: {Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalai_plan_results?: {The best AI plans of the range, by interactions per measured publication — the short version of GET /clients/{id}/organizations/{id}/ai_plans/results, with the same rules: the range filters on the plan's week, and only plans with at least 3 measured publications compete.
Unlike that endpoint it covers the organization **and its children**, like the rest of this screen, so each plan carries its `id_organization`.
Optionalengagement_per_publication?: numberOptionalengagement_vs_average?: numberOptionalai_plans?: {Optionallast_plan?: {Format: date-time
Optionalcredits_spent?: numberOptionalerror?: { code: number; data?: { [key: string]: unknown }; message: string }Optionalgeneration_end_date?: stringFormat: date-time
Optionalprompt?: stringOptionalpublications?: string[]Optionalhealth?: {Optionalaccounts_with_errors?: {Optionalpublications_with_errors?: {Optionaltotal_drafts?: numberOptionalupcoming_publications?: {Optionalmessages?: { unread: number }Optionalplan_use?: {Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalpublication_metrics?: {Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalpublications?: {Format: date-time
Format: date-time
Format: date-time
Format: date-time
Format: date-time
Optionalcredits_spent?: numberOptionalerror?: { code: number; data?: { [key: string]: unknown }; message: string }Optionalgeneration_end_date?: stringFormat: date-time
Optionalprompt?: stringOptionalpublications?: string[]Optionalcreation_date?: stringFormat: date-time
Optionalname?: stringOptionalpublication_errors?: { code: number; data?: { [key: string]: unknown }; message: string }[]Optionalpublication_warnings?: { code: number; data?: { [key: string]: unknown }; message: string }[]What still has to be done by hand on the network for a publication that DID go out. Same shape as publication_errors, and an empty array when there is nothing to do.
It is a separate field because these are not failures: the publication stays in state `sended` and must **not** be retried, because retrying would publish it twice. Like `publication_errors`, it belongs to the last attempt.
Today there is one code. **2400** (YouTube): YouTube accepted the upload but did not make the video public, so YouTube Studio shows it as a draft or private until someone sets its audience and visibility there. The upload does not fail when this happens; PlanVortex reads the video back right after uploading it to find out. `data` carries `requested_privacy_status`, `privacy_status` (what YouTube actually left), `upload_status`, `made_for_kids` and `studio_url`, the YouTube Studio page where it is finished.
Optionalpublish_date?: stringFormat: date-time
Optionaltext?: stringFormat: date-time
Format: date-time
Format: date-time
Format: date-time
Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOne row of the ranking. It comes out of the stats aggregation, not out of the publications collection, so it does not have the shape of a Publication: there is no _id (the identifier is id_publication) and the content travels nested under publication.
Only publications that have already been measured can appear here. For a listing that includes the unmeasured ones, use `GET /organizations/{id}/publications/stats`.
Optionalcollected_date?: stringFormat: date-time
Optionalengagement_base?: "reach" | "impressions" | "followers"Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalpublish_date?: stringFormat: date-time
Error payload returned by every failing request.
**Classify by `code`, never by the HTTP status.** Every domain error travels with HTTP 400 — an expired token, a disconnected account, an exhausted plan quota and a text that is too long are all 400. Only error 520 (permissions) answers 401, and an unexpected failure answers 500.
The catalogue grows with the product, so treat an unknown `code` as a generic failure instead of rejecting it.
PlanVortex error code. Ranges: 500-554 auth, tokens, client apps and connect sessions · 601-612 user · 700-716 social accounts · 800-810 files · 900-996 publications · 1000-1003 general · 1100-1111 organizations · 1200-1207 roles · 1300-1308 client plan · 1400-1408 organization plan · 1500-1512 messaging · 1600-1601 contacts · 1900-1906 payments · 2000-2099 products · 2100-2199 AI plans · 2200-2299 integrations · 2600-2699 comments, the ones that no longer fit in 945-948.
Optionaldata?: { [key: string]: unknown }Optionalapi_base?: "wp-json" | "rest_route"Optionalauth_mode?: "query" | "basic"Optionalauto_publish?: booleanOptionalcurrency?: stringOptionalid_accounts?: string[]Optionalimport_image?: booleanOptionalkey_ending?: stringOptionallast_checked?: stringFormat: date-time
Optionalpublication_type?: stringOptionalseen_guids?: string[]Optionaltax_location_missing?: booleanwoocommerce. true when the store's API gives prices WITHOUT tax, labelled as tax included. It happens with the default customer location set to geolocate (or to no location), prices entered without tax and tax based on the customer's address: an API request has no customer, so WooCommerce has nowhere to charge tax, and price_html reads "20,00 € IVA incluido" where the shop charges 24,20 €. While it is true, the store's taxable products come back without a price rather than with a wrong one; products with no tax keep theirs. It is checked when connecting, so after fixing the setting (WooCommerce → Settings → General → Default customer location → Shop country/region) the store has to be reconnected. Always present on a store, also as false.
Optionaltemplate?: stringOptionalurl?: stringFormat: date-time
Optionalerror_code?: null | numberPlanVortex error code of the last failure (2203 token revoked, 2205 feed unreachable, 2211 the store rejected its key, 924 no publication allowance left…). 2219 is not a failure: a store connected with the button is being checked, for a few seconds; if it stays there, the check never finished and the store has to be reconnected.
Optionalexternal_identifier?: stringOptionallast_used_date?: stringFormat: date-time
Optionalnext_cursor?: stringOptionaldescription?: stringOptionalimage_url?: stringOptionalpermalink?: stringOptionalprice?: stringText ready to be copied verbatim, as the store displays it: with its tax suffix, its currency symbol and the range of a variable product ("36,30 € - 48,40 € IVA incluido"); for a product on sale, the sale price alone. Never a number to do arithmetic with. Absent = no price: the product has none, or it is taxable and the store's config.tax_location_missing is true.
Optionalapi_base?: "wp-json" | "rest_route"Optionalauth_mode?: "query" | "basic"Optionalauto_publish?: booleanOptionalcurrency?: stringOptionalid_accounts?: string[]Optionalimport_image?: booleanOptionalkey_ending?: stringOptionallast_checked?: stringFormat: date-time
Optionalpublication_type?: stringOptionalseen_guids?: string[]Optionaltax_location_missing?: booleanwoocommerce. true when the store's API gives prices WITHOUT tax, labelled as tax included. It happens with the default customer location set to geolocate (or to no location), prices entered without tax and tax based on the customer's address: an API request has no customer, so WooCommerce has nowhere to charge tax, and price_html reads "20,00 € IVA incluido" where the shop charges 24,20 €. While it is true, the store's taxable products come back without a price rather than with a wrong one; products with no tax keep theirs. It is checked when connecting, so after fixing the setting (WooCommerce → Settings → General → Default customer location → Shop country/region) the store has to be reconnected. Always present on a store, also as false.
Optionaltemplate?: stringOptionalurl?: stringOptionaldefault?: unknownOptionaloptions?: string[]accounts is a picker of the organization's accounts. secret is a credential (WooCommerce's consumer secret): mask it, never prefill it when editing, and mark the input as a new password (autocomplete="new-password"), or the browser's password manager fills it with the user's own PlanVortex password and the store answers 2211.
Can be connected by sending the user to GET .../integrations/{provider}/connect_link: Google Drive (its OAuth) and WooCommerce (the store's approval button, which creates the key without anyone copying it). A provider with a link can ALSO have config_fields: a WooCommerce store accepts API keys by hand too.
Optionalauto_publish?: booleanOptionalid_accounts?: string[]Optionalimport_image?: booleanOptionallast_checked?: stringFormat: date-time
Optionalpublication_type?: stringOptionalseen_guids?: string[]Optionaltemplate?: stringOptionalurl?: stringOptionalauto_publish?: booleanOptionalimport_image?: booleanOptionalpublication_type?: stringOptionaltemplate?: stringA message exchanged with a contact.
**Careful with the three reference fields.** `contact_id`, `from_contact_id` and `message_options.files` arrive **populated** — the whole object, not the identifier — in the messages list and in the webhook PlanVortex posts to your app, and as plain identifiers everywhere else. The types say `string | object` because both really happen.
Optionalcontact_id?: Format: date-time
Optionalelement_external_id?: stringOptionalfrom_contact_id?: Optionalin_response_external_id?: stringOptionalin_response_to?: stringOptionalmetaElements?: { [key: string]: unknown }[]OptionalmetaQuickReplies?: { [key: string]: unknown }[]Optionalpayload?: stringOptionaltemplate_language?: stringOptionaltemplate_name?: stringOptionaltemplate_parameters?: string[]For a template_message whose body has variables: their values, in order. The first one fills {{1}}, the second {{2}}, and so on. Without them WhatsApp rejects a template that has variables, so a reminder with the customer's name and time needs this. Each value is a non-empty string with no line breaks, no tabs and no more than four consecutive spaces; anything else answers 1511 with data.field = template_parameters and data.index pointing at the bad value. Only positional body variables are supported: named variables ({{name}}) and variables in the header or in a button URL are not sent yet.
OptionalwhatsappInteractive?: { [key: string]: unknown }Optionaltext?: stringOptionalmetaElements?: { [key: string]: unknown }[]OptionalmetaQuickReplies?: { [key: string]: unknown }[]Optionalpayload?: stringOptionaltemplate_language?: stringOptionaltemplate_name?: stringOptionaltemplate_parameters?: string[]For a template_message whose body has variables: their values, in order. The first one fills {{1}}, the second {{2}}, and so on. Without them WhatsApp rejects a template that has variables, so a reminder with the customer's name and time needs this. Each value is a non-empty string with no line breaks, no tabs and no more than four consecutive spaces; anything else answers 1511 with data.field = template_parameters and data.index pointing at the bad value. Only positional body variables are supported: named variables ({{name}}) and variables in the header or in a button URL are not sent yet.
OptionalwhatsappInteractive?: { [key: string]: unknown }Format: date-time
Optionalcreator_keycloak_identifier?: stringOptionalextra_data?: {Optionaladdress?: stringOptionalboolean_property?: booleanOptionalboolean_property2?: booleanOptionalbuilding?: string | numberOptionalcity?: stringOptionalcoords?: number[]Optionalcountry?: stringOptionalcountry_code?: stringOptionaldoor?: string | numberOptionalfloor?: string | numberOptionalnumber?: string | numberOptionalnumber_property?: numberOptionalnumber_property2?: numberOptionalplace_id?: stringOptionalstate?: stringOptionalstring_property?: stringOptionalstring_property2?: stringOptionalzip_code?: string | numberFormat: date-time
Optionalname?: stringOptionalprofile_image?: stringFormat: date-time
What you send to write a message.
`comment_message` and `publication_message` need `in_response_external_id`, the identifier of the comment or the publication being answered ON THE NETWORK. The endpoint did not read it from the body until 2026-08-24, which left both types unreachable from the public API; it does now.
Optionalin_response_external_id?: stringRequired 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`.
Optionalmessage_options?: {OptionalmetaElements?: { [key: string]: unknown }[]OptionalmetaQuickReplies?: { [key: string]: unknown }[]Optionalpayload?: stringOptionaltemplate_language?: stringOptionaltemplate_name?: stringOptionaltemplate_parameters?: string[]For a template_message whose body has variables: their values, in order. The first one fills {{1}}, the second {{2}}, and so on. Without them WhatsApp rejects a template that has variables, so a reminder with the customer's name and time needs this. Each value is a non-empty string with no line breaks, no tabs and no more than four consecutive spaces; anything else answers 1511 with data.field = template_parameters and data.index pointing at the bad value. Only positional body variables are supported: named variables ({{name}}) and variables in the header or in a button URL are not sent yet.
OptionalwhatsappInteractive?: { [key: string]: unknown }Optionaltext?: stringMetrics translated to a common vocabulary shared by every network, which is what makes two networks comparable and summable (each network names them differently: page_post_engagements, total_interactions, views…).
**A missing key means the network does not publish that metric** — it is never an implicit zero. A key present with value `0` means it was measured and came out zero. Never default a missing key to 0 when displaying it.
`engagement` is the network's own total when it provides one, and otherwise the sum of likes, comments, shares, saves and clicks. Video views are deliberately excluded from it: a view is not an interaction.
Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalactual_asigned?: {Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalactual_plan?: {The slice of the client's plan assigned to this organization. Absent when nothing was assigned, and then the organization shares whatever its nearest parent with a plan has — or, failing that, the client's unassigned remainder. Ask GET /organizations/{id_organization}/limits for the effective numbers instead of reading this.
Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalactual_use?: { publications?: number; users?: number } & {Optionalai_context?: {Optionalaudience?: stringOptionalavoid?: stringOptionalblog_url?: stringOptionalbrand_name?: stringOptionaldefault_tone?: stringOptionaldescription?: stringOptionalkeywords?: string[]Optionalnotes?: stringOptionalproducts?: stringOptionalsector?: stringOptionalshop_url?: stringOptionalsocial_urls?: string[]Optionalvalue_proposition?: stringOptionalwebsite?: stringFormat: date-time
Optionalparent_organization?: stringOptionalsocial_credentials?: {Optionaldiscord?: {Optionalapplication_name?: stringOptionalclient_id?: stringOptionalhas_bot_token?: booleanOptionalhas_client_secret?: booleanOptionalverified_date?: stringFormat: date-time
Optionalstats_settings?: { auto_refresh_twitter: boolean }Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalactual_plan?: {Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalactual_asigned?: {Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalactual_plan?: {The slice of the client's plan assigned to this organization. Absent when nothing was assigned, and then the organization shares whatever its nearest parent with a plan has — or, failing that, the client's unassigned remainder. Ask GET /organizations/{id_organization}/limits for the effective numbers instead of reading this.
Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalactual_use?: { publications?: number; users?: number } & {Optionalai_context?: {Optionalaudience?: stringOptionalavoid?: stringOptionalblog_url?: stringOptionalbrand_name?: stringOptionaldefault_tone?: stringOptionaldescription?: stringOptionalkeywords?: string[]Optionalnotes?: stringOptionalproducts?: stringOptionalsector?: stringOptionalshop_url?: stringOptionalsocial_urls?: string[]Optionalvalue_proposition?: stringOptionalwebsite?: stringFormat: date-time
Optionalparent_organization?: stringOptionalsocial_credentials?: {Optionaldiscord?: {Optionalapplication_name?: stringOptionalclient_id?: stringOptionalhas_bot_token?: booleanOptionalhas_client_secret?: booleanOptionalverified_date?: stringFormat: date-time
Optionalstats_settings?: { auto_refresh_twitter: boolean }Optionalactual_plan?: {Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalstats_settings?: { auto_refresh_twitter: boolean }Optional_id?: stringOptionalcreation_date?: stringFormat: date-time
Optionaldefault?: booleanOptionalid_organization?: stringOptionalname?: stringOptionalpermissions?: (Optionaltotal_users?: numberOptionalroles?: {Optionaltotal?: numberOptionalrol?: {Optional_id?: stringOptionalcreation_date?: stringFormat: date-time
Optionaldefault?: booleanOptionalid_organization?: stringOptionalname?: stringOptionalpermissions?: (Optionaltotal_users?: numberOptionalemail?: stringFormat: email
Optionalenabled?: booleanOptionalfirstname?: stringOptionalid?: stringOptionallastname?: stringOptionalroles?: { _id?: string; name?: string }[]Optionalusername?: stringOptionaltotal?: numberOptionalusers?: {Optionalbot_token?: stringOptionalclient_id?: stringOptionalclient_secret?: stringThe resources a plan grants. On a client it is what was contracted; on an organization, the slice of it that was assigned. The sum across all the organizations of a client can never exceed what the client has contracted.
Users are NOT here: every plan has unlimited users, so they are neither charged nor split between organizations. They are still counted, in `PlanUseData.users`. There are no `artificial_inteligence`, `whatsapp` or `stats` flags either: statistics and WhatsApp are on every plan, and what gates AI is `ai_credits` — a plan with credits has AI, and Free has zero.
Optionalai_credits?: numberOptionalapps?: numberApps this plan allows: 1 on Free, 2 on Basic, 5 on Pro, 10 on Custom. Each one is a client_id with a secret, so each one is a key to the whole public API — which every plan has, Free included. Unlike accounts or storage this is NOT split between organizations: an app belongs to the client. On a use payload it is how many exist right now.
Optionaltwitter_credits?: numberOptionalai_generated?: { image: boolean; text: boolean }Format: date-time
Optionaldestination?: { id?: string; name?: string; section_id?: string; section_name?: string }Optionalid?: stringThe board id. Always a string, never a number: Pinterest's ids are long integers, so a client that sends one as a JSON number has already lost digits to rounding before the request arrives. That is refused — publication_errors[].code = 987 with data.reason = "invalid_id" — rather than published to some other board. Sending the board's name instead of its id fails the same way, which is the likeliest first mistake of an integration.
Optionalname?: stringOptionalsection_id?: stringOptionalsection_name?: stringOptionalengagement_base?: "reach" | "impressions" | "followers"Optionalexternal_identifier?: stringOptionalextra_data?: { [key: string]: unknown }What one network needs to remember about this publication and that has no common field. Absent on a publication whose network needs nothing, which is almost all of them.
Today only `telegram` writes here, and only `telegram_message_ids`: the ids of every message an album turned into, because deleting the album means deleting all of them.
Optionalid_integration?: stringOptionallink?: stringThe destination link: where the publication takes whoever clicks it. Not to be confused with url, which is the link to the publication on the network and only exists once it is published.
Only the networks that answer `link: true` in `GET /social_capabilities` carry it — today `pinterest` alone, where a pin has a `link` field of its own and sending traffic somewhere is the whole point of publishing there. Putting the URL inside `text` instead leaves it visible and unclickable.
**On every other network the field is deleted on save**, like `destination`.
Optionalmade_for_kids?: booleanOptionalmetrics?: {Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalname?: stringOptionalnext_stats_update?: stringFormat: date-time
Optionalpending_publish?: {Optionaldata?: { [key: string]: unknown }Optionaldeadline?: stringFormat: date-time
Optionalnext_check?: stringFormat: date-time
Optionaltemp_keys?: string[]Why the publication failed, one entry per problem. It is an array, and it is empty on a publication that has not failed.
For a scheduled X (Twitter) publication that runs out of credits at publish time, `code` is 940 and `data` is `{ used, limit }`; the publication stays in state `withErrors`. No webhook announces it: read the publication to find out.
On Instagram, two codes tell you whether a retry makes sense. **998**: Meta rejected the media while processing it (a codec, a duration, a URL it could not download). `data` carries `container_id`, `status_code` and, when Meta gives one, its reason in `status`. Retrying the same file fails the same way: change it first. **999**: Meta had not finished processing the media 10 minutes after it was sent. `data` carries `container_ids` and `minutes`. The file is not the problem, and a retry usually works.
Optionalpublication_warnings?: { code: number; data?: { [key: string]: unknown }; message: string }[]What still has to be done by hand on the network for a publication that DID go out. Same shape as publication_errors, and an empty array when there is nothing to do.
It is a separate field because these are not failures: the publication stays in state `sended` and must **not** be retried, because retrying would publish it twice. Like `publication_errors`, it belongs to the last attempt.
Today there is one code. **2400** (YouTube): YouTube accepted the upload but did not make the video public, so YouTube Studio shows it as a draft or private until someone sets its audience and visibility there. The upload does not fail when this happens; PlanVortex reads the video back right after uploading it to find out. `data` carries `requested_privacy_status`, `privacy_status` (what YouTube actually left), `upload_status`, `made_for_kids` and `studio_url`, the YouTube Studio page where it is finished.
Optionalpublish_date?: stringFormat: date-time
Optionalreply_control?: "everyone" | "accounts_you_follow" | "mentioned_only" | "followers_only"Who can reply to the publication, on the networks that answer reply_control: true in GET /social_capabilities (today threads). Absent means the network's default, which is anyone. It is set when the publication goes out and cannot be changed afterwards.
**On every other network the field is deleted on save**, like `link`.
draft is never sent; ready is scheduled; publishing is in the network's hands right now; sended went out; withErrors failed and carries the reason in publication_errors.
`publishing` usually lasts a few seconds, but it can last **up to 10 minutes** when the network is still processing a video (today, Instagram). Then `pending_publish` is present: wait and read the publication again, do not retry it.
Optionalstatistics?: {Optionalangers?: numberOptionalbookmarks?: numberOptionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollows?: numberOptionalhahas?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalloves?: numberOptionalnegative_feedback?: numberOptionaloutbound_clicks?: numberOptionalpage_likes?: numberOptionalpin_clicks?: numberOptionalplayback_0_count?: numberOptionalplayback_100_count?: numberOptionalplayback_25_count?: numberOptionalplayback_50_count?: numberOptionalplayback_75_count?: numberOptionalprofile_activity?: numberOptionalprofile_visits?: numberOptionalquotes?: numberOptionalreach?: numberOptionalreactions?: numberOptionalreactions_by_emoji?: { [key: string]: number }Optionalreplys?: numberOptionalretwets?: numberOptionalsaved?: numberOptionalsaves?: numberOptionalshare?: numberOptionalshareMentions?: numberOptionalshares?: numberOptionalsorrys?: numberOptionalurl_link_clicks?: numberOptionaluser_profile_clicks?: numberOptionalvideo_views?: numberOptionalviews?: numberOptionalwows?: numberOptionalstats_updated_date?: stringFormat: date-time
Optionaltext?: stringOptionaltitle?: stringOptionalurl?: stringOptionalvisibility?: "public" | "unlisted" | "private"Where inside the account a publication goes, on the networks where choosing the account is not yet choosing the destination. Today that is pinterest alone, where it is the board the pin is saved to.
Ask `destinations` in `GET /social_capabilities` instead of keeping a list of your own, and read the account's boards with `GET /organizations/{id_organization}/accounts/{id_account}/destinations`.
**On Pinterest it is required.** A publication created without it is created in state `withErrors` with `publication_errors[].code = 987` and the background job will not attempt it — the same treatment as a YouTube video with no title, and for the same reason: a scheduled publication that dies at 3 a.m. over a board that was missing from the start is a failure that could have been told to the person while they were still there.
**On every other network the field is deleted, not rejected.** A `destination` on a Facebook publication would be a field that does nothing, and giving it back in the response would be worse than dropping it.
`name` and `section_name` are labels for display ("Recipes > Desserts"). The server never checks them and nothing is decided by them: what publishes is `id`.
Optionalid?: stringThe board id. Always a string, never a number: Pinterest's ids are long integers, so a client that sends one as a JSON number has already lost digits to rounding before the request arrives. That is refused — publication_errors[].code = 987 with data.reason = "invalid_id" — rather than published to some other board. Sending the board's name instead of its id fails the same way, which is the likeliest first mistake of an integration.
Optionalname?: stringOptionalsection_id?: stringOptionalsection_name?: stringPresent only while the network is still processing what was sent, and PlanVortex is waiting to publish it. Today that happens on Instagram alone, when Meta takes longer than the ~30 seconds the request waits to process the media. In practice, that means videos.
The publication stays in state `publishing`, and the background job asks Instagram again about once a minute until it goes out (`sended`) or fails (`withErrors`). It gives up 10 minutes after the media was sent to Instagram, and then the publication ends in `withErrors` with code 999. The object disappears as soon as the publication is resolved.
**Wait for it. Do not retry it and do not create it again**: it has not failed, and a second publication would put the same video out twice. There is no webhook for the outcome: read the publication again after `next_check`. While this object is present the publication **cannot be updated** (error 921), for the same reason.
Optionaldata?: { [key: string]: unknown }Optionaldeadline?: stringFormat: date-time
Optionalnext_check?: stringFormat: date-time
Optionaltemp_keys?: string[]Optionaldescription?: stringOptionalimage?: stringOptionalprivacy?: stringOptionalsections?: { id: string; name: string }[]Optionaldestination?: { id?: string; name?: string; section_id?: string; section_name?: string }Where inside the account the publication goes. Required on the networks that answer destinations: true in GET /social_capabilities — today pinterest, where it is the board — and deleted on every other one.
Read the account's destinations with `GET /organizations/{id_organization}/accounts/{id_account}/destinations` and send back the `id` you got from there. Missing or malformed, the publication is still created, in state `withErrors` with `publication_errors[].code = 987` and a `data.reason` of `missing`, `invalid_id` or `invalid_section_id`. It is **not** checked against the network at creation: that would cost a call to Pinterest on every publication and make creating one depend on Pinterest answering.
On an update, omitting it keeps the destination that was there.
Optionalid?: stringThe board id. Always a string, never a number: Pinterest's ids are long integers, so a client that sends one as a JSON number has already lost digits to rounding before the request arrives. That is refused — publication_errors[].code = 987 with data.reason = "invalid_id" — rather than published to some other board. Sending the board's name instead of its id fails the same way, which is the likeliest first mistake of an integration.
Optionalname?: stringOptionalsection_id?: stringOptionalsection_name?: stringOptionalfiles?: 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.
**On Pinterest a pin is an image or a video, never text alone** (code 922 with neither), and it is of one kind only: two to five images make a carousel — outside that range, code 990 — images and videos are not mixed and there is no more than one video (code 916). The weight ceilings are two orders of magnitude apart, so `max_file_size_mb.pinterest` is the **image** one (~20 MB) while a video is allowed far more; over either, code 996 with `data.max_mb`.
Optionallink?: stringThe destination link: where the publication takes whoever clicks it. Only on the networks that answer link: true in GET /social_capabilities — today pinterest, where it is the whole point of a pin — and deleted on every other one. It is not url, which is the link to the publication on the network once published.
It is optional (a pin with no link is legitimate), but if it is sent it must be a real `http(s)://` URL of at most 2.048 characters: a bare domain such as `mysite.com/recipe` is created in state `withErrors` with `publication_errors[].code = 994` and `data.reason` of `invalid_url` or `too_long`. It is checked when the publication is **created**, not when it is published, so a scheduled pin does not die at 3 a.m. over a link that was already wrong.
On an update: omit it to keep what was there, send `null` or `""` to remove it.
Optionalmade_for_kids?: booleanWhether the video is made for kids. Only on the networks that answer made_for_kids: true in GET /social_capabilities (today youtube), and deleted on every other one. Absent means false.
The law (COPPA) and YouTube's policies require a video directed at children to be uploaded with `true`, and it is the **user's** call: if your software publishes on behalf of someone, ask them. YouTube then turns off features such as comments and personalised ads on that video.
It must be a JSON boolean. Anything else (`"yes"`, `"no"`, `1`) is not guessed: the publication is still created, in state `withErrors` with `publication_errors[].code = 2501`.
On an update: omit it to keep what was there, send `null` or `""` to go back to `false`.
Optionalname?: stringOptionalpublication_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.
Optionalpublish_date?: stringFormat: date-time
Optionalreply_control?: "everyone" | "accounts_you_follow" | "mentioned_only" | "followers_only"Who can reply to the publication. Only on the networks that answer reply_control: true in GET /social_capabilities (today threads), and deleted on every other one.
- `everyone`: anyone. It is the network's default, and what applies when the field is omitted.
- `followers_only`: only the account's followers.
- `accounts_you_follow`: only the profiles the account follows.
- `mentioned_only`: only the profiles mentioned in the text.
**It is set when the publication goes out and cannot be changed afterwards**: Threads has no way to change it on a published post through its API. A value the network does not accept does not fail the request: the publication is still created, in state `withErrors` with `publication_errors[].code = 997`, whose `data` carries the `allowed` values. It is checked when the publication is **created**, so a scheduled post does not fail at 3 a.m. over a value that was already wrong.
On an update: omit it to keep what was there, send `null` or `""` to go back to the network's default.
Optionalsocial_network?: 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.
Optionalstate?: "ready" | "withErrors" | "sended" | "draft" | "publishing"Optionaltext?: stringBody 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.
**On Pinterest `text` is the pin's description**, at most 800 characters — the title is the separate `title` field, which is the first thing anyone arriving from Facebook gets wrong. Over it, `publication_errors[].code = 995` with `data.field = "description"`.
Optionaltitle?: stringTitle 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.
**On Pinterest it is the pin's title**, optional and at most 100 characters; over it the publication is created in state `withErrors` with `publication_errors[].code = 995` and `data.field = "title"`.
Optionalvisibility?: "public" | "unlisted" | "private"Who can see the publication when it goes out. Only on the networks that answer visibility: true in GET /social_capabilities (today youtube), and deleted on every other one.
- `public`: anyone. It is what applies when the field is omitted.
- `unlisted`: only people with the link. It does not show on the channel or in search.
- `private`: only the channel owner and the people they invite from YouTube Studio.
YouTube's policies require this to be **the user's choice**: if your software publishes on behalf of someone, ask them. A value the network does not accept does not fail the request: the publication is still created, in state `withErrors` with `publication_errors[].code = 2500`, whose `data` carries the `allowed` values. It is checked when the publication is **created**, so a scheduled video does not fail at 3 a.m. over a value that was already wrong.
If YouTube does not leave the video with the visibility that was asked for, the publication still ends up `sended` (the video is on the channel) and carries a warning in `publication_warnings` with `code = 2400`.
On an update: omit it to keep what was there, send `null` or `""` to go back to `public`.
Optionalai_generated?: { image: boolean; text: boolean }Format: date-time
Optionaldestination?: { id?: string; name?: string; section_id?: string; section_name?: string }Optionalid?: stringThe board id. Always a string, never a number: Pinterest's ids are long integers, so a client that sends one as a JSON number has already lost digits to rounding before the request arrives. That is refused — publication_errors[].code = 987 with data.reason = "invalid_id" — rather than published to some other board. Sending the board's name instead of its id fails the same way, which is the likeliest first mistake of an integration.
Optionalname?: stringOptionalsection_id?: stringOptionalsection_name?: stringOptionalengagement_base?: "reach" | "impressions" | "followers"Optionalexternal_identifier?: stringOptionalextra_data?: { [key: string]: unknown }What one network needs to remember about this publication and that has no common field. Absent on a publication whose network needs nothing, which is almost all of them.
Today only `telegram` writes here, and only `telegram_message_ids`: the ids of every message an album turned into, because deleting the album means deleting all of them.
Optionalid_integration?: stringOptionallink?: stringThe destination link: where the publication takes whoever clicks it. Not to be confused with url, which is the link to the publication on the network and only exists once it is published.
Only the networks that answer `link: true` in `GET /social_capabilities` carry it — today `pinterest` alone, where a pin has a `link` field of its own and sending traffic somewhere is the whole point of publishing there. Putting the URL inside `text` instead leaves it visible and unclickable.
**On every other network the field is deleted on save**, like `destination`.
Optionalmade_for_kids?: booleanOptionalmetrics?: {Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalname?: stringOptionalnext_stats_update?: stringFormat: date-time
Optionalpending_publish?: {Optionaldata?: { [key: string]: unknown }Optionaldeadline?: stringFormat: date-time
Optionalnext_check?: stringFormat: date-time
Optionaltemp_keys?: string[]Why the publication failed, one entry per problem. It is an array, and it is empty on a publication that has not failed.
For a scheduled X (Twitter) publication that runs out of credits at publish time, `code` is 940 and `data` is `{ used, limit }`; the publication stays in state `withErrors`. No webhook announces it: read the publication to find out.
On Instagram, two codes tell you whether a retry makes sense. **998**: Meta rejected the media while processing it (a codec, a duration, a URL it could not download). `data` carries `container_id`, `status_code` and, when Meta gives one, its reason in `status`. Retrying the same file fails the same way: change it first. **999**: Meta had not finished processing the media 10 minutes after it was sent. `data` carries `container_ids` and `minutes`. The file is not the problem, and a retry usually works.
Optionalpublication_warnings?: { code: number; data?: { [key: string]: unknown }; message: string }[]What still has to be done by hand on the network for a publication that DID go out. Same shape as publication_errors, and an empty array when there is nothing to do.
It is a separate field because these are not failures: the publication stays in state `sended` and must **not** be retried, because retrying would publish it twice. Like `publication_errors`, it belongs to the last attempt.
Today there is one code. **2400** (YouTube): YouTube accepted the upload but did not make the video public, so YouTube Studio shows it as a draft or private until someone sets its audience and visibility there. The upload does not fail when this happens; PlanVortex reads the video back right after uploading it to find out. `data` carries `requested_privacy_status`, `privacy_status` (what YouTube actually left), `upload_status`, `made_for_kids` and `studio_url`, the YouTube Studio page where it is finished.
Optionalpublish_date?: stringFormat: date-time
Optionalreply_control?: "everyone" | "accounts_you_follow" | "mentioned_only" | "followers_only"Who can reply to the publication, on the networks that answer reply_control: true in GET /social_capabilities (today threads). Absent means the network's default, which is anyone. It is set when the publication goes out and cannot be changed afterwards.
**On every other network the field is deleted on save**, like `link`.
draft is never sent; ready is scheduled; publishing is in the network's hands right now; sended went out; withErrors failed and carries the reason in publication_errors.
`publishing` usually lasts a few seconds, but it can last **up to 10 minutes** when the network is still processing a video (today, Instagram). Then `pending_publish` is present: wait and read the publication again, do not retry it.
Optionalstatistics?: {Optionalangers?: numberOptionalbookmarks?: numberOptionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollows?: numberOptionalhahas?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalloves?: numberOptionalnegative_feedback?: numberOptionaloutbound_clicks?: numberOptionalpage_likes?: numberOptionalpin_clicks?: numberOptionalplayback_0_count?: numberOptionalplayback_100_count?: numberOptionalplayback_25_count?: numberOptionalplayback_50_count?: numberOptionalplayback_75_count?: numberOptionalprofile_activity?: numberOptionalprofile_visits?: numberOptionalquotes?: numberOptionalreach?: numberOptionalreactions?: numberOptionalreactions_by_emoji?: { [key: string]: number }Optionalreplys?: numberOptionalretwets?: numberOptionalsaved?: numberOptionalsaves?: numberOptionalshare?: numberOptionalshareMentions?: numberOptionalshares?: numberOptionalsorrys?: numberOptionalurl_link_clicks?: numberOptionaluser_profile_clicks?: numberOptionalvideo_views?: numberOptionalviews?: numberOptionalwows?: numberOptionalstats_updated_date?: stringFormat: date-time
Optionaltext?: stringOptionaltitle?: stringOptionalurl?: stringOptionalvisibility?: "public" | "unlisted" | "private"Optionalai_generated?: { image: boolean; text: boolean }Format: date-time
Optionaldestination?: { id?: string; name?: string; section_id?: string; section_name?: string }Optionalid?: stringThe board id. Always a string, never a number: Pinterest's ids are long integers, so a client that sends one as a JSON number has already lost digits to rounding before the request arrives. That is refused — publication_errors[].code = 987 with data.reason = "invalid_id" — rather than published to some other board. Sending the board's name instead of its id fails the same way, which is the likeliest first mistake of an integration.
Optionalname?: stringOptionalsection_id?: stringOptionalsection_name?: stringOptionalengagement_base?: "reach" | "impressions" | "followers"Optionalexternal_identifier?: stringOptionalextra_data?: { [key: string]: unknown }What one network needs to remember about this publication and that has no common field. Absent on a publication whose network needs nothing, which is almost all of them.
Today only `telegram` writes here, and only `telegram_message_ids`: the ids of every message an album turned into, because deleting the album means deleting all of them.
Optionalid_integration?: stringOptionallink?: stringThe destination link: where the publication takes whoever clicks it. Not to be confused with url, which is the link to the publication on the network and only exists once it is published.
Only the networks that answer `link: true` in `GET /social_capabilities` carry it — today `pinterest` alone, where a pin has a `link` field of its own and sending traffic somewhere is the whole point of publishing there. Putting the URL inside `text` instead leaves it visible and unclickable.
**On every other network the field is deleted on save**, like `destination`.
Optionalmade_for_kids?: booleanOptionalmetrics?: {Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalname?: stringOptionalnext_stats_update?: stringFormat: date-time
Optionalpending_publish?: {Optionaldata?: { [key: string]: unknown }Optionaldeadline?: stringFormat: date-time
Optionalnext_check?: stringFormat: date-time
Optionaltemp_keys?: string[]Why the publication failed, one entry per problem. It is an array, and it is empty on a publication that has not failed.
For a scheduled X (Twitter) publication that runs out of credits at publish time, `code` is 940 and `data` is `{ used, limit }`; the publication stays in state `withErrors`. No webhook announces it: read the publication to find out.
On Instagram, two codes tell you whether a retry makes sense. **998**: Meta rejected the media while processing it (a codec, a duration, a URL it could not download). `data` carries `container_id`, `status_code` and, when Meta gives one, its reason in `status`. Retrying the same file fails the same way: change it first. **999**: Meta had not finished processing the media 10 minutes after it was sent. `data` carries `container_ids` and `minutes`. The file is not the problem, and a retry usually works.
Optionalpublication_warnings?: { code: number; data?: { [key: string]: unknown }; message: string }[]What still has to be done by hand on the network for a publication that DID go out. Same shape as publication_errors, and an empty array when there is nothing to do.
It is a separate field because these are not failures: the publication stays in state `sended` and must **not** be retried, because retrying would publish it twice. Like `publication_errors`, it belongs to the last attempt.
Today there is one code. **2400** (YouTube): YouTube accepted the upload but did not make the video public, so YouTube Studio shows it as a draft or private until someone sets its audience and visibility there. The upload does not fail when this happens; PlanVortex reads the video back right after uploading it to find out. `data` carries `requested_privacy_status`, `privacy_status` (what YouTube actually left), `upload_status`, `made_for_kids` and `studio_url`, the YouTube Studio page where it is finished.
Optionalpublish_date?: stringFormat: date-time
Optionalreply_control?: "everyone" | "accounts_you_follow" | "mentioned_only" | "followers_only"Who can reply to the publication, on the networks that answer reply_control: true in GET /social_capabilities (today threads). Absent means the network's default, which is anyone. It is set when the publication goes out and cannot be changed afterwards.
**On every other network the field is deleted on save**, like `link`.
draft is never sent; ready is scheduled; publishing is in the network's hands right now; sended went out; withErrors failed and carries the reason in publication_errors.
`publishing` usually lasts a few seconds, but it can last **up to 10 minutes** when the network is still processing a video (today, Instagram). Then `pending_publish` is present: wait and read the publication again, do not retry it.
Optionalstatistics?: {Optionalangers?: numberOptionalbookmarks?: numberOptionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollows?: numberOptionalhahas?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalloves?: numberOptionalnegative_feedback?: numberOptionaloutbound_clicks?: numberOptionalpage_likes?: numberOptionalpin_clicks?: numberOptionalplayback_0_count?: numberOptionalplayback_100_count?: numberOptionalplayback_25_count?: numberOptionalplayback_50_count?: numberOptionalplayback_75_count?: numberOptionalprofile_activity?: numberOptionalprofile_visits?: numberOptionalquotes?: numberOptionalreach?: numberOptionalreactions?: numberOptionalreactions_by_emoji?: { [key: string]: number }Optionalreplys?: numberOptionalretwets?: numberOptionalsaved?: numberOptionalsaves?: numberOptionalshare?: numberOptionalshareMentions?: numberOptionalshares?: numberOptionalsorrys?: numberOptionalurl_link_clicks?: numberOptionaluser_profile_clicks?: numberOptionalvideo_views?: numberOptionalviews?: numberOptionalwows?: numberOptionalstats_updated_date?: stringFormat: date-time
Optionaltext?: stringOptionaltitle?: stringOptionalurl?: stringOptionalvisibility?: "public" | "unlisted" | "private"Optionalmetric?: stringOptionalpublications?: {Optionalrange?: {Optionalfrom_date?: stringFormat: date-time
Optionalprevious_from_date?: stringFormat: date-time
Optionalprevious_to_date?: stringFormat: date-time
Optionalto_date?: stringFormat: date-time
Optionalsummary?: {Optionalby_network?: {Optionalprevious_total?: {Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionaltotal?: {Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionaltotal?: numberOptionalengagement_base?: "reach" | "impressions" | "followers"Optionallatest?: {Format: date-time
Optionalengagement_base?: "reach" | "impressions" | "followers"Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalraw?: {Optionalangers?: numberOptionalbookmarks?: numberOptionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollows?: numberOptionalhahas?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalloves?: numberOptionalnegative_feedback?: numberOptionaloutbound_clicks?: numberOptionalpage_likes?: numberOptionalpin_clicks?: numberOptionalplayback_0_count?: numberOptionalplayback_100_count?: numberOptionalplayback_25_count?: numberOptionalplayback_50_count?: numberOptionalplayback_75_count?: numberOptionalprofile_activity?: numberOptionalprofile_visits?: numberOptionalquotes?: numberOptionalreach?: numberOptionalreactions?: numberOptionalreactions_by_emoji?: { [key: string]: number }Optionalreplys?: numberOptionalretwets?: numberOptionalsaved?: numberOptionalsaves?: numberOptionalshare?: numberOptionalshareMentions?: numberOptionalshares?: numberOptionalsorrys?: numberOptionalurl_link_clicks?: numberOptionaluser_profile_clicks?: numberOptionalvideo_views?: numberOptionalviews?: numberOptionalwows?: numberOptionalmetrics?: {Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberOptionalnext_stats_update?: stringFormat: date-time
Optionalpublish_date?: stringFormat: date-time
Optionalstatistics?: {Optionalangers?: numberOptionalbookmarks?: numberOptionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollows?: numberOptionalhahas?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalloves?: numberOptionalnegative_feedback?: numberOptionaloutbound_clicks?: numberOptionalpage_likes?: numberOptionalpin_clicks?: numberOptionalplayback_0_count?: numberOptionalplayback_100_count?: numberOptionalplayback_25_count?: numberOptionalplayback_50_count?: numberOptionalplayback_75_count?: numberOptionalprofile_activity?: numberOptionalprofile_visits?: numberOptionalquotes?: numberOptionalreach?: numberOptionalreactions?: numberOptionalreactions_by_emoji?: { [key: string]: number }Optionalreplys?: numberOptionalretwets?: numberOptionalsaved?: numberOptionalsaves?: numberOptionalshare?: numberOptionalshareMentions?: numberOptionalshares?: numberOptionalsorrys?: numberOptionalurl_link_clicks?: numberOptionaluser_profile_clicks?: numberOptionalvideo_views?: numberOptionalviews?: numberOptionalwows?: numberOptionalstats_updated_date?: stringFormat: date-time
Format: date-time
Optionalengagement_base?: "reach" | "impressions" | "followers"Optionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollowers?: numberOptionalfollowers_gained?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalprofile_views?: numberOptionalreach?: numberOptionalsaves?: numberOptionalshares?: numberOptionalvideo_views?: numberRaw, per-network metrics for a publication. Only the fields that belong to the publication's own social network are returned.
**An absent field is not a zero.** A field is present only when the network actually reported it; `0` means the network measured zero. This matters most on X (Twitter), where metrics are split into groups with different access levels: `public_metrics` (likes, replys, retwets, quotes, bookmarks, impressions) is always available, while clicks, the pre-computed `engagement` and the video playback quartiles come from X's non-public metrics — only for your own posts, within 30 days of publishing, and only if the app is entitled to them. When they are unavailable they are omitted rather than returned as `0`. Do not default missing fields to zero when displaying them.
On `discord` there are only two: `likes` (the reactions on the message) and `comments` (the messages in its thread). There is no impressions figure anywhere in Discord's API, so engagement is computed over the server's member count.
On `bluesky` there are no impressions and no reach either — only the public counters — so engagement is computed over followers.
On `threads` there are six, and the one that matters is `views`: it is the only one of the four newest networks with something like impressions, so its engagement rate is computed over a real base and not over followers. Sharing arrives split in three — `reposts`, `quotes` and `shares` (outside Threads) — and the three are added into one figure, the same criterion as on X; the breakdown stays in `raw`. A reply is what every other network calls a comment.
On `telegram` there are two as well, and **neither of them is asked for**: the Bot API has no method that returns a message's metrics, so `reactions` arrives on its own through the bot and `comments` is counted in PlanVortex's own inbox. There are no impressions, no reach, no views and no forwards to be had anywhere in it, so engagement is computed over followers.
On `slack` there is exactly **one**, `reactions`, and the other absences are the informative part: the Web API publishes no impressions, no reach, no views and no clicks for a message, so those keys are missing rather than zero. There is no `comments` either — Slack threads are not read at all (`comments` is `false` in `GET /social_capabilities`). Engagement is computed over the channel's members.
On `pinterest` there are seven, and three of them exist nowhere else: `saves` — the gesture the whole network is built on —, `outbound_clicks` (clicks that left the pin towards its `link`, which is the one normalised as the common `clicks`) and `pin_clicks` (clicks that opened the pin inside Pinterest, with no common equivalent). `impressions` is real here, so the engagement rate is computed over it and not over followers. `video_views` only exists on a video pin. And `comments` is the odd one: Pinterest **counts** them and offers no way to read them, so the number is reported while `comments` is `false` in `GET /social_capabilities` — this network has no comment inbox, and it is not a gap waiting to be filled.
Optionalangers?: numberOptionalbookmarks?: numberOptionalclicks?: numberOptionalcomments?: numberOptionalengagement?: numberOptionalfollows?: numberOptionalhahas?: numberOptionalimpressions?: numberOptionallikes?: numberOptionalloves?: numberOptionalnegative_feedback?: numberOptionaloutbound_clicks?: numberOptionalpage_likes?: numberOptionalpin_clicks?: numberOptionalplayback_0_count?: numberOptionalplayback_100_count?: numberOptionalplayback_25_count?: numberOptionalplayback_50_count?: numberOptionalplayback_75_count?: numberOptionalprofile_activity?: numberOptionalprofile_visits?: numberOptionalquotes?: numberOptionalreach?: numberOptionalreactions?: numberOptionalreactions_by_emoji?: { [key: string]: number }Optionalreplys?: numberOptionalretwets?: numberOptionalsaved?: numberOptionalsaves?: numberOptionalshare?: numberOptionalshareMentions?: numberOptionalshares?: numberOptionalsorrys?: numberOptionalurl_link_clicks?: numberOptionaluser_profile_clicks?: numberOptionalvideo_views?: numberOptionalviews?: numberOptionalwows?: numberOptionalapplication_name?: stringOptionalclient_id?: stringOptionalhas_bot_token?: booleanOptionalhas_client_secret?: booleanOptionalverified_date?: stringFormat: date-time
Optionalexternal_identifier?: stringOptionallast_send_date?: stringFormat: date-time
Optionalai_generated?: { generated_at: string; id_ai_plan?: string; model: string; provider: string }Format: date-time
Optionalid_ai_plan?: stringOptionalcover_image?: { _id: string; ai_generated?: { generated_at: string; id_ai_plan?: string; model: string; provider: string; }; cover_image?: ...; cover_offset?: number; creation_date: string; file_externals: { external_identifier: string; external_url: string; social_network: "facebook" | ... 12 more ... | "pinterest"; }[]; ... 6 m...Optionalcover_offset?: numberFormat: date-time
Format: uri
Optionalai_generated?: { generated_at: string; id_ai_plan?: string; model: string; provider: string }Format: date-time
Optionalid_ai_plan?: stringOptionalcover_image?: { _id: string; ai_generated?: { generated_at: string; id_ai_plan?: string; model: string; provider: string; }; cover_image?: ...; cover_offset?: number; creation_date: string; file_externals: { external_identifier: string; external_url: string; social_network: "facebook" | ... 12 more ... | "pinterest"; }[]; ... 6 m...Optionalcover_offset?: numberFormat: date-time
Format: uri
Description
Start of the range, ISO 8601. Defaults to 30 days before
to_date. A range longer than 366 days answers error 1003.