ararahq 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: 76d8cd62472512d57be879f384202d4c0075c6e281328e265ad8103391232c1a
4
+ data.tar.gz: efc27423be8a8a20e965fb81e74d463b217631cd8bdfb8f7f07529807fabdae5
5
+ SHA512:
6
+ metadata.gz: 5150377594577e64ce679080a5bde8ed93b993a6ec48031d502391f82d5af9f894392c62b19e568121aa3f37b6ee59bc48b32e16762ef89e1d0ea195c561285b
7
+ data.tar.gz: 7b21d10c51ac110ee75f197cd706a97c3c97d2c1c9e7131191d7e684af38e67fd8853e8cc180a8dfa54032559c3a2c7cb70d728fe10c203b315a57b83c6ac65e
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 - 2026-09-24
4
+
5
+ Primeira versão publicada no RubyGems. Alinha o SDK ao contrato da API por chave.
6
+
7
+ ### Breaking
8
+ - `messages.send` foi removido (sobrescrevia `Object#send`). Use `messages.send_message` ou `messages.deliver`.
9
+ - `templates.get`, `get_status` e `delete` recebem o `id` (UUID) do template, não o nome. Para buscar por nome, `templates.find_by_name` (filtro local sobre `list`).
10
+ - `templates.list`, `smart_links.list`, `campaigns.list` e `wallet.transactions` devolvem `Arara::Page` (`data` + `pagination`).
11
+ - Removidos `users`, `organizations` (webhook) e `api_keys`: não são alcançáveis por chave. Use `auth.me` (`GET /auth/me`).
12
+ - 403 sem `code` agora levanta `Arara::AuthenticationError`.
13
+
14
+ ### Added
15
+ - `Idempotency-Key` automático (UUID v4) em `messages.send_message`, `messages.send_batch` e `campaigns.create`, reaproveitado em todas as retentativas.
16
+ - `sender`, `type`, `interactive`, `charge`, `location`, `reaction`, `reply_to`, `smart_link_param`, `smart_link_url` e `mode` no envio.
17
+ - `messages.get`, `messages.send_batch` (até 1000), `messages.list_by_batch`.
18
+ - `templates.analytics`, `templates.template_analytics`, filtros e paginação em `templates.list`; paginação em `smart_links.list`.
19
+ - `opt_outs` (`list`, `create`, `get`, `delete`).
20
+ - Erros `PlanFeatureLockedError` (`feature`, `current_plan`, `upgrade_to`), `PaymentRequiredError` (402) e `UnprocessableEntityError` (422).
21
+ - LICENSE MIT e suíte de testes (minitest).
22
+
23
+ ### Fixed
24
+ - `Retry-After` em formato de data não derruba mais a chamada (`require "time"`).
25
+ - POST/PATCH sem `Idempotency-Key` não é mais repetido em 5xx, 429 ou erro de rede.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arara HQ
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,124 @@
1
+ # AraraHQ Ruby SDK
2
+
3
+ Official Ruby client for the [AraraHQ](https://ararahq.com) WhatsApp API. Zero runtime dependencies: pure Ruby stdlib (`net/http`, `json`, `uri`, `time`, `securerandom`). Ruby >= 3.0.
4
+
5
+ ## Install
6
+
7
+ ```ruby
8
+ gem "ararahq"
9
+ ```
10
+
11
+ ```bash
12
+ gem install ararahq
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```ruby
18
+ require "arara"
19
+
20
+ client = Arara::Client.new(api_key: "ara_live_...")
21
+
22
+ # Send a template message. `deliver` is an alias of `send_message`.
23
+ result = client.messages.send_message(
24
+ receiver: "5511999998888", # also accepts "+55..." and "whatsapp:+55..."
25
+ sender: "+5511888887777", # optional: which of your numbers sends it
26
+ template_name: "boas_vindas",
27
+ template_variables: ["Micael"],
28
+ idempotency_key: "order-1234" # optional: generated when omitted
29
+ )
30
+ puts result["id"]
31
+
32
+ client.messages.get(result["id"])
33
+
34
+ # Up to 1000 receivers of the same template
35
+ client.messages.send_batch(
36
+ template_name: "boas_vindas",
37
+ messages: [{ "receiver" => "5511999998888", "variables" => ["Ana"] }]
38
+ )
39
+
40
+ # Paginated lists return Arara::Page (Enumerable over `data`)
41
+ page = client.templates.list(status: "APPROVED", page: 0, size: 50)
42
+ page.each { |template| puts template["id"] }
43
+ page.pagination.total_pages
44
+ page.next_page?
45
+
46
+ # Templates are addressed by id (UUID)
47
+ client.templates.get_status(page.data.first["id"])
48
+ client.templates.find_by_name("boas_vindas") # local filter over list
49
+
50
+ # Campaigns (idempotency key is auto-generated when omitted)
51
+ client.campaigns.create(
52
+ {
53
+ "name" => "Reativação Julho",
54
+ "templateName" => "reativacao",
55
+ "contacts" => [{ "to" => "5511999998888", "variables" => ["Micael"] }]
56
+ }
57
+ )
58
+ ```
59
+
60
+ Responses are parsed from JSON into plain Ruby `Hash` objects with string keys, exactly as the API returns them.
61
+
62
+ ## Configuration
63
+
64
+ ```ruby
65
+ Arara::Client.new(
66
+ api_key: "ara_live_...",
67
+ base_url: "https://api.ararahq.com", # default
68
+ timeout: 10, # seconds, default
69
+ max_retries: 3 # default
70
+ )
71
+ ```
72
+
73
+ `GET`, `PUT` and `DELETE`, and any request carrying an `Idempotency-Key`, are retried on `429`, `5xx` and network errors with exponential backoff, honoring `Retry-After`. A `POST`/`PATCH` without `Idempotency-Key` is never retried. `messages.send_message`, `messages.send_batch` and `campaigns.create` always send one (a UUID v4 when you do not pass yours), reused across retries, so a retry never duplicates a send.
74
+
75
+ ## Resources
76
+
77
+ `auth`, `messages`, `templates`, `contacts`, `conversations`, `wallet`, `numbers`, `smart_links`, `campaigns`, `opt_outs`.
78
+
79
+ ## API key permissions
80
+
81
+ `READ` keys can `GET` messages, campaigns, templates and numbers. Sending needs `MESSAGES_SEND` (messages) or `CAMPAIGNS_SEND` (campaigns); creating templates needs `TEMPLATES_WRITE`; `contacts` writes need `CONTACTS_WRITE`.
82
+
83
+ These require an `ADMIN` key: `auth.me`, `contacts` (reads), `conversations`, `wallet` and `opt_outs`. Permissions are enforced by the API.
84
+
85
+ ## Errors
86
+
87
+ Every failed request raises an `Arara::Error` (or a subclass) with `status_code`, `code`, `message`, `details` and `retry_after`.
88
+
89
+ | Status | Class |
90
+ |---|---|
91
+ | 400 | `Arara::BadRequestError` |
92
+ | 401, 403 without `code` (invalid key or missing permission) | `Arara::AuthenticationError` |
93
+ | 402 | `Arara::PaymentRequiredError` |
94
+ | 403 `PLAN_FEATURE_LOCKED` | `Arara::PlanFeatureLockedError` (`feature`, `current_plan`, `upgrade_to`) |
95
+ | 403 with another `code` | `Arara::PermissionError` |
96
+ | 404 | `Arara::NotFoundError` |
97
+ | 409 | `Arara::ConflictError` |
98
+ | 422 | `Arara::UnprocessableEntityError` (e.g. `INVALID_RECIPIENT`, `TEMPLATE_PAUSED`) |
99
+ | 429 | `Arara::RateLimitError` |
100
+ | 5xx | `Arara::ServerError` |
101
+ | network | `Arara::NetworkError` |
102
+
103
+ ```ruby
104
+ begin
105
+ client.messages.send_message(receiver: "5511999998888", template_name: "x")
106
+ rescue Arara::PlanFeatureLockedError => e
107
+ puts "upgrade to #{e.upgrade_to}"
108
+ rescue Arara::RateLimitError => e
109
+ puts "retry after #{e.retry_after}s"
110
+ rescue Arara::Error => e
111
+ puts "#{e.code}: #{e.message}"
112
+ end
113
+ ```
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ bundle install
119
+ bundle exec rake test
120
+ ```
121
+
122
+ ## License
123
+
124
+ MIT. See [LICENSE](LICENSE).
data/arara.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ require_relative "lib/arara/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "ararahq"
5
+ spec.version = Arara::VERSION
6
+ spec.authors = ["AraraHQ"]
7
+ spec.email = ["dev@ararahq.com"]
8
+
9
+ spec.summary = "Official Ruby SDK for the AraraHQ WhatsApp API."
10
+ spec.description = "Ruby client for the AraraHQ API: messages, templates, contacts, " \
11
+ "conversations, campaigns, wallet, numbers, smart links and opt-outs. Zero runtime dependencies."
12
+ spec.homepage = "https://ararahq.com"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.0"
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/ararahq/arara-ruby-sdk"
18
+ spec.metadata["changelog_uri"] = "https://github.com/ararahq/arara-ruby-sdk/blob/main/CHANGELOG.md"
19
+ spec.metadata["rubygems_mfa_required"] = "true"
20
+
21
+ spec.files = Dir["lib/**/*.rb"] + ["README.md", "CHANGELOG.md", "LICENSE", "arara.gemspec"]
22
+ spec.require_paths = ["lib"]
23
+ end
@@ -0,0 +1,38 @@
1
+ require_relative "http_client"
2
+ require_relative "resources/auth"
3
+ require_relative "resources/messages"
4
+ require_relative "resources/templates"
5
+ require_relative "resources/contacts"
6
+ require_relative "resources/conversations"
7
+ require_relative "resources/wallet"
8
+ require_relative "resources/numbers"
9
+ require_relative "resources/smart_links"
10
+ require_relative "resources/campaigns"
11
+ require_relative "resources/opt_outs"
12
+
13
+ module Arara
14
+ class Client
15
+ DEFAULT_BASE_URL = "https://api.ararahq.com".freeze
16
+ DEFAULT_TIMEOUT = 10
17
+
18
+ attr_reader :auth, :messages, :templates, :contacts, :conversations, :wallet,
19
+ :numbers, :smart_links, :campaigns, :opt_outs
20
+
21
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
22
+ max_retries: HttpClient::DEFAULT_MAX_RETRIES)
23
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty?
24
+
25
+ http = HttpClient.new(api_key: api_key, base_url: base_url, timeout: timeout, max_retries: max_retries)
26
+ @auth = Resources::Auth.new(http)
27
+ @messages = Resources::Messages.new(http)
28
+ @templates = Resources::Templates.new(http)
29
+ @contacts = Resources::Contacts.new(http)
30
+ @conversations = Resources::Conversations.new(http)
31
+ @wallet = Resources::Wallet.new(http)
32
+ @numbers = Resources::Numbers.new(http)
33
+ @smart_links = Resources::SmartLinks.new(http)
34
+ @campaigns = Resources::Campaigns.new(http)
35
+ @opt_outs = Resources::OptOuts.new(http)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,87 @@
1
+ module Arara
2
+ class Error < StandardError
3
+ attr_reader :status_code, :code, :details, :retry_after
4
+
5
+ def initialize(message, code: nil, status_code: nil, details: nil, retry_after: nil)
6
+ super(message)
7
+ @code = code
8
+ @status_code = status_code
9
+ @details = details
10
+ @retry_after = retry_after
11
+ end
12
+ end
13
+
14
+ class BadRequestError < Error; end
15
+
16
+ class AuthenticationError < Error; end
17
+
18
+ class PaymentRequiredError < Error; end
19
+
20
+ class PermissionError < Error; end
21
+
22
+ class PlanFeatureLockedError < PermissionError
23
+ def feature
24
+ detail("feature")
25
+ end
26
+
27
+ def current_plan
28
+ detail("currentPlan")
29
+ end
30
+
31
+ def upgrade_to
32
+ detail("upgradeTo")
33
+ end
34
+
35
+ private
36
+
37
+ def detail(key)
38
+ details.is_a?(Hash) ? details[key] : nil
39
+ end
40
+ end
41
+
42
+ class NotFoundError < Error; end
43
+
44
+ class ConflictError < Error; end
45
+
46
+ class UnprocessableEntityError < Error; end
47
+
48
+ class RateLimitError < Error; end
49
+
50
+ class ServerError < Error; end
51
+
52
+ class NetworkError < Error; end
53
+
54
+ PLAN_FEATURE_LOCKED = "PLAN_FEATURE_LOCKED".freeze
55
+ SERVER_ERROR_THRESHOLD = 500
56
+
57
+ STATUS_ERRORS = {
58
+ 400 => BadRequestError,
59
+ 401 => AuthenticationError,
60
+ 402 => PaymentRequiredError,
61
+ 404 => NotFoundError,
62
+ 409 => ConflictError,
63
+ 422 => UnprocessableEntityError,
64
+ 429 => RateLimitError
65
+ }.freeze
66
+
67
+ module_function
68
+
69
+ def error_for_status(status_code, message, code: nil, details: nil, retry_after: nil)
70
+ klass = error_class_for(status_code, code)
71
+ klass.new(message, code: code, status_code: status_code, details: details, retry_after: retry_after)
72
+ end
73
+
74
+ def error_class_for(status_code, code)
75
+ return forbidden_error_class(code) if status_code == 403
76
+ return STATUS_ERRORS[status_code] if STATUS_ERRORS.key?(status_code)
77
+
78
+ status_code && status_code >= SERVER_ERROR_THRESHOLD ? ServerError : Error
79
+ end
80
+
81
+ def forbidden_error_class(code)
82
+ return AuthenticationError if code.nil? || code.to_s.empty?
83
+ return PlanFeatureLockedError if code == PLAN_FEATURE_LOCKED
84
+
85
+ PermissionError
86
+ end
87
+ end
@@ -0,0 +1,186 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "time"
5
+ require_relative "errors"
6
+
7
+ module Arara
8
+ class HttpClient
9
+ DEFAULT_MAX_RETRIES = 3
10
+ BASE_RETRY_DELAY = 0.5
11
+ MAX_RETRY_DELAY = 30.0
12
+ RATE_LIMIT_STATUS = 429
13
+ SERVER_ERROR_THRESHOLD = 500
14
+ IDEMPOTENT_METHODS = %i[get put delete].freeze
15
+
16
+ def initialize(api_key:, base_url:, timeout: 10, max_retries: DEFAULT_MAX_RETRIES)
17
+ @api_key = api_key
18
+ @base_uri = URI.parse(base_url.to_s.sub(%r{/+\z}, ""))
19
+ @timeout = timeout
20
+ @max_retries = max_retries
21
+ end
22
+
23
+ def get(path, params: nil)
24
+ request(:get, path, params: params)
25
+ end
26
+
27
+ def post(path, body: nil, params: nil, idempotency_key: nil)
28
+ request(:post, path, body: body, params: params, idempotency_key: idempotency_key)
29
+ end
30
+
31
+ def patch(path, body: nil, params: nil)
32
+ request(:patch, path, body: body, params: params)
33
+ end
34
+
35
+ def put(path, body: nil, params: nil)
36
+ request(:put, path, body: body, params: params)
37
+ end
38
+
39
+ def delete(path, params: nil)
40
+ request(:delete, path, params: params)
41
+ end
42
+
43
+ private
44
+
45
+ def request(method, path, body: nil, params: nil, idempotency_key: nil)
46
+ idempotency_key = normalize_idempotency_key(idempotency_key)
47
+ attempt = 0
48
+ loop do
49
+ response = perform(method, path, body, params, idempotency_key)
50
+ status = response.code.to_i
51
+ return handle_success(response) if status < 400
52
+
53
+ if retry_allowed?(method, idempotency_key) && retryable?(status) && attempt < @max_retries
54
+ sleep(retry_delay(attempt, response))
55
+ attempt += 1
56
+ next
57
+ end
58
+ raise error_from_response(status, response)
59
+ rescue Timeout::Error, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
60
+ raise NetworkError.new(e.message) if attempt >= @max_retries || !retry_allowed?(method, idempotency_key)
61
+
62
+ sleep(retry_delay(attempt, nil))
63
+ attempt += 1
64
+ end
65
+ end
66
+
67
+ def perform(method, path, body, params, idempotency_key)
68
+ uri = build_uri(path, params)
69
+ request = build_request(method, uri, body, idempotency_key)
70
+ http = Net::HTTP.new(uri.host, uri.port)
71
+ http.use_ssl = uri.scheme == "https"
72
+ http.open_timeout = @timeout
73
+ http.read_timeout = @timeout
74
+ http.request(request)
75
+ end
76
+
77
+ def build_uri(path, params)
78
+ uri = @base_uri.dup
79
+ uri.path = "#{@base_uri.path}#{path}"
80
+ unless params.nil? || params.empty?
81
+ cleaned = params.reject { |_, value| value.nil? }
82
+ uri.query = URI.encode_www_form(cleaned) unless cleaned.empty?
83
+ end
84
+ uri
85
+ end
86
+
87
+ def build_request(method, uri, body, idempotency_key)
88
+ klass = {
89
+ get: Net::HTTP::Get,
90
+ post: Net::HTTP::Post,
91
+ patch: Net::HTTP::Patch,
92
+ put: Net::HTTP::Put,
93
+ delete: Net::HTTP::Delete
94
+ }.fetch(method)
95
+ request = klass.new(uri)
96
+ request["Authorization"] = "Bearer #{@api_key}"
97
+ request["Accept"] = "application/json"
98
+ request["Idempotency-Key"] = idempotency_key if idempotency_key
99
+ unless body.nil?
100
+ request["Content-Type"] = "application/json"
101
+ request.body = JSON.generate(body)
102
+ end
103
+ request
104
+ end
105
+
106
+ def handle_success(response)
107
+ return nil if response.code.to_i == 204
108
+
109
+ raw = response.body
110
+ return nil if raw.nil? || raw.strip.empty?
111
+
112
+ JSON.parse(raw)
113
+ rescue JSON::ParserError
114
+ raise Error.new("Failed to parse Arara API response as JSON")
115
+ end
116
+
117
+ def normalize_idempotency_key(key)
118
+ normalized = key.to_s.strip
119
+ normalized.empty? ? nil : normalized
120
+ end
121
+
122
+ def retry_allowed?(method, idempotency_key)
123
+ IDEMPOTENT_METHODS.include?(method) || !idempotency_key.nil?
124
+ end
125
+
126
+ def retryable?(status)
127
+ status == RATE_LIMIT_STATUS || status >= SERVER_ERROR_THRESHOLD
128
+ end
129
+
130
+ def retry_delay(attempt, response)
131
+ after = response && parse_retry_after(response["Retry-After"])
132
+ return [after, MAX_RETRY_DELAY].min if after
133
+
134
+ [BASE_RETRY_DELAY * (2**attempt), MAX_RETRY_DELAY].min
135
+ end
136
+
137
+ def parse_retry_after(header)
138
+ return nil if header.nil? || header.strip.empty?
139
+
140
+ seconds = Float(header, exception: false)
141
+ return seconds if seconds && seconds >= 0
142
+
143
+ begin
144
+ [(Time.httpdate(header) - Time.now).ceil, 0].max
145
+ rescue ArgumentError, TypeError
146
+ nil
147
+ end
148
+ end
149
+
150
+ def error_from_response(status, response)
151
+ envelope = parse_error_envelope(response.body)
152
+ message = envelope[:message] || "Arara API request failed with status #{status}"
153
+ Arara.error_for_status(
154
+ status,
155
+ message,
156
+ code: envelope[:code],
157
+ details: envelope[:details],
158
+ retry_after: parse_retry_after(response["Retry-After"])
159
+ )
160
+ end
161
+
162
+ def parse_error_envelope(raw)
163
+ return {} if raw.nil? || raw.strip.empty?
164
+
165
+ parsed = JSON.parse(raw)
166
+ return {} unless parsed.is_a?(Hash)
167
+
168
+ inner = parsed["error"]
169
+ return spring_error(parsed) if inner.is_a?(String)
170
+ return {} unless inner.is_a?(Hash)
171
+
172
+ {
173
+ code: inner["code"],
174
+ message: inner["message"],
175
+ details: inner["details"].is_a?(Hash) ? inner["details"] : nil
176
+ }
177
+ rescue JSON::ParserError
178
+ {}
179
+ end
180
+
181
+ def spring_error(parsed)
182
+ message = parsed["message"]
183
+ { message: message.is_a?(String) && !message.strip.empty? ? message : nil }
184
+ end
185
+ end
186
+ end
data/lib/arara/page.rb ADDED
@@ -0,0 +1,57 @@
1
+ module Arara
2
+ Pagination = Struct.new(:page, :size, :total_elements, :total_pages, keyword_init: true)
3
+
4
+ class Page
5
+ include Enumerable
6
+
7
+ attr_reader :data, :pagination, :raw
8
+
9
+ def self.from_data(response)
10
+ body = require_list(response, "data")
11
+ meta = body["pagination"].is_a?(Hash) ? body["pagination"] : {}
12
+ new(
13
+ data: body["data"],
14
+ pagination: Pagination.new(
15
+ page: meta["page"], size: meta["size"],
16
+ total_elements: meta["totalElements"], total_pages: meta["totalPages"]
17
+ ),
18
+ raw: response
19
+ )
20
+ end
21
+
22
+ def self.from_content(response, page:, size:)
23
+ body = require_list(response, "content")
24
+ new(
25
+ data: body["content"],
26
+ pagination: Pagination.new(
27
+ page: page, size: size,
28
+ total_elements: body["totalElements"], total_pages: body["totalPages"]
29
+ ),
30
+ raw: response
31
+ )
32
+ end
33
+
34
+ def self.require_list(response, field)
35
+ return response if response.is_a?(Hash) && response[field].is_a?(Array)
36
+
37
+ raise Arara::Error.new("Unexpected paginated response from Arara API: missing \"#{field}\" list")
38
+ end
39
+ private_class_method :require_list
40
+
41
+ def initialize(data:, pagination:, raw: nil)
42
+ @data = data
43
+ @pagination = pagination
44
+ @raw = raw
45
+ end
46
+
47
+ def each(&block)
48
+ @data.each(&block)
49
+ end
50
+
51
+ def next_page?
52
+ return false if @pagination.page.nil? || @pagination.total_pages.nil?
53
+
54
+ @pagination.page + 1 < @pagination.total_pages
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,12 @@
1
+ require_relative "base_resource"
2
+
3
+ module Arara
4
+ module Resources
5
+ class Auth < BaseResource
6
+ # Get the user that owns the API key. GET /auth/me (requires an ADMIN key)
7
+ def me
8
+ @http.get("/auth/me")
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,18 @@
1
+ require "securerandom"
2
+
3
+ module Arara
4
+ module Resources
5
+ class BaseResource
6
+ def initialize(http)
7
+ @http = http
8
+ end
9
+
10
+ private
11
+
12
+ def idempotency_key_or_generate(key)
13
+ normalized = key.to_s.strip
14
+ normalized.empty? ? SecureRandom.uuid : normalized
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,38 @@
1
+ require "securerandom"
2
+ require_relative "base_resource"
3
+ require_relative "../page"
4
+
5
+ module Arara
6
+ module Resources
7
+ class Campaigns < BaseResource
8
+ DEFAULT_PAGE_SIZE = 20
9
+
10
+ # Create a campaign. POST /v1/campaigns
11
+ def create(payload, idempotency_key: nil)
12
+ key = idempotency_key_or_generate(idempotency_key)
13
+ @http.post("/v1/campaigns", body: payload, idempotency_key: key)
14
+ end
15
+
16
+ # List campaigns. GET /v1/campaigns. Returns an Arara::Page.
17
+ def list(page: 0, size: DEFAULT_PAGE_SIZE, status: nil)
18
+ response = @http.get("/v1/campaigns", params: { "page" => page, "size" => size, "status" => status })
19
+ Arara::Page.from_content(response, page: page, size: size)
20
+ end
21
+
22
+ # Estimate campaign cost. GET /v1/campaigns/estimate
23
+ def estimate(template_name:, count:)
24
+ @http.get("/v1/campaigns/estimate", params: { "templateName" => template_name, "count" => count })
25
+ end
26
+
27
+ # Get a campaign by id. GET /v1/campaigns/{id}
28
+ def get(id)
29
+ @http.get("/v1/campaigns/#{id}")
30
+ end
31
+
32
+ # Cancel a campaign. POST /v1/campaigns/{id}/cancel
33
+ def cancel(id)
34
+ @http.post("/v1/campaigns/#{id}/cancel")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,52 @@
1
+ require_relative "base_resource"
2
+
3
+ module Arara
4
+ module Resources
5
+ class Contacts < BaseResource
6
+ # List contacts. GET /v1/contacts
7
+ def list(page: 0, size: 20, q: nil, lifecycle: nil)
8
+ @http.get("/v1/contacts", params: { "page" => page, "size" => size, "q" => q, "lifecycle" => lifecycle })
9
+ end
10
+
11
+ # Import a batch of contacts. POST /v1/contacts/batch
12
+ def import_batch(contacts)
13
+ @http.post("/v1/contacts/batch", body: contacts)
14
+ end
15
+
16
+ # Get contact lifecycle stats. GET /v1/contacts/stats
17
+ def stats
18
+ @http.get("/v1/contacts/stats")
19
+ end
20
+
21
+ # List reactivation candidates. GET /v1/contacts/reactivation
22
+ def reactivation_candidates(limit: 100)
23
+ @http.get("/v1/contacts/reactivation", params: { "limit" => limit })
24
+ end
25
+
26
+ # List distinct contact tags. GET /v1/contacts/tags
27
+ def list_tags
28
+ @http.get("/v1/contacts/tags")
29
+ end
30
+
31
+ # Get a contact by phone. GET /v1/contacts/{phone}
32
+ def get(phone)
33
+ @http.get("/v1/contacts/#{phone}")
34
+ end
35
+
36
+ # Update a contact by phone. PATCH /v1/contacts/{phone}
37
+ def update(phone, name: nil, email: nil, tags: nil)
38
+ payload = {
39
+ "name" => name,
40
+ "email" => email,
41
+ "tags" => tags
42
+ }.reject { |_, value| value.nil? }
43
+ @http.patch("/v1/contacts/#{phone}", body: payload)
44
+ end
45
+
46
+ # List a contact's recent messages. GET /v1/contacts/{phone}/messages
47
+ def messages(phone, limit: 30)
48
+ @http.get("/v1/contacts/#{phone}/messages", params: { "limit" => limit })
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,39 @@
1
+ require_relative "base_resource"
2
+
3
+ module Arara
4
+ module Resources
5
+ class Conversations < BaseResource
6
+ # List conversations. GET /v1/conversations
7
+ def list(status: nil, lead_status: nil, page: 0, size: 20)
8
+ @http.get("/v1/conversations", params: {
9
+ "status" => status, "leadStatus" => lead_status, "page" => page, "size" => size
10
+ })
11
+ end
12
+
13
+ # Get lead status stats. GET /v1/conversations/lead-stats
14
+ def lead_stats
15
+ @http.get("/v1/conversations/lead-stats")
16
+ end
17
+
18
+ # List messages in a conversation. GET /v1/conversations/{conversationId}/messages
19
+ def messages(conversation_id, page: 0, size: 50)
20
+ @http.get("/v1/conversations/#{conversation_id}/messages", params: { "page" => page, "size" => size })
21
+ end
22
+
23
+ # Reply within an open conversation window. POST /v1/conversations/reply
24
+ def reply(conversation_id:, body:)
25
+ @http.post("/v1/conversations/reply", body: { "conversationId" => conversation_id, "body" => body })
26
+ end
27
+
28
+ # Update a conversation status. PATCH /v1/conversations/{conversationId}/status
29
+ def update_status(conversation_id, status)
30
+ @http.patch("/v1/conversations/#{conversation_id}/status", body: { "status" => status })
31
+ end
32
+
33
+ # Check the 24h window status for phones. POST /v1/conversations/window-status
34
+ def window_status(phones)
35
+ @http.post("/v1/conversations/window-status", body: { "phones" => phones })
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,69 @@
1
+ require "securerandom"
2
+ require_relative "base_resource"
3
+
4
+ module Arara
5
+ module Resources
6
+ class Messages < BaseResource
7
+ BASE_PATH = "/v1/messages".freeze
8
+ MAX_BATCH_SIZE = 1000
9
+
10
+ PAYLOAD_FIELDS = {
11
+ receiver: "receiver",
12
+ sender: "sender",
13
+ type: "type",
14
+ template_name: "templateName",
15
+ template_variables: "templateVariables",
16
+ body: "body",
17
+ interactive: "interactive",
18
+ charge: "charge",
19
+ location: "location",
20
+ reaction: "reaction",
21
+ reply_to: "replyTo",
22
+ smart_link_param: "smartLinkParam",
23
+ smart_link_url: "smartLinkUrl",
24
+ scheduled_at: "scheduled_at",
25
+ mode: "mode",
26
+ media_url: "media_url"
27
+ }.freeze
28
+
29
+ # Send a WhatsApp message. POST /v1/messages
30
+ # An Idempotency-Key is generated when omitted and reused on every retry.
31
+ def send_message(receiver:, idempotency_key: nil, **fields)
32
+ unknown = fields.keys - PAYLOAD_FIELDS.keys
33
+ raise ArgumentError, "unknown message fields: #{unknown.join(', ')}" unless unknown.empty?
34
+
35
+ payload = build_payload(fields.merge(receiver: receiver))
36
+ @http.post(BASE_PATH, body: payload, idempotency_key: idempotency_key_or_generate(idempotency_key))
37
+ end
38
+
39
+ alias deliver send_message
40
+
41
+ # Send the same template to up to 1000 receivers. POST /v1/messages/batch
42
+ def send_batch(template_name:, messages:, idempotency_key: nil)
43
+ raise ArgumentError, "messages must not be empty" if messages.nil? || messages.empty?
44
+ raise ArgumentError, "messages must have at most #{MAX_BATCH_SIZE} items" if messages.size > MAX_BATCH_SIZE
45
+
46
+ payload = { "templateName" => template_name, "messages" => messages }
47
+ @http.post("#{BASE_PATH}/batch", body: payload, idempotency_key: idempotency_key_or_generate(idempotency_key))
48
+ end
49
+
50
+ # Get a message by id. GET /v1/messages/{id}
51
+ def get(id)
52
+ @http.get("#{BASE_PATH}/#{id}")
53
+ end
54
+
55
+ # List the messages of a batch. GET /v1/messages?batchId=
56
+ def list_by_batch(batch_id)
57
+ @http.get(BASE_PATH, params: { "batchId" => batch_id })
58
+ end
59
+
60
+ private
61
+
62
+ def build_payload(fields)
63
+ fields.each_with_object({}) do |(key, value), payload|
64
+ payload[PAYLOAD_FIELDS.fetch(key)] = value unless value.nil?
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,57 @@
1
+ require_relative "base_resource"
2
+
3
+ module Arara
4
+ module Resources
5
+ class Numbers < BaseResource
6
+ BASE_PATH = "/v1/organizations/me/numbers".freeze
7
+
8
+ # List numbers with plan slot info. GET /v1/organizations/me/numbers
9
+ def list
10
+ @http.get(BASE_PATH)
11
+ end
12
+
13
+ # Update a number. PATCH /v1/organizations/me/numbers/{id}
14
+ def update(id, alias_name: nil, is_default: nil, name: nil, description: nil)
15
+ payload = {
16
+ "alias" => alias_name,
17
+ "isDefault" => is_default,
18
+ "name" => name,
19
+ "description" => description
20
+ }.reject { |_, value| value.nil? }
21
+ @http.patch("#{BASE_PATH}/#{id}", body: payload)
22
+ end
23
+
24
+ # Deactivate a number. DELETE /v1/organizations/me/numbers/{id}
25
+ def delete(id)
26
+ @http.delete("#{BASE_PATH}/#{id}")
27
+ end
28
+
29
+ # Request a new dedicated number. POST /v1/organizations/me/numbers/request
30
+ def request(reason: nil, expected_volume: nil, area_code: nil, display_name: nil, profile_picture_url: nil)
31
+ payload = {
32
+ "reason" => reason,
33
+ "expectedVolume" => expected_volume,
34
+ "areaCode" => area_code,
35
+ "displayName" => display_name,
36
+ "profilePictureUrl" => profile_picture_url
37
+ }.reject { |_, value| value.nil? }
38
+ @http.post("#{BASE_PATH}/request", body: payload)
39
+ end
40
+
41
+ # List number provisioning requests. GET /v1/organizations/me/numbers/requests
42
+ def list_requests
43
+ @http.get("#{BASE_PATH}/requests")
44
+ end
45
+
46
+ # Sync a number's health from the provider. POST /v1/organizations/me/numbers/{id}/sync
47
+ def sync(id)
48
+ @http.post("#{BASE_PATH}/#{id}/sync")
49
+ end
50
+
51
+ # Get warming recommendations for a number. GET /v1/organizations/me/numbers/{id}/warming
52
+ def warming(id)
53
+ @http.get("#{BASE_PATH}/#{id}/warming")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,31 @@
1
+ require "uri"
2
+ require_relative "base_resource"
3
+
4
+ module Arara
5
+ module Resources
6
+ class OptOuts < BaseResource
7
+ BASE_PATH = "/v1/opt-outs".freeze
8
+
9
+ # List opt-outs. GET /v1/opt-outs (requires an ADMIN key)
10
+ def list
11
+ @http.get(BASE_PATH)
12
+ end
13
+
14
+ # Opt a phone out. POST /v1/opt-outs (requires an ADMIN key)
15
+ def create(phone:, reason: nil)
16
+ payload = { "phone" => phone, "reason" => reason }.reject { |_, value| value.nil? }
17
+ @http.post(BASE_PATH, body: payload)
18
+ end
19
+
20
+ # Get the opt-out of a phone. GET /v1/opt-outs/{phone}
21
+ def get(phone)
22
+ @http.get("#{BASE_PATH}/#{URI.encode_www_form_component(phone)}")
23
+ end
24
+
25
+ # Remove the opt-out of a phone. DELETE /v1/opt-outs/{phone}
26
+ def delete(phone)
27
+ @http.delete("#{BASE_PATH}/#{URI.encode_www_form_component(phone)}")
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,42 @@
1
+ require_relative "base_resource"
2
+ require_relative "../page"
3
+
4
+ module Arara
5
+ module Resources
6
+ class SmartLinks < BaseResource
7
+ BASE_PATH = "/v1/smart-links/whatsapp".freeze
8
+ DEFAULT_PAGE_SIZE = 50
9
+
10
+ # Create a WhatsApp smart link. POST /v1/smart-links/whatsapp
11
+ def create(name:, phone_number:, default_text: nil, qr_code_color: nil)
12
+ payload = {
13
+ "name" => name,
14
+ "phoneNumber" => phone_number,
15
+ "defaultText" => default_text,
16
+ "qrCodeColor" => qr_code_color
17
+ }.reject { |_, value| value.nil? }
18
+ @http.post(BASE_PATH, body: payload)
19
+ end
20
+
21
+ # Update a WhatsApp smart link. PUT /v1/smart-links/whatsapp/{id}
22
+ def update(id, name: nil, default_text: nil, qr_code_color: nil)
23
+ payload = {
24
+ "name" => name,
25
+ "defaultText" => default_text,
26
+ "qrCodeColor" => qr_code_color
27
+ }.reject { |_, value| value.nil? }
28
+ @http.put("#{BASE_PATH}/#{id}", body: payload)
29
+ end
30
+
31
+ # List WhatsApp smart links. GET /v1/smart-links/whatsapp. Returns an Arara::Page.
32
+ def list(page: 0, size: DEFAULT_PAGE_SIZE)
33
+ Arara::Page.from_data(@http.get(BASE_PATH, params: { "page" => page, "size" => size }))
34
+ end
35
+
36
+ # Get smart link click stats. GET /v1/smart-links/whatsapp/{id}/stats
37
+ def stats(id)
38
+ @http.get("#{BASE_PATH}/#{id}/stats")
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,61 @@
1
+ require_relative "base_resource"
2
+ require_relative "../page"
3
+
4
+ module Arara
5
+ module Resources
6
+ class Templates < BaseResource
7
+ BASE_PATH = "/v1/templates".freeze
8
+ DEFAULT_PAGE_SIZE = 50
9
+ DEFAULT_PERIOD = "30d".freeze
10
+
11
+ # List templates. GET /v1/templates. Returns an Arara::Page.
12
+ def list(name: nil, status: nil, page: 0, size: DEFAULT_PAGE_SIZE)
13
+ response = @http.get(BASE_PATH, params: { "name" => name, "status" => status, "page" => page, "size" => size })
14
+ Arara::Page.from_data(response)
15
+ end
16
+
17
+ # Find a template by exact name, walking every page of the filtered list. Returns nil when absent.
18
+ def find_by_name(name)
19
+ page_number = 0
20
+ loop do
21
+ page = list(name: name, page: page_number)
22
+ match = page.find { |template| template["name"] == name }
23
+ return match if match
24
+ return nil unless page.next_page?
25
+
26
+ page_number += 1
27
+ end
28
+ end
29
+
30
+ # Create a template for Meta approval. POST /v1/templates
31
+ def create(payload)
32
+ @http.post(BASE_PATH, body: payload)
33
+ end
34
+
35
+ # Get a template by id (UUID). GET /v1/templates/{id}
36
+ def get(id)
37
+ @http.get("#{BASE_PATH}/#{id}")
38
+ end
39
+
40
+ # Get template status from provider. GET /v1/templates/{id}/status
41
+ def get_status(id)
42
+ @http.get("#{BASE_PATH}/#{id}/status")
43
+ end
44
+
45
+ # Delete a template by id (UUID). DELETE /v1/templates/{id}
46
+ def delete(id)
47
+ @http.delete("#{BASE_PATH}/#{id}")
48
+ end
49
+
50
+ # Analytics of all templates. GET /v1/templates/analytics
51
+ def analytics(period: DEFAULT_PERIOD)
52
+ @http.get("#{BASE_PATH}/analytics", params: { "period" => period })
53
+ end
54
+
55
+ # Analytics of one template. GET /v1/templates/{id}/analytics
56
+ def template_analytics(id, period: DEFAULT_PERIOD)
57
+ @http.get("#{BASE_PATH}/#{id}/analytics", params: { "period" => period })
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,31 @@
1
+ require_relative "base_resource"
2
+ require_relative "../page"
3
+
4
+ module Arara
5
+ module Resources
6
+ class Wallet < BaseResource
7
+ DEFAULT_PAGE_SIZE = 20
8
+
9
+ # List wallet transactions. GET /v1/wallet/transactions. Returns an Arara::Page.
10
+ def transactions(page: 0, size: DEFAULT_PAGE_SIZE)
11
+ response = @http.get("/v1/wallet/transactions", params: { "page" => page, "size" => size })
12
+ Arara::Page.from_content(response, page: page, size: size)
13
+ end
14
+
15
+ # Get auto-recharge settings. GET /v1/wallet/auto-recharge
16
+ def get_auto_recharge
17
+ @http.get("/v1/wallet/auto-recharge")
18
+ end
19
+
20
+ # Update auto-recharge settings. PATCH /v1/wallet/auto-recharge
21
+ def update_auto_recharge(enabled: nil, threshold: nil, amount: nil)
22
+ payload = {
23
+ "enabled" => enabled,
24
+ "threshold" => threshold,
25
+ "amount" => amount
26
+ }.reject { |_, value| value.nil? }
27
+ @http.patch("/v1/wallet/auto-recharge", body: payload)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,3 @@
1
+ module Arara
2
+ VERSION = "1.0.0".freeze
3
+ end
data/lib/arara.rb ADDED
@@ -0,0 +1,7 @@
1
+ require_relative "arara/version"
2
+ require_relative "arara/errors"
3
+ require_relative "arara/page"
4
+ require_relative "arara/client"
5
+
6
+ module Arara
7
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ararahq
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - AraraHQ
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-29 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'Ruby client for the AraraHQ API: messages, templates, contacts, conversations,
14
+ campaigns, wallet, numbers, smart links and opt-outs. Zero runtime dependencies.'
15
+ email:
16
+ - dev@ararahq.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - arara.gemspec
25
+ - lib/arara.rb
26
+ - lib/arara/client.rb
27
+ - lib/arara/errors.rb
28
+ - lib/arara/http_client.rb
29
+ - lib/arara/page.rb
30
+ - lib/arara/resources/auth.rb
31
+ - lib/arara/resources/base_resource.rb
32
+ - lib/arara/resources/campaigns.rb
33
+ - lib/arara/resources/contacts.rb
34
+ - lib/arara/resources/conversations.rb
35
+ - lib/arara/resources/messages.rb
36
+ - lib/arara/resources/numbers.rb
37
+ - lib/arara/resources/opt_outs.rb
38
+ - lib/arara/resources/smart_links.rb
39
+ - lib/arara/resources/templates.rb
40
+ - lib/arara/resources/wallet.rb
41
+ - lib/arara/version.rb
42
+ homepage: https://ararahq.com
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ homepage_uri: https://ararahq.com
47
+ source_code_uri: https://github.com/ararahq/arara-ruby-sdk
48
+ changelog_uri: https://github.com/ararahq/arara-ruby-sdk/blob/main/CHANGELOG.md
49
+ rubygems_mfa_required: 'true'
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '3.0'
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.4.19
66
+ signing_key:
67
+ specification_version: 4
68
+ summary: Official Ruby SDK for the AraraHQ WhatsApp API.
69
+ test_files: []