misarreach 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9daf8c016c2f6ba763cd1e7a2a0a76646dda1c42abe3cb39db1350a530301178
4
+ data.tar.gz: f9cf3101b87c4b6cf4d27d8ff93804778e45b635973de23ba09c428b5fdf258d
5
+ SHA512:
6
+ metadata.gz: a459a3ba78a8f4e82e95e6f639cf7cc289d83f15e58269cfcadf9f011e72e23122c4d037feaa12ba9380735222fcee6833ab07c2b8a37f5573179ffc435bbb9b
7
+ data.tar.gz: 84e40b7e482340465c7261e77f0ca4d31861a236f5cb46d9f44b24ec15c49d8d3b2b6afd0f150d792cde0f978964fe79c04dc8536a0e5bf6fe7dbdf03d5645bc
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to this SDK are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [1.0.0] — 2026-08-17
8
+
9
+ First release.
10
+
11
+ ### Added
12
+
13
+ - Full coverage of the MisarReach REST API, authenticated with a `mrk_` developer
14
+ key sent as `Authorization: Bearer mrk_…`.
15
+ - Server-Sent Events for `GET /lead-finder/jobs/{id}/stream`, carrying the
16
+ server's event name (`progress`, `found`, `complete`, `error`, `timeout`).
17
+ A job that has already finished is answered with a JSON snapshot rather than
18
+ a stream; that is reported as a single `complete` — or `error` when the job
19
+ failed — so callers need no special case for it.
20
+ - `GET /plan` for reading the subscription's allowances and per-feature usage,
21
+ so an expensive call can be checked before it is attempted rather than after
22
+ it is refused.
23
+ - Retries with exponential back-off on genuinely transient statuses
24
+ (429 rate limits, 500, 502, 503, 504).
25
+
26
+ ### Notes
27
+
28
+ - Plan limits are enforced server-side against the subscription attached to the
29
+ API key. A counted cap answers 402 with `upgrade: true` and names the
30
+ exhausted counter. Surfaced as a distinct error type and never retried.
31
+ Deliberately separate from the 503 `retry: true` the server sends when it
32
+ could not *check* the quota — that one is retried, so "we don't know" is
33
+ never mistaken for "you're over your limit".
34
+ - Streams are never retried: replaying one that failed mid-flight would
35
+ duplicate whatever the caller had already consumed.
36
+
37
+ [1.0.0]: https://misarreach.com/docs
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Misar AI Technology Private Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # MisarReach Ruby SDK
2
+
3
+ Official Ruby client for the [MisarReach](https://misarreach.com) developer API —
4
+ lead finder (23 sources), multi-channel outreach, CRM (deals + pipeline),
5
+ autopilot, and the AI sales agent.
6
+
7
+ - Base URL: `https://api.misar.io/reach/api`
8
+ - Auth: `Authorization: Bearer mrk_...` (reach-only developer key)
9
+ - Pure `Net::HTTP` — no runtime dependencies
10
+ - Automatic retries with backoff on `429/5xx` (honours `Retry-After`)
11
+ - Typed error classes + Server-Sent Events streaming for lead-finder jobs
12
+
13
+ ## Install
14
+
15
+ ```ruby
16
+ # Gemfile
17
+ gem "misarreach"
18
+ ```
19
+
20
+ ```bash
21
+ gem build misar_reach.gemspec
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```ruby
27
+ require "misar_reach"
28
+
29
+ reach = MisarReach.new(api_key: ENV["MISARREACH_API_KEY"]) # mrk_...
30
+
31
+ # Lead Finder
32
+ job = reach.leads.search(query: "Series A SaaS founders", location: "US")
33
+ status = reach.leads.job(job["jobId"])
34
+ reach.leads.enrich(email: "jane@acme.com")
35
+ reach.leads.verify(emails: ["jane@acme.com"])
36
+ reach.leads.score(leadIds: ["l_1", "l_2"])
37
+
38
+ # Live progress via Server-Sent Events
39
+ reach.leads.stream_job(job["jobId"]) do |evt|
40
+ puts "#{evt[:event]}: #{evt[:data].inspect}"
41
+ end
42
+
43
+ # CRM
44
+ deal = reach.deals.create(leadEmail: "cto@acme.com", leadName: "Acme CTO", value: 12_000)
45
+ reach.pipeline.get
46
+ reach.deals.suggestions(deal["id"])
47
+
48
+ # Channels
49
+ reach.channels.status
50
+ reach.channels.connect_whatsapp(phoneNumberId: "...", token: "...")
51
+
52
+ # Autopilot & sales agent
53
+ reach.autopilot.start(campaignId: "camp_1")
54
+ reach.sales_agent.process(conversationId: "conv_1")
55
+ ```
56
+
57
+ ## Resources
58
+
59
+ `leads` · `deals` · `pipeline` · `channels` · `autopilot` · `sales_agent` ·
60
+ `campaigns` · `contacts` · `conversations` · `workspaces` · `settings` · `ads`
61
+
62
+ ## Errors
63
+
64
+ | Class | When |
65
+ |-------|------|
66
+ | `MisarReach::AuthError` | 401 / 403 — missing or wrong-scope key |
67
+ | `MisarReach::NotFoundError` | 404 |
68
+ | `MisarReach::RateLimitError` | 429 (`retry_after`, `balance`, `free_remaining`) |
69
+ | `MisarReach::UpgradeRequiredError` | 429 with `upgrade: true` |
70
+ | `MisarReach::ApiError` | any other non-2xx (`status`, `code`, `body`) |
71
+ | `MisarReach::NetworkError` | connectivity failure |
72
+
73
+ ## Test
74
+
75
+ ```bash
76
+ bundle install
77
+ bundle exec rspec
78
+ ```
79
+
80
+ See https://docs.misar.io/reach/api for the full reference.
@@ -0,0 +1,729 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module MisarReach
6
+ BASE_URL = "https://api.misar.io/reach/api".freeze
7
+ RETRYABLE = [429, 500, 502, 503, 504].freeze
8
+ RETRY_BASE_S = 0.3
9
+
10
+ # ── Resource base ────────────────────────────────────────────────────────────
11
+
12
+ class Resource
13
+ def initialize(client)
14
+ @client = client
15
+ end
16
+
17
+ private
18
+
19
+ def qs(params)
20
+ return "" if params.nil? || params.empty?
21
+ "?#{URI.encode_www_form(params)}"
22
+ end
23
+ end
24
+
25
+ # ── Resource: Lead Finder (leads) ────────────────────────────────────────────
26
+ #
27
+ # 23-source lead discovery, enrichment, verification, scoring, lists,
28
+ # saved searches, scoring rules, recommendations, and the SSE job stream.
29
+ class LeadsResource < Resource
30
+ def account
31
+ @client.request(:get, "/lead-finder/account")
32
+ end
33
+
34
+ def config
35
+ @client.request(:get, "/lead-finder/config")
36
+ end
37
+
38
+ # Synchronous lead lookup.
39
+ def list(params = {})
40
+ @client.request(:get, "/lead-finder/leads#{qs(params)}")
41
+ end
42
+
43
+ # Start an async lead search — returns a jobId; poll `job(job_id)` or
44
+ # consume `stream_job(job_id)` for live progress.
45
+ def search(data)
46
+ @client.request(:post, "/lead-finder/search", data)
47
+ end
48
+
49
+ def discover(data)
50
+ @client.request(:post, "/lead-finder/discover", data)
51
+ end
52
+
53
+ def enrich(data)
54
+ @client.request(:post, "/lead-finder/enrich", data)
55
+ end
56
+
57
+ def verify(data)
58
+ @client.request(:post, "/lead-finder/verify", data)
59
+ end
60
+
61
+ def score(data)
62
+ @client.request(:post, "/lead-finder/score", data)
63
+ end
64
+
65
+ def export(params = {})
66
+ @client.request(:get, "/lead-finder/export#{qs(params)}")
67
+ end
68
+
69
+ def search_history(params = {})
70
+ @client.request(:get, "/lead-finder/search-history#{qs(params)}")
71
+ end
72
+
73
+ def recommendations(params = {})
74
+ @client.request(:get, "/lead-finder/recommendations#{qs(params)}")
75
+ end
76
+
77
+ def preview_message(data)
78
+ @client.request(:post, "/lead-finder/preview-message", data)
79
+ end
80
+
81
+ def send_to_campaign(data)
82
+ @client.request(:post, "/lead-finder/send-to-campaign", data)
83
+ end
84
+
85
+ def add_to_segment(data)
86
+ @client.request(:post, "/lead-finder/add-to-segment", data)
87
+ end
88
+
89
+ def company(domain)
90
+ @client.request(:get, "/lead-finder/companies/#{URI.encode_www_form_component(domain)}")
91
+ end
92
+
93
+ def company_people(domain, params = {})
94
+ @client.request(:get, "/lead-finder/companies/#{URI.encode_www_form_component(domain)}/people#{qs(params)}")
95
+ end
96
+
97
+ # ── Lists ──
98
+ def lists(params = {})
99
+ @client.request(:get, "/lead-finder/lists#{qs(params)}")
100
+ end
101
+
102
+ def create_list(data)
103
+ @client.request(:post, "/lead-finder/lists", data)
104
+ end
105
+
106
+ def sync_list(list_id, data = {})
107
+ @client.request(:post, "/lead-finder/lists/#{list_id}/sync", data)
108
+ end
109
+
110
+ # ── Saved searches ──
111
+ def saved_searches(params = {})
112
+ @client.request(:get, "/lead-finder/saved-searches#{qs(params)}")
113
+ end
114
+
115
+ def create_saved_search(data)
116
+ @client.request(:post, "/lead-finder/saved-searches", data)
117
+ end
118
+
119
+ def delete_saved_search(id)
120
+ @client.request(:delete, "/lead-finder/saved-searches/#{id}")
121
+ end
122
+
123
+ # ── Scoring rules ──
124
+ def scoring_rules(params = {})
125
+ @client.request(:get, "/lead-finder/scoring-rules#{qs(params)}")
126
+ end
127
+
128
+ def create_scoring_rule(data)
129
+ @client.request(:post, "/lead-finder/scoring-rules", data)
130
+ end
131
+
132
+ def update_scoring_rule(id, data)
133
+ @client.request(:patch, "/lead-finder/scoring-rules/#{id}", data)
134
+ end
135
+
136
+ def delete_scoring_rule(id)
137
+ @client.request(:delete, "/lead-finder/scoring-rules/#{id}")
138
+ end
139
+
140
+ # ── Jobs ──
141
+ def job(job_id)
142
+ @client.request(:get, "/lead-finder/jobs/#{job_id}")
143
+ end
144
+
145
+ def job_feedback(job_id, data)
146
+ @client.request(:post, "/lead-finder/jobs/#{job_id}/feedback", data)
147
+ end
148
+
149
+ # Server-Sent Events stream for a running lead-finder job.
150
+ #
151
+ # Yields each parsed event as a Hash: { event:, data: } where `data` is the
152
+ # JSON-decoded `data:` payload (or the raw string if not JSON). Blocks until
153
+ # the stream closes. Requires a block.
154
+ #
155
+ # client.leads.stream_job(job_id) do |evt|
156
+ # puts evt[:event], evt[:data]
157
+ # end
158
+ def stream_job(job_id, &block)
159
+ raise ArgumentError, "stream_job requires a block" unless block_given?
160
+ @client.stream(:get, "/lead-finder/jobs/#{job_id}/stream", &block)
161
+ end
162
+ end
163
+
164
+ # ── Resource: Deals ──────────────────────────────────────────────────────────
165
+
166
+ class DealsResource < Resource
167
+ def list(params = {})
168
+ @client.request(:get, "/deals#{qs(params)}")
169
+ end
170
+
171
+ def create(data)
172
+ @client.request(:post, "/deals", data)
173
+ end
174
+
175
+ def update(id, data)
176
+ @client.request(:patch, "/deals/#{id}", data)
177
+ end
178
+
179
+ def delete(id)
180
+ @client.request(:delete, "/deals/#{id}")
181
+ end
182
+
183
+ def activity(id, params = {})
184
+ @client.request(:get, "/deals/#{id}/activity#{qs(params)}")
185
+ end
186
+
187
+ # Apply one operation to many deals at once —
188
+ # `{ ids: [...], op: "tag"|"untag"|"stage"|"delete", ... }`. Tag writes are
189
+ # applied atomically server-side, so concurrent callers cannot lose a tag.
190
+ def bulk(data)
191
+ @client.request(:post, "/deals/bulk", data)
192
+ end
193
+
194
+ def suggestions(id, params = {})
195
+ @client.request(:get, "/deals/#{id}/suggestions#{qs(params)}")
196
+ end
197
+ end
198
+
199
+ # ── Resource: Pipeline ───────────────────────────────────────────────────────
200
+
201
+ class PipelineResource < Resource
202
+ def get(params = {})
203
+ @client.request(:get, "/pipeline#{qs(params)}")
204
+ end
205
+
206
+ # Move a deal / reorder the pipeline board.
207
+ def update(data)
208
+ @client.request(:post, "/pipeline", data)
209
+ end
210
+ end
211
+
212
+ # ── Resource: Channels ───────────────────────────────────────────────────────
213
+
214
+ class ChannelsResource < Resource
215
+ def status
216
+ @client.request(:get, "/channels/status")
217
+ end
218
+
219
+ def update_status(data)
220
+ @client.request(:patch, "/channels/status", data)
221
+ end
222
+
223
+ def opt_in_links(params = {})
224
+ @client.request(:get, "/channels/opt-in-links#{qs(params)}")
225
+ end
226
+
227
+ def connect_sms(data)
228
+ @client.request(:post, "/channels/sms/connect", data)
229
+ end
230
+
231
+ def connect_whatsapp(data)
232
+ @client.request(:post, "/channels/whatsapp/connect", data)
233
+ end
234
+
235
+ def connect_telegram(data)
236
+ @client.request(:post, "/channels/telegram/connect", data)
237
+ end
238
+
239
+ def connect_twitter(data)
240
+ @client.request(:post, "/channels/twitter/connect", data)
241
+ end
242
+
243
+ def connect_instagram(data)
244
+ @client.request(:post, "/channels/instagram/connect", data)
245
+ end
246
+
247
+ def connect_facebook(data)
248
+ @client.request(:post, "/channels/facebook/connect", data)
249
+ end
250
+
251
+ def connect_discord(data)
252
+ @client.request(:post, "/channels/discord/connect", data)
253
+ end
254
+
255
+ def subscribe_push(data)
256
+ @client.request(:post, "/channels/push/subscribe", data)
257
+ end
258
+
259
+ def unsubscribe_push
260
+ @client.request(:delete, "/channels/push/subscribe")
261
+ end
262
+ end
263
+
264
+ # ── Resource: Autopilot ──────────────────────────────────────────────────────
265
+
266
+ class AutopilotResource < Resource
267
+ def start(data)
268
+ @client.request(:post, "/autopilot/start", data)
269
+ end
270
+
271
+ def runs(params = {})
272
+ @client.request(:get, "/autopilot/runs#{qs(params)}")
273
+ end
274
+
275
+ def get(id)
276
+ @client.request(:get, "/autopilot/#{id}")
277
+ end
278
+
279
+ def status(id)
280
+ @client.request(:get, "/autopilot/#{id}/status")
281
+ end
282
+
283
+ def set_status(id, data)
284
+ @client.request(:post, "/autopilot/#{id}/status", data)
285
+ end
286
+ end
287
+
288
+ # ── Resource: Sales Agent ────────────────────────────────────────────────────
289
+
290
+ class SalesAgentResource < Resource
291
+ def config
292
+ @client.request(:get, "/sales-agent/config")
293
+ end
294
+
295
+ def update_config(data)
296
+ @client.request(:patch, "/sales-agent/config", data)
297
+ end
298
+
299
+ def actions(params = {})
300
+ @client.request(:get, "/sales-agent/actions#{qs(params)}")
301
+ end
302
+
303
+ def conversations(params = {})
304
+ @client.request(:get, "/sales-agent/conversations#{qs(params)}")
305
+ end
306
+
307
+ def process(data)
308
+ @client.request(:post, "/sales-agent/process", data)
309
+ end
310
+ end
311
+
312
+ # ── Resource: Campaigns ──────────────────────────────────────────────────────
313
+
314
+ class CampaignsResource < Resource
315
+ def list(params = {})
316
+ @client.request(:get, "/campaigns#{qs(params)}")
317
+ end
318
+
319
+ def create(data)
320
+ @client.request(:post, "/campaigns", data)
321
+ end
322
+
323
+ def get(id)
324
+ @client.request(:get, "/campaigns/#{id}")
325
+ end
326
+
327
+ def update(id, data)
328
+ @client.request(:patch, "/campaigns/#{id}", data)
329
+ end
330
+
331
+ def delete(id)
332
+ @client.request(:delete, "/campaigns/#{id}")
333
+ end
334
+
335
+ def enqueue(id, data = {})
336
+ @client.request(:post, "/campaigns/#{id}/enqueue", data)
337
+ end
338
+ end
339
+
340
+ # ── Resource: Contacts ───────────────────────────────────────────────────────
341
+
342
+ class ContactsResource < Resource
343
+ def list(params = {})
344
+ @client.request(:get, "/contacts#{qs(params)}")
345
+ end
346
+
347
+ def create(data)
348
+ @client.request(:post, "/contacts", data)
349
+ end
350
+
351
+ def get(id)
352
+ @client.request(:get, "/contacts/#{id}")
353
+ end
354
+
355
+ def update(id, data)
356
+ @client.request(:patch, "/contacts/#{id}", data)
357
+ end
358
+
359
+ def delete(id)
360
+ @client.request(:delete, "/contacts/#{id}")
361
+ end
362
+
363
+ def bulk(data)
364
+ @client.request(:post, "/contacts/bulk", data)
365
+ end
366
+
367
+ def import_contacts(data)
368
+ @client.request(:post, "/contacts/import", data)
369
+ end
370
+
371
+ def segments(params = {})
372
+ @client.request(:get, "/contacts/segments#{qs(params)}")
373
+ end
374
+
375
+ def stats(params = {})
376
+ @client.request(:get, "/contacts/stats#{qs(params)}")
377
+ end
378
+ end
379
+
380
+ # ── Resource: Conversations ──────────────────────────────────────────────────
381
+
382
+ class ConversationsResource < Resource
383
+ def list(params = {})
384
+ @client.request(:get, "/conversations#{qs(params)}")
385
+ end
386
+
387
+ def get(email)
388
+ @client.request(:get, "/conversations/#{URI.encode_www_form_component(email)}")
389
+ end
390
+
391
+ def reply(email, data)
392
+ @client.request(:post, "/conversations/#{URI.encode_www_form_component(email)}/reply", data)
393
+ end
394
+ end
395
+
396
+ # ── Resource: Campaign templates ─────────────────────────────────────────────
397
+
398
+ class CampaignTemplatesResource < Resource
399
+ def list(params = {})
400
+ @client.request(:get, "/campaign-templates#{qs(params)}")
401
+ end
402
+
403
+ def create(data)
404
+ @client.request(:post, "/campaign-templates", data)
405
+ end
406
+ end
407
+
408
+ # ── Resource: Deliverability ─────────────────────────────────────────────────
409
+
410
+ class DeliverabilityResource < Resource
411
+ # Sender health. `bounceRate` and `complaintRate` are nil when there is not
412
+ # enough volume to judge — which is not the same as zero.
413
+ def get(params = {})
414
+ @client.request(:get, "/deliverability#{qs(params)}")
415
+ end
416
+ end
417
+
418
+ # ── Resource: Notifications ──────────────────────────────────────────────────
419
+
420
+ class NotificationsResource < Resource
421
+ def list(params = {})
422
+ @client.request(:get, "/notifications#{qs(params)}")
423
+ end
424
+
425
+ # Mark notifications read. Pass `{ ids: [...] }` or `{ all: true }`.
426
+ def mark_read(data)
427
+ @client.request(:patch, "/notifications", data)
428
+ end
429
+ end
430
+
431
+ # ── Resource: Webhooks ───────────────────────────────────────────────────────
432
+
433
+ class WebhooksResource < Resource
434
+ def list
435
+ @client.request(:get, "/webhooks/endpoints")
436
+ end
437
+
438
+ # Register an endpoint. The response carries the signing secret exactly
439
+ # once — store it then; it is not retrievable afterwards.
440
+ def create(data)
441
+ @client.request(:post, "/webhooks/endpoints", data)
442
+ end
443
+ end
444
+
445
+ # ── Resource: Workspaces ─────────────────────────────────────────────────────
446
+
447
+ class WorkspacesResource < Resource
448
+ def list(params = {})
449
+ @client.request(:get, "/workspaces#{qs(params)}")
450
+ end
451
+
452
+ def create(data)
453
+ @client.request(:post, "/workspaces", data)
454
+ end
455
+
456
+ def members(id)
457
+ @client.request(:get, "/workspaces/#{id}/members")
458
+ end
459
+
460
+ def add_member(id, data)
461
+ @client.request(:post, "/workspaces/#{id}/members", data)
462
+ end
463
+
464
+ def remove_member(id)
465
+ @client.request(:delete, "/workspaces/#{id}/members")
466
+ end
467
+ end
468
+
469
+ # ── Resource: Settings ───────────────────────────────────────────────────────
470
+
471
+ # The subscription behind the API key.
472
+ #
473
+ # Read this before an expensive run rather than discovering the ceiling through
474
+ # an UpgradeRequiredError halfway through: a 402 says a call *was* refused,
475
+ # whereas +usage+ says what is left before anything is spent.
476
+ class PlanResource < Resource
477
+ # GET /plan — plan, caps, per-feature usage and the upgrade offer.
478
+ #
479
+ # A null limit means unlimited, and +remaining+ is nil with it rather than 0
480
+ # — 0 would read as exhausted.
481
+ def get
482
+ @client.request(:get, "/plan")
483
+ end
484
+ end
485
+
486
+ class SettingsResource < Resource
487
+ def sender_address
488
+ @client.request(:get, "/settings/sender-address")
489
+ end
490
+
491
+ def set_sender_address(data)
492
+ @client.request(:put, "/settings/sender-address", data)
493
+ end
494
+ end
495
+
496
+ # ── Resource: Ads ────────────────────────────────────────────────────────────
497
+
498
+ class AdsResource < Resource
499
+ def linkedin_company_audience(data)
500
+ @client.request(:post, "/ads/linkedin/company-audience", data)
501
+ end
502
+ end
503
+
504
+ # ── Main Client ──────────────────────────────────────────────────────────────
505
+
506
+ class Client
507
+ attr_reader :leads, :deals, :pipeline, :channels, :autopilot, :sales_agent,
508
+ :campaigns, :contacts, :conversations, :workspaces, :settings, :ads,
509
+ :campaign_templates, :deliverability, :notifications, :webhooks,
510
+ :plan
511
+
512
+ # @param api_key [String] a reach developer key (`mrk_...`)
513
+ def initialize(api_key:, base_url: BASE_URL, timeout: 30, max_retries: 3)
514
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
515
+
516
+ @api_key = api_key
517
+ @base_url = base_url.chomp("/")
518
+ @timeout = timeout
519
+ @max_retries = max_retries
520
+
521
+ @leads = LeadsResource.new(self)
522
+ @deals = DealsResource.new(self)
523
+ @pipeline = PipelineResource.new(self)
524
+ @channels = ChannelsResource.new(self)
525
+ @autopilot = AutopilotResource.new(self)
526
+ @sales_agent = SalesAgentResource.new(self)
527
+ @campaigns = CampaignsResource.new(self)
528
+ @contacts = ContactsResource.new(self)
529
+ @conversations = ConversationsResource.new(self)
530
+ @workspaces = WorkspacesResource.new(self)
531
+ @settings = SettingsResource.new(self)
532
+ @plan = PlanResource.new(self)
533
+ @ads = AdsResource.new(self)
534
+ @campaign_templates = CampaignTemplatesResource.new(self)
535
+ @deliverability = DeliverabilityResource.new(self)
536
+ @notifications = NotificationsResource.new(self)
537
+ @webhooks = WebhooksResource.new(self)
538
+ end
539
+
540
+ # @param method [Symbol] :get, :post, :patch, :put, :delete
541
+ # @param path [String] e.g. "/deals"
542
+ # @param data [Hash] request body (post/put/patch only)
543
+ # @return [Hash]
544
+ # @raise [ApiError, NetworkError]
545
+ def request(method, path, data = {})
546
+ url = URI.parse(@base_url + "/" + path.delete_prefix("/"))
547
+ has_body = !data.nil? && !data.empty? && %i[post put patch].include?(method)
548
+ json_body = has_body ? JSON.generate(data) : nil
549
+
550
+ last_status = 0
551
+
552
+ @max_retries.times do |attempt|
553
+ begin
554
+ http = build_http(url)
555
+ req = build_request(method, url, json_body)
556
+ resp = http.request(req)
557
+ last_status = resp.code.to_i
558
+
559
+ if RETRYABLE.include?(last_status) && attempt < @max_retries - 1
560
+ sleep(backoff_seconds(resp, attempt))
561
+ next
562
+ end
563
+
564
+ return parse_response(resp, last_status)
565
+ rescue Net::OpenTimeout, Net::ReadTimeout,
566
+ Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
567
+ if attempt < @max_retries - 1
568
+ sleep(RETRY_BASE_S * (2**attempt))
569
+ next
570
+ end
571
+ raise NetworkError.new(e.message, e)
572
+ end
573
+ end
574
+
575
+ raise ApiError.new(last_status, "Max retries exceeded")
576
+ end
577
+
578
+ # Streaming request for Server-Sent Events. Yields { event:, data: } hashes.
579
+ def stream(method, path, &block)
580
+ url = URI.parse(@base_url + "/" + path.delete_prefix("/"))
581
+ http = build_http(url)
582
+ req = build_request(method, url, nil)
583
+ req["Accept"] = "text/event-stream"
584
+
585
+ http.request(req) do |resp|
586
+ status = resp.code.to_i
587
+ raise_for_status(status, resp.body) if status >= 400
588
+
589
+ # The route does not always stream. A job that has already finished is
590
+ # answered with a JSON snapshot, because there is nothing left to
591
+ # stream. Parsing that as SSE finds no frames and returns silently, so
592
+ # the caller would see nothing rather than the outcome. Synthesise the
593
+ # terminal event the SSE path would have sent.
594
+ unless resp["content-type"].to_s.include?("text/event-stream")
595
+ body = +""
596
+ resp.read_body { |chunk| body << chunk }
597
+ snapshot = begin
598
+ JSON.parse(body)
599
+ rescue JSON::ParserError
600
+ body
601
+ end
602
+ name = snapshot.is_a?(Hash) && snapshot["status"] == "failed" ? "error" : "complete"
603
+ block.call({ event: name, data: snapshot })
604
+ return nil
605
+ end
606
+
607
+ buffer = +""
608
+ resp.read_body do |chunk|
609
+ buffer << chunk
610
+ while (idx = buffer.index("\n\n"))
611
+ raw = buffer.slice!(0..idx + 1)
612
+ event = parse_sse_block(raw)
613
+ block.call(event) if event
614
+ end
615
+ end
616
+ end
617
+ nil
618
+ end
619
+
620
+ private
621
+
622
+ def build_http(url)
623
+ http = Net::HTTP.new(url.host, url.port)
624
+ http.use_ssl = url.scheme == "https"
625
+ http.open_timeout = 10
626
+ http.read_timeout = @timeout
627
+ http
628
+ end
629
+
630
+ def build_request(method, url, json_body)
631
+ klass = case method
632
+ when :get then Net::HTTP::Get
633
+ when :post then Net::HTTP::Post
634
+ when :patch then Net::HTTP::Patch
635
+ when :put then Net::HTTP::Put
636
+ when :delete then Net::HTTP::Delete
637
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
638
+ end
639
+
640
+ req = klass.new(url.request_uri)
641
+ req["Authorization"] = "Bearer #{@api_key}"
642
+ req["Content-Type"] = "application/json"
643
+ req["Accept"] = "application/json"
644
+ req["User-Agent"] = "misar-reach-ruby/1.0.0"
645
+ req.body = json_body if json_body
646
+ req
647
+ end
648
+
649
+ def backoff_seconds(resp, attempt)
650
+ ra = resp["Retry-After"]
651
+ return ra.to_f if ra && ra.to_f > 0
652
+ RETRY_BASE_S * (2**attempt)
653
+ end
654
+
655
+ def parse_response(resp, status)
656
+ return {} if status == 204 || resp.body.nil? || resp.body.empty?
657
+
658
+ decoded = begin
659
+ JSON.parse(resp.body)
660
+ rescue JSON::ParserError
661
+ nil
662
+ end
663
+
664
+ raise_for_status(status, resp.body, decoded) if status >= 400
665
+
666
+ decoded.is_a?(Hash) ? decoded : { "data" => decoded }
667
+ end
668
+
669
+ def raise_for_status(status, raw_body, decoded = nil)
670
+ decoded ||= begin
671
+ JSON.parse(raw_body)
672
+ rescue StandardError
673
+ nil
674
+ end
675
+ h = decoded.is_a?(Hash) ? decoded : {}
676
+ err = h["error"]
677
+ msg = err.is_a?(String) ? err : (h["message"] || (err && err.to_s) || raw_body.to_s)
678
+ code = h["code"]
679
+ retry_after = h["retryAfter"]
680
+
681
+ case status
682
+ when 401, 403
683
+ raise AuthError.new(msg, status: status, code: code, body: h)
684
+ when 402
685
+ # A counted cap answers 402 with `upgrade: true` (reach-gate.rb contract).
686
+ # This branch did not exist, so every real refusal fell through to the
687
+ # generic ApiError.
688
+ raise UpgradeRequiredError.new(msg, code: code, body: h)
689
+
690
+ when 404
691
+ raise NotFoundError.new(msg, code: code, body: h)
692
+ when 429
693
+ # 429 with `upgrade` is the legacy shape; the server now answers 402.
694
+ if h["upgrade"]
695
+ raise UpgradeRequiredError.new(msg, code: code, body: h)
696
+ end
697
+ raise RateLimitError.new(msg, retry_after: retry_after,
698
+ balance: h["balance"],
699
+ free_remaining: h["freeRemaining"],
700
+ code: code, body: h)
701
+ else
702
+ raise ApiError.new(status, msg, code: code, retry_after: retry_after, body: h)
703
+ end
704
+ end
705
+
706
+ def parse_sse_block(raw)
707
+ event_name = nil
708
+ data_lines = []
709
+ raw.each_line do |line|
710
+ line = line.chomp
711
+ next if line.empty? || line.start_with?(":")
712
+ if line.start_with?("event:")
713
+ event_name = line.sub(/^event:\s?/, "")
714
+ elsif line.start_with?("data:")
715
+ data_lines << line.sub(/^data:\s?/, "")
716
+ end
717
+ end
718
+ return nil if data_lines.empty? && event_name.nil?
719
+
720
+ payload = data_lines.join("\n")
721
+ parsed = begin
722
+ payload.empty? ? nil : JSON.parse(payload)
723
+ rescue JSON::ParserError
724
+ payload
725
+ end
726
+ { event: event_name, data: parsed }
727
+ end
728
+ end
729
+ end
@@ -0,0 +1,88 @@
1
+ module MisarReach
2
+ # Base error raised for any non-2xx MisarReach API response.
3
+ class ApiError < StandardError
4
+ attr_reader :status, :code, :retry_after, :body
5
+
6
+ def initialize(status, message, code: nil, retry_after: nil, body: nil)
7
+ @status = status
8
+ @code = code
9
+ @retry_after = retry_after
10
+ @body = body
11
+ super("misar-reach: API error #{status}#{code ? " (#{code})" : ""}: #{message}")
12
+ end
13
+ end
14
+
15
+ # 401 / 403 — missing, invalid, or insufficiently-scoped `mrk_` key.
16
+ class AuthError < ApiError
17
+ def initialize(message = "Unauthorized", status: 401, **opts)
18
+ super(status, message, code: opts[:code] || "unauthorized", **opts.reject { |k, _| k == :code })
19
+ end
20
+ end
21
+
22
+ # 404 — resource not found.
23
+ class NotFoundError < ApiError
24
+ def initialize(message = "Not found", **opts)
25
+ super(404, message, code: opts[:code] || "not_found", **opts.reject { |k, _| k == :code })
26
+ end
27
+ end
28
+
29
+ # 429 — rate limited. `retry_after` carries the seconds to wait when provided.
30
+ class RateLimitError < ApiError
31
+ attr_reader :balance, :free_remaining
32
+
33
+ def initialize(message = "Too many requests", retry_after: nil, balance: nil, free_remaining: nil, **opts)
34
+ @balance = balance
35
+ @free_remaining = free_remaining
36
+ super(429, message, code: opts[:code] || "rate_limit", retry_after: retry_after, body: opts[:body])
37
+ end
38
+ end
39
+
40
+ # 429 with `upgrade: true` — the workspace plan must be upgraded to proceed.
41
+ # A counted plan cap was hit.
42
+ #
43
+ # MisarReach answers 402 with +upgrade: true+ when a cap is reached and names
44
+ # the offending counter. Retrying cannot help until the cap resets or the
45
+ # plan changes.
46
+ #
47
+ # Distinct from the 503 +retry: true+ the server sends when it could not
48
+ # *check* the quota: that one is retried, so "we do not know" is never
49
+ # mistaken for "you are over your limit".
50
+ class UpgradeRequiredError < ApiError
51
+ APP_ORIGIN = "https://misarreach.com".freeze
52
+
53
+ # @return [String, nil] the counter that was exhausted, e.g. "lead_searches"
54
+ attr_reader :feature
55
+ # @return [Integer, nil] the cap on the current plan
56
+ attr_reader :limit
57
+ # @return [Integer, nil] usage against that cap when the call was refused
58
+ attr_reader :current
59
+ # @return [String, nil] absolute URL to the billing page
60
+ attr_reader :upgrade_url
61
+
62
+ def initialize(message = "Upgrade required", **opts)
63
+ body = opts[:body] || {}
64
+ @feature = body["feature"]
65
+ @limit = body["limit"]
66
+ @current = body["current"]
67
+ # The server sends an app-relative path; make it linkable.
68
+ url = body["upgrade_url"]
69
+ @upgrade_url =
70
+ if url.nil? || url.start_with?("http://", "https://")
71
+ url
72
+ else
73
+ APP_ORIGIN + (url.start_with?("/") ? url : "/#{url}")
74
+ end
75
+ super(opts[:status] || 402, message,
76
+ code: opts[:code] || "upgrade_required",
77
+ **opts.reject { |k, _| %i[code status].include?(k) })
78
+ end
79
+ end
80
+
81
+ # Connectivity failure — no HTTP response received.
82
+ class NetworkError < ApiError
83
+ def initialize(message, cause = nil)
84
+ super(0, message, code: "network_error")
85
+ @cause = cause
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "misar_reach/errors"
2
+ require_relative "misar_reach/client"
3
+
4
+ # Official Ruby SDK for the MisarReach developer API (api.misar.io/reach/api).
5
+ #
6
+ # require "misar_reach"
7
+ # reach = MisarReach.new(api_key: "mrk_...")
8
+ # reach.leads.search(query: "SaaS founders", location: "US")
9
+ #
10
+ module MisarReach
11
+ def self.new(**kwargs)
12
+ Client.new(**kwargs)
13
+ end
14
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: misarreach
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Misar AI
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: webmock
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.23'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.23'
41
+ - !ruby/object:Gem::Dependency
42
+ name: simplecov
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.22'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.22'
55
+ description: Full-featured Ruby SDK for the MisarReach developer API (api.misar.io/reach/api).
56
+ Covers all 12 resource groups and 84 operations, including the lead-finder SSE job
57
+ stream. Pure Net::HTTP, no runtime dependencies.
58
+ email:
59
+ - hello@misar.io
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CHANGELOG.md
65
+ - LICENSE
66
+ - README.md
67
+ - lib/misar_reach.rb
68
+ - lib/misar_reach/client.rb
69
+ - lib/misar_reach/errors.rb
70
+ homepage: https://misarreach.com/docs
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://misarreach.com/docs
75
+ documentation_uri: https://misarreach.com/docs/sdks/ruby
76
+ source_code_uri: https://github.com/Misar-AI/misarreach-sdks
77
+ changelog_uri: https://github.com/Misar-AI/misarreach-sdks/blob/main/sdks/ruby/CHANGELOG.md
78
+ bug_tracker_uri: https://github.com/Misar-AI/misarreach-sdks/issues
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '2.7'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.5.22
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: Official Ruby SDK for MisarReach — lead finder, outreach channels, CRM, autopilot
98
+ test_files: []