senddart 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 283eab8c1be47c578fa465596d35ff85abf8b3c504ef041d21a3fbb23631697f
4
+ data.tar.gz: ae074cff46085c39c5d9b801c1324e5259a6e172f49ddd40bbb2516cea99fcd1
5
+ SHA512:
6
+ metadata.gz: 4b4ab1becf2e4a760d96883384adf46743c3e8e19346f35858c0edc5cc46a7690635c538651bb932d0ee5d192a5224ef019e561a8154376b710b1d65dbe9da55
7
+ data.tar.gz: d5db56168fcad952b541148cf9c944463e6092735f267189ab860383ea0f6abe5a13eeded4ecfbd47d7e62ac685923c4f985aa23ce9b933418d614569577a795
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SendDart
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,469 @@
1
+ # senddart
2
+
3
+ Official Ruby SDK for the [SendDart](https://www.senddart.com) email API — send transactional and marketing email from your own verified domain.
4
+
5
+ Zero runtime dependencies: the gem uses only Ruby's standard library (`Net::HTTP`, `JSON`, `OpenSSL`).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ gem install senddart
11
+ ```
12
+
13
+ Or in your Gemfile:
14
+
15
+ ```ruby
16
+ gem "senddart"
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ ```ruby
22
+ require "senddart"
23
+
24
+ SendDart.api_key = "mb_xxxxxxxxx"
25
+
26
+ # or
27
+ SendDart.configure do |config|
28
+ config.api_key = ENV["SENDDART_API_KEY"]
29
+ # config.base_url = "https://www.senddart.com/api" # override your API host
30
+ end
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```ruby
36
+ sent = SendDart::Emails.send({
37
+ from: "Acme <hello@yourdomain.com>",
38
+ to: ["delivered@test.senddart.com"], # the mailbox simulator (see below)
39
+ subject: "Hello from SendDart",
40
+ html: "<p>Your first email 🎉</p>"
41
+ })
42
+ puts sent["id"]
43
+ ```
44
+
45
+ `delivered@test.senddart.com` is SendDart's mailbox simulator: the send is accepted, produces a real email object and a delivery event, and never reaches a provider. `bounced@`, `complained@` and `suppressed@test.senddart.com` exercise the other outcomes, and `delivered@` / `bounced@` / `complained@` accept a `+label` suffix (`delivered+signup@test.senddart.com`). Do **not** point a send at `example.com`, `example.net`, `example.org`, or an address under `.test`, `.invalid`, `.localhost` or `.example`: those are reserved for documentation, so every recipient is suppressed and the call comes back 422 `validation_error` ("All `to` recipients are suppressed") having sent nothing. They are fine as *contact* records — only the send path rejects them.
46
+
47
+ Params are plain hashes with snake_case keys, passed through as JSON. Successful calls return the parsed response (a Hash, or a raw String for binary downloads). Any non-2xx response raises `SendDart::Error`:
48
+
49
+ ```ruby
50
+ begin
51
+ SendDart::Emails.send(params)
52
+ rescue SendDart::Error => e
53
+ puts e.status_code # => 422
54
+ puts e.name # => "validation_error"
55
+ puts e.message # => human-readable explanation
56
+ end
57
+ ```
58
+
59
+ Branch on `e.name`, never on `e.message` — messages are sanitized server-side and may change. The same `name` can arrive with different HTTP statuses depending on the endpoint, so read `e.status_code` rather than assuming one. Common names: `missing_api_key` (401), `restricted_api_key` (401, the key lacks the scope), `invalid_api_key` (403), `validation_error` (422), `not_found` (404), `plan_limit_reached` (402), `daily_quota_exceeded` / `monthly_quota_exceeded` / `rate_limit_exceeded` (429).
60
+
61
+ Some errors carry more than that envelope. The extras are readers on the error and are `nil` on an ordinary one:
62
+
63
+ ```ruby
64
+ rescue SendDart::Error => e
65
+ # WHICH quota ran out, and what would clear it.
66
+ if (cap = e.limit)
67
+ cap["kind"] # => "emails_daily"
68
+ cap["used"], cap["limit"] # => 100, 100
69
+ cap["period"] # => "24h"
70
+ cap.dig("next_plan", "name") # => "Pro"
71
+ end
72
+
73
+ # Reputation gates: whether waiting helps, and until when.
74
+ if (rep = e.reputation)
75
+ rep["retryable"], rep["scope"], rep["retry_at"]
76
+ end
77
+
78
+ # A batch that failed part way through — do NOT resend these.
79
+ if (sent = e.sent)
80
+ puts "#{e.sent_count} already went out: #{sent.map { |s| s['id'] }.join(', ')}"
81
+ end
82
+ end
83
+ ```
84
+
85
+ `e.body` is the whole parsed error body, so a field newer than this SDK version is still reachable.
86
+
87
+ Every request carries a `User-Agent` automatically — the API rejects requests without one with a 403 `validation_error`.
88
+
89
+ ## Domain-first model
90
+
91
+ SendDart is **domain-first**: each sending domain has its own pool of contacts. The same email address on two domains is two records with separate consent, so unsubscribes on one product never leak into another.
92
+
93
+ That means `domain` (the sending domain, e.g. `"yourdomain.com"` — one of your verified domains) is **required** on:
94
+
95
+ - `Contacts.create` / `Contacts.list` (the flat `/contacts` API — pass `audience_id:` to use the nested audience routes instead)
96
+ - `Segments.create` / `Segments.list`
97
+ - `Topics.create` / `Topics.list`
98
+ - `Campaigns.create` (picks the contact pool the campaign targets; `from` may be a different verified domain)
99
+ - `Automations.create` and `Events.send` (only automations belonging to that domain are triggered)
100
+
101
+ ## Emails
102
+
103
+ ```ruby
104
+ SendDart::Emails.send({ from: from, to: to, subject: subject, html: html })
105
+ SendDart::Emails.list({ limit: 20, after: cursor }) # cursor pagination
106
+ SendDart::Emails.list({ status: "bounced", search: "acme.com" }) # filters
107
+ SendDart::Emails.list({ folder: "scheduled" }) # one of outbox | sent | scheduled | failed — any other value is rejected (422)
108
+ SendDart::Emails.sources # per-campaign/automation send metrics
109
+ SendDart::Emails.get(email_id)
110
+ SendDart::Emails.list_attachments(email_id)
111
+ SendDart::Emails.get_attachment(email_id, attachment_id)
112
+ SendDart::Emails.update(email_id, { scheduled_at: "2026-08-01T09:00:00Z" }) # reschedule
113
+ SendDart::Emails.cancel(email_id)
114
+
115
+ # Batch send — up to 100 emails in one request.
116
+ # Batch items reject `attachments` and `scheduled_at` (422) — send those individually.
117
+ SendDart::Batch.send([
118
+ { from: from, to: ["delivered+a@test.senddart.com"], subject: "Hi A", html: "<p>A</p>" },
119
+ { from: from, to: ["delivered+b@test.senddart.com"], subject: "Hi B", html: "<p>B</p>" }
120
+ ])
121
+
122
+ # Attachments: hosted URL (path) or inline base64 (content)
123
+ SendDart::Emails.send({
124
+ from: from, to: to, subject: "Your invoice", html: "<p>Attached.</p>",
125
+ attachments: [
126
+ { filename: "invoice.pdf", path: "https://yourdomain.com/invoices/invoice.pdf" },
127
+ { filename: "report.csv", content: base64_content, content_type: "text/csv" }
128
+ ]
129
+ })
130
+ ```
131
+
132
+ ### Inbound email
133
+
134
+ ```ruby
135
+ SendDart::Emails::Receiving.list
136
+ SendDart::Emails::Receiving.list_addresses # per-address inbound stats
137
+ SendDart::Emails::Receiving.get(id)
138
+ SendDart::Emails::Receiving.list_attachments(id)
139
+ SendDart::Emails::Receiving.get_attachment(id, attachment_id) # => raw bytes (String)
140
+ SendDart::Emails::Receiving.get_raw(id) # => original RFC822 message
141
+ SendDart::Emails::Receiving.forward(id, { from: "you@yourdomain.com", to: "team@you.com" })
142
+ SendDart::Emails::Receiving.reply(id, { from: "you@yourdomain.com", html: "<p>Thanks!</p>" })
143
+ SendDart::Emails::Receiving.delete(id)
144
+ ```
145
+
146
+ ## Domains
147
+
148
+ ```ruby
149
+ SendDart::Domains.create({ name: "yourdomain.com" })
150
+ SendDart::Domains.get(id)
151
+ SendDart::Domains.list
152
+ SendDart::Domains.update(id, { click_tracking: true })
153
+ SendDart::Domains.verify(id)
154
+ SendDart::Domains.mx_check("yourdomain.com") # inspect live MX before enabling receiving
155
+ SendDart::Domains.records_csv(id) # => CSV text (String)
156
+ SendDart::Domains.delete(id)
157
+
158
+ # Claim a domain verified in another account
159
+ SendDart::Domains.claim({ name: "yourdomain.com" })
160
+ SendDart::Domains.get_claim(id)
161
+ SendDart::Domains.verify_claim(id)
162
+
163
+ # One-click DNS setup
164
+ SendDart::Domains.detect_dns(id)
165
+ SendDart::Domains.apply_cloudflare_dns(id, { token: cf_token })
166
+ SendDart::Domains.apply_godaddy_dns(id, { key: key, secret: secret })
167
+ SendDart::Domains.apply_namecheap_dns(id, { apiUser: user, apiKey: key })
168
+ ```
169
+
170
+ ## Contacts (domain-first)
171
+
172
+ ```ruby
173
+ SendDart::Contacts.create({ domain: "yourdomain.com", email: "user@example.com", first_name: "Ada" })
174
+ SendDart::Contacts.list({ domain: "yourdomain.com" })
175
+ SendDart::Contacts.get({ id: contact_id }) # by id (exact) …
176
+ SendDart::Contacts.get({ id: "user@example.com", domain: "yourdomain.com" }) # … or email + domain
177
+ SendDart::Contacts.update({ id: contact_id, unsubscribed: true })
178
+ SendDart::Contacts.delete({ id: contact_id })
179
+
180
+ # Nested audience variants
181
+ SendDart::Contacts.create({ audience_id: aud_id, email: "user@example.com" })
182
+ SendDart::Contacts.list({ audience_id: aud_id, segment_id: seg_id })
183
+
184
+ # Bulk import
185
+ SendDart::Contacts.batch({ audience_id: aud_id, contacts: [{ email: "a@b.com" }], on_conflict: "skip" })
186
+ # Domain-first: import straight into a domain's pool, no audience id needed.
187
+ SendDart::Contacts.batch({ domain: "yourdomain.com", contacts: [{ email: "a@b.com" }] })
188
+ SendDart::Contacts.import({ audience_id: aud_id, csv: "email,company\na@b.com,Acme" })
189
+
190
+ # CSV too big to inline (5 MB / 10,000 rows)? Upload it directly, then import by key.
191
+ slot = SendDart::Contacts.create_import_upload({ audience_id: aud_id, filename: "list.csv", size: bytes })
192
+ # PUT the file to slot["upload_url"], then:
193
+ SendDart::Contacts.import({ audience_id: aud_id, storage_key: slot["storage_key"] })
194
+
195
+ # Segments & topics per contact
196
+ SendDart::Contacts.add_to_segment(contact_id, segment_id)
197
+ SendDart::Contacts.remove_from_segment(contact_id, segment_id)
198
+ SendDart::Contacts.list_segments(contact_id)
199
+ SendDart::Contacts.get_topics(contact_id)
200
+ SendDart::Contacts.update_topics(contact_id, { topics: [{ id: topic_id, subscription: "opt_in" }] })
201
+
202
+ # Custom contact properties ({{merge_tags}})
203
+ SendDart::ContactProperties.create({ key: "plan", type: "string", fallback_value: "free" })
204
+ ```
205
+
206
+ ## Audiences
207
+
208
+ ```ruby
209
+ SendDart::Audiences.create({ name: "Newsletter" })
210
+ SendDart::Audiences.get(id)
211
+ SendDart::Audiences.list
212
+ SendDart::Audiences.update(id, { name: "Weekly newsletter" })
213
+ SendDart::Audiences.delete(id)
214
+
215
+ # Import from a link-shared Google Sheet
216
+ SendDart::Audiences.import_sheet(id, { url: sheet_url, segment_name: "June leads" })
217
+ ```
218
+
219
+ ## Segments & Topics (domain-first)
220
+
221
+ ```ruby
222
+ SendDart::Segments.create({ domain: "yourdomain.com", name: "VIP", filter: { status: "subscribed" } })
223
+ SendDart::Segments.list({ domain: "yourdomain.com" })
224
+ SendDart::Segments.get(id)
225
+ SendDart::Segments.contacts(id) # preview who matches
226
+ SendDart::Segments.update(id, { name: "VIP customers" })
227
+ SendDart::Segments.delete(id)
228
+
229
+ SendDart::Topics.create({ domain: "yourdomain.com", name: "Product updates", default_subscription: "opt_in" })
230
+ SendDart::Topics.list({ domain: "yourdomain.com" })
231
+ SendDart::Topics.update(id, { description: "New features" })
232
+ SendDart::Topics.delete(id)
233
+ ```
234
+
235
+ ## Campaigns (domain-first)
236
+
237
+ ```ruby
238
+ campaign = SendDart::Campaigns.create({
239
+ domain: "yourdomain.com", # REQUIRED — the contact pool this campaign targets
240
+ from: "Acme <hello@yourdomain.com>",
241
+ subject: "Big news",
242
+ html: "<p>Hello {{first_name}}</p>",
243
+ segment_id: seg_id # optional — subset instead of everyone
244
+ })
245
+
246
+ SendDart::Campaigns.send(campaign["id"]) # send now
247
+ SendDart::Campaigns.send(campaign["id"], { scheduled_at: "2026-08-01T09:00:00Z" }) # or schedule
248
+ SendDart::Campaigns.cancel(campaign["id"])
249
+ SendDart::Campaigns.stats(campaign["id"])
250
+ SendDart::Campaigns.engagement(campaign["id"]) # who opened / clicked / replied
251
+ SendDart::Campaigns.ab(campaign["id"]) # A/B winner evaluation
252
+ SendDart::Campaigns.get(campaign["id"])
253
+ SendDart::Campaigns.list({ limit: 25 })
254
+ SendDart::Campaigns.update(campaign["id"], { subject: "Bigger news" })
255
+ SendDart::Campaigns.delete(campaign["id"])
256
+ ```
257
+
258
+ ## Templates
259
+
260
+ ```ruby
261
+ tmpl = SendDart::Templates.create({ name: "Welcome", subject: "Welcome!", html: "<p>Hi {{first_name}}</p>" })
262
+ SendDart::Templates.publish(tmpl["id"])
263
+ SendDart::Templates.duplicate(tmpl["id"], { name: "Welcome v2" })
264
+ SendDart::Templates.get(tmpl["id"])
265
+ SendDart::Templates.list
266
+ SendDart::Templates.update(tmpl["id"], { subject: "Welcome aboard!" })
267
+ SendDart::Templates.delete(tmpl["id"])
268
+
269
+ # Send with a template
270
+ SendDart::Emails.send({ from: from, to: to, template_id: tmpl["id"], variables: { first_name: "Ada" } })
271
+ ```
272
+
273
+ ## Automations & Events (domain-first)
274
+
275
+ ```ruby
276
+ automation = SendDart::Automations.create({
277
+ name: "Welcome series",
278
+ domain: "yourdomain.com", # REQUIRED
279
+ trigger: "contact.created"
280
+ })
281
+
282
+ SendDart::Automations.add_step(automation["id"], { type: "send_email", config: { template_id: tmpl_id } })
283
+ # `type` is REQUIRED on update_step: PATCH re-validates the whole step and
284
+ # `config` REPLACES the stored config wholesale (there is no merge), so resend
285
+ # every key you want to keep. A step's graph `key` is create-only — settable on
286
+ # add_step, ignored here — so delete and re-add a step to re-key it.
287
+ SendDart::Automations.update_step(automation["id"], step_id, { type: "send_email", config: { template_id: tmpl_id, subject: "New subject" } })
288
+ SendDart::Automations.update(automation["id"], { status: "enabled" })
289
+
290
+ # Or describe the flow and let the server build the steps (automation must be stopped)
291
+ SendDart::Automations.create_with_ai(automation["id"], { prompt: "Wait 2 days, then send the onboarding email" })
292
+
293
+ # Fire a custom event — only yourdomain.com's automations are triggered
294
+ SendDart::Events.send({
295
+ event: "signup.completed",
296
+ domain: "yourdomain.com", # REQUIRED
297
+ email: "user@example.com",
298
+ payload: { plan: "pro" }
299
+ })
300
+
301
+ # Event definitions — schema types are "string", "number", "boolean" or "date".
302
+ # Event names cannot start with the reserved "senddart:" prefix.
303
+ SendDart::Events.create({ name: "signup.completed", schema: { plan: "string" } })
304
+ SendDart::Events.list
305
+ SendDart::Events.update(event_id, { schema: { plan: "string", seats: "number" } }) # name is immutable
306
+ SendDart::Events.delete(event_id)
307
+
308
+ # Inspect execution
309
+ runs = SendDart::Automations.runs(automation["id"], { limit: 25, status: ["failed"] })
310
+ SendDart::Automations.get_run(automation["id"], runs["data"].first["id"])
311
+ SendDart::Automations.delete_step(automation["id"], step_id)
312
+ SendDart::Automations.stop(automation["id"])
313
+ SendDart::Automations.delete(automation["id"])
314
+ ```
315
+
316
+ ## Webhooks
317
+
318
+ ```ruby
319
+ hook = SendDart::Webhooks.create({
320
+ endpoint: "https://yourapp.com/hooks/senddart",
321
+ events: ["email.delivered", "email.bounced", "email.unsubscribed"]
322
+ })
323
+ hook["signing_secret"] # shown ONCE — store it
324
+
325
+ SendDart::Webhooks.list
326
+ SendDart::Webhooks.update(hook["id"], { status: "disabled" })
327
+ SendDart::Webhooks.rotate(hook["id"]) # new secret returned once
328
+ SendDart::Webhooks.test(hook["id"])
329
+ SendDart::Webhooks.delete(hook["id"])
330
+ ```
331
+
332
+ Endpoints must be `https://` and must not resolve to a private address. Valid event names are `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.opened`, `email.clicked`, `email.failed`, `email.scheduled`, `email.suppressed`, `email.received`, `email.replied`, `email.unsubscribed`, `contact.created`, `contact.updated`, `contact.deleted`, `domain.created`, `domain.updated` and `domain.deleted`. Anything else is a 422.
333
+
334
+ `Webhooks.test` returns HTTP 200 even when the delivery failed — it does not raise. The outcome is `result["ok"]`, with `result["status"]` (your endpoint's HTTP status, when it responded) and `result["error"]` (e.g. `"lookup_failed"`):
335
+
336
+ ```ruby
337
+ result = SendDart::Webhooks.test(hook["id"])
338
+ warn "test delivery failed: #{result['error']}" unless result["ok"]
339
+ ```
340
+
341
+ ### Verifying deliveries
342
+
343
+ `verify` checks the Svix-style HMAC-SHA256 signature locally (no HTTP request). Pass the **exact raw request body** — re-serializing parsed JSON breaks the signature.
344
+
345
+ ```ruby
346
+ result = SendDart::Webhooks.verify(
347
+ request.raw_post, # raw body string
348
+ {
349
+ "svix-id" => request.headers["svix-id"],
350
+ "svix-timestamp" => request.headers["svix-timestamp"],
351
+ "svix-signature" => request.headers["svix-signature"]
352
+ },
353
+ signing_secret # the whsec_... secret from create/rotate
354
+ )
355
+
356
+ head :unauthorized unless result[:valid]
357
+ # result => { valid: true } or { valid: false, reason: "no_match" | "timestamp_out_of_tolerance" | ... }
358
+ ```
359
+
360
+ Pass `tolerance: 0` to skip the timestamp freshness check (default 300 seconds).
361
+
362
+ ## API keys, Logs & Polls
363
+
364
+ ```ruby
365
+ SendDart::ApiKeys.list # `token` is the 8-character display prefix, never the secret
366
+
367
+ SendDart::Logs.list({ limit: 100, method: "POST", status: 429 })
368
+ SendDart::Logs.get(log_id)
369
+
370
+ SendDart::Polls.list
371
+ SendDart::Polls.get(email_id) # aggregated answer breakdown
372
+ ```
373
+
374
+ `SendDart::ApiKeys.list` is the whole API-key surface: the SDK deliberately
375
+ exposes no method to create, re-scope or revoke a key. Key lifecycle belongs to
376
+ a signed-in dashboard session, and the API enforces it — `POST /api-keys`,
377
+ `PATCH /api-keys/:id` and `DELETE /api-keys/:id` answer `403 dashboard_only` to
378
+ any API-key caller, whatever its permission. That is the point: a key that leaks
379
+ cannot mint itself a replacement, widen its own access, or revoke the keys you
380
+ would use to shut it off. Create and revoke keys at
381
+ [senddart.com](https://www.senddart.com).
382
+
383
+ ## Pagination
384
+
385
+ `list` methods accept cursor pagination — `{ limit:, after:, before: }` — appended as a query string:
386
+
387
+ ```ruby
388
+ page = SendDart::Campaigns.list({ limit: 25, after: "cursor_abc" })
389
+ page["object"] # => "list"
390
+ page["has_more"] # => true when more rows exist beyond this page
391
+ page["data"] # => [...]
392
+ ```
393
+
394
+ `limit` is an integer between 1 and 100 (default 20); `after` and `before` are item ids and cannot be combined. An unknown cursor returns an empty page, not an error. There is no `total` and no `next_cursor` — page forward with the last `data` entry's `id` as `after`.
395
+
396
+ Defaults differ per endpoint. `GET /templates`, `/webhooks`, `/audiences`, `/automations`, `/events` and `/automations/:id/runs` cap an unpaginated call at 20 rows. `/domains`, `/api-keys`, `/topics`, `/campaigns`, `/contacts`, `/contact-properties`, `/segments` and `/polls` instead return the collection in one response when you pass neither `limit` nor a cursor — but still bounded, at 1,000 rows. That ceiling is not silent: `has_more` is `true` when it bites, so keep paging with `after` rather than treating the first response as the whole table. Always pass `limit` if you depend on page size.
397
+
398
+ ## Idempotency
399
+
400
+ Pass an idempotency key to safely retry a send.
401
+
402
+ ```ruby
403
+ SendDart::Emails.send(payload, { idempotency_key: "order-123" })
404
+ SendDart::Batch.send(payloads, { idempotency_key: "orders-2026-08-08" })
405
+ ```
406
+
407
+ The key must be **1–255 characters**, measured after the server trims it — 255, not 256. `SendDart::Client::IDEMPOTENCY_KEY_MAX_LENGTH` carries that number. The SDK sends the key verbatim and lets the **server** be the authority: an out-of-range key comes back as `400 invalid_idempotency_key` (a `SendDart::Error` with `name == "invalid_idempotency_key"`).
408
+
409
+ Reusing a key replays the original response; reusing it with a *different* body is a 409 (`invalid_idempotent_request`), and a second request while the first is still in flight is a 409 (`concurrent_idempotent_requests`).
410
+
411
+ `Emails.send`, `Batch.send`, and received-email reply/forward honour the header. Every other endpoint — including `Events.send` — accepts and forwards it but the API ignores it, so a retry there creates a second record. De-duplicate on your side instead.
412
+
413
+ ## Rate limits
414
+
415
+ Only the `/emails` **send** routes are rate-limited: **30 requests per minute per IP**. Reads (`GET /emails`, `GET /emails/:id`, the `receiving` subtree and attachment listings) are NOT subject to that cap, so paging a large list no longer risks a 429. Capped responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` headers (on successes too) so you can throttle before being rejected. The SDK retries a 429 or 503 automatically — up to `SendDart.max_retries` times (default 2), honouring `Retry-After`.
416
+
417
+ ## Documentation
418
+
419
+ Full docs: <https://www.senddart.com/docs>
420
+
421
+ ## License
422
+
423
+ MIT
424
+
425
+ ## Recovery and tracking contracts
426
+
427
+ Use a stable, unique operation key for each intended send, batch, reply, or
428
+ forward. Keep the same key and payload when recovering that operation. These
429
+ are the supported idempotent send endpoints; events do not implement this
430
+ header. Existing calls without options still work.
431
+
432
+ ```ruby
433
+ SendDart::Emails::Receiving.reply(id, reply, idempotency_key: "reply-operation-1")
434
+ SendDart::Emails::Receiving.forward(id, forward, idempotency_key: "forward-operation-1")
435
+ health = SendDart::Domains.tracking_health(domain_id)
436
+ ```
437
+
438
+ Automatic retries consider only 429/503. They stop on an original email `id`,
439
+ positive `sent_count`, nonempty `sent` or `reserved`, or `batch_incomplete`.
440
+ An ordinary rate limit can retry; a generic 503 can retry a read or a send with
441
+ the same supported key. Other writes retry only documented pre-processing
442
+ rejections (`service_unavailable`, `sending_service_unavailable`,
443
+ `sending_configuration_unavailable`, `contacts_busy`, `contacts_timeout`).
444
+ No network/body-read failure, 409, 422, or other 5xx is retried automatically.
445
+ The default transport refuses redirects; a custom transport/client must enforce
446
+ its own policy.
447
+
448
+ On a failed or unconfirmed send, inspect `id` with the email retrieval method
449
+ before creating another send. A 422 with an ID can identify an uncertain
450
+ provider handoff; 422 does not always mean nothing happened. For interrupted
451
+ batches, `sent` contains confirmed sends, `reserved` contains the original
452
+ attempted prefix (including uncertain handoffs), and `unsent_count` counts the
453
+ never-attempted tail. Do not resend the full batch or the reserved prefix under
454
+ a new key. Reconcile original IDs first, then submit only known unattempted
455
+ items as a new operation. Recovery fields remain available in the full error
456
+ body as well as language-specific fields/accessors.
457
+
458
+ Tracking health returns `custom_host`, `status` (`shared`, `ready`, or
459
+ `unavailable`), and `checked_at`. Configure custom tracking through the domain
460
+ API and check health before relying on it. A healthy endpoint cannot guarantee
461
+ an open event: recipients may block images, and coupon redemption alone is not
462
+ proof that the tracking pixel loaded. SDKs preserve supplied HTML/text and do
463
+ not infer opens or rewrite editor spacing.
464
+
465
+ Campaign cancellation also stops pending follow-ups for an already-sent
466
+ campaign while retaining its sent history. Permanent received-email deletion
467
+ acknowledges a durable cleanup request; attachment/object cleanup can finish
468
+ asynchronously. Retrying that deletion is safe; it cannot be undone after the
469
+ purge request is accepted.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendDart
4
+ # Listing only, by design. Keys are created, re-scoped and revoked in the
5
+ # SendDart dashboard by a signed-in user — POST /api-keys,
6
+ # PATCH /api-keys/:id and DELETE /api-keys/:id answer 403 `dashboard_only`
7
+ # to every API-key caller, whatever its permission. Exposing only `list`
8
+ # means a leaked key cannot mint itself a replacement or widen its access.
9
+ module ApiKeys
10
+ class << self
11
+ # GET /api-keys — with no pagination params one page carries up to 1,000
12
+ # non-revoked keys and `has_more` reports any truncation. `token` here is
13
+ # the 8-character display prefix, never the secret.
14
+ def list(params = {})
15
+ Client.request(:get, "/api-keys", query: Client.pagination(params))
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendDart
4
+ module Audiences
5
+ class << self
6
+ # POST /audiences — params: { name: "..." }
7
+ def create(params)
8
+ Client.request(:post, "/audiences", body: params)
9
+ end
10
+
11
+ # GET /audiences/:id
12
+ def get(audience_id)
13
+ Client.request(:get, "/audiences/#{Client.path_escape(audience_id)}")
14
+ end
15
+
16
+ # GET /audiences
17
+ def list(params = {})
18
+ Client.request(:get, "/audiences", query: Client.pagination(params))
19
+ end
20
+
21
+ # Rename an audience. PATCH /audiences/:id
22
+ def update(audience_id, params)
23
+ Client.request(:patch, "/audiences/#{Client.path_escape(audience_id)}", body: params)
24
+ end
25
+
26
+ # DELETE /audiences/:id
27
+ def delete(audience_id)
28
+ Client.request(:delete, "/audiences/#{Client.path_escape(audience_id)}")
29
+ end
30
+
31
+ # Import contacts from a link-shared Google Sheet; header columns become
32
+ # contact properties and rows land in a fresh segment.
33
+ # POST /audiences/:id/contacts/import-sheet — params: { url:, segment_name: }
34
+ def import_sheet(audience_id, params)
35
+ Client.request(:post, "/audiences/#{Client.path_escape(audience_id)}/contacts/import-sheet", body: params)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendDart
4
+ # Automations are DOMAIN-FIRST: `domain` is required on create, and only
5
+ # Events.send calls naming the same domain trigger them.
6
+ module Automations
7
+ class << self
8
+ # POST /automations
9
+ # SendDart::Automations.create({ name: "Welcome series", domain: "yourdomain.com",
10
+ # trigger: "contact.created" })
11
+ # The built-in "senddart:schedule" trigger fires once at
12
+ # `trigger_config` ({ at: ISO 8601 instant, timezone: IANA name }),
13
+ # enrolling every contact of the domain's pool — `trigger_config` is
14
+ # required with that trigger and not accepted on any other.
15
+ def create(params)
16
+ Client.require_domain!(params, "Automations.create")
17
+ Client.request(:post, "/automations", body: params)
18
+ end
19
+
20
+ # GET /automations/:id
21
+ def get(automation_id)
22
+ Client.request(:get, "/automations/#{Client.path_escape(automation_id)}")
23
+ end
24
+
25
+ # GET /automations
26
+ def list(params = {})
27
+ Client.request(:get, "/automations", query: Client.pagination(params))
28
+ end
29
+
30
+ # PATCH /automations/:id — params: { name:, status: "enabled"|"disabled", ... }
31
+ # `trigger_config` ({ at:, timezone: }) updates the "senddart:schedule"
32
+ # trigger's schedule (only valid on automations with that trigger).
33
+ def update(automation_id, params)
34
+ Client.request(:patch, "/automations/#{Client.path_escape(automation_id)}", body: params)
35
+ end
36
+
37
+ # Append a step. POST /automations/:id/steps — params: { type:, config:, key: }
38
+ # The automation must be disabled first, and `type: "trigger"` is
39
+ # rejected here (the trigger lives on the automation, not in `steps`).
40
+ def add_step(automation_id, params)
41
+ Client.request(:post, "/automations/#{Client.path_escape(automation_id)}/steps", body: params)
42
+ end
43
+
44
+ # Edit a step in place (automation must be disabled).
45
+ # PATCH /automations/:id/steps/:step_id — params: { type:, config: }
46
+ # `type` is REQUIRED: the server re-validates the whole step, so omitting
47
+ # it is a validation_error naming the valid types. `config` REPLACES the
48
+ # stored config wholesale (there is no merge) — resend every key you want
49
+ # to keep. `key` is not accepted here; a step's graph key is create-only
50
+ # (set on add_step), so delete and re-add a step to re-key it.
51
+ def update_step(automation_id, step_id, params)
52
+ Client.request(
53
+ :patch,
54
+ "/automations/#{Client.path_escape(automation_id)}/steps/#{Client.path_escape(step_id)}",
55
+ body: params
56
+ )
57
+ end
58
+
59
+ # Delete a step. DELETE /automations/:id/steps/:step_id
60
+ def delete_step(automation_id, step_id)
61
+ Client.request(:delete, "/automations/#{Client.path_escape(automation_id)}/steps/#{Client.path_escape(step_id)}")
62
+ end
63
+
64
+ # Build (or extend) the automation's steps from a prompt.
65
+ # POST /automations/:id/ai — params: { prompt:, template_ids:, events:, attach: }
66
+ # `prompt` is required and capped at 2000 characters. Without `attach` the
67
+ # automation must have no steps yet; pass `attach` ({ from:, type:,
68
+ # before: }) to append to an existing graph. The automation must be
69
+ # stopped, and the route is limited to 20 requests per minute per account.
70
+ def create_with_ai(automation_id, params)
71
+ Client.request(:post, "/automations/#{Client.path_escape(automation_id)}/ai", body: params)
72
+ end
73
+
74
+ # List an automation's runs. `status` filters to specific run statuses
75
+ # ("running", "completed", "failed", "skipped") and accepts an Array or a
76
+ # comma-separated String. GET /automations/:id/runs
77
+ def runs(automation_id, params = {})
78
+ query = Client.pagination(params)
79
+ status = Client.opt(params, :status)
80
+ query[:status] = status.is_a?(Array) ? status.join(",") : status unless status.nil?
81
+ Client.request(:get, "/automations/#{Client.path_escape(automation_id)}/runs", query: query)
82
+ end
83
+
84
+ # Retrieve a single run with its step trace. GET /automations/:id/runs/:run_id
85
+ def get_run(automation_id, run_id)
86
+ Client.request(:get, "/automations/#{Client.path_escape(automation_id)}/runs/#{Client.path_escape(run_id)}")
87
+ end
88
+
89
+ # Stop an automation — no new runs; in-progress runs finish. POST /automations/:id/stop
90
+ def stop(automation_id)
91
+ Client.request(:post, "/automations/#{Client.path_escape(automation_id)}/stop")
92
+ end
93
+
94
+ # DELETE /automations/:id
95
+ def delete(automation_id)
96
+ Client.request(:delete, "/automations/#{Client.path_escape(automation_id)}")
97
+ end
98
+ end
99
+ end
100
+ end