---
title: "A change happened (notification to your app)"
method: POST
path: "comments"
tags: ["webhooks"]
---

# A change happened (notification to your app)

`POST comments` (webhook)

PlanVortex `POST`s to the `webhook_url` of your client app when something happens. **The body is an array of changes**, and each one carries `field` telling you what it is — switch on it and ignore what you do not handle, because the list grows with the product.

Three shapes travel in that array. A change on an **account** (`WebhookChange`) always carries `id_account`, `id_organization` and `social_network`. A change on an **integration** (`IntegrationWebhookChange`) and the end of an **AI plan** (`AiPlanWebhookChange`) carry none of those, because they hang off the organization and not off any account.

The events delivered today are `new_account`, `change_state_account`, `messages`, `messaging_postbacks`, `messaging_seen`, `messaging_error`, `comments`, `integration_error`, `ai_plan_generated` and `ai_plan_failed`.

### Comments

`field: "comments"` is the one for this section, and the comment itself travels in **`commentObj`** — a property of its own, never in `messageObj`. That is deliberate: a comment is not a message, it has no contact and it hangs off a publication. A consumer that only understands messages sees `commentObj` as absent and ignores the change, which is exactly what should happen.

**Which networks arrive this way:** Facebook and Instagram, because Meta pushes them. YouTube, LinkedIn and Google Business have no comment webhook, so those are polled by a background job and appear in the inbox within a few hours rather than instantly. X is polled too, and only when the organization turns it on, because reading replies there is billed per unit.

**Meta repeats deliveries.** The same comment can arrive more than once; the backend is idempotent about it and so should you be — deduplicate on `commentObj.external_id`.

### AI plans

`ai_plan_generated` and `ai_plan_failed` say that a plan has **finished**: it was generated, or it gave up. Instead of polling the plan every few seconds, wait for one of these and then read it once with `GET /clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}`. The change carries ids and numbers only, never the prompt or the generated text, so the publications are in that `GET`.

A plan that hits a transient error goes back to the queue and is retried. That is not an end and sends nothing, so each plan sends **one** of these two events, never a `failed` followed by a `generated`. A plan generated with notices (`warnings` above zero) is still `ai_plan_generated`: it is usable, and the notices are in the plan.

The event goes to **every** app of the client that has a `webhook_url`, not only to the one that created the plan. And it is not retried: if your endpoint was down when the plan finished, the `GET` still tells you how it ended, so on startup read the plans you were waiting for.

### Verifying the signature

Every delivery carries the body signed with your app's secret, twice:

- `x-hub-signature`: `sha1=<hmac>`
- `x-hub-signature-256`: `sha256=<hmac>`

Compute the HMAC over the **raw** request body — not over a re-serialized copy of the parsed JSON — and compare in constant time. Prefer the sha256 header.

Delivery is best effort: PlanVortex does not retry a webhook that fails.

## Payload

- union[]
  - union
    - CommentsWebhookChange: 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.
      - `field` 'new_account' | 'change_state_account' | 'messages' | 'messaging_postbacks' | 'messaging_seen' | 'messaging_error' | 'comments', required: What 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`.
      - `id_account` string, required
      - `id_organization` string, required
      - `social_network` 'facebook' | 'instagram' | 'linkedin' | 'tiktok' | 'twitter' | 'whatsapp' | 'youtube' | 'google_business' | 'bluesky' | 'discord' | 'telegram' | 'threads' | 'slack' | 'pinterest', required: A social network supported by PlanVortex. **This list grows.** Treat it as an open enumeration: a client that rejects an unknown value breaks the day a network is added, which happens several times a year. Not every network does everything — ask `GET /social_capabilities`.
      - `commentObj` CommentsComment: A comment — or a review — as PlanVortex stores it. Remember it is a **snapshot** of what the network said at `collected_date`; the live thread endpoints return the same shape reconciled against the network.
        - `_id` string, required
        - `id_account` union, required: 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.
          - string
          - Account: 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).
            - `_id` string, required
            - `id_organization` string, required
            - `id_client` string, required: The client the organization hangs from. Denormalized here for the plan checks.
            - `name` string, required: Display name of the profile, page or channel.
            - `username` string: The 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`.
            - `social_network` 'facebook' | 'instagram' | 'linkedin' | 'tiktok' | 'twitter' | 'whatsapp' | 'youtube' | 'google_business' | 'bluesky' | 'discord' | 'telegram' | 'threads' | 'slack' | 'pinterest', required: A social network supported by PlanVortex. **This list grows.** Treat it as an open enumeration: a client that rejects an unknown value breaks the day a network is added, which happens several times a year. Not every network does everything — ask `GET /social_capabilities`.
            - `creation_date` string, date-time, required
            - `error_code` integer, required: `0` is a healthy account. Anything else is a PlanVortex error code explaining why the connection stopped working; the account keeps its data but cannot be used until it is reconnected.
            - `image` string: Avatar URL as the network publishes it. Empty string when there is none.
            - `followers_count` integer: Followers 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.
            - `next_stats_update` string, date-time: When the collector will ask the network for this account's stats again.
            - `next_comments_update` string, date-time: When the collector will read this account's comments again. Only on the networks whose comments hang off the account and not off a publication — today, `google_business`, whose reviews belong to the listing.
            - `private_message_link` string: A link that opens a private chat with this account (`m.me`, `ig.me`, `wa.me`). Absent on every other network.
            - `extra_data` object: What only makes sense on this account's network. Only the keys documented here are part of the contract: anything else in the object can change without notice.
              - …
        - `id_organization` string, required
        - `id_publication` union: **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.
          - string
          - Publication
            - `_id` string, required
            - `id_organization` string, required
            - `id_account` union, required: The account the publication goes out through. **It is not always the same shape.** The single-publication operations (create, read, retry) return the account already resolved; the listing and the update return its identifier as a string. Check before using it.
              - …
            - `social_network` 'facebook' | 'instagram' | 'linkedin' | 'tiktok' | 'twitter' | 'whatsapp' | 'youtube' | 'google_business' | 'bluesky' | 'discord' | 'telegram' | 'threads' | 'slack' | 'pinterest', required: A social network supported by PlanVortex. **This list grows.** Treat it as an open enumeration: a client that rejects an unknown value breaks the day a network is added, which happens several times a year. Not every network does everything — ask `GET /social_capabilities`.
            - `name` string: Internal name. Never shown on the social network.
            - `text` string
            - `title` string: Only the networks that have a title field use it.
            - `destination` PublicationDestination: **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`.
              - …
            - `link` string: **The 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`.
            - `reply_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`.
            - `visibility` 'public' | 'unlisted' | 'friends' | 'followers' | 'private': **Who can see the publication** when it goes out, on the networks that answer `visibility: true` in `GET /social_capabilities` (today `youtube` and `tiktok`). Absent means `public`. It is set when the publication goes out. `unlisted` exists only on YouTube; `friends` and `followers` only on TikTok. **On every other network the field is deleted on save**, like `reply_control`.
            - `made_for_kids` boolean: Whether the video is **made for kids** (COPPA), on the networks that answer `made_for_kids: true` in `GET /social_capabilities` (today `youtube`). Absent means `false`. **On every other network the field is deleted on save**, like `reply_control`.
            - `interactions` PublicationInteractions: What the audience can do with a publication. `true` allows it; a missing key is the network's default.
              - …
            - `commercial_content` PublicationCommercialContent: Whether a publication is commercial content. Both `false` is the same as omitting it.
              - …
            - `publication_type` 'profile' | 'page' | 'group' | 'reels' | 'stories' | 'message', required
            - `state` 'ready' | 'withErrors' | 'sended' | 'draft' | 'publishing', required: `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.
            - `pending_publish` PublicationPending: Present 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.
              - …
            - `files` Upload[], required: The attached files, **already resolved**: every read and write path returns full uploads, not identifiers. Identifiers are what you SEND (see `PublicationInput.files`).
              - …
            - `publish_date` string, date-time
            - `creation_date` string, date-time, required
            - `publication_errors` object[], required: 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.
              - …
            - `publication_warnings` object[]: 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.
              - …
            - `retries` integer, required: Manual retries already spent on a failed publication, against the `max_retries` published by `GET /publication_limits`. Only `POST .../retry` increases it; updating the publication resets it to 0.
            - `extra_data` object: 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.
            - `external_identifier` string: The network's own identifier, once published. On `telegram` an album is one publication that is **several messages**, and this holds the first one; the rest travel in `extra_data.telegram_message_ids`.
            - `url` string: Link to the publication on the network, when there is one. A **private** Telegram channel has no public URL, so it comes back empty even though the post went out.
            - `statistics` PublicationStats: Raw, 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.
              - …
            - `metrics` NormalizedMetrics: Metrics 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.
              - …
            - `engagement_base` 'reach' | 'impressions' | 'followers': What this publication's engagement rate is divided by.
            - `stats_updated_date` string, date-time
            - `next_stats_update` string, date-time
            - `id_integration` string: The integration this publication came from — an RSS feed, for instance. Set when it is created and never changed afterwards.
            - `ai_generated` PublicationAiGenerated: Which part of a publication was generated by AI. **Absent means nothing was.** Use it to show your own AI label: under article 50(4) of the EU AI Act, telling the audience is the job of whoever publishes.
              - …
        - `publication_external_id` string, required: What the comment hangs off, on the network: the post/video id in most networks, the **listing** (`locations/{id}`) for a Google Business review, and the published message's id on Telegram — where the thread that holds the comments *is* the forwarded post.
        - `external_id` string, required: 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.
        - `parent_external_id` string: Present only when this is a reply to another comment
        - `social_network` 'facebook' | 'instagram' | 'twitter' | 'linkedin' | 'youtube' | 'google_business' | 'bluesky' | 'discord' | 'telegram' | 'threads', required: A network that has comments. Treat it as an open list: a new one is added before your integration hears about it.
        - `author` CommentsCommentAuthor, required: Who wrote it. Embedded in the comment and **not** a contact: a YouTube commenter has no private inbox you could ever write to, so they get no contact record.
          - `external_id` string, required: The author's id on the network. When the network gives none — Google Business publishes no identifier for a reviewer — this falls back to the review's own id, which means two reviews by the same person look like two different authors. There is no way around it from the API.
          - `name` string: Display name. Always present for a review, including anonymous ones, which get a placeholder rather than an empty string.
          - `profile_pic` string
          - `is_own` boolean, required: Whether the connected account wrote it. This is what separates "delete mine" from "delete theirs", and what keeps your own replies out of the inbox.
        - `text` string, required: **May legitimately be empty.** A stars-only review carries no text at all, so render `rating` alongside it and never assume a blank comment is a loading failure.
        - `rating` integer: Star rating of a **review**. Present only on review networks — today Google Business. Its absence means "this network has no such thing", never zero. It is not decorative: without it a one-star review and a five-star one are the same row, and a stars-only review is a blank line.
        - `creation_date` string, date-time, required: When it was written **on the network**. This is what orders the inbox.
        - `collected_date` string, date-time, required: When PlanVortex last read it. The inbox is a photograph and this says how old it is.
        - `read` boolean, required: Yours, not the network's. A live read never overwrites it.
        - `replied` boolean, required: Yours, not the network's. Set when you reply through the API.
        - `hidden` boolean, required: Hidden on the network. A hidden comment is never swept as deleted: on YouTube, hiding one makes the API stop returning it forever, and without that exception hiding would be indistinguishable from deleting.
        - `deleted` boolean, required: Gone from the network. The row is kept so it is not created again by the next read or by a repeated webhook; it stops appearing in the inbox.
        - `like_count` integer: Absent when the network does not publish it — not zero
        - `reply_count` integer: Absent when the network does not publish it — not zero
        - `our_reply_external_id` string: The network id of your reply, so you can find it or delete it later
      - `messageObj` Message: A 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.
        - `_id` string, required
        - `id_account` string, required
        - `contact_id` union: Set when **we** wrote to the contact. Exactly one of `contact_id` and `from_contact_id` is present, and which one tells you the direction of the message.
          - string
          - Contact: A person the organization exchanges messages with. The same contact can be reachable on several channels — that is what `social_identifiers` is.
            - `_id` string, required
            - `id_organization` string, required
            - `name` string
            - `profile_image` string: URL of the contact's avatar on the network. It belongs to the network and it can stop working.
            - `social_identifiers` SocialIdentifier[], required
              - …
            - `extra_data` ContactExtraData: Your own fields on the contact. The address block is known to PlanVortex; the six generic properties are yours to use, and they are what `extra_data` filters on.
              - …
            - `creator_keycloak_identifier` string: The user who created the contact, when it was created from the panel instead of arriving from a network.
            - `creation_date` string, date-time, required
            - `last_contact_update` string, date-time, required: Last time the contact's profile was refreshed from the network.
        - `from_contact_id` union: Set when the **contact** wrote to us.
          - string
          - Contact: A person the organization exchanges messages with. The same contact can be reachable on several channels — that is what `social_identifiers` is.
            - `_id` string, required
            - `id_organization` string, required
            - `name` string
            - `profile_image` string: URL of the contact's avatar on the network. It belongs to the network and it can stop working.
            - `social_identifiers` SocialIdentifier[], required
              - …
            - `extra_data` ContactExtraData: Your own fields on the contact. The address block is known to PlanVortex; the six generic properties are yours to use, and they are what `extra_data` filters on.
              - …
            - `creator_keycloak_identifier` string: The user who created the contact, when it was created from the panel instead of arriving from a network.
            - `creation_date` string, date-time, required
            - `last_contact_update` string, date-time, required: Last time the contact's profile was refreshed from the network.
        - `read` boolean, required
        - `text` string: Absent on the messages that carry no text of their own, such as a `messaging_seen` acknowledgement.
        - `message_type` 'simple_message' | 'file_message' | 'comment_message' | 'publication_message' | 'quick_reply_message' | 'button_message' | 'elements_message' | 'postback_message' | 'template_message' | 'interactive_message', required: What kind of message this is. `simple_message` and `file_message` work everywhere; the rest are network-specific shapes.
        - `message_options` MessageOptions, required: Everything a message can carry besides its text. Which block is required depends on `message_type`.
          - `files` union[], required: The uploads attached to the message. **Populated** wherever the message itself is: the messages list and webhook deliveries carry whole uploads, everything else carries their identifiers. Handle both.
            - union
              - …
          - `files_urls` object[], required: Files already hosted somewhere else. Filled in by PlanVortex when a message arrives from the network.
            - `url` string
            - `mime_type` string
          - `template_name` string: Required for `template_message`.
          - `template_language` string: Required for `template_message`, as the network's language code.
          - `template_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.
          - `payload` string: Payload of a Meta postback.
          - `metaElements` object[]: Meta cards, for `elements_message` and `button_message`.
          - `metaQuickReplies` object[]: Meta quick replies, for `quick_reply_message`.
          - `whatsappInteractive` object: WhatsApp interactive list, for `interactive_message`. It needs at least one section.
        - `message_errors` Error[], required: Why the network refused this message, if it did. Same shape as an API error. Empty when nothing went wrong.
          - `message` string, required: Human readable description of the error.
          - `code` integer, required: 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 · 2500-2599 publication fields, the ones that no longer fit in 900-996 (visibility, made for kids, TikTok's interactions and commercial content) · 2600-2699 comments, the ones that no longer fit in 945-948.
          - `data` object: Extra context attached to the error, when there is any.
        - `in_response_to` string: Identifier of the PlanVortex message this one answers.
        - `in_response_external_id` string: Identifier on the network of the publication, comment or message this one answers.
        - `element_external_id` string: Identifier of this message on the network.
        - `creation_date` string, date-time, required
      - `id_contact` string: Only for messaging fields. A comment has no contact: its author is not someone you can write to.
      - `originalChange` object: The raw payload the social network sent, passed through untouched. Absent on the changes PlanVortex raises itself, such as `new_account`.
    - CommentsIntegrationWebhookChange: One 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.
      - `field` 'integration_error', required
      - `id_integration` string, required
      - `id_organization` string, required
      - `provider` string, required: `google_drive` or `rss` today. **This list grows**: treat it as an open enumeration.
      - `error_code` integer, required: PlanVortex error code saying what went wrong. Integration codes live in the 2200-2299 range.
    - CommentsAiPlanWebhookChange: One 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}`.
      - `field` 'ai_plan_generated' | 'ai_plan_failed', required
      - `id_ai_plan` string, required
      - `id_organization` string, required
      - `state` 'generated' | 'failed', required: The state the plan finished in. It always matches `field`: `generated` with `ai_plan_generated`, `failed` with `ai_plan_failed`.
      - `template` 'standard' | 'from_images' | 'from_text' | 'from_catalog' | 'campaign', required: What the plan was generated from, the same value as `AiPlan.template`.
      - `total_publications` integer, required: How many draft publications the plan holds. A failed plan can still hold the drafts of its failed attempt; retrying it discards them.
      - `credits_spent` integer, required: AI credits the generation consumed, the same figure as `AiPlan.credits_spent`. A failed plan can have spent credits too, and they are not refunded.
      - `warnings` integer, required: **How many** non-blocking notices the plan has, not the notices themselves: those are in `AiPlan.warnings`. Above zero the plan is still generated and usable.
      - `error` CommentsAiPlanWebhookError: Why an AI plan failed, inside `AiPlanWebhookChange.error`: the same `code` and `message` as `AiPlan.error`. Its `data` is left out, because it can carry what the AI provider answered; read the plan for it.
        - `code` integer, required
        - `message` string, required

## Acknowledgement `200`

Your endpoint acknowledged the delivery. PlanVortex does not retry: a delivery that fails is logged and dropped, so treat the inbox endpoints as the way to catch up on anything you missed.

---

[API](https://skmtc.dev/planvortex/apis/planvortex-api.md) · [All operations](https://skmtc.dev/planvortex/apis/planvortex-api/llms.txt) · [OpenAPI document](https://skmtc.dev/planvortex/apis/planvortex-api/revisions/abedbbc1cd52?raw)
