upload-post 2.13.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.13.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
@@ -191,6 +195,21 @@ client.edit_scheduled(
191
195
  timezone="America/New_York",
192
196
  )
193
197
 
198
+ # Fix the text in place (only the platforms listed change)
199
+ client.edit_scheduled(
200
+ "job-id",
201
+ platform_content={"instagram": {"caption": "New Instagram caption"}},
202
+ )
203
+
204
+ # Change any other option by its API form name (None removes it), schedule the
205
+ # video natively on YouTube, or replace the Reels cover
206
+ client.edit_scheduled(
207
+ "job-id",
208
+ fields={"privacyStatus": "unlisted", "disable_comment": True},
209
+ youtube_publish_at="2026-11-01T09:00:00Z",
210
+ cover_image_base64="data:image/jpeg;base64,...", # or remove_cover=True
211
+ )
212
+
194
213
  # Cancel a scheduled post
195
214
  client.cancel_scheduled("job-id")
196
215
  ```
@@ -241,6 +260,33 @@ print(analytics["analytics"]["instagram"]["follower_demographics"])
241
260
  print(analytics["analytics"]["instagram"]["engaged_audience_demographics"])
242
261
  ```
243
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
+
244
290
  ### Cached Post Analytics
245
291
 
246
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
@@ -309,6 +355,12 @@ fb_pages = client.get_facebook_pages("my-profile")
309
355
  # Get LinkedIn pages for a profile
310
356
  li_pages = client.get_linkedin_pages("my-profile")
311
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
+
312
364
  # Get Pinterest boards for a profile
313
365
  boards = client.get_pinterest_boards("my-profile")
314
366
 
@@ -347,6 +399,15 @@ replies = client.get_post_comments(
347
399
  comment_id="7412345678909999999",
348
400
  )
349
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
+
350
411
  # Comment on the post, or reply to a comment
351
412
  client.create_comment(
352
413
  user="my-profile",
@@ -374,6 +435,20 @@ On TikTok all of this needs the `comments` capability, which the account grants
374
435
  when it connects — see
375
436
  [What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
376
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
+
377
452
  #### Moderating a comment: hide, like, pin
378
453
 
379
454
  `comment_action()` does the three and undoes them, on any platform that
@@ -401,6 +476,98 @@ client.comment_action(
401
476
  | `like` | `unlike` | not sent |
402
477
  | `pin` | `unpin` | required |
403
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
+
404
571
  ### Audience
405
572
 
406
573
  Where the analytics methods answer *how did my posts do*, `get_audience()`
@@ -469,6 +636,52 @@ vs non-followers), `new_followers`, `reach` and the watch times.
469
636
  Asking a platform a question it cannot answer fails with
470
637
  `platform_not_supported` and the list of the ones that can.
471
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
+
472
685
  ## What a TikTok connection can do (`capabilities`)
473
686
 
474
687
  Not every TikTok connection can do the same things. `client.list_users()`
@@ -681,7 +894,7 @@ for the music, location, cover and draft options.
681
894
  - `x_alt_text` - Alt text (string, `x_alt_text[]`, or JSON array; ≤1000 chars)
682
895
  - `x_subtitles` / `x_subtitles_url` / `x_subtitles_language` / `x_subtitles_name` - Video SRT captions
683
896
  - `x_paid_partnership` - Paid partnership flag on the first post
684
- - `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`
685
898
 
686
899
  ### Threads
687
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.
@@ -734,7 +947,7 @@ client.upload_photos(
734
947
  - `bluesky_alt_text` - Alt text per image/video (JSON array, `||`-separated, or string)
735
948
  - `bluesky_langs` - Comma list, max 3 BCP-47 codes
736
949
  - `bluesky_labels` - `porn`, `sexual`, `nudity`, `graphic-media`
737
- - `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
738
951
  - `bluesky_threadgate` / `bluesky_reply_settings` - Who can reply
739
952
  - `bluesky_postgate` / `bluesky_quote_settings` - `disable_quotes` to block quotes
740
953
  - `bluesky_quote_uri` / `bluesky_quote_id` / `bluesky_quote_url` - Quote another post
@@ -819,6 +1032,10 @@ try:
819
1032
  print("Upload successful:", response)
820
1033
  except UploadPostError as e:
821
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
822
1039
  ```
823
1040
 
824
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
@@ -153,6 +157,21 @@ client.edit_scheduled(
153
157
  timezone="America/New_York",
154
158
  )
155
159
 
160
+ # Fix the text in place (only the platforms listed change)
161
+ client.edit_scheduled(
162
+ "job-id",
163
+ platform_content={"instagram": {"caption": "New Instagram caption"}},
164
+ )
165
+
166
+ # Change any other option by its API form name (None removes it), schedule the
167
+ # video natively on YouTube, or replace the Reels cover
168
+ client.edit_scheduled(
169
+ "job-id",
170
+ fields={"privacyStatus": "unlisted", "disable_comment": True},
171
+ youtube_publish_at="2026-11-01T09:00:00Z",
172
+ cover_image_base64="data:image/jpeg;base64,...", # or remove_cover=True
173
+ )
174
+
156
175
  # Cancel a scheduled post
157
176
  client.cancel_scheduled("job-id")
158
177
  ```
@@ -203,6 +222,33 @@ print(analytics["analytics"]["instagram"]["follower_demographics"])
203
222
  print(analytics["analytics"]["instagram"]["engaged_audience_demographics"])
204
223
  ```
205
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
+
206
252
  ### Cached Post Analytics
207
253
 
208
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
@@ -271,6 +317,12 @@ fb_pages = client.get_facebook_pages("my-profile")
271
317
  # Get LinkedIn pages for a profile
272
318
  li_pages = client.get_linkedin_pages("my-profile")
273
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
+
274
326
  # Get Pinterest boards for a profile
275
327
  boards = client.get_pinterest_boards("my-profile")
276
328
 
@@ -309,6 +361,15 @@ replies = client.get_post_comments(
309
361
  comment_id="7412345678909999999",
310
362
  )
311
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
+
312
373
  # Comment on the post, or reply to a comment
313
374
  client.create_comment(
314
375
  user="my-profile",
@@ -336,6 +397,20 @@ On TikTok all of this needs the `comments` capability, which the account grants
336
397
  when it connects — see
337
398
  [What a TikTok connection can do](#what-a-tiktok-connection-can-do-capabilities).
338
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
+
339
414
  #### Moderating a comment: hide, like, pin
340
415
 
341
416
  `comment_action()` does the three and undoes them, on any platform that
@@ -363,6 +438,98 @@ client.comment_action(
363
438
  | `like` | `unlike` | not sent |
364
439
  | `pin` | `unpin` | required |
365
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
+
366
533
  ### Audience
367
534
 
368
535
  Where the analytics methods answer *how did my posts do*, `get_audience()`
@@ -431,6 +598,52 @@ vs non-followers), `new_followers`, `reach` and the watch times.
431
598
  Asking a platform a question it cannot answer fails with
432
599
  `platform_not_supported` and the list of the ones that can.
433
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
+
434
647
  ## What a TikTok connection can do (`capabilities`)
435
648
 
436
649
  Not every TikTok connection can do the same things. `client.list_users()`
@@ -643,7 +856,7 @@ for the music, location, cover and draft options.
643
856
  - `x_alt_text` - Alt text (string, `x_alt_text[]`, or JSON array; ≤1000 chars)
644
857
  - `x_subtitles` / `x_subtitles_url` / `x_subtitles_language` / `x_subtitles_name` - Video SRT captions
645
858
  - `x_paid_partnership` - Paid partnership flag on the first post
646
- - `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`
647
860
 
648
861
  ### Threads
649
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.
@@ -696,7 +909,7 @@ client.upload_photos(
696
909
  - `bluesky_alt_text` - Alt text per image/video (JSON array, `||`-separated, or string)
697
910
  - `bluesky_langs` - Comma list, max 3 BCP-47 codes
698
911
  - `bluesky_labels` - `porn`, `sexual`, `nudity`, `graphic-media`
699
- - `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
700
913
  - `bluesky_threadgate` / `bluesky_reply_settings` - Who can reply
701
914
  - `bluesky_postgate` / `bluesky_quote_settings` - `disable_quotes` to block quotes
702
915
  - `bluesky_quote_uri` / `bluesky_quote_id` / `bluesky_quote_url` - Quote another post
@@ -781,6 +994,10 @@ try:
781
994
  print("Upload successful:", response)
782
995
  except UploadPostError as e:
783
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
784
1001
  ```
785
1002
 
786
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.13.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)",