planvortex 0.12.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {planvortex-0.12.0 → planvortex-0.13.0}/CHANGELOG.md +29 -0
  2. {planvortex-0.12.0 → planvortex-0.13.0}/PKG-INFO +8 -2
  3. {planvortex-0.12.0 → planvortex-0.13.0}/README.md +7 -1
  4. {planvortex-0.12.0 → planvortex-0.13.0}/openapi/planvortex.openapi.json +132 -14
  5. {planvortex-0.12.0 → planvortex-0.13.0}/pyproject.toml +1 -1
  6. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_generated/models.py +81 -4
  7. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_version.py +1 -1
  8. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/ai_plans.py +10 -6
  9. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/ai_plans.py +6 -4
  10. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/types.py +13 -0
  11. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/webhooks.py +61 -2
  12. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_example_webhooks.py +18 -5
  13. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_types.py +18 -0
  14. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_webhooks.py +90 -4
  15. {planvortex-0.12.0 → planvortex-0.13.0}/.gitignore +0 -0
  16. {planvortex-0.12.0 → planvortex-0.13.0}/LICENSE +0 -0
  17. {planvortex-0.12.0 → planvortex-0.13.0}/scripts/build_docs.py +0 -0
  18. {planvortex-0.12.0 → planvortex-0.13.0}/scripts/check_packaging.py +0 -0
  19. {planvortex-0.12.0 → planvortex-0.13.0}/scripts/generate_models.py +0 -0
  20. {planvortex-0.12.0 → planvortex-0.13.0}/scripts/generate_sync.py +0 -0
  21. {planvortex-0.12.0 → planvortex-0.13.0}/scripts/route_coverage.py +0 -0
  22. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/__init__.py +0 -0
  23. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_client.py +0 -0
  24. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_client_sync.py +0 -0
  25. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_core/__init__.py +0 -0
  26. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_core/auth.py +0 -0
  27. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_core/auth_sync.py +0 -0
  28. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_core/errors.py +0 -0
  29. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_core/files.py +0 -0
  30. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_core/http.py +0 -0
  31. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_core/http_sync.py +0 -0
  32. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_core/pagination.py +0 -0
  33. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_core/query.py +0 -0
  34. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_core/transport.py +0 -0
  35. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_generated/__init__.py +0 -0
  36. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/_shapes.py +0 -0
  37. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/py.typed +0 -0
  38. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/__init__.py +0 -0
  39. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/accounts.py +0 -0
  40. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/apps.py +0 -0
  41. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/base.py +0 -0
  42. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/catalog.py +0 -0
  43. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/clients.py +0 -0
  44. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/comments.py +0 -0
  45. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/contacts.py +0 -0
  46. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/dashboard.py +0 -0
  47. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/integrations.py +0 -0
  48. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/messages.py +0 -0
  49. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/organizations.py +0 -0
  50. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/products.py +0 -0
  51. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/publications.py +0 -0
  52. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources/uploads.py +0 -0
  53. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/__init__.py +0 -0
  54. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/accounts.py +0 -0
  55. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/apps.py +0 -0
  56. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/base.py +0 -0
  57. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/catalog.py +0 -0
  58. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/clients.py +0 -0
  59. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/comments.py +0 -0
  60. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/contacts.py +0 -0
  61. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/dashboard.py +0 -0
  62. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/integrations.py +0 -0
  63. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/messages.py +0 -0
  64. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/organizations.py +0 -0
  65. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/products.py +0 -0
  66. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/publications.py +0 -0
  67. {planvortex-0.12.0 → planvortex-0.13.0}/src/planvortex/resources_sync/uploads.py +0 -0
  68. {planvortex-0.12.0 → planvortex-0.13.0}/tests/__init__.py +0 -0
  69. {planvortex-0.12.0 → planvortex-0.13.0}/tests/conftest.py +0 -0
  70. {planvortex-0.12.0 → planvortex-0.13.0}/tests/contrato.py +0 -0
  71. {planvortex-0.12.0 → planvortex-0.13.0}/tests/live/__init__.py +0 -0
  72. {planvortex-0.12.0 → planvortex-0.13.0}/tests/live/conftest.py +0 -0
  73. {planvortex-0.12.0 → planvortex-0.13.0}/tests/live/test_auth.py +0 -0
  74. {planvortex-0.12.0 → planvortex-0.13.0}/tests/live/test_catalog.py +0 -0
  75. {planvortex-0.12.0 → planvortex-0.13.0}/tests/live/test_connect_flow.py +0 -0
  76. {planvortex-0.12.0 → planvortex-0.13.0}/tests/live/test_errors.py +0 -0
  77. {planvortex-0.12.0 → planvortex-0.13.0}/tests/live/test_publish.py +0 -0
  78. {planvortex-0.12.0 → planvortex-0.13.0}/tests/live/test_read.py +0 -0
  79. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_accounts.py +0 -0
  80. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_ai_plans.py +0 -0
  81. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_apps.py +0 -0
  82. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_catalog.py +0 -0
  83. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_client.py +0 -0
  84. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_clients.py +0 -0
  85. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_comments.py +0 -0
  86. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_contacts.py +0 -0
  87. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_core.py +0 -0
  88. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_dashboard.py +0 -0
  89. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_example_comments.py +0 -0
  90. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_example_connect.py +0 -0
  91. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_example_publish.py +0 -0
  92. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_example_schedule.py +0 -0
  93. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_files.py +0 -0
  94. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_generate_models.py +0 -0
  95. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_generate_sync.py +0 -0
  96. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_integrations.py +0 -0
  97. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_messages.py +0 -0
  98. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_models.py +0 -0
  99. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_openapi_freshness.py +0 -0
  100. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_organizations.py +0 -0
  101. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_packaging.py +0 -0
  102. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_pagination.py +0 -0
  103. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_products.py +0 -0
  104. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_publications.py +0 -0
  105. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_query_parity.py +0 -0
  106. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_readme.py +0 -0
  107. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_route_coverage.py +0 -0
  108. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_shapes_parity.py +0 -0
  109. {planvortex-0.12.0 → planvortex-0.13.0}/tests/test_uploads.py +0 -0
@@ -4,6 +4,35 @@ All notable changes to this package are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.13.0] - 2026-10-03
8
+
9
+ ### Added
10
+
11
+ - **Two webhook events for AI plans: `ai_plan_generated` and `ai_plan_failed`**, typed as
12
+ `AiPlanChange` (with `AiPlanFailure` for its `error`) and narrowed with `is_ai_plan_change` in
13
+ `planvortex.webhooks`. When a plan finishes, your app's `webhook_url` now hears about it, so you
14
+ no longer have to poll `pv.ai_plans.get` every few seconds. The change carries ids and numbers
15
+ only (`id_ai_plan`, `state`, `template`, `total_publications`, `credits_spent`, how many
16
+ `warnings`, and `error` with its `code` and `message` on a failure), never the prompt or the
17
+ generated text: read the plan once when it arrives. Each plan sends one of the two, never a
18
+ `failed` followed by a `generated`. Deliveries are not retried, so keep polling as the fallback
19
+ if your endpoint can be down.
20
+
21
+ - **`pending_publish` on `Publication`**, with the `PublicationPending` type to name it. An
22
+ Instagram video that Meta takes more than ~30 seconds to process now comes back in state
23
+ `publishing` with this object, and PlanVortex keeps asking Instagram in the background for up to
24
+ 10 minutes. **Wait and read the publication again after `next_check`; do not retry it or create
25
+ it again**, or the video goes out twice. While it is present the publication cannot be updated
26
+ (error 921). It is read-only: nothing new to send.
27
+
28
+ ### Changed
29
+
30
+ - The reference no longer says that an X publication left without credits (940) fires your app's
31
+ webhook. It never did: no webhook announces publications, so read the publication to find out.
32
+ - `publication_errors` documents two new Instagram codes: **998** (Meta rejected the file, so
33
+ retrying it unchanged fails the same way) and **999** (Meta ran out of time, and a retry usually
34
+ works).
35
+
7
36
  ## [0.12.0] - 2026-10-02
8
37
 
9
38
  **Your app can show its own account picker.** Until now the person connecting a social account
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: planvortex
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Official Python client for the PlanVortex API
5
5
  Project-URL: Homepage, https://planvortex.com/en/developers
6
6
  Project-URL: Documentation, https://taliasoftworks.github.io/PlanVortexPython/
@@ -369,13 +369,19 @@ would rather do it in two steps, `verify_webhook_signature(payload, signature, s
369
369
  plain `True`/`False` and `parse_webhook_body(payload)` gives you the changes.
370
370
 
371
371
  Narrow with the predicates — `is_account_state_change`, `is_message_change`, `is_comment_change`,
372
- `is_integration_error_change` — and let anything else fall through: **the event list grows**, and a
372
+ `is_integration_error_change`, `is_ai_plan_change` — and let anything else fall through: **the event list grows**, and a
373
373
  `field` this release has never heard of is not an error.
374
374
 
375
375
  **PlanVortex does not retry a failed delivery.** A 500 of yours loses the event, so if your work is
376
376
  slow, queue it and answer — and use `pv.comments.list` / `pv.messages.list` to catch up on anything
377
377
  you missed.
378
378
 
379
+ **An AI plan tells you when it is done.** `ai_plan_generated` and `ai_plan_failed` arrive when a plan
380
+ finishes, so you do not have to poll `pv.ai_plans.get` every few seconds: read the plan once when
381
+ one of them comes in. They carry ids and numbers only (`id_ai_plan`, `state`, how many `warnings`,
382
+ and `error` on a failure), never the prompt or the generated text. If your endpoint was down,
383
+ `pv.ai_plans.get` still tells you how the plan ended.
384
+
379
385
  ## The whole API, in fourteen resources
380
386
 
381
387
  `pv.catalog` · `pv.clients` · `pv.organizations` · `pv.accounts` · `pv.uploads` · `pv.publications`
@@ -339,13 +339,19 @@ would rather do it in two steps, `verify_webhook_signature(payload, signature, s
339
339
  plain `True`/`False` and `parse_webhook_body(payload)` gives you the changes.
340
340
 
341
341
  Narrow with the predicates — `is_account_state_change`, `is_message_change`, `is_comment_change`,
342
- `is_integration_error_change` — and let anything else fall through: **the event list grows**, and a
342
+ `is_integration_error_change`, `is_ai_plan_change` — and let anything else fall through: **the event list grows**, and a
343
343
  `field` this release has never heard of is not an error.
344
344
 
345
345
  **PlanVortex does not retry a failed delivery.** A 500 of yours loses the event, so if your work is
346
346
  slow, queue it and answer — and use `pv.comments.list` / `pv.messages.list` to catch up on anything
347
347
  you missed.
348
348
 
349
+ **An AI plan tells you when it is done.** `ai_plan_generated` and `ai_plan_failed` arrive when a plan
350
+ finishes, so you do not have to poll `pv.ai_plans.get` every few seconds: read the plan once when
351
+ one of them comes in. They carry ids and numbers only (`id_ai_plan`, `state`, how many `warnings`,
352
+ and `error` on a failure), never the prompt or the generated text. If your endpoint was down,
353
+ `pv.ai_plans.get` still tells you how the plan ended.
354
+
349
355
  ## The whole API, in fourteen resources
350
356
 
351
357
  `pv.catalog` · `pv.clients` · `pv.organizations` · `pv.accounts` · `pv.uploads` · `pv.publications`
@@ -1359,7 +1359,7 @@
1359
1359
  "ai_plans"
1360
1360
  ],
1361
1361
  "summary": "Create an AI publication plan",
1362
- "description": "Create a weekly AI publication plan. The plan is queued in state 'pending' and generated asynchronously by the generate-ai-plans job; poll GET by id while state is pending or generating. Validations at creation: the client plan allows AI (it contracts AI credits; Free does not), the accounts belong to the organization, there are enough AI credits for the deterministic base cost (orchestration + target texts), and there is room in the organization's monthly publication limit. Returns the created plan together with the deterministic cost estimate. Requires the ai_plans:create permission. Since the plan can be generated from a SOURCE (`template` + `source`), part of that validation is the source itself: the article is downloaded, the catalogue is read live and the product pictures are copied — all of it inside this request, so what does not work fails with the user in front of it. A plan whose source did not fit in the week is still created, and says so in ai_plan.warnings (2117). With a Pinterest account in the plan, two more checks run at creation: every Pinterest account needs its board in `destinations` (2118), and a configuration that would leave pins without an image is refused (2119).",
1362
+ "description": "Create a weekly AI publication plan. The plan is queued in state 'pending' and generated asynchronously by the generate-ai-plans job. When it finishes, your app's `webhook_url` receives `ai_plan_generated` or `ai_plan_failed` (the payload is in the comments specification, `AiPlanWebhookChange`); without a webhook, poll GET by id while state is pending or generating. The webhook is best effort and not retried, so the GET is still the source of truth. Validations at creation: the client plan allows AI (it contracts AI credits; Free does not), the accounts belong to the organization, there are enough AI credits for the deterministic base cost (orchestration + target texts), and there is room in the organization's monthly publication limit. Returns the created plan together with the deterministic cost estimate. Requires the ai_plans:create permission. Since the plan can be generated from a SOURCE (`template` + `source`), part of that validation is the source itself: the article is downloaded, the catalogue is read live and the product pictures are copied — all of it inside this request, so what does not work fails with the user in front of it. A plan whose source did not fit in the week is still created, and says so in ai_plan.warnings (2117). With a Pinterest account in the plan, two more checks run at creation: every Pinterest account needs its board in `destinations` (2118), and a configuration that would leave pins without an image is refused (2119).",
1363
1363
  "operationId": "addAiPlan",
1364
1364
  "x-planvortex-identity": [
1365
1365
  "current_user",
@@ -1674,7 +1674,7 @@
1674
1674
  "ai_plans"
1675
1675
  ],
1676
1676
  "summary": "Get an AI publication plan by id",
1677
- "description": "Return a single AI plan with its generated publications populated. This is the endpoint clients poll while state is pending or generating to detect when generation finishes. Requires the ai_plans:read permission.",
1677
+ "description": "Return a single AI plan with its generated publications populated. Read it when your app receives `ai_plan_generated` or `ai_plan_failed`. Without a webhook, this is the endpoint to poll while state is pending or generating. The webhook is not retried, so if your endpoint was down when the plan finished, this GET still tells you how it ended. Requires the ai_plans:read permission.",
1678
1678
  "operationId": "getAiPlan",
1679
1679
  "x-planvortex-identity": [
1680
1680
  "current_user",
@@ -1873,7 +1873,7 @@
1873
1873
  "ai_plans"
1874
1874
  ],
1875
1875
  "summary": "Retry a failed AI publication plan",
1876
- "description": "Re-queue a failed plan: it goes back to state 'pending' (attempts and error reset) and the background job regenerates it with the SAME data (prompt, accounts, options). Poll the plan while state is pending or generating, as after creation. Only valid from state 'failed'. Already-spent credits are NOT refunded and a new generation spends again; drafts left over from the failed attempt are discarded by the generation itself. If the failure was a business error (no AI credits, no monthly publication slots), retrying will fail the same way. Requires the ai_plans:update permission.",
1876
+ "description": "Re-queue a failed plan: it goes back to state 'pending' (attempts and error reset) and the background job regenerates it with the SAME data (prompt, accounts, options). As after creation, the end arrives on your webhook as `ai_plan_generated` or `ai_plan_failed`; without one, poll the plan while state is pending or generating. Only valid from state 'failed'. Already-spent credits are NOT refunded and a new generation spends again; drafts left over from the failed attempt are discarded by the generation itself. If the failure was a business error (no AI credits, no monthly publication slots), retrying will fail the same way. Requires the ai_plans:update permission.",
1877
1877
  "operationId": "retryAiPlan",
1878
1878
  "x-planvortex-identity": [
1879
1879
  "current_user",
@@ -10503,7 +10503,7 @@
10503
10503
  "publications"
10504
10504
  ],
10505
10505
  "summary": "Create new publications",
10506
- "description": "Create new publications. For X (Twitter) accounts, paid actions consume the client's monthly X credit pool (shared across organizations). When a publication is sent immediately, the required credits are checked before publishing and only charged on success. A tweet costs 15 credits, or 200 credits if its text contains a link. Link detection uses the same rule the backend applies: an http(s):// URL, a www. host, or a bare domain with a recognized TLD (regex TWITTER_LINK_REGEX). A scheduled publication does not consume credits when created; it is charged when the background job publishes it. If there are not enough credits at publish time the scheduled publication is left in state 'withErrors' with publication_errors.code = 940 and the client-app webhook is fired (it is never thrown). Publishing immediately without enough credits returns error 940.",
10506
+ "description": "Create new publications. For X (Twitter) accounts, paid actions consume the client's monthly X credit pool (shared across organizations). When a publication is sent immediately, the required credits are checked before publishing and only charged on success. A tweet costs 15 credits, or 200 credits if its text contains a link. Link detection uses the same rule the backend applies: an http(s):// URL, a www. host, or a bare domain with a recognized TLD (regex TWITTER_LINK_REGEX). A scheduled publication does not consume credits when created; it is charged when the background job publishes it. If there are not enough credits at publish time the scheduled publication is left in state 'withErrors' with publication_errors.code = 940 (it is never thrown, and no webhook announces it: read the publication to find out). Publishing immediately without enough credits returns error 940.\n\n**An Instagram video can come back in state `publishing`, and that is not an error.** Instagram processes a video before it can be published, and the request waits about 30 seconds for it. If Meta has not finished by then, the response carries `state: \"publishing\"` and a `pending_publish` object, and PlanVortex keeps asking Instagram about once a minute in the background, for up to 10 minutes. Read the publication again (`GET /organizations/{id_organization}/publish/{id_publication}`) after `pending_publish.next_check` until it is `sended` or `withErrors`. **Do not retry it or create it again**: the first one is still on its way, and a second one would publish the video twice.",
10507
10507
  "operationId": "addPublication",
10508
10508
  "x-planvortex-identity": [
10509
10509
  "current_user",
@@ -10942,7 +10942,7 @@
10942
10942
  }
10943
10943
  },
10944
10944
  "400": {
10945
- "description": "The request failed. The body carries the PlanVortex error code in `code`:\n\n| Code | Meaning |\n| --- | --- |\n| `1101` | Invalid organization |\n| `917` | Publication doesn't exists |\n| `935` | Invalid publication |\n| `921` | Can't update a publication already sended |\n| `940` | X (Twitter) credits exhausted. Not enough monthly credits to publish this tweet (same link detection and 15/200 tariff as on create). Response data: { used, limit }. |\n| `523` | Invalid application |",
10945
+ "description": "The request failed. The body carries the PlanVortex error code in `code`:\n\n| Code | Meaning |\n| --- | --- |\n| `1101` | Invalid organization |\n| `917` | Publication doesn't exists |\n| `935` | Invalid publication |\n| `921` | Can't update a publication already sended, **or one the network is still processing** (state `publishing` with `pending_publish`; then `data.state` is `publishing`). Editing it would publish it twice. It can be edited again if it fails. |\n| `940` | X (Twitter) credits exhausted. Not enough monthly credits to publish this tweet (same link detection and 15/200 tariff as on create). Response data: { used, limit }. |\n| `523` | Invalid application |",
10946
10946
  "content": {
10947
10947
  "application/json": {
10948
10948
  "schema": {
@@ -10978,7 +10978,7 @@
10978
10978
  "publications"
10979
10979
  ],
10980
10980
  "summary": "Delete publication by identifier",
10981
- "description": "Delete publication by identifier. It stops being readable by identifier afterwards, so deleting twice answers error 917. For an already-sent X (Twitter) publication, removing the tweet on X is a paid action that consumes 15 X credits; it is only removed on X when there are enough credits. Returns error 940 when the X credit pool is exhausted.\n\n**On Telegram there is a 48-hour window.** Past it the Bot API refuses to delete a message whatever the bot's role is, and the answer is error 966 with `published_date` and `max_hours` in `data` — so the sensible thing is to grey the button out rather than offer it and fail. Error 969 is the other case: the bot is no longer allowed to delete there. And an album is several messages: all of them go, or the post would be left half-published in the channel.",
10981
+ "description": "Delete publication by identifier. It stops being readable by identifier afterwards, so deleting twice answers error 917. For an already-sent X (Twitter) publication, removing the tweet on X is a paid action that consumes 15 X credits; it is only removed on X when there are enough credits. Returns error 940 when the X credit pool is exhausted.\n\n**On Telegram there is a 48-hour window.** Past it the Bot API refuses to delete a message whatever the bot's role is, and the answer is error 966 with `published_date` and `max_hours` in `data` — so the sensible thing is to grey the button out rather than offer it and fail. Error 969 is the other case: the bot is no longer allowed to delete there. And an album is several messages: all of them go, or the post would be left half-published in the channel.\n\n**Deleting a publication that is still `publishing` with `pending_publish` stops it**: PlanVortex stops asking Instagram and never publishes it, unless the check running at that very moment has already done so.",
10982
10982
  "operationId": "deletePublication",
10983
10983
  "x-planvortex-identity": [
10984
10984
  "current_user",
@@ -11054,7 +11054,7 @@
11054
11054
  "publications"
11055
11055
  ],
11056
11056
  "summary": "Retry a failed publication",
11057
- "description": "Sends a publication that ended in state `withErrors` to its social network again, WITHOUT changing its content.\n\nThe publication is sent during the request, so the response already carries the outcome: `state: \"sended\"` if it went out, or `state: \"withErrors\"` with the errors of THIS attempt if it failed again. A scheduled publication whose date has not arrived yet goes back to `ready` and the scheduler picks it up at its time.\n\nEvery accepted call spends one retry, even when the publication fails again — what the endpoint promises is an attempt, not a success. The limit is `max_retries`, also served by `GET /publication_limits`. Once it is spent the way to try again is to update the publication (`PUT`), which resets the counter to 0, or to create a new one.",
11057
+ "description": "Sends a publication that ended in state `withErrors` to its social network again, WITHOUT changing its content.\n\nThe publication is sent during the request, so the response already carries the outcome: `state: \"sended\"` if it went out, or `state: \"withErrors\"` with the errors of THIS attempt if it failed again. An Instagram video can also come back in `publishing` with `pending_publish`: the attempt goes on in the background, and the retry is already spent (see `pending_publish` in `Publication`).\n\nOn Instagram, a retry is worth it after a **999** (Meta ran out of time) and pointless after a **998** (Meta rejected the file): change the file with a `PUT` first. A scheduled publication whose date has not arrived yet goes back to `ready` and the scheduler picks it up at its time.\n\nEvery accepted call spends one retry, even when the publication fails again — what the endpoint promises is an attempt, not a success. The limit is `max_retries`, also served by `GET /publication_limits`. Once it is spent the way to try again is to update the publication (`PUT`), which resets the counter to 0, or to create a new one.",
11058
11058
  "operationId": "retryPublication",
11059
11059
  "x-planvortex-identity": [
11060
11060
  "current_user",
@@ -11893,7 +11893,7 @@
11893
11893
  }
11894
11894
  },
11895
11895
  "400": {
11896
- "description": "The request failed. The body carries the PlanVortex error code in `code`:\n\n| Code | Meaning |\n| --- | --- |\n| `1101` | Invalid organization |\n| `917` | Publication doesn't exists |\n| `935` | Invalid publication |\n| `921` | Can't update a publication already sended |\n| `940` | X (Twitter) credits exhausted. Not enough monthly credits to publish this tweet (same link detection and 15/200 tariff as on create). Response data: { used, limit }. |\n| `523` | Invalid application |",
11896
+ "description": "The request failed. The body carries the PlanVortex error code in `code`:\n\n| Code | Meaning |\n| --- | --- |\n| `1101` | Invalid organization |\n| `917` | Publication doesn't exists |\n| `935` | Invalid publication |\n| `921` | Can't update a publication already sended, **or one the network is still processing** (state `publishing` with `pending_publish`; then `data.state` is `publishing`). Editing it would publish it twice. It can be edited again if it fails. |\n| `940` | X (Twitter) credits exhausted. Not enough monthly credits to publish this tweet (same link detection and 15/200 tariff as on create). Response data: { used, limit }. |\n| `523` | Invalid application |",
11897
11897
  "content": {
11898
11898
  "application/json": {
11899
11899
  "schema": {
@@ -12537,7 +12537,7 @@
12537
12537
  "webhooks"
12538
12538
  ],
12539
12539
  "summary": "A change happened (notification to your app)",
12540
- "description": "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.\n\nTwo shapes travel in that array. A change on an **account** (`WebhookChange`) always carries `id_account`, `id_organization` and `social_network`. A change on an **integration** (`IntegrationWebhookChange`) carries none of those, because an integration hangs off the organization and not off any account.\n\nThe events delivered today are `new_account`, `change_state_account`, `messages`, `messaging_postbacks`, `messaging_seen`, `messaging_error`, `comments` and `integration_error`.\n\n### Comments\n\n`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.\n\n**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.\n\n**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`.\n\n### Verifying the signature\n\nEvery delivery carries the body signed with your app's secret, twice:\n\n- `x-hub-signature`: `sha1=<hmac>`\n- `x-hub-signature-256`: `sha256=<hmac>`\n\nCompute 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.\n\nDelivery is best effort: PlanVortex does not retry a webhook that fails.",
12540
+ "description": "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.\n\nThree 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.\n\nThe 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`.\n\n### Comments\n\n`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.\n\n**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.\n\n**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`.\n\n### AI plans\n\n`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`.\n\nA 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.\n\nThe 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.\n\n### Verifying the signature\n\nEvery delivery carries the body signed with your app's secret, twice:\n\n- `x-hub-signature`: `sha1=<hmac>`\n- `x-hub-signature-256`: `sha256=<hmac>`\n\nCompute 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.\n\nDelivery is best effort: PlanVortex does not retry a webhook that fails.",
12541
12541
  "operationId": "onCommentsWebhook",
12542
12542
  "requestBody": {
12543
12543
  "required": true,
@@ -12552,6 +12552,9 @@
12552
12552
  },
12553
12553
  {
12554
12554
  "$ref": "#/components/schemas/CommentsIntegrationWebhookChange"
12555
+ },
12556
+ {
12557
+ "$ref": "#/components/schemas/CommentsAiPlanWebhookChange"
12555
12558
  }
12556
12559
  ]
12557
12560
  }
@@ -13372,7 +13375,7 @@
13372
13375
  "failed",
13373
13376
  "cancelled"
13374
13377
  ],
13375
- "description": "State machine: pending -> generating -> generated -> validated | failed | cancelled. Poll the plan while state is pending or generating."
13378
+ "description": "State machine: pending -> generating -> generated -> validated | failed | cancelled. Reaching `generated` or `failed` is announced to your app's webhook (`ai_plan_generated`, `ai_plan_failed`); without one, poll the plan while state is pending or generating."
13376
13379
  },
13377
13380
  "archived_date": {
13378
13381
  "type": "string",
@@ -14040,7 +14043,7 @@
14040
14043
  },
14041
14044
  "webhook_url": {
14042
14045
  "type": "string",
14043
- "description": "Where PlanVortex posts events, when one is configured.\n\n**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.\n\nThe events delivered today are `new_account`, `change_state_account`, `messages`, `messaging_postbacks`, `messaging_seen`, `messaging_error`, `comments` and `integration_error`. The payload is documented in the `comments` specification. Delivery is best effort: PlanVortex does not retry a webhook that fails."
14046
+ "description": "Where PlanVortex posts events, when one is configured.\n\n**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.\n\nThe 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."
14044
14047
  },
14045
14048
  "creation_date": {
14046
14049
  "type": "string",
@@ -14894,6 +14897,90 @@
14894
14897
  }
14895
14898
  }
14896
14899
  },
14900
+ "CommentsAiPlanWebhookChange": {
14901
+ "type": "object",
14902
+ "description": "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.\n\nLike `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}`.",
14903
+ "properties": {
14904
+ "field": {
14905
+ "type": "string",
14906
+ "enum": [
14907
+ "ai_plan_generated",
14908
+ "ai_plan_failed"
14909
+ ]
14910
+ },
14911
+ "id_ai_plan": {
14912
+ "type": "string"
14913
+ },
14914
+ "id_organization": {
14915
+ "type": "string"
14916
+ },
14917
+ "state": {
14918
+ "type": "string",
14919
+ "enum": [
14920
+ "generated",
14921
+ "failed"
14922
+ ],
14923
+ "description": "The state the plan finished in. It always matches `field`: `generated` with `ai_plan_generated`, `failed` with `ai_plan_failed`."
14924
+ },
14925
+ "template": {
14926
+ "type": "string",
14927
+ "enum": [
14928
+ "standard",
14929
+ "from_images",
14930
+ "from_text",
14931
+ "from_catalog",
14932
+ "campaign"
14933
+ ],
14934
+ "description": "What the plan was generated from, the same value as `AiPlan.template`."
14935
+ },
14936
+ "total_publications": {
14937
+ "type": "integer",
14938
+ "description": "How many draft publications the plan holds. A failed plan can still hold the drafts of its failed attempt; retrying it discards them."
14939
+ },
14940
+ "credits_spent": {
14941
+ "type": "integer",
14942
+ "description": "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."
14943
+ },
14944
+ "warnings": {
14945
+ "type": "integer",
14946
+ "description": "**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."
14947
+ },
14948
+ "error": {
14949
+ "allOf": [
14950
+ {
14951
+ "$ref": "#/components/schemas/CommentsAiPlanWebhookError"
14952
+ }
14953
+ ],
14954
+ "description": "Only on `ai_plan_failed`: why the plan failed, the same `code` and `message` as `AiPlan.error`. Its `data` does not travel here; read the plan for it."
14955
+ }
14956
+ },
14957
+ "required": [
14958
+ "field",
14959
+ "id_ai_plan",
14960
+ "id_organization",
14961
+ "state",
14962
+ "template",
14963
+ "total_publications",
14964
+ "credits_spent",
14965
+ "warnings"
14966
+ ]
14967
+ },
14968
+ "CommentsAiPlanWebhookError": {
14969
+ "type": "object",
14970
+ "description": "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.",
14971
+ "properties": {
14972
+ "code": {
14973
+ "type": "integer"
14974
+ },
14975
+ "message": {
14976
+ "type": "string"
14977
+ }
14978
+ },
14979
+ "required": [
14980
+ "code",
14981
+ "message"
14982
+ ]
14983
+ },
14897
14984
  "CommentsComment": {
14898
14985
  "type": "object",
14899
14986
  "description": "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.",
@@ -15188,7 +15275,7 @@
15188
15275
  },
15189
15276
  "CommentsWebhookChange": {
15190
15277
  "type": "object",
15191
- "description": "One change in the array PlanVortex posts to your app's `webhook_url`, when the change concerns a social **account**.\n\nAn integration that stopped working has a shape of its own — `IntegrationWebhookChange` — and one delivery can mix both. Switch on `field`, and ignore what you do not handle.",
15278
+ "description": "One change in the array PlanVortex posts to your app's `webhook_url`, when the change concerns a social **account**.\n\nAn 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.",
15192
15279
  "properties": {
15193
15280
  "field": {
15194
15281
  "type": "string",
@@ -17725,7 +17812,10 @@
17725
17812
  "draft",
17726
17813
  "publishing"
17727
17814
  ],
17728
- "description": "`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`."
17815
+ "description": "`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`.\n\n`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."
17816
+ },
17817
+ "pending_publish": {
17818
+ "$ref": "#/components/schemas/PublicationPending"
17729
17819
  },
17730
17820
  "files": {
17731
17821
  "type": "array",
@@ -17744,7 +17834,7 @@
17744
17834
  },
17745
17835
  "publication_errors": {
17746
17836
  "type": "array",
17747
- "description": "Why the publication failed, one entry per problem. **It is an array**, and it is empty on a publication that has not failed.\n\nFor 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` and the client-app webhook is fired.",
17837
+ "description": "Why the publication failed, one entry per problem. **It is an array**, and it is empty on a publication that has not failed.\n\nFor 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.\n\nOn 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.",
17748
17838
  "items": {
17749
17839
  "type": "object",
17750
17840
  "required": [
@@ -17859,6 +17949,34 @@
17859
17949
  }
17860
17950
  }
17861
17951
  },
17952
+ "PublicationPending": {
17953
+ "type": "object",
17954
+ "description": "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.\n\nThe 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.\n\n**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.",
17955
+ "properties": {
17956
+ "next_check": {
17957
+ "type": "string",
17958
+ "format": "date-time",
17959
+ "description": "When PlanVortex will ask the network again. A good moment to read the publication again: asking before it changes nothing."
17960
+ },
17961
+ "deadline": {
17962
+ "type": "string",
17963
+ "format": "date-time",
17964
+ "description": "When PlanVortex stops waiting. If the network has not finished by then, the publication ends in `withErrors` (999 on Instagram)."
17965
+ },
17966
+ "temp_keys": {
17967
+ "type": "array",
17968
+ "items": {
17969
+ "type": "string"
17970
+ },
17971
+ "description": "PlanVortex's own bookkeeping: the temporary cropped files the network may still be downloading. Do not rely on it."
17972
+ },
17973
+ "data": {
17974
+ "type": "object",
17975
+ "additionalProperties": true,
17976
+ "description": "PlanVortex's own bookkeeping to resume the publication (on Instagram, the ids of Meta's containers). Its content may change without notice."
17977
+ }
17978
+ }
17979
+ },
17862
17980
  "PublicationStats": {
17863
17981
  "type": "object",
17864
17982
  "description": "Raw, per-network metrics for a publication. Only the fields that belong to the publication's own social network are returned.\n\n**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.\n\nOn `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.\n\nOn `bluesky` there are no impressions and no reach either — only the public counters — so engagement is computed over followers.\n\nOn `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.\n\nOn `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.\n\nOn `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.\n\nOn `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.",
@@ -9,7 +9,7 @@ name = "planvortex"
9
9
  # entonces `project.version` desapareceria del pyproject y el paso del workflow de release que
10
10
  # compara el tag con la version —que lo lee con `tomllib`— dejaria de encontrarla. Se quedan las dos
11
11
  # y las ata `tests/test_packaging.py`, que falla si se separan.
12
- version = "0.12.0"
12
+ version = "0.13.0"
13
13
  description = "Official Python client for the PlanVortex API"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.10"
@@ -562,7 +562,7 @@ class AppsClientApp(TypedDict):
562
562
 
563
563
  **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.
564
564
 
565
- The events delivered today are `new_account`, `change_state_account`, `messages`, `messaging_postbacks`, `messaging_seen`, `messaging_error`, `comments` and `integration_error`. The payload is documented in the `comments` specification. Delivery is best effort: PlanVortex does not retry a webhook that fails.
565
+ 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.
566
566
  """
567
567
  creation_date: str
568
568
  deleted: bool
@@ -897,6 +897,15 @@ class ClientsRolesUserList(TypedDict):
897
897
  total: NotRequired[int]
898
898
 
899
899
 
900
+ class CommentsAiPlanWebhookError(TypedDict):
901
+ """
902
+ 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.
903
+ """
904
+
905
+ code: int
906
+ message: str
907
+
908
+
900
909
  class CommentsCommentActions(TypedDict):
901
910
  """
902
911
  What one network lets you do to a comment. Four booleans and never fewer: an absent key would be indistinguishable from an oversight.
@@ -1927,6 +1936,33 @@ class PublicationDestination(TypedDict):
1927
1936
  """
1928
1937
 
1929
1938
 
1939
+ class PublicationPending(TypedDict):
1940
+ """
1941
+ 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.
1942
+
1943
+ 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.
1944
+
1945
+ **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.
1946
+ """
1947
+
1948
+ next_check: NotRequired[str]
1949
+ """
1950
+ When PlanVortex will ask the network again. A good moment to read the publication again: asking before it changes nothing.
1951
+ """
1952
+ deadline: NotRequired[str]
1953
+ """
1954
+ When PlanVortex stops waiting. If the network has not finished by then, the publication ends in `withErrors` (999 on Instagram).
1955
+ """
1956
+ temp_keys: NotRequired[list[str]]
1957
+ """
1958
+ PlanVortex's own bookkeeping: the temporary cropped files the network may still be downloading. Do not rely on it.
1959
+ """
1960
+ data: NotRequired[dict[str, Any]]
1961
+ """
1962
+ PlanVortex's own bookkeeping to resume the publication (on Instagram, the ids of Meta's containers). Its content may change without notice.
1963
+ """
1964
+
1965
+
1930
1966
  class PublicationStats(TypedDict):
1931
1967
  """
1932
1968
  Raw, per-network metrics for a publication. Only the fields that belong to the publication's own social network are returned.
@@ -2773,6 +2809,42 @@ class ClientsPlan(TypedDict):
2773
2809
  stripe_customer_id: NotRequired[str]
2774
2810
 
2775
2811
 
2812
+ class CommentsAiPlanWebhookChange(TypedDict):
2813
+ """
2814
+ 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.
2815
+
2816
+ 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}`.
2817
+ """
2818
+
2819
+ field: Literal["ai_plan_generated", "ai_plan_failed"]
2820
+ id_ai_plan: str
2821
+ id_organization: str
2822
+ state: Literal["generated", "failed"]
2823
+ """
2824
+ The state the plan finished in. It always matches `field`: `generated` with `ai_plan_generated`, `failed` with `ai_plan_failed`.
2825
+ """
2826
+ template: Literal["standard", "from_images", "from_text", "from_catalog", "campaign"]
2827
+ """
2828
+ What the plan was generated from, the same value as `AiPlan.template`.
2829
+ """
2830
+ total_publications: int
2831
+ """
2832
+ How many draft publications the plan holds. A failed plan can still hold the drafts of its failed attempt; retrying it discards them.
2833
+ """
2834
+ credits_spent: int
2835
+ """
2836
+ 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.
2837
+ """
2838
+ warnings: int
2839
+ """
2840
+ **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.
2841
+ """
2842
+ error: NotRequired[CommentsAiPlanWebhookError]
2843
+ """
2844
+ Only on `ai_plan_failed`: why the plan failed, the same `code` and `message` as `AiPlan.error`. Its `data` does not travel here; read the plan for it.
2845
+ """
2846
+
2847
+
2776
2848
  class Contact(TypedDict):
2777
2849
  """
2778
2850
  A person the organization exchanges messages with. The same contact can be reachable on several channels — that is what `social_identifiers` is.
@@ -3307,7 +3379,10 @@ class Publication(TypedDict):
3307
3379
  state: Literal["ready", "withErrors", "sended", "draft", "publishing"]
3308
3380
  """
3309
3381
  `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`.
3382
+
3383
+ `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.
3310
3384
  """
3385
+ pending_publish: NotRequired[PublicationPending]
3311
3386
  files: list[Upload]
3312
3387
  """
3313
3388
  The attached files, **already resolved**: every read and write path returns full uploads, not identifiers. Identifiers are what you SEND (see `PublicationInput.files`).
@@ -3318,7 +3393,9 @@ class Publication(TypedDict):
3318
3393
  """
3319
3394
  Why the publication failed, one entry per problem. **It is an array**, and it is empty on a publication that has not failed.
3320
3395
 
3321
- 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` and the client-app webhook is fired.
3396
+ 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.
3397
+
3398
+ 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.
3322
3399
  """
3323
3400
  retries: int
3324
3401
  """
@@ -3493,7 +3570,7 @@ class AiPlansAiPlan(TypedDict):
3493
3570
  options: AiPlansAiPlanOptions
3494
3571
  state: Literal["pending", "generating", "generated", "validated", "failed", "cancelled"]
3495
3572
  """
3496
- State machine: pending -> generating -> generated -> validated | failed | cancelled. Poll the plan while state is pending or generating.
3573
+ State machine: pending -> generating -> generated -> validated | failed | cancelled. Reaching `generated` or `failed` is announced to your app's webhook (`ai_plan_generated`, `ai_plan_failed`); without one, poll the plan while state is pending or generating.
3497
3574
  """
3498
3575
  archived_date: NotRequired[str]
3499
3576
  """
@@ -3877,7 +3954,7 @@ class CommentsWebhookChange(TypedDict):
3877
3954
  """
3878
3955
  One change in the array PlanVortex posts to your app's `webhook_url`, when the change concerns a social **account**.
3879
3956
 
3880
- An integration that stopped working has a shape of its own — `IntegrationWebhookChange` — and one delivery can mix both. Switch on `field`, and ignore what you do not handle.
3957
+ 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.
3881
3958
  """
3882
3959
 
3883
3960
  field: Literal[
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import platform
14
14
 
15
- VERSION = "0.12.0"
15
+ VERSION = "0.13.0"
16
16
 
17
17
  # Sin barra final, y con el `/v1.0.0` dentro: la version de la API es parte de la ruta, no una
18
18
  # cabecera, asi que una `base_url` sin ella da 404 en todo.
@@ -11,8 +11,10 @@ THE CYCLE, which is what to have clear before anything else::
11
11
  AND WHAT SURPRISES PEOPLE:
12
12
 
13
13
  - **``create()`` generates nothing**: it queues the plan and returns the budget. The generating is
14
- done by a separate job, so you poll :meth:`AsyncAiPlansResource.get` while the state is ``pending``
15
- or ``generating``. It can take minutes.
14
+ done by a separate job and can take minutes. When it ends, your app's ``webhook_url`` receives
15
+ ``ai_plan_generated`` or ``ai_plan_failed`` (``is_ai_plan_change`` in :mod:`planvortex.webhooks`);
16
+ without a webhook, poll :meth:`AsyncAiPlansResource.get` while the state is ``pending`` or
17
+ ``generating``.
16
18
  - **What is generated are NORMAL publications in state ``draft``.** They are edited and deleted with
17
19
  ``pv.publications``, not with anything here. Validating is what moves them to ``ready``.
18
20
  - **It is paid for in AI credits and the price is known BEFOREHAND.** The budget is computed by the
@@ -175,8 +177,10 @@ class AsyncAiPlansResource(AsyncResource):
175
177
  ) -> AiPlan:
176
178
  """One plan, with its publications **already resolved** and with each one's files.
177
179
 
178
- It is the endpoint you poll while ``state`` is ``pending`` or ``generating``. There is no
179
- webhook for this yet.
180
+ Read it when your app receives ``ai_plan_generated`` or ``ai_plan_failed``. Without a
181
+ webhook, it is the endpoint you poll while ``state`` is ``pending`` or ``generating``. The
182
+ webhook is not retried, so if your endpoint was down when the plan finished, this still tells
183
+ you how it ended.
180
184
 
181
185
  Read ``warnings`` on a plan that came out ``generated``, which is the place nobody
182
186
  looks: it is not an error of the response, it is a notice about a plan that generated fine.
@@ -333,8 +337,8 @@ class AsyncAiPlansResource(AsyncResource):
333
337
  *,
334
338
  timeout: float | None = None,
335
339
  ) -> AiPlan:
336
- """Queue a ``failed`` plan again with the same data. The state goes back to ``pending`` and you
337
- have to poll again.
340
+ """Queue a ``failed`` plan again with the same data. The state goes back to ``pending`` and the
341
+ end arrives again as after ``create()``: on your webhook, or by polling.
338
342
 
339
343
  It uses the brand context copied when the plan was created, not today's: a plan is
340
344
  reproducible even if somebody edited the configuration in the meantime.
@@ -114,8 +114,10 @@ class AiPlansResource(Resource):
114
114
  ) -> AiPlan:
115
115
  """One plan, with its publications **already resolved** and with each one's files.
116
116
 
117
- It is the endpoint you poll while ``state`` is ``pending`` or ``generating``. There is no
118
- webhook for this yet.
117
+ Read it when your app receives ``ai_plan_generated`` or ``ai_plan_failed``. Without a
118
+ webhook, it is the endpoint you poll while ``state`` is ``pending`` or ``generating``. The
119
+ webhook is not retried, so if your endpoint was down when the plan finished, this still tells
120
+ you how it ended.
119
121
 
120
122
  Read ``warnings`` on a plan that came out ``generated``, which is the place nobody
121
123
  looks: it is not an error of the response, it is a notice about a plan that generated fine.
@@ -272,8 +274,8 @@ class AiPlansResource(Resource):
272
274
  *,
273
275
  timeout: float | None = None,
274
276
  ) -> AiPlan:
275
- """Queue a ``failed`` plan again with the same data. The state goes back to ``pending`` and you
276
- have to poll again.
277
+ """Queue a ``failed`` plan again with the same data. The state goes back to ``pending`` and the
278
+ end arrives again as after ``create()``: on your webhook, or by polling.
277
279
 
278
280
  It uses the brand context copied when the plan was created, not today's: a plan is
279
281
  reproducible even if somebody edited the configuration in the meantime.