fopost 0.2.0 → 0.3.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.
data/README.md CHANGED
@@ -64,6 +64,21 @@ client.posts.create(
64
64
 
65
65
  `accounts` takes account ids, the `Fopost::SocialAccount` objects themselves, or hashes with an `id`.
66
66
 
67
+ ### Files on a post
68
+
69
+ `files` attaches up to 5 files to the first block. Each is a path, an IO, or a hash of `filename:` plus `io:` or `data:` and `mime_type:` (a path or `File` guesses the type from its extension). The files land in the workspace library; the SDK picks the route by size and the result is the same either way: files at or under the multipart ceiling (`media.limits.multipart_max_file_bytes`, 100 MB) go in one multipart request, anything larger goes through direct upload first. `posts.update(id, files: ...)` appends to the first block the same way.
70
+
71
+ ```ruby
72
+ client.posts.create(
73
+ workspace_id: workspace.id,
74
+ content: 'Launch day',
75
+ accounts: accounts.map(&:id),
76
+ files: ['launch.png', { filename: 'teaser.mp4', io: File.open('teaser.mp4', 'rb'), mime_type: 'video/mp4' }]
77
+ )
78
+ ```
79
+
80
+ A file over the account's ceiling raises `Fopost::FileTooLargeError` (413); `max_file_bytes` is the largest the account may upload and `upgrade_url` is where a bigger one is unlocked.
81
+
67
82
  ## Scheduling
68
83
 
69
84
  `status` is `"draft"` or `"scheduled"`; a scheduled post needs `schedule_at`. To send something out now, create it and call `publish`.
@@ -102,6 +117,99 @@ client.posts.update(post_id, schedule_at: nil) # sends {"schedule_at": null}
102
117
  client.posts.update(post_id, title: 'Renamed') # sends {"title": "Renamed"}
103
118
  ```
104
119
 
120
+ ## Account groups
121
+
122
+ Name a set of accounts once and post to all of them. Needs the `accounts` scope.
123
+
124
+ ```ruby
125
+ group = client.account_groups.create(workspace_id: workspace.id, name: 'Launch', account_ids: %w[acc_1 acc_2])
126
+ client.account_groups.list(workspace_id: workspace.id)
127
+ client.account_groups.update(group.id, name: 'Launch week')
128
+ client.account_groups.set_members(group.id, %w[acc_1 acc_3]) # replaces the members
129
+ client.account_groups.delete(group.id)
130
+
131
+ client.posts.create(workspace_id: workspace.id, content: 'Hello', account_group_id: group.id)
132
+ client.accounts.list(group_id: group.id)
133
+ ```
134
+
135
+ Accounts can also be renamed and moved between workspaces you own:
136
+
137
+ ```ruby
138
+ client.accounts.update(account_id, display_name: 'Brand HQ') # nil restores the platform name
139
+ client.accounts.move(account_id, workspace_id: other_workspace.id)
140
+ ```
141
+
142
+ Connect a Telegram chat with a one-time code, and set the command menu the bot shows there:
143
+
144
+ ```ruby
145
+ code = client.accounts.create_telegram_connect_code(workspace_id: workspace.id)
146
+ puts code.command # send this to the bot in the chat
147
+ client.accounts.get_telegram_connect_status(code.code).status # pending, connected, failed or expired
148
+
149
+ client.accounts.set_telegram_bot_commands(account_id, [{ command: 'start', description: 'Start here' }])
150
+ client.accounts.get_telegram_bot_commands(account_id)
151
+ client.accounts.delete_telegram_bot_commands(account_id)
152
+ ```
153
+
154
+ Read a Slack account's channels and members, and set the name and icon it posts under. A member's `id` is the
155
+ handle for `inbox.start_conversation`:
156
+
157
+ ```ruby
158
+ client.accounts.list_slack_channels(account_id)
159
+ client.accounts.list_slack_members(account_id)
160
+ client.accounts.get_slack_identity(account_id)
161
+ client.accounts.update_slack_identity(account_id, username: 'Launch Bot', icon_emoji: ':rocket:') # nil clears
162
+
163
+ # Meta messaging settings. Ice breakers on Facebook Pages and Instagram; the menu and
164
+ # greeting on Pages only. A network without a field answers 400.
165
+ client.accounts.set_ice_breakers(account_id, [{ question: 'What are your hours?', payload: 'HOURS' }])
166
+ client.accounts.set_persistent_menu(account_id, [{ locale: 'default',
167
+ call_to_actions: [{ type: 'postback', title: 'Talk to Us',
168
+ payload: 'HUMAN' }] }])
169
+ client.accounts.set_greeting(account_id, [{ text: 'Hi! Ask us anything.' }])
170
+
171
+ # Is the network still delivering events for this account?
172
+ subscription = client.accounts.get_webhook_subscription(account_id)
173
+ client.accounts.resubscribe_webhook(account_id) unless subscription.subscribed
174
+ ```
175
+
176
+ On a Discord bot connection, read and change the channel it posts to and manage the server itself:
177
+
178
+ ```ruby
179
+ client.accounts.list_discord_channels(account_id)
180
+ client.accounts.switch_discord_channel(account_id, 'c2')
181
+ client.accounts.update_discord_identity(account_id, username: 'Release Bot') # nil clears
182
+
183
+ client.accounts.create_discord_event(account_id, name: 'Launch stream',
184
+ start_time: '2026-10-01T18:00:00Z',
185
+ end_time: '2026-10-01T19:00:00Z',
186
+ location: 'https://yourbrand.com/live')
187
+
188
+ members = client.accounts.list_discord_members(account_id, query: 'ada')
189
+ role = client.accounts.create_discord_role(account_id, name: 'Beta')
190
+ client.accounts.add_discord_member_role(account_id, role.id, members[0].id)
191
+ client.accounts.send_discord_dm(account_id, members[0].id, 'Welcome aboard')
192
+ ```
193
+
194
+ Read the numbers only an account's own network reports, in its own vocabulary — ad-break earnings, story
195
+ taps, a retention curve, the search terms behind a listing. A network whose metric access has not been
196
+ granted yet answers `503`:
197
+
198
+ ```ruby
199
+ metrics = client.accounts.platform_metrics(account_id)
200
+ metrics.account.metrics.each { |row| puts "#{row.label}: #{row.value}" }
201
+ metrics.post.external_post_id
202
+ ```
203
+
204
+ On Reddit, where a post can go and what that subreddit expects of it:
205
+
206
+ ```ruby
207
+ client.accounts.list_reddit_subreddits(account_id)
208
+ client.accounts.list_reddit_subreddit_rules(account_id, 'webdev')
209
+ client.accounts.list_reddit_flairs(account_id, 'webdev')
210
+ client.accounts.set_reddit_default_subreddit(account_id, 'webdev') # nil falls back to the profile page
211
+ ```
212
+
105
213
  ## Pagination
106
214
 
107
215
  `posts.list` returns one page, which is `Enumerable` over its items. `posts.each` walks every page for you.
@@ -152,6 +260,75 @@ repurposed = client.ai.repurpose_url(
152
260
 
153
261
  > **API keys reach `credits` and `generate_caption`.** `rewrite` and `repurpose_url` currently require a signed-in dashboard session and answer `401` to an API key. They are here so the surface is complete once the server opens them up.
154
262
 
263
+ ## Validate
264
+
265
+ Check a draft before you create a post. Needs the `posts` scope; nothing is stored.
266
+
267
+ ```ruby
268
+ result = client.validate.post(content: 'Hello', platforms: %w[twitter linkedin],
269
+ media: [{ url: 'https://example.com/a.png', mime_type: 'image/png' }])
270
+ result.platforms.reject(&:ready).each { |p| puts "#{p.platform}: #{p.issues.join(', ')}" }
271
+
272
+ lengths = client.validate.length(text: 'Hello', platforms: %w[twitter bluesky])
273
+ lengths.platforms.each { |p| puts "#{p.platform}: #{p.length}/#{p.limit || 'no limit'} #{p.unit}" }
274
+
275
+ file = client.validate.media(url: 'https://example.com/a.png')
276
+ puts file.ok ? file.type : file.issues.join(', ')
277
+
278
+ sub = client.validate.subreddit(account_id: account_id, name: 'webdev')
279
+ ```
280
+
281
+ ## Activity
282
+
283
+ What happened in a workspace, newest first. Needs the `analytics` scope.
284
+
285
+ ```ruby
286
+ page = client.activity.list(workspace_id: workspace.id)
287
+ page.events.each { |event| puts "#{event.time} #{event.actor.name}: #{event.summary}" }
288
+ page.next_cursor # pass back as cursor: for the next page
289
+ ```
290
+
291
+ `kind: 'security'` is the audit log: members joining, leaving or changing role
292
+ and access, and changes to two-step verification, passkeys, single sign-on and
293
+ signed-in devices. Those rows are append-only and never expire.
294
+
295
+ ```ruby
296
+ audit = client.activity.list(workspace_id: workspace.id, kind: 'security')
297
+ ```
298
+
299
+ ## Blogs, articles and products
300
+
301
+ Content a connected site already owns: the articles on a WordPress site or a
302
+ Shopify store's blog, and a Shopify store's products. Every id here is the
303
+ platform's own, never a FoPost id. Reads need the `posts` scope; anything that
304
+ changes the site needs `publish` as well.
305
+
306
+ ```ruby
307
+ blogs = client.blogs.list_blogs(account_id)
308
+ # Shopify reports every blog; WordPress reports one, under the id 'default'.
309
+
310
+ articles = client.blogs.list_articles(account_id, blogs.first.id, status: 'draft')
311
+ articles.first.title
312
+ articles.first.status # published, draft, pending, scheduled
313
+
314
+ article = client.blogs.create_article(
315
+ account_id, blogs.first.id,
316
+ title: 'Spring drop',
317
+ body: 'The new collection is live.',
318
+ status: 'draft',
319
+ tags: ['news']
320
+ )
321
+
322
+ # Changes the live article in place: only what you name is touched, and the
323
+ # article is addressed by its own id, so this never leaves a duplicate behind.
324
+ client.blogs.update_article(account_id, blogs.first.id, article.id, status: 'published')
325
+
326
+ client.blogs.delete_article(account_id, blogs.first.id, article.id)
327
+
328
+ products = client.blogs.list_products(account_id, status: 'active')
329
+ client.blogs.update_product(account_id, products.first.id, title: 'Mug XL')
330
+ ```
331
+
155
332
  ## Inbox
156
333
 
157
334
  Comments, mentions and direct messages on connected accounts. Needs the `inbox` scope.
@@ -174,12 +351,211 @@ client.inbox.refresh(workspace_id: workspace.id)
174
351
  client.inbox.approvals(workspace_id: workspace.id).each { |a| client.inbox.approve_reply(a.id) }
175
352
  ```
176
353
 
354
+ Acting on the platform also needs the `publish` scope. Each item's `can_*` flags say which actions its network supports.
355
+
356
+ ```ruby
357
+ client.inbox.like(item.id) # unlike, pin, unpin work the same way
358
+ client.inbox.vote(item.id, 'down') # 'up', 'down', or 'none' to take it back
359
+ client.inbox.react(item.id, reaction: '❤️') # reaction: nil removes ours
360
+ client.inbox.edit_comment(item.id, text: 'Fixed a typo')
361
+ client.inbox.reply(item.id, media_ids: [media.id], quick_replies: %w[Yes No])
362
+ client.inbox.start_conversation(account_id: account.id, handle: 'jordanvale', text: 'Hi!')
363
+ client.inbox.start_conversation(comment_id: item.id, text: 'Sent you the details')
364
+ client.inbox.set_typing(item.conversation_id, account_id: item.account.id)
365
+
366
+ # Messenger hand-over: pass the thread to another Meta app, or take it back with no app id.
367
+ client.inbox.handover(item.conversation_id, account_id: item.account.id, app_id: '263902037430900')
368
+ client.inbox.handover(item.conversation_id, account_id: item.account.id)
369
+ ```
370
+
371
+ ## Contacts
372
+
373
+ The people behind the inbox. A contact is one human however many handles they write from: an inbound item files its author, a reply files whoever you answered, and both fold into whatever is already on file. Needs the `inbox` scope.
374
+
375
+ ```ruby
376
+ page = client.contacts.list(workspace_id: workspace_id, search: 'ada')
377
+ page.each { |contact| puts "#{contact.display_name} — #{contact.channels.size} handles" }
378
+ puts page.meta.total
379
+
380
+ contact = client.contacts.get(contact_id)
381
+
382
+ # Folds into whoever already holds the first channel, so this cannot duplicate someone.
383
+ contact = client.contacts.create(
384
+ workspace_id: workspace_id,
385
+ channels: [{ 'platform' => 'x', 'handle' => 'ada_writes' }],
386
+ display_name: 'Ada Okafor',
387
+ fields: { 'plan_tier' => 'Pro' }
388
+ )
389
+
390
+ client.contacts.update(contact.id, fields: { 'region' => nil }) # nil clears a field
391
+ client.contacts.delete(contact.id) # the messages stay
392
+
393
+ # The threads this person appears in, newest first.
394
+ client.contacts.conversations(contact.id).each do |thread|
395
+ puts "#{thread.platform} #{thread.messages} messages, #{thread.received} in"
396
+ end
397
+
398
+ # platform and handle are required columns; any other column is a custom field key.
399
+ result = client.contacts.import(workspace_id: workspace_id, csv: "platform,handle\nx,ada_writes")
400
+ puts "#{result.created} created, #{result.merged} merged"
401
+ puts result.unknown_columns.inspect
402
+
403
+ # The columns your workspace keeps.
404
+ fields = client.contacts.list_fields(workspace_id)
405
+ field = client.contacts.create_field(workspace_id: workspace_id, key: 'plan_tier',
406
+ name: 'Plan Tier', type: 'select', options: %w[Free Pro])
407
+ client.contacts.update_field(field.id, name: 'Tier')
408
+ client.contacts.delete_field(field.id) # removes every answer to it
409
+
410
+ # Volume and median reply time per thread. Needs the `analytics` scope.
411
+ report = client.contacts.conversation_analytics(days: 30, sort: 'slowest')
412
+ puts report.conversations.first.median_response_minutes
413
+ ```
414
+
415
+ ## Broadcasts
416
+
417
+ One message into every conversation you already have with a segment of your contacts. Nothing is sent into a closed messaging window: Messenger and Instagram take a business-initiated message only within 24 hours of the contact's last one, so recipients outside it come back skipped with `window_closed` rather than attempted. Telegram, Slack, Bluesky and Reddit have no window.
418
+
419
+ Reading needs the `inbox` scope; `send` and `cancel` also need `publish`.
420
+
421
+ ```ruby
422
+ page = client.broadcasts.list(workspace_id: workspace_id, status: 'sent')
423
+ page.each { |b| puts "#{b.name} — #{b.counts.sent} sent, #{b.counts.skipped} skipped" }
424
+
425
+ broadcast = client.broadcasts.create(
426
+ workspace_id: workspace_id,
427
+ account_id: account_id,
428
+ name: 'September check-in',
429
+ text: 'New colours just landed. Want a look?',
430
+ audience: { 'platforms' => ['instagram'] }
431
+ )
432
+
433
+ # The recipients count is how many contacts matched, not how many will be
434
+ # messaged — the messaging window decides that.
435
+ client.broadcasts.send(broadcast.id)
436
+
437
+ # Who was skipped, and why.
438
+ client.broadcasts.recipients(broadcast.id, status: 'skipped').each do |r|
439
+ puts "#{r.display_name}: #{r.skip_reason}"
440
+ end
441
+ ```
442
+
443
+ ## Sequences
444
+
445
+ A series of messages, each a delay after the one before, walked per enrolled contact. The messaging window applies to every step: one that comes due outside it is skipped rather than sent, and the enrollment carries on.
446
+
447
+ ```ruby
448
+ sequence = client.sequences.create(
449
+ workspace_id: workspace_id,
450
+ account_id: account_id,
451
+ name: 'Welcome',
452
+ steps: [
453
+ { 'delay_hours' => 0, 'text' => 'Thanks for the follow — anything I can help with?' },
454
+ { 'delay_hours' => 48, 'text' => 'Here is what people usually ask us first.' }
455
+ ]
456
+ )
457
+
458
+ # By id, or by the same audience filter a broadcast takes.
459
+ client.sequences.enroll(sequence.id, contact_ids: [contact_id])
460
+ client.sequences.enroll(sequence.id, audience: { 'platforms' => ['telegram'] })
461
+
462
+ # Nothing further fires for them.
463
+ client.sequences.unenroll(sequence.id, [contact_id])
464
+
465
+ client.sequences.enrollments(sequence.id).each do |e|
466
+ puts "#{e.display_name} — step #{e.step}, #{e.status}"
467
+ end
468
+ ```
469
+ ## Blog
470
+
471
+ Articles written for a website, as opposed to posts that go to a social network. Reads, settings, keyword edits and `submit_article` need the `posts` scope; `suggest_seeds`, `plan_keywords`, `request_article` and `regenerate_article` spend credits and need `ai`; `publish_article` needs `publish`.
472
+
473
+ Planning starts from the blog's goal. Suggest search terms, research them, then pick the keywords worth writing; a picked keyword takes the next free date on the calendar.
474
+
475
+ ```ruby
476
+ client.blog.update_settings(workspace_id, goal: 'leads', focus_topics: 'Trail running gear')
477
+
478
+ seeds = client.blog.suggest_seeds(workspace_id, prompt: 'Beginners, not racers')
479
+ client.blog.plan_keywords(workspace_id, seeds: seeds)
480
+ client.blog.get_settings(workspace_id).plan_run&.status # => "done"
481
+
482
+ client.blog.list_keywords(workspace_id, status: 'suggested').first(3).each do |keyword|
483
+ client.blog.update_keyword(workspace_id, keyword.id, status: 'planned')
484
+ end
485
+ ```
486
+
487
+ Every article is approved before it publishes: submit a draft for review, then publish it or give it a `publish_at`.
488
+
489
+ ```ruby
490
+ article = client.blog.request_article(
491
+ workspace_id,
492
+ keyword: 'best trail running shoes',
493
+ instructions: 'Lead with a comparison table.'
494
+ )
495
+ article.status # => "queued"
496
+
497
+ # Later, once it is a draft.
498
+ full = client.blog.get_article(workspace_id, article.id)
499
+ puts full.body_markdown
500
+ client.blog.submit_article(workspace_id, article.id) # => status "in_review"
501
+ client.blog.publish_article(workspace_id, article.id)
502
+ ```
503
+
504
+ The Website target delivers every published article to `website_url` as a signed webhook. Setting the URL answers `website_secret` once; verify the raw request bytes with it, never a re-serialised body:
505
+
506
+ ```ruby
507
+ # Rails: request.raw_post and request.headers; Rack: env['rack.input'].read and env
508
+ event = Fopost::Webhooks.verify(raw_body, headers, ENV['FOPOST_WEBSITE_SECRET'])
509
+ if event.event == 'article.published'
510
+ article = event.article # Fopost::BlogArticlePublished
511
+ store(article.slug, article.body_html, article.featured_image&.url)
512
+ end
513
+ rescue Fopost::WebhookSignatureError
514
+ head :unauthorized
515
+ ```
516
+
517
+ `verify` prefers the timestamped `X-FoPost-Signature-256` header and rejects a timestamp more than 300 seconds off; it falls back to `X-FoPost-Signature` only when no timestamp was sent. The same helper verifies every other FoPost webhook.
518
+
519
+ ## Knowledge
520
+
521
+ What the workspace has told FoPost about itself. Retrieval over these sources
522
+ is what grounds a drafted inbox reply in your own answers instead of an
523
+ invented one. Needs the `inbox` scope.
524
+
525
+ ```ruby
526
+ # A source is an FAQ, a note, a page on your own site, or a plain-text/CSV
527
+ # media item. Adding one queues it for indexing, so it comes back `pending`.
528
+ faq = client.knowledge.create(
529
+ kind: 'faq',
530
+ title: 'Refunds and returns',
531
+ content: "Q: How long do refunds take?\nA: Up to 30 days from the request.",
532
+ workspace_id: workspace.id,
533
+ )
534
+ page = client.knowledge.create(kind: 'url', title: 'Shipping', url: 'https://yourbrand.com/shipping')
535
+
536
+ client.knowledge.list(workspace_id: workspace.id).each do |source|
537
+ puts "#{source.title} #{source.status} #{source.chunk_count}"
538
+ end
539
+
540
+ # Editing the text or the URL re-indexes the source on its own; a page you
541
+ # changed on your own site needs an explicit re-read.
542
+ client.knowledge.update(faq.id, title: 'Refunds')
543
+ client.knowledge.sync(page.id)
544
+ client.knowledge.delete(page.id)
545
+
546
+ # Empty is the honest answer when nothing stored answers the question.
547
+ client.knowledge.search('how long do refunds take?', top_k: 3).each do |match|
548
+ puts "#{match.source_title}: #{match.text}"
549
+ end
550
+ ```
551
+
552
+
177
553
  ## Ads
178
554
 
179
- Meta ads, audiences and lead forms. Every call needs the `ads` scope; `boost`, `create`, `set_status` and `delete` spend money and also need `publish`. A boost or ad starts paused unless you pass `paused: false`.
555
+ Ads, campaigns, creatives, audiences, insights and lead forms on a connected network. `providers` lists the networks a deployment knows and what each supports. Every call needs the `ads` scope; `boost`, `create`, `set_status`, `delete`, `bulk_set_status` and the create, update, delete and duplicate calls for campaigns, ad sets and network ads spend money and also need `publish`. A boost, campaign, ad set or ad starts paused unless you pass `paused: false`.
180
556
 
181
557
  ```ruby
182
- url = client.ads.authorize_meta(workspace_id: workspace.id) # finish the Meta login in a browser
558
+ url = client.ads.authorize('meta', workspace_id: workspace.id) # finish the login in a browser
183
559
  source = client.ads.sources(workspace_id: workspace.id).first
184
560
 
185
561
  ad = client.ads.boost(
@@ -205,6 +581,176 @@ client.ads.lead_forms(workspace_id: workspace.id)
205
581
  client.ads.leads('form_1', connection_id: source.connection_id, page_id: '42')
206
582
  ```
207
583
 
584
+ The campaign tree is read live from Meta by Meta id, so each call names the connection:
585
+
586
+ ```ruby
587
+ conn = source.connection_id
588
+ tree = client.ads.account_tree('act_123', connection_id: conn, workspace_id: workspace.id)
589
+ campaign = client.ads.create_campaign(workspace_id: workspace.id, connection_id: conn, ad_account_id: 'act_123',
590
+ name: 'Spring', goal: 'traffic')
591
+ ad_set = client.ads.create_ad_set(workspace_id: workspace.id, connection_id: conn, campaign_id: campaign.id,
592
+ page_id: '42', name: 'US adults', goal: 'traffic',
593
+ budget: { minor: 5000, type: 'daily' },
594
+ targeting: { countries: ['US'], ageMin: 18, ageMax: 65, gender: 'all' })
595
+ creative = client.ads.create_creative(workspace_id: workspace.id, connection_id: conn, ad_account_id: 'act_123',
596
+ page_id: '42', name: 'Hero', format: 'image', text: 'New in store',
597
+ media_url: 'https://yourbrand.com/hero.jpg', url_tags: 'utm_source=meta')
598
+ client.ads.create_network_ad(workspace_id: workspace.id, connection_id: conn, ad_set_id: ad_set.id,
599
+ creative_id: creative.id, name: 'Hero ad')
600
+ client.ads.bulk_set_status(workspace_id: workspace.id, connection_id: conn, status: 'active',
601
+ objects: [{ id: campaign.id, level: 'campaign' }])
602
+
603
+ client.ads.insights(connection_id: conn, object_id: campaign.id, since: '2026-09-01', until: '2026-09-07',
604
+ breakdown: 'age', daily: true)
605
+ client.ads.ad_insights(ad.id, workspace_id: workspace.id, since: '2026-09-01', until: '2026-09-07')
606
+
607
+ client.ads.subscribe_lead_page(workspace_id: workspace.id, connection_id: conn, page_id: '42')
608
+ page = client.ads.leads_feed(workspace_id: workspace.id, limit: 50)
609
+ page = client.ads.leads_feed(workspace_id: workspace.id, cursor: page.next_cursor) if page.next_cursor
610
+ ```
611
+
612
+ | Group | Methods |
613
+ | --- | --- |
614
+ | Campaigns | `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign` |
615
+ | Ad sets | `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set` |
616
+ | Network ads | `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status` |
617
+ | Creatives | `creatives`, `create_creative`, `get_creative`, `delete_creative` |
618
+ | Networks | `providers`, `authorize` |
619
+ | Audiences | `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach` |
620
+ | Forecasts | `bid_pricing`, `supply_forecast` |
621
+ | Conversions | `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` |
622
+ | Ad library | `ad_library` |
623
+ | Insights | `insights`, `ad_insights` |
624
+ | Leads | `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page` |
625
+ | Goals | `goals` |
626
+ | Catalogs | `catalogs`, `create_catalog`, `catalog`, `update_catalog`, `delete_catalog`, `catalog_products`, `write_catalog_products`, `product_feeds`, `create_product_feed`, `delete_product_feed`, `feed_uploads`, `start_feed_upload`, `product_sets`, `create_product_set`, `update_product_set`, `delete_product_set` |
627
+ | Reach and frequency | `reach_frequency`, `create_reach_frequency`, `reach_frequency_prediction`, `reserve_reach_frequency`, `cancel_reach_frequency` |
628
+ | Ad Library | `library` |
629
+ | Partnership ads | `partnership_creators`, `request_partnership`, `revoke_partnership` |
630
+ | Account settings | `account_activity`, `labels`, `create_label`, `update_label`, `delete_label`, `apply_label`, `studies`, `create_study`, `study`, `delete_study`, `ios_campaign_limits`, `high_demand_periods`, `create_high_demand_period`, `delete_high_demand_period`, `value_rule_sets`, `create_value_rule_set`, `delete_value_rule_set` |
631
+
632
+ ## Google Business Profile
633
+
634
+ Manage a connected Business Profile location: the profile, attributes, food menus, services, photos, action links, verification and performance.
635
+
636
+ ```ruby
637
+ location = client.google_business.get_location('acc_1')
638
+ client.google_business.update_location('acc_1', title: 'Corner Bakery', website_uri: 'https://yourbrand.com')
639
+
640
+ # Photos come from your media library, JPEG or PNG.
641
+ client.google_business.add_media('acc_1', media_id: media_id, category: 'INTERIOR')
642
+
643
+ client.google_business.create_place_action('acc_1', uri: 'https://yourbrand.com/book',
644
+ place_action_type: 'APPOINTMENT')
645
+
646
+ metrics = client.google_business.get_performance('acc_1', start_date: '2026-09-01', end_date: '2026-09-30')
647
+ terms = client.google_business.get_search_keywords('acc_1', start_date: '2026-08-01', end_date: '2026-09-01')
648
+ ```
649
+
650
+ Responses relay Google's own shape as plain hashes. Reads need the `accounts` scope, writes `publish` as well. Every call raises a 503 `configuration_error` until Google grants the deployment Business Profile API access.
651
+
652
+ ## Media
653
+
654
+ `limits` reads the upload ceilings for the account: `max_file_bytes` is the largest file by any route (100 MB up to 2 connected accounts, 400 MB up to 7, 2 GB up to 15, 5 GB beyond, or 5 GB with the Large Uploads add-on), `multipart_max_file_bytes` what one multipart request carries per file, `next_tier` what more accounts would unlock.
655
+
656
+ ```ruby
657
+ limits = client.media.limits(workspace_id: workspace.id)
658
+ limits.max_file_bytes # => 419430400
659
+ limits.multipart_max_file_bytes # => 104857600
660
+ limits.next_tier&.up_to_accounts # => 15
661
+ limits.allowed_types
662
+ ```
663
+
664
+ Direct uploads to the media library, in three steps: presign a slot, PUT the bytes to the returned URL with the returned headers (no API key), then complete. `upload_direct` does all three and returns the media item; a rejected PUT raises before `complete` is called. `size` may go up to `max_file_bytes`, and `presign` also takes `folder_id`, `origin`, `brand_role`, `brand_voice_id` and `person_consent`.
665
+
666
+ ```ruby
667
+ item = client.media.upload_direct(
668
+ workspace_id: workspace.id,
669
+ filename: 'logo.png',
670
+ mime_type: 'image/png',
671
+ data: File.binread('logo.png')
672
+ )
673
+ item.id
674
+ item.preview_url
675
+
676
+ # Or step by step
677
+ upload = client.media.presign(workspace_id: workspace.id, filename: 'logo.png', mime_type: 'image/png', size: 4096,
678
+ brand_role: 'logo')
679
+ upload.upload_url # PUT the bytes here with upload.headers
680
+ upload.max_file_bytes
681
+ client.media.complete(upload.upload_id)
682
+ ```
683
+
684
+ ## WhatsApp Business
685
+
686
+ A WhatsApp Business number you already own. The platform owns the templates,
687
+ flows, profile and commerce settings, so every call is live; all of it answers
688
+ 503 until WhatsApp is set up. Needs the `accounts` scope, except the sandbox,
689
+ which sends a template and needs `publish`.
690
+
691
+ ```ruby
692
+ profile = client.whatsapp.profile(account_id)
693
+ profile.quality_rating # as the platform reports it
694
+
695
+ # Filing a template returns the review status the platform gave it.
696
+ template = client.whatsapp.create_template(
697
+ account_id,
698
+ name: 'order_shipped',
699
+ language: 'en_US',
700
+ category: 'UTILITY',
701
+ components: [{ type: 'BODY', text: 'Your order is on its way.' }]
702
+ )
703
+ template.status # PENDING until the platform approves it
704
+
705
+ # A flow is created as a draft, its screens uploaded, then published.
706
+ flow = client.whatsapp.create_flow(account_id, name: 'Book a fitting', categories: ['LEAD_GENERATION'])
707
+ client.whatsapp.upload_flow_json(account_id, flow.id, { 'version' => '7.0', 'screens' => [] })
708
+ client.whatsapp.publish_flow(account_id, flow.id)
709
+ client.whatsapp.flow_responses(account_id)
710
+
711
+ # Groups are invite-only: there is no endpoint that adds a participant.
712
+ group = client.whatsapp.create_group(account_id, subject: 'Launch crew')
713
+ client.whatsapp.group_invite_link(account_id, group.id)
714
+ ## Analytics
715
+
716
+ ```ruby
717
+ # How long a post keeps earning, from the repeated readings of each post
718
+ decay = client.analytics.decay(days: 30)
719
+ puts decay.half_life_bucket # => "1h_3h"
720
+
721
+ # Whether posting more earned more
722
+ cadence = client.analytics.frequency(days: 90)
723
+ puts cadence.best&.label # => "3-5 a week"
724
+
725
+ # Every reading held for one post, with what moved between them
726
+ timeline = client.analytics.timeline(post.id)
727
+
728
+ # Mirror the metrics into your own store, without refetching everything
729
+ cursor = nil
730
+ loop do
731
+ page = client.analytics.changes(since: cursor)
732
+ save(page.changes)
733
+ break unless page.has_more && page.cursor
734
+
735
+ cursor = page.cursor
736
+ end
737
+
738
+ # Refresh one post now instead of waiting for the next collection run
739
+ client.analytics.collect_post(post.id)
740
+
741
+ # Posts on the account that never went out through FoPost
742
+ client.analytics.native_posts(accounts.first.id).each do |native|
743
+ puts "#{native.permalink} #{native.metrics.engagements}"
744
+ end
745
+ ```
746
+
747
+ A post is addressed by its FoPost id or by its permalink, so a post made by
748
+ hand on the network works the same way:
749
+
750
+ ```ruby
751
+ client.analytics.timeline('https://x.com/acme/status/1')
752
+ ```
753
+
208
754
  ## Errors
209
755
 
210
756
  Every non-2xx response raises. All of them are rescuable as `Fopost::Error`.
@@ -216,6 +762,7 @@ Every non-2xx response raises. All of them are rescuable as `Fopost::Error`.
216
762
  | 402 | `Fopost::PaymentRequiredError` |
217
763
  | 403 | `Fopost::PermissionDeniedError` |
218
764
  | 404 | `Fopost::NotFoundError` |
765
+ | 413 | `Fopost::FileTooLargeError` |
219
766
  | 429 | `Fopost::RateLimitError` |
220
767
  | other | `Fopost::Error` |
221
768
 
@@ -229,7 +776,7 @@ rescue Fopost::Error => e
229
776
  end
230
777
  ```
231
778
 
232
- `message` is what the API said; `to_s` adds the status and code, so an uncaught error still reports both. `e.body` holds the decoded response, and `Fopost::ValidationError#errors` carries per-field messages when the API sends them.
779
+ `message` is what the API said; `to_s` adds the status and code, so an uncaught error still reports both. `e.body` holds the decoded response, and `Fopost::ValidationError#errors` carries per-field messages when the API sends them. `Fopost::FileTooLargeError` reads `max_file_bytes`, `file_bytes` and `upgrade_url` off the body.
233
780
 
234
781
  ## Configuration
235
782
 
@@ -288,6 +835,22 @@ post['some_new_field'] # by either spelling
288
835
 
289
836
  That means a field added server-side never breaks an older client.
290
837
 
838
+ ## Chatbots and the inbox
839
+
840
+ The [chat adapter](https://fopost.com/docs/sdks/chat-adapter) turns the FoPost inbox into one send/receive channel for a chatbot
841
+ framework. It ships in the TypeScript and Python SDKs. There is no dedicated adapter here and no
842
+ API change behind it, so the same loop is three pieces with this client:
843
+
844
+ 1. **Verify** the `inbox.message_received` webhook. The payload is ids only, on purpose, so
845
+ nothing a customer wrote sits in your logs. The [signing scheme](https://fopost.com/docs/webhooks/verification)
846
+ is HMAC-SHA256 over `{timestamp}.{body}`, refused past a five minute tolerance.
847
+ 2. **Read** the item back with `client.inbox.list(workspace_id:, type: 'dm', account_id:)`, filtered to the payload's
848
+ `accountId` and matched on its `itemId`.
849
+ 3. **Answer** with `client.inbox.reply(item.id, text: text)`, or open a thread with
850
+ `client.inbox.start_conversation(account_id:, handle:, text:)`.
851
+
852
+ Reading needs the `inbox` scope; answering needs `publish` as well.
853
+
291
854
  ## Development
292
855
 
293
856
  ```bash
@@ -301,3 +864,30 @@ Tests run against a stubbed transport, so nothing touches the network.
301
864
  ## License
302
865
 
303
866
  MIT. See [LICENSE](LICENSE).
867
+
868
+ ### Google Ads
869
+
870
+ Campaigns, ad groups, ads, audiences and insights are on `client.ads` and dispatch by
871
+ connection. What only Google has is under `client.ads.google`:
872
+
873
+ ```ruby
874
+ keywords = client.ads.google.keywords(
875
+ connection_id: 'c4d5e6f7-…',
876
+ customer_id: '1234567890'
877
+ )
878
+
879
+ client.ads.google.create_keyword(
880
+ workspace_id: '7d2b8c11-…',
881
+ connection_id: 'c4d5e6f7-…',
882
+ customer_id: '1234567890',
883
+ ad_group_id: '1234567890~adGroup~77',
884
+ text: 'running shoes',
885
+ match_type: 'EXACT'
886
+ )
887
+ ```
888
+
889
+ Also `keyword_ideas`, `keyword_metrics`, `search_terms`, `bid_strategies`, `ad_schedule`
890
+ and `set_ad_schedule`, the negative keyword lists, `assets` and `asset_groups`,
891
+ `local_services_leads`, the conversion methods, and `query` for a raw read-only GAQL
892
+ SELECT. Changes need the `publish` scope as well as `ads`; `customer_id` has to name an
893
+ account the connection's grant reaches.