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.
- {upload_post-2.13.0 → upload_post-2.15.0}/PKG-INFO +220 -3
- {upload_post-2.13.0 → upload_post-2.15.0}/README.md +219 -2
- {upload_post-2.13.0 → upload_post-2.15.0}/setup.py +1 -1
- upload_post-2.15.0/tests/test_edit_scheduled.py +68 -0
- upload_post-2.15.0/tests/test_messaging_webhooks.py +293 -0
- {upload_post-2.13.0 → upload_post-2.15.0}/tests/test_parity_fields.py +0 -1
- {upload_post-2.13.0 → upload_post-2.15.0}/upload_post/__init__.py +1 -1
- {upload_post-2.13.0 → upload_post-2.15.0}/upload_post/api_client.py +646 -32
- {upload_post-2.13.0 → upload_post-2.15.0}/upload_post.egg-info/PKG-INFO +220 -3
- {upload_post-2.13.0 → upload_post-2.15.0}/upload_post.egg-info/SOURCES.txt +2 -0
- {upload_post-2.13.0 → upload_post-2.15.0}/setup.cfg +0 -0
- {upload_post-2.13.0 → upload_post-2.15.0}/tests/test_comments_audience.py +0 -0
- {upload_post-2.13.0 → upload_post-2.15.0}/upload_post/cli.py +0 -0
- {upload_post-2.13.0 → upload_post-2.15.0}/upload_post.egg-info/dependency_links.txt +0 -0
- {upload_post-2.13.0 → upload_post-2.15.0}/upload_post.egg-info/requires.txt +0 -0
- {upload_post-2.13.0 → upload_post-2.15.0}/upload_post.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: upload-post
|
|
3
|
-
Version: 2.
|
|
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
|
|
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
|
|
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.
|
|
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)",
|