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.
data/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # broadcast-ruby
2
2
 
3
- Ruby client for the [Broadcast](https://sendbroadcast.com) email platform.
3
+ Official Ruby client for [Broadcast](https://sendbroadcast.net), the self-hosted email marketing 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. 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://sendbroadcast.com' # or your self-hosted URL
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
- ### Options
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` | `https://sendbroadcast.com` | Broadcast instance URL |
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 timeouts are retried; client errors (4xx) are not |
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
- | `debug` | `false` | Log request/response details |
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://sendbroadcast.com'
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://sendbroadcast.com'
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(tags: ['newsletter', 'premium'])
273
- client.subscribers.list(created_after: '2026-01-01T00:00:00Z')
274
- client.subscribers.list(custom_data: { plan: 'pro' })
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
- ## Channel Scoping (Admin/System Tokens)
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
- ## Webhook Endpoints
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
- ## Error Handling
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 -- exceeded 120 requests/minute
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) 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.
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:** If you're self-hosting, make sure `host` points to your Broadcast instance, not `sendbroadcast.com`.
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).