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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +113 -0
- data/Gemfile.lock +3 -2
- data/README.md +582 -17
- data/SDK-COVERAGE.md +422 -0
- data/lib/broadcast/client.rb +90 -119
- data/lib/broadcast/configuration.rb +56 -5
- data/lib/broadcast/connection.rb +278 -0
- data/lib/broadcast/debug_logger.rb +64 -0
- data/lib/broadcast/delivery_method.rb +5 -0
- data/lib/broadcast/errors.rb +29 -1
- data/lib/broadcast/resources/autopilots.rb +100 -0
- data/lib/broadcast/resources/discovery.rb +36 -0
- data/lib/broadcast/resources/email_servers.rb +88 -0
- data/lib/broadcast/resources/migration.rb +75 -0
- data/lib/broadcast/resources/opt_in_forms.rb +83 -0
- data/lib/broadcast/resources/subscribers.rb +43 -1
- data/lib/broadcast/resources/templates.rb +17 -0
- data/lib/broadcast/resources/transactionals.rb +86 -0
- data/lib/broadcast/response.rb +104 -0
- data/lib/broadcast/version.rb +1 -1
- data/lib/broadcast/webhook.rb +34 -0
- data/lib/broadcast.rb +9 -0
- metadata +12 -3
- data/.rubocop.yml +0 -48
data/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# broadcast-ruby
|
|
2
2
|
|
|
3
|
-
Ruby client for the [Broadcast](https://sendbroadcast.
|
|
3
|
+
Ruby client for the [Broadcast](https://sendbroadcast.net) email platform.
|
|
4
4
|
|
|
5
|
-
Works with
|
|
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://
|
|
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` |
|
|
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
|
|
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
|
-
| `
|
|
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://
|
|
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://
|
|
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(
|
|
203
|
-
client.subscribers.list(
|
|
204
|
-
client.subscribers.list(
|
|
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 --
|
|
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)
|
|
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:**
|
|
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.
|