sendly 3.34.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71c3ad356b4aa2903e05f1f174ce9a70f1fb1fe5cc4b0748ae69db333a0c25ac
4
- data.tar.gz: 86df6410241d17dc84854057d19b2b13e6f32f504bf87243e276ec9260f78a34
3
+ metadata.gz: 43ce471688134e41cbed23f9d6b8bcc82895c867a4f42340120f670424d5a5f7
4
+ data.tar.gz: 628de06e8a2f2a25fc4837c94cb3bb651dc877f6d27ad8967af8b732d97f0a30
5
5
  SHA512:
6
- metadata.gz: 385c9b3f1973a51b923fed18d0b23a128c060d0d122ed782e08f3a21c1477ece2caca8aaeba65c71c8c494946630ffe21e9ac12eade63530ea207de10dfa0661
7
- data.tar.gz: 6e274e7b40280d52118812b61ffa31b936d912baa0147027a448c7156d110ed24e4c3c0ba70f2a589bae3d0b3531316761a80fe2864fa821415002e0d3741f01
6
+ metadata.gz: a012fb951c1c7e1f000e83e031d8c35eb3145bcdef3ce7ddc2eacc400263dae269223c4631e12f2db621ea0aa3a887142c18614773fcb5f577eee09bda0a1d92
7
+ data.tar.gz: f01118db4ee6fd181b148a2d2c1d7ea551b14c2e748cc0d01cf16cf5e097ba5718e53b943423ded434c071f17f2f27adbb63ed5064312c2d5ddebcdb0b22fa0e
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (3.34.0)
4
+ sendly (3.37.0)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
 
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
- url = "#{base_url}#{path}"
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"