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 +7 -0
- data/LICENSE +21 -0
- data/README.md +469 -0
- data/lib/senddart/api_keys.rb +19 -0
- data/lib/senddart/audiences.rb +39 -0
- data/lib/senddart/automations.rb +100 -0
- data/lib/senddart/campaigns.rb +81 -0
- data/lib/senddart/client.rb +265 -0
- data/lib/senddart/contact_properties.rb +33 -0
- data/lib/senddart/contacts.rb +188 -0
- data/lib/senddart/domains.rb +103 -0
- data/lib/senddart/emails.rb +165 -0
- data/lib/senddart/error.rb +109 -0
- data/lib/senddart/events.rb +53 -0
- data/lib/senddart/logs.rb +24 -0
- data/lib/senddart/polls.rb +18 -0
- data/lib/senddart/segments.rb +52 -0
- data/lib/senddart/templates.rb +42 -0
- data/lib/senddart/topics.rb +38 -0
- data/lib/senddart/version.rb +5 -0
- data/lib/senddart/webhooks.rb +265 -0
- data/lib/senddart.rb +81 -0
- metadata +83 -0
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
|