postproxy-sdk 1.13.0 → 1.14.0
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.
- checksums.yaml +4 -4
- data/README.md +415 -11
- data/lib/postproxy/client.rb +72 -7
- data/lib/postproxy/constants.rb +37 -2
- data/lib/postproxy/errors.rb +15 -0
- data/lib/postproxy/resources/api_keys.rb +30 -0
- data/lib/postproxy/resources/chats.rb +30 -1
- data/lib/postproxy/resources/comments.rb +52 -4
- data/lib/postproxy/resources/google_business.rb +194 -0
- data/lib/postproxy/resources/messages.rb +25 -1
- data/lib/postproxy/resources/placements.rb +35 -0
- data/lib/postproxy/resources/posts.rb +86 -25
- data/lib/postproxy/resources/profile_groups.rb +8 -1
- data/lib/postproxy/resources/profiles.rb +131 -0
- data/lib/postproxy/resources/whatsapp.rb +278 -0
- data/lib/postproxy/resources/whatsapp_templates.rb +108 -0
- data/lib/postproxy/types.rb +408 -29
- data/lib/postproxy/version.rb +1 -1
- data/lib/postproxy/webhook_events.rb +90 -4
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7aef460a3a9d2e2188dc5098adf89fe29e1a917417e965c2234fc23a43019ca
|
|
4
|
+
data.tar.gz: 14c146f3c9b8fab34ff17b9a87e4fb4c4464a600b90fe5610bd93a3dc4a9baa1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4e3e06d85a609de033c1918c55c6388d1ff17fcf604576ee3aec7231e906d2328ffd6f57b6e5747c08ee16f9d5f3b5c04affb99eef012be43fdcd2dcf6e0a1b8
|
|
7
|
+
data.tar.gz: 66d7d324060a481a24002ecbaf00238478904e5fdd40916258f1f1aec88e67d8cbfef46d380ab8062304b9364ef3109774834335423a11d0ff56d186c1ffb5b4
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Postproxy Ruby SDK
|
|
2
2
|
|
|
3
|
-
Ruby client for the [
|
|
3
|
+
Ruby client for the [Postproxy API](https://postproxy.dev) — manage social media posts, profiles, and profile groups.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -109,6 +109,17 @@ post = client.posts.create(
|
|
|
109
109
|
media_files: ["/path/to/photo.jpg"]
|
|
110
110
|
)
|
|
111
111
|
|
|
112
|
+
# Media items can be URL strings or { url: } hashes
|
|
113
|
+
post = client.posts.create(
|
|
114
|
+
"Photos",
|
|
115
|
+
profiles: ["prof-1"],
|
|
116
|
+
media: ["https://example.com/1.jpg", { url: "https://example.com/2.jpg" }]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Skip the duplicate check (otherwise an identical post submitted moments ago
|
|
120
|
+
# raises ConflictError)
|
|
121
|
+
post = client.posts.create("Same text again", profiles: ["prof-1"], skip_duplicate_check: true)
|
|
122
|
+
|
|
112
123
|
# Create a draft
|
|
113
124
|
draft = client.posts.create("Draft", profiles: ["prof-1"], draft: true)
|
|
114
125
|
|
|
@@ -132,6 +143,17 @@ post = client.posts.create(
|
|
|
132
143
|
]
|
|
133
144
|
)
|
|
134
145
|
post.thread.each { |child| puts "#{child.id}: #{child.body}" }
|
|
146
|
+
# Each thread item is delivered on its own; its platforms show whether it went out
|
|
147
|
+
post.thread.each { |child| puts child.platforms.map { |p| "#{p.platform}: #{p.status}" } }
|
|
148
|
+
|
|
149
|
+
# Edit a post that's already published. Only the body, the YouTube title and
|
|
150
|
+
# the Pinterest title / destination_link can change; anything else is a 422.
|
|
151
|
+
# The edit is pushed to the platforms in the background — watch for the
|
|
152
|
+
# platform_post.edited / platform_post.edit_failed webhooks.
|
|
153
|
+
client.posts.update("published-post-id",
|
|
154
|
+
body: "Fixed a typo",
|
|
155
|
+
platforms: { youtube: { title: "Better title" } }
|
|
156
|
+
)
|
|
135
157
|
|
|
136
158
|
# Delete a post
|
|
137
159
|
client.posts.delete("post-id")
|
|
@@ -294,7 +316,11 @@ PostProxy::WebhookSignature.verify(
|
|
|
294
316
|
|
|
295
317
|
Subscribe to any of these events (or pass `["*"]` for all):
|
|
296
318
|
|
|
297
|
-
`post.processed`, `post.imported`, `platform_post.published`, `platform_post.failed`, `platform_post.failed_waiting_for_retry`, `platform_post.
|
|
319
|
+
- **Posts:** `post.processed`, `post.imported`, `platform_post.published`, `platform_post.failed`, `platform_post.failed_waiting_for_retry`, `platform_post.edited`, `platform_post.edit_failed`, `platform_post.insights`, `media.failed`
|
|
320
|
+
- **Profiles:** `profile.connected`, `profile.disconnected`, `profile.stats`, `profile.placements_synced`, `placement.orphaned`, `placement.restored`
|
|
321
|
+
- **Comments:** `comment.created`, `comment.failed`, `comment.failed_waiting_for_retry`, `comment.delivery_unconfirmed`, `comment.edited`, `comment.edit_failed`, `profile_comment.created`, `profile_comment.failed`, `profile_comment.failed_waiting_for_retry`, `profile_comment.deleted`
|
|
322
|
+
- **Messages:** `message.received`, `message.sent`, `message.delivered`, `message.read`, `message.edited`, `message.deleted`, `message.failed_waiting_for_retry`, `message.failed`, `reaction.received`, `referral.received`
|
|
323
|
+
- **WhatsApp:** `message_template.status_updated`, `message_template.category_updated`, `placement.name_status_updated`, `placement.quality_updated`, `whatsapp.automatic_event`
|
|
298
324
|
|
|
299
325
|
`PostProxy::WebhookEvents.parse` validates the envelope and returns a typed `Event` — `event.data` is the right model for the event:
|
|
300
326
|
|
|
@@ -316,10 +342,15 @@ when "reaction.received"
|
|
|
316
342
|
when "profile_comment.created"
|
|
317
343
|
# ProfileCommentCreatedData
|
|
318
344
|
puts "#{event.data.author_username}: #{event.data.body}"
|
|
345
|
+
when "platform_post.edited"
|
|
346
|
+
puts "Edit went live on #{event.data.platform}"
|
|
347
|
+
when "message_template.status_updated"
|
|
348
|
+
# MessageTemplateEventData
|
|
349
|
+
puts "#{event.data.name}: #{event.data.status}"
|
|
319
350
|
end
|
|
320
351
|
```
|
|
321
352
|
|
|
322
|
-
The direct-message events (`message.*`) carry a `MessageEventData` (`data.message` is a full `Message`); `reaction.received` carries a `ReactionEventData`; `profile_comment.
|
|
353
|
+
The direct-message events (`message.*`) carry a `MessageEventData` (`data.message` is a full `Message`); `reaction.received` carries a `ReactionEventData`; the `profile_comment.*` events carry a `ProfileCommentCreatedData` and the `comment.*` events a `CommentCreatedData` (with `error` / `error_details` on failures). `platform_post.edited` / `edit_failed` reuse `PlatformPostData`; `profile.placements_synced` carries a `PlacementsSyncedData`, the `placement.*` events a `PlacementEventData`, `referral.received` a `ReferralEventData`, `message_template.*` a `MessageTemplateEventData` and `whatsapp.automatic_event` a `WhatsAppAutomaticEventData`.
|
|
323
354
|
|
|
324
355
|
## Comments
|
|
325
356
|
|
|
@@ -366,6 +397,14 @@ client.comments.unhide("post-id", "comment-id", profile_id: "profile-id")
|
|
|
366
397
|
client.comments.like("post-id", "comment-id", profile_id: "profile-id")
|
|
367
398
|
client.comments.unlike("post-id", "comment-id", profile_id: "profile-id")
|
|
368
399
|
|
|
400
|
+
# Edit a comment you published (Facebook and YouTube). Runs in the
|
|
401
|
+
# background — comment.edited / comment.edit_failed report the outcome.
|
|
402
|
+
client.comments.edit("post-id", "comment-id", "Corrected text", profile_id: "profile-id")
|
|
403
|
+
|
|
404
|
+
# Pin / unpin a comment (TikTok)
|
|
405
|
+
client.comments.pin("post-id", "comment-id", profile_id: "profile-id")
|
|
406
|
+
client.comments.unpin("post-id", "comment-id", profile_id: "profile-id")
|
|
407
|
+
|
|
369
408
|
# Synced comments may carry media attachments and author metadata
|
|
370
409
|
comment = client.comments.get("post-id", "comment-id", profile_id: "profile-id")
|
|
371
410
|
comment.attachments.each { |att| puts "#{att.type} #{att.url} (#{att.status})" }
|
|
@@ -374,6 +413,20 @@ puts comment.metadata[:follower_count] if comment.metadata
|
|
|
374
413
|
# Private reply to a comment's author (Instagram/Facebook) — returns a Message
|
|
375
414
|
message = client.comments.private_reply("post-id", "comment-id", profile_id: "profile-id", text: "DM-ing you the details.")
|
|
376
415
|
puts message.chat_id, message.status
|
|
416
|
+
|
|
417
|
+
# Private replies take the same interactive options as messages.send —
|
|
418
|
+
# quick_replies, buttons, card, and a carousel of elements (text may be empty
|
|
419
|
+
# then). One private reply per comment, within 7 days of it.
|
|
420
|
+
client.comments.private_reply("post-id", "comment-id",
|
|
421
|
+
profile_id: "profile-id",
|
|
422
|
+
text: "",
|
|
423
|
+
elements: [
|
|
424
|
+
{ title: "Red sneaker", image_url: "https://cdn.example.com/red.png",
|
|
425
|
+
buttons: [{ type: "web_url", title: "Shop", url: "https://shop.example.com/red" }] },
|
|
426
|
+
{ title: "Blue sneaker", image_url: "https://cdn.example.com/blue.png",
|
|
427
|
+
buttons: [{ type: "web_url", title: "Shop", url: "https://shop.example.com/blue" }] }
|
|
428
|
+
]
|
|
429
|
+
)
|
|
377
430
|
```
|
|
378
431
|
|
|
379
432
|
### Comments across posts
|
|
@@ -389,6 +442,7 @@ top-level or reply, is its own entry linked to its parent by `parent_external_id
|
|
|
389
442
|
all = client.comments.list_all(
|
|
390
443
|
profiles: ["instagram", "prof-abc"], # profile IDs or network names, mixed
|
|
391
444
|
post_ids: ["post-1", "post-2"], # omit for every post in scope
|
|
445
|
+
placement_id: "page-123", # one Facebook Page / LinkedIn org
|
|
392
446
|
from: "2026-03-25",
|
|
393
447
|
per_page: 50 # max 100
|
|
394
448
|
)
|
|
@@ -410,7 +464,7 @@ Results are ordered newest first by receipt time.
|
|
|
410
464
|
|
|
411
465
|
## Direct Messages
|
|
412
466
|
|
|
413
|
-
Read and send 1:1 messages on DM-capable profiles (Facebook Messenger, Instagram, Telegram, Bluesky). A conversation is a **Chat**; it holds **Messages**. Outbound sends are processed asynchronously (`status` starts as `pending`).
|
|
467
|
+
Read and send 1:1 messages on DM-capable profiles (Facebook Messenger, Instagram, WhatsApp, Telegram, Bluesky). A conversation is a **Chat**; it holds **Messages**. Outbound sends are processed asynchronously (`status` starts as `pending`).
|
|
414
468
|
|
|
415
469
|
```ruby
|
|
416
470
|
# List chats for a profile (paginated, most recent first)
|
|
@@ -459,6 +513,13 @@ client.messages.unreact(sent.id)
|
|
|
459
513
|
client.chats.archive(chat.id)
|
|
460
514
|
client.chats.unarchive(chat.id)
|
|
461
515
|
|
|
516
|
+
# Mark the latest inbound message read (sends the read receipt on WhatsApp)
|
|
517
|
+
client.chats.mark_read(chat.id)
|
|
518
|
+
|
|
519
|
+
# Typing indicator (Facebook, Instagram, WhatsApp)
|
|
520
|
+
client.chats.start_typing(chat.id)
|
|
521
|
+
client.chats.stop_typing(chat.id)
|
|
522
|
+
|
|
462
523
|
# Private reply to a comment's author (Instagram/Facebook) — returns a Message
|
|
463
524
|
message = client.comments.private_reply("post-id", "comment-id", profile_id: "profile-id", text: "DM-ing you the details.")
|
|
464
525
|
puts message.chat_id, message.status
|
|
@@ -503,7 +564,7 @@ client.messages.send(
|
|
|
503
564
|
|
|
504
565
|
Buttons are delivered as a Meta generic template and your `body` becomes the template's
|
|
505
566
|
element title — so **`body` is capped at 80 characters when buttons are present**. That is
|
|
506
|
-
Meta's limit, not
|
|
567
|
+
Meta's limit, not Postproxy's, and a longer body is rejected with a `422` naming the
|
|
507
568
|
length. Buttons cannot be combined with media. Instagram is stricter than Messenger: it
|
|
508
569
|
delivers quick replies only on a plain-text message, so `quick_replies` with media or with
|
|
509
570
|
`buttons` returns `422` on Instagram while both are accepted on Facebook.
|
|
@@ -528,9 +589,85 @@ end
|
|
|
528
589
|
|
|
529
590
|
Subscribe to `message.received` to react to taps as they happen — the same field is on the
|
|
530
591
|
webhook payload. `tapped_action` is derived rather than stored, so it also resolves for
|
|
531
|
-
taps recorded before
|
|
592
|
+
taps recorded before Postproxy exposed it, including Instagram ice-breaker taps and
|
|
532
593
|
Telegram callback queries (`kind` `"callback_query"`). A tap also opens the 24h window.
|
|
533
594
|
|
|
595
|
+
### Carousels (Facebook & Instagram)
|
|
596
|
+
|
|
597
|
+
`elements` sends a horizontally scrolling carousel of up to 10 cards. Each card needs a
|
|
598
|
+
`title` (≤ 80 chars) plus at least one of `subtitle`, `image_url`, `default_action` or
|
|
599
|
+
`buttons` (≤ 3). A carousel can't be combined with `body`, `buttons` or `card`. Cards take
|
|
600
|
+
`PostProxy::MessageElement` instances or plain hashes:
|
|
601
|
+
|
|
602
|
+
```ruby
|
|
603
|
+
client.messages.send(
|
|
604
|
+
chat.id,
|
|
605
|
+
elements: [
|
|
606
|
+
PostProxy::MessageElement.new(
|
|
607
|
+
title: "Red sneaker",
|
|
608
|
+
subtitle: "$89",
|
|
609
|
+
image_url: "https://cdn.example.com/red.png",
|
|
610
|
+
buttons: [{ type: "web_url", title: "Buy", url: "https://shop.example.com/red" }]
|
|
611
|
+
),
|
|
612
|
+
{
|
|
613
|
+
title: "Blue sneaker",
|
|
614
|
+
subtitle: "$92",
|
|
615
|
+
image_url: "https://cdn.example.com/blue.png",
|
|
616
|
+
buttons: [{ type: "postback", title: "Notify me", payload: "NOTIFY:blue" }]
|
|
617
|
+
}
|
|
618
|
+
]
|
|
619
|
+
)
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Sent carousels come back on `Message#elements`; an inbound reply to an Instagram story
|
|
623
|
+
carries `Message#story_reply`.
|
|
624
|
+
|
|
625
|
+
### WhatsApp
|
|
626
|
+
|
|
627
|
+
WhatsApp chats run through a phone number, so creating one takes the `placement_id` (the
|
|
628
|
+
phone number id from `profiles.placements`). Send exactly one of `body`, `media`,
|
|
629
|
+
`template`, `interactive`, `location` or `contacts` per message. Outside the 24-hour
|
|
630
|
+
customer service window (`chat.within_messaging_window == false`) only an approved
|
|
631
|
+
template gets through.
|
|
632
|
+
|
|
633
|
+
```ruby
|
|
634
|
+
chat = client.chats.create("wa-profile-id", "15551234567", placement_id: "phone-number-id")
|
|
635
|
+
|
|
636
|
+
# Template — address it by name (+ language) or id. Variables are positional:
|
|
637
|
+
# header, then body, then dynamic URL buttons.
|
|
638
|
+
client.messages.send(
|
|
639
|
+
chat.id,
|
|
640
|
+
template: PostProxy::MessageTemplateInput.new(
|
|
641
|
+
name: "order_update",
|
|
642
|
+
language: "en_US",
|
|
643
|
+
variables: ["Jane", "#1234"],
|
|
644
|
+
button_params: [{ index: 0, sub_type: "url", text: "1234" }]
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# Location, contacts, and Meta's interactive messages (buttons, lists, ...)
|
|
649
|
+
client.messages.send(chat.id,
|
|
650
|
+
location: PostProxy::MessageLocation.new(latitude: 52.52, longitude: 13.405, name: "Our store", address: "Main St 1"))
|
|
651
|
+
client.messages.send(chat.id,
|
|
652
|
+
contacts: [{ name: { formatted_name: "Support" }, phones: [{ phone: "+15550000000" }] }])
|
|
653
|
+
client.messages.send(chat.id, interactive: {
|
|
654
|
+
type: "button",
|
|
655
|
+
body: { text: "Did that help?" },
|
|
656
|
+
action: { buttons: [{ type: "reply", reply: { id: "yes", title: "Yes" } }] }
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
# Send options
|
|
660
|
+
client.messages.send(chat.id, body: "Your code is 1234", category: "utility")
|
|
661
|
+
client.messages.send(chat.id, body: "https://example.com", link_preview: true)
|
|
662
|
+
client.messages.send(chat.id, media: ["https://example.com/invoice.pdf"], filename: "invoice.pdf")
|
|
663
|
+
client.messages.send(chat.id, media: ["https://example.com/note.ogg"], voice_note: true)
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
`Message` carries the WhatsApp payloads back as `template` (a `MessageTemplateRef`),
|
|
667
|
+
`interactive`, `location` and `contacts`; a failed send has `error_details`. Templates,
|
|
668
|
+
number registration, the business profile, groups and more are managed through
|
|
669
|
+
`client.whatsapp_templates` and `client.whatsapp` — see [WhatsApp management](#whatsapp-management).
|
|
670
|
+
|
|
534
671
|
## Profile comments (Google Business reviews)
|
|
535
672
|
|
|
536
673
|
Profile-level comments expose Google Business reviews and replies. Reviews are user-generated — the SDK lets you list/get them and reply to or delete your own replies. Reviews sync twice daily.
|
|
@@ -576,6 +713,43 @@ placement = client.profiles.assign_placement_to_group("prof-id",
|
|
|
576
713
|
)
|
|
577
714
|
puts placement.profile_group_id # => "pg-other"
|
|
578
715
|
|
|
716
|
+
# Move the whole profile (with its placements) to another group
|
|
717
|
+
client.profiles.assign_to_group("prof-id", "pg-other")
|
|
718
|
+
|
|
719
|
+
# Daily stats and follower history
|
|
720
|
+
daily = client.profiles.daily_stats("prof-id",
|
|
721
|
+
from: "2026-09-01",
|
|
722
|
+
to: "2026-09-30",
|
|
723
|
+
attribution: "publish" # or "received" (default)
|
|
724
|
+
)
|
|
725
|
+
daily.data.records.each { |day| puts "#{day.date}: #{day[:impressions]}" }
|
|
726
|
+
|
|
727
|
+
followers = client.profiles.follower_history("prof-id", from: "2026-09-01")
|
|
728
|
+
followers.data.records.each { |day| puts "#{day.date}: #{day.followers_total}" }
|
|
729
|
+
|
|
730
|
+
# Instagram: whether a DM participant is verified / follows you
|
|
731
|
+
status = client.profiles.participant_status("ig-profile-id", "igsid-123")
|
|
732
|
+
puts status.is_user_follow_business
|
|
733
|
+
|
|
734
|
+
# LinkedIn: resolve a company page to the URN used to @-mention it
|
|
735
|
+
mention = client.profiles.resolve_mention("li-profile-id", "https://www.linkedin.com/company/acme")
|
|
736
|
+
puts mention.urn # => "urn:li:organization:123"
|
|
737
|
+
|
|
738
|
+
# Instagram (Facebook login): music for reels — pass the id as
|
|
739
|
+
# platforms.instagram.audio_id
|
|
740
|
+
audio = client.profiles.instagram_available_audio("ig-profile-id",
|
|
741
|
+
audio_type: "music", # or "original_sound"
|
|
742
|
+
search_query: "summer"
|
|
743
|
+
)
|
|
744
|
+
puts audio[:data].map { |a| a[:id] }
|
|
745
|
+
|
|
746
|
+
# Facebook Page about / contact info (returned as a hash)
|
|
747
|
+
page = client.profiles.facebook_page("fb-profile-id", "page-id")
|
|
748
|
+
client.profiles.update_facebook_page("fb-profile-id", "page-id", {
|
|
749
|
+
about: "Coffee roasters since 2010",
|
|
750
|
+
website: "https://example.com"
|
|
751
|
+
})
|
|
752
|
+
|
|
579
753
|
# Ice breakers (Instagram DMs): FAQ prompts shown when a user opens a chat
|
|
580
754
|
result = client.profiles.ice_breakers("prof-id")
|
|
581
755
|
puts result.ice_breakers.map(&:question)
|
|
@@ -621,7 +795,7 @@ LinkedIn post stats now normalize `likes`, `comments`, `shares`, and `clicks` al
|
|
|
621
795
|
|
|
622
796
|
### Post syncs & backfill
|
|
623
797
|
|
|
624
|
-
|
|
798
|
+
Postproxy mirrors posts published natively on a platform into your account. Every one of
|
|
625
799
|
those pulls is recorded as a **post sync**: the one fired when the profile connects, the
|
|
626
800
|
recurring poll, and any backfill you start.
|
|
627
801
|
|
|
@@ -658,7 +832,7 @@ runs = client.profiles.post_syncs("prof-id",
|
|
|
658
832
|
| `error` | Platform error message when `status` is `"failed"` |
|
|
659
833
|
| `created_at` | `Time` |
|
|
660
834
|
|
|
661
|
-
**How far back a backfill reaches depends on the platform's API**, not on
|
|
835
|
+
**How far back a backfill reaches depends on the platform's API**, not on Postproxy: where
|
|
662
836
|
history is pageable we follow it, otherwise the run ends early with whatever it got and
|
|
663
837
|
still reports `status == "completed"`.
|
|
664
838
|
|
|
@@ -679,6 +853,18 @@ behave exactly like ones the poll picks up (`source: "imported"`, `post.imported
|
|
|
679
853
|
webhook), but a backfill's follow-up work is queued at a lower priority so a deep run
|
|
680
854
|
can't slow down publishing.
|
|
681
855
|
|
|
856
|
+
### Chat backfill
|
|
857
|
+
|
|
858
|
+
`backfill_chats` imports DM history the same way `backfill_posts` imports posts. Runs are
|
|
859
|
+
`ChatSync` records (`chats_seen`, `messages_imported`, `oldest_updated_at`, plus the same
|
|
860
|
+
`trigger` / `status` / timing fields as `PostSync`).
|
|
861
|
+
|
|
862
|
+
```ruby
|
|
863
|
+
run = client.profiles.backfill_chats("prof-id", from: "2026-01-01")
|
|
864
|
+
latest = client.profiles.chat_sync("prof-id", run.id)
|
|
865
|
+
runs = client.profiles.chat_syncs("prof-id", status: "completed")
|
|
866
|
+
```
|
|
867
|
+
|
|
682
868
|
## Profile Groups
|
|
683
869
|
|
|
684
870
|
```ruby
|
|
@@ -702,6 +888,17 @@ connection = client.profile_groups.initialize_connection(
|
|
|
702
888
|
)
|
|
703
889
|
# Redirect user to connection.url
|
|
704
890
|
|
|
891
|
+
# Pick the onboarding flow: Instagram via Facebook login (needed for reel music),
|
|
892
|
+
# WhatsApp via the Cloud API ("api") or the WhatsApp Business app ("business_app").
|
|
893
|
+
client.profile_groups.initialize_connection("pg-id",
|
|
894
|
+
platform: "instagram", redirect_url: "https://myapp.com/callback", onboarding: "facebook")
|
|
895
|
+
client.profile_groups.initialize_connection("pg-id",
|
|
896
|
+
platform: "whatsapp", redirect_url: "https://myapp.com/callback", onboarding: "business_app")
|
|
897
|
+
|
|
898
|
+
# Re-authorize an existing connection
|
|
899
|
+
client.profile_groups.initialize_connection("pg-id",
|
|
900
|
+
platform: "linkedin", redirect_url: "https://myapp.com/callback", refresh_connection: true)
|
|
901
|
+
|
|
705
902
|
# BlueSky — app password (synchronous, no OAuth)
|
|
706
903
|
bsky = client.profile_groups.connect_bluesky("pg-id",
|
|
707
904
|
identifier: "yourname.bsky.social",
|
|
@@ -725,6 +922,139 @@ end
|
|
|
725
922
|
puts "Channels: #{placements.map { |p| [p.id, p.name] }}"
|
|
726
923
|
```
|
|
727
924
|
|
|
925
|
+
## Placements & API keys
|
|
926
|
+
|
|
927
|
+
Both require an **account-wide** API key.
|
|
928
|
+
|
|
929
|
+
```ruby
|
|
930
|
+
# Every placement (Facebook Page, LinkedIn org, WhatsApp number, ...) across groups
|
|
931
|
+
placements = client.placements.list(per_page: 50)
|
|
932
|
+
placements.data.each do |p|
|
|
933
|
+
puts "#{p.platform} #{p.name} via #{p.active_profile.name}"
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# When several connected profiles reach the same placement, pick the one to use
|
|
937
|
+
client.placements.set_active_profile("placement-id", "profile-id")
|
|
938
|
+
|
|
939
|
+
# Group-scoped API keys
|
|
940
|
+
key = client.api_keys.create("CI pipeline", profile_group_id: "pg-id")
|
|
941
|
+
puts key.token # shown only once
|
|
942
|
+
keys = client.api_keys.list.data
|
|
943
|
+
client.api_keys.delete(key.id)
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
## Google Business management
|
|
947
|
+
|
|
948
|
+
Manage a Google Business Profile location's own data — separate from posting to it.
|
|
949
|
+
`location_id` is the placement id from `profiles.placements`. Google's camelCased payloads
|
|
950
|
+
pass through untouched (responses are plain hashes), so you can PATCH back what you GET;
|
|
951
|
+
updates take a `fields` list naming what to change.
|
|
952
|
+
|
|
953
|
+
```ruby
|
|
954
|
+
gbp = client.google_business
|
|
955
|
+
|
|
956
|
+
location = gbp.location("gbp-profile-id", "location-id")
|
|
957
|
+
gbp.update_location("gbp-profile-id", "location-id", { websiteUri: "https://example.com" },
|
|
958
|
+
fields: ["websiteUri"])
|
|
959
|
+
gbp.update_hours("gbp-profile-id", "location-id",
|
|
960
|
+
fields: ["regularHours"],
|
|
961
|
+
regular_hours: {
|
|
962
|
+
periods: [{ openDay: "MONDAY", openTime: { hours: 9 }, closeDay: "MONDAY", closeTime: { hours: 17 } }]
|
|
963
|
+
}
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
# Categories and attributes
|
|
967
|
+
gbp.available_location_categories("gbp-profile-id", "location-id", "US", filter: "coffee")
|
|
968
|
+
attrs = gbp.attributes("gbp-profile-id", "location-id")
|
|
969
|
+
gbp.available_attributes("gbp-profile-id", "location-id")
|
|
970
|
+
gbp.update_attributes("gbp-profile-id", "location-id",
|
|
971
|
+
[{ name: "attributes/has_wi_fi", values: [true] }],
|
|
972
|
+
attribute_mask: ["attributes/has_wi_fi"])
|
|
973
|
+
|
|
974
|
+
# Services and food menus
|
|
975
|
+
gbp.service_list("gbp-profile-id", "location-id")
|
|
976
|
+
gbp.update_service_list("gbp-profile-id", "location-id", [ ... ]) # serviceItems
|
|
977
|
+
gbp.food_menus("gbp-profile-id", "location-id")
|
|
978
|
+
gbp.update_food_menus("gbp-profile-id", "location-id", [ ... ]) # menus
|
|
979
|
+
|
|
980
|
+
# Place action links (booking, ordering, ...)
|
|
981
|
+
gbp.available_place_action_types("gbp-profile-id", "location-id")
|
|
982
|
+
gbp.create_place_action_link("gbp-profile-id", "location-id",
|
|
983
|
+
uri: "https://book.example.com", place_action_type: "APPOINTMENT", is_preferred: true)
|
|
984
|
+
links = gbp.place_action_links("gbp-profile-id", "location-id")
|
|
985
|
+
gbp.update_place_action_link("gbp-profile-id", "location-id", "locations/1/placeActionLinks/2",
|
|
986
|
+
fields: ["uri"], uri: "https://book.example.com/new")
|
|
987
|
+
gbp.delete_place_action_link("gbp-profile-id", "location-id", "locations/1/placeActionLinks/2")
|
|
988
|
+
|
|
989
|
+
# Photos and videos
|
|
990
|
+
gbp.create_media("gbp-profile-id", "location-id", "https://example.com/storefront.jpg",
|
|
991
|
+
category: "EXTERIOR", media_format: "PHOTO")
|
|
992
|
+
media = gbp.media("gbp-profile-id", "location-id")
|
|
993
|
+
gbp.delete_media("gbp-profile-id", "location-id", "accounts/1/locations/1/media/9")
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
## WhatsApp management
|
|
997
|
+
|
|
998
|
+
`phone_number_id` is the placement id from `profiles.placements`. Responses other than
|
|
999
|
+
templates are Meta's payloads as plain hashes.
|
|
1000
|
+
|
|
1001
|
+
```ruby
|
|
1002
|
+
# Message templates (submitted to Meta for review)
|
|
1003
|
+
tpl = client.whatsapp_templates.create("wa-profile-id",
|
|
1004
|
+
name: "order_update",
|
|
1005
|
+
category: "UTILITY",
|
|
1006
|
+
language: "en_US",
|
|
1007
|
+
components: [{ type: "BODY", text: "Hi {{1}}, order {{2}} has shipped." }]
|
|
1008
|
+
)
|
|
1009
|
+
templates = client.whatsapp_templates.list("wa-profile-id", status: "APPROVED").data
|
|
1010
|
+
client.whatsapp_templates.get("wa-profile-id", "order_update", language: "en_US")
|
|
1011
|
+
client.whatsapp_templates.update("wa-profile-id", tpl.id, message_send_ttl_seconds: 3600)
|
|
1012
|
+
client.whatsapp_templates.delete("wa-profile-id", "order_update") # every language
|
|
1013
|
+
client.whatsapp_templates.library("wa-profile-id", name: "delivery")
|
|
1014
|
+
|
|
1015
|
+
wa = client.whatsapp
|
|
1016
|
+
|
|
1017
|
+
# Number registration
|
|
1018
|
+
wa.number_info("wa-profile-id", "phone-number-id")
|
|
1019
|
+
wa.request_verification_code("wa-profile-id", "phone-number-id", method: "SMS", language: "en")
|
|
1020
|
+
wa.verify_phone_number("wa-profile-id", "phone-number-id", "123456")
|
|
1021
|
+
wa.register_phone_number("wa-profile-id", "phone-number-id", "000000") # two-step PIN
|
|
1022
|
+
|
|
1023
|
+
# Business profile, display name, username
|
|
1024
|
+
wa.update_business_profile("wa-profile-id", "phone-number-id",
|
|
1025
|
+
about: "We reply fast", websites: ["https://example.com"])
|
|
1026
|
+
wa.update_business_profile_photo("wa-profile-id", "phone-number-id", url: "https://example.com/logo.png")
|
|
1027
|
+
wa.request_display_name_change("wa-profile-id", "phone-number-id", "Acme Coffee")
|
|
1028
|
+
wa.username_suggestions("wa-profile-id", "phone-number-id")
|
|
1029
|
+
wa.set_username("wa-profile-id", "phone-number-id", "acmecoffee")
|
|
1030
|
+
|
|
1031
|
+
# Blocked users
|
|
1032
|
+
wa.block_users("wa-profile-id", "phone-number-id", ["15551234567"])
|
|
1033
|
+
wa.blocked_users("wa-profile-id", "phone-number-id")
|
|
1034
|
+
|
|
1035
|
+
# Groups
|
|
1036
|
+
group = wa.create_group("wa-profile-id", "phone-number-id", "VIP customers",
|
|
1037
|
+
join_approval_mode: "approval_required")
|
|
1038
|
+
wa.create_group_invite_link("wa-profile-id", "phone-number-id", "group-id")
|
|
1039
|
+
wa.group_join_requests("wa-profile-id", "phone-number-id", "group-id")
|
|
1040
|
+
wa.approve_group_join_requests("wa-profile-id", "phone-number-id", "group-id", ["15551234567"])
|
|
1041
|
+
|
|
1042
|
+
# Conversions for click-to-WhatsApp ads (account-level, no phone number id)
|
|
1043
|
+
wa.create_dataset("wa-profile-id")
|
|
1044
|
+
wa.send_conversion_event("wa-profile-id",
|
|
1045
|
+
event_name: "Purchase",
|
|
1046
|
+
event_id: "order-1234",
|
|
1047
|
+
chat_id: "chat-id",
|
|
1048
|
+
currency: "USD",
|
|
1049
|
+
value: 49.0
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
# Download inbound media — a RawResponse with the bytes and their content type
|
|
1053
|
+
file = wa.media("wa-profile-id", "phone-number-id", "media-id")
|
|
1054
|
+
File.binwrite("download", file.data)
|
|
1055
|
+
puts file.content_type
|
|
1056
|
+
```
|
|
1057
|
+
|
|
728
1058
|
## Platform Parameters
|
|
729
1059
|
|
|
730
1060
|
```ruby
|
|
@@ -771,7 +1101,75 @@ post = client.posts.create(
|
|
|
771
1101
|
)
|
|
772
1102
|
```
|
|
773
1103
|
|
|
774
|
-
Supported platforms: `facebook`, `instagram`, `tiktok`, `linkedin`, `youtube`, `twitter`, `threads`, `pinterest`, `bluesky`, `telegram`, `google_business
|
|
1104
|
+
Supported platforms: `facebook`, `instagram`, `tiktok`, `linkedin`, `youtube`, `twitter`, `threads`, `pinterest`, `bluesky`, `telegram`, `google_business`, `whatsapp` (DMs only). Telegram requires a `chat_id` per post — list channels with `client.profiles.placements(profile_id)`.
|
|
1105
|
+
|
|
1106
|
+
### More per-platform options
|
|
1107
|
+
|
|
1108
|
+
```ruby
|
|
1109
|
+
# Alt text (a single string, or an array matching the order of media) and AI disclosure
|
|
1110
|
+
client.posts.create("Launch day", profiles: ["ig-profile", "li-profile", "fb-profile"],
|
|
1111
|
+
media: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
|
1112
|
+
platforms: PostProxy::PlatformParams.new(
|
|
1113
|
+
instagram: PostProxy::InstagramParams.new(alt_text: ["Our team", "The product"], made_with_ai: false),
|
|
1114
|
+
linkedin: PostProxy::LinkedInParams.new(alt_text: "Our team and the product",
|
|
1115
|
+
countries: ["US", "CA"], organization_id: "123"),
|
|
1116
|
+
facebook: PostProxy::FacebookParams.new(countries: ["US"])
|
|
1117
|
+
)
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
# Facebook link post (text-only — renders the link preview card)
|
|
1121
|
+
client.posts.create("Read the announcement", profiles: ["fb-profile"],
|
|
1122
|
+
platforms: { facebook: { link: "https://example.com/blog/launch" } })
|
|
1123
|
+
|
|
1124
|
+
# LinkedIn poll
|
|
1125
|
+
client.posts.create("Quick question for you all", profiles: ["li-profile"],
|
|
1126
|
+
platforms: PostProxy::PlatformParams.new(
|
|
1127
|
+
linkedin: PostProxy::LinkedInParams.new(
|
|
1128
|
+
format: "poll",
|
|
1129
|
+
organization_id: "123",
|
|
1130
|
+
poll_question: "Tabs or spaces?",
|
|
1131
|
+
poll_options: ["Tabs", "Spaces"],
|
|
1132
|
+
poll_duration_days: 7 # 1, 3, 7 or 14
|
|
1133
|
+
)
|
|
1134
|
+
)
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
# Threads topic tag, TikTok draft with a custom cover
|
|
1138
|
+
client.posts.create("New drop", profiles: ["threads-profile", "tt-profile"],
|
|
1139
|
+
media: ["https://example.com/clip.mp4"],
|
|
1140
|
+
platforms: PostProxy::PlatformParams.new(
|
|
1141
|
+
threads: PostProxy::ThreadsParams.new(topic_tag: "Sneakers"),
|
|
1142
|
+
tiktok: PostProxy::TikTokParams.new(format: "video", upload_as_draft: true,
|
|
1143
|
+
cover_url: "https://example.com/cover.jpg")
|
|
1144
|
+
)
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
# Instagram reel with licensed music (profiles connected via Facebook login).
|
|
1148
|
+
# Find an audio_id with client.profiles.instagram_available_audio.
|
|
1149
|
+
client.posts.create("Summer vibes", profiles: ["ig-profile"],
|
|
1150
|
+
media: ["https://example.com/reel.mp4"],
|
|
1151
|
+
platforms: { instagram: { format: "reel", audio_id: "1234567890", audio_volume: 60, video_volume: 30 } })
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
`InstagramParams#trial_strategy` takes `"MANUAL"` or `"SS_PERFORMANCE"`.
|
|
1155
|
+
|
|
1156
|
+
### Custom covers
|
|
1157
|
+
|
|
1158
|
+
Instagram, TikTok, YouTube and Pinterest take a hosted `cover_url`, or a `cover_file`:
|
|
1159
|
+
|
|
1160
|
+
```ruby
|
|
1161
|
+
# A local file, uploaded with the post (the request switches to multipart)…
|
|
1162
|
+
client.posts.create("New video", profiles: ["tt-profile"],
|
|
1163
|
+
media: ["https://example.com/video.mp4"],
|
|
1164
|
+
platforms: { tiktok: { format: "video", cover_file: "./cover.jpg" } })
|
|
1165
|
+
|
|
1166
|
+
# …or a base64 data URL, sent as-is
|
|
1167
|
+
client.posts.create("New pin", profiles: ["pin-profile"],
|
|
1168
|
+
media: ["https://example.com/video.mp4"],
|
|
1169
|
+
platforms: { pinterest: { board_id: "board-id", cover_file: "data:image/jpeg;base64,/9j/4AAQ..." } })
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
`cover_file` works on `posts.create` and `posts.update`.
|
|
775
1173
|
|
|
776
1174
|
### Instagram user tags
|
|
777
1175
|
|
|
@@ -837,6 +1235,11 @@ begin
|
|
|
837
1235
|
client.posts.get("bad-id")
|
|
838
1236
|
rescue PostProxy::AuthenticationError => e
|
|
839
1237
|
puts "Auth failed: #{e.message}" # 401
|
|
1238
|
+
rescue PostProxy::ForbiddenError => e
|
|
1239
|
+
puts "Forbidden: #{e.message}" # 403
|
|
1240
|
+
rescue PostProxy::RateLimitError => e
|
|
1241
|
+
puts "Rate limited: #{e.message}" # 429
|
|
1242
|
+
sleep(e.retry_after) if e.retry_after # seconds from Retry-After, or nil
|
|
840
1243
|
rescue PostProxy::NotFoundError => e
|
|
841
1244
|
puts "Not found: #{e.message}" # 404
|
|
842
1245
|
rescue PostProxy::ConflictError => e
|
|
@@ -857,10 +1260,11 @@ end
|
|
|
857
1260
|
|---|---|---|
|
|
858
1261
|
| 400 | `BadRequestError` | Missing required parameters |
|
|
859
1262
|
| 401 | `AuthenticationError` | Invalid, missing, or insufficient API key permissions |
|
|
1263
|
+
| 403 | `ForbiddenError` | Account canceled or past due, or the platform refused the action |
|
|
860
1264
|
| 404 | `NotFoundError` | Resource does not exist or is not accessible |
|
|
861
1265
|
| 409 | `ConflictError` | Duplicate submission, a backfill already running, or an in-flight `Idempotency-Key` |
|
|
862
1266
|
| 422 | `ValidationError` | Validation failed |
|
|
863
|
-
| 429 | `
|
|
1267
|
+
| 429 | `RateLimitError` | Posting rate limit reached, or the platform is rate-limiting — `retry_after` holds the `Retry-After` seconds |
|
|
864
1268
|
|
|
865
1269
|
## License
|
|
866
1270
|
|