sendly 3.35.0 → 3.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +413 -0
- data/lib/sendly/account_resource.rb +31 -0
- data/lib/sendly/client.rb +57 -4
- data/lib/sendly/links_resource.rb +240 -0
- data/lib/sendly/messages.rb +83 -0
- data/lib/sendly/numbers_resource.rb +107 -1
- data/lib/sendly/tendlc_resource.rb +397 -0
- data/lib/sendly/types.rb +79 -0
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43ce471688134e41cbed23f9d6b8bcc82895c867a4f42340120f670424d5a5f7
|
|
4
|
+
data.tar.gz: 628de06e8a2f2a25fc4837c94cb3bb651dc877f6d27ad8967af8b732d97f0a30
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a012fb951c1c7e1f000e83e031d8c35eb3145bcdef3ce7ddc2eacc400263dae269223c4631e12f2db621ea0aa3a887142c18614773fcb5f577eee09bda0a1d92
|
|
7
|
+
data.tar.gz: f01118db4ee6fd181b148a2d2c1d7ea551b14c2e748cc0d01cf16cf5e097ba5718e53b943423ded434c071f17f2f27adbb63ed5064312c2d5ddebcdb0b22fa0e
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -115,6 +115,14 @@ message = client.messages.send(
|
|
|
115
115
|
metadata: { order_id: "12345", customer_id: "cust_abc" }
|
|
116
116
|
)
|
|
117
117
|
|
|
118
|
+
# Send from one of your owned numbers (or an alphanumeric sender ID).
|
|
119
|
+
# Omit `from` to use your default sender.
|
|
120
|
+
message = client.messages.send(
|
|
121
|
+
to: "+15551234567",
|
|
122
|
+
text: "Hello from our team!",
|
|
123
|
+
from: "+447111111111"
|
|
124
|
+
)
|
|
125
|
+
|
|
118
126
|
puts message.id
|
|
119
127
|
puts message.status
|
|
120
128
|
puts message.credits_used
|
|
@@ -224,6 +232,54 @@ client.messages.each(status: "delivered") do |message|
|
|
|
224
232
|
end
|
|
225
233
|
```
|
|
226
234
|
|
|
235
|
+
### Group MMS
|
|
236
|
+
|
|
237
|
+
Send one MMS to 2-8 US/Canada recipients who all share a single thread —
|
|
238
|
+
replies fan out to every participant. Group messaging is an A2P 10DLC
|
|
239
|
+
capability, so the sending number must be an MMS-enabled, 10DLC-registered
|
|
240
|
+
number you own. Omit `from` to use your workspace's default sender. Requires
|
|
241
|
+
the `group_mms` feature (and `enable_mms` when sending media).
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
group = client.messages.send_group(
|
|
245
|
+
to: ["+14155551234", "+14155555678"],
|
|
246
|
+
text: "Hey team - quick sync at noon?"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
puts group.id # => "msg_abc123"
|
|
250
|
+
puts group.group_message_id # => "grp_..." (present on live sends)
|
|
251
|
+
puts group.status # => "sent" (or "delivered" when simulated)
|
|
252
|
+
puts group.simulated? # => true on test keys / before verification
|
|
253
|
+
|
|
254
|
+
# With media instead of (or in addition to) text
|
|
255
|
+
client.messages.send_group(
|
|
256
|
+
to: ["+14155551234", "+14155555678"],
|
|
257
|
+
media_urls: ["https://cdn.example.com/flyer.jpg"],
|
|
258
|
+
message_type: "marketing"
|
|
259
|
+
)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Billed per recipient. US/Canada destinations only.
|
|
263
|
+
|
|
264
|
+
### AI Message Enhancement
|
|
265
|
+
|
|
266
|
+
Rewrite a draft into a single, polished SMS segment (≤160 chars) and get a
|
|
267
|
+
short explanation of what changed. Pass `message_type` to steer the tone; with
|
|
268
|
+
no `text` the model generates a suitable message for that type. At least one of
|
|
269
|
+
`text` or `message_type` is required. Requires the `ai_classification` feature —
|
|
270
|
+
when AI is unavailable, the original text is returned with an empty explanation.
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
result = client.messages.enhance(
|
|
274
|
+
text: "hey come check out our sale this weekend",
|
|
275
|
+
message_type: "marketing"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
puts result.enhanced # polished, ≤160-char rewrite
|
|
279
|
+
puts result.explanation # what changed and why
|
|
280
|
+
puts result.model # model used (when available)
|
|
281
|
+
```
|
|
282
|
+
|
|
227
283
|
## Webhooks
|
|
228
284
|
|
|
229
285
|
```ruby
|
|
@@ -287,10 +343,266 @@ end
|
|
|
287
343
|
result = client.account.create_api_key('Production Key')
|
|
288
344
|
puts "New key: #{result['key']}" # Only shown once!
|
|
289
345
|
|
|
346
|
+
# Rotate an API key. Issues a new key and keeps the old one valid for a grace
|
|
347
|
+
# period (default 24h; 24-168 allowed) so you can deploy before the old expires.
|
|
348
|
+
rotation = client.account.rotate_api_key('key_xxx', grace_period_hours: 72)
|
|
349
|
+
puts "New key: #{rotation['newKey']['key']}" # Only shown once!
|
|
350
|
+
puts rotation['message'] # "Old key will expire in 72 hours"
|
|
351
|
+
|
|
290
352
|
# Revoke an API key
|
|
291
353
|
client.account.revoke_api_key('key_xxx')
|
|
292
354
|
```
|
|
293
355
|
|
|
356
|
+
## Contacts
|
|
357
|
+
|
|
358
|
+
Manage your contact directory. `list` returns a Hash with a `:contacts` array
|
|
359
|
+
of `Contact` objects plus pagination fields.
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
# Create a contact
|
|
363
|
+
contact = client.contacts.create(
|
|
364
|
+
phone_number: "+15551234567",
|
|
365
|
+
name: "Alice Example",
|
|
366
|
+
email: "alice@example.com",
|
|
367
|
+
metadata: { plan: "pro" }
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# List / search (scope to a list with list_id:)
|
|
371
|
+
result = client.contacts.list(limit: 50, search: "alice")
|
|
372
|
+
result[:contacts].each { |c| puts "#{c.name}: #{c.phone_number}" }
|
|
373
|
+
puts result[:total]
|
|
374
|
+
|
|
375
|
+
# Get, update, delete
|
|
376
|
+
c = client.contacts.get(contact.id)
|
|
377
|
+
client.contacts.update(contact.id, name: "Alice E.")
|
|
378
|
+
client.contacts.delete(contact.id)
|
|
379
|
+
|
|
380
|
+
# A contact's helper flags
|
|
381
|
+
puts c.opted_out? # excluded from marketing sends
|
|
382
|
+
puts c.invalid? # auto-flagged as unreachable (landline / bad number)
|
|
383
|
+
|
|
384
|
+
# Bulk import (dedupes by phone; each entry is a Hash)
|
|
385
|
+
report = client.contacts.import_contacts(
|
|
386
|
+
[
|
|
387
|
+
{ phone: "+15551234567", name: "Alice" },
|
|
388
|
+
{ phone: "+15559876543", name: "Bob", email: "bob@example.com" }
|
|
389
|
+
],
|
|
390
|
+
list_id: "list_abc"
|
|
391
|
+
)
|
|
392
|
+
puts "Imported #{report[:imported]}, skipped #{report[:skipped_duplicates]}"
|
|
393
|
+
|
|
394
|
+
# Clear the auto-invalid flag (single or bulk)
|
|
395
|
+
client.contacts.mark_valid(contact.id)
|
|
396
|
+
client.contacts.bulk_mark_valid(list_id: "list_abc")
|
|
397
|
+
|
|
398
|
+
# Trigger a carrier line-type lookup (async; landlines get excluded)
|
|
399
|
+
client.contacts.check_numbers(list_id: "list_abc", force: false)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
## Contact Lists
|
|
403
|
+
|
|
404
|
+
Group contacts into lists for campaigns. Access via `client.contacts.lists`.
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
# Create and manage lists
|
|
408
|
+
list = client.contacts.lists.create(name: "VIP Customers", description: "Top spenders")
|
|
409
|
+
all = client.contacts.lists.list
|
|
410
|
+
all[:lists].each { |l| puts "#{l.name} (#{l.contact_count})" }
|
|
411
|
+
|
|
412
|
+
# Get a list (paginate its members)
|
|
413
|
+
detail = client.contacts.lists.get(list.id, limit: 100, offset: 0)
|
|
414
|
+
|
|
415
|
+
client.contacts.lists.update(list.id, name: "VIPs")
|
|
416
|
+
|
|
417
|
+
# Add / remove members
|
|
418
|
+
result = client.contacts.lists.add_contacts(list.id, ["contact_1", "contact_2"])
|
|
419
|
+
puts "Added #{result[:added_count]}"
|
|
420
|
+
client.contacts.lists.remove_contact(list.id, "contact_1")
|
|
421
|
+
|
|
422
|
+
client.contacts.lists.delete(list.id)
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## Campaigns
|
|
426
|
+
|
|
427
|
+
Send a message to one or more contact lists as a single campaign.
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
# Create a campaign
|
|
431
|
+
campaign = client.campaigns.create(
|
|
432
|
+
name: "Spring Sale",
|
|
433
|
+
text: "Our spring sale is live! 20% off everything.",
|
|
434
|
+
contact_list_ids: ["list_abc"]
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Preview cost + reachability before sending
|
|
438
|
+
preview = client.campaigns.preview(campaign.id)
|
|
439
|
+
puts "Recipients: #{preview.recipient_count}"
|
|
440
|
+
puts "Credits needed: #{preview.estimated_credits}"
|
|
441
|
+
puts "Enough credits? #{preview.enough_credits?}"
|
|
442
|
+
|
|
443
|
+
# Send now, or schedule for later
|
|
444
|
+
client.campaigns.send_campaign(campaign.id)
|
|
445
|
+
client.campaigns.schedule(campaign.id, scheduled_at: "2025-06-01T15:00:00Z", timezone: "America/New_York")
|
|
446
|
+
|
|
447
|
+
# List, update, cancel, clone, delete
|
|
448
|
+
client.campaigns.list(status: "sent")[:campaigns].each { |c| puts "#{c.name}: #{c.status}" }
|
|
449
|
+
client.campaigns.update(campaign.id, name: "Spring Sale (v2)")
|
|
450
|
+
client.campaigns.cancel(campaign.id)
|
|
451
|
+
client.campaigns.clone(campaign.id)
|
|
452
|
+
client.campaigns.delete(campaign.id)
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
## Templates
|
|
456
|
+
|
|
457
|
+
Reusable message templates with variables. AI can also draft one for you.
|
|
458
|
+
|
|
459
|
+
```ruby
|
|
460
|
+
# Create / list / get
|
|
461
|
+
template = client.templates.create(
|
|
462
|
+
name: "Order shipped",
|
|
463
|
+
body: "Hi {{name}}, order #{{order_id}} has shipped!",
|
|
464
|
+
is_published: true
|
|
465
|
+
)
|
|
466
|
+
client.templates.list(type: "custom")[:templates].each { |t| puts t.name }
|
|
467
|
+
t = client.templates.get(template.id)
|
|
468
|
+
|
|
469
|
+
# Update, publish/unpublish, clone
|
|
470
|
+
client.templates.update(template.id, body: "Hi {{name}}, your order is on the way!")
|
|
471
|
+
client.templates.publish(template.id)
|
|
472
|
+
client.templates.unpublish(template.id)
|
|
473
|
+
client.templates.clone(template.id, name: "Order shipped (copy)")
|
|
474
|
+
client.templates.delete(template.id)
|
|
475
|
+
|
|
476
|
+
# Generate a template with AI
|
|
477
|
+
generated = client.templates.generate(description: "A friendly appointment reminder")
|
|
478
|
+
puts generated.text
|
|
479
|
+
puts generated.variables.inspect
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
## Conversations
|
|
483
|
+
|
|
484
|
+
Two-way messaging threads with your contacts.
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
# List conversations (Enumerable)
|
|
488
|
+
conversations = client.conversations.list(status: "active")
|
|
489
|
+
conversations.each { |c| puts "#{c.phone_number}: #{c.last_message_text}" }
|
|
490
|
+
|
|
491
|
+
# Get one, optionally with its messages
|
|
492
|
+
convo = client.conversations.get("conv_abc", include_messages: true)
|
|
493
|
+
|
|
494
|
+
# Reply in a thread
|
|
495
|
+
client.conversations.reply("conv_abc", text: "Thanks for reaching out!")
|
|
496
|
+
|
|
497
|
+
# Lifecycle + metadata
|
|
498
|
+
client.conversations.mark_read("conv_abc")
|
|
499
|
+
client.conversations.close("conv_abc")
|
|
500
|
+
client.conversations.reopen("conv_abc")
|
|
501
|
+
client.conversations.update("conv_abc", tags: ["priority"], metadata: { csat: 5 })
|
|
502
|
+
|
|
503
|
+
# Apply / remove labels
|
|
504
|
+
client.conversations.add_labels("conv_abc", label_ids: ["label_1"])
|
|
505
|
+
client.conversations.remove_label("conv_abc", label_id: "label_1")
|
|
506
|
+
|
|
507
|
+
# AI: conversation context + suggested replies
|
|
508
|
+
context = client.conversations.get_context("conv_abc", max_messages: 20)
|
|
509
|
+
puts context.token_estimate
|
|
510
|
+
|
|
511
|
+
replies = client.conversations.suggest_replies("conv_abc")
|
|
512
|
+
replies.each { |r| puts "[#{r.tone}] #{r.text}" }
|
|
513
|
+
|
|
514
|
+
# Auto-paginate every conversation
|
|
515
|
+
client.conversations.each(status: "active") { |c| puts c.id }
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
## Drafts
|
|
519
|
+
|
|
520
|
+
Stage replies for review before they're sent (approve → sends via the API).
|
|
521
|
+
|
|
522
|
+
```ruby
|
|
523
|
+
draft = client.drafts.create(
|
|
524
|
+
conversation_id: "conv_abc",
|
|
525
|
+
text: "Here's the info you asked for.",
|
|
526
|
+
source: "agent"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
client.drafts.list(conversation_id: "conv_abc", status: "pending").each { |d| puts d.text }
|
|
530
|
+
client.drafts.update(draft.id, text: "Here is the info you asked for.")
|
|
531
|
+
|
|
532
|
+
# Approve (sends the message) or reject with a reason
|
|
533
|
+
client.drafts.approve(draft.id)
|
|
534
|
+
client.drafts.reject(draft.id, reason: "Needs the discount code")
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
## Labels
|
|
538
|
+
|
|
539
|
+
Organize conversations with labels.
|
|
540
|
+
|
|
541
|
+
```ruby
|
|
542
|
+
label = client.labels.create(name: "Urgent", color: "#ff0000", description: "Needs a fast reply")
|
|
543
|
+
client.labels.list.each { |l| puts l.name }
|
|
544
|
+
client.labels.delete(label.id)
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
## Rules
|
|
548
|
+
|
|
549
|
+
Automations that act on inbound messages based on conditions.
|
|
550
|
+
|
|
551
|
+
```ruby
|
|
552
|
+
rule = client.rules.create(
|
|
553
|
+
name: "Auto-label opt-outs",
|
|
554
|
+
conditions: { keyword: "STOP" },
|
|
555
|
+
actions: { add_label: "opted-out" },
|
|
556
|
+
priority: 1
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
client.rules.list.each { |r| puts "#{r.name} (priority #{r.priority})" }
|
|
560
|
+
client.rules.update(rule.id, priority: 2)
|
|
561
|
+
client.rules.delete(rule.id)
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
## Verify
|
|
565
|
+
|
|
566
|
+
Phone verification (OTP) — send a code, then check it. Hosted verification
|
|
567
|
+
sessions are available under `client.verify.sessions`.
|
|
568
|
+
|
|
569
|
+
```ruby
|
|
570
|
+
# Send a verification code
|
|
571
|
+
verification = client.verify.send(to: "+15551234567", app_name: "Acme")
|
|
572
|
+
puts verification.id
|
|
573
|
+
|
|
574
|
+
# Check the code the user entered
|
|
575
|
+
result = client.verify.check(verification.id, code: "123456")
|
|
576
|
+
puts result.verified?
|
|
577
|
+
|
|
578
|
+
# Resend, fetch, and list
|
|
579
|
+
client.verify.resend(verification.id)
|
|
580
|
+
client.verify.get(verification.id)
|
|
581
|
+
client.verify.list(status: "verified")[:verifications].each { |v| puts v.phone }
|
|
582
|
+
|
|
583
|
+
# Hosted verification session (returns a URL to send the user to)
|
|
584
|
+
session = client.verify.sessions.create(
|
|
585
|
+
success_url: "https://example.com/verified",
|
|
586
|
+
brand_name: "Acme"
|
|
587
|
+
)
|
|
588
|
+
puts session.url
|
|
589
|
+
check = client.verify.sessions.validate(token: "session_token")
|
|
590
|
+
puts check.valid?
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
## Media
|
|
594
|
+
|
|
595
|
+
Upload an image to attach to an MMS (returns a hosted media URL).
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
# From a file path
|
|
599
|
+
media = client.media.upload("flyer.jpg", content_type: "image/jpeg")
|
|
600
|
+
puts media.url
|
|
601
|
+
|
|
602
|
+
# ...then attach it to a send
|
|
603
|
+
client.messages.send(to: "+15551234567", text: "Check this out!", media_urls: [media.url])
|
|
604
|
+
```
|
|
605
|
+
|
|
294
606
|
## Numbers
|
|
295
607
|
|
|
296
608
|
Search for, list, and purchase phone numbers. Requires an API key with the
|
|
@@ -330,6 +642,107 @@ when 'documents_required', 'payment_required'
|
|
|
330
642
|
# ...after the action completes:
|
|
331
643
|
# client.numbers.buy(..., action_code: purchase.action_code)
|
|
332
644
|
end
|
|
645
|
+
|
|
646
|
+
# Get one number you own (includes is_default, which the list omits)
|
|
647
|
+
number = client.numbers.get('num_abc123')
|
|
648
|
+
puts "#{number.phone_number} — default sender: #{number.is_default}"
|
|
649
|
+
|
|
650
|
+
# Update a number — make it the default sender (must be active),
|
|
651
|
+
# and/or cancel a scheduled release ("keep this number")
|
|
652
|
+
client.numbers.update('num_abc123', is_default: true)
|
|
653
|
+
client.numbers.update('num_abc123', pending_cancellation: false)
|
|
654
|
+
|
|
655
|
+
# Release a number. A live paid purchase is cancelled at the end of the paid
|
|
656
|
+
# period (scheduled?), everything else is released immediately.
|
|
657
|
+
result = client.numbers.release('num_abc123')
|
|
658
|
+
if result.scheduled?
|
|
659
|
+
puts "Releases at #{result.scheduled_release_at}"
|
|
660
|
+
else
|
|
661
|
+
puts "Released"
|
|
662
|
+
end
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
## 10DLC (Local Number Texting)
|
|
666
|
+
|
|
667
|
+
Register your business for carrier review so you can text from local
|
|
668
|
+
(10-digit) US numbers. Requires an API key with the `tendlc:read` /
|
|
669
|
+
`tendlc:write` scopes; writes need a live key.
|
|
670
|
+
|
|
671
|
+
```ruby
|
|
672
|
+
# 1. Register a brand for carrier review
|
|
673
|
+
brand = client.ten_dlc.create_brand(
|
|
674
|
+
legal_name: 'Acme Holdings LLC',
|
|
675
|
+
ein: '12-3456789',
|
|
676
|
+
website: 'https://acme.example',
|
|
677
|
+
email: 'ops@acme.example'
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
# Poll until the brand is verified (or failed, with failure_reasons)
|
|
681
|
+
refreshed = client.ten_dlc.get_brand(brand.id)
|
|
682
|
+
puts refreshed.status # "pending" -> "verified"
|
|
683
|
+
|
|
684
|
+
# 2. Pre-check your use case, then create a campaign
|
|
685
|
+
check = client.ten_dlc.qualify(brand.id, 'MIXED')
|
|
686
|
+
if check.qualified?
|
|
687
|
+
campaign = client.ten_dlc.create_campaign(
|
|
688
|
+
brand_id: brand.id,
|
|
689
|
+
use_case: 'MIXED',
|
|
690
|
+
description: 'Order updates and support replies for Acme customers',
|
|
691
|
+
message_flow: 'Customers opt in at checkout on acme.example',
|
|
692
|
+
sample_messages: ['Your order #123 has shipped!'],
|
|
693
|
+
opt_out_keywords: 'STOP'
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
# Poll until carriers approve
|
|
697
|
+
approved = client.ten_dlc.get_campaign(campaign.id)
|
|
698
|
+
puts approved.status # "pending" -> "active"
|
|
699
|
+
puts approved.throughput&.tier # e.g. "Standard"
|
|
700
|
+
|
|
701
|
+
# 3. Assign a number you own — it can send once the assignment is Active
|
|
702
|
+
assignment = client.ten_dlc.assign_number(campaign.id, phone_number: '+15551234567')
|
|
703
|
+
puts assignment.status # "Under review" -> "Active"
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# List everything
|
|
707
|
+
client.ten_dlc.list_brands[:brands].each { |b| puts "#{b.legal_name} — #{b.status}" }
|
|
708
|
+
client.ten_dlc.list_campaigns[:campaigns].each { |c| puts "#{c.use_case} — #{c.status}" }
|
|
709
|
+
client.ten_dlc.list_assignments[:assignments].each { |a| puts "#{a.phone_number} — #{a.status}" }
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
## URL Shortening (Branded Links)
|
|
713
|
+
|
|
714
|
+
Mint branded short links for a destination URL, list them with click
|
|
715
|
+
analytics, and disable an individual link (a per-link kill switch). Branded,
|
|
716
|
+
owned-domain short links improve deliverability — carriers filter public
|
|
717
|
+
shorteners — and give you click data.
|
|
718
|
+
|
|
719
|
+
> **Not yet GA.** URL shortening is gated behind the `url_shortener` rollout
|
|
720
|
+
> flag (currently founder-only). Until the flag is on for your account, calls
|
|
721
|
+
> return a 404 (`Sendly::NotFoundError`) — the feature reads as absent.
|
|
722
|
+
|
|
723
|
+
```ruby
|
|
724
|
+
# Shorten a URL (must be http/https)
|
|
725
|
+
link = client.links.create(url: "https://example.com/spring-sale?utm_source=sms")
|
|
726
|
+
puts link.short_url # => "https://sendly.live/l/Ab3xY7"
|
|
727
|
+
puts link.code # => "Ab3xY7"
|
|
728
|
+
puts link.destination_url # => "https://example.com/spring-sale?utm_source=sms"
|
|
729
|
+
|
|
730
|
+
# List your links with click counts (limit 1-200, default 50)
|
|
731
|
+
listing = client.links.list(limit: 20)
|
|
732
|
+
puts listing.total
|
|
733
|
+
listing.each do |l|
|
|
734
|
+
puts "#{l.short_url} -> #{l.destination_url} (#{l.click_count} clicks)"
|
|
735
|
+
puts " last click: #{l.last_country} #{l.last_clicked_at}"
|
|
736
|
+
puts " 14-day spark: #{l.spark.inspect}"
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# Disable (redirect returns 404) or re-enable a link
|
|
740
|
+
client.links.disable(link.code)
|
|
741
|
+
client.links.enable(link.code)
|
|
742
|
+
|
|
743
|
+
# Or set the state explicitly
|
|
744
|
+
status = client.links.update(link.code, disabled: true)
|
|
745
|
+
puts status.disabled?
|
|
333
746
|
```
|
|
334
747
|
|
|
335
748
|
## Error Handling
|
|
@@ -95,6 +95,37 @@ module Sendly
|
|
|
95
95
|
@client.delete("/account/keys/#{key_id}")
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
# Rotate an API key.
|
|
99
|
+
#
|
|
100
|
+
# Issues a new key that inherits the old key's scopes and type, and puts the
|
|
101
|
+
# old key into a grace period (default 24 hours; 24-168 inclusive) during
|
|
102
|
+
# which BOTH keys work — deploy the new key, then let the old one expire. The
|
|
103
|
+
# new key's raw secret is returned only once, at
|
|
104
|
+
# +result["newKey"]["key"]+; store it now.
|
|
105
|
+
#
|
|
106
|
+
# @param key_id [String] ID of the API key to rotate
|
|
107
|
+
# @param grace_period_hours [Integer, nil] Hours the old key stays valid
|
|
108
|
+
# (24-168 inclusive; the API defaults to 24 when omitted)
|
|
109
|
+
# @return [Hash] +{ "newKey" => { ..., "key" => "sk_...", "warning" => ... }, "oldKey" => { ... }, "message" => ... }+
|
|
110
|
+
#
|
|
111
|
+
# @raise [ArgumentError] If key_id is missing
|
|
112
|
+
# @raise [Sendly::ValidationError] If grace_period_hours is outside 24-168, or the key
|
|
113
|
+
# cannot be rotated (inactive/revoked/already-rotating/predecessor still in grace)
|
|
114
|
+
# @raise [Sendly::NotFoundError] If the key does not exist
|
|
115
|
+
#
|
|
116
|
+
# @example
|
|
117
|
+
# result = client.account.rotate_api_key('key_xxx', grace_period_hours: 72)
|
|
118
|
+
# puts result['newKey']['key'] # Save this — shown once!
|
|
119
|
+
# puts result['message'] # "Old key will expire in 72 hours"
|
|
120
|
+
def rotate_api_key(key_id, grace_period_hours: nil)
|
|
121
|
+
raise ArgumentError, "API key ID is required" if key_id.nil? || key_id.empty?
|
|
122
|
+
|
|
123
|
+
body = {}
|
|
124
|
+
body[:gracePeriodHours] = grace_period_hours unless grace_period_hours.nil?
|
|
125
|
+
|
|
126
|
+
@client.post("/account/keys/#{key_id}/rotate", body)
|
|
127
|
+
end
|
|
128
|
+
|
|
98
129
|
def transfer_credits(target_organization_id:, amount:)
|
|
99
130
|
raise ArgumentError, "Target organization ID is required" if target_organization_id.nil? || target_organization_id.empty?
|
|
100
131
|
raise ArgumentError, "Amount must be a positive integer" if !amount.is_a?(Integer) || amount <= 0
|
data/lib/sendly/client.rb
CHANGED
|
@@ -164,6 +164,20 @@ module Sendly
|
|
|
164
164
|
@numbers ||= NumbersResource.new(self)
|
|
165
165
|
end
|
|
166
166
|
|
|
167
|
+
# Access the 10DLC resource
|
|
168
|
+
#
|
|
169
|
+
# @return [Sendly::TenDlcResource]
|
|
170
|
+
def ten_dlc
|
|
171
|
+
@ten_dlc ||= TenDlcResource.new(self)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Access the Links resource (branded URL shortening)
|
|
175
|
+
#
|
|
176
|
+
# @return [Sendly::LinksResource]
|
|
177
|
+
def links
|
|
178
|
+
@links ||= LinksResource.new(self)
|
|
179
|
+
end
|
|
180
|
+
|
|
167
181
|
# Make a GET request
|
|
168
182
|
#
|
|
169
183
|
# @param path [String] API path
|
|
@@ -208,6 +222,35 @@ module Sendly
|
|
|
208
222
|
request(:delete, path)
|
|
209
223
|
end
|
|
210
224
|
|
|
225
|
+
# Make a GET request against the API origin, bypassing the +/api/v1+ base.
|
|
226
|
+
# Used by resources whose endpoints live at the origin (e.g. the URL
|
|
227
|
+
# shortener at +/api/links+).
|
|
228
|
+
#
|
|
229
|
+
# @param path [String] Origin-relative path (e.g. "/api/links")
|
|
230
|
+
# @param params [Hash] Query parameters
|
|
231
|
+
# @return [Hash] Response body
|
|
232
|
+
def get_unversioned(path, params = {})
|
|
233
|
+
request(:get, path, params: params, unversioned: true)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Make a POST request against the API origin, bypassing the +/api/v1+ base.
|
|
237
|
+
#
|
|
238
|
+
# @param path [String] Origin-relative path (e.g. "/api/links")
|
|
239
|
+
# @param body [Hash] Request body
|
|
240
|
+
# @return [Hash] Response body
|
|
241
|
+
def post_unversioned(path, body = {})
|
|
242
|
+
request(:post, path, body: body, unversioned: true)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Make a PATCH request against the API origin, bypassing the +/api/v1+ base.
|
|
246
|
+
#
|
|
247
|
+
# @param path [String] Origin-relative path (e.g. "/api/links/:code")
|
|
248
|
+
# @param body [Hash] Request body
|
|
249
|
+
# @return [Hash] Response body
|
|
250
|
+
def patch_unversioned(path, body = {})
|
|
251
|
+
request(:patch, path, body: body, unversioned: true)
|
|
252
|
+
end
|
|
253
|
+
|
|
211
254
|
# Make a multipart POST request for file uploads
|
|
212
255
|
#
|
|
213
256
|
# @param path [String] API path
|
|
@@ -273,8 +316,8 @@ module Sendly
|
|
|
273
316
|
end
|
|
274
317
|
end
|
|
275
318
|
|
|
276
|
-
def request(method, path, params: {}, body: nil)
|
|
277
|
-
uri = build_uri(path, params)
|
|
319
|
+
def request(method, path, params: {}, body: nil, unversioned: false)
|
|
320
|
+
uri = build_uri(path, params, unversioned: unversioned)
|
|
278
321
|
http = build_http(uri)
|
|
279
322
|
req = build_request(method, uri, body)
|
|
280
323
|
|
|
@@ -303,8 +346,9 @@ module Sendly
|
|
|
303
346
|
end
|
|
304
347
|
end
|
|
305
348
|
|
|
306
|
-
def build_uri(path, params)
|
|
307
|
-
|
|
349
|
+
def build_uri(path, params, unversioned: false)
|
|
350
|
+
base = unversioned ? api_origin : base_url
|
|
351
|
+
url = "#{base}#{path}"
|
|
308
352
|
uri = URI.parse(url)
|
|
309
353
|
|
|
310
354
|
if params.any?
|
|
@@ -315,6 +359,15 @@ module Sendly
|
|
|
315
359
|
uri
|
|
316
360
|
end
|
|
317
361
|
|
|
362
|
+
# Derive the bare API origin (scheme + host [+ non-default port]) from the
|
|
363
|
+
# configured base URL, dropping its path. Origin-level endpoints such as
|
|
364
|
+
# the URL shortener at +/api/links+ hang off this, not the +/api/v1+ base.
|
|
365
|
+
def api_origin
|
|
366
|
+
uri = URI.parse(base_url)
|
|
367
|
+
port = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
|
|
368
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
369
|
+
end
|
|
370
|
+
|
|
318
371
|
def build_http(uri)
|
|
319
372
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
320
373
|
http.use_ssl = uri.scheme == "https"
|