upload-post 2.14.0__tar.gz → 2.15.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upload-post
3
- Version: 2.14.0
3
+ Version: 2.15.0
4
4
  Summary: Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn, Facebook, Pinterest, Threads, Reddit, Bluesky, Discord, Telegram, and X (Twitter)
5
5
  Home-page: https://www.upload-post.com/
6
6
  Author: Upload-Post
@@ -78,6 +78,10 @@ print(response)
78
78
  - ✅ **Analytics** - Get engagement metrics
79
79
  - ✅ **Audience** - Who follows a profile, per platform
80
80
  - ✅ **Suggestions** - Hashtags and searches to post about, per platform
81
+ - ✅ **Comments** - Read, reply, moderate; replies under a comment on TikTok, Threads, YouTube, Instagram, Facebook and LinkedIn
82
+ - ✅ **Direct Messages** - Instagram, Messenger, WhatsApp and Bluesky
83
+ - ✅ **AutoDM rules** - Account-wide Instagram comment/story/keyword DMs
84
+ - ✅ **Webhook deliveries** - 7-day delivery log and redelivery
81
85
  - ✅ **Full Type Hints**
82
86
 
83
87
  ## API Reference
@@ -256,6 +260,33 @@ print(analytics["analytics"]["instagram"]["follower_demographics"])
256
260
  print(analytics["analytics"]["instagram"]["engaged_audience_demographics"])
257
261
  ```
258
262
 
263
+ ### Post Analytics and TikTok drafts
264
+
265
+ ```python
266
+ # Every platform the upload went to, or just one of them
267
+ stats = client.get_post_analytics("REQUEST_ID")
268
+ tiktok = client.get_post_analytics("REQUEST_ID", platform="tiktok")
269
+
270
+ # Any post of the account by its native id (e.g. one returned by get_media)
271
+ client.get_post_analytics_by_platform_id("7412345678901234567", "tiktok", "my-profile")
272
+ ```
273
+
274
+ A TikTok upload sent to drafts (`post_mode="MEDIA_UPLOAD"` or
275
+ `tiktok_upload_to_draft=True`) has no post until the creator publishes it from
276
+ the TikTok app. Call `get_post_analytics(request_id)` after that: Upload-Post
277
+ asks TikTok which post came from the draft, then returns its metrics and fills
278
+ in `platform_post_id` and `post_url`.
279
+
280
+ - While the draft is still in the inbox, `post_metrics_error` says so. That
281
+ answer is reused for a while (about 5 minutes for a fresh upload, up to 6
282
+ hours for one several days old); after 30 days without a post Upload-Post
283
+ stops asking.
284
+ - TikTok only gives the post id once the post is **public** and moderated. A
285
+ draft published as private never gets per-post analytics.
286
+ - TikTok rows carry `original_publish_id` (the upload's TikTok `publish_id`),
287
+ here and in `get_cached_post_analytics`. `post_url` is empty when the
288
+ account's @username is not known.
289
+
259
290
  ### Cached Post Analytics
260
291
 
261
292
  Replays per-post metrics already fetched, instead of calling the platforms again. Only contains posts previously fetched through a live per-post endpoint; there is no background refresh, so captured_at is the last time that post was read live. Not subject to the live
@@ -324,6 +355,12 @@ fb_pages = client.get_facebook_pages("my-profile")
324
355
  # Get LinkedIn pages for a profile
325
356
  li_pages = client.get_linkedin_pages("my-profile")
326
357
 
358
+ # Resolve any LinkedIn company Page to the urn + exact name a mention needs
359
+ org = client.resolve_linkedin_organization(
360
+ "my-profile", "https://www.linkedin.com/company/linkedin/"
361
+ )
362
+ print(org["urn"], org["name"], org["mention"]) # urn:li:organization:1337 LinkedIn @[LinkedIn](urn:li:organization:1337)
363
+
327
364
  # Get Pinterest boards for a profile
328
365
  boards = client.get_pinterest_boards("my-profile")
329
366
 
@@ -362,6 +399,15 @@ replies = client.get_post_comments(
362
399
  comment_id="7412345678909999999",
363
400
  )
364
401
 
402
+ # Same on Instagram, Facebook and LinkedIn (company Pages). On LinkedIn pass the
403
+ # comment_urn from the listing; a bare id is only looked up in the first 250.
404
+ replies = client.get_post_comments(
405
+ user="my-profile",
406
+ platform="linkedin",
407
+ post_id="urn:li:share:7500811262105264139",
408
+ comment_id="urn:li:comment:(urn:li:activity:7500811262105264139,7503818768586981376)",
409
+ )
410
+
365
411
  # Comment on the post, or reply to a comment
366
412
  client.create_comment(
367
413
  user="my-profile",
@@ -389,6 +435,20 @@ On TikTok all of this needs the `comments` capability, which the account grants
389
435
  when it connects — see
390
436
  [What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
391
437
 
438
+ Replies (`comment_id`) work on TikTok, Threads, YouTube, Instagram, Facebook and
439
+ LinkedIn (company Pages only); `post_id` is still required. X and Bluesky answer
440
+ `platform_not_supported`. Error codes you can branch on with
441
+ `UploadPostError.error_code`:
442
+
443
+ | `error_code` | Status | Meaning |
444
+ | --- | --- | --- |
445
+ | `invalid_comment_id` | 400 | Wrong format (Instagram: digits; Facebook: `{post}_{comment}`; LinkedIn: digits or the `comment_urn`) |
446
+ | `invalid_cursor` | 400 | LinkedIn replies: `after` must be the number from `next_cursor` |
447
+ | `comment_not_found` | 404 | Instagram/Facebook do not know the comment (one without replies is a 200 with an empty list) |
448
+ | `linkedin_comment_not_found` | 404 | Bare LinkedIn id not among the post's first 250 comments: pass the `comment_urn` |
449
+ | `linkedin_personal_not_supported` | 403 | Personal LinkedIn profile: LinkedIn only lets us read company Pages |
450
+ | `linkedin_page_access_denied` | 403 | The member who connected LinkedIn is no longer an admin of the Page |
451
+
392
452
  #### Moderating a comment: hide, like, pin
393
453
 
394
454
  `comment_action()` does the three and undoes them, on any platform that
@@ -416,6 +476,98 @@ client.comment_action(
416
476
  | `like` | `unlike` | not sent |
417
477
  | `pin` | `unpin` | required |
418
478
 
479
+ ### Direct Messages
480
+
481
+ One pair of methods for every messaging platform: `instagram`, `facebook`
482
+ (Messenger), `whatsapp` and `bluesky`.
483
+
484
+ ```python
485
+ # Instagram: by the person's Instagram-scoped id, inside the 24-hour window
486
+ client.send_dm("my-profile", "instagram", recipient_id="1784...", message="Thanks!")
487
+
488
+ # Bluesky: by handle, DID or profile link. Links, #hashtags and @mentions are clickable
489
+ sent = client.send_dm(
490
+ "my-profile", "bluesky",
491
+ recipient_id="alice.bsky.social",
492
+ message="Our new guide: https://example.com/guide",
493
+ )
494
+
495
+ # Share a Bluesky post, reply to a message, or act on one (no DM quota used)
496
+ client.send_dm("my-profile", "bluesky", recipient_id="alice.bsky.social",
497
+ message="Look at this",
498
+ attachments={"type": "post", "url": "https://bsky.app/profile/alice.bsky.social/post/3l5t..."})
499
+ client.send_dm("my-profile", "bluesky", conversation_id=sent["conversation_id"],
500
+ sender_action="react", message_id=sent["message_id"], reaction="🔥")
501
+ client.send_dm("my-profile", "bluesky", conversation_id=sent["conversation_id"],
502
+ sender_action="mark_read")
503
+
504
+ # Read conversations (Bluesky: at most 25 per page)
505
+ page = client.get_dm_conversations("my-profile", "bluesky", messages_limit=5)
506
+ for convo in page["conversations"]:
507
+ print(convo["id"], convo.get("status"), convo.get("unread_count"))
508
+
509
+ # Bluesky-only filters: one conversation and its older messages, requests, unread
510
+ client.get_dm_conversations("my-profile", "bluesky", conversation_id="3l5ttwb6ycx2f",
511
+ messages_cursor="...")
512
+ client.get_dm_conversations("my-profile", "bluesky", status="request", read_state="unread")
513
+ ```
514
+
515
+ Any other `/dms/send` field (WhatsApp `template`, `location`, `contacts`,
516
+ `list`, `cta_url`; Messenger `tag`, `template`...) goes through as a keyword
517
+ argument. **Bluesky DMs need an app password created with "Allow access to your
518
+ direct messages"** (Bluesky > Settings > Privacy and security > App passwords),
519
+ then reconnect Bluesky with it; otherwise the API answers 403
520
+ `bluesky_dm_scope_missing`. Other Bluesky codes:
521
+
522
+ | `error_code` | Status | Meaning |
523
+ | --- | --- | --- |
524
+ | `bluesky_recipient_unavailable` | 403 | The recipient does not accept your DM (`reason`: `MessagesDisabled`, `NotFollowedBySender`, `BlockedActor`...). No quota used |
525
+ | `bluesky_recipient_not_found` / `bluesky_conversation_not_found` / `bluesky_post_not_found` | 404 | Unknown handle, conversation or shared post |
526
+ | `bluesky_message_too_long` | 400 | More than 1000 characters. Nothing sent |
527
+ | `bluesky_rejected` | 400 | Bluesky refused it (e.g. a reaction that is not a single emoji) |
528
+ | `bluesky_reauth_required` | 401 | Reconnect Bluesky |
529
+ | `bluesky_rate_limited` | 429 | Wait `retry_after` seconds |
530
+ | `bluesky_chat_unavailable` / `bluesky_edge_rate_limited` | 502 / 503 | Nothing was sent; retry later |
531
+ | `bluesky_send_unconfirmed` | 504 | **It may have been sent.** Do not retry automatically: read the conversation first |
532
+
533
+ New DMs (Instagram, Messenger, WhatsApp and Bluesky) can also reach you as the
534
+ opt-in `dm_received` webhook. Bluesky has no push, so Upload-Post checks every
535
+ 5 minutes.
536
+
537
+ ### AutoDM rules (whole Instagram account)
538
+
539
+ A rule answers comments on **every** post and reel of the account (present and
540
+ future), plus story replies, story mentions and keyword DMs. Rules never expire
541
+ and work only from Meta's real-time notifications.
542
+
543
+ ```python
544
+ rule = client.create_autodm_rule(
545
+ profile_username="my-profile",
546
+ reply_message="Here is your free guide!",
547
+ triggers=["comment", "story_reply", "story_mention", "dm_keyword"],
548
+ trigger_keywords=["guide"], # required for dm_keyword
549
+ buttons=[{"title": "Get the guide", "url": "https://example.com/guide"}],
550
+ per_user_cooldown_hours=24, # 0-720; never < 24 for story/DM triggers
551
+ )["rule"]
552
+
553
+ for r in client.list_autodm_rules("my-profile")["rules"]:
554
+ print(r["rule_id"], r["status"], r["webhook"]["receiving_events"], r["webhook"].get("hint"))
555
+
556
+ client.pause_autodm_rule(rule["rule_id"])
557
+ client.resume_autodm_rule(rule["rule_id"])
558
+ client.delete_autodm_rule(rule["rule_id"])
559
+ ```
560
+
561
+ - Up to 10 rules per Instagram account; they do not count towards the
562
+ 2-monitors-per-day limit. A post with its own monitor is answered by that
563
+ monitor only.
564
+ - Pace: 25 DMs per account every 15 minutes plus the plan's daily DM allowance;
565
+ what does not fit is queued for up to 24 hours.
566
+ - Errors: 409 `duplicate_rule` / `rule_limit_reached` /
567
+ `rule_creation_in_progress`, 502 `webhook_subscription_failed` (reconnect
568
+ Instagram), 503 `account_rules_disabled`, 409 `rule_stopped` on resume when
569
+ the account left its profile.
570
+
419
571
  ### Audience
420
572
 
421
573
  Where the analytics methods answer *how did my posts do*, `get_audience()`
@@ -484,6 +636,52 @@ vs non-followers), `new_followers`, `reach` and the watch times.
484
636
  Asking a platform a question it cannot answer fails with
485
637
  `platform_not_supported` and the list of the ones that can.
486
638
 
639
+ ## Webhooks: configuration, delivery log and redelivery
640
+
641
+ ```python
642
+ # Turn the webhook on and opt into the inbound events
643
+ client.update_notification_config(
644
+ webhook_url="https://example.com/upload-post",
645
+ webhook_events=["upload_completed", "dm_received", "comment_received"],
646
+ channels={"webhook": True},
647
+ )
648
+ print(client.get_notification_config()["notifications"]["webhook_secret"])
649
+
650
+ # Did an event reach my server? (7-day history, newest first)
651
+ failed = client.list_webhook_deliveries(status="failed", limit=20, include_payload=True)
652
+ for d in failed["deliveries"]:
653
+ print(d["delivery_id"], d["event"], d["attempt_count"], d["last_status_code"], d["last_error"])
654
+
655
+ # One delivery with its exact payload, and send it again
656
+ one = client.get_webhook_delivery(failed["deliveries"][0]["delivery_id"])
657
+ again = client.redeliver_webhook(one["delivery"]["delivery_id"])
658
+ print(again["delivery"]["delivery_id"], again["delivery"]["redelivery_of"])
659
+ ```
660
+
661
+ Webhooks are delivered **at least once**: a timeout, connection error, `5xx`,
662
+ `408` or `429` is retried with the same body and the same
663
+ `X-Upload-Post-Delivery` id (6 attempts over about 8.5 hours, header
664
+ `X-Upload-Post-Attempt` 1-6); other `4xx` answers are final. Deduplicate by
665
+ `X-Upload-Post-Delivery`. A redelivery is a new delivery with its own id and
666
+ `X-Upload-Post-Redelivery-Of`. Filters of `list_webhook_deliveries`: `event`,
667
+ `status` (`pending`, `delivered`, `failed`, `cancelled`), `request_id`,
668
+ `job_id`, `profile_username`, `limit` (1-100), `page` (1-50). `redeliver_webhook`
669
+ answers 409 `webhook_removed` / `event_unsubscribed` / `blocked_url` when there
670
+ is nowhere to send it.
671
+
672
+ ## Team members (acting on the owner's account)
673
+
674
+ ```python
675
+ client = UploadPostClient("MEMBER_API_KEY", act_as_user="owner@example.com")
676
+ client.list_users() # the owner's profiles
677
+ ```
678
+
679
+ Every request carries `X-Act-As-User`. If the owner's current plan no longer
680
+ covers your seat (the last invited members are suspended after a downgrade,
681
+ never removed), requests fail with `status_code` 403 and `error_code`
682
+ `"TEAM_SEAT_SUSPENDED"`; access comes back by itself when the owner upgrades or
683
+ frees a seat.
684
+
487
685
  ## What a TikTok connection can do (`capabilities`)
488
686
 
489
687
  Not every TikTok connection can do the same things. `client.list_users()`
@@ -696,7 +894,7 @@ for the music, location, cover and draft options.
696
894
  - `x_alt_text` - Alt text (string, `x_alt_text[]`, or JSON array; ≤1000 chars)
697
895
  - `x_subtitles` / `x_subtitles_url` / `x_subtitles_language` / `x_subtitles_name` - Video SRT captions
698
896
  - `x_paid_partnership` - Paid partnership flag on the first post
699
- - `x_article_title` / `x_article_body` / `x_article_content_state` / `x_article_draft` / `x_article_cover_media` - X Articles (text; Premium)
897
+ - `x_article_title` / `x_article_body` / `x_article_content_state` / `x_article_draft` / `x_article_cover_media` - X Articles (text; Premium). Articles have two heading levels: a `### Heading` in the body, or a `header-three` block in `x_article_content_state`, is published as `header-two`
700
898
 
701
899
  ### Threads
702
900
  - `threads_long_text_as_post` - Post long text as single post (vs thread). Over 500 UTF-8 bytes this sends an official long post instead of truncating.
@@ -749,7 +947,7 @@ client.upload_photos(
749
947
  - `bluesky_alt_text` - Alt text per image/video (JSON array, `||`-separated, or string)
750
948
  - `bluesky_langs` - Comma list, max 3 BCP-47 codes
751
949
  - `bluesky_labels` - `porn`, `sexual`, `nudity`, `graphic-media`
752
- - `bluesky_gallery` - Publish up to 20 images as a gallery (`false` keeps the first 4)
950
+ - `bluesky_gallery` - Publish up to 10 images as a gallery (`false` keeps the first 4). With more than 10, the first 10 are published and the response carries a `warnings` entry
753
951
  - `bluesky_threadgate` / `bluesky_reply_settings` - Who can reply
754
952
  - `bluesky_postgate` / `bluesky_quote_settings` - `disable_quotes` to block quotes
755
953
  - `bluesky_quote_uri` / `bluesky_quote_id` / `bluesky_quote_url` - Quote another post
@@ -834,6 +1032,10 @@ try:
834
1032
  print("Upload successful:", response)
835
1033
  except UploadPostError as e:
836
1034
  print("Upload failed:", str(e))
1035
+ print(e.status_code) # HTTP status, or None if no answer arrived
1036
+ print(e.error_code) # stable code, e.g. "bluesky_dm_scope_missing", "TEAM_SEAT_SUSPENDED"
1037
+ print(e.response) # decoded JSON body of the error
1038
+ print(e.retry_after) # Retry-After seconds, when the API sent it
837
1039
  ```
838
1040
 
839
1041
  ## Links
@@ -40,6 +40,10 @@ print(response)
40
40
  - ✅ **Analytics** - Get engagement metrics
41
41
  - ✅ **Audience** - Who follows a profile, per platform
42
42
  - ✅ **Suggestions** - Hashtags and searches to post about, per platform
43
+ - ✅ **Comments** - Read, reply, moderate; replies under a comment on TikTok, Threads, YouTube, Instagram, Facebook and LinkedIn
44
+ - ✅ **Direct Messages** - Instagram, Messenger, WhatsApp and Bluesky
45
+ - ✅ **AutoDM rules** - Account-wide Instagram comment/story/keyword DMs
46
+ - ✅ **Webhook deliveries** - 7-day delivery log and redelivery
43
47
  - ✅ **Full Type Hints**
44
48
 
45
49
  ## API Reference
@@ -218,6 +222,33 @@ print(analytics["analytics"]["instagram"]["follower_demographics"])
218
222
  print(analytics["analytics"]["instagram"]["engaged_audience_demographics"])
219
223
  ```
220
224
 
225
+ ### Post Analytics and TikTok drafts
226
+
227
+ ```python
228
+ # Every platform the upload went to, or just one of them
229
+ stats = client.get_post_analytics("REQUEST_ID")
230
+ tiktok = client.get_post_analytics("REQUEST_ID", platform="tiktok")
231
+
232
+ # Any post of the account by its native id (e.g. one returned by get_media)
233
+ client.get_post_analytics_by_platform_id("7412345678901234567", "tiktok", "my-profile")
234
+ ```
235
+
236
+ A TikTok upload sent to drafts (`post_mode="MEDIA_UPLOAD"` or
237
+ `tiktok_upload_to_draft=True`) has no post until the creator publishes it from
238
+ the TikTok app. Call `get_post_analytics(request_id)` after that: Upload-Post
239
+ asks TikTok which post came from the draft, then returns its metrics and fills
240
+ in `platform_post_id` and `post_url`.
241
+
242
+ - While the draft is still in the inbox, `post_metrics_error` says so. That
243
+ answer is reused for a while (about 5 minutes for a fresh upload, up to 6
244
+ hours for one several days old); after 30 days without a post Upload-Post
245
+ stops asking.
246
+ - TikTok only gives the post id once the post is **public** and moderated. A
247
+ draft published as private never gets per-post analytics.
248
+ - TikTok rows carry `original_publish_id` (the upload's TikTok `publish_id`),
249
+ here and in `get_cached_post_analytics`. `post_url` is empty when the
250
+ account's @username is not known.
251
+
221
252
  ### Cached Post Analytics
222
253
 
223
254
  Replays per-post metrics already fetched, instead of calling the platforms again. Only contains posts previously fetched through a live per-post endpoint; there is no background refresh, so captured_at is the last time that post was read live. Not subject to the live
@@ -286,6 +317,12 @@ fb_pages = client.get_facebook_pages("my-profile")
286
317
  # Get LinkedIn pages for a profile
287
318
  li_pages = client.get_linkedin_pages("my-profile")
288
319
 
320
+ # Resolve any LinkedIn company Page to the urn + exact name a mention needs
321
+ org = client.resolve_linkedin_organization(
322
+ "my-profile", "https://www.linkedin.com/company/linkedin/"
323
+ )
324
+ print(org["urn"], org["name"], org["mention"]) # urn:li:organization:1337 LinkedIn @[LinkedIn](urn:li:organization:1337)
325
+
289
326
  # Get Pinterest boards for a profile
290
327
  boards = client.get_pinterest_boards("my-profile")
291
328
 
@@ -324,6 +361,15 @@ replies = client.get_post_comments(
324
361
  comment_id="7412345678909999999",
325
362
  )
326
363
 
364
+ # Same on Instagram, Facebook and LinkedIn (company Pages). On LinkedIn pass the
365
+ # comment_urn from the listing; a bare id is only looked up in the first 250.
366
+ replies = client.get_post_comments(
367
+ user="my-profile",
368
+ platform="linkedin",
369
+ post_id="urn:li:share:7500811262105264139",
370
+ comment_id="urn:li:comment:(urn:li:activity:7500811262105264139,7503818768586981376)",
371
+ )
372
+
327
373
  # Comment on the post, or reply to a comment
328
374
  client.create_comment(
329
375
  user="my-profile",
@@ -351,6 +397,20 @@ On TikTok all of this needs the `comments` capability, which the account grants
351
397
  when it connects — see
352
398
  [What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
353
399
 
400
+ Replies (`comment_id`) work on TikTok, Threads, YouTube, Instagram, Facebook and
401
+ LinkedIn (company Pages only); `post_id` is still required. X and Bluesky answer
402
+ `platform_not_supported`. Error codes you can branch on with
403
+ `UploadPostError.error_code`:
404
+
405
+ | `error_code` | Status | Meaning |
406
+ | --- | --- | --- |
407
+ | `invalid_comment_id` | 400 | Wrong format (Instagram: digits; Facebook: `{post}_{comment}`; LinkedIn: digits or the `comment_urn`) |
408
+ | `invalid_cursor` | 400 | LinkedIn replies: `after` must be the number from `next_cursor` |
409
+ | `comment_not_found` | 404 | Instagram/Facebook do not know the comment (one without replies is a 200 with an empty list) |
410
+ | `linkedin_comment_not_found` | 404 | Bare LinkedIn id not among the post's first 250 comments: pass the `comment_urn` |
411
+ | `linkedin_personal_not_supported` | 403 | Personal LinkedIn profile: LinkedIn only lets us read company Pages |
412
+ | `linkedin_page_access_denied` | 403 | The member who connected LinkedIn is no longer an admin of the Page |
413
+
354
414
  #### Moderating a comment: hide, like, pin
355
415
 
356
416
  `comment_action()` does the three and undoes them, on any platform that
@@ -378,6 +438,98 @@ client.comment_action(
378
438
  | `like` | `unlike` | not sent |
379
439
  | `pin` | `unpin` | required |
380
440
 
441
+ ### Direct Messages
442
+
443
+ One pair of methods for every messaging platform: `instagram`, `facebook`
444
+ (Messenger), `whatsapp` and `bluesky`.
445
+
446
+ ```python
447
+ # Instagram: by the person's Instagram-scoped id, inside the 24-hour window
448
+ client.send_dm("my-profile", "instagram", recipient_id="1784...", message="Thanks!")
449
+
450
+ # Bluesky: by handle, DID or profile link. Links, #hashtags and @mentions are clickable
451
+ sent = client.send_dm(
452
+ "my-profile", "bluesky",
453
+ recipient_id="alice.bsky.social",
454
+ message="Our new guide: https://example.com/guide",
455
+ )
456
+
457
+ # Share a Bluesky post, reply to a message, or act on one (no DM quota used)
458
+ client.send_dm("my-profile", "bluesky", recipient_id="alice.bsky.social",
459
+ message="Look at this",
460
+ attachments={"type": "post", "url": "https://bsky.app/profile/alice.bsky.social/post/3l5t..."})
461
+ client.send_dm("my-profile", "bluesky", conversation_id=sent["conversation_id"],
462
+ sender_action="react", message_id=sent["message_id"], reaction="🔥")
463
+ client.send_dm("my-profile", "bluesky", conversation_id=sent["conversation_id"],
464
+ sender_action="mark_read")
465
+
466
+ # Read conversations (Bluesky: at most 25 per page)
467
+ page = client.get_dm_conversations("my-profile", "bluesky", messages_limit=5)
468
+ for convo in page["conversations"]:
469
+ print(convo["id"], convo.get("status"), convo.get("unread_count"))
470
+
471
+ # Bluesky-only filters: one conversation and its older messages, requests, unread
472
+ client.get_dm_conversations("my-profile", "bluesky", conversation_id="3l5ttwb6ycx2f",
473
+ messages_cursor="...")
474
+ client.get_dm_conversations("my-profile", "bluesky", status="request", read_state="unread")
475
+ ```
476
+
477
+ Any other `/dms/send` field (WhatsApp `template`, `location`, `contacts`,
478
+ `list`, `cta_url`; Messenger `tag`, `template`...) goes through as a keyword
479
+ argument. **Bluesky DMs need an app password created with "Allow access to your
480
+ direct messages"** (Bluesky > Settings > Privacy and security > App passwords),
481
+ then reconnect Bluesky with it; otherwise the API answers 403
482
+ `bluesky_dm_scope_missing`. Other Bluesky codes:
483
+
484
+ | `error_code` | Status | Meaning |
485
+ | --- | --- | --- |
486
+ | `bluesky_recipient_unavailable` | 403 | The recipient does not accept your DM (`reason`: `MessagesDisabled`, `NotFollowedBySender`, `BlockedActor`...). No quota used |
487
+ | `bluesky_recipient_not_found` / `bluesky_conversation_not_found` / `bluesky_post_not_found` | 404 | Unknown handle, conversation or shared post |
488
+ | `bluesky_message_too_long` | 400 | More than 1000 characters. Nothing sent |
489
+ | `bluesky_rejected` | 400 | Bluesky refused it (e.g. a reaction that is not a single emoji) |
490
+ | `bluesky_reauth_required` | 401 | Reconnect Bluesky |
491
+ | `bluesky_rate_limited` | 429 | Wait `retry_after` seconds |
492
+ | `bluesky_chat_unavailable` / `bluesky_edge_rate_limited` | 502 / 503 | Nothing was sent; retry later |
493
+ | `bluesky_send_unconfirmed` | 504 | **It may have been sent.** Do not retry automatically: read the conversation first |
494
+
495
+ New DMs (Instagram, Messenger, WhatsApp and Bluesky) can also reach you as the
496
+ opt-in `dm_received` webhook. Bluesky has no push, so Upload-Post checks every
497
+ 5 minutes.
498
+
499
+ ### AutoDM rules (whole Instagram account)
500
+
501
+ A rule answers comments on **every** post and reel of the account (present and
502
+ future), plus story replies, story mentions and keyword DMs. Rules never expire
503
+ and work only from Meta's real-time notifications.
504
+
505
+ ```python
506
+ rule = client.create_autodm_rule(
507
+ profile_username="my-profile",
508
+ reply_message="Here is your free guide!",
509
+ triggers=["comment", "story_reply", "story_mention", "dm_keyword"],
510
+ trigger_keywords=["guide"], # required for dm_keyword
511
+ buttons=[{"title": "Get the guide", "url": "https://example.com/guide"}],
512
+ per_user_cooldown_hours=24, # 0-720; never < 24 for story/DM triggers
513
+ )["rule"]
514
+
515
+ for r in client.list_autodm_rules("my-profile")["rules"]:
516
+ print(r["rule_id"], r["status"], r["webhook"]["receiving_events"], r["webhook"].get("hint"))
517
+
518
+ client.pause_autodm_rule(rule["rule_id"])
519
+ client.resume_autodm_rule(rule["rule_id"])
520
+ client.delete_autodm_rule(rule["rule_id"])
521
+ ```
522
+
523
+ - Up to 10 rules per Instagram account; they do not count towards the
524
+ 2-monitors-per-day limit. A post with its own monitor is answered by that
525
+ monitor only.
526
+ - Pace: 25 DMs per account every 15 minutes plus the plan's daily DM allowance;
527
+ what does not fit is queued for up to 24 hours.
528
+ - Errors: 409 `duplicate_rule` / `rule_limit_reached` /
529
+ `rule_creation_in_progress`, 502 `webhook_subscription_failed` (reconnect
530
+ Instagram), 503 `account_rules_disabled`, 409 `rule_stopped` on resume when
531
+ the account left its profile.
532
+
381
533
  ### Audience
382
534
 
383
535
  Where the analytics methods answer *how did my posts do*, `get_audience()`
@@ -446,6 +598,52 @@ vs non-followers), `new_followers`, `reach` and the watch times.
446
598
  Asking a platform a question it cannot answer fails with
447
599
  `platform_not_supported` and the list of the ones that can.
448
600
 
601
+ ## Webhooks: configuration, delivery log and redelivery
602
+
603
+ ```python
604
+ # Turn the webhook on and opt into the inbound events
605
+ client.update_notification_config(
606
+ webhook_url="https://example.com/upload-post",
607
+ webhook_events=["upload_completed", "dm_received", "comment_received"],
608
+ channels={"webhook": True},
609
+ )
610
+ print(client.get_notification_config()["notifications"]["webhook_secret"])
611
+
612
+ # Did an event reach my server? (7-day history, newest first)
613
+ failed = client.list_webhook_deliveries(status="failed", limit=20, include_payload=True)
614
+ for d in failed["deliveries"]:
615
+ print(d["delivery_id"], d["event"], d["attempt_count"], d["last_status_code"], d["last_error"])
616
+
617
+ # One delivery with its exact payload, and send it again
618
+ one = client.get_webhook_delivery(failed["deliveries"][0]["delivery_id"])
619
+ again = client.redeliver_webhook(one["delivery"]["delivery_id"])
620
+ print(again["delivery"]["delivery_id"], again["delivery"]["redelivery_of"])
621
+ ```
622
+
623
+ Webhooks are delivered **at least once**: a timeout, connection error, `5xx`,
624
+ `408` or `429` is retried with the same body and the same
625
+ `X-Upload-Post-Delivery` id (6 attempts over about 8.5 hours, header
626
+ `X-Upload-Post-Attempt` 1-6); other `4xx` answers are final. Deduplicate by
627
+ `X-Upload-Post-Delivery`. A redelivery is a new delivery with its own id and
628
+ `X-Upload-Post-Redelivery-Of`. Filters of `list_webhook_deliveries`: `event`,
629
+ `status` (`pending`, `delivered`, `failed`, `cancelled`), `request_id`,
630
+ `job_id`, `profile_username`, `limit` (1-100), `page` (1-50). `redeliver_webhook`
631
+ answers 409 `webhook_removed` / `event_unsubscribed` / `blocked_url` when there
632
+ is nowhere to send it.
633
+
634
+ ## Team members (acting on the owner's account)
635
+
636
+ ```python
637
+ client = UploadPostClient("MEMBER_API_KEY", act_as_user="owner@example.com")
638
+ client.list_users() # the owner's profiles
639
+ ```
640
+
641
+ Every request carries `X-Act-As-User`. If the owner's current plan no longer
642
+ covers your seat (the last invited members are suspended after a downgrade,
643
+ never removed), requests fail with `status_code` 403 and `error_code`
644
+ `"TEAM_SEAT_SUSPENDED"`; access comes back by itself when the owner upgrades or
645
+ frees a seat.
646
+
449
647
  ## What a TikTok connection can do (`capabilities`)
450
648
 
451
649
  Not every TikTok connection can do the same things. `client.list_users()`
@@ -658,7 +856,7 @@ for the music, location, cover and draft options.
658
856
  - `x_alt_text` - Alt text (string, `x_alt_text[]`, or JSON array; ≤1000 chars)
659
857
  - `x_subtitles` / `x_subtitles_url` / `x_subtitles_language` / `x_subtitles_name` - Video SRT captions
660
858
  - `x_paid_partnership` - Paid partnership flag on the first post
661
- - `x_article_title` / `x_article_body` / `x_article_content_state` / `x_article_draft` / `x_article_cover_media` - X Articles (text; Premium)
859
+ - `x_article_title` / `x_article_body` / `x_article_content_state` / `x_article_draft` / `x_article_cover_media` - X Articles (text; Premium). Articles have two heading levels: a `### Heading` in the body, or a `header-three` block in `x_article_content_state`, is published as `header-two`
662
860
 
663
861
  ### Threads
664
862
  - `threads_long_text_as_post` - Post long text as single post (vs thread). Over 500 UTF-8 bytes this sends an official long post instead of truncating.
@@ -711,7 +909,7 @@ client.upload_photos(
711
909
  - `bluesky_alt_text` - Alt text per image/video (JSON array, `||`-separated, or string)
712
910
  - `bluesky_langs` - Comma list, max 3 BCP-47 codes
713
911
  - `bluesky_labels` - `porn`, `sexual`, `nudity`, `graphic-media`
714
- - `bluesky_gallery` - Publish up to 20 images as a gallery (`false` keeps the first 4)
912
+ - `bluesky_gallery` - Publish up to 10 images as a gallery (`false` keeps the first 4). With more than 10, the first 10 are published and the response carries a `warnings` entry
715
913
  - `bluesky_threadgate` / `bluesky_reply_settings` - Who can reply
716
914
  - `bluesky_postgate` / `bluesky_quote_settings` - `disable_quotes` to block quotes
717
915
  - `bluesky_quote_uri` / `bluesky_quote_id` / `bluesky_quote_url` - Quote another post
@@ -796,6 +994,10 @@ try:
796
994
  print("Upload successful:", response)
797
995
  except UploadPostError as e:
798
996
  print("Upload failed:", str(e))
997
+ print(e.status_code) # HTTP status, or None if no answer arrived
998
+ print(e.error_code) # stable code, e.g. "bluesky_dm_scope_missing", "TEAM_SEAT_SUSPENDED"
999
+ print(e.response) # decoded JSON body of the error
1000
+ print(e.retry_after) # Retry-After seconds, when the API sent it
799
1001
  ```
800
1002
 
801
1003
  ## Links
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="upload-post",
8
- version="2.14.0",
8
+ version="2.15.0",
9
9
  author="Upload-Post",
10
10
  author_email="hi@img2html.com",
11
11
  description="Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn, Facebook, Pinterest, Threads, Reddit, Bluesky, Discord, Telegram, and X (Twitter)",