postproxy-sdk 1.12.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 +480 -9
- 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 +43 -2
- 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 +508 -28
- 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,11 +513,161 @@ 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
|
|
465
526
|
```
|
|
466
527
|
|
|
528
|
+
### Quick replies and buttons (Facebook & Instagram)
|
|
529
|
+
|
|
530
|
+
Meta's two interactive primitives. **Quick replies** are chips above the participant's
|
|
531
|
+
composer that disappear once tapped; **buttons** are attached to the message and stay in
|
|
532
|
+
the thread. Telegram's equivalent is `reply_markup` above — passing `quick_replies` or
|
|
533
|
+
`buttons` on a Telegram or Bluesky chat returns `422`.
|
|
534
|
+
|
|
535
|
+
Each param accepts model instances or plain hashes, whichever you prefer:
|
|
536
|
+
|
|
537
|
+
```ruby
|
|
538
|
+
# Quick replies — up to 13. title ≤ 20 chars, payload ≤ 1000.
|
|
539
|
+
client.messages.send(
|
|
540
|
+
chat.id,
|
|
541
|
+
body: "What can I help with?",
|
|
542
|
+
quick_replies: [
|
|
543
|
+
PostProxy::QuickReply.new(title: "Track order", payload: "TRACK"),
|
|
544
|
+
{ title: "Talk to support", payload: "HELP" }
|
|
545
|
+
]
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Buttons — up to 3, each either web_url or postback. card is optional and
|
|
549
|
+
# requires buttons.
|
|
550
|
+
client.messages.send(
|
|
551
|
+
chat.id,
|
|
552
|
+
body: "Your order shipped",
|
|
553
|
+
buttons: [
|
|
554
|
+
PostProxy::MessageButton.new(type: "web_url", title: "Track", url: "https://shop.example.com/o/123"),
|
|
555
|
+
PostProxy::MessageButton.new(type: "postback", title: "Cancel", payload: "CANCEL:123")
|
|
556
|
+
],
|
|
557
|
+
card: PostProxy::MessageCard.new(
|
|
558
|
+
subtitle: "Arriving Friday",
|
|
559
|
+
image_url: "https://cdn.example.com/shoe.png",
|
|
560
|
+
default_action: { type: "web_url", url: "https://shop.example.com/o/123" }
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
Buttons are delivered as a Meta generic template and your `body` becomes the template's
|
|
566
|
+
element title — so **`body` is capped at 80 characters when buttons are present**. That is
|
|
567
|
+
Meta's limit, not Postproxy's, and a longer body is rejected with a `422` naming the
|
|
568
|
+
length. Buttons cannot be combined with media. Instagram is stricter than Messenger: it
|
|
569
|
+
delivers quick replies only on a plain-text message, so `quick_replies` with media or with
|
|
570
|
+
`buttons` returns `422` on Instagram while both are accepted on Facebook.
|
|
571
|
+
|
|
572
|
+
Validation happens server-side and names the offending index — `buttons[1].url must be an
|
|
573
|
+
https:// URL` — surfacing as the SDK's usual error for a `422`.
|
|
574
|
+
|
|
575
|
+
> The new params are sent on the JSON path only. To combine quick replies with an
|
|
576
|
+
> attachment, pass `media` as a hosted URL rather than uploading via `media_files`.
|
|
577
|
+
|
|
578
|
+
A tap comes back as an **inbound message** carrying `tapped_action`:
|
|
579
|
+
|
|
580
|
+
```ruby
|
|
581
|
+
inbound = client.messages.list(chat.id, direction: "inbound")
|
|
582
|
+
inbound.data.each do |msg|
|
|
583
|
+
next unless msg.tapped_action
|
|
584
|
+
|
|
585
|
+
# kind: "quick_reply", "postback", or "callback_query"
|
|
586
|
+
puts "#{msg.tapped_action.kind}: #{msg.tapped_action.payload}"
|
|
587
|
+
end
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Subscribe to `message.received` to react to taps as they happen — the same field is on the
|
|
591
|
+
webhook payload. `tapped_action` is derived rather than stored, so it also resolves for
|
|
592
|
+
taps recorded before Postproxy exposed it, including Instagram ice-breaker taps and
|
|
593
|
+
Telegram callback queries (`kind` `"callback_query"`). A tap also opens the 24h window.
|
|
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
|
+
|
|
467
671
|
## Profile comments (Google Business reviews)
|
|
468
672
|
|
|
469
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.
|
|
@@ -509,6 +713,43 @@ placement = client.profiles.assign_placement_to_group("prof-id",
|
|
|
509
713
|
)
|
|
510
714
|
puts placement.profile_group_id # => "pg-other"
|
|
511
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
|
+
|
|
512
753
|
# Ice breakers (Instagram DMs): FAQ prompts shown when a user opens a chat
|
|
513
754
|
result = client.profiles.ice_breakers("prof-id")
|
|
514
755
|
puts result.ice_breakers.map(&:question)
|
|
@@ -554,7 +795,7 @@ LinkedIn post stats now normalize `likes`, `comments`, `shares`, and `clicks` al
|
|
|
554
795
|
|
|
555
796
|
### Post syncs & backfill
|
|
556
797
|
|
|
557
|
-
|
|
798
|
+
Postproxy mirrors posts published natively on a platform into your account. Every one of
|
|
558
799
|
those pulls is recorded as a **post sync**: the one fired when the profile connects, the
|
|
559
800
|
recurring poll, and any backfill you start.
|
|
560
801
|
|
|
@@ -591,7 +832,7 @@ runs = client.profiles.post_syncs("prof-id",
|
|
|
591
832
|
| `error` | Platform error message when `status` is `"failed"` |
|
|
592
833
|
| `created_at` | `Time` |
|
|
593
834
|
|
|
594
|
-
**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
|
|
595
836
|
history is pageable we follow it, otherwise the run ends early with whatever it got and
|
|
596
837
|
still reports `status == "completed"`.
|
|
597
838
|
|
|
@@ -612,6 +853,18 @@ behave exactly like ones the poll picks up (`source: "imported"`, `post.imported
|
|
|
612
853
|
webhook), but a backfill's follow-up work is queued at a lower priority so a deep run
|
|
613
854
|
can't slow down publishing.
|
|
614
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
|
+
|
|
615
868
|
## Profile Groups
|
|
616
869
|
|
|
617
870
|
```ruby
|
|
@@ -635,6 +888,17 @@ connection = client.profile_groups.initialize_connection(
|
|
|
635
888
|
)
|
|
636
889
|
# Redirect user to connection.url
|
|
637
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
|
+
|
|
638
902
|
# BlueSky — app password (synchronous, no OAuth)
|
|
639
903
|
bsky = client.profile_groups.connect_bluesky("pg-id",
|
|
640
904
|
identifier: "yourname.bsky.social",
|
|
@@ -658,6 +922,139 @@ end
|
|
|
658
922
|
puts "Channels: #{placements.map { |p| [p.id, p.name] }}"
|
|
659
923
|
```
|
|
660
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
|
+
|
|
661
1058
|
## Platform Parameters
|
|
662
1059
|
|
|
663
1060
|
```ruby
|
|
@@ -704,7 +1101,75 @@ post = client.posts.create(
|
|
|
704
1101
|
)
|
|
705
1102
|
```
|
|
706
1103
|
|
|
707
|
-
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`.
|
|
708
1173
|
|
|
709
1174
|
### Instagram user tags
|
|
710
1175
|
|
|
@@ -770,6 +1235,11 @@ begin
|
|
|
770
1235
|
client.posts.get("bad-id")
|
|
771
1236
|
rescue PostProxy::AuthenticationError => e
|
|
772
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
|
|
773
1243
|
rescue PostProxy::NotFoundError => e
|
|
774
1244
|
puts "Not found: #{e.message}" # 404
|
|
775
1245
|
rescue PostProxy::ConflictError => e
|
|
@@ -790,10 +1260,11 @@ end
|
|
|
790
1260
|
|---|---|---|
|
|
791
1261
|
| 400 | `BadRequestError` | Missing required parameters |
|
|
792
1262
|
| 401 | `AuthenticationError` | Invalid, missing, or insufficient API key permissions |
|
|
1263
|
+
| 403 | `ForbiddenError` | Account canceled or past due, or the platform refused the action |
|
|
793
1264
|
| 404 | `NotFoundError` | Resource does not exist or is not accessible |
|
|
794
1265
|
| 409 | `ConflictError` | Duplicate submission, a backfill already running, or an in-flight `Idempotency-Key` |
|
|
795
1266
|
| 422 | `ValidationError` | Validation failed |
|
|
796
|
-
| 429 | `
|
|
1267
|
+
| 429 | `RateLimitError` | Posting rate limit reached, or the platform is rate-limiting — `retry_after` holds the `Retry-After` seconds |
|
|
797
1268
|
|
|
798
1269
|
## License
|
|
799
1270
|
|