broadcast-ruby 0.2.0 โ 0.4.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 +146 -0
- data/Gemfile.lock +3 -2
- data/README.md +434 -22
- data/SDK-COVERAGE.md +469 -0
- data/lib/broadcast/client.rb +65 -138
- data/lib/broadcast/configuration.rb +53 -4
- data/lib/broadcast/connection.rb +278 -0
- data/lib/broadcast/debug_logger.rb +64 -0
- data/lib/broadcast/delivery_method.rb +22 -2
- data/lib/broadcast/errors.rb +27 -1
- data/lib/broadcast/resources/autopilots.rb +100 -0
- data/lib/broadcast/resources/discovery.rb +36 -0
- data/lib/broadcast/resources/global_suppressions.rb +43 -0
- data/lib/broadcast/resources/migration.rb +75 -0
- data/lib/broadcast/resources/opt_in_forms.rb +12 -0
- data/lib/broadcast/resources/subscribers.rb +24 -0
- data/lib/broadcast/resources/suppressions.rb +56 -0
- data/lib/broadcast/resources/templates.rb +17 -0
- data/lib/broadcast/resources/transactionals.rb +39 -2
- 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 +8 -0
- metadata +11 -3
- data/.rubocop.yml +0 -50
data/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# broadcast-ruby
|
|
2
2
|
|
|
3
|
-
Ruby client for
|
|
3
|
+
Official Ruby client for [Broadcast](https://sendbroadcast.net), the self-hosted email marketing platform.
|
|
4
4
|
|
|
5
|
-
Works with
|
|
5
|
+
Works with any Broadcast instance โ self-hosted or SaaS. Covers **104/104 API operations**, verified against the API's generated OpenAPI document.
|
|
6
|
+
|
|
7
|
+
๐ **[Ruby SDK documentation](https://sendbroadcast.net/docs/ruby-sdk)** ยท [API reference](https://sendbroadcast.net/docs/api-authentication) ยท [All docs](https://sendbroadcast.net/docs)
|
|
8
|
+
|
|
9
|
+
Also available: [PHP](https://github.com/send-broadcast/broadcast-php) ยท [Node/TypeScript](https://github.com/send-broadcast/broadcast-node) ยท [Python](https://github.com/send-broadcast/broadcast-python)
|
|
6
10
|
|
|
7
11
|
## Installation
|
|
8
12
|
|
|
@@ -37,7 +41,7 @@ require 'broadcast'
|
|
|
37
41
|
|
|
38
42
|
client = Broadcast::Client.new(
|
|
39
43
|
api_token: 'your-token',
|
|
40
|
-
host: 'https://
|
|
44
|
+
host: 'https://mail.example.com' # your Broadcast instance
|
|
41
45
|
)
|
|
42
46
|
|
|
43
47
|
client.send_email(
|
|
@@ -47,26 +51,124 @@ client.send_email(
|
|
|
47
51
|
)
|
|
48
52
|
```
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
`host` has no default โ every Broadcast instance lives at its own domain, so
|
|
55
|
+
there is no URL the gem could guess correctly. You can supply it through the
|
|
56
|
+
environment instead, using the same variable names as the Broadcast CLI:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
export BROADCAST_HOST=https://mail.example.com
|
|
60
|
+
export BROADCAST_API_TOKEN=your-token
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
client = Broadcast::Client.new # picks both up from ENV
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
51
68
|
|
|
52
69
|
| Option | Default | Description |
|
|
53
70
|
|--------|---------|-------------|
|
|
54
|
-
| `api_token` | *required* | Your Broadcast API token |
|
|
55
|
-
| `host` |
|
|
71
|
+
| `api_token` | *required* | Your Broadcast API token. Falls back to `BROADCAST_API_TOKEN` |
|
|
72
|
+
| `host` | *required* | Broadcast instance URL, scheme included. Falls back to `BROADCAST_HOST` |
|
|
56
73
|
| `timeout` | `30` | Read timeout (seconds) |
|
|
57
74
|
| `open_timeout` | `10` | Connection timeout (seconds) |
|
|
58
|
-
| `retry_attempts` | `3` | Max total attempts (1 initial + 2 retries). Server errors (5xx) and
|
|
75
|
+
| `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
76
|
| `retry_delay` | `1` | Base delay between retries in seconds (multiplied by attempt number) |
|
|
60
|
-
| `
|
|
77
|
+
| `max_retry_delay` | `30` | Ceiling for a server-supplied `Retry-After`, so a long rate-limit window can't stall the caller |
|
|
78
|
+
| `warnings_mode` | `:log` | How to handle API warnings โ `:log`, `:raise`, or `:ignore`. See [Warnings](#warnings) |
|
|
79
|
+
| `debug` | `false` | Log request/response details (credentials are redacted) |
|
|
61
80
|
| `logger` | `nil` | Logger instance for debug output (e.g. `Rails.logger`) |
|
|
62
81
|
| `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) { ... }` |
|
|
63
82
|
|
|
64
|
-
All methods return parsed JSON as Ruby Hashes with string keys.
|
|
65
|
-
|
|
66
83
|
> **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.
|
|
67
84
|
|
|
68
85
|
---
|
|
69
86
|
|
|
87
|
+
## Responses
|
|
88
|
+
|
|
89
|
+
Every call returns parsed JSON as a Ruby Hash with string keys, so `result['id']`
|
|
90
|
+
works as you would expect. The returned object is a `Broadcast::Response` โ a Hash
|
|
91
|
+
subclass that also carries the transport metadata:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
result = client.subscribers.create(email: 'ada@example.com')
|
|
95
|
+
|
|
96
|
+
result['id'] # the response body
|
|
97
|
+
result.status # 201
|
|
98
|
+
result.warnings # parsed warnings, if any
|
|
99
|
+
result.rate_limit&.remaining # requests left in the window
|
|
100
|
+
result.idempotent_replay? # true if the API replayed a stored response
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Anything that worked against a plain Hash still works โ `dig`, `is_a?(Hash)`,
|
|
104
|
+
equality against a literal Hash, splatting.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Warnings
|
|
109
|
+
|
|
110
|
+
Broadcast accepts a write and then tells you what it ignored. A misspelled
|
|
111
|
+
attribute, a parameter that only applies in another mode, a value the server
|
|
112
|
+
overrode โ none of these fail the request, they come back as a `warnings` array
|
|
113
|
+
on the successful response.
|
|
114
|
+
|
|
115
|
+
This is the difference between "my custom field isn't saving and I can't work
|
|
116
|
+
out why" and a one-line answer:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
result = client.subscribers.create(email: 'jane@example.com', frist_name: 'Jane')
|
|
120
|
+
|
|
121
|
+
result['id'] # => 42 โ the subscriber WAS created
|
|
122
|
+
result.warnings? # => true
|
|
123
|
+
result.warnings.each { |w| puts w }
|
|
124
|
+
# [unrecognized_parameter] subscriber.frist_name: frist_name is not a recognized
|
|
125
|
+
# subscriber attribute and was ignored.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Each warning has `#code`, `#param` (a dot-path, may be nil), and `#message`.
|
|
129
|
+
Values you submitted are never echoed back, so warnings are safe to log.
|
|
130
|
+
|
|
131
|
+
Control what happens via `warnings_mode`:
|
|
132
|
+
|
|
133
|
+
| Mode | Behaviour |
|
|
134
|
+
|------|-----------|
|
|
135
|
+
| `:log` (default) | Written to `logger` at WARN level, if a logger is configured |
|
|
136
|
+
| `:raise` | Raises `Broadcast::WarningError` |
|
|
137
|
+
| `:ignore` | Left on the response for you to inspect |
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# Recommended in CI or a test suite: turn silent parameter drops into failures
|
|
141
|
+
client = Broadcast::Client.new(api_token: '...', host: '...', warnings_mode: :raise)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
> `:raise` fires **after** the request succeeded. Rescuing `WarningError` does
|
|
145
|
+
> not mean the write was rolled back โ the subscriber was still created, the
|
|
146
|
+
> email was still sent.
|
|
147
|
+
|
|
148
|
+
Common codes: `unrecognized_parameter`, `parameter_ignored`,
|
|
149
|
+
`parameter_overridden`, `double_opt_in_skipped`.
|
|
150
|
+
|
|
151
|
+
## Rate Limits
|
|
152
|
+
|
|
153
|
+
Every response carries the current limit state, and 429s are retried
|
|
154
|
+
automatically honouring the server's `Retry-After` (capped at `max_retry_delay`).
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
result = client.subscribers.list
|
|
158
|
+
|
|
159
|
+
result.rate_limit.limit # => 120
|
|
160
|
+
result.rate_limit.remaining # => 118
|
|
161
|
+
result.rate_limit.reset # => 2026-07-26 12:00:00 UTC
|
|
162
|
+
|
|
163
|
+
# Back off before you get throttled
|
|
164
|
+
sleep 1 if result.rate_limit.remaining < 10
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
If the retries are exhausted you get a `Broadcast::RateLimitError`, which
|
|
168
|
+
carries `#retry_after` so you can requeue the job sensibly.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
70
172
|
## Rails ActionMailer Integration
|
|
71
173
|
|
|
72
174
|
In a Rails app, the gem auto-registers a `:broadcast` delivery method. Your existing mailers work unchanged.
|
|
@@ -91,7 +193,7 @@ Then configure production to use Broadcast:
|
|
|
91
193
|
config.action_mailer.delivery_method = :broadcast
|
|
92
194
|
config.action_mailer.broadcast_settings = {
|
|
93
195
|
api_token: Rails.application.credentials.dig(:broadcast, :api_token),
|
|
94
|
-
host: 'https://
|
|
196
|
+
host: 'https://mail.example.com'
|
|
95
197
|
}
|
|
96
198
|
```
|
|
97
199
|
|
|
@@ -133,7 +235,7 @@ Replace in `config/environments/production.rb`:
|
|
|
133
235
|
+ config.action_mailer.delivery_method = :broadcast
|
|
134
236
|
+ config.action_mailer.broadcast_settings = {
|
|
135
237
|
+ api_token: Rails.application.credentials.dig(:broadcast, :api_token),
|
|
136
|
-
+ host: 'https://
|
|
238
|
+
+ host: 'https://mail.example.com'
|
|
137
239
|
+ }
|
|
138
240
|
```
|
|
139
241
|
|
|
@@ -213,6 +315,47 @@ client.transactionals.create(
|
|
|
213
315
|
client.transactionals.get_transactional(42)
|
|
214
316
|
```
|
|
215
317
|
|
|
318
|
+
### Idempotent Sends
|
|
319
|
+
|
|
320
|
+
Pass `idempotency_key:` to make a retry safe. The server stores the response for
|
|
321
|
+
24 hours keyed on (token, key) and replays it instead of sending a second email
|
|
322
|
+
โ so a job that times out after the send but before the ack won't double-mail
|
|
323
|
+
your customer.
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
result = client.transactionals.create(
|
|
327
|
+
to: 'user@example.com',
|
|
328
|
+
subject: "Receipt for order ##{order.id}",
|
|
329
|
+
body: receipt_html,
|
|
330
|
+
idempotency_key: "receipt-#{order.id}"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
result.idempotent_replay? # => true if this replayed a stored response
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Use a key derived from the thing you're emailing about (`"receipt-#{order.id}"`),
|
|
337
|
+
not a random UUID generated per attempt โ a fresh UUID on each retry defeats the
|
|
338
|
+
whole mechanism.
|
|
339
|
+
|
|
340
|
+
Two failure modes worth handling separately:
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
begin
|
|
344
|
+
client.transactionals.create(to: ..., idempotency_key: key)
|
|
345
|
+
rescue Broadcast::ConflictError
|
|
346
|
+
# 409 โ the original request is still in flight. Retry shortly; do not
|
|
347
|
+
# change the key, or you'll send twice.
|
|
348
|
+
retry_job(wait: 5.seconds)
|
|
349
|
+
rescue Broadcast::ValidationError => e
|
|
350
|
+
# 422 with an idempotency key can mean the key was already used with a
|
|
351
|
+
# DIFFERENT payload. Retrying with the same key will never succeed.
|
|
352
|
+
raise
|
|
353
|
+
end
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The fingerprint covers method, full path (including query string), and body.
|
|
357
|
+
Changing any of them while reusing a key is what triggers that 422.
|
|
358
|
+
|
|
216
359
|
### Double Opt-In
|
|
217
360
|
|
|
218
361
|
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.
|
|
@@ -267,13 +410,22 @@ result['subscribers'] # => [{'email' => '...', 'tags' => [...], ...},
|
|
|
267
410
|
result['pagination']['total'] # => 1500
|
|
268
411
|
result['pagination']['current'] # => 1
|
|
269
412
|
|
|
270
|
-
# Filter by status, tags, dates, or custom data
|
|
413
|
+
# Filter by status, tags, dates, or custom data -- all combinable
|
|
271
414
|
client.subscribers.list(is_active: true)
|
|
272
|
-
client.subscribers.list(
|
|
273
|
-
client.subscribers.list(
|
|
274
|
-
client.subscribers.list(
|
|
415
|
+
client.subscribers.list(source: 'opt_in_form')
|
|
416
|
+
client.subscribers.list(tags: ['newsletter', 'premium']) # AND -- must have all
|
|
417
|
+
client.subscribers.list(email: 'example.com') # partial match, not exact
|
|
418
|
+
client.subscribers.list(confirmation_status: 'unconfirmed')
|
|
419
|
+
client.subscribers.list(created_after: '2026-01-01T00:00:00Z',
|
|
420
|
+
created_before: '2026-02-01T00:00:00Z')
|
|
421
|
+
client.subscribers.list(custom_data: { plan: 'pro' }) # JSONB containment
|
|
275
422
|
```
|
|
276
423
|
|
|
424
|
+
> An unparseable `created_after` / `created_before` is **ignored** by the server
|
|
425
|
+
> rather than rejected โ you get every subscriber back, plus a
|
|
426
|
+
> `parameter_ignored` warning. Check `result.warnings` (or run with
|
|
427
|
+
> `warnings_mode: :raise`) if a filtered count looks too high.
|
|
428
|
+
|
|
277
429
|
```ruby
|
|
278
430
|
# Find by email
|
|
279
431
|
subscriber = client.subscribers.find(email: 'jane@example.com')
|
|
@@ -810,7 +962,38 @@ admin_client.email_servers.copy_to_channel(99, target_channel_id: 7)
|
|
|
810
962
|
|
|
811
963
|
---
|
|
812
964
|
|
|
813
|
-
##
|
|
965
|
+
## Suppressions
|
|
966
|
+
|
|
967
|
+
A suppressed address is one Broadcast will not email. Each channel has its own list, and the installation has a global one; `client.suppressions` manages the current channel's list:
|
|
968
|
+
|
|
969
|
+
```ruby
|
|
970
|
+
client.suppressions.check('ada@example.com') # will this address receive mail?
|
|
971
|
+
client.suppressions.list(page: 1, email: 'example.com')
|
|
972
|
+
client.suppressions.add('bounced@example.com')
|
|
973
|
+
client.suppressions.remove('bounced@example.com')
|
|
974
|
+
client.suppressions.bulk_add(['a@example.com', 'b@example.com']) # up to 10,000
|
|
975
|
+
client.suppressions.bulk_remove(['a@example.com'])
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
`check` reads across both the channel's list and the global list, so it answers the question integrations actually ask: will this address receive mail? The response's `scope` says which list matched. Adding an already-suppressed address is a success (200 rather than 201), so there is no need to check first.
|
|
979
|
+
|
|
980
|
+
Bulk adds are idempotent -- a retried batch cannot duplicate -- and return `added`, `already_suppressed`, and `invalid` counts. `remove` returns `removed: false` (not an error) when the address was not on the list, and never touches the global list.
|
|
981
|
+
|
|
982
|
+
The global list is a separate resource and **requires an admin/system token**:
|
|
983
|
+
|
|
984
|
+
```ruby
|
|
985
|
+
client.global_suppressions.list
|
|
986
|
+
client.global_suppressions.add('spamtrap@example.com')
|
|
987
|
+
client.global_suppressions.remove('spamtrap@example.com')
|
|
988
|
+
client.global_suppressions.bulk_add([...])
|
|
989
|
+
client.global_suppressions.bulk_remove([...])
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
Removing an address globally does not unblock it in channels that suppressed it on their own account. There is no `check` on the global resource: checking is a per-channel question, so it lives on `client.suppressions`.
|
|
993
|
+
|
|
994
|
+
---
|
|
995
|
+
|
|
996
|
+
## Channel Scoping
|
|
814
997
|
|
|
815
998
|
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.
|
|
816
999
|
|
|
@@ -844,7 +1027,7 @@ end
|
|
|
844
1027
|
|
|
845
1028
|
---
|
|
846
1029
|
|
|
847
|
-
##
|
|
1030
|
+
## Webhooks
|
|
848
1031
|
|
|
849
1032
|
Receive real-time notifications when events occur (email delivered, subscriber created, sequence completed, etc.).
|
|
850
1033
|
|
|
@@ -871,6 +1054,20 @@ result = client.webhook_endpoints.create(
|
|
|
871
1054
|
# IMPORTANT: Save the secret from the response -- it is only shown once
|
|
872
1055
|
secret = result['secret']
|
|
873
1056
|
|
|
1057
|
+
# Every valid event type is available as a constant. An unknown event type is
|
|
1058
|
+
# dropped server-side rather than rejected, so subscribe from these.
|
|
1059
|
+
Broadcast::Webhook::EVENT_TYPES # all 32
|
|
1060
|
+
Broadcast::Webhook::EMAIL_EVENTS # email.sent, email.delivered, ...
|
|
1061
|
+
Broadcast::Webhook::SUBSCRIBER_EVENTS # subscriber.created, ...
|
|
1062
|
+
Broadcast::Webhook::BROADCAST_EVENTS # broadcast.sending, broadcast.sent, ...
|
|
1063
|
+
Broadcast::Webhook::SEQUENCE_EVENTS # sequence.subscriber_added, ...
|
|
1064
|
+
Broadcast::Webhook::SYSTEM_EVENTS # message.attempt.exhausted, test.webhook
|
|
1065
|
+
|
|
1066
|
+
client.webhook_endpoints.create(
|
|
1067
|
+
url: 'https://yourapp.com/webhooks/broadcast',
|
|
1068
|
+
event_types: Broadcast::Webhook::EMAIL_EVENTS
|
|
1069
|
+
)
|
|
1070
|
+
|
|
874
1071
|
# Update (url and secret cannot be changed -- create a new endpoint instead)
|
|
875
1072
|
client.webhook_endpoints.update(1, active: false)
|
|
876
1073
|
client.webhook_endpoints.update(1, event_types: ['email.delivered', 'email.opened'])
|
|
@@ -933,7 +1130,192 @@ The signature is computed as `HMAC-SHA256(timestamp + "." + payload, secret)`. T
|
|
|
933
1130
|
|
|
934
1131
|
---
|
|
935
1132
|
|
|
936
|
-
##
|
|
1133
|
+
## Autopilot
|
|
1134
|
+
|
|
1135
|
+
AI-generated newsletters. An autopilot reads your configured sources on a
|
|
1136
|
+
schedule, drafts copy in the tone you describe, and produces broadcast drafts
|
|
1137
|
+
for review. Requires `autopilot_read` / `autopilot_write`.
|
|
1138
|
+
|
|
1139
|
+
```ruby
|
|
1140
|
+
client.autopilots.list
|
|
1141
|
+
client.autopilots.get_autopilot(id)
|
|
1142
|
+
|
|
1143
|
+
autopilot = client.autopilots.create(
|
|
1144
|
+
name: 'Weekly Roundup',
|
|
1145
|
+
openrouter_api_key: ENV['OPENROUTER_API_KEY'],
|
|
1146
|
+
ai_model: 'openai/gpt-4o',
|
|
1147
|
+
schedule_frequency: 'weekly',
|
|
1148
|
+
schedule_day_of_week: 1,
|
|
1149
|
+
schedule_time: '09:00',
|
|
1150
|
+
schedule_timezone: 'America/New_York',
|
|
1151
|
+
copies_to_generate: 3,
|
|
1152
|
+
tone_description: 'Direct and technical. No hype.',
|
|
1153
|
+
content_instructions: 'Lead with the most consequential change.',
|
|
1154
|
+
segment_ids: [ 12 ]
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
client.autopilots.update(autopilot['id'], copies_to_generate: 5)
|
|
1158
|
+
client.autopilots.delete(autopilot['id'])
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
### Lifecycle
|
|
1162
|
+
|
|
1163
|
+
```ruby
|
|
1164
|
+
client.autopilots.activate(id) # start running on schedule
|
|
1165
|
+
client.autopilots.pause(id) # keep config, stop generating
|
|
1166
|
+
client.autopilots.deactivate(id)
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
`activate` requires **at least one active source, an API key, and a model**. If
|
|
1170
|
+
any is missing it raises `Broadcast::ValidationError` naming the prerequisites:
|
|
1171
|
+
|
|
1172
|
+
```ruby
|
|
1173
|
+
begin
|
|
1174
|
+
client.autopilots.activate(id)
|
|
1175
|
+
rescue Broadcast::ValidationError => e
|
|
1176
|
+
# All missing prerequisites, comma-joined:
|
|
1177
|
+
e.message # => "At least one active source is required, AI model is required"
|
|
1178
|
+
end
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
Sources and tone samples have **no API endpoints yet** โ they are configured in
|
|
1182
|
+
the web UI. Since `activate` needs an active source, a brand-new autopilot
|
|
1183
|
+
created over the API cannot be activated until a source is added there.
|
|
1184
|
+
|
|
1185
|
+
### Runs
|
|
1186
|
+
|
|
1187
|
+
`trigger_run` queues generation immediately and returns `202` โ the work is
|
|
1188
|
+
asynchronous, so poll rather than expecting finished copy back:
|
|
1189
|
+
|
|
1190
|
+
```ruby
|
|
1191
|
+
run = client.autopilots.trigger_run(id)
|
|
1192
|
+
run['status'] # => "pending"
|
|
1193
|
+
|
|
1194
|
+
client.autopilots.runs(id, limit: 10) # most recent first
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
### API key handling
|
|
1198
|
+
|
|
1199
|
+
`openrouter_api_key` is write-only. It is encrypted at rest and never returned โ
|
|
1200
|
+
reads expose only `api_key_configured`. The API renders a configured key
|
|
1201
|
+
bullet-masked, and writing that mask back would replace a working credential
|
|
1202
|
+
with bullets, so `update` strips it and warns:
|
|
1203
|
+
|
|
1204
|
+
```ruby
|
|
1205
|
+
current = client.autopilots.get_autopilot(id)
|
|
1206
|
+
current['api_key_configured'] # => true
|
|
1207
|
+
current['openrouter_api_key'] # => nil โ never returned
|
|
1208
|
+
|
|
1209
|
+
# Safe: the masked value is dropped, the stored key survives
|
|
1210
|
+
client.autopilots.update(id, openrouter_api_key: 'โขโขโขโขโขโขโขโข', ai_model: 'openai/gpt-4o')
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
Pass the real key to rotate it, or omit the field entirely. This is the same
|
|
1214
|
+
guard as [Email Servers](#credential-redaction).
|
|
1215
|
+
|
|
1216
|
+
---
|
|
1217
|
+
|
|
1218
|
+
## Discovery
|
|
1219
|
+
|
|
1220
|
+
Ask the instance what this token can do and whether the channel is ready to
|
|
1221
|
+
send. Useful as a deploy-time smoke check, and as the entry point for agents
|
|
1222
|
+
and CLIs.
|
|
1223
|
+
|
|
1224
|
+
```ruby
|
|
1225
|
+
# Who am I? Token type, permissions, resolved channel.
|
|
1226
|
+
me = client.whoami
|
|
1227
|
+
me['token']['type'] # => 'channel_scoped' or 'admin_cross_channel'
|
|
1228
|
+
me['token']['permissions'] # => { 'subscribers' => ['read', 'write'], ... }
|
|
1229
|
+
me['channel']['name'] # => 'Main'
|
|
1230
|
+
|
|
1231
|
+
# Is this channel actually able to send?
|
|
1232
|
+
status = client.status
|
|
1233
|
+
status['subscribers']['active'] # => 1042
|
|
1234
|
+
status['readiness']['broadcasts'] # => true
|
|
1235
|
+
status['readiness']['sequences'] # => false โ no email server configured
|
|
1236
|
+
|
|
1237
|
+
# Full capability manifest: platform version, endpoint list, rate limit, tips.
|
|
1238
|
+
client.prime
|
|
1239
|
+
|
|
1240
|
+
# Plain-text agent skill manifest (Markdown + YAML front matter).
|
|
1241
|
+
# Returns a String, not a Hash.
|
|
1242
|
+
puts client.skill
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
A useful preflight before a send job:
|
|
1246
|
+
|
|
1247
|
+
```ruby
|
|
1248
|
+
raise 'channel not ready to send' unless client.status.dig('readiness', 'broadcasts')
|
|
1249
|
+
```
|
|
1250
|
+
|
|
1251
|
+
---
|
|
1252
|
+
|
|
1253
|
+
## Export & Migration
|
|
1254
|
+
|
|
1255
|
+
Read-only endpoints under `/api/migration/v1` for backups and moving a channel
|
|
1256
|
+
between instances. Two constraints differ from the rest of the API:
|
|
1257
|
+
|
|
1258
|
+
- **Admin tokens only.** Channel-scoped tokens are rejected.
|
|
1259
|
+
- **`broadcast_channel_id` is required on every call.** Set it once on the
|
|
1260
|
+
client and the gem attaches it for you.
|
|
1261
|
+
|
|
1262
|
+
```ruby
|
|
1263
|
+
client = Broadcast::Client.new(
|
|
1264
|
+
api_token: ENV['BROADCAST_ADMIN_TOKEN'],
|
|
1265
|
+
host: 'https://mail.example.com',
|
|
1266
|
+
broadcast_channel_id: 1
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
# Size the export first
|
|
1270
|
+
manifest = client.migration.manifest
|
|
1271
|
+
manifest['counts'] # => { 'subscribers' => 5000, 'templates' => 12, ... }
|
|
1272
|
+
manifest['export_format_version']
|
|
1273
|
+
|
|
1274
|
+
# Time-bounded counts (broadcasts, receipts, histories) default to 90 days
|
|
1275
|
+
client.migration.manifest(days_history: 365)
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
Each collection is a paginated list returning `data` and `pagination`:
|
|
1279
|
+
|
|
1280
|
+
```ruby
|
|
1281
|
+
page = client.migration.subscribers(limit: 250, offset: 0)
|
|
1282
|
+
page['data']
|
|
1283
|
+
page['pagination'] # => { 'total' => 5000, 'limit' => 250, 'offset' => 0, 'has_more' => true }
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
`each_record` handles the paging for you, advancing by the page size the server
|
|
1287
|
+
actually granted rather than the one you asked for:
|
|
1288
|
+
|
|
1289
|
+
```ruby
|
|
1290
|
+
CSV.open('subscribers.csv', 'w') do |csv|
|
|
1291
|
+
client.migration.each_record(:subscribers) do |subscriber|
|
|
1292
|
+
csv << [subscriber['email'], subscriber['first_name'], subscriber['created_at']]
|
|
1293
|
+
end
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
# Without a block you get an Enumerator
|
|
1297
|
+
client.migration.each_record(:tags).map { |tag| tag['name'] }
|
|
1298
|
+
```
|
|
1299
|
+
|
|
1300
|
+
Available collections:
|
|
1301
|
+
|
|
1302
|
+
```
|
|
1303
|
+
channels subscribers templates segments sequences email_servers
|
|
1304
|
+
opt_in_forms broadcasts outbound_receipts webhook_endpoints tokens
|
|
1305
|
+
suppressions tags users link_redirects link_clicks
|
|
1306
|
+
subscriber_histories file_assets
|
|
1307
|
+
```
|
|
1308
|
+
|
|
1309
|
+
File assets are downloaded separately and return raw bytes:
|
|
1310
|
+
|
|
1311
|
+
```ruby
|
|
1312
|
+
bytes = client.migration.download_file_asset(7)
|
|
1313
|
+
File.binwrite('logo.png', bytes)
|
|
1314
|
+
```
|
|
1315
|
+
|
|
1316
|
+
---
|
|
1317
|
+
|
|
1318
|
+
## Errors
|
|
937
1319
|
|
|
938
1320
|
All API errors inherit from `Broadcast::Error`. Put specific errors before general ones:
|
|
939
1321
|
|
|
@@ -943,15 +1325,24 @@ begin
|
|
|
943
1325
|
rescue Broadcast::AuthenticationError # 401 -- invalid or expired API token
|
|
944
1326
|
rescue Broadcast::AuthorizationError # 403 -- token lacks the required permission, or admin-only endpoint
|
|
945
1327
|
rescue Broadcast::NotFoundError # 404 -- resource does not exist
|
|
1328
|
+
rescue Broadcast::ConflictError # 409 -- Idempotency-Key request still in flight
|
|
946
1329
|
rescue Broadcast::ValidationError # 422 -- missing or invalid parameters
|
|
947
|
-
rescue Broadcast::RateLimitError # 429 --
|
|
1330
|
+
rescue Broadcast::RateLimitError # 429 -- rate limited; carries #retry_after
|
|
948
1331
|
rescue Broadcast::TimeoutError # connection or read timeout
|
|
949
1332
|
rescue Broadcast::APIError # 5xx or unexpected status codes
|
|
1333
|
+
rescue Broadcast::WarningError # warnings_mode: :raise -- request SUCCEEDED
|
|
1334
|
+
rescue Broadcast::ConfigurationError # missing api_token or host
|
|
950
1335
|
rescue Broadcast::DeliveryError # ActionMailer wrapper (wraps any of the above)
|
|
951
1336
|
end
|
|
952
1337
|
```
|
|
953
1338
|
|
|
954
|
-
Server errors (5xx)
|
|
1339
|
+
Server errors (5xx), timeouts, and rate limits (429) are retried automatically โ
|
|
1340
|
+
429s honour the server's `Retry-After`, bounded by `max_retry_delay`. Other
|
|
1341
|
+
client errors (401, 403, 404, 409, 422) are raised immediately.
|
|
1342
|
+
|
|
1343
|
+
`DeliveryError` is only raised from the ActionMailer delivery method and wraps
|
|
1344
|
+
the underlying API error. `WarningError` is deliberately not wrapped: the email
|
|
1345
|
+
was sent, so reporting it as a delivery failure would be wrong.
|
|
955
1346
|
|
|
956
1347
|
---
|
|
957
1348
|
|
|
@@ -970,6 +1361,8 @@ Each token can be scoped to specific resources. The ActionMailer delivery method
|
|
|
970
1361
|
| Opt-In Forms | `opt_in_forms_read` -- list, get, analytics | `opt_in_forms_write` -- create, update, delete, create_variant, duplicate |
|
|
971
1362
|
| Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
|
|
972
1363
|
| Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
|
|
1364
|
+
| Autopilot | `autopilot_read` -- list, get, runs | `autopilot_write` -- create, update, delete, activate, pause, deactivate, trigger_run |
|
|
1365
|
+
| Suppressions | `suppressions_read` -- list, check | `suppressions_write` -- add, remove, bulk add/remove |
|
|
973
1366
|
|
|
974
1367
|
---
|
|
975
1368
|
|
|
@@ -978,7 +1371,7 @@ Each token can be scoped to specific resources. The ActionMailer delivery method
|
|
|
978
1371
|
### `Broadcast::AuthenticationError` (401)
|
|
979
1372
|
|
|
980
1373
|
- **Wrong token:** Double-check you copied the full token from your Broadcast dashboard.
|
|
981
|
-
- **Wrong host:**
|
|
1374
|
+
- **Wrong host:** Make sure `host` points at your own Broadcast instance. There is no default.
|
|
982
1375
|
- **Missing permissions:** Your token may not have the required permissions for the resource you're accessing. Check the [permissions table](#api-token-permissions).
|
|
983
1376
|
|
|
984
1377
|
### `Broadcast::AuthorizationError` (403)
|
|
@@ -1013,6 +1406,25 @@ Each token can be scoped to specific resources. The ActionMailer delivery method
|
|
|
1013
1406
|
- **Check credentials:** Run `bin/rails credentials:show` and verify `broadcast.api_token` is set.
|
|
1014
1407
|
- **Check logs:** Set `debug: true` in `broadcast_settings` to see request/response details.
|
|
1015
1408
|
|
|
1409
|
+
## Documentation
|
|
1410
|
+
|
|
1411
|
+
- **[Ruby SDK guide](https://sendbroadcast.net/docs/ruby-sdk)** โ the same material as this README, on the docs site
|
|
1412
|
+
- **[API reference](https://sendbroadcast.net/docs/api-authentication)** โ endpoints, parameters, and permissions
|
|
1413
|
+
- **[API response warnings](https://sendbroadcast.net/docs/api-response-warnings)** โ why a 2xx can still tell you something went wrong
|
|
1414
|
+
- **[Webhook endpoints](https://sendbroadcast.net/docs/api-webhook-endpoints)** โ signature format and event types
|
|
1415
|
+
- **[Agents CLI](https://sendbroadcast.net/docs/agents-cli)** โ the same credentials, from a terminal
|
|
1416
|
+
|
|
1417
|
+
### Other SDKs
|
|
1418
|
+
|
|
1419
|
+
| Language | Package | Repository |
|
|
1420
|
+
|---|---|---|
|
|
1421
|
+
| Ruby | [broadcast-ruby](https://rubygems.org/gems/broadcast-ruby) | this repository |
|
|
1422
|
+
| PHP | broadcast/broadcast-php | [broadcast-php](https://github.com/send-broadcast/broadcast-php) |
|
|
1423
|
+
| Node / TypeScript | @send-broadcast/sdk | [broadcast-node](https://github.com/send-broadcast/broadcast-node) |
|
|
1424
|
+
| Python | broadcast-python | [broadcast-python](https://github.com/send-broadcast/broadcast-python) |
|
|
1425
|
+
|
|
1426
|
+
All four cover the same 115 operations and behave the same way on the wire โ the transport contract (warnings, idempotency, rate-limit handling, redirect safety, credential redaction) is identical across languages.
|
|
1427
|
+
|
|
1016
1428
|
## License
|
|
1017
1429
|
|
|
1018
1430
|
MIT License. See [LICENSE.txt](LICENSE.txt).
|