walinko 0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fab8530917c42b499678f18a29388a6a022aa95db4244b52aab4521f86769103
4
+ data.tar.gz: 7229de0b6795beb1cad9e3f3ce3194e48a008029c0b0ea085b035a7df8bc0540
5
+ SHA512:
6
+ metadata.gz: 9632be9a36b8603403f9bb32007856f0524a6d107ad917103f6e7b3303eebabbd131a49a1269664be6a5a49bcb4c416b9d6cd47c02768dbc35ec0344f6b730dd
7
+ data.tar.gz: 5f86b31f8b0e9d1aec9fafc54e1d14db11c3abb23bc64e8424415b221f75741b5a9450d2fe726ca773344bddf8993e130a5626b2a9e867f45bbb1a99fef9d0a6
data/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog — walinko (Ruby)
2
+
3
+ All notable changes to this gem are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] — 2026-05-01
8
+
9
+ First publishable release. Targets Walinko public API
10
+ `POST /api/v1/public/messages` and `GET /api/v1/public/messages/:tracking_id`.
11
+
12
+ ### Added
13
+ - `Walinko::Client.new(api_key:, base_url:, timeout:, open_timeout:,
14
+ max_retries:, logger:)`.
15
+ - `client.messages.send` (sync) returning `Walinko::SyncResult`.
16
+ - `client.messages.enqueue` (async) returning `Walinko::AsyncJob`.
17
+ - `client.messages.fetch(tracking_id)` returning `Walinko::MessageStatus`.
18
+ - `client.messages.wait_until_done(tracking_id, timeout:, interval:)` —
19
+ polls until the delivery reaches a terminal state, raises
20
+ `Walinko::TimeoutError` if it doesn't.
21
+ - Typed exception hierarchy (`Walinko::Error` →
22
+ `Walinko::ApiError` / `Walinko::ConnectionError`), with specialized
23
+ classes for every documented `error_code` (`AuthenticationError`,
24
+ `BadRequestError`, `ForbiddenError`, `TenantSuspendedError`,
25
+ `QuotaExceededError`, `NotFoundError`, `ConflictError`,
26
+ `DeviceDisconnectedError`, `IdempotencyConflictError`,
27
+ `ValidationError`, `RateLimitError`, `ServerError`,
28
+ `TimeoutError`).
29
+ - Automatic `Idempotency-Key` generation for `send` / `enqueue` (use
30
+ `idempotency_key:` to override).
31
+ - Idempotent retry policy: network errors, 429 (honouring `Retry-After`),
32
+ and 5xx are retried up to `max_retries` with exponential backoff +
33
+ jitter. The same `Idempotency-Key` is reused on every retry.
34
+ - `client.last_rate_limit` and `client.last_request_id` reflect the
35
+ most recent response's `X-RateLimit-*` and `X-Request-Id` headers.
36
+ - `Idempotent-Replayed: true` is surfaced on `SyncResult#idempotent_replayed`
37
+ / `AsyncJob#idempotent_replayed`.
38
+
39
+ ### Internal
40
+ - 100% stdlib transport — no Faraday / HTTPX dependency.
41
+ - Ruby 3.1+ required.
42
+ - 66 RSpec examples covering every documented error code, retry path,
43
+ and idempotency edge case.
data/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # walinko (Ruby)
2
+
3
+ Official Ruby client for the [Walinko](https://walinko.com) public API.
4
+
5
+ Send transactional WhatsApp messages from your Ruby app with idempotent
6
+ retries, structured errors, and a tiny dependency footprint (Ruby stdlib
7
+ only).
8
+
9
+ * Ruby 3.1+
10
+ * Zero runtime dependencies (only `net/http` + `json` from stdlib)
11
+ * MIT licensed
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ gem install walinko
17
+ ```
18
+
19
+ …or in a `Gemfile`:
20
+
21
+ ```ruby
22
+ gem 'walinko', '~> 0.1'
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ```ruby
28
+ require 'walinko'
29
+
30
+ client = Walinko::Client.new(
31
+ api_key: ENV.fetch('WALINKO_API_KEY'),
32
+ base_url: 'https://api.walinko.com', # optional
33
+ timeout: 30, # optional, seconds
34
+ max_retries: 2 # optional
35
+ )
36
+
37
+ # Sync send — blocks until the message is delivered (or 504 timeout).
38
+ result = client.messages.send(
39
+ device_id: 1,
40
+ template_id: 12,
41
+ variant_index: 0, # optional, nil = primary
42
+ phone: '+8801617738431',
43
+ variables: { name: 'Kazi', dist: 'Dhaka' }
44
+ )
45
+
46
+ puts result.tracking_id # tx_...
47
+ puts result.wa_message_id # 3EB0...
48
+ puts result.status # "sent"
49
+
50
+ # Async enqueue + poll.
51
+ job = client.messages.enqueue(
52
+ device_id: 1, template_id: 12,
53
+ phone: '+8801617738431',
54
+ variables: { name: 'Kazi', dist: 'Dhaka' }
55
+ )
56
+
57
+ final = client.messages.wait_until_done(job.tracking_id, timeout: 60)
58
+ puts final.status # "sent" | "failed"
59
+ ```
60
+
61
+ ## Looking up a delivery
62
+
63
+ ```ruby
64
+ status = client.messages.fetch('tx_767fd2faca0f4037b2a2bbcb91e5735f')
65
+ status.sent? # true / false
66
+ status.error_code # nil if sent, e.g. "phone_not_on_whatsapp" if failed
67
+ status.wa_message_id # WhatsApp's id, set on success
68
+ status.created_at # Time
69
+ status.sent_at # Time, nil while pending
70
+ ```
71
+
72
+ ## Errors
73
+
74
+ Every error is a subclass of `Walinko::Error`. See
75
+ [`docs/error-codes.md`](../../docs/error-codes.md) for the full mapping.
76
+
77
+ ```ruby
78
+ begin
79
+ client.messages.send(...)
80
+ rescue Walinko::RateLimitError => e
81
+ sleep e.retry_after
82
+ retry
83
+ rescue Walinko::ValidationError => e
84
+ warn e.fields # { phone: ['must match pattern …'] }
85
+ rescue Walinko::DeviceDisconnectedError
86
+ # tell the user to reconnect their device
87
+ rescue Walinko::Error => e
88
+ Rails.logger.error("Walinko send failed: #{e.message}")
89
+ end
90
+ ```
91
+
92
+ ## Idempotency
93
+
94
+ The SDK auto-generates a UUID `Idempotency-Key` for every `send` / `enqueue`
95
+ call so retries are safe end-to-end. Pass `idempotency_key:` to set your own
96
+ (e.g. tying a send to your domain object).
97
+
98
+ ## Retries
99
+
100
+ The SDK auto-retries idempotently on:
101
+
102
+ | Trigger | Behaviour |
103
+ | ----------------------- | ----------------------------------------------- |
104
+ | Network errors | Exponential backoff with jitter |
105
+ | HTTP 429 | Honours `Retry-After` (capped at 60s) |
106
+ | HTTP 500/502/503/504 | Exponential backoff with jitter |
107
+
108
+ `max_retries` (default 2) controls how many additional attempts are made.
109
+ 4xx responses (other than 429) are surfaced immediately — the request is
110
+ malformed or the server has rejected it on application grounds, and no
111
+ amount of retrying will help.
112
+
113
+ ## Rate limits
114
+
115
+ The server enforces 30 req/min/key (sliding window). The SDK exposes the
116
+ latest known window state via:
117
+
118
+ ```ruby
119
+ client.last_rate_limit
120
+ # => #<Walinko::RateLimitSnapshot limit=30 remaining=29>
121
+ client.last_request_id
122
+ # => "req_4f2c..." (handy for support tickets)
123
+ ```
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ cd sdks/ruby
129
+ bundle install
130
+ bundle exec rspec
131
+ bundle exec rubocop
132
+ ```
133
+
134
+ ## License
135
+
136
+ [MIT](../../LICENSE)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Walinko
4
+ # Immutable configuration captured by `Walinko::Client.new`. Validates
5
+ # required fields and applies defaults.
6
+ class Configuration
7
+ DEFAULT_BASE_URL = 'https://api.walinko.com'
8
+ DEFAULT_TIMEOUT = 30
9
+ DEFAULT_OPEN_TIMEOUT = 10
10
+ DEFAULT_MAX_RETRIES = 2
11
+
12
+ attr_reader :api_key, :base_url, :timeout, :open_timeout,
13
+ :max_retries, :logger
14
+
15
+ # @param api_key [String] required, e.g. "walk_live_<keyId>.<secret>"
16
+ # @param base_url [String] defaults to "https://api.walinko.com"
17
+ # @param timeout [Integer] per-request read timeout in seconds
18
+ # @param open_timeout [Integer] TCP/TLS connection-setup timeout in seconds
19
+ # @param max_retries [Integer] retries on idempotent failures (network, 429, 5xx)
20
+ # @param logger [#info,#warn,#error] optional structured logger
21
+ def initialize(api_key:,
22
+ base_url: DEFAULT_BASE_URL,
23
+ timeout: DEFAULT_TIMEOUT,
24
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
25
+ max_retries: DEFAULT_MAX_RETRIES,
26
+ logger: nil)
27
+ raise ArgumentError, 'api_key is required' if api_key.nil? || api_key.to_s.empty?
28
+ raise ArgumentError, 'base_url is required' if base_url.nil? || base_url.to_s.empty?
29
+ raise ArgumentError, 'timeout must be > 0' if timeout.to_i <= 0
30
+ raise ArgumentError, 'open_timeout must be > 0' if open_timeout.to_i <= 0
31
+ raise ArgumentError, 'max_retries must be >= 0' if max_retries.to_i.negative?
32
+
33
+ @api_key = api_key.to_s
34
+ @base_url = base_url.to_s.sub(%r{/+$}, '')
35
+ @timeout = timeout.to_i
36
+ @open_timeout = open_timeout.to_i
37
+ @max_retries = max_retries.to_i
38
+ @logger = logger
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Walinko
4
+ # Base class for every exception raised by the SDK. Catch this to handle
5
+ # anything Walinko throws.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when we couldn't reach the API at all (DNS failure, connection
9
+ # refused, TLS handshake failed, socket reset, etc.). These are retried
10
+ # automatically up to `max_retries`; if you see one, the retry budget was
11
+ # exhausted.
12
+ class ConnectionError < Error
13
+ attr_reader :cause_class
14
+
15
+ def initialize(message, cause_class: nil)
16
+ super(message)
17
+ @cause_class = cause_class
18
+ end
19
+ end
20
+
21
+ # Base class for every error response returned by the API. Carries the
22
+ # full diagnostic payload — HTTP status, server-side `error_code`, the
23
+ # `X-Request-Id` (handy for support tickets), and the optional `details`
24
+ # blob.
25
+ class ApiError < Error
26
+ attr_reader :http_status, :error_code, :request_id, :details, :body
27
+
28
+ def initialize(message, http_status:, error_code: nil, request_id: nil,
29
+ details: nil, body: nil)
30
+ super(message)
31
+ @http_status = http_status
32
+ @error_code = error_code
33
+ @request_id = request_id
34
+ @details = details || {}
35
+ @body = body
36
+ end
37
+
38
+ def inspect
39
+ parts = [self.class.name, "status=#{http_status}"]
40
+ parts << "code=#{error_code}" if error_code
41
+ parts << "request_id=#{request_id}" if request_id
42
+ "#<#{parts.join(' ')} message=#{message.inspect}>"
43
+ end
44
+ end
45
+
46
+ # 401 — missing / malformed / expired / revoked / unknown API key.
47
+ # All five are returned with the same generic message on purpose.
48
+ class AuthenticationError < ApiError; end
49
+
50
+ # 400 — malformed request, unknown body shape, or `variant_index` out of
51
+ # range. Validation errors are raised as `ValidationError` instead.
52
+ class BadRequestError < ApiError; end
53
+
54
+ # 403 — generic forbidden. See specialized subclasses below.
55
+ class ForbiddenError < ApiError; end
56
+
57
+ # 403 + `error_code: tenant_suspended` — tenant account is suspended or
58
+ # scheduled for deletion.
59
+ class TenantSuspendedError < ForbiddenError; end
60
+
61
+ # 403 + `error_code: quota_exceeded` — tenant has hit its monthly /
62
+ # daily / balance message limit. `details[:resets_at]` (when present)
63
+ # tells you when the limit resets.
64
+ class QuotaExceededError < ForbiddenError; end
65
+
66
+ # 404 — device, template, or tracking id not found for this tenant.
67
+ class NotFoundError < ApiError; end
68
+
69
+ # 409 — generic conflict. See specialized subclasses below.
70
+ class ConflictError < ApiError; end
71
+
72
+ # 409 + `error_code: device_disconnected` — device session is not
73
+ # connected. Reconnect from the dashboard, then retry.
74
+ class DeviceDisconnectedError < ConflictError; end
75
+
76
+ # 409 + `error_code: idempotency_conflict` — `Idempotency-Key` was
77
+ # previously used with a *different* payload. Use a new key.
78
+ class IdempotencyConflictError < ConflictError; end
79
+
80
+ # 422 — semantic validation failure. For DTO validation errors (the
81
+ # default class-validator path), `#fields` returns a `{field => [reason,
82
+ # ...]}` map. For `phone_not_on_whatsapp` the map is empty and the
83
+ # reason is in `#message`.
84
+ class ValidationError < ApiError
85
+ # @return [Hash{String => Array<String>}]
86
+ def fields
87
+ f = details[:fields] || details['fields']
88
+ f.is_a?(Hash) ? f : {}
89
+ end
90
+ end
91
+
92
+ # 429 — sliding-window rate limit (default 30 req/min/key) exceeded.
93
+ # `#retry_after` is the recommended sleep in seconds (parsed from
94
+ # `Retry-After` header).
95
+ class RateLimitError < ApiError
96
+ attr_reader :retry_after
97
+
98
+ def initialize(message, retry_after: nil, **kwargs)
99
+ super(message, **kwargs)
100
+ @retry_after = retry_after
101
+ end
102
+ end
103
+
104
+ # 5xx — server-side failure. Outcome of the underlying send may or may
105
+ # not have happened; replay with the same `Idempotency-Key` is safe.
106
+ class ServerError < ApiError; end
107
+
108
+ # 504 — WhatsApp send did not complete within the server's 15s window.
109
+ # Outcome unknown; replay with the same `Idempotency-Key` is safe.
110
+ class TimeoutError < ApiError; end
111
+
112
+ # Internal: maps an HTTP status + server `error_code` to one of the
113
+ # typed exception classes above. Public so users can introspect the
114
+ # mapping in tests if they want.
115
+ module ErrorMapping
116
+ BY_HTTP_STATUS = {
117
+ 400 => BadRequestError,
118
+ 401 => AuthenticationError,
119
+ 403 => ForbiddenError,
120
+ 404 => NotFoundError,
121
+ 409 => ConflictError,
122
+ 422 => ValidationError,
123
+ 429 => RateLimitError,
124
+ 504 => TimeoutError
125
+ }.freeze
126
+
127
+ BY_ERROR_CODE = {
128
+ 'tenant_suspended' => TenantSuspendedError,
129
+ 'quota_exceeded' => QuotaExceededError,
130
+ 'device_disconnected' => DeviceDisconnectedError,
131
+ 'idempotency_conflict' => IdempotencyConflictError
132
+ }.freeze
133
+
134
+ # @return [Class<ApiError>]
135
+ def self.for(http_status:, error_code: nil)
136
+ BY_ERROR_CODE[error_code.to_s] ||
137
+ BY_HTTP_STATUS[http_status] ||
138
+ (http_status.between?(500, 599) ? ServerError : ApiError)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ require_relative 'errors'
8
+ require_relative 'result'
9
+
10
+ module Walinko
11
+ # Internal HTTP transport. Owns the wire format (JSON in/out, header
12
+ # naming), retry policy, and error mapping. The `Messages` resource is
13
+ # the only consumer.
14
+ #
15
+ # Public methods are documented but treat anything outside `request` as
16
+ # internal — minor versions may refactor.
17
+ class HttpClient
18
+ Response = Struct.new(
19
+ :status,
20
+ :body,
21
+ :request_id,
22
+ :rate_limit,
23
+ :idempotent_replayed,
24
+ keyword_init: true
25
+ )
26
+
27
+ # Statuses we consider transient and retry automatically.
28
+ RETRYABLE_HTTP_STATUSES = [429, 500, 502, 503, 504].freeze
29
+
30
+ # Backoff curve (seconds). Index = attempt number (0 = first retry).
31
+ # Caps at the last entry.
32
+ BACKOFF_SECONDS = [0.25, 0.75, 1.75, 3.75].freeze
33
+
34
+ # Cap on `Retry-After` honoring — don't let the server make us sleep
35
+ # forever.
36
+ MAX_RETRY_AFTER_SECONDS = 60
37
+
38
+ attr_reader :last_request_id, :last_rate_limit
39
+
40
+ # @param config [Walinko::Configuration]
41
+ def initialize(config)
42
+ @config = config
43
+ end
44
+
45
+ # Issues an HTTP request with the configured retry policy.
46
+ #
47
+ # @param method [Symbol] :get / :post
48
+ # @param path [String] e.g. "/api/v1/public/messages"
49
+ # @param body [Hash, nil] JSON-encoded into the request body
50
+ # @param headers [Hash{String => String}] extra headers to merge
51
+ # @return [Response]
52
+ # @raise [Walinko::ApiError, Walinko::ConnectionError]
53
+ def request(method:, path:, body: nil, headers: {})
54
+ attempt = 0
55
+
56
+ loop do
57
+ result = perform(method: method, path: path, body: body, headers: headers)
58
+ return result if result.is_a?(Response)
59
+
60
+ # `result` is a retryable error (Exception subclass) — decide
61
+ # whether to retry.
62
+ attempt += 1
63
+ raise result if attempt > @config.max_retries
64
+
65
+ sleep_for = sleep_seconds(result, attempt)
66
+ log(:warn,
67
+ "retrying after #{sleep_for}s (attempt #{attempt}/#{@config.max_retries}): #{result.message}")
68
+ sleep(sleep_for) if sleep_for.positive?
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ # Returns either a `Response` (success) or an Exception instance
75
+ # representing a retryable failure. Non-retryable failures are
76
+ # raised directly.
77
+ def perform(method:, path:, body:, headers:)
78
+ uri = URI.join("#{@config.base_url}/", path.sub(%r{^/}, ''))
79
+ req = build_request(method, uri, body: body, headers: headers)
80
+
81
+ http = Net::HTTP.new(uri.host, uri.port)
82
+ http.use_ssl = (uri.scheme == 'https')
83
+ http.read_timeout = @config.timeout
84
+ http.open_timeout = @config.open_timeout
85
+ http.write_timeout = @config.timeout if http.respond_to?(:write_timeout=)
86
+
87
+ raw = http.request(req)
88
+ handle_response(raw)
89
+ rescue *connection_error_classes => e
90
+ ConnectionError.new("#{e.class}: #{e.message}", cause_class: e.class)
91
+ rescue ConnectionError => e
92
+ e
93
+ end
94
+
95
+ # Network/timeout errors that should map to ConnectionError and be
96
+ # retried.
97
+ def connection_error_classes
98
+ [
99
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
100
+ Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
101
+ SocketError, IOError,
102
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
103
+ OpenSSL::SSL::SSLError,
104
+ EOFError
105
+ ]
106
+ end
107
+
108
+ def build_request(method, uri, body:, headers:)
109
+ request_class =
110
+ case method
111
+ when :get then Net::HTTP::Get
112
+ when :post then Net::HTTP::Post
113
+ else raise ArgumentError, "Unsupported method #{method.inspect}"
114
+ end
115
+
116
+ req = request_class.new(uri.request_uri)
117
+ req['Authorization'] = "Bearer #{@config.api_key}"
118
+ req['Accept'] = 'application/json'
119
+ headers.each { |k, v| req[k] = v unless v.nil? }
120
+ if body
121
+ req['Content-Type'] = 'application/json'
122
+ req.body = JSON.generate(body)
123
+ end
124
+ req
125
+ end
126
+
127
+ def handle_response(raw)
128
+ status = raw.code.to_i
129
+ request_id = raw['X-Request-Id'] || raw['x-request-id']
130
+ rate_limit = parse_rate_limit(raw)
131
+ replayed = raw['Idempotent-Replayed'] == 'true'
132
+
133
+ @last_request_id = request_id
134
+ @last_rate_limit = rate_limit
135
+
136
+ parsed = safe_parse_json(raw.body)
137
+
138
+ if (200..299).cover?(status)
139
+ return Response.new(
140
+ status: status,
141
+ body: parsed,
142
+ request_id: request_id,
143
+ rate_limit: rate_limit,
144
+ idempotent_replayed: replayed
145
+ )
146
+ end
147
+
148
+ build_api_error(status, parsed, raw)
149
+ end
150
+
151
+ # Builds the typed exception. For retryable statuses returns the
152
+ # exception (caller decides whether to retry); for non-retryable
153
+ # statuses raises immediately.
154
+ def build_api_error(status, body, raw)
155
+ message = (body.is_a?(Hash) && body['message']) || "HTTP #{status}"
156
+ error_code = body.is_a?(Hash) ? body['error_code'] : nil
157
+ details = body.is_a?(Hash) ? body['details'] : nil
158
+ request_id = raw['X-Request-Id'] || raw['x-request-id']
159
+
160
+ klass = ErrorMapping.for(http_status: status, error_code: error_code)
161
+
162
+ err =
163
+ if klass <= RateLimitError
164
+ retry_after = parse_retry_after(raw)
165
+ klass.new(message,
166
+ http_status: status, error_code: error_code,
167
+ request_id: request_id, details: stringify_keys(details),
168
+ body: body, retry_after: retry_after)
169
+ else
170
+ klass.new(message,
171
+ http_status: status, error_code: error_code,
172
+ request_id: request_id, details: stringify_keys(details),
173
+ body: body)
174
+ end
175
+
176
+ return err if RETRYABLE_HTTP_STATUSES.include?(status)
177
+
178
+ raise err
179
+ end
180
+
181
+ def parse_rate_limit(raw)
182
+ limit_h = raw['X-RateLimit-Limit']
183
+ remaining_h = raw['X-RateLimit-Remaining']
184
+ return nil if limit_h.nil? && remaining_h.nil?
185
+
186
+ RateLimitSnapshot.new(
187
+ limit: limit_h&.to_i,
188
+ remaining: remaining_h&.to_i
189
+ )
190
+ end
191
+
192
+ def parse_retry_after(raw)
193
+ v = raw['Retry-After']
194
+ return nil if v.nil?
195
+
196
+ v.to_i.positive? ? v.to_i : nil
197
+ end
198
+
199
+ def safe_parse_json(body)
200
+ return nil if body.nil? || body.empty?
201
+
202
+ JSON.parse(body)
203
+ rescue JSON::ParserError
204
+ nil
205
+ end
206
+
207
+ def stringify_keys(hash)
208
+ return {} unless hash.is_a?(Hash)
209
+
210
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
211
+ end
212
+
213
+ def sleep_seconds(error, attempt)
214
+ if error.is_a?(RateLimitError) && error.retry_after
215
+ return [error.retry_after, MAX_RETRY_AFTER_SECONDS].min
216
+ end
217
+
218
+ base = BACKOFF_SECONDS[[attempt - 1, BACKOFF_SECONDS.size - 1].min]
219
+ base + (rand * 0.1) # tiny jitter
220
+ end
221
+
222
+ def log(level, message)
223
+ logger = @config.logger
224
+ return unless logger.respond_to?(level)
225
+
226
+ logger.public_send(level, "[walinko] #{message}")
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ require_relative 'errors'
6
+ require_relative 'http_client'
7
+ require_relative 'result'
8
+
9
+ module Walinko
10
+ # The `messages` resource on `Walinko::Client`. Wraps:
11
+ #
12
+ # POST /api/v1/public/messages
13
+ # GET /api/v1/public/messages/:tracking_id
14
+ class Messages
15
+ SEND_PATH = '/api/v1/public/messages'
16
+
17
+ # @api private
18
+ def initialize(http_client)
19
+ @http = http_client
20
+ end
21
+
22
+ # Synchronous send: blocks until the WhatsApp gateway acknowledges
23
+ # delivery (or the server's 15s timeout fires).
24
+ #
25
+ # @param device_id [Integer] required
26
+ # @param template_id [Integer] required
27
+ # @param phone [String] required, E.164 (`+8801617738431`)
28
+ # @param variables [Hash] optional `{ name: 'value' }` map
29
+ # @param variant_index [Integer] optional, 0 = primary
30
+ # @param idempotency_key [String] optional; auto-generated otherwise
31
+ # @return [Walinko::SyncResult]
32
+ def send(device_id:, template_id:, phone:,
33
+ variables: nil, variant_index: nil,
34
+ idempotency_key: nil)
35
+ payload = build_payload(
36
+ device_id: device_id, template_id: template_id,
37
+ phone: phone, variables: variables,
38
+ variant_index: variant_index, async: false
39
+ )
40
+
41
+ response = post(payload, idempotency_key: idempotency_key)
42
+ data = extract_data(response.body)
43
+
44
+ SyncResult.new(
45
+ data: data,
46
+ request_id: response.request_id,
47
+ rate_limit: response.rate_limit,
48
+ idempotent_replayed: response.idempotent_replayed
49
+ )
50
+ end
51
+
52
+ # Asynchronous enqueue: server returns immediately with a tracking id;
53
+ # the actual WhatsApp send happens out-of-band. Poll `fetch` (or use
54
+ # `wait_until_done`) for the final state.
55
+ #
56
+ # @return [Walinko::AsyncJob]
57
+ def enqueue(device_id:, template_id:, phone:,
58
+ variables: nil, variant_index: nil,
59
+ idempotency_key: nil)
60
+ payload = build_payload(
61
+ device_id: device_id, template_id: template_id,
62
+ phone: phone, variables: variables,
63
+ variant_index: variant_index, async: true
64
+ )
65
+
66
+ response = post(payload, idempotency_key: idempotency_key)
67
+ data = extract_data(response.body)
68
+
69
+ AsyncJob.new(
70
+ data: data,
71
+ request_id: response.request_id,
72
+ rate_limit: response.rate_limit,
73
+ idempotent_replayed: response.idempotent_replayed
74
+ )
75
+ end
76
+
77
+ # Look up a delivery by its tracking id.
78
+ #
79
+ # @param tracking_id [String] e.g. `tx_767fd2faca0f4037b2a2bbcb91e5735f`
80
+ # @return [Walinko::MessageStatus]
81
+ def fetch(tracking_id)
82
+ raise ArgumentError, 'tracking_id is required' if tracking_id.nil? || tracking_id.to_s.empty?
83
+
84
+ response = @http.request(method: :get, path: "#{SEND_PATH}/#{tracking_id}")
85
+ data = extract_data(response.body)
86
+
87
+ MessageStatus.new(data: data, request_id: response.request_id)
88
+ end
89
+
90
+ # Poll `fetch` until the delivery reaches a terminal state (sent /
91
+ # failed) or the timeout expires.
92
+ #
93
+ # @param tracking_id [String]
94
+ # @param timeout [Integer] max seconds to wait, default 60
95
+ # @param interval [Numeric] seconds between polls, default 2
96
+ # @return [Walinko::MessageStatus]
97
+ # @raise [Walinko::TimeoutError] if the message is still pending
98
+ # when `timeout` elapses (the message is *not* failed —
99
+ # continue polling later if you need to)
100
+ def wait_until_done(tracking_id, timeout: 60, interval: 2)
101
+ raise ArgumentError, 'timeout must be > 0' if timeout.to_i <= 0
102
+ raise ArgumentError, 'interval must be > 0' if interval.to_f <= 0
103
+
104
+ deadline = monotonic_now + timeout.to_f
105
+ loop do
106
+ status = fetch(tracking_id)
107
+ return status if status.done?
108
+
109
+ if monotonic_now + interval.to_f >= deadline
110
+ raise TimeoutError.new(
111
+ "Timed out waiting for #{tracking_id} after #{timeout}s (still #{status.status})",
112
+ http_status: 504,
113
+ error_code: 'send_timeout',
114
+ request_id: status.request_id,
115
+ details: { 'last_status' => status.status }
116
+ )
117
+ end
118
+
119
+ sleep(interval.to_f)
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def post(payload, idempotency_key:)
126
+ key = idempotency_key&.to_s
127
+ key = generate_idempotency_key if key.nil? || key.empty?
128
+
129
+ @http.request(
130
+ method: :post,
131
+ path: SEND_PATH,
132
+ body: payload,
133
+ headers: { 'Idempotency-Key' => key }
134
+ )
135
+ end
136
+
137
+ def build_payload(device_id:, template_id:, phone:,
138
+ variables:, variant_index:, async:)
139
+ raise ArgumentError, 'device_id is required' if device_id.nil?
140
+ raise ArgumentError, 'template_id is required' if template_id.nil?
141
+ raise ArgumentError, 'phone is required' if phone.nil? || phone.to_s.empty?
142
+
143
+ payload = {
144
+ 'device_id' => Integer(device_id),
145
+ 'template_id' => Integer(template_id),
146
+ 'phone' => phone.to_s,
147
+ 'async' => async
148
+ }
149
+ payload['variant_index'] = Integer(variant_index) unless variant_index.nil?
150
+ payload['variables'] = stringify_variables(variables) if variables
151
+ payload
152
+ end
153
+
154
+ def stringify_variables(variables)
155
+ raise ArgumentError, 'variables must be a Hash' unless variables.is_a?(Hash)
156
+
157
+ variables.each_with_object({}) do |(k, v), out|
158
+ out[k.to_s] = v.nil? ? '' : v.to_s
159
+ end
160
+ end
161
+
162
+ def extract_data(body)
163
+ return {} unless body.is_a?(Hash)
164
+
165
+ data = body['data']
166
+ data.is_a?(Hash) ? data : {}
167
+ end
168
+
169
+ def generate_idempotency_key
170
+ "walinko-rb-#{SecureRandom.uuid}"
171
+ end
172
+
173
+ def monotonic_now
174
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Walinko
6
+ # Returned by `client.messages.send(...)` (sync mode). Wraps the
7
+ # `data` block of a 200 OK response.
8
+ class SyncResult
9
+ attr_reader :tracking_id, :status, :device_id, :template_id,
10
+ :variant_index, :phone, :sent_at, :wa_message_id,
11
+ :request_id, :rate_limit, :idempotent_replayed
12
+
13
+ def initialize(data:, request_id:, rate_limit:, idempotent_replayed:)
14
+ @tracking_id = data['tracking_id']
15
+ @status = data['status']
16
+ @device_id = data['device_id']
17
+ @template_id = data['template_id']
18
+ @variant_index = data['variant_index']
19
+ @phone = data['phone']
20
+ @sent_at = parse_time(data['sent_at'])
21
+ @wa_message_id = data['wa_message_id']
22
+ @request_id = request_id
23
+ @rate_limit = rate_limit
24
+ @idempotent_replayed = idempotent_replayed
25
+ end
26
+
27
+ def sent?
28
+ status == 'sent'
29
+ end
30
+
31
+ private
32
+
33
+ def parse_time(value)
34
+ return nil if value.nil? || value.to_s.empty?
35
+
36
+ Time.iso8601(value)
37
+ rescue ArgumentError
38
+ nil
39
+ end
40
+ end
41
+
42
+ # Returned by `client.messages.enqueue(...)` (async mode). Wraps the
43
+ # `data` block of a 202 Accepted response.
44
+ class AsyncJob
45
+ attr_reader :tracking_id, :status, :status_url,
46
+ :request_id, :rate_limit, :idempotent_replayed
47
+
48
+ def initialize(data:, request_id:, rate_limit:, idempotent_replayed:)
49
+ @tracking_id = data['tracking_id']
50
+ @status = data['status']
51
+ @status_url = data['status_url']
52
+ @request_id = request_id
53
+ @rate_limit = rate_limit
54
+ @idempotent_replayed = idempotent_replayed
55
+ end
56
+
57
+ def queued?
58
+ status == 'queued'
59
+ end
60
+ end
61
+
62
+ # Returned by `client.messages.fetch(tracking_id)`. Wraps the `data`
63
+ # block of `GET /messages/:trackingId`.
64
+ class MessageStatus
65
+ attr_reader :tracking_id, :status, :device_id, :template_id,
66
+ :variant_index, :phone, :wa_message_id,
67
+ :error_code, :error_message,
68
+ :sent_at, :created_at,
69
+ :request_id
70
+
71
+ def initialize(data:, request_id:)
72
+ @tracking_id = data['tracking_id']
73
+ @status = data['status']
74
+ @device_id = data['device_id']
75
+ @template_id = data['template_id']
76
+ @variant_index = data['variant_index']
77
+ @phone = data['phone']
78
+ @wa_message_id = data['wa_message_id']
79
+ @error_code = data['error_code']
80
+ @error_message = data['error_message']
81
+ @sent_at = parse_time(data['sent_at'])
82
+ @created_at = parse_time(data['created_at'])
83
+ @request_id = request_id
84
+ end
85
+
86
+ def sent?; status == 'sent'; end
87
+ def failed?; status == 'failed'; end
88
+ def pending?; %w[queued sending].include?(status); end
89
+ def done?; sent? || failed?; end
90
+
91
+ private
92
+
93
+ def parse_time(value)
94
+ return nil if value.nil? || value.to_s.empty?
95
+
96
+ Time.iso8601(value)
97
+ rescue ArgumentError
98
+ nil
99
+ end
100
+ end
101
+
102
+ # Snapshot of the server-reported rate-limit window from the most
103
+ # recent response.
104
+ class RateLimitSnapshot
105
+ attr_reader :limit, :remaining
106
+
107
+ def initialize(limit:, remaining:)
108
+ @limit = limit
109
+ @remaining = remaining
110
+ end
111
+
112
+ def saturated?
113
+ remaining.is_a?(Integer) && remaining <= 0
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Walinko
4
+ VERSION = '0.1.0'
5
+ end
data/lib/walinko.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'walinko/version'
4
+ require_relative 'walinko/errors'
5
+ require_relative 'walinko/configuration'
6
+ require_relative 'walinko/result'
7
+ require_relative 'walinko/http_client'
8
+ require_relative 'walinko/messages'
9
+
10
+ # Walinko Ruby SDK — server-to-server client for the Walinko public API.
11
+ #
12
+ # See the README for a quick-start; the contract is pinned in
13
+ # `walinko-sdk/docs/openapi.yaml`.
14
+ #
15
+ # TODO(walinko-webhooks): when the server starts emitting webhooks,
16
+ # `Walinko::Client#webhooks` (e.g. `client.webhooks.verify(payload, sig)`)
17
+ # will land here. Reserving the namespace so v1 callers don't break.
18
+ module Walinko
19
+ class Client
20
+ # @return [Walinko::Configuration]
21
+ attr_reader :config
22
+
23
+ # @return [Walinko::Messages]
24
+ attr_reader :messages
25
+
26
+ # @param api_key [String] required, e.g. "walk_live_<keyId>.<secret>"
27
+ # @param base_url [String] optional, defaults to "https://api.walinko.com"
28
+ # @param timeout [Integer] read timeout in seconds (default 30)
29
+ # @param open_timeout [Integer] connection timeout in seconds (default 10)
30
+ # @param max_retries [Integer] retries on idempotent failures (default 2)
31
+ # @param logger [#info,#warn,#error] optional structured logger
32
+ def initialize(api_key:, **opts)
33
+ @config = Configuration.new(api_key: api_key, **opts)
34
+ @http = HttpClient.new(@config)
35
+ @messages = Messages.new(@http)
36
+ end
37
+
38
+ # Snapshot of the rate-limit window from the most recent response.
39
+ # Returns `nil` until the first call has completed.
40
+ # @return [Walinko::RateLimitSnapshot, nil]
41
+ def last_rate_limit
42
+ @http.last_rate_limit
43
+ end
44
+
45
+ # The `X-Request-Id` from the most recent response (or `nil`).
46
+ # Useful when filing support tickets.
47
+ # @return [String, nil]
48
+ def last_request_id
49
+ @http.last_request_id
50
+ end
51
+ end
52
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: walinko
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Walinko
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-04 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: rubocop
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.65'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.65'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webmock
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.20'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.20'
55
+ description: |
56
+ Server-to-server Ruby client for the Walinko public API. Provides
57
+ ergonomic helpers for sending transactional WhatsApp messages, idempotent
58
+ retries, structured errors, and lookups by tracking id.
59
+ email:
60
+ - support@walinko.com
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - README.md
67
+ - lib/walinko.rb
68
+ - lib/walinko/configuration.rb
69
+ - lib/walinko/errors.rb
70
+ - lib/walinko/http_client.rb
71
+ - lib/walinko/messages.rb
72
+ - lib/walinko/result.rb
73
+ - lib/walinko/version.rb
74
+ homepage: https://github.com/walinko/walinko-sdk
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ homepage_uri: https://github.com/walinko/walinko-sdk
79
+ source_code_uri: https://github.com/walinko/walinko-sdk/tree/main/sdks/ruby
80
+ changelog_uri: https://github.com/walinko/walinko-sdk/blob/main/sdks/ruby/CHANGELOG.md
81
+ documentation_uri: https://walinko.com/docs/api
82
+ bug_tracker_uri: https://github.com/walinko/walinko-sdk/issues
83
+ rubygems_mfa_required: 'true'
84
+ post_install_message:
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 3.1.0
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 3.5.22
100
+ signing_key:
101
+ specification_version: 4
102
+ summary: Official Ruby SDK for the Walinko public API.
103
+ test_files: []