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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fabdc6a06ca3cef409cf88533c925ff183e3e1c7089fd53aa541c09bff0aa6f
4
- data.tar.gz: 7b4245a53e37aab93f006d408430676dbe3352a8815e5479de765946d941e20e
3
+ metadata.gz: f7aef460a3a9d2e2188dc5098adf89fe29e1a917417e965c2234fc23a43019ca
4
+ data.tar.gz: 14c146f3c9b8fab34ff17b9a87e4fb4c4464a600b90fe5610bd93a3dc4a9baa1
5
5
  SHA512:
6
- metadata.gz: 97e32211fa2f54a2a28b506333108a484153083efcf9272bcf23fc821e27e6e2e88be7dd7d166acf6f9cd339a69a07afd1fb9b9f3c271a860b8ec9f5b41b7a9e
7
- data.tar.gz: 3217e3f75dd540a6c13215dc5a5b7830d483ebd38f4cd30433162e7c732f69b214b06e3f73ccfbe5683f953a9c57133bf9dd1de4bdaf4ffe5dc3a631c15a132e
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,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
- 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
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 PostProxy: where
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`. 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`.
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 | `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 |
797
1268
 
798
1269
  ## License
799
1270