broadcast-ruby 0.1.4 → 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
@@ -1,8 +1,8 @@
1
1
  # broadcast-ruby
2
2
 
3
- Ruby client for the [Broadcast](https://sendbroadcast.com) email platform.
3
+ Ruby client for the [Broadcast](https://sendbroadcast.net) email platform.
4
4
 
5
- Works with [sendbroadcast.com](https://sendbroadcast.com) or any self-hosted Broadcast instance.
5
+ Works with any Broadcast instance self-hosted or SaaS.
6
6
 
7
7
  ## Installation
8
8
 
@@ -37,7 +37,7 @@ require 'broadcast'
37
37
 
38
38
  client = Broadcast::Client.new(
39
39
  api_token: 'your-token',
40
- host: 'https://sendbroadcast.com' # or your self-hosted URL
40
+ host: 'https://mail.example.com' # your Broadcast instance
41
41
  )
42
42
 
43
43
  client.send_email(
@@ -47,25 +47,108 @@ client.send_email(
47
47
  )
48
48
  ```
49
49
 
50
+ `host` has no default — every Broadcast instance lives at its own domain, so
51
+ there is no URL the gem could guess correctly. You can supply it through the
52
+ environment instead, using the same variable names as the Broadcast CLI:
53
+
54
+ ```bash
55
+ export BROADCAST_HOST=https://mail.example.com
56
+ export BROADCAST_API_TOKEN=your-token
57
+ ```
58
+
59
+ ```ruby
60
+ client = Broadcast::Client.new # picks both up from ENV
61
+ ```
62
+
50
63
  ### Options
51
64
 
52
65
  | Option | Default | Description |
53
66
  |--------|---------|-------------|
54
- | `api_token` | *required* | Your Broadcast API token |
55
- | `host` | `https://sendbroadcast.com` | Broadcast instance URL |
67
+ | `api_token` | *required* | Your Broadcast API token. Falls back to `BROADCAST_API_TOKEN` |
68
+ | `host` | *required* | Broadcast instance URL, scheme included. Falls back to `BROADCAST_HOST` |
56
69
  | `timeout` | `30` | Read timeout (seconds) |
57
70
  | `open_timeout` | `10` | Connection timeout (seconds) |
58
- | `retry_attempts` | `3` | Max total attempts (1 initial + 2 retries). Server errors (5xx) and timeouts are retried; client errors (4xx) are not |
71
+ | `retry_attempts` | `3` | Max total attempts (1 initial + 2 retries). Server errors (5xx), timeouts, and rate limits (429) are retried; other client errors (4xx) are not |
59
72
  | `retry_delay` | `1` | Base delay between retries in seconds (multiplied by attempt number) |
60
- | `debug` | `false` | Log request/response details |
73
+ | `max_retry_delay` | `30` | Ceiling for a server-supplied `Retry-After`, so a long rate-limit window can't stall the caller |
74
+ | `warnings_mode` | `:log` | How to handle API warnings — `:log`, `:raise`, or `:ignore`. See [API Warnings](#api-warnings) |
75
+ | `debug` | `false` | Log request/response details (credentials are redacted) |
61
76
  | `logger` | `nil` | Logger instance for debug output (e.g. `Rails.logger`) |
77
+ | `broadcast_channel_id` | `nil` | Auto-included on every request when set. Required when using an admin/system token (regular tokens are channel-scoped already). Can be overridden per-call or via `client.with_channel(id) { ... }` |
62
78
 
63
- All methods return parsed JSON as Ruby Hashes with string keys.
79
+ All methods return parsed JSON as Ruby Hashes with string keys. The returned
80
+ object is a `Broadcast::Response` — a Hash subclass that also carries response
81
+ metadata (`#warnings`, `#rate_limit`, `#status`, `#idempotent_replay?`).
82
+ Anything that worked against a plain Hash still works.
64
83
 
65
84
  > **Note on module naming:** This gem defines a top-level `Broadcast` module. If your application already has a `Broadcast` class or module (e.g. an ActiveRecord model), you may encounter a namespace collision.
66
85
 
67
86
  ---
68
87
 
88
+ ## API Warnings
89
+
90
+ Broadcast accepts a write and then tells you what it ignored. A misspelled
91
+ attribute, a parameter that only applies in another mode, a value the server
92
+ overrode — none of these fail the request, they come back as a `warnings` array
93
+ on the successful response.
94
+
95
+ This is the difference between "my custom field isn't saving and I can't work
96
+ out why" and a one-line answer:
97
+
98
+ ```ruby
99
+ result = client.subscribers.create(email: 'jane@example.com', frist_name: 'Jane')
100
+
101
+ result['id'] # => 42 — the subscriber WAS created
102
+ result.warnings? # => true
103
+ result.warnings.each { |w| puts w }
104
+ # [unrecognized_parameter] subscriber.frist_name: frist_name is not a recognized
105
+ # subscriber attribute and was ignored.
106
+ ```
107
+
108
+ Each warning has `#code`, `#param` (a dot-path, may be nil), and `#message`.
109
+ Values you submitted are never echoed back, so warnings are safe to log.
110
+
111
+ Control what happens via `warnings_mode`:
112
+
113
+ | Mode | Behaviour |
114
+ |------|-----------|
115
+ | `:log` (default) | Written to `logger` at WARN level, if a logger is configured |
116
+ | `:raise` | Raises `Broadcast::WarningError` |
117
+ | `:ignore` | Left on the response for you to inspect |
118
+
119
+ ```ruby
120
+ # Recommended in CI or a test suite: turn silent parameter drops into failures
121
+ client = Broadcast::Client.new(api_token: '...', host: '...', warnings_mode: :raise)
122
+ ```
123
+
124
+ > `:raise` fires **after** the request succeeded. Rescuing `WarningError` does
125
+ > not mean the write was rolled back — the subscriber was still created, the
126
+ > email was still sent.
127
+
128
+ Common codes: `unrecognized_parameter`, `parameter_ignored`,
129
+ `parameter_overridden`, `double_opt_in_skipped`.
130
+
131
+ ## Rate Limits
132
+
133
+ Every response carries the current limit state, and 429s are retried
134
+ automatically honouring the server's `Retry-After` (capped at `max_retry_delay`).
135
+
136
+ ```ruby
137
+ result = client.subscribers.list
138
+
139
+ result.rate_limit.limit # => 120
140
+ result.rate_limit.remaining # => 118
141
+ result.rate_limit.reset # => 2026-07-26 12:00:00 UTC
142
+
143
+ # Back off before you get throttled
144
+ sleep 1 if result.rate_limit.remaining < 10
145
+ ```
146
+
147
+ If the retries are exhausted you get a `Broadcast::RateLimitError`, which
148
+ carries `#retry_after` so you can requeue the job sensibly.
149
+
150
+ ---
151
+
69
152
  ## Rails ActionMailer Integration
70
153
 
71
154
  In a Rails app, the gem auto-registers a `:broadcast` delivery method. Your existing mailers work unchanged.
@@ -90,7 +173,7 @@ Then configure production to use Broadcast:
90
173
  config.action_mailer.delivery_method = :broadcast
91
174
  config.action_mailer.broadcast_settings = {
92
175
  api_token: Rails.application.credentials.dig(:broadcast, :api_token),
93
- host: 'https://sendbroadcast.com'
176
+ host: 'https://mail.example.com'
94
177
  }
95
178
  ```
96
179
 
@@ -132,7 +215,7 @@ Replace in `config/environments/production.rb`:
132
215
  + config.action_mailer.delivery_method = :broadcast
133
216
  + config.action_mailer.broadcast_settings = {
134
217
  + api_token: Rails.application.credentials.dig(:broadcast, :api_token),
135
- + host: 'https://sendbroadcast.com'
218
+ + host: 'https://mail.example.com'
136
219
  + }
137
220
  ```
138
221
 
@@ -182,6 +265,116 @@ email['queue_at'] # => '2026-03-17T07:59:58Z'
182
265
  | `body` | yes | Email content (HTML or plain text) |
183
266
  | `reply_to` | no | Reply-to address |
184
267
 
268
+ `send_email` is a thin convenience wrapper. For template-based sends, double opt-in, preheaders, and other advanced options, use `client.transactionals.create`:
269
+
270
+ ```ruby
271
+ # Send via a saved Template (resolves subject/body/preheader server-side)
272
+ client.transactionals.create(
273
+ to: 'user@example.com',
274
+ template_id: 42,
275
+ reply_to: 'support@yourapp.com',
276
+ include_unsubscribe_link: true
277
+ )
278
+
279
+ # Override individual fields while still using a template
280
+ client.transactionals.create(
281
+ to: 'user@example.com',
282
+ template_id: 42,
283
+ subject: 'Custom subject for this send'
284
+ )
285
+
286
+ # Set first/last name on a brand-new subscriber created by this send
287
+ client.transactionals.create(
288
+ to: 'new@example.com',
289
+ subject: 'Welcome!',
290
+ body: '<p>Hi {{first_name}}</p>',
291
+ subscriber: { first_name: 'Jane', last_name: 'Doe' }
292
+ )
293
+
294
+ # Get delivery status (alias of client.get_email)
295
+ client.transactionals.get_transactional(42)
296
+ ```
297
+
298
+ ### Idempotent Sends
299
+
300
+ Pass `idempotency_key:` to make a retry safe. The server stores the response for
301
+ 24 hours keyed on (token, key) and replays it instead of sending a second email
302
+ — so a job that times out after the send but before the ack won't double-mail
303
+ your customer.
304
+
305
+ ```ruby
306
+ result = client.transactionals.create(
307
+ to: 'user@example.com',
308
+ subject: "Receipt for order ##{order.id}",
309
+ body: receipt_html,
310
+ idempotency_key: "receipt-#{order.id}"
311
+ )
312
+
313
+ result.idempotent_replay? # => true if this replayed a stored response
314
+ ```
315
+
316
+ Use a key derived from the thing you're emailing about (`"receipt-#{order.id}"`),
317
+ not a random UUID generated per attempt — a fresh UUID on each retry defeats the
318
+ whole mechanism.
319
+
320
+ Two failure modes worth handling separately:
321
+
322
+ ```ruby
323
+ begin
324
+ client.transactionals.create(to: ..., idempotency_key: key)
325
+ rescue Broadcast::ConflictError
326
+ # 409 — the original request is still in flight. Retry shortly; do not
327
+ # change the key, or you'll send twice.
328
+ retry_job(wait: 5.seconds)
329
+ rescue Broadcast::ValidationError => e
330
+ # 422 with an idempotency key can mean the key was already used with a
331
+ # DIFFERENT payload. Retrying with the same key will never succeed.
332
+ raise
333
+ end
334
+ ```
335
+
336
+ The fingerprint covers method, full path (including query string), and body.
337
+ Changing any of them while reusing a key is what triggers that 422.
338
+
339
+ ### Double Opt-In
340
+
341
+ Pass `double_opt_in: true` to require email confirmation before delivery. The recipient receives a confirmation email; the actual transactional email is held until they confirm. If the recipient is already a confirmed subscriber, `double_opt_in` is ignored and the email sends normally.
342
+
343
+ ```ruby
344
+ # Boolean form: uses the channel's default confirmation template
345
+ result = client.transactionals.create(
346
+ to: 'new@example.com',
347
+ subject: 'Welcome!',
348
+ body: '<p>Confirmation email coming first...</p>',
349
+ double_opt_in: true
350
+ )
351
+ result['confirmation_status'] # => 'pending'
352
+ result['confirmation_url'] # => 'https://...'
353
+
354
+ # Hash form: customize the confirmation flow
355
+ client.transactionals.create(
356
+ to: 'new@example.com',
357
+ subject: 'Welcome!',
358
+ body: '<p>...</p>',
359
+ double_opt_in: {
360
+ reply_to: 'support@yourapp.com',
361
+ confirmation_template_id: 7,
362
+ include_unsubscribe_link: true
363
+ }
364
+ )
365
+
366
+ # Equivalent shortcut for confirmation_template_id at the top level
367
+ client.transactionals.create(
368
+ to: 'new@example.com',
369
+ subject: 'Welcome!',
370
+ body: '<p>...</p>',
371
+ double_opt_in: true,
372
+ confirmation_template_id: 7
373
+ )
374
+ ```
375
+
376
+ A transactional email server must be configured for the channel and a confirmation template (or default) must exist, otherwise the request returns 422.
377
+
185
378
  ---
186
379
 
187
380
  ## Subscribers
@@ -197,13 +390,22 @@ result['subscribers'] # => [{'email' => '...', 'tags' => [...], ...},
197
390
  result['pagination']['total'] # => 1500
198
391
  result['pagination']['current'] # => 1
199
392
 
200
- # Filter by status, tags, dates, or custom data
393
+ # Filter by status, tags, dates, or custom data -- all combinable
201
394
  client.subscribers.list(is_active: true)
202
- client.subscribers.list(tags: ['newsletter', 'premium'])
203
- client.subscribers.list(created_after: '2026-01-01T00:00:00Z')
204
- client.subscribers.list(custom_data: { plan: 'pro' })
395
+ client.subscribers.list(source: 'opt_in_form')
396
+ client.subscribers.list(tags: ['newsletter', 'premium']) # AND -- must have all
397
+ client.subscribers.list(email: 'example.com') # partial match, not exact
398
+ client.subscribers.list(confirmation_status: 'unconfirmed')
399
+ client.subscribers.list(created_after: '2026-01-01T00:00:00Z',
400
+ created_before: '2026-02-01T00:00:00Z')
401
+ client.subscribers.list(custom_data: { plan: 'pro' }) # JSONB containment
205
402
  ```
206
403
 
404
+ > An unparseable `created_after` / `created_before` is **ignored** by the server
405
+ > rather than rejected — you get every subscriber back, plus a
406
+ > `parameter_ignored` warning. Check `result.warnings` (or run with
407
+ > `warnings_mode: :raise`) if a filtered count looks too high.
408
+
207
409
  ```ruby
208
410
  # Find by email
209
411
  subscriber = client.subscribers.find(email: 'jane@example.com')
@@ -263,6 +465,42 @@ client.subscribers.resubscribe('jane@example.com')
263
465
  client.subscribers.redact('jane@example.com')
264
466
  ```
265
467
 
468
+ ### Double Opt-In
469
+
470
+ Pass `double_opt_in: true` (or a hash) to create the subscriber in unconfirmed state and queue a confirmation email. If the subscriber already exists and is confirmed, `double_opt_in` is ignored and the existing record is returned.
471
+
472
+ ```ruby
473
+ # Boolean form (uses the channel's default confirmation template)
474
+ result = client.subscribers.create(
475
+ email: 'new@example.com',
476
+ first_name: 'Jane',
477
+ double_opt_in: true
478
+ )
479
+ result['confirmation_status'] # => 'pending'
480
+ result['confirmation_url'] # => '...'
481
+
482
+ # Hash form -- customize reply-to, template, and unsubscribe link
483
+ client.subscribers.create(
484
+ email: 'new@example.com',
485
+ double_opt_in: {
486
+ reply_to: 'support@yourapp.com',
487
+ confirmation_template_id: 7,
488
+ include_unsubscribe_link: true
489
+ }
490
+ )
491
+
492
+ # `confirmation_template_id` is also accepted at the top level
493
+ client.subscribers.create(
494
+ email: 'new@example.com',
495
+ double_opt_in: true,
496
+ confirmation_template_id: 7
497
+ )
498
+ ```
499
+
500
+ `double_opt_in` and `confirmation_template_id` are top-level options -- they do **not** go inside the `subscriber:` envelope on the wire (the gem extracts them automatically).
501
+
502
+ The channel must have an active transactional email server and a confirmation template (default or `confirmation_template_id`), otherwise the request returns 422.
503
+
266
504
  ---
267
505
 
268
506
  ## Sequences
@@ -543,6 +781,201 @@ client.templates.delete(1)
543
781
 
544
782
  ---
545
783
 
784
+ ## Opt-In Forms
785
+
786
+ Embeddable subscription forms with theming, A/B variants, and analytics.
787
+
788
+ **Required permissions:** `opt_in_forms_read`, `opt_in_forms_write`
789
+
790
+ ```ruby
791
+ # List forms (paginated, up to 250 per page; only main forms -- variants are excluded)
792
+ result = client.opt_in_forms.list
793
+ result['opt_in_forms'] # => [{'id' => 1, 'label' => 'Newsletter', ...}, ...]
794
+ result['pagination']['current'] # => 1
795
+ result['pagination']['total'] # => 12
796
+
797
+ # Filter
798
+ client.opt_in_forms.list(filter: 'newsletter', widget_type: 'inline', enabled: 'true')
799
+
800
+ # Get a single form (full payload incl. blocks and settings)
801
+ form = client.opt_in_forms.get_opt_in_form(1)
802
+
803
+ # Create a form
804
+ result = client.opt_in_forms.create(
805
+ label: 'Newsletter Signup',
806
+ form_type: 'inline',
807
+ widget_type: 'inline',
808
+ enabled: true,
809
+ theme_settings: {
810
+ colors: { primary: '#3b82f6', background: '#ffffff', text: '#111827', border: '#d1d5db' }
811
+ },
812
+ automation_settings: {
813
+ tag_list: 'newsletter',
814
+ send_welcome_email: true,
815
+ double_opt_in: true,
816
+ sequence_ids: [3]
817
+ }
818
+ )
819
+
820
+ # Update (deeply nested settings hashes pass through verbatim)
821
+ client.opt_in_forms.update(1, enabled: false)
822
+
823
+ # Delete
824
+ client.opt_in_forms.delete(1)
825
+ ```
826
+
827
+ ### Analytics
828
+
829
+ ```ruby
830
+ # Last 30 days by default
831
+ client.opt_in_forms.analytics(1)
832
+
833
+ # Custom date range -- accepts Date, Time, or ISO-8601 strings
834
+ client.opt_in_forms.analytics(1,
835
+ start_date: Date.new(2026, 1, 1),
836
+ end_date: Date.new(2026, 1, 31)
837
+ )
838
+ # Returns: { totals: { views, unique_views, submissions, conversion_rate },
839
+ # daily: [...], variants: [...] }
840
+ ```
841
+
842
+ ### A/B Variants
843
+
844
+ ```ruby
845
+ # Create a variant of a form (defaults to "Variant N" / weight 50)
846
+ client.opt_in_forms.create_variant(1, name: 'B', weight: 50)
847
+
848
+ # Duplicate a form into a new top-level form (counts toward your plan limit)
849
+ client.opt_in_forms.duplicate(1, label: 'Newsletter Signup (Copy)')
850
+ ```
851
+
852
+ > **Note:** `index`/`show` and `create`/`update` return slightly different JSON shapes (the index/show responses go through JBuilder views; create/update use a richer inline serializer with analytics counts and embed URL). Don't depend on field-level parity between these paths.
853
+
854
+ ---
855
+
856
+ ## Email Servers
857
+
858
+ Configure outbound email providers for a channel (SMTP, AWS SES, Postmark, Inboxroad, SMTP.com, etc.).
859
+
860
+ **Required permissions:** `email_servers_read`, `email_servers_write`
861
+
862
+ ```ruby
863
+ # List email servers
864
+ result = client.email_servers.list
865
+ result['data'] # => [{'id' => 1, 'label' => 'Primary SES', 'vendor' => 'aws_ses', ...}, ...]
866
+ result['total'] # => 3
867
+
868
+ # Pagination
869
+ client.email_servers.list(limit: 10, offset: 0)
870
+
871
+ # Get a single email server
872
+ es = client.email_servers.get_email_server(1)
873
+
874
+ # Create -- example: AWS SES
875
+ client.email_servers.create(
876
+ label: 'Primary SES',
877
+ vendor: 'aws_ses',
878
+ delivery_method: 'aws_ses',
879
+ active: true,
880
+ aws_region: 'us-east-1',
881
+ aws_access_key_id: 'AKIA...',
882
+ aws_secret_access_key: 'secret...',
883
+ use_for_broadcasts: true,
884
+ use_for_sequences: true,
885
+ use_for_transactionals: true
886
+ )
887
+
888
+ # Create -- example: SMTP
889
+ client.email_servers.create(
890
+ label: 'Backup SMTP',
891
+ vendor: 'smtp',
892
+ delivery_method: 'smtp',
893
+ smtp_address: 'smtp.example.com',
894
+ smtp_port: 587,
895
+ smtp_username: 'user',
896
+ smtp_password: 'pass',
897
+ smtp_authentication: 'plain',
898
+ smtp_enable_starttls_auto: true,
899
+ emails_per_hour: 10000
900
+ )
901
+
902
+ # Update -- pass only the fields you want to change
903
+ client.email_servers.update(1, label: 'Primary SES (updated)', emails_per_hour: 50000)
904
+
905
+ # Test the connection (toggles `active` based on result)
906
+ result = client.email_servers.test_connection(1)
907
+ result['success'] # => true / false
908
+ result['message'] # => 'Connection successful' / 'Connection failed'
909
+
910
+ # Delete
911
+ client.email_servers.delete(1)
912
+ ```
913
+
914
+ ### Credential Redaction
915
+
916
+ API responses redact credential fields with bullet characters (e.g. `smtp_password: "abcd••••••wxyz"`). **Never round-trip a fetched response back into `update`** -- the gem detects redacted-shape values on known credential fields (`smtp_password`, `aws_*`, `postmark_api_token`, `inboxroad_api_token`, `smtp_com_api_key`) and silently strips them from the payload (with a warning) so they don't overwrite the real value:
917
+
918
+ ```ruby
919
+ # Safe -- only the fields you actually want to change
920
+ client.email_servers.update(1, label: 'New label', emails_per_hour: 25000)
921
+
922
+ # This would corrupt credentials WITHOUT the gem's scrubber:
923
+ es = client.email_servers.get_email_server(1)
924
+ client.email_servers.update(1, **es) # bullets get scrubbed, label still updates
925
+
926
+ # To rotate a credential, pass the real new value
927
+ client.email_servers.update(1, smtp_password: 'new-real-secret')
928
+ ```
929
+
930
+ ### Cross-Channel Copy (admin tokens only)
931
+
932
+ `copy_to_channel` clones an email server (with all settings and headers) into another channel. **Requires an admin/system token** with `email_servers_write` permission. Regular per-channel tokens get `Broadcast::AuthorizationError`. In SaaS mode, the target channel must be in the admin token creator's account.
933
+
934
+ ```ruby
935
+ admin_client = Broadcast::Client.new(
936
+ api_token: ENV['BROADCAST_ADMIN_TOKEN'],
937
+ broadcast_channel_id: 1 # the source server's channel
938
+ )
939
+
940
+ admin_client.email_servers.copy_to_channel(99, target_channel_id: 7)
941
+ ```
942
+
943
+ ---
944
+
945
+ ## Channel Scoping (Admin/System Tokens)
946
+
947
+ Regular API tokens are scoped to a single broadcast channel automatically. Admin/system tokens are not -- they require `broadcast_channel_id` on every request to indicate which channel they're acting on.
948
+
949
+ The gem auto-includes `broadcast_channel_id` from your `Configuration` on every request:
950
+
951
+ ```ruby
952
+ # Set globally
953
+ client = Broadcast::Client.new(
954
+ api_token: ENV['BROADCAST_ADMIN_TOKEN'],
955
+ broadcast_channel_id: 1
956
+ )
957
+ client.email_servers.list # broadcast_channel_id=1 is appended automatically
958
+ ```
959
+
960
+ For multi-channel scripts, use `with_channel` to scope a block of calls to a specific channel:
961
+
962
+ ```ruby
963
+ client = Broadcast::Client.new(api_token: ENV['BROADCAST_ADMIN_TOKEN'])
964
+
965
+ client.with_channel(1) do
966
+ client.email_servers.list
967
+ client.opt_in_forms.list
968
+ end
969
+
970
+ client.with_channel(2) do
971
+ client.subscribers.list
972
+ end
973
+ ```
974
+
975
+ `with_channel` overrides the config-level value inside the block. Per-call `broadcast_channel_id:` always wins over both. The override is thread-local, so it's safe to share a `Client` instance across threads.
976
+
977
+ ---
978
+
546
979
  ## Webhook Endpoints
547
980
 
548
981
  Receive real-time notifications when events occur (email delivered, subscriber created, sequence completed, etc.).
@@ -570,6 +1003,20 @@ result = client.webhook_endpoints.create(
570
1003
  # IMPORTANT: Save the secret from the response -- it is only shown once
571
1004
  secret = result['secret']
572
1005
 
1006
+ # Every valid event type is available as a constant. An unknown event type is
1007
+ # dropped server-side rather than rejected, so subscribe from these.
1008
+ Broadcast::Webhook::EVENT_TYPES # all 32
1009
+ Broadcast::Webhook::EMAIL_EVENTS # email.sent, email.delivered, ...
1010
+ Broadcast::Webhook::SUBSCRIBER_EVENTS # subscriber.created, ...
1011
+ Broadcast::Webhook::BROADCAST_EVENTS # broadcast.sending, broadcast.sent, ...
1012
+ Broadcast::Webhook::SEQUENCE_EVENTS # sequence.subscriber_added, ...
1013
+ Broadcast::Webhook::SYSTEM_EVENTS # message.attempt.exhausted, test.webhook
1014
+
1015
+ client.webhook_endpoints.create(
1016
+ url: 'https://yourapp.com/webhooks/broadcast',
1017
+ event_types: Broadcast::Webhook::EMAIL_EVENTS
1018
+ )
1019
+
573
1020
  # Update (url and secret cannot be changed -- create a new endpoint instead)
574
1021
  client.webhook_endpoints.update(1, active: false)
575
1022
  client.webhook_endpoints.update(1, event_types: ['email.delivered', 'email.opened'])
@@ -632,6 +1079,106 @@ The signature is computed as `HMAC-SHA256(timestamp + "." + payload, secret)`. T
632
1079
 
633
1080
  ---
634
1081
 
1082
+ ## Discovery
1083
+
1084
+ Ask the instance what this token can do and whether the channel is ready to
1085
+ send. Useful as a deploy-time smoke check, and as the entry point for agents
1086
+ and CLIs.
1087
+
1088
+ ```ruby
1089
+ # Who am I? Token type, permissions, resolved channel.
1090
+ me = client.whoami
1091
+ me['token']['type'] # => 'channel_scoped' or 'admin_cross_channel'
1092
+ me['token']['permissions'] # => { 'subscribers' => ['read', 'write'], ... }
1093
+ me['channel']['name'] # => 'Main'
1094
+
1095
+ # Is this channel actually able to send?
1096
+ status = client.status
1097
+ status['subscribers']['active'] # => 1042
1098
+ status['readiness']['broadcasts'] # => true
1099
+ status['readiness']['sequences'] # => false — no email server configured
1100
+
1101
+ # Full capability manifest: platform version, endpoint list, rate limit, tips.
1102
+ client.prime
1103
+
1104
+ # Plain-text agent skill manifest (Markdown + YAML front matter).
1105
+ # Returns a String, not a Hash.
1106
+ puts client.skill
1107
+ ```
1108
+
1109
+ A useful preflight before a send job:
1110
+
1111
+ ```ruby
1112
+ raise 'channel not ready to send' unless client.status.dig('readiness', 'broadcasts')
1113
+ ```
1114
+
1115
+ ---
1116
+
1117
+ ## Export & Migration (admin tokens only)
1118
+
1119
+ Read-only endpoints under `/api/migration/v1` for backups and moving a channel
1120
+ between instances. Two constraints differ from the rest of the API:
1121
+
1122
+ - **Admin tokens only.** Channel-scoped tokens are rejected.
1123
+ - **`broadcast_channel_id` is required on every call.** Set it once on the
1124
+ client and the gem attaches it for you.
1125
+
1126
+ ```ruby
1127
+ client = Broadcast::Client.new(
1128
+ api_token: ENV['BROADCAST_ADMIN_TOKEN'],
1129
+ host: 'https://mail.example.com',
1130
+ broadcast_channel_id: 1
1131
+ )
1132
+
1133
+ # Size the export first
1134
+ manifest = client.migration.manifest
1135
+ manifest['counts'] # => { 'subscribers' => 5000, 'templates' => 12, ... }
1136
+ manifest['export_format_version']
1137
+
1138
+ # Time-bounded counts (broadcasts, receipts, histories) default to 90 days
1139
+ client.migration.manifest(days_history: 365)
1140
+ ```
1141
+
1142
+ Each collection is a paginated list returning `data` and `pagination`:
1143
+
1144
+ ```ruby
1145
+ page = client.migration.subscribers(limit: 250, offset: 0)
1146
+ page['data']
1147
+ page['pagination'] # => { 'total' => 5000, 'limit' => 250, 'offset' => 0, 'has_more' => true }
1148
+ ```
1149
+
1150
+ `each_record` handles the paging for you, advancing by the page size the server
1151
+ actually granted rather than the one you asked for:
1152
+
1153
+ ```ruby
1154
+ CSV.open('subscribers.csv', 'w') do |csv|
1155
+ client.migration.each_record(:subscribers) do |subscriber|
1156
+ csv << [subscriber['email'], subscriber['first_name'], subscriber['created_at']]
1157
+ end
1158
+ end
1159
+
1160
+ # Without a block you get an Enumerator
1161
+ client.migration.each_record(:tags).map { |tag| tag['name'] }
1162
+ ```
1163
+
1164
+ Available collections:
1165
+
1166
+ ```
1167
+ channels subscribers templates segments sequences email_servers
1168
+ opt_in_forms broadcasts outbound_receipts webhook_endpoints tokens
1169
+ suppressions tags users link_redirects link_clicks
1170
+ subscriber_histories file_assets
1171
+ ```
1172
+
1173
+ File assets are downloaded separately and return raw bytes:
1174
+
1175
+ ```ruby
1176
+ bytes = client.migration.download_file_asset(7)
1177
+ File.binwrite('logo.png', bytes)
1178
+ ```
1179
+
1180
+ ---
1181
+
635
1182
  ## Error Handling
636
1183
 
637
1184
  All API errors inherit from `Broadcast::Error`. Put specific errors before general ones:
@@ -640,16 +1187,26 @@ All API errors inherit from `Broadcast::Error`. Put specific errors before gener
640
1187
  begin
641
1188
  client.send_email(to: 'user@example.com', subject: 'Hi', body: 'Hello')
642
1189
  rescue Broadcast::AuthenticationError # 401 -- invalid or expired API token
1190
+ rescue Broadcast::AuthorizationError # 403 -- token lacks the required permission, or admin-only endpoint
643
1191
  rescue Broadcast::NotFoundError # 404 -- resource does not exist
1192
+ rescue Broadcast::ConflictError # 409 -- Idempotency-Key request still in flight
644
1193
  rescue Broadcast::ValidationError # 422 -- missing or invalid parameters
645
- rescue Broadcast::RateLimitError # 429 -- exceeded 120 requests/minute
1194
+ rescue Broadcast::RateLimitError # 429 -- rate limited; carries #retry_after
646
1195
  rescue Broadcast::TimeoutError # connection or read timeout
647
1196
  rescue Broadcast::APIError # 5xx or unexpected status codes
1197
+ rescue Broadcast::WarningError # warnings_mode: :raise -- request SUCCEEDED
1198
+ rescue Broadcast::ConfigurationError # missing api_token or host
648
1199
  rescue Broadcast::DeliveryError # ActionMailer wrapper (wraps any of the above)
649
1200
  end
650
1201
  ```
651
1202
 
652
- Server errors (5xx) and timeouts are automatically retried with linear backoff. Client errors (401, 404, 422, 429) are raised immediately. `DeliveryError` is only raised from the ActionMailer delivery method -- it wraps the underlying API error.
1203
+ Server errors (5xx), timeouts, and rate limits (429) are retried automatically
1204
+ 429s honour the server's `Retry-After`, bounded by `max_retry_delay`. Other
1205
+ client errors (401, 403, 404, 409, 422) are raised immediately.
1206
+
1207
+ `DeliveryError` is only raised from the ActionMailer delivery method and wraps
1208
+ the underlying API error. `WarningError` is deliberately not wrapped: the email
1209
+ was sent, so reporting it as a delivery failure would be wrong.
653
1210
 
654
1211
  ---
655
1212
 
@@ -665,6 +1222,8 @@ Each token can be scoped to specific resources. The ActionMailer delivery method
665
1222
  | Broadcasts | `broadcasts_read` -- list, get, statistics | `broadcasts_write` -- create, update, delete, send, schedule |
666
1223
  | Segments | `segments_read` -- list, get | `segments_write` -- create, update, delete |
667
1224
  | Templates | `templates_read` -- list, get | `templates_write` -- create, update, delete |
1225
+ | Opt-In Forms | `opt_in_forms_read` -- list, get, analytics | `opt_in_forms_write` -- create, update, delete, create_variant, duplicate |
1226
+ | Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
668
1227
  | Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
669
1228
 
670
1229
  ---
@@ -674,9 +1233,15 @@ Each token can be scoped to specific resources. The ActionMailer delivery method
674
1233
  ### `Broadcast::AuthenticationError` (401)
675
1234
 
676
1235
  - **Wrong token:** Double-check you copied the full token from your Broadcast dashboard.
677
- - **Wrong host:** If you're self-hosting, make sure `host` points to your Broadcast instance, not `sendbroadcast.com`.
1236
+ - **Wrong host:** Make sure `host` points at your own Broadcast instance. There is no default.
678
1237
  - **Missing permissions:** Your token may not have the required permissions for the resource you're accessing. Check the [permissions table](#api-token-permissions).
679
1238
 
1239
+ ### `Broadcast::AuthorizationError` (403)
1240
+
1241
+ - **Missing permission:** Your token doesn't have the read or write permission for the resource (e.g. calling `client.opt_in_forms.create` with a token that only has `opt_in_forms_read`).
1242
+ - **Admin-only endpoint:** `email_servers.copy_to_channel` requires an admin/system token. Regular per-channel tokens cannot perform cross-channel operations.
1243
+ - **Missing channel scope on admin token:** Admin tokens require `broadcast_channel_id` on every request. Set it on `Broadcast::Client.new(broadcast_channel_id: …)` or wrap calls with `client.with_channel(id) { … }`.
1244
+
680
1245
  ### `Broadcast::ValidationError` (422)
681
1246
 
682
1247
  - **Missing required fields:** Check the parameters table for the method you're calling.