planvortex 0.16.0__tar.gz → 0.17.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.16.0 → planvortex-0.17.0}/CHANGELOG.md +26 -0
  2. {planvortex-0.16.0 → planvortex-0.17.0}/PKG-INFO +12 -1
  3. {planvortex-0.16.0 → planvortex-0.17.0}/README.md +11 -0
  4. {planvortex-0.16.0 → planvortex-0.17.0}/openapi/planvortex.openapi.json +17 -5
  5. {planvortex-0.16.0 → planvortex-0.17.0}/pyproject.toml +1 -1
  6. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_generated/models.py +15 -2
  7. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_version.py +1 -1
  8. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/webhooks.py +28 -0
  9. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_webhooks.py +8 -2
  10. {planvortex-0.16.0 → planvortex-0.17.0}/.gitignore +0 -0
  11. {planvortex-0.16.0 → planvortex-0.17.0}/LICENSE +0 -0
  12. {planvortex-0.16.0 → planvortex-0.17.0}/scripts/build_docs.py +0 -0
  13. {planvortex-0.16.0 → planvortex-0.17.0}/scripts/check_packaging.py +0 -0
  14. {planvortex-0.16.0 → planvortex-0.17.0}/scripts/generate_models.py +0 -0
  15. {planvortex-0.16.0 → planvortex-0.17.0}/scripts/generate_sync.py +0 -0
  16. {planvortex-0.16.0 → planvortex-0.17.0}/scripts/route_coverage.py +0 -0
  17. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/__init__.py +0 -0
  18. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_client.py +0 -0
  19. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_client_sync.py +0 -0
  20. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_core/__init__.py +0 -0
  21. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_core/auth.py +0 -0
  22. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_core/auth_sync.py +0 -0
  23. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_core/errors.py +0 -0
  24. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_core/files.py +0 -0
  25. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_core/http.py +0 -0
  26. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_core/http_sync.py +0 -0
  27. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_core/pagination.py +0 -0
  28. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_core/query.py +0 -0
  29. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_core/transport.py +0 -0
  30. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_generated/__init__.py +0 -0
  31. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/_shapes.py +0 -0
  32. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/py.typed +0 -0
  33. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/__init__.py +0 -0
  34. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/accounts.py +0 -0
  35. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/ai_plans.py +0 -0
  36. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/apps.py +0 -0
  37. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/base.py +0 -0
  38. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/catalog.py +0 -0
  39. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/clients.py +0 -0
  40. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/comments.py +0 -0
  41. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/contacts.py +0 -0
  42. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/dashboard.py +0 -0
  43. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/integrations.py +0 -0
  44. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/messages.py +0 -0
  45. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/organizations.py +0 -0
  46. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/products.py +0 -0
  47. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/publications.py +0 -0
  48. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources/uploads.py +0 -0
  49. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/__init__.py +0 -0
  50. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/accounts.py +0 -0
  51. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/ai_plans.py +0 -0
  52. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/apps.py +0 -0
  53. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/base.py +0 -0
  54. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/catalog.py +0 -0
  55. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/clients.py +0 -0
  56. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/comments.py +0 -0
  57. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/contacts.py +0 -0
  58. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/dashboard.py +0 -0
  59. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/integrations.py +0 -0
  60. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/messages.py +0 -0
  61. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/organizations.py +0 -0
  62. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/products.py +0 -0
  63. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/publications.py +0 -0
  64. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/resources_sync/uploads.py +0 -0
  65. {planvortex-0.16.0 → planvortex-0.17.0}/src/planvortex/types.py +0 -0
  66. {planvortex-0.16.0 → planvortex-0.17.0}/tests/__init__.py +0 -0
  67. {planvortex-0.16.0 → planvortex-0.17.0}/tests/conftest.py +0 -0
  68. {planvortex-0.16.0 → planvortex-0.17.0}/tests/contrato.py +0 -0
  69. {planvortex-0.16.0 → planvortex-0.17.0}/tests/live/__init__.py +0 -0
  70. {planvortex-0.16.0 → planvortex-0.17.0}/tests/live/conftest.py +0 -0
  71. {planvortex-0.16.0 → planvortex-0.17.0}/tests/live/test_auth.py +0 -0
  72. {planvortex-0.16.0 → planvortex-0.17.0}/tests/live/test_catalog.py +0 -0
  73. {planvortex-0.16.0 → planvortex-0.17.0}/tests/live/test_connect_flow.py +0 -0
  74. {planvortex-0.16.0 → planvortex-0.17.0}/tests/live/test_errors.py +0 -0
  75. {planvortex-0.16.0 → planvortex-0.17.0}/tests/live/test_publish.py +0 -0
  76. {planvortex-0.16.0 → planvortex-0.17.0}/tests/live/test_read.py +0 -0
  77. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_accounts.py +0 -0
  78. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_ai_plans.py +0 -0
  79. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_apps.py +0 -0
  80. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_catalog.py +0 -0
  81. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_client.py +0 -0
  82. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_clients.py +0 -0
  83. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_comments.py +0 -0
  84. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_contacts.py +0 -0
  85. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_core.py +0 -0
  86. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_dashboard.py +0 -0
  87. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_example_comments.py +0 -0
  88. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_example_connect.py +0 -0
  89. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_example_publish.py +0 -0
  90. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_example_schedule.py +0 -0
  91. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_example_webhooks.py +0 -0
  92. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_files.py +0 -0
  93. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_generate_models.py +0 -0
  94. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_generate_sync.py +0 -0
  95. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_integrations.py +0 -0
  96. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_messages.py +0 -0
  97. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_models.py +0 -0
  98. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_openapi_freshness.py +0 -0
  99. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_organizations.py +0 -0
  100. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_packaging.py +0 -0
  101. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_pagination.py +0 -0
  102. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_products.py +0 -0
  103. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_publications.py +0 -0
  104. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_query_parity.py +0 -0
  105. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_readme.py +0 -0
  106. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_route_coverage.py +0 -0
  107. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_shapes_parity.py +0 -0
  108. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_types.py +0 -0
  109. {planvortex-0.16.0 → planvortex-0.17.0}/tests/test_uploads.py +0 -0
@@ -4,6 +4,32 @@ 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.17.0] - 2026-10-28
8
+
9
+ ### Added
10
+
11
+ - **`message_echoes` webhook event (WhatsApp).** When a WhatsApp number is also used in the
12
+ WhatsApp Business app on a phone, what the business sends from the app arrives as
13
+ `field: "message_echoes"`, with the message in `messageObj` as one of **ours**: `contact_id` is
14
+ the person it was sent to and `from_contact_id` is absent. New type `MessageEchoChange` and
15
+ predicate `is_message_echo_change()`. It is deliberately **not** part of `is_message_change()`: a
16
+ bot that answers every incoming message must not answer the business itself. Store it, do not
17
+ reply.
18
+ - **`edit_date` and `deleted_date` on messages.** A contact edited or deleted the message on
19
+ WhatsApp. An edited message already carries the new text; a deleted one has no text and no files.
20
+ No webhook event is sent for either: the message itself changes.
21
+ - **`unsupported_message`** in the message types: a WhatsApp message the API cannot show (a voice
22
+ note, a document, a sticker, or a type WhatsApp does not let the API read). It only ever arrives,
23
+ keeps the caption as `text` when there is one, and sending one is refused.
24
+
25
+ ## [0.16.1] - 2026-10-25
26
+
27
+ ### Changed
28
+
29
+ - The README has a **Guides** section with the longer write-ups on planvortex.com: publishing to
30
+ social media from Python, why a Meta access token lasts 60 days and how to refresh it, character
31
+ limits across social APIs, what posting on X costs, and LinkedIn API access. No code changed.
32
+
7
33
  ## [0.16.0] - 2026-10-15
8
34
 
9
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: planvortex
3
- Version: 0.16.0
3
+ Version: 0.17.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/
@@ -407,6 +407,17 @@ Five runnable scripts, each one the whole of its path and with a test of its own
407
407
  `comments.py` only reads unless you set `PLANVORTEX_ALLOW_REPLY=1`: replying is public, immediate,
408
408
  and reaches a person.
409
409
 
410
+ ## Guides
411
+
412
+ Longer write-ups on planvortex.com:
413
+
414
+ - [Publishing to social media from Python without building one integration per network](https://planvortex.com/en/resources/publish-social-media-python)
415
+ - [Meta access token expired? Why it lasts 60 days and how to refresh it](https://planvortex.com/en/resources/meta-access-token-expiry)
416
+ - [Character limits across social APIs: .length lies, and it lies both ways](https://planvortex.com/en/resources/social-media-character-limits)
417
+ - [How much does it cost to post on X with the API in 2026](https://planvortex.com/en/resources/x-api-pricing-2026)
418
+ - [Is the LinkedIn API free? Pricing and posting access in 2026](https://planvortex.com/en/resources/linkedin-api-pricing-access)
419
+ - [All articles](https://planvortex.com/en/resources)
420
+
410
421
  ## Links
411
422
 
412
423
  - [Reference](https://taliasoftworks.github.io/PlanVortexPython/)
@@ -377,6 +377,17 @@ Five runnable scripts, each one the whole of its path and with a test of its own
377
377
  `comments.py` only reads unless you set `PLANVORTEX_ALLOW_REPLY=1`: replying is public, immediate,
378
378
  and reaches a person.
379
379
 
380
+ ## Guides
381
+
382
+ Longer write-ups on planvortex.com:
383
+
384
+ - [Publishing to social media from Python without building one integration per network](https://planvortex.com/en/resources/publish-social-media-python)
385
+ - [Meta access token expired? Why it lasts 60 days and how to refresh it](https://planvortex.com/en/resources/meta-access-token-expiry)
386
+ - [Character limits across social APIs: .length lies, and it lies both ways](https://planvortex.com/en/resources/social-media-character-limits)
387
+ - [How much does it cost to post on X with the API in 2026](https://planvortex.com/en/resources/x-api-pricing-2026)
388
+ - [Is the LinkedIn API free? Pricing and posting access in 2026](https://planvortex.com/en/resources/linkedin-api-pricing-access)
389
+ - [All articles](https://planvortex.com/en/resources)
390
+
380
391
  ## Links
381
392
 
382
393
  - [Reference](https://taliasoftworks.github.io/PlanVortexPython/)
@@ -12669,7 +12669,7 @@
12669
12669
  "webhooks"
12670
12670
  ],
12671
12671
  "summary": "A change happened (notification to your app)",
12672
- "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.",
12672
+ "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`, `message_echoes`, `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### WhatsApp replies sent from the phone\n\nA WhatsApp number can work in the **WhatsApp Business app** on a phone and in PlanVortex at the same time. What the business sends from the app arrives as `field: \"message_echoes\"`, with the message in `messageObj` as one of **ours**: `contact_id` is the person it was sent to and `from_contact_id` is absent. Store it in the thread; do not answer it. It does not open WhatsApp's 24-hour window.\n\nWhen a contact edits or deletes a message, no event is sent: the message itself changes, and the next read returns it with `edit_date` or `deleted_date`.\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.",
12673
12673
  "operationId": "onCommentsWebhook",
12674
12674
  "requestBody": {
12675
12675
  "required": true,
@@ -14186,7 +14186,7 @@
14186
14186
  },
14187
14187
  "webhook_url": {
14188
14188
  "type": "string",
14189
- "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."
14189
+ "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`, `message_echoes`, `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."
14190
14190
  },
14191
14191
  "creation_date": {
14192
14192
  "type": "string",
@@ -15455,7 +15455,7 @@
15455
15455
  "properties": {
15456
15456
  "field": {
15457
15457
  "type": "string",
15458
- "description": "What kind of change this is. **Treat it as an open list** and ignore what you do not handle: it grows with the product.\n\n- `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.\n- `messages`: a message came in. It travels in `messageObj`.\n- `messaging_postbacks`: the contact pressed a button or a quick reply. Also in `messageObj`.\n- `messaging_seen`: the contact read the conversation. `messageObj` carries the message they read, when we still have it.\n- `messaging_error`: the network refused a message we sent. The reason is in `messageObj.message_errors`.\n- `comments`: a comment came in. It travels in `commentObj`, never in `messageObj`.",
15458
+ "description": "What kind of change this is. **Treat it as an open list** and ignore what you do not handle: it grows with the product.\n\n- `new_account` / `change_state_account`: an account was connected, or its state changed — it stopped working, its token was refreshed, it was disconnected. A WhatsApp number disconnected from the WhatsApp Business app (or by Meta, after about 14 days without the phone using the app) arrives here with `error_code` 717. 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.\n- `messages`: a message came in. It travels in `messageObj`.\n- `messaging_postbacks`: the contact pressed a button or a quick reply. Also in `messageObj`.\n- `messaging_seen`: the contact read the conversation. `messageObj` carries the message they read, when we still have it.\n- `messaging_error`: the network refused a message we sent. The reason is in `messageObj.message_errors`.\n- `message_echoes`: WhatsApp only, when the number is also used in the **WhatsApp Business app** on a phone. The business replied from the app, and the reply travels in `messageObj` as **our** message (`contact_id` is set, `from_contact_id` is not). It is a separate `field` on purpose: a bot that answers every `messages` must not answer the business itself.\n- `comments`: a comment came in. It travels in `commentObj`, never in `messageObj`.",
15459
15459
  "enum": [
15460
15460
  "new_account",
15461
15461
  "change_state_account",
@@ -15463,6 +15463,7 @@
15463
15463
  "messaging_postbacks",
15464
15464
  "messaging_seen",
15465
15465
  "messaging_error",
15466
+ "message_echoes",
15466
15467
  "comments"
15467
15468
  ]
15468
15469
  },
@@ -16999,6 +17000,16 @@
16999
17000
  "type": "string",
17000
17001
  "description": "Identifier of this message on the network."
17001
17002
  },
17003
+ "edit_date": {
17004
+ "type": "string",
17005
+ "format": "date-time",
17006
+ "description": "When the contact **edited** this message on the network (WhatsApp allows it for 15 minutes). `text` is already the edited text; the original is not kept."
17007
+ },
17008
+ "deleted_date": {
17009
+ "type": "string",
17010
+ "format": "date-time",
17011
+ "description": "When the contact **deleted** this message for everyone (WhatsApp allows it for two days). The message stays in the thread with no `text` and no files."
17012
+ },
17002
17013
  "creation_date": {
17003
17014
  "type": "string",
17004
17015
  "format": "date-time"
@@ -17095,7 +17106,7 @@
17095
17106
  },
17096
17107
  "MessageType": {
17097
17108
  "type": "string",
17098
- "description": "What kind of message this is. `simple_message` and `file_message` work everywhere; the rest are network-specific shapes.",
17109
+ "description": "What kind of message this is. `simple_message` and `file_message` work everywhere; the rest are network-specific shapes.\n\n`unsupported_message` only ever **arrives**: a WhatsApp message that cannot be shown through the API (a voice note, a document, a sticker, or a type WhatsApp does not let the API read). It carries no files, keeps the caption as `text` when there is one, and may carry the network's reason in `message_errors`. If the number is also in the WhatsApp Business app, the message can be read there. Sending one is refused.",
17099
17110
  "enum": [
17100
17111
  "simple_message",
17101
17112
  "file_message",
@@ -17106,7 +17117,8 @@
17106
17117
  "elements_message",
17107
17118
  "postback_message",
17108
17119
  "template_message",
17109
- "interactive_message"
17120
+ "interactive_message",
17121
+ "unsupported_message"
17110
17122
  ]
17111
17123
  },
17112
17124
  "MessagesConversation": {
@@ -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.16.0"
12
+ version = "0.17.0"
13
13
  description = "Official Python client for the PlanVortex API"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.10"
@@ -574,7 +574,7 @@ class AppsClientApp(TypedDict):
574
574
 
575
575
  **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.
576
576
 
577
- 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.
577
+ The events delivered today are `new_account`, `change_state_account`, `messages`, `messaging_postbacks`, `messaging_seen`, `messaging_error`, `message_echoes`, `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.
578
578
  """
579
579
  creation_date: str
580
580
  deleted: bool
@@ -1461,9 +1461,12 @@ MessageType: TypeAlias = Literal[
1461
1461
  "postback_message",
1462
1462
  "template_message",
1463
1463
  "interactive_message",
1464
+ "unsupported_message",
1464
1465
  ]
1465
1466
  """
1466
1467
  What kind of message this is. `simple_message` and `file_message` work everywhere; the rest are network-specific shapes.
1468
+
1469
+ `unsupported_message` only ever **arrives**: a WhatsApp message that cannot be shown through the API (a voice note, a document, a sticker, or a type WhatsApp does not let the API read). It carries no files, keeps the caption as `text` when there is one, and may carry the network's reason in `message_errors`. If the number is also in the WhatsApp Business app, the message can be read there. Sending one is refused.
1467
1470
  """
1468
1471
 
1469
1472
 
@@ -4180,6 +4183,14 @@ class Message(TypedDict):
4180
4183
  """
4181
4184
  Identifier of this message on the network.
4182
4185
  """
4186
+ edit_date: NotRequired[str]
4187
+ """
4188
+ When the contact **edited** this message on the network (WhatsApp allows it for 15 minutes). `text` is already the edited text; the original is not kept.
4189
+ """
4190
+ deleted_date: NotRequired[str]
4191
+ """
4192
+ When the contact **deleted** this message for everyone (WhatsApp allows it for two days). The message stays in the thread with no `text` and no files.
4193
+ """
4183
4194
  creation_date: str
4184
4195
 
4185
4196
 
@@ -4197,16 +4208,18 @@ class CommentsWebhookChange(TypedDict):
4197
4208
  "messaging_postbacks",
4198
4209
  "messaging_seen",
4199
4210
  "messaging_error",
4211
+ "message_echoes",
4200
4212
  "comments",
4201
4213
  ]
4202
4214
  """
4203
4215
  What kind of change this is. **Treat it as an open list** and ignore what you do not handle: it grows with the product.
4204
4216
 
4205
- - `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.
4217
+ - `new_account` / `change_state_account`: an account was connected, or its state changed — it stopped working, its token was refreshed, it was disconnected. A WhatsApp number disconnected from the WhatsApp Business app (or by Meta, after about 14 days without the phone using the app) arrives here with `error_code` 717. 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.
4206
4218
  - `messages`: a message came in. It travels in `messageObj`.
4207
4219
  - `messaging_postbacks`: the contact pressed a button or a quick reply. Also in `messageObj`.
4208
4220
  - `messaging_seen`: the contact read the conversation. `messageObj` carries the message they read, when we still have it.
4209
4221
  - `messaging_error`: the network refused a message we sent. The reason is in `messageObj.message_errors`.
4222
+ - `message_echoes`: WhatsApp only, when the number is also used in the **WhatsApp Business app** on a phone. The business replied from the app, and the reply travels in `messageObj` as **our** message (`contact_id` is set, `from_contact_id` is not). It is a separate `field` on purpose: a bot that answers every `messages` must not answer the business itself.
4210
4223
  - `comments`: a comment came in. It travels in `commentObj`, never in `messageObj`.
4211
4224
  """
4212
4225
  id_account: str
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import platform
14
14
 
15
- VERSION = "0.16.0"
15
+ VERSION = "0.17.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.
@@ -81,6 +81,7 @@ WEBHOOK_EVENTS: tuple[str, ...] = (
81
81
  "messaging_postbacks",
82
82
  "messaging_seen",
83
83
  "messaging_error",
84
+ "message_echoes",
84
85
  "comments",
85
86
  "integration_error",
86
87
  "ai_plan_generated",
@@ -136,6 +137,24 @@ class MessageChange(AccountWebhookChangeBase):
136
137
  """The contact on the other side. A comment has none, which is why it is here and not there."""
137
138
 
138
139
 
140
+ class MessageEchoChange(AccountWebhookChangeBase):
141
+ """WhatsApp, when the number is also used in the **WhatsApp Business app** on a phone: the
142
+ business replied from the app.
143
+
144
+ ``messageObj`` is one of **our** messages — ``contact_id`` is the person it was sent to and
145
+ ``from_contact_id`` is absent — and it arrives populated, like in :class:`MessageChange`.
146
+
147
+ It is a separate event on purpose, and :func:`is_message_change` does **not** include it: a bot
148
+ that answers every incoming message must not answer the business itself. Store it in the thread
149
+ and that is all. It does not open WhatsApp's 24-hour window.
150
+ """
151
+
152
+ field: Literal["message_echoes"]
153
+ messageObj: NotRequired[Message]
154
+ id_contact: NotRequired[str]
155
+ """The contact the business wrote to."""
156
+
157
+
139
158
  class CommentChange(AccountWebhookChangeBase):
140
159
  """A comment came in.
141
160
 
@@ -229,6 +248,7 @@ class UnknownWebhookChange(TypedDict):
229
248
  WebhookChange: TypeAlias = (
230
249
  AccountStateChange
231
250
  | MessageChange
251
+ | MessageEchoChange
232
252
  | CommentChange
233
253
  | IntegrationErrorChange
234
254
  | AiPlanChange
@@ -257,6 +277,12 @@ def is_message_change(change: WebhookChange) -> TypeGuard[MessageChange]:
257
277
  return change["field"] in _MESSAGE_EVENTS
258
278
 
259
279
 
280
+ def is_message_echo_change(change: WebhookChange) -> TypeGuard[MessageEchoChange]:
281
+ """The business replied from the WhatsApp Business app on a phone. Not an incoming message: do
282
+ not reply to it. :func:`is_message_change` does not include it."""
283
+ return change["field"] == "message_echoes"
284
+
285
+
260
286
  def is_comment_change(change: WebhookChange) -> TypeGuard[CommentChange]:
261
287
  """A comment came in."""
262
288
  return change["field"] == "comments"
@@ -517,6 +543,7 @@ __all__ = [
517
543
  "CommentChange",
518
544
  "IntegrationErrorChange",
519
545
  "MessageChange",
546
+ "MessageEchoChange",
520
547
  "RawWebhookBody",
521
548
  "UnknownWebhookChange",
522
549
  "WebhookAlgorithm",
@@ -530,6 +557,7 @@ __all__ = [
530
557
  "is_comment_change",
531
558
  "is_integration_error_change",
532
559
  "is_message_change",
560
+ "is_message_echo_change",
533
561
  "parse_webhook_body",
534
562
  "verify_webhook_signature",
535
563
  ]
@@ -32,6 +32,7 @@ from planvortex.webhooks import (
32
32
  CommentChange,
33
33
  IntegrationErrorChange,
34
34
  MessageChange,
35
+ MessageEchoChange,
35
36
  UnknownWebhookChange,
36
37
  WebhookBodyError,
37
38
  WebhookChange,
@@ -42,6 +43,7 @@ from planvortex.webhooks import (
42
43
  is_comment_change,
43
44
  is_integration_error_change,
44
45
  is_message_change,
46
+ is_message_echo_change,
45
47
  parse_webhook_body,
46
48
  verify_webhook_signature,
47
49
  )
@@ -328,6 +330,9 @@ def _cambio(field: str, **extra: Any) -> WebhookChange:
328
330
  ("messaging_postbacks", is_message_change),
329
331
  ("messaging_seen", is_message_change),
330
332
  ("messaging_error", is_message_change),
333
+ # El eco de WhatsApp (lo que el negocio contesta desde la app) es suyo y NO es un mensaje
334
+ # que contestar: un bot que filtra por is_message_change no tiene que verlo
335
+ ("message_echoes", is_message_echo_change),
331
336
  ("comments", is_comment_change),
332
337
  ("integration_error", is_integration_error_change),
333
338
  ("ai_plan_generated", is_ai_plan_change),
@@ -339,6 +344,7 @@ def test_cada_evento_lo_reconoce_su_predicado_y_solo_el_suyo(field: str, predica
339
344
  todos = (
340
345
  is_account_state_change,
341
346
  is_message_change,
347
+ is_message_echo_change,
342
348
  is_comment_change,
343
349
  is_integration_error_change,
344
350
  is_ai_plan_change,
@@ -517,12 +523,12 @@ def test_lo_que_el_spec_declara_obligatorio_lo_es_en_los_tres(tipo: Any) -> None
517
523
  assert set(CUENTA_SPEC["required"]) <= set(tipo.__required_keys__)
518
524
 
519
525
 
520
- def test_cada_field_del_spec_cae_en_uno_de_los_tres_tipos() -> None:
526
+ def test_cada_field_del_spec_cae_en_uno_de_los_cuatro_tipos() -> None:
521
527
  """El reparto por `field`: ni un evento sin tipo, ni un tipo con un `field` que el spec no
522
528
  declara — que es como se colaria un evento inventado con toda la pinta de ser real.
523
529
  """
524
530
  repartidos: list[str] = []
525
- for tipo in (AccountStateChange, MessageChange, CommentChange):
531
+ for tipo in (AccountStateChange, MessageChange, MessageEchoChange, CommentChange):
526
532
  repartidos.extend(get_args(tipo.__annotations__["field"]))
527
533
 
528
534
  assert sorted(repartidos) == sorted(CUENTA_SPEC["properties"]["field"]["enum"])
File without changes
File without changes