unitpost 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e92552a542bfa040de967b8d88af9aba31d6edbd0001768633793263a0728bc2
4
- data.tar.gz: 7b49579c2d34abdb8efb2d9b01382edb1b1f646467b1c7fe33573c65ce89176a
3
+ metadata.gz: 9e9a3528c250381235631c5bb39f42374542bcccd093cdfda7fe1ea53a6f8173
4
+ data.tar.gz: b59b18ef0a50321e22ede9e3e090e1d4e92389f7abd38a1de37cd47797c5723b
5
5
  SHA512:
6
- metadata.gz: f35532690b25c113997d1b594db3bf849dc4f60853f2c290eadb95940958b3c5084c59ef71320486728a9a97f666b21a1541599a972edc5e670a609dc640fddf
7
- data.tar.gz: d8d884f80928d12d3023886986c641de6221886a1b0bcde7ab9005a9971f48257aa8df58e8bc7ea5126d3213231dd08cb7ef6dbd41312489bf07127f08b947b9
6
+ metadata.gz: 9a3479a052e24411e80050aa8ca471ba06d61f26d2620ffe789c6257a039f6698fd17e2a43bb62adfd63802ab2c7691f2f99bed5301d8191ed99e59b2a2d7aaf
7
+ data.tar.gz: 822df4364aef7e1ffc2aec4bbdda76f6e07645ddcd6092b6e09f196a636a127e1b46ae4b81c317429a792c4d68ff90c4a4e4282162a5d956cca3239510f2a9ab
data/README.md CHANGED
@@ -51,15 +51,16 @@ key at [unitpost.com → Settings → API keys](https://www.unitpost.com).
51
51
 
52
52
  | Accessor | Covers |
53
53
  | --- | --- |
54
- | `emails` | Send, batch, list, get, update, stats, inbound (`received_*`) |
54
+ | `email` | Send, batch, list, get, update, stats, inbound (`received_*`) |
55
+ | `email.topics` | Topics + contact topic prefs |
56
+ | `email.campaigns` | Draft, schedule, send, pause/resume |
57
+ | `email.templates` | Template CRUD |
58
+ | `email.domains` | Domains + verify |
59
+ | `sms` | Send, get, list, `brands`, `numbers`, contact SMS consent. Available on paid plans: on the free plan `send` returns a `403` with code `limit_reached`, while the reads keep working. |
55
60
  | `contacts` | CRUD, import jobs |
56
61
  | `contact_fields` | Custom field definitions |
57
62
  | `segments` | Segments + membership |
58
- | `topics` | Topics + contact topic prefs |
59
- | `campaigns` | Draft, schedule, send, pause/resume |
60
- | `templates` | Template CRUD |
61
63
  | `brand_kits` | Brand kit |
62
- | `domains` | Domains + verify |
63
64
  | `webhooks` | Endpoints + `verify` |
64
65
  | `api_keys` | API key management |
65
66
  | `suppressions` | Suppression list |
@@ -84,7 +85,7 @@ complaints are added automatically; you can also add your own:
84
85
  unitpost.suppressions.create({ email: "bounced@example.com", reason: "manual" })
85
86
  unitpost.suppressions.create({ emails: ["a@example.com", "b@example.com"] })
86
87
 
87
- # Look one up by id or email; list (optionally filter scope); un-suppress.
88
+ # Look one up by id or email; list your own; un-suppress.
88
89
  unitpost.suppressions.get("bounced@example.com")
89
90
  unitpost.suppressions.list_all.each { |s| puts "#{s["email"]} #{s["reason"]}" }
90
91
  unitpost.suppressions.delete("bounced@example.com")
@@ -110,9 +111,44 @@ Or from the client (Resend-style alias):
110
111
  event = client.webhooks.verify(payload: raw_body, secret: secret, headers: request.headers)
111
112
  ```
112
113
 
114
+ ## Receive a text and answer it
115
+
116
+ US toll-free, 10DLC and Canadian numbers take replies (`two_way` is true on
117
+ `sms.numbers`). Subscribe a webhook to `sms.received`, skip STOP and texts
118
+ Unitpost already answered, read the conversation with `participant`, and answer
119
+ without `from` so the reply leaves from the number they texted.
120
+
121
+ ```ruby
122
+ require "unitpost"
123
+
124
+ unitpost = Unitpost::Client.new # reads UNITPOST_API_KEY
125
+
126
+ # Your webhook endpoint, subscribed to sms.received. Pass the RAW body.
127
+ def on_webhook(unitpost, raw_body, headers)
128
+ event = Unitpost.verify_webhook(
129
+ payload: raw_body,
130
+ secret: ENV.fetch("UNITPOST_WEBHOOK_SECRET"), # whsec_…
131
+ headers: headers
132
+ )
133
+ return unless event["type"] == "sms.received"
134
+
135
+ reply = event["data"]
136
+ return if reply["keyword"] == "OPT_OUT" || reply["auto_replied"] # STOP, or already answered
137
+
138
+ # The whole conversation with this person, both directions, newest first.
139
+ # Other filters: direction, status, from, to, contact_id, campaign_id,
140
+ # created_after, created_before.
141
+ history = unitpost.sms.list(participant: reply["from"], limit: 20)
142
+ answer = your_agent(history.data, reply["body"].to_s)
143
+
144
+ # No :from: the answer goes out from the number they texted.
145
+ unitpost.sms.send({ to: reply["from"], body: answer })
146
+ end
147
+ ```
148
+
113
149
  ## Idempotency
114
150
 
115
- `emails.send` and `emails.batch` are safe to retry by default — when you don't
151
+ `email.send` and `email.batch` are safe to retry by default — when you don't
116
152
  pass `idempotency_key:`, the SDK generates one so an automatic retry can't
117
153
  double-send. Pass your own to dedupe across separate calls:
118
154
 
@@ -132,7 +168,7 @@ Transient failures are retried automatically: `429`, `5xx`, and transport errors
132
168
  `Retry-After` is honored; otherwise exponential backoff with full jitter is used.
133
169
  Client errors (`401`/`403`/`404`/`409`/`422`) are never retried. Only requests
134
170
  safe to replay are retried: `GET`s and writes carrying an idempotency key (which
135
- `emails.send` and `emails.batch` always do); any other keyless write is never
171
+ `email.send` and `email.batch` always do); any other keyless write is never
136
172
  auto-retried.
137
173
 
138
174
  ```ruby
@@ -145,7 +181,8 @@ unitpost = Unitpost::Client.new(max_retries: 2, retry_base_delay: 0.5, max_retry
145
181
  | | |
146
182
  | --- | --- |
147
183
  | Docs + API reference | https://www.unitpost.com/docs |
148
- | Product / SMTP / MCP guides | https://www.unitpost.com/guides |
184
+ | Product guides | https://www.unitpost.com/guides |
185
+ | SMTP | https://www.unitpost.com/docs/smtp |
149
186
  | OpenAPI JSON | https://www.unitpost.com/api/v1/openapi.json |
150
187
  | LLM index | https://www.unitpost.com/llms.txt |
151
188
  | Dashboard / API keys | https://www.unitpost.com |
@@ -153,7 +190,7 @@ unitpost = Unitpost::Client.new(max_retries: 2, retry_base_delay: 0.5, max_retry
153
190
  | Template gallery | https://www.unitpost.com/templates/gallery |
154
191
  | Playground | https://www.unitpost.com/playground |
155
192
  | Migration Assistant | https://www.unitpost.com/migration |
156
- | MCP + Agent Skills | https://www.unitpost.com/guides#ai-mcp · https://www.unitpost.com/guides#ai-skills |
193
+ | MCP + Agent Skills | https://www.unitpost.com/docs/ai/mcp · https://www.unitpost.com/docs/ai/skills |
157
194
  | Support | support@unitpost.com |
158
195
 
159
196
  ## License
@@ -15,9 +15,9 @@ module Unitpost
15
15
  # puts result.data["id"]
16
16
  # end
17
17
  class Client
18
- attr_reader :email, :contacts, :contact_fields, :segments,
18
+ attr_reader :email, :sms, :contacts, :contact_fields, :segments,
19
19
  :brand_kits, :webhooks, :api_keys, :suppressions,
20
- :usage
20
+ :jobs, :usage
21
21
 
22
22
  def initialize(api_key: nil, base_url: nil, timeout: 30, headers: {}, adapter: nil, stubs: nil,
23
23
  max_retries: 2, retry_base_delay: 0.5, max_retry_delay: 20.0, sleeper: nil)
@@ -34,6 +34,9 @@ module Unitpost
34
34
  sleeper: sleeper
35
35
  )
36
36
  @email = Resources::Email.new(http)
37
+ # SMS channel. Available on paid plans: on the free plan `send` returns
38
+ # a 403 with code `limit_reached`; reads keep working.
39
+ @sms = Resources::Sms.new(http)
37
40
  @contacts = Resources::Contacts.new(http)
38
41
  @contact_fields = Resources::ContactFields.new(http)
39
42
  @segments = Resources::Segments.new(http)
@@ -43,6 +46,7 @@ module Unitpost
43
46
  @suppressions = Resources::Suppressions.new(http)
44
47
  # NOTE (pre-launch): `events` is intentionally absent while the
45
48
  # custom-events surface is behind the launch gate.
49
+ @jobs = Resources::Jobs.new(http)
46
50
  @usage = Resources::Usage.new(http)
47
51
  end
48
52
  end
@@ -104,7 +104,7 @@ module Unitpost
104
104
  query_params: [],
105
105
  has_body: false,
106
106
  request_schema: nil,
107
- response_schema: nil,
107
+ response_schema: "Job",
108
108
  is_list: false,
109
109
  list_item_schema: nil
110
110
  },
@@ -164,6 +164,20 @@ module Unitpost
164
164
  is_list: false,
165
165
  list_item_schema: nil
166
166
  },
167
+ {
168
+ operation_id: "contacts.cancelImport",
169
+ resource: "contacts",
170
+ method_name: "cancelImport",
171
+ http_method: "POST",
172
+ path: "/contacts/imports/{id}/cancel",
173
+ path_params: ["id"],
174
+ query_params: [],
175
+ has_body: false,
176
+ request_schema: nil,
177
+ response_schema: "ContactImport",
178
+ is_list: false,
179
+ list_item_schema: nil
180
+ },
167
181
  {
168
182
  operation_id: "contacts.create",
169
183
  resource: "contacts",
@@ -410,8 +424,8 @@ module Unitpost
410
424
  path: "/email/campaigns/{id}/send",
411
425
  path_params: ["id"],
412
426
  query_params: [],
413
- has_body: false,
414
- request_schema: nil,
427
+ has_body: true,
428
+ request_schema: "CampaignSend",
415
429
  response_schema: "Campaign",
416
430
  is_list: false,
417
431
  list_item_schema: nil
@@ -486,6 +500,20 @@ module Unitpost
486
500
  is_list: false,
487
501
  list_item_schema: nil
488
502
  },
503
+ {
504
+ operation_id: "email.domains.diagnose",
505
+ resource: "email.domains",
506
+ method_name: "diagnose",
507
+ http_method: "GET",
508
+ path: "/email/domains/{id}/diagnose",
509
+ path_params: ["id"],
510
+ query_params: [],
511
+ has_body: false,
512
+ request_schema: nil,
513
+ response_schema: "DomainDiagnosis",
514
+ is_list: false,
515
+ list_item_schema: nil
516
+ },
489
517
  {
490
518
  operation_id: "email.domains.get",
491
519
  resource: "email.domains",
@@ -710,6 +738,20 @@ module Unitpost
710
738
  is_list: true,
711
739
  list_item_schema: "Template"
712
740
  },
741
+ {
742
+ operation_id: "email.templates.testSend",
743
+ resource: "email.templates",
744
+ method_name: "testSend",
745
+ http_method: "POST",
746
+ path: "/email/templates/{id}/test-send",
747
+ path_params: ["id"],
748
+ query_params: [],
749
+ has_body: true,
750
+ request_schema: "TemplateTestSend",
751
+ response_schema: "TemplateTestSendResult",
752
+ is_list: false,
753
+ list_item_schema: nil
754
+ },
713
755
  {
714
756
  operation_id: "email.templates.update",
715
757
  resource: "email.templates",
@@ -773,7 +815,7 @@ module Unitpost
773
815
  http_method: "GET",
774
816
  path: "/email/topics",
775
817
  path_params: [],
776
- query_params: ["limit", "after", "before"],
818
+ query_params: ["limit", "after", "before", "archived"],
777
819
  has_body: false,
778
820
  request_schema: nil,
779
821
  response_schema: nil,
@@ -808,20 +850,6 @@ module Unitpost
808
850
  is_list: false,
809
851
  list_item_schema: nil
810
852
  },
811
- {
812
- operation_id: "email.topics.setTopic",
813
- resource: "email.topics",
814
- method_name: "setTopic",
815
- http_method: "PUT",
816
- path: "/contacts/{id}/topics",
817
- path_params: ["id"],
818
- query_params: [],
819
- has_body: true,
820
- request_schema: "ContactTopicSet",
821
- response_schema: "ContactTopic",
822
- is_list: false,
823
- list_item_schema: nil
824
- },
825
853
  {
826
854
  operation_id: "email.topics.update",
827
855
  resource: "email.topics",
@@ -850,6 +878,20 @@ module Unitpost
850
878
  is_list: false,
851
879
  list_item_schema: nil
852
880
  },
881
+ {
882
+ operation_id: "jobs.get",
883
+ resource: "jobs",
884
+ method_name: "get",
885
+ http_method: "GET",
886
+ path: "/jobs/{id}",
887
+ path_params: ["id"],
888
+ query_params: [],
889
+ has_body: false,
890
+ request_schema: nil,
891
+ response_schema: "Job",
892
+ is_list: false,
893
+ list_item_schema: nil
894
+ },
853
895
  {
854
896
  operation_id: "segments.addMember",
855
897
  resource: "segments",
@@ -962,6 +1004,426 @@ module Unitpost
962
1004
  is_list: false,
963
1005
  list_item_schema: nil
964
1006
  },
1007
+ {
1008
+ operation_id: "sms.brands",
1009
+ resource: "sms",
1010
+ method_name: "brands",
1011
+ http_method: "GET",
1012
+ path: "/sms/brands",
1013
+ path_params: [],
1014
+ query_params: ["limit", "after", "before"],
1015
+ has_body: false,
1016
+ request_schema: nil,
1017
+ response_schema: nil,
1018
+ is_list: true,
1019
+ list_item_schema: "SmsBrand"
1020
+ },
1021
+ {
1022
+ operation_id: "sms.campaigns.cancel",
1023
+ resource: "sms.campaigns",
1024
+ method_name: "cancel",
1025
+ http_method: "POST",
1026
+ path: "/sms/campaigns/{id}/cancel",
1027
+ path_params: ["id"],
1028
+ query_params: [],
1029
+ has_body: false,
1030
+ request_schema: nil,
1031
+ response_schema: "SmsCampaign",
1032
+ is_list: false,
1033
+ list_item_schema: nil
1034
+ },
1035
+ {
1036
+ operation_id: "sms.campaigns.create",
1037
+ resource: "sms.campaigns",
1038
+ method_name: "create",
1039
+ http_method: "POST",
1040
+ path: "/sms/campaigns",
1041
+ path_params: [],
1042
+ query_params: [],
1043
+ has_body: true,
1044
+ request_schema: "SmsCampaignCreate",
1045
+ response_schema: "SmsCampaign",
1046
+ is_list: false,
1047
+ list_item_schema: nil
1048
+ },
1049
+ {
1050
+ operation_id: "sms.campaigns.delete",
1051
+ resource: "sms.campaigns",
1052
+ method_name: "delete",
1053
+ http_method: "DELETE",
1054
+ path: "/sms/campaigns/{id}",
1055
+ path_params: ["id"],
1056
+ query_params: [],
1057
+ has_body: false,
1058
+ request_schema: nil,
1059
+ response_schema: nil,
1060
+ is_list: false,
1061
+ list_item_schema: nil
1062
+ },
1063
+ {
1064
+ operation_id: "sms.campaigns.get",
1065
+ resource: "sms.campaigns",
1066
+ method_name: "get",
1067
+ http_method: "GET",
1068
+ path: "/sms/campaigns/{id}",
1069
+ path_params: ["id"],
1070
+ query_params: [],
1071
+ has_body: false,
1072
+ request_schema: nil,
1073
+ response_schema: "SmsCampaign",
1074
+ is_list: false,
1075
+ list_item_schema: nil
1076
+ },
1077
+ {
1078
+ operation_id: "sms.campaigns.list",
1079
+ resource: "sms.campaigns",
1080
+ method_name: "list",
1081
+ http_method: "GET",
1082
+ path: "/sms/campaigns",
1083
+ path_params: [],
1084
+ query_params: ["limit", "after", "before", "archived"],
1085
+ has_body: false,
1086
+ request_schema: nil,
1087
+ response_schema: nil,
1088
+ is_list: true,
1089
+ list_item_schema: "SmsCampaign"
1090
+ },
1091
+ {
1092
+ operation_id: "sms.campaigns.pause",
1093
+ resource: "sms.campaigns",
1094
+ method_name: "pause",
1095
+ http_method: "POST",
1096
+ path: "/sms/campaigns/{id}/pause",
1097
+ path_params: ["id"],
1098
+ query_params: [],
1099
+ has_body: false,
1100
+ request_schema: nil,
1101
+ response_schema: "SmsCampaign",
1102
+ is_list: false,
1103
+ list_item_schema: nil
1104
+ },
1105
+ {
1106
+ operation_id: "sms.campaigns.reschedule",
1107
+ resource: "sms.campaigns",
1108
+ method_name: "reschedule",
1109
+ http_method: "POST",
1110
+ path: "/sms/campaigns/{id}/reschedule",
1111
+ path_params: ["id"],
1112
+ query_params: [],
1113
+ has_body: true,
1114
+ request_schema: "SmsCampaignReschedule",
1115
+ response_schema: "SmsCampaign",
1116
+ is_list: false,
1117
+ list_item_schema: nil
1118
+ },
1119
+ {
1120
+ operation_id: "sms.campaigns.resume",
1121
+ resource: "sms.campaigns",
1122
+ method_name: "resume",
1123
+ http_method: "POST",
1124
+ path: "/sms/campaigns/{id}/resume",
1125
+ path_params: ["id"],
1126
+ query_params: [],
1127
+ has_body: false,
1128
+ request_schema: nil,
1129
+ response_schema: "SmsCampaign",
1130
+ is_list: false,
1131
+ list_item_schema: nil
1132
+ },
1133
+ {
1134
+ operation_id: "sms.campaigns.send",
1135
+ resource: "sms.campaigns",
1136
+ method_name: "send",
1137
+ http_method: "POST",
1138
+ path: "/sms/campaigns/{id}/send",
1139
+ path_params: ["id"],
1140
+ query_params: [],
1141
+ has_body: true,
1142
+ request_schema: "SmsCampaignSend",
1143
+ response_schema: "SmsCampaign",
1144
+ is_list: false,
1145
+ list_item_schema: nil
1146
+ },
1147
+ {
1148
+ operation_id: "sms.campaigns.update",
1149
+ resource: "sms.campaigns",
1150
+ method_name: "update",
1151
+ http_method: "PATCH",
1152
+ path: "/sms/campaigns/{id}",
1153
+ path_params: ["id"],
1154
+ query_params: [],
1155
+ has_body: true,
1156
+ request_schema: "SmsCampaignUpdate",
1157
+ response_schema: "SmsCampaign",
1158
+ is_list: false,
1159
+ list_item_schema: nil
1160
+ },
1161
+ {
1162
+ operation_id: "sms.campaigns.validate",
1163
+ resource: "sms.campaigns",
1164
+ method_name: "validate",
1165
+ http_method: "GET",
1166
+ path: "/sms/campaigns/{id}/validate",
1167
+ path_params: ["id"],
1168
+ query_params: [],
1169
+ has_body: false,
1170
+ request_schema: nil,
1171
+ response_schema: "SmsCampaignValidation",
1172
+ is_list: false,
1173
+ list_item_schema: nil
1174
+ },
1175
+ {
1176
+ operation_id: "sms.get",
1177
+ resource: "sms",
1178
+ method_name: "get",
1179
+ http_method: "GET",
1180
+ path: "/sms/{id}",
1181
+ path_params: ["id"],
1182
+ query_params: [],
1183
+ has_body: false,
1184
+ request_schema: nil,
1185
+ response_schema: "Sms",
1186
+ is_list: false,
1187
+ list_item_schema: nil
1188
+ },
1189
+ {
1190
+ operation_id: "sms.list",
1191
+ resource: "sms",
1192
+ method_name: "list",
1193
+ http_method: "GET",
1194
+ path: "/sms",
1195
+ path_params: [],
1196
+ query_params: ["limit", "after", "before", "direction", "status", "from", "to", "participant", "contact_id", "campaign_id", "created_after", "created_before"],
1197
+ has_body: false,
1198
+ request_schema: nil,
1199
+ response_schema: nil,
1200
+ is_list: true,
1201
+ list_item_schema: "Sms"
1202
+ },
1203
+ {
1204
+ operation_id: "sms.numbers",
1205
+ resource: "sms",
1206
+ method_name: "numbers",
1207
+ http_method: "GET",
1208
+ path: "/sms/numbers",
1209
+ path_params: [],
1210
+ query_params: ["limit", "after", "before"],
1211
+ has_body: false,
1212
+ request_schema: nil,
1213
+ response_schema: nil,
1214
+ is_list: true,
1215
+ list_item_schema: "SmsNumber"
1216
+ },
1217
+ {
1218
+ operation_id: "sms.recordSmsConsent",
1219
+ resource: "sms",
1220
+ method_name: "recordSmsConsent",
1221
+ http_method: "POST",
1222
+ path: "/contacts/{id}/sms-consent",
1223
+ path_params: ["id"],
1224
+ query_params: [],
1225
+ has_body: true,
1226
+ request_schema: "SmsConsentRecord",
1227
+ response_schema: "SmsConsentRecorded",
1228
+ is_list: false,
1229
+ list_item_schema: nil
1230
+ },
1231
+ {
1232
+ operation_id: "sms.send",
1233
+ resource: "sms",
1234
+ method_name: "send",
1235
+ http_method: "POST",
1236
+ path: "/sms",
1237
+ path_params: [],
1238
+ query_params: [],
1239
+ has_body: true,
1240
+ request_schema: "SmsSend",
1241
+ response_schema: "Sms",
1242
+ is_list: false,
1243
+ list_item_schema: nil
1244
+ },
1245
+ {
1246
+ operation_id: "sms.smsConsent",
1247
+ resource: "sms",
1248
+ method_name: "smsConsent",
1249
+ http_method: "GET",
1250
+ path: "/contacts/{id}/sms-consent",
1251
+ path_params: ["id"],
1252
+ query_params: [],
1253
+ has_body: false,
1254
+ request_schema: nil,
1255
+ response_schema: "SmsConsent",
1256
+ is_list: false,
1257
+ list_item_schema: nil
1258
+ },
1259
+ {
1260
+ operation_id: "sms.stats",
1261
+ resource: "sms",
1262
+ method_name: "stats",
1263
+ http_method: "GET",
1264
+ path: "/sms/stats",
1265
+ path_params: [],
1266
+ query_params: ["days"],
1267
+ has_body: false,
1268
+ request_schema: nil,
1269
+ response_schema: "SmsStats",
1270
+ is_list: false,
1271
+ list_item_schema: nil
1272
+ },
1273
+ {
1274
+ operation_id: "sms.templates.create",
1275
+ resource: "sms.templates",
1276
+ method_name: "create",
1277
+ http_method: "POST",
1278
+ path: "/sms/templates",
1279
+ path_params: [],
1280
+ query_params: [],
1281
+ has_body: true,
1282
+ request_schema: "SmsTemplateCreate",
1283
+ response_schema: "SmsTemplate",
1284
+ is_list: false,
1285
+ list_item_schema: nil
1286
+ },
1287
+ {
1288
+ operation_id: "sms.templates.delete",
1289
+ resource: "sms.templates",
1290
+ method_name: "delete",
1291
+ http_method: "DELETE",
1292
+ path: "/sms/templates/{id}",
1293
+ path_params: ["id"],
1294
+ query_params: [],
1295
+ has_body: false,
1296
+ request_schema: nil,
1297
+ response_schema: nil,
1298
+ is_list: false,
1299
+ list_item_schema: nil
1300
+ },
1301
+ {
1302
+ operation_id: "sms.templates.get",
1303
+ resource: "sms.templates",
1304
+ method_name: "get",
1305
+ http_method: "GET",
1306
+ path: "/sms/templates/{id}",
1307
+ path_params: ["id"],
1308
+ query_params: [],
1309
+ has_body: false,
1310
+ request_schema: nil,
1311
+ response_schema: "SmsTemplate",
1312
+ is_list: false,
1313
+ list_item_schema: nil
1314
+ },
1315
+ {
1316
+ operation_id: "sms.templates.list",
1317
+ resource: "sms.templates",
1318
+ method_name: "list",
1319
+ http_method: "GET",
1320
+ path: "/sms/templates",
1321
+ path_params: [],
1322
+ query_params: ["limit", "after", "before"],
1323
+ has_body: false,
1324
+ request_schema: nil,
1325
+ response_schema: nil,
1326
+ is_list: true,
1327
+ list_item_schema: "SmsTemplate"
1328
+ },
1329
+ {
1330
+ operation_id: "sms.templates.update",
1331
+ resource: "sms.templates",
1332
+ method_name: "update",
1333
+ http_method: "PATCH",
1334
+ path: "/sms/templates/{id}",
1335
+ path_params: ["id"],
1336
+ query_params: [],
1337
+ has_body: true,
1338
+ request_schema: "SmsTemplateUpdate",
1339
+ response_schema: "SmsTemplate",
1340
+ is_list: false,
1341
+ list_item_schema: nil
1342
+ },
1343
+ {
1344
+ operation_id: "sms.topics.create",
1345
+ resource: "sms.topics",
1346
+ method_name: "create",
1347
+ http_method: "POST",
1348
+ path: "/sms/topics",
1349
+ path_params: [],
1350
+ query_params: [],
1351
+ has_body: true,
1352
+ request_schema: "TopicCreate",
1353
+ response_schema: "Topic",
1354
+ is_list: false,
1355
+ list_item_schema: nil
1356
+ },
1357
+ {
1358
+ operation_id: "sms.topics.delete",
1359
+ resource: "sms.topics",
1360
+ method_name: "delete",
1361
+ http_method: "DELETE",
1362
+ path: "/sms/topics/{id}",
1363
+ path_params: ["id"],
1364
+ query_params: [],
1365
+ has_body: false,
1366
+ request_schema: nil,
1367
+ response_schema: nil,
1368
+ is_list: false,
1369
+ list_item_schema: nil
1370
+ },
1371
+ {
1372
+ operation_id: "sms.topics.get",
1373
+ resource: "sms.topics",
1374
+ method_name: "get",
1375
+ http_method: "GET",
1376
+ path: "/sms/topics/{id}",
1377
+ path_params: ["id"],
1378
+ query_params: [],
1379
+ has_body: false,
1380
+ request_schema: nil,
1381
+ response_schema: "Topic",
1382
+ is_list: false,
1383
+ list_item_schema: nil
1384
+ },
1385
+ {
1386
+ operation_id: "sms.topics.list",
1387
+ resource: "sms.topics",
1388
+ method_name: "list",
1389
+ http_method: "GET",
1390
+ path: "/sms/topics",
1391
+ path_params: [],
1392
+ query_params: ["limit", "after", "before", "archived"],
1393
+ has_body: false,
1394
+ request_schema: nil,
1395
+ response_schema: nil,
1396
+ is_list: true,
1397
+ list_item_schema: "Topic"
1398
+ },
1399
+ {
1400
+ operation_id: "sms.topics.update",
1401
+ resource: "sms.topics",
1402
+ method_name: "update",
1403
+ http_method: "PATCH",
1404
+ path: "/sms/topics/{id}",
1405
+ path_params: ["id"],
1406
+ query_params: [],
1407
+ has_body: true,
1408
+ request_schema: "TopicUpdate",
1409
+ response_schema: "Topic",
1410
+ is_list: false,
1411
+ list_item_schema: nil
1412
+ },
1413
+ {
1414
+ operation_id: "sms.update",
1415
+ resource: "sms",
1416
+ method_name: "update",
1417
+ http_method: "PATCH",
1418
+ path: "/sms/{id}",
1419
+ path_params: ["id"],
1420
+ query_params: [],
1421
+ has_body: true,
1422
+ request_schema: "SmsPatch",
1423
+ response_schema: "Sms",
1424
+ is_list: false,
1425
+ list_item_schema: nil
1426
+ },
965
1427
  {
966
1428
  operation_id: "suppressions.create",
967
1429
  resource: "suppressions",
@@ -1088,6 +1550,34 @@ module Unitpost
1088
1550
  is_list: true,
1089
1551
  list_item_schema: "Webhook"
1090
1552
  },
1553
+ {
1554
+ operation_id: "webhooks.listDeliveries",
1555
+ resource: "webhooks",
1556
+ method_name: "listDeliveries",
1557
+ http_method: "GET",
1558
+ path: "/webhooks/{id}/deliveries",
1559
+ path_params: ["id"],
1560
+ query_params: ["limit", "after", "before"],
1561
+ has_body: false,
1562
+ request_schema: nil,
1563
+ response_schema: nil,
1564
+ is_list: true,
1565
+ list_item_schema: "WebhookDelivery"
1566
+ },
1567
+ {
1568
+ operation_id: "webhooks.rotateSecret",
1569
+ resource: "webhooks",
1570
+ method_name: "rotateSecret",
1571
+ http_method: "POST",
1572
+ path: "/webhooks/{id}/rotate-secret",
1573
+ path_params: ["id"],
1574
+ query_params: [],
1575
+ has_body: false,
1576
+ request_schema: nil,
1577
+ response_schema: "WebhookSecretRotated",
1578
+ is_list: false,
1579
+ list_item_schema: nil
1580
+ },
1091
1581
  {
1092
1582
  operation_id: "webhooks.test",
1093
1583
  resource: "webhooks",
@@ -0,0 +1,222 @@
1
+ # Code generated by @unitpost/sdk-codegen from the public OpenAPI spec.
2
+ # DO NOT EDIT BY HAND. Run `npm run sdk:generate` to regenerate.
3
+ # Source of truth: apps/web/docs/openapi.json (itself derived from the API's
4
+ # Zod schemas and CI-gated for drift).
5
+
6
+ # frozen_string_literal: true
7
+
8
+ module Unitpost
9
+ module Generated
10
+ # Why a message was skipped before reaching the carrier. `out_of_reach`: no
11
+ # sender covers the recipient's country; `unsupported_country`: no
12
+ # compliance rule for the country yet, or SMS to that country is paused for
13
+ # now (every message, transactional included, until it resumes); `no_phone`:
14
+ # the contact has no SMS handle; `opted_out`: STOP / suppression /
15
+ # unreachable; `no_marketing_consent`: marketing without express consent;
16
+ # `quiet_hours_deferred`: delayed to the recipient's local window;
17
+ # `sender_not_active`: the sender lost approval.
18
+ module SmsSkipReason
19
+ OUT_OF_REACH = "out_of_reach"
20
+ UNSUPPORTED_COUNTRY = "unsupported_country"
21
+ NO_PHONE = "no_phone"
22
+ OPTED_OUT = "opted_out"
23
+ NO_MARKETING_CONSENT = "no_marketing_consent"
24
+ QUIET_HOURS_DEFERRED = "quiet_hours_deferred"
25
+ SENDER_NOT_ACTIVE = "sender_not_active"
26
+ ALL = [OUT_OF_REACH, UNSUPPORTED_COUNTRY, NO_PHONE, OPTED_OUT, NO_MARKETING_CONSENT, QUIET_HOURS_DEFERRED, SENDER_NOT_ACTIVE].freeze
27
+ end
28
+
29
+ # The `SmsError` object from the public API.
30
+ # @!attribute code Why a message was skipped before reaching the carrier. `out_of_reach`: no sender covers the recipient's country; `unsupported_country`: no compliance rule for the country yet, or SMS to that country is paused for now (every message, transactional included, until it resumes); `no_phone`: the contact has no SMS handle; `opted_out`: STOP / suppression / unreachable; `no_marketing_consent`: marketing without express consent; `quiet_hours_deferred`: delayed to the recipient's local window; `sender_not_active`: the sender lost approval.
31
+ # @!attribute message Human-readable sentence for `code`.
32
+ SmsError = Struct.new(:code, :message, keyword_init: true) do
33
+ # @param h [Hash, nil] the decoded API object (string keys)
34
+ # @return [SmsError, nil]
35
+ def self.from_hash(h)
36
+ return nil if h.nil?
37
+ h = h.transform_keys(&:to_s)
38
+ new(
39
+ code: h["code"],
40
+ message: h["message"]
41
+ )
42
+ end
43
+ end
44
+
45
+ # The `SmsNumberUseCase` object from the public API.
46
+ # @!attribute name
47
+ # @!attribute category
48
+ SmsNumberUseCase = Struct.new(:name, :category, keyword_init: true) do
49
+ # @param h [Hash, nil] the decoded API object (string keys)
50
+ # @return [SmsNumberUseCase, nil]
51
+ def self.from_hash(h)
52
+ return nil if h.nil?
53
+ h = h.transform_keys(&:to_s)
54
+ new(
55
+ name: h["name"],
56
+ category: h["category"]
57
+ )
58
+ end
59
+ end
60
+
61
+ # The `SmsNumberSenderIdGroupCountriesItem` object from the public API.
62
+ # @!attribute country ISO-3166 alpha-2.
63
+ # @!attribute status One of: pending, action_required, active, rejected, released.
64
+ SmsNumberSenderIdGroupCountriesItem = Struct.new(:country, :status, keyword_init: true) do
65
+ # @param h [Hash, nil] the decoded API object (string keys)
66
+ # @return [SmsNumberSenderIdGroupCountriesItem, nil]
67
+ def self.from_hash(h)
68
+ return nil if h.nil?
69
+ h = h.transform_keys(&:to_s)
70
+ new(
71
+ country: h["country"],
72
+ status: h["status"]
73
+ )
74
+ end
75
+ end
76
+
77
+ # The `SmsNumberSenderIdGroup` object from the public API.
78
+ # @!attribute name The Sender ID string recipients see.
79
+ # @!attribute brand_id The brand that owns this Sender ID.
80
+ # @!attribute countries Every country this Sender ID is enabled for, with each country's own status.
81
+ SmsNumberSenderIdGroup = Struct.new(:name, :brand_id, :countries, keyword_init: true) do
82
+ # @param h [Hash, nil] the decoded API object (string keys)
83
+ # @return [SmsNumberSenderIdGroup, nil]
84
+ def self.from_hash(h)
85
+ return nil if h.nil?
86
+ h = h.transform_keys(&:to_s)
87
+ new(
88
+ name: h["name"],
89
+ brand_id: h["brand_id"],
90
+ countries: (h["countries"] || []).map { |x| SmsNumberSenderIdGroupCountriesItem.from_hash(x) }
91
+ )
92
+ end
93
+ end
94
+
95
+ # The `Sms` object from the public API.
96
+ # @!attribute object Always "sms".
97
+ # @!attribute id
98
+ # @!attribute to
99
+ # @!attribute from
100
+ # @!attribute direction One of: outbound, inbound.
101
+ # @!attribute status One of: queued, scheduled, sending, sent, delivered, failed, suppressed, canceled, received.
102
+ # @!attribute type One of: transactional, marketing.
103
+ # @!attribute body
104
+ # @!attribute segments Billed units. Outbound: segments. Inbound: 1 when the received text was charged (1¢ in the US and Canada), else 0.
105
+ # @!attribute in_reply_to Inbound only: your text this reply answers (`sms_…`), when the carrier says which. Null otherwise.
106
+ # @!attribute received_at Inbound only: when the reply reached Unitpost. Order a conversation by this; carriers don't guarantee order. Null on outbound.
107
+ # @!attribute looks_like_opt_out Inbound only: the text reads like a request to stop texting ("please stop texting me") but was not an opt-out keyword, so no opt-out was recorded. Decide whether to record one (POST /v1/contacts/{id}/sms-consent, action opt_out). False for an exact keyword such as STOP (already recorded) and on outbound.
108
+ # @!attribute country
109
+ # @!attribute scheduled_at
110
+ # @!attribute last_error Legacy free-text error. Prefer `error.code`.
111
+ # @!attribute error Set on `suppressed` messages: the stable skip reason and its sentence. Null otherwise.
112
+ # @!attribute created_at
113
+ Sms = Struct.new(:object, :id, :to, :from, :direction, :status, :type, :body, :segments, :in_reply_to, :received_at, :looks_like_opt_out, :country, :scheduled_at, :last_error, :error, :created_at, keyword_init: true) do
114
+ # @param h [Hash, nil] the decoded API object (string keys)
115
+ # @return [Sms, nil]
116
+ def self.from_hash(h)
117
+ return nil if h.nil?
118
+ h = h.transform_keys(&:to_s)
119
+ new(
120
+ object: h["object"],
121
+ id: h["id"],
122
+ to: h["to"],
123
+ from: h["from"],
124
+ direction: h["direction"],
125
+ status: h["status"],
126
+ type: h["type"],
127
+ body: h["body"],
128
+ segments: h["segments"],
129
+ in_reply_to: h["in_reply_to"],
130
+ received_at: h["received_at"],
131
+ looks_like_opt_out: h["looks_like_opt_out"],
132
+ country: h["country"],
133
+ scheduled_at: h["scheduled_at"],
134
+ last_error: h["last_error"],
135
+ error: SmsError.from_hash(h["error"]),
136
+ created_at: h["created_at"]
137
+ )
138
+ end
139
+ end
140
+
141
+ # The `SmsBrand` object from the public API.
142
+ # @!attribute object Always "sms_brand".
143
+ # @!attribute id Prefixed brand id, e.g. `sbrand_…`.
144
+ # @!attribute name Public-facing brand name.
145
+ # @!attribute legal_name Registered legal entity name.
146
+ # @!attribute website Brand website URL.
147
+ # @!attribute domain Registrable domain derived from the website. The brand contact email must be on this domain.
148
+ # @!attribute contact_email
149
+ # @!attribute status `incomplete` and `action_required` need the customer to act in the dashboard; `pending` is waiting on the carrier. One of: incomplete, ready, pending, action_required, verified, rejected, archived.
150
+ # @!attribute missing Human-readable list of what is still required before this brand can back a number. Empty when nothing is missing.
151
+ # @!attribute ten_dlc_verified True when the 10DLC brand registration is approved.
152
+ # @!attribute tax_id_on_file
153
+ # @!attribute number_count Non-released senders on this brand (phone numbers and Sender ID countries, any status).
154
+ # @!attribute editable False once the brand is locked: carriers approved or are reviewing its 10DLC registration, or senders are registered with its details. Locked brands are corrected only when carriers ask.
155
+ # @!attribute created_at
156
+ SmsBrand = Struct.new(:object, :id, :name, :legal_name, :website, :domain, :contact_email, :status, :missing, :ten_dlc_verified, :tax_id_on_file, :number_count, :editable, :created_at, keyword_init: true) do
157
+ # @param h [Hash, nil] the decoded API object (string keys)
158
+ # @return [SmsBrand, nil]
159
+ def self.from_hash(h)
160
+ return nil if h.nil?
161
+ h = h.transform_keys(&:to_s)
162
+ new(
163
+ object: h["object"],
164
+ id: h["id"],
165
+ name: h["name"],
166
+ legal_name: h["legal_name"],
167
+ website: h["website"],
168
+ domain: h["domain"],
169
+ contact_email: h["contact_email"],
170
+ status: h["status"],
171
+ missing: h["missing"],
172
+ ten_dlc_verified: h["ten_dlc_verified"],
173
+ tax_id_on_file: h["tax_id_on_file"],
174
+ number_count: h["number_count"],
175
+ editable: h["editable"],
176
+ created_at: h["created_at"]
177
+ )
178
+ end
179
+ end
180
+
181
+ # The `SmsNumber` object from the public API.
182
+ # @!attribute object Always "sms_number".
183
+ # @!attribute id Prefixed number id, e.g. `snum_…`.
184
+ # @!attribute phone_number E.164 number. Null while a 10DLC registration is still in review; the number is assigned on approval.
185
+ # @!attribute type `long_code` is a local number issued outside the US (today: Canada, `country: "CA"`, reach `["CA"]`; instant, no carrier registration). `sender_id` is an alphanumeric Sender ID (one entry per enabled country; see `sender_id_group`). `simulator` only delivers to verified test destinations; it is not a production sender. One of: toll_free, ten_dlc, long_code, sender_id, simulator.
186
+ # @!attribute status Only `active` numbers can send. One of: pending, action_required, active, rejected, released.
187
+ # @!attribute is_default Informational: the workspace's first or primary number. Sends route by the recipient's country, not by this flag.
188
+ # @!attribute country ISO-3166 alpha-2 country the number was issued in (`US` for toll-free and 10DLC, `CA` for a Canadian long code). Null until recorded.
189
+ # @!attribute two_way Whether people can text this number: once it is active, a text to it fires the `sms.received` webhook and costs 1¢. True for US toll-free and 10DLC numbers and Canadian long codes (two-way is offered in the US and Canada only); false for Sender IDs, which are one-way, and for the simulator.
190
+ # @!attribute reach ISO-3166 alpha-2 countries this number can deliver to. US toll-free and 10DLC numbers reach `["US"]` only; a Canadian long code reaches `["CA"]` only. A recipient outside this list is skipped before send (status `suppressed`, not billed) rather than forwarded best-effort from a shared identity. Empty while the country is unknown.
191
+ # @!attribute brand_id The brand this number is registered under.
192
+ # @!attribute use_case What carriers approved this number to send. Required for approval; submitted with the number.
193
+ # @!attribute action_required_reason Set when status is `action_required` (what to fix: carrier feedback, why a submission failed, or a request that never reached the registry) or `rejected`.
194
+ # @!attribute sender_id_group Present on `sender_id` entries only. A Sender ID is ONE identity per brand enabled per country: the list still returns one entry per (Sender ID, country) so existing integrations keep working, and every entry of the same identity carries the same `sender_id_group` describing the whole set.
195
+ # @!attribute created_at
196
+ SmsNumber = Struct.new(:object, :id, :phone_number, :type, :status, :is_default, :country, :two_way, :reach, :brand_id, :use_case, :action_required_reason, :sender_id_group, :created_at, keyword_init: true) do
197
+ # @param h [Hash, nil] the decoded API object (string keys)
198
+ # @return [SmsNumber, nil]
199
+ def self.from_hash(h)
200
+ return nil if h.nil?
201
+ h = h.transform_keys(&:to_s)
202
+ new(
203
+ object: h["object"],
204
+ id: h["id"],
205
+ phone_number: h["phone_number"],
206
+ type: h["type"],
207
+ status: h["status"],
208
+ is_default: h["is_default"],
209
+ country: h["country"],
210
+ two_way: h["two_way"],
211
+ reach: h["reach"],
212
+ brand_id: h["brand_id"],
213
+ use_case: SmsNumberUseCase.from_hash(h["use_case"]),
214
+ action_required_reason: h["action_required_reason"],
215
+ sender_id_group: SmsNumberSenderIdGroup.from_hash(h["sender_id_group"]),
216
+ created_at: h["created_at"]
217
+ )
218
+ end
219
+ end
220
+
221
+ end
222
+ end
@@ -27,7 +27,14 @@ module Unitpost
27
27
  { limit: limit, after: after, before: before }.merge(extra)
28
28
  end
29
29
 
30
+ FORWARD_ONLY_MESSAGE = "list_all pages forward only: `before` is not supported (use list to page backward)."
31
+
32
+ # Forward only, refused up front: a `before` cursor used to ride along
33
+ # with the moving `after` and 422 on the second page.
30
34
  def paginate(path, params)
35
+ before = params[:before] || params["before"]
36
+ raise ArgumentError, FORWARD_ONLY_MESSAGE if before && !before.to_s.empty?
37
+
31
38
  Enumerator.new do |yielder|
32
39
  after = params[:after]
33
40
  loop do
@@ -46,6 +53,193 @@ module Unitpost
46
53
  end
47
54
  end
48
55
 
56
+ class Sms < Base
57
+ attr_reader :templates, :campaigns, :topics
58
+
59
+ def initialize(http)
60
+ super(http)
61
+ @templates = SmsTemplates.new(http)
62
+ @campaigns = SmsCampaigns.new(http)
63
+ @topics = SmsTopics.new(http)
64
+ end
65
+
66
+ # SMS channel (paid plans). Recipient must be E.164; marketing sends require
67
+ # prior express consent on record and honor recipient-local quiet hours.
68
+ # Billing is per segment. When no idempotency_key is given one is
69
+ # generated so a transient retry can't double-send.
70
+ def send(body, idempotency_key: nil)
71
+ @http.request("POST", "/sms", body: body, idempotency_key: idempotency_key || SecureRandom.uuid)
72
+ end
73
+
74
+ def get(id)
75
+ @http.request("GET", "/sms/#{enc(id)}")
76
+ end
77
+
78
+ # Cancel a queued/scheduled SMS ({ cancel: true }, credit refunded) or
79
+ # move a scheduled one ({ scheduled_at: "<ISO 8601>" }).
80
+ def update(id, body)
81
+ @http.request("PATCH", "/sms/#{enc(id)}", body: body)
82
+ end
83
+
84
+ def list(params = {})
85
+ @http.request("GET", "/sms", query: params)
86
+ end
87
+
88
+ def list_all(params = {})
89
+ paginate("/sms", params)
90
+ end
91
+
92
+ # List SMS brands and their setup status.
93
+ #
94
+ # A brand is the business identity carriers register before they approve a
95
+ # phone number, so this is what to poll to answer "is SMS ready yet?":
96
+ # +status+ says whether you or the carrier is the blocker, and +missing+
97
+ # lists exactly what is still required.
98
+ #
99
+ # Read-only — creating a brand is a paid carrier submission and is only
100
+ # available in the dashboard.
101
+ def brands(params = {})
102
+ @http.request("GET", "/sms/brands", query: params)
103
+ end
104
+
105
+ # List SMS phone numbers.
106
+ #
107
+ # Only numbers with +status+ of "active" can send. A +ten_dlc+ number has
108
+ # no +phone_number+ until carriers approve it, and a +simulator+ number
109
+ # only reaches verified test destinations.
110
+ #
111
+ # Read-only — requesting or releasing a number is only available in the
112
+ # dashboard.
113
+ def numbers(params = {})
114
+ @http.request("GET", "/sms/numbers", query: params)
115
+ end
116
+
117
+ # SMS delivery stats over a rolling window (days: 1–365, defaults to 30
118
+ # server-side): queued, sent, delivered, failed, suppressed, canceled,
119
+ # inbound and segments billed, with delivery, failure and suppression
120
+ # rates. No opens or clicks on SMS.
121
+ def stats(days: nil)
122
+ @http.request("GET", "/sms/stats", query: { days: days })
123
+ end
124
+
125
+ # Read a contact's SMS consent state and immutable consent history.
126
+ def sms_consent(contact_id)
127
+ @http.request("GET", "/contacts/#{enc(contact_id)}/sms-consent")
128
+ end
129
+
130
+ # Record an SMS consent change (opt-in or opt-out) for a contact.
131
+ def record_sms_consent(contact_id, body)
132
+ @http.request("POST", "/contacts/#{enc(contact_id)}/sms-consent", body: body)
133
+ end
134
+ end
135
+
136
+ # SMS templates: plain-text bodies with {{variable}} placeholders.
137
+ class SmsTemplates < Base
138
+ def list(limit: nil, after: nil, before: nil)
139
+ @http.request("GET", "/sms/templates", query: list_params(limit: limit, after: after, before: before))
140
+ end
141
+
142
+ def list_all(**params)
143
+ paginate("/sms/templates", params)
144
+ end
145
+
146
+ def create(body)
147
+ @http.request("POST", "/sms/templates", body: body)
148
+ end
149
+
150
+ def get(id)
151
+ @http.request("GET", "/sms/templates/#{enc(id)}")
152
+ end
153
+
154
+ def update(id, body)
155
+ @http.request("PATCH", "/sms/templates/#{enc(id)}", body: body)
156
+ end
157
+
158
+ def delete(id)
159
+ @http.request("DELETE", "/sms/templates/#{enc(id)}")
160
+ end
161
+ end
162
+
163
+ # SMS campaigns: a published SMS template sent to a segment. create makes a
164
+ # draft (omit sender_id for automatic routing); send sends now or at
165
+ # scheduled_at; delete archives.
166
+ class SmsCampaigns < Base
167
+ def list(limit: nil, after: nil, before: nil)
168
+ @http.request("GET", "/sms/campaigns", query: list_params(limit: limit, after: after, before: before))
169
+ end
170
+
171
+ def list_all(**params)
172
+ paginate("/sms/campaigns", params)
173
+ end
174
+
175
+ def create(body)
176
+ @http.request("POST", "/sms/campaigns", body: body)
177
+ end
178
+
179
+ def get(id)
180
+ @http.request("GET", "/sms/campaigns/#{enc(id)}")
181
+ end
182
+
183
+ def update(id, body)
184
+ @http.request("PATCH", "/sms/campaigns/#{enc(id)}", body: body)
185
+ end
186
+
187
+ def delete(id)
188
+ @http.request("DELETE", "/sms/campaigns/#{enc(id)}")
189
+ end
190
+
191
+ def send(id, body = {})
192
+ @http.request("POST", "/sms/campaigns/#{enc(id)}/send", body: body)
193
+ end
194
+
195
+ def cancel(id)
196
+ @http.request("POST", "/sms/campaigns/#{enc(id)}/cancel")
197
+ end
198
+
199
+ def pause(id)
200
+ @http.request("POST", "/sms/campaigns/#{enc(id)}/pause")
201
+ end
202
+
203
+ def resume(id)
204
+ @http.request("POST", "/sms/campaigns/#{enc(id)}/resume")
205
+ end
206
+
207
+ def reschedule(id, body)
208
+ @http.request("POST", "/sms/campaigns/#{enc(id)}/reschedule", body: body)
209
+ end
210
+
211
+ def validate(id)
212
+ @http.request("GET", "/sms/campaigns/#{enc(id)}/validate")
213
+ end
214
+ end
215
+
216
+ # SMS subscription topics (independent from email topics); delete archives.
217
+ class SmsTopics < Base
218
+ def list(limit: nil, after: nil, before: nil)
219
+ @http.request("GET", "/sms/topics", query: list_params(limit: limit, after: after, before: before))
220
+ end
221
+
222
+ def list_all(**params)
223
+ paginate("/sms/topics", params)
224
+ end
225
+
226
+ def create(body)
227
+ @http.request("POST", "/sms/topics", body: body)
228
+ end
229
+
230
+ def get(id)
231
+ @http.request("GET", "/sms/topics/#{enc(id)}")
232
+ end
233
+
234
+ def update(id, body)
235
+ @http.request("PATCH", "/sms/topics/#{enc(id)}", body: body)
236
+ end
237
+
238
+ def delete(id)
239
+ @http.request("DELETE", "/sms/topics/#{enc(id)}")
240
+ end
241
+ end
242
+
49
243
  class Email < Base
50
244
  attr_reader :topics, :campaigns, :templates, :domains
51
245
 
@@ -152,6 +346,10 @@ module Unitpost
152
346
  def get_import(id)
153
347
  @http.request("GET", "/contacts/imports/#{enc(id)}")
154
348
  end
349
+
350
+ def cancel_import(id)
351
+ @http.request("POST", "/contacts/imports/#{enc(id)}/cancel")
352
+ end
155
353
  end
156
354
 
157
355
  class ContactFields < Base
@@ -184,6 +382,14 @@ module Unitpost
184
382
  end
185
383
  end
186
384
 
385
+ class Jobs < Base
386
+ # A background job: the 202 answer of an operation too large for one
387
+ # request. Poll until `status` is COMPLETED, FAILED or CANCELED.
388
+ def get(id)
389
+ @http.request("GET", "/jobs/#{enc(id)}")
390
+ end
391
+ end
392
+
187
393
  class Segments < Base
188
394
  def list(limit: nil, after: nil, before: nil)
189
395
  @http.request("GET", "/segments", query: list_params(limit: limit, after: after, before: before))
@@ -281,8 +487,11 @@ module Unitpost
281
487
  @http.request("DELETE", "/email/campaigns/#{enc(id)}")
282
488
  end
283
489
 
284
- def send(id)
285
- @http.request("POST", "/email/campaigns/#{enc(id)}/send")
490
+ # Send a draft. `{}` arms its saved `scheduled_at` while that is still
491
+ # ahead (otherwise it sends NOW); `{ scheduled_at: "<ISO 8601>" }`
492
+ # schedules it; `{ scheduled_at: nil }` sends now.
493
+ def send(id, body = {})
494
+ @http.request("POST", "/email/campaigns/#{enc(id)}/send", body: body)
286
495
  end
287
496
 
288
497
  def cancel(id)
@@ -330,6 +539,11 @@ module Unitpost
330
539
  def delete(id)
331
540
  @http.request("DELETE", "/email/templates/#{enc(id)}")
332
541
  end
542
+
543
+ # Send ONE test of the template to body[:to] with sample variables.
544
+ def test_send(id, body)
545
+ @http.request("POST", "/email/templates/#{enc(id)}/test-send", body: body)
546
+ end
333
547
  end
334
548
 
335
549
  # Read-only Brand Kit profiles (voice, colors, pinned /img/{id} graphics).
@@ -373,6 +587,11 @@ module Unitpost
373
587
  def verify(id)
374
588
  @http.request("POST", "/email/domains/#{enc(id)}/verify")
375
589
  end
590
+
591
+ # Read-only DNS diagnosis: per-record answers and ranked findings with fixes.
592
+ def diagnose(id)
593
+ @http.request("GET", "/email/domains/#{enc(id)}/diagnose")
594
+ end
376
595
  end
377
596
 
378
597
  class Webhooks < Base
@@ -404,6 +623,19 @@ module Unitpost
404
623
  @http.request("POST", "/webhooks/#{enc(id)}/test")
405
624
  end
406
625
 
626
+ # New signing secret, returned once; the old one co-signs for 24 h.
627
+ def rotate_secret(id)
628
+ @http.request("POST", "/webhooks/#{enc(id)}/rotate-secret")
629
+ end
630
+
631
+ def list_deliveries(id, limit: nil, after: nil, before: nil)
632
+ @http.request("GET", "/webhooks/#{enc(id)}/deliveries", query: list_params(limit: limit, after: after, before: before))
633
+ end
634
+
635
+ def list_all_deliveries(id, **params)
636
+ paginate("/webhooks/#{enc(id)}/deliveries", params)
637
+ end
638
+
407
639
  # Verify a delivery's signature and return the parsed event. Alias of
408
640
  # Unitpost.verify_webhook / Unitpost::Webhooks.verify for a Resend-style
409
641
  # client.webhooks.verify(...). Raises Unitpost::WebhookVerificationError on
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Unitpost
4
- VERSION = "0.2.1"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: unitpost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Unitpost
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-31 00:00:00.000000000 Z
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -30,6 +30,20 @@ dependencies:
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '3.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: base64
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0.1'
33
47
  - !ruby/object:Gem::Dependency
34
48
  name: rspec
35
49
  requirement: !ruby/object:Gem::Requirement
@@ -70,6 +84,7 @@ files:
70
84
  - lib/unitpost/client.rb
71
85
  - lib/unitpost/errors.rb
72
86
  - lib/unitpost/generated/operations.rb
87
+ - lib/unitpost/generated/sms_models.rb
73
88
  - lib/unitpost/http_client.rb
74
89
  - lib/unitpost/resources.rb
75
90
  - lib/unitpost/version.rb