Class: Sendly::Messages

Inherits:
Object
  • Object
show all
Defined in:
lib/sendly/messages.rb

Overview

Messages resource for sending and managing SMS

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client) ⇒ Messages

Returns a new instance of Messages.



9
10
11
# File 'lib/sendly/messages.rb', line 9

def initialize(client)
  @client = client
end

Instance Attribute Details

#clientSendly::Client (readonly)

Returns The API client.

Returns:



7
8
9
# File 'lib/sendly/messages.rb', line 7

def client
  @client
end

Instance Method Details

#cancel_scheduled(id) ⇒ Hash

Cancel a scheduled message

Examples:

result = client.messages.cancel_scheduled("sched_abc123")
puts "Refunded #{result['creditsRefunded']} credits"

Parameters:

  • id (String)

    Scheduled message ID

Returns:

  • (Hash)

    The cancelled message with refund details

Raises:



434
435
436
437
438
439
# File 'lib/sendly/messages.rb', line 434

def cancel_scheduled(id)
  raise ValidationError, "Scheduled message ID is required" if id.nil? || id.empty?

  encoded_id = URI.encode_www_form_component(id)
  client.delete("/messages/scheduled/#{encoded_id}")
end

#each(status: nil, to: nil, batch_size: 100) {|Message| ... } ⇒ Enumerator

Iterate over all messages with automatic pagination

Examples:

client.messages.each do |message|
  puts "#{message.id}: #{message.to}"
end

Parameters:

  • status (String) (defaults to: nil)

    Filter by status

  • to (String) (defaults to: nil)

    Filter by recipient

  • batch_size (Integer) (defaults to: 100)

    Number of messages per request

Yields:

Returns:

  • (Enumerator)

    If no block given



338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/sendly/messages.rb', line 338

def each(status: nil, to: nil, batch_size: 100, &block)
  return enum_for(:each, status: status, to: to, batch_size: batch_size) unless block_given?

  offset = 0
  loop do
    page = list(limit: batch_size, offset: offset, status: status, to: to)
    page.each(&block)

    break unless page.has_more

    offset += batch_size
  end
end

#enhance(text: nil, message_type: nil) ⇒ Sendly::EnhancedMessage

AI-enhance a draft message for clarity, compliance, and send-readiness

Rewrites the supplied text into a single, polished SMS segment (<=160 chars) and returns a short explanation of what changed. Pass message_type to steer the rewrite (e.g. "marketing" vs "transactional"); with no text it generates a suitable message for that type instead. At least one of text or message_type is required. Requires the ai_classification feature. When AI enhancement is unavailable, the response falls back to the original text with an empty explanation.

Examples:

result = client.messages.enhance(
  text: "hey come check out our sale this weekend",
  message_type: "marketing"
)
puts result.enhanced     # polished, <=160-char rewrite
puts result.explanation  # what changed and why

Parameters:

  • text (String) (defaults to: nil)

    Draft message text to rewrite (optional if message_type given)

  • message_type (String) (defaults to: nil)

    Message-type hint, e.g. "marketing" or "transactional"

Returns:

Raises:



264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/sendly/messages.rb', line 264

def enhance(text: nil, message_type: nil)
  if (text.nil? || text.to_s.empty?) && (message_type.nil? || message_type.to_s.empty?)
    raise ValidationError, "Provide 'text' or 'message_type'"
  end

  body = {}
  body[:text] = text unless text.nil?
  body[:messageType] = message_type if message_type

  response = client.post("/ai/enhance", body)
  EnhancedMessage.new(response)
end

#get(id) ⇒ Sendly::Message

Get a message by ID

Examples:

message = client.messages.get("msg_abc123")
puts message.status

Parameters:

  • id (String)

    Message ID

Returns:

Raises:



316
317
318
319
320
321
322
323
324
# File 'lib/sendly/messages.rb', line 316

def get(id)
  raise ValidationError, "Message ID is required" if id.nil? || id.empty?

  # URL encode the ID to prevent path injection
  encoded_id = URI.encode_www_form_component(id)
  response = client.get("/messages/#{encoded_id}")
  # API returns message directly at top level
  Message.new(response)
end

#get_batch(batch_id) ⇒ Hash

Get batch status by ID

Examples:

batch = client.messages.get_batch("batch_abc123")
puts "#{batch['sent']}/#{batch['total']} sent"

Parameters:

  • batch_id (String)

    Batch ID

Returns:

  • (Hash)

    Batch status and details

Raises:



497
498
499
500
501
502
# File 'lib/sendly/messages.rb', line 497

def get_batch(batch_id)
  raise ValidationError, "Batch ID is required" if batch_id.nil? || batch_id.empty?

  encoded_id = URI.encode_www_form_component(batch_id)
  client.get("/messages/batch/#{encoded_id}")
end

#get_scheduled(id) ⇒ Hash

Get a scheduled message by ID

Examples:

scheduled = client.messages.get_scheduled("sched_abc123")
puts scheduled["status"]

Parameters:

  • id (String)

    Scheduled message ID

Returns:

  • (Hash)

    The scheduled message

Raises:



416
417
418
419
420
421
# File 'lib/sendly/messages.rb', line 416

def get_scheduled(id)
  raise ValidationError, "Scheduled message ID is required" if id.nil? || id.empty?

  encoded_id = URI.encode_www_form_component(id)
  client.get("/messages/scheduled/#{encoded_id}")
end

#list(limit: 20, offset: 0, status: nil, to: nil) ⇒ Sendly::MessageList

List messages

Examples:

messages = client.messages.list(limit: 50)
messages.each { |m| puts m.to }

With filters

messages = client.messages.list(
  status: "delivered",
  to: "+15551234567"
)

Parameters:

  • limit (Integer) (defaults to: 20)

    Maximum messages to return (default: 20, max: 100)

  • offset (Integer) (defaults to: 0)

    Number of messages to skip

  • status (String) (defaults to: nil)

    Filter by status

  • to (String) (defaults to: nil)

    Filter by recipient

Returns:



294
295
296
297
298
299
300
301
302
303
304
# File 'lib/sendly/messages.rb', line 294

def list(limit: 20, offset: 0, status: nil, to: nil)
  params = {
    limit: [limit, 100].min,
    offset: offset
  }
  params[:status] = status if status
  params[:to] = to if to

  response = client.get("/messages", params.compact)
  MessageList.new(response)
end

#list_batches(limit: 20, offset: 0, status: nil) ⇒ Hash

List batches

Examples:

batches = client.messages.list_batches(limit: 10)
batches["data"].each { |b| puts "#{b['batchId']}: #{b['status']}" }

Parameters:

  • limit (Integer) (defaults to: 20)

    Maximum batches to return (default: 20, max: 100)

  • offset (Integer) (defaults to: 0)

    Number of batches to skip

  • status (String) (defaults to: nil)

    Filter by status (processing, completed, failed)

Returns:

  • (Hash)

    Paginated list of batches



514
515
516
517
518
519
520
521
522
# File 'lib/sendly/messages.rb', line 514

def list_batches(limit: 20, offset: 0, status: nil)
  params = {
    limit: [limit, 100].min,
    offset: offset
  }
  params[:status] = status if status

  client.get("/messages/batches", params.compact)
end

#list_scheduled(limit: 20, offset: 0, status: nil) ⇒ Hash

List scheduled messages

Examples:

scheduled = client.messages.list_scheduled(limit: 50)
scheduled["data"].each { |m| puts m["scheduledAt"] }

Parameters:

  • limit (Integer) (defaults to: 20)

    Maximum messages to return (default: 20, max: 100)

  • offset (Integer) (defaults to: 0)

    Number of messages to skip

  • status (String) (defaults to: nil)

    Filter by status (scheduled, sent, cancelled, failed)

Returns:

  • (Hash)

    Paginated list of scheduled messages



396
397
398
399
400
401
402
403
404
# File 'lib/sendly/messages.rb', line 396

def list_scheduled(limit: 20, offset: 0, status: nil)
  params = {
    limit: [limit, 100].min,
    offset: offset
  }
  params[:status] = status if status

  client.get("/messages/scheduled", params.compact)
end

#preview_batch(messages:, from: nil, message_type: nil) ⇒ Hash

Preview a batch without sending (dry run)

Examples:

preview = client.messages.preview_batch(
  messages: [
    { to: "+15551234567", text: "Hello Alice!" },
    { to: "+15559876543", text: "Hello Bob!" }
  ]
)
puts "Can send: #{preview['canSend']}"
puts "Credits needed: #{preview['creditsNeeded']}"

Parameters:

  • messages (Array<Hash>)

    Array of messages with :to and :text keys

  • from (String) (defaults to: nil)

    Sender ID or phone number (optional, applies to all)

  • message_type (String) (defaults to: nil)

    Message type: "marketing" (default) or "transactional"

Returns:

  • (Hash)

    Preview showing what would happen if batch was sent

Raises:



542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
# File 'lib/sendly/messages.rb', line 542

def preview_batch(messages:, from: nil, message_type: nil)
  raise ValidationError, "Messages array is required" if messages.nil? || messages.empty?

  messages.each_with_index do |msg, i|
    raise ValidationError, "Message at index #{i} missing 'to'" unless msg[:to] || msg["to"]
    raise ValidationError, "Message at index #{i} missing 'text'" unless msg[:text] || msg["text"]

    to = msg[:to] || msg["to"]
    text = msg[:text] || msg["text"]
    validate_phone!(to)
    validate_text!(text)
  end

  body = { messages: messages }
  body[:from] = from if from
  body[:messageType] = message_type if message_type

  client.post("/messages/batch/preview", body)
end

#schedule(to:, text:, scheduled_at:, from: nil, message_type: nil, metadata: nil, idempotency_key: nil) ⇒ Hash

Schedule an SMS message for future delivery

Examples:

scheduled = client.messages.schedule(
  to: "+15551234567",
  text: "Reminder: Your appointment is tomorrow!",
  scheduled_at: "2025-01-20T10:00:00Z"
)
puts scheduled["id"]

Parameters:

  • to (String)

    Recipient phone number in E.164 format

  • text (String)

    Message content (max 1600 characters)

  • scheduled_at (String)

    ISO 8601 datetime for when to send

  • from (String) (defaults to: nil)

    Sender ID or phone number (optional)

  • message_type (String) (defaults to: nil)

    Message type: "marketing" (default) or "transactional"

  • metadata (Hash) (defaults to: nil)

    Custom JSON metadata to attach to the message (max 4KB)

  • idempotency_key (String) (defaults to: nil)

    Idempotency key for this operation (1-255 printable ASCII characters, optional)

Returns:

  • (Hash)

    The scheduled message

Raises:



373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/sendly/messages.rb', line 373

def schedule(to:, text:, scheduled_at:, from: nil, message_type: nil, metadata: nil, idempotency_key: nil)
  validate_phone!(to)
  validate_text!(text)
  raise ValidationError, "scheduled_at is required" if scheduled_at.nil? || scheduled_at.empty?

  body = { to: to, text: text, scheduledAt: scheduled_at }
  body[:from] = from if from
  body[:messageType] = message_type if message_type
  body[:metadata] =  if 

  client.post("/messages/schedule", body, idempotency_key: idempotency_key)
end

#send(to:, text: nil, from: nil, message_type: nil, metadata: nil, media_urls: nil, channel: nil, template: nil, agent_id: nil, card: nil, suggestions: nil, fallback_to_sms: nil, idempotency_key: nil) ⇒ Sendly::Message, ...

Send an SMS, WhatsApp, or RCS message

Pass channel: "whatsapp" to send on WhatsApp. WhatsApp sends require a live API key and a from number with an active WhatsApp connection (see client.whatsapp.signup). Provide exactly one of text (free-form, max 4096 bytes), media_urls (a single attachment; optional text becomes its caption, max 1024 bytes), or template (an approved template). Free-form text and media only deliver inside an open 24-hour customer-service window — outside it the API responds 422 whatsapp_window_closed; send a template instead (check with client.whatsapp.window).

Pass channel: "rcs" to send on RCS. RCS sends require a live API key and a sendable RCS agent on your workspace (see client.rcs.agents). Provide exactly one of text (free-form, optionally with suggestions — suggested replies and URL actions) or card (a rich card). agent_id picks the sending agent when your workspace has more than one. Recipients that can't receive RCS get text delivered as plain SMS (billed as SMS) unless fallback_to_sms is false — the returned RcsMessage discloses which channel delivered.

Examples:

message = client.messages.send(
  to: "+15551234567",
  text: "Hello from Sendly!"
)
puts message.id
puts message.status

Transactional message (bypasses quiet hours)

message = client.messages.send(
  to: "+15551234567",
  text: "Your verification code is 123456",
  message_type: "transactional"
)

WhatsApp free-form reply inside an open 24h window

message = client.messages.send(
  channel: "whatsapp",
  to: "+15551234567",
  from: "+15559876543",
  text: "Your table is ready!"
)

WhatsApp template send — works regardless of the window

message = client.messages.send(
  channel: "whatsapp",
  to: "+15551234567",
  from: "+15559876543",
  template: {
    name: "order_shipped",
    language: "en_US",
    variables: { "1" => "Acme Inc", "2" => "#4821" }
  }
)
puts message.whatsapp.kind   # "template"
puts message.credits_used    # priced by country + category

RCS text with suggested replies and actions

message = client.messages.send(
  channel: "rcs",
  to: "+15551234567",
  text: "Your order has shipped! Want live updates?",
  suggestions: [
    { reply: { text: "Yes, notify me", postbackData: "notify_yes" } },
    { action: { text: "Track order", postbackData: "track",
                url: "https://acme.example/orders/4821" } }
  ]
)
puts message.channel      # "rcs", or "sms" when it fell back
puts message.fell_back?   # true when delivered as plain SMS

RCS rich card (RCS-capable recipients only)

client.messages.send(
  channel: "rcs",
  to: "+15551234567",
  card: {
    title: "Spring collection",
    description: "New arrivals are in - take a look.",
    mediaUrl: "https://example.com/spring.jpg",
    orientation: "vertical"
  }
)

Parameters:

  • to (String)

    Recipient phone number in E.164 format

  • text (String) (defaults to: nil)

    Message content (max 1600 characters for SMS); for WhatsApp, optional free-form text or the media caption

  • from (String) (defaults to: nil)

    Sender ID or phone number (optional for SMS); required for WhatsApp — must be a WhatsApp-connected number

  • message_type (String) (defaults to: nil)

    Message type: "marketing" (default) or "transactional" (SMS only)

  • metadata (Hash) (defaults to: nil)

    Custom JSON metadata to attach to the message (max 4KB)

  • media_urls (Array<String>) (defaults to: nil)

    Media URLs to attach (WhatsApp accepts exactly one)

  • channel (String) (defaults to: nil)

    Message channel: omit (or "sms") for SMS, "whatsapp" for WhatsApp, "rcs" for RCS

  • template (Hash) (defaults to: nil)

    WhatsApp only: approved template to send, with :name, :language, and optional :variables ({ "1" => "Acme" }) and :buttons ([{ index: 0, variables: { "1" => "4821" } }]). Works regardless of the 24-hour window.

  • agent_id (String) (defaults to: nil)

    RCS only: the agent to send from. Optional when your workspace has exactly one sendable agent; required (the API responds 400 rcs_agent_ambiguous) when it has more.

  • card (Hash) (defaults to: nil)

    RCS only: a rich card, with :title, :description, and optional :mediaUrl (a public JPEG, PNG, or GIF), :orientation ("vertical" or "horizontal"), and :suggestions. Passed through verbatim, so use the camelCase keys shown. Cards have no SMS form and only deliver to RCS-capable recipients.

  • suggestions (Array<Hash>) (defaults to: nil)

    RCS only, alongside text: suggested replies and actions, each either { reply: { text: ..., postbackData: ... } } or { action: { text: ..., postbackData: ..., url: ... } }. Passed through verbatim, so use the camelCase keys shown. Suggestions have no SMS form and are dropped on a fallback.

  • fallback_to_sms (Boolean) (defaults to: nil)

    RCS only: deliver text as plain SMS when the recipient can't receive RCS (default true). Pass false to fail with 422 rcs_not_supported_for_recipient instead.

  • idempotency_key (String) (defaults to: nil)

    Idempotency key for this operation (1-255 printable ASCII characters). The SDK already generates a key per logical request automatically, so the server can dedupe the SDK's own retries. Supply your own key when you need idempotency across process restarts or your own retry loops — repeating a request with the same key within 24 hours returns the original response instead of executing again.

Returns:

Raises:



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/sendly/messages.rb', line 140

def send(to:, text: nil, from: nil, message_type: nil, metadata: nil, media_urls: nil,
         channel: nil, template: nil, agent_id: nil, card: nil, suggestions: nil,
         fallback_to_sms: nil, idempotency_key: nil)
  validate_phone!(to)

  if channel.to_s == "rcs"
    has_text = !(text.nil? || text.to_s.empty?)
    has_card = !card.nil?
    raise ValidationError, "Provide exactly one of 'text' or 'card'" if has_text == has_card

    body = { channel: "rcs", to: to }
    body[:agentId] = agent_id if agent_id
    body[:text] = text if has_text
    body[:card] = card if has_card
    body[:suggestions] = suggestions if suggestions
    body[:fallbackToSms] = fallback_to_sms unless fallback_to_sms.nil?
    body[:metadata] =  if 

    response = client.post("/messages", body, idempotency_key: idempotency_key)
    return RcsMessage.new(response)
  end

  if channel.to_s == "whatsapp"
    validate_phone!(from)
    has_media = media_urls.is_a?(Array) && !media_urls.empty?
    if (text.nil? || text.empty?) && !has_media && template.nil?
      raise ValidationError, "Provide 'text', 'media_urls', or 'template'"
    end

    body = { channel: "whatsapp", to: to, from: from }
    body[:text] = text unless text.nil?
    body[:mediaUrls] = media_urls if has_media
    body[:template] = template if template
    body[:metadata] =  if 

    response = client.post("/messages", body, idempotency_key: idempotency_key)
    return WhatsAppMessage.new(response)
  end

  validate_text!(text)

  body = { to: to, text: text }
  body[:from] = from if from
  body[:messageType] = message_type if message_type
  body[:metadata] =  if 
  body[:mediaUrls] = media_urls if media_urls

  response = client.post("/messages", body, idempotency_key: idempotency_key)
  # API returns message directly at top level
  Message.new(response)
end

#send_batch(messages:, from: nil, message_type: nil, metadata: nil, idempotency_key: nil) ⇒ Hash

Send multiple SMS messages in a batch

Examples:

result = client.messages.send_batch(
  messages: [
    { to: "+15551234567", text: "Hello Alice!" },
    { to: "+15559876543", text: "Hello Bob!" }
  ]
)
puts "Batch #{result['batchId']}: #{result['queued']} queued"

Parameters:

  • messages (Array<Hash>)

    Array of messages with :to and :text keys

  • from (String) (defaults to: nil)

    Sender ID or phone number (optional, applies to all)

  • message_type (String) (defaults to: nil)

    Message type: "marketing" (default) or "transactional"

  • metadata (Hash) (defaults to: nil)

    Shared metadata for all messages (max 4KB). Each message can also have its own metadata hash which takes priority.

  • idempotency_key (String) (defaults to: nil)

    Idempotency key for this operation (1-255 printable ASCII characters, optional)

Returns:

  • (Hash)

    Batch response with batch_id and status

Raises:



462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# File 'lib/sendly/messages.rb', line 462

def send_batch(messages:, from: nil, message_type: nil, metadata: nil, idempotency_key: nil)
  raise ValidationError, "Messages array is required" if messages.nil? || messages.empty?

  messages.each_with_index do |msg, i|
    raise ValidationError, "Message at index #{i} missing 'to'" unless msg[:to] || msg["to"]
    raise ValidationError, "Message at index #{i} missing 'text'" unless msg[:text] || msg["text"]

    to = msg[:to] || msg["to"]
    text = msg[:text] || msg["text"]
    validate_phone!(to)
    validate_text!(text)
  end

  body = { messages: messages }
  body[:from] = from if from
  body[:messageType] = message_type if message_type
  body[:metadata] =  if 

  # The batch endpoint dedupes header-less retries server-side by hashing
  # the request content; an auto-generated key would bypass that net for
  # identical cross-process re-runs, so only caller-supplied keys are sent.
  client.post("/messages/batch", body, idempotency_key: idempotency_key,
                                       auto_idempotency_key: false)
end

#send_group(to:, text: nil, from: nil, media_urls: nil, message_type: nil, idempotency_key: nil) ⇒ Sendly::GroupMessage

Send a group MMS to 2-8 recipients (US/Canada only)

Creates a multi-party MMS conversation: every recipient sees the others, and replies fan out to all participants. Group messaging is an A2P 10DLC capability — the sending number must be an MMS-enabled, 10DLC-registered number you own. Omit from to use your workspace's default sender. Requires the group_mms feature (and enable_mms when sending media).

Examples:

group = client.messages.send_group(
  to: ["+14155551234", "+14155555678"],
  text: "Hey team - quick sync at noon?"
)
puts group.id
puts group.group_message_id

Parameters:

  • to (Array<String>)

    2-8 recipient phone numbers in E.164 format (US/CA only)

  • text (String) (defaults to: nil)

    Message content (required unless media_urls is provided)

  • from (String) (defaults to: nil)

    Sender ID or phone number (optional)

  • media_urls (Array<String>) (defaults to: nil)

    Media URLs to attach (required unless text is provided)

  • message_type (String) (defaults to: nil)

    Message type: "transactional" (default) or "marketing"

  • idempotency_key (String) (defaults to: nil)

    Idempotency key for this operation (1-255 printable ASCII characters, optional)

Returns:

Raises:



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/sendly/messages.rb', line 219

def send_group(to:, text: nil, from: nil, media_urls: nil, message_type: nil, idempotency_key: nil)
  unless to.is_a?(Array) && to.length >= 2
    raise ValidationError, "Group messaging requires at least 2 recipients in 'to'"
  end
  raise ValidationError, "Group messaging supports at most 8 recipients" if to.length > 8

  to.each { |recipient| validate_phone!(recipient) }

  has_media = media_urls.is_a?(Array) && !media_urls.empty?
  raise ValidationError, "Provide 'text' or 'media_urls'" if (text.nil? || text.empty?) && !has_media

  body = { to: to }
  body[:text] = text if text && !text.empty?
  body[:from] = from if from
  body[:mediaUrls] = media_urls if has_media
  body[:messageType] = message_type if message_type

  response = client.post("/messages/group", body, idempotency_key: idempotency_key)
  GroupMessage.new(response)
end