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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6be6a31464b3afaf473473d94fd639b2553782cf0494e87ccfebb3e5e2e5e4a
4
- data.tar.gz: 0ff052e8410c536e2938cf6887cecee713f615afa11207c88f733c29b6770d07
3
+ metadata.gz: f7aef460a3a9d2e2188dc5098adf89fe29e1a917417e965c2234fc23a43019ca
4
+ data.tar.gz: 14c146f3c9b8fab34ff17b9a87e4fb4c4464a600b90fe5610bd93a3dc4a9baa1
5
5
  SHA512:
6
- metadata.gz: 2a67b96326d47a4c33463c10b5346ff28debf6e4faf7ef11a297fded79abf9cac4b062099cc1e1cf6002e6e9f2bc4b9e641dbf26537552f8d2041310598a8163
7
- data.tar.gz: cf8c37298d421c37886440e75819a9a1462d34ccadad79e4623cbb2cb431292e4ead2e1ba0014bd13b522c9c0e62737eeb524987bde5a97f99404e3416686950
6
+ metadata.gz: 4e3e06d85a609de033c1918c55c6388d1ff17fcf604576ee3aec7231e906d2328ffd6f57b6e5747c08ee16f9d5f3b5c04affb99eef012be43fdcd2dcf6e0a1b8
7
+ data.tar.gz: 66d7d324060a481a24002ecbaf00238478904e5fdd40916258f1f1aec88e67d8cbfef46d380ab8062304b9364ef3109774834335423a11d0ff56d186c1ffb5b4
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # PostProxy Ruby SDK
1
+ # Postproxy Ruby SDK
2
2
 
3
- Ruby client for the [PostProxy API](https://postproxy.dev) — manage social media posts, profiles, and profile groups.
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.insights`, `profile.connected`, `profile.disconnected`, `profile.stats`, `media.failed`, `comment.created`, `profile_comment.created`, `message.received`, `message.sent`, `message.delivered`, `message.read`, `message.edited`, `message.deleted`, `message.failed_waiting_for_retry`, `message.failed`, `reaction.received`.
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.created` carries a `ProfileCommentCreatedData`.
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 PostProxy's, and a longer body is rejected with a `422` naming the
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 PostProxy exposed it, including Instagram ice-breaker taps and
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
- PostProxy mirrors posts published natively on a platform into your account. Every one of
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 PostProxy: where
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`. Telegram requires a `chat_id` per post — list channels with `client.profiles.placements(profile_id)`.
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 | `Error` | Posting rate limit reached |
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