sendly 3.40.0 → 4.1.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.
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendly
4
+ # One line of what was said on an agent-handled call. +speaker+ is
5
+ # "caller" or "agent"; +at_ms+ is the offset from the start of the call.
6
+ class CallTranscriptLine
7
+ SPEAKERS = %w[caller agent].freeze
8
+
9
+ attr_reader :speaker, :text, :at_ms
10
+
11
+ def initialize(data)
12
+ data ||= {}
13
+ @speaker = data["speaker"]
14
+ @text = data["text"]
15
+ @at_ms = data["atMs"] || data["at_ms"]
16
+ end
17
+
18
+ def to_h
19
+ { speaker: speaker, text: text, at_ms: at_ms }.compact
20
+ end
21
+ end
22
+
23
+ # A phone call placed or received by one of your workspace's numbers.
24
+ #
25
+ # +kind+ is "pstn" for a phone call and "internal" for a browser-to-browser
26
+ # call between teammates. +status+ is "ringing" or "active" while the call
27
+ # is live and one of the terminal values ("completed", "no_answer", "busy",
28
+ # "cancelled", "declined", "failed") once it has ended; "suspended" can
29
+ # appear on an internal call whose media dropped and may recover.
30
+ # +handled_by+ says whether an AI agent ("agent") or the team in the
31
+ # dashboard ("dashboard") took the call. +billing+ is "metered" while a
32
+ # phone call is charged per started minute, "settled" once it has ended,
33
+ # and "unbilled" for calls that are never charged. +hangup_class+ says why
34
+ # the call ended (see {HANGUP_CLASSES}); anything unrecognised arrives as
35
+ # "ended". +metadata+ is the string map attached on create (+{}+ when
36
+ # none). +transcript+ is only present on {CallsResource#get} for
37
+ # agent-handled calls and is +nil+ otherwise.
38
+ class Call
39
+ STATUSES = %w[ringing active completed no_answer busy cancelled declined failed suspended].freeze
40
+ LIVE_STATUSES = %w[ringing active].freeze
41
+ DIRECTIONS = %w[inbound outbound].freeze
42
+ KINDS = %w[pstn internal].freeze
43
+ HANDLED_BY = %w[agent dashboard].freeze
44
+ BILLING_STATES = %w[metered settled unbilled].freeze
45
+ RECORDING_STATUSES = %w[recording ready failed].freeze
46
+ HANGUP_CLASSES = %w[
47
+ normal caller_hung_up callee_hung_up caller_left peer_left agent_ended
48
+ agent_agent_hangup agent_caller_left
49
+ ring_timeout callee_declined callee_busy caller_cancelled room_closed_unanswered
50
+ agent_left_unanswered agent_caller_never_joined invalid_number destination_rejected
51
+ max_duration credits_exhausted media_aborted peer_connection_lost room_closed agent_left
52
+ setup_failed agent_dispatch_failed agent_api_unreachable agent_already_ended
53
+ ended
54
+ ].freeze
55
+ ERROR_CODES = %w[
56
+ voice_unavailable agents_unavailable voice_not_enabled outbound_calls_not_enabled
57
+ agent_required agent_not_found agent_disabled invalid_metadata from_number_required
58
+ no_voice_number number_not_found destination_not_supported e911_required lines_busy
59
+ daily_call_limit call_not_found live_key_required voice_internal_error
60
+ insufficient_credits invalid_number rate_limit_exceeded forbidden
61
+ ].freeze
62
+
63
+ attr_reader :id, :object, :kind, :direction, :status, :handled_by, :agent_id,
64
+ :from, :to, :caller_name, :callee_name, :started_at, :answered_at,
65
+ :ended_at, :duration_secs, :credits_charged, :billing, :hangup_class,
66
+ :recording_status, :metadata, :transcript
67
+
68
+ # @return [Hash] The raw parsed response
69
+ attr_reader :raw
70
+
71
+ def initialize(data)
72
+ data ||= {}
73
+ @raw = data
74
+ @id = data["id"]
75
+ @object = data["object"] || "call"
76
+ @kind = data["kind"]
77
+ @direction = data["direction"]
78
+ @status = data["status"]
79
+ @handled_by = data["handledBy"] || data["handled_by"]
80
+ @agent_id = data["agentId"] || data["agent_id"]
81
+ @from = data["from"]
82
+ @to = data["to"]
83
+ @caller_name = data["callerName"] || data["caller_name"]
84
+ @callee_name = data["calleeName"] || data["callee_name"]
85
+ @started_at = data["startedAt"] || data["started_at"]
86
+ @answered_at = data["answeredAt"] || data["answered_at"]
87
+ @ended_at = data["endedAt"] || data["ended_at"]
88
+ @duration_secs = data["durationSecs"] || data["duration_secs"] || 0
89
+ @credits_charged = data["creditsCharged"] || data["credits_charged"] || 0
90
+ @billing = data["billing"]
91
+ @hangup_class = data["hangupClass"] || data["hangup_class"]
92
+ @recording_status = data["recordingStatus"] || data["recording_status"]
93
+ @metadata = data["metadata"] || {}
94
+ lines = data["transcript"]
95
+ @transcript = lines.is_a?(Array) ? lines.map { |l| CallTranscriptLine.new(l) } : nil
96
+ end
97
+
98
+ # @return [Boolean] Whether the call is still ringing or in progress
99
+ def live?
100
+ LIVE_STATUSES.include?(status)
101
+ end
102
+
103
+ # @return [Boolean] Whether the call has reached a terminal status
104
+ def ended?
105
+ !status.nil? && !live? && status != "suspended"
106
+ end
107
+
108
+ def answered?
109
+ !answered_at.nil?
110
+ end
111
+
112
+ def agent_handled?
113
+ handled_by == "agent"
114
+ end
115
+
116
+ def inbound?
117
+ direction == "inbound"
118
+ end
119
+
120
+ def outbound?
121
+ direction == "outbound"
122
+ end
123
+
124
+ def to_h
125
+ {
126
+ id: id, object: object, kind: kind, direction: direction, status: status,
127
+ handled_by: handled_by, agent_id: agent_id, from: from, to: to,
128
+ caller_name: caller_name, callee_name: callee_name, started_at: started_at,
129
+ answered_at: answered_at, ended_at: ended_at, duration_secs: duration_secs,
130
+ credits_charged: credits_charged, billing: billing, hangup_class: hangup_class,
131
+ recording_status: recording_status, metadata: metadata,
132
+ transcript: transcript&.map(&:to_h)
133
+ }.compact
134
+ end
135
+ end
136
+
137
+ # A page of calls, newest first, with the pagination the API returned.
138
+ class CallList
139
+ include Enumerable
140
+
141
+ attr_reader :data, :total, :limit, :offset, :has_more
142
+
143
+ def initialize(response)
144
+ @data = (response["data"] || []).map { |c| Call.new(c) }
145
+ pagination = response["pagination"] || {}
146
+ @total = pagination["total"] || @data.length
147
+ @limit = pagination["limit"] || 50
148
+ @offset = pagination["offset"] || 0
149
+ @has_more = pagination["hasMore"] || pagination["has_more"] || false
150
+ end
151
+
152
+ def has_more?
153
+ has_more == true
154
+ end
155
+
156
+ def each(&block)
157
+ data.each(&block)
158
+ end
159
+
160
+ def count
161
+ data.length
162
+ end
163
+
164
+ alias size count
165
+ alias length count
166
+
167
+ def empty?
168
+ data.empty?
169
+ end
170
+
171
+ def first
172
+ data.first
173
+ end
174
+
175
+ def last
176
+ data.last
177
+ end
178
+ end
179
+
180
+ # The recording of a call. +status+ is "none" when there is no recording
181
+ # (recording off, or the call was never answered), "recording" while the
182
+ # call runs, "ready" once it can be fetched, or "failed". +url+ and
183
+ # +expires_at+ are set only when {#ready?}: the URL is signed and valid for
184
+ # five minutes. Recordings are Ogg/Opus (+content_type+ "audio/ogg");
185
+ # agent-handled calls are recorded dual-channel, caller left and agent
186
+ # right.
187
+ class CallRecording
188
+ STATUSES = %w[none recording ready failed].freeze
189
+
190
+ attr_reader :call_id, :status, :url, :expires_at, :content_type
191
+
192
+ def initialize(data)
193
+ data ||= {}
194
+ @call_id = data["callId"] || data["call_id"]
195
+ @status = data["status"]
196
+ @url = data["url"]
197
+ @expires_at = data["expiresAt"] || data["expires_at"]
198
+ @content_type = data["contentType"] || data["content_type"]
199
+ end
200
+
201
+ def ready?
202
+ status == "ready"
203
+ end
204
+
205
+ def to_h
206
+ {
207
+ call_id: call_id, status: status, url: url, expires_at: expires_at,
208
+ content_type: content_type
209
+ }.compact
210
+ end
211
+ end
212
+
213
+ # Calls resource: place phone calls handled by your AI agents, list and
214
+ # inspect calls, end a call and fetch recordings.
215
+ #
216
+ # A call placed over the API is answered by one of the AI agents you
217
+ # configure in the dashboard under Calls, then Agents; the +from+ number
218
+ # must have voice switched on in the dashboard. Calls are charged per
219
+ # started minute from your credit balance: 2 credits a minute outbound,
220
+ # plus 8 a minute while an agent is on the call. Destinations are US and
221
+ # Canadian numbers. Reads need the +calls:read+ scope, writes
222
+ # +calls:write+ and a live API key (+sk_live_v1_xxx+).
223
+ #
224
+ # Voice is enabled workspace by workspace. Until it is on for yours,
225
+ # every method here raises {Sendly::NotFoundError} (+voice_not_enabled+).
226
+ #
227
+ # Error codes map onto the usual classes: {Sendly::NotFoundError} for
228
+ # +voice_not_enabled+, +outbound_calls_not_enabled+, +agent_not_found+,
229
+ # +number_not_found+ and +call_not_found+; {Sendly::ValidationError} for
230
+ # +invalid_number+, +destination_not_supported+, +agent_required+,
231
+ # +invalid_metadata+ and +from_number_required+;
232
+ # {Sendly::InsufficientCreditsError} for +insufficient_credits+;
233
+ # {Sendly::RateLimitError} for +daily_call_limit+ and
234
+ # +rate_limit_exceeded+; {Sendly::APIError} with the HTTP status for
235
+ # +e911_required+ (428), +agent_disabled+ / +no_voice_number+ /
236
+ # +lines_busy+ (409) and +live_key_required+ / +forbidden+ (403); and
237
+ # {Sendly::ServerError} (not an +APIError+) for +voice_unavailable+ /
238
+ # +agents_unavailable+ (503) and +voice_internal_error+ (500). The full
239
+ # list is {Call::ERROR_CODES}.
240
+ #
241
+ # @example Place a call and wait for it to end
242
+ # call = client.calls.create(
243
+ # to: "+15555550123",
244
+ # agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
245
+ # context: "Confirm the 3pm appointment on Tuesday."
246
+ # )
247
+ # call = client.calls.get(call.id) while call.live? && sleep(2)
248
+ # puts call.hangup_class
249
+ # call.transcript.each { |line| puts "#{line.speaker}: #{line.text}" }
250
+ class CallsResource
251
+ def initialize(client)
252
+ @client = client
253
+ end
254
+
255
+ # Place a phone call that one of your AI agents handles. Returns at once
256
+ # with the call +ringing+; poll {#get} or subscribe to the +call.started+
257
+ # and +call.completed+ webhooks to follow it. Requires the +calls:write+
258
+ # scope and a live key.
259
+ #
260
+ # @param to [String] The number to call, in E.164 format (US or Canada)
261
+ # @param agent_id [String] The AI agent that talks on the call
262
+ # @param from [String, nil] A voice-enabled number in your workspace.
263
+ # Optional when the workspace has exactly one; required (the API
264
+ # responds 400 +from_number_required+) when it has more.
265
+ # @param context [String, nil] Up to 2000 characters appended to the
266
+ # agent's instructions for this call only. Not echoed back.
267
+ # @param metadata [Hash{String => String}, nil] Up to 20 string pairs
268
+ # (keys 1-40 characters of +A-Z a-z 0-9 _ . : -+, values up to 500
269
+ # characters). Stored, echoed on every read and in every +call.*+
270
+ # webhook.
271
+ # @param idempotency_key [String, nil] Idempotency key for this operation
272
+ # @return [Sendly::Call] The new call (+status+ "ringing", +handled_by+ "agent")
273
+ # @raise [Sendly::ValidationError] If +to+ or +agent_id+ is missing, or
274
+ # HTTP 400 (+invalid_number+, +destination_not_supported+,
275
+ # +agent_required+, +invalid_metadata+, +from_number_required+)
276
+ # @raise [Sendly::NotFoundError] HTTP 404 (+voice_not_enabled+,
277
+ # +outbound_calls_not_enabled+, +agent_not_found+, +number_not_found+)
278
+ # @raise [Sendly::InsufficientCreditsError] HTTP 402 when the balance
279
+ # cannot cover one minute at the agent rate
280
+ # @raise [Sendly::APIError] HTTP 428 +e911_required+ (register an
281
+ # emergency address for the number first), 409 +agent_disabled+ /
282
+ # +no_voice_number+ / +lines_busy+, 403 +live_key_required+
283
+ # @raise [Sendly::RateLimitError] HTTP 429 +daily_call_limit+ / +rate_limit_exceeded+
284
+ # @raise [Sendly::ServerError] HTTP 503 +voice_unavailable+ /
285
+ # +agents_unavailable+ (calling is not switched on for this deployment),
286
+ # HTTP 500 +voice_internal_error+
287
+ #
288
+ # @example
289
+ # call = client.calls.create(
290
+ # to: "+15555550123",
291
+ # agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
292
+ # from: "+15555550188",
293
+ # metadata: { "crmId" => "lead_8812" }
294
+ # )
295
+ # puts call.id
296
+ def create(to:, agent_id:, from: nil, context: nil, metadata: nil, idempotency_key: nil)
297
+ raise ValidationError, "to is required" if to.nil? || to.to_s.empty?
298
+ raise ValidationError, "agent_id is required" if agent_id.nil? || agent_id.to_s.empty?
299
+
300
+ body = { to: to, agentId: agent_id }
301
+ body[:from] = from unless from.nil?
302
+ body[:context] = context unless context.nil?
303
+ body[:metadata] = metadata unless metadata.nil?
304
+
305
+ response = @client.post("/calls", body, idempotency_key: idempotency_key)
306
+ Call.new(response)
307
+ end
308
+
309
+ # List your workspace's calls, newest first. Live rows are reconciled
310
+ # before they are returned, so a ring past its deadline reads as
311
+ # +no_answer+. Requires the +calls:read+ scope.
312
+ #
313
+ # @param limit [Integer, nil] Calls per page (1-100, default 50)
314
+ # @param offset [Integer, nil] Calls to skip (default 0)
315
+ # @param status [String, nil] One of {Call::STATUSES}
316
+ # @param direction [String, nil] "inbound" or "outbound"
317
+ # @param kind [String, nil] "pstn" or "internal"
318
+ # @param agent_id [String, nil] Only calls handled by this agent
319
+ # @param to [String, nil] Exact E.164 match on the called number
320
+ # @param from [String, nil] Exact E.164 match on the calling number
321
+ # @return [Sendly::CallList] The page and its pagination
322
+ # @raise [Sendly::ValidationError] HTTP 400 +invalid_request+ for a value
323
+ # outside the vocabularies above
324
+ #
325
+ # @example
326
+ # page = client.calls.list(status: "completed", direction: "outbound", limit: 20)
327
+ # page.each { |c| puts "#{c.to} #{c.duration_secs}s #{c.credits_charged} credits" }
328
+ # puts page.has_more?
329
+ def list(limit: nil, offset: nil, status: nil, direction: nil, kind: nil,
330
+ agent_id: nil, to: nil, from: nil)
331
+ params = {}
332
+ params[:limit] = limit unless limit.nil?
333
+ params[:offset] = offset unless offset.nil?
334
+ params[:status] = status unless status.nil?
335
+ params[:direction] = direction unless direction.nil?
336
+ params[:kind] = kind unless kind.nil?
337
+ params[:agentId] = agent_id unless agent_id.nil?
338
+ params[:to] = to unless to.nil?
339
+ params[:from] = from unless from.nil?
340
+
341
+ response = @client.get("/calls", params)
342
+ CallList.new(response)
343
+ end
344
+
345
+ # Fetch one call. Agent-handled calls include their +transcript+; a live
346
+ # call is reconciled first. Requires the +calls:read+ scope.
347
+ #
348
+ # @param id [String] Call identifier
349
+ # @return [Sendly::Call]
350
+ # @raise [Sendly::NotFoundError] HTTP 404 +call_not_found+ when the call
351
+ # is not in your workspace
352
+ #
353
+ # @example
354
+ # call = client.calls.get("6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f")
355
+ # call.transcript&.each { |line| puts "#{line.speaker}: #{line.text}" }
356
+ def get(id)
357
+ encoded_id = encode_id!(id)
358
+ response = @client.get("/calls/#{encoded_id}")
359
+ Call.new(response)
360
+ end
361
+
362
+ # End a call. A ringing call becomes +cancelled+ (+hangup_class+
363
+ # "caller_cancelled") and the callee stops ringing; an active call
364
+ # becomes +completed+ ("normal"). Hanging up a call that has already
365
+ # ended returns it unchanged. Requires the +calls:write+ scope and a
366
+ # live key.
367
+ #
368
+ # @param id [String] Call identifier
369
+ # @param idempotency_key [String, nil] Idempotency key for this operation
370
+ # @return [Sendly::Call] The call after the hangup
371
+ # @raise [Sendly::NotFoundError] HTTP 404 +call_not_found+
372
+ #
373
+ # @example
374
+ # call = client.calls.hangup(call.id)
375
+ # puts call.status # "cancelled" or "completed"
376
+ def hangup(id, idempotency_key: nil)
377
+ encoded_id = encode_id!(id)
378
+ response = @client.post("/calls/#{encoded_id}/hangup", {}, idempotency_key: idempotency_key)
379
+ Call.new(response)
380
+ end
381
+
382
+ # Fetch the recording of a call. +url+ is a signed link valid for five
383
+ # minutes and is only set once the recording is +ready?+; fetch again
384
+ # for a fresh link. Requires the +calls:read+ scope.
385
+ #
386
+ # @param id [String] Call identifier
387
+ # @return [Sendly::CallRecording]
388
+ # @raise [Sendly::NotFoundError] HTTP 404 +call_not_found+
389
+ #
390
+ # @example
391
+ # rec = client.calls.recording(call.id)
392
+ # if rec.ready?
393
+ # File.binwrite("#{call.id}.ogg", Net::HTTP.get(URI(rec.url)))
394
+ # end
395
+ def recording(id)
396
+ encoded_id = encode_id!(id)
397
+ response = @client.get("/calls/#{encoded_id}/recording")
398
+ CallRecording.new(response)
399
+ end
400
+
401
+ private
402
+
403
+ def encode_id!(id)
404
+ raise ValidationError, "Call ID is required" if id.nil? || id.to_s.empty?
405
+
406
+ URI.encode_www_form_component(id)
407
+ end
408
+ end
409
+ end
@@ -131,7 +131,7 @@ module Sendly
131
131
  end
132
132
 
133
133
  def get(id)
134
- response = @client.get("/campaigns/#{id}")
134
+ response = @client.get("/campaigns/#{URI.encode_www_form_component(id)}")
135
135
  Campaign.new(response)
136
136
  end
137
137
 
@@ -142,21 +142,21 @@ module Sendly
142
142
  body[:templateId] = template_id unless template_id.nil?
143
143
  body[:contactListIds] = contact_list_ids if contact_list_ids
144
144
 
145
- response = @client.patch("/campaigns/#{id}", body)
145
+ response = @client.patch("/campaigns/#{URI.encode_www_form_component(id)}", body)
146
146
  Campaign.new(response)
147
147
  end
148
148
 
149
149
  def delete(id)
150
- @client.delete("/campaigns/#{id}")
150
+ @client.delete("/campaigns/#{URI.encode_www_form_component(id)}")
151
151
  end
152
152
 
153
153
  def preview(id)
154
- response = @client.get("/campaigns/#{id}/preview")
154
+ response = @client.get("/campaigns/#{URI.encode_www_form_component(id)}/preview")
155
155
  CampaignPreview.new(response)
156
156
  end
157
157
 
158
158
  def send_campaign(id)
159
- response = @client.post("/campaigns/#{id}/send")
159
+ response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/send")
160
160
  Campaign.new(response)
161
161
  end
162
162
 
@@ -164,17 +164,17 @@ module Sendly
164
164
  body = { scheduledAt: scheduled_at }
165
165
  body[:timezone] = timezone if timezone
166
166
 
167
- response = @client.post("/campaigns/#{id}/schedule", body)
167
+ response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/schedule", body)
168
168
  Campaign.new(response)
169
169
  end
170
170
 
171
171
  def cancel(id)
172
- response = @client.post("/campaigns/#{id}/cancel")
172
+ response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/cancel")
173
173
  Campaign.new(response)
174
174
  end
175
175
 
176
176
  def clone(id)
177
- response = @client.post("/campaigns/#{id}/clone")
177
+ response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/clone")
178
178
  Campaign.new(response)
179
179
  end
180
180
  end
data/lib/sendly/client.rb CHANGED
@@ -192,6 +192,13 @@ module Sendly
192
192
  @rcs ||= RcsResource.new(self)
193
193
  end
194
194
 
195
+ # Access the Calls resource (phone calls handled by your AI agents)
196
+ #
197
+ # @return [Sendly::CallsResource]
198
+ def calls
199
+ @calls ||= CallsResource.new(self)
200
+ end
201
+
195
202
  # Make a GET request
196
203
  #
197
204
  # @param path [String] API path
@@ -114,7 +114,7 @@ module Sendly
114
114
  params[:limit] = limit if limit
115
115
  params[:offset] = offset if offset
116
116
 
117
- response = @client.get("/contact-lists/#{id}", params)
117
+ response = @client.get("/contact-lists/#{URI.encode_www_form_component(id)}", params)
118
118
  ContactList.new(response)
119
119
  end
120
120
 
@@ -131,21 +131,21 @@ module Sendly
131
131
  body[:name] = name if name
132
132
  body[:description] = description unless description.nil?
133
133
 
134
- response = @client.patch("/contact-lists/#{id}", body)
134
+ response = @client.patch("/contact-lists/#{URI.encode_www_form_component(id)}", body)
135
135
  ContactList.new(response)
136
136
  end
137
137
 
138
138
  def delete(id)
139
- @client.delete("/contact-lists/#{id}")
139
+ @client.delete("/contact-lists/#{URI.encode_www_form_component(id)}")
140
140
  end
141
141
 
142
142
  def add_contacts(list_id, contact_ids)
143
- response = @client.post("/contact-lists/#{list_id}/contacts", { contact_ids: contact_ids })
143
+ response = @client.post("/contact-lists/#{URI.encode_www_form_component(list_id)}/contacts", { contact_ids: contact_ids })
144
144
  { added_count: response["added_count"] || response["addedCount"] }
145
145
  end
146
146
 
147
147
  def remove_contact(list_id, contact_id)
148
- @client.delete("/contact-lists/#{list_id}/contacts/#{contact_id}")
148
+ @client.delete("/contact-lists/#{URI.encode_www_form_component(list_id)}/contacts/#{URI.encode_www_form_component(contact_id)}")
149
149
  end
150
150
  end
151
151
 
@@ -175,7 +175,7 @@ module Sendly
175
175
  end
176
176
 
177
177
  def get(id)
178
- response = @client.get("/contacts/#{id}")
178
+ response = @client.get("/contacts/#{URI.encode_www_form_component(id)}")
179
179
  Contact.new(response)
180
180
  end
181
181
 
@@ -195,12 +195,12 @@ module Sendly
195
195
  body[:email] = email unless email.nil?
196
196
  body[:metadata] = metadata unless metadata.nil?
197
197
 
198
- response = @client.patch("/contacts/#{id}", body)
198
+ response = @client.patch("/contacts/#{URI.encode_www_form_component(id)}", body)
199
199
  Contact.new(response)
200
200
  end
201
201
 
202
202
  def delete(id)
203
- @client.delete("/contacts/#{id}")
203
+ @client.delete("/contacts/#{URI.encode_www_form_component(id)}")
204
204
  end
205
205
 
206
206
  # Clear the invalid flag on a contact so future campaigns include it again.
@@ -208,7 +208,7 @@ module Sendly
208
208
  # error (landline, invalid number) or when a carrier lookup reports they
209
209
  # can't receive SMS.
210
210
  def mark_valid(id)
211
- response = @client.post("/contacts/#{id}/mark-valid", {})
211
+ response = @client.post("/contacts/#{URI.encode_www_form_component(id)}/mark-valid", {})
212
212
  Contact.new(response)
213
213
  end
214
214