From 5077ea962c591e78c6fd3f833a2c7b53c37611be Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:13:25 -0400 Subject: [PATCH 1/7] feat: add one-key client Co-Authored-By: Claude Opus 5.5 --- README.md | 30 +++++ lib/customerio.rb | 1 + lib/customerio/unified_client.rb | 145 ++++++++++++++++++++++++ spec/unified_client_spec.rb | 187 +++++++++++++++++++++++++++++++ 4 files changed, 363 insertions(+) create mode 100644 lib/customerio/unified_client.rb create mode 100644 spec/unified_client_spec.rb diff --git a/README.md b/README.md index 3f43173..c7f6c6f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,36 @@ In the end, the decision on whether or not to use the API client or the JavaScript snippet should be based on what works best for you. You'll be able to integrate **fully** with [Customer.io](https://customer.io) with either approach. +### Quick start + +Create a client with your secret key (`ak_…`). It sends tracking calls to the Track API and transactional messages to the App API, and picks the US or EU region from the key. + +```ruby +cio = Customerio::UnifiedClient.new(ENV["CIO_API_KEY"]) + +cio.identify(id: "user_123", email: "a@example.com") +cio.track("user_123", "order_completed") +cio.send_email(Customerio::SendEmailRequest.new( + to: "a@example.com", + identifiers: { id: "user_123" }, + transactional_message_id: 5 +)) +``` + +Pass `region:` to override the region in the key. Legacy App API keys also work here; they default to `Customerio::Regions::US` unless you pass `region:`. + +`identify`, `track`, `track_anonymous`, `pageview`, the `send_*` methods and `trigger_broadcast` are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. + +If the key can't do what you asked, the call raises `Customerio::KeyCapabilityError`. A public key (`wk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, with the original error as `cause`. + +```ruby +begin + cio.send_email(request) +rescue Customerio::KeyCapabilityError => e + puts e.capability, e.message # :transactional, "This key can't send transactional messages ..." +end +``` + ### Setup Create an instance of the client with your [Customer.io credentials](https://fly.customer.io/settings/api_credentials). diff --git a/lib/customerio.rb b/lib/customerio.rb index 98bed86..e8fe320 100644 --- a/lib/customerio.rb +++ b/lib/customerio.rb @@ -14,4 +14,5 @@ module Customerio require "customerio/requests/send_in_app_request" require "customerio/requests/trigger_broadcast_request" require "customerio/api" + require "customerio/unified_client" end diff --git a/lib/customerio/unified_client.rb b/lib/customerio/unified_client.rb new file mode 100644 index 0000000..09751b8 --- /dev/null +++ b/lib/customerio/unified_client.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module Customerio + # Raised by UnifiedClient when its key can't do what a method asks. + # + # Raised before the request when the key's type rules it out (a public `wk_` + # key can't send transactional messages). Otherwise the server's 401 or 403 + # is re-raised as this error, with the original InvalidResponse as `cause`. + class KeyCapabilityError < StandardError + # What the key can't do: :track or :transactional. + attr_reader :capability + # The HTTP status code, when the server refused the key. + attr_reader :code + + def initialize(capability, message, code = nil) + @capability = capability + @code = code + + super(message) + end + end + + # One client for tracking and transactional messages, authenticated with a + # single key sent as a Bearer token. + # + # Tracking methods go to the Track API; send methods go to the App API. For + # `ak_us_…` / `ak_eu_…` keys the region comes from the key; an explicit + # `region` wins, and legacy keys default to Regions::US. + # + # Other Track and App API methods are on #track_client and #api_client. + class UnifiedClient + # Keys in the shared format look like `{ak|wk}_{us|eu}_{random}_{checksum}`. + # The server checks the checksum; the client only reads the prefix. + KEY_PREFIX = /\A(ak|wk)_(us|eu)_/ + + CAPABILITY_ACTIONS = { + track: "send tracking data", + transactional: "send transactional messages" + }.freeze + + attr_reader :track_client, :region + + def initialize(api_key, options = {}) + raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty? + + match = KEY_PREFIX.match(api_key) + @key_kind = match ? match[1].to_sym : :legacy + @region = options[:region] || (match && match[2] == "eu" ? Regions::EU : Regions::US) + + @track_client = BearerTrackClient.new(api_key, track_options(options)) + @api_client = APIClient.new(api_key, api_options(options)) + end + + # The App API client. Raises KeyCapabilityError for a public `wk_` key. + def api_client + if @key_kind == :wk + raise KeyCapabilityError.new( + :transactional, + "This key can't use the App API: public keys (wk_) are for tracking only. Use a secret key (ak_)." + ) + end + + @api_client + end + + def identify(attributes) + guard(:track) { track_client.identify(attributes) } + end + + def track(customer_id, event_name, attributes = {}, id: nil, timestamp: nil) + guard(:track) { track_client.track(customer_id, event_name, attributes, id: id, timestamp: timestamp) } + end + + def track_anonymous(anonymous_id, event_name, attributes = {}, id: nil, timestamp: nil) + guard(:track) { track_client.track_anonymous(anonymous_id, event_name, attributes, id: id, timestamp: timestamp) } + end + + def pageview(customer_id, page, attributes = {}) + guard(:track) { track_client.pageview(customer_id, page, attributes) } + end + + def send_email(req) + guard(:transactional) { api_client.send_email(req) } + end + + def send_push(req) + guard(:transactional) { api_client.send_push(req) } + end + + def send_sms(req) + guard(:transactional) { api_client.send_sms(req) } + end + + def send_whatsapp(req) + guard(:transactional) { api_client.send_whatsapp(req) } + end + + def send_inbox_message(req) + guard(:transactional) { api_client.send_inbox_message(req) } + end + + def send_in_app(req) + guard(:transactional) { api_client.send_in_app(req) } + end + + def trigger_broadcast(req) + guard(:transactional) { api_client.trigger_broadcast(req) } + end + + private + + def track_options(options) + options.except(:api_url).merge(region: @region, url: options[:track_url]) + end + + def api_options(options) + options.except(:track_url).merge(region: @region, url: options[:api_url]) + end + + # Turns a 401/403 into a KeyCapabilityError so a key mismatch is never a + # bare auth failure. The original error is kept as `cause`. + def guard(capability) + yield + rescue InvalidResponse => e + raise unless %w[401 403].include?(e.code.to_s) + + raise KeyCapabilityError.new( + capability, + "This key can't #{CAPABILITY_ACTIONS[capability]} (#{e.code}): #{e.message}", + e.code + ) + end + + # Client only speaks Basic auth, so swap in a Bearer transport. + class BearerTrackClient < Client + def initialize(api_key, options) + super(nil, nil, options) + + options = options.merge(url: options[:region].track_url) if options[:url].nil? || options[:url].empty? + @client = BaseClient.new({ app_key: api_key }, options) + end + end + private_constant :BearerTrackClient + end +end diff --git a/spec/unified_client_spec.rb b/spec/unified_client_spec.rb new file mode 100644 index 0000000..bf341e2 --- /dev/null +++ b/spec/unified_client_spec.rb @@ -0,0 +1,187 @@ +require 'spec_helper' +require 'json' + +describe Customerio::UnifiedClient do + let(:us_key) { "ak_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } + let(:eu_key) { "ak_eu_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } + let(:public_key) { "wk_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } + let(:legacy_key) { "abc123" } + + let(:email_request) do + Customerio::SendEmailRequest.new(to: "a@example.com", identifiers: { id: "user_123" }, transactional_message_id: 5) + end + + def bearer(key) + { 'Authorization' => "Bearer #{key}" } + end + + it "requires an api key" do + expect { Customerio::UnifiedClient.new("") }.to raise_error(ArgumentError, "api_key is required") + expect { Customerio::UnifiedClient.new(nil) }.to raise_error(ArgumentError, "api_key is required") + end + + it "raises an error when an incorrect region is passed in" do + expect { + Customerio::UnifiedClient.new(us_key, region: :au) + }.to raise_error(ArgumentError, /region must be an instance of Customerio::Regions::Region/) + end + + describe "region" do + it "comes from the key" do + expect(Customerio::UnifiedClient.new(us_key).region).to eq(Customerio::Regions::US) + expect(Customerio::UnifiedClient.new(eu_key).region).to eq(Customerio::Regions::EU) + expect(Customerio::UnifiedClient.new(public_key).region).to eq(Customerio::Regions::US) + end + + it "uses an explicit region over the key" do + expect(Customerio::UnifiedClient.new(eu_key, region: Customerio::Regions::US).region).to eq(Customerio::Regions::US) + end + + it "keeps the region option and US default for legacy keys" do + expect(Customerio::UnifiedClient.new(legacy_key).region).to eq(Customerio::Regions::US) + expect(Customerio::UnifiedClient.new(legacy_key, region: Customerio::Regions::EU).region).to eq(Customerio::Regions::EU) + end + end + + describe "tracking" do + let(:client) { Customerio::UnifiedClient.new(us_key) } + + it "sends identify to the Track API with Bearer" do + stub = stub_request(:put, "https://track.customer.io/api/v1/customers/user_123") + .with(headers: bearer(us_key), body: { id: "user_123", email: "a@example.com" }) + .to_return(status: 200, body: "") + + client.identify(id: "user_123", email: "a@example.com") + expect(stub).to have_been_requested + end + + it "sends track, track_anonymous and pageview to the Track API with Bearer" do + customer_events = stub_request(:post, "https://track.customer.io/api/v1/customers/user_123/events") + .with(headers: bearer(us_key)) + .to_return(status: 200, body: "") + anonymous_events = stub_request(:post, "https://track.customer.io/api/v1/events") + .with(headers: bearer(us_key), body: { name: "viewed", data: {}, anonymous_id: "anon_1" }) + .to_return(status: 200, body: "") + + client.track("user_123", "order_completed") + client.pageview("user_123", "/pricing") + client.track_anonymous("anon_1", "viewed") + + expect(customer_events).to have_been_requested.twice + expect(anonymous_events).to have_been_requested + end + + it "lets a public key track" do + stub = stub_request(:put, "https://track.customer.io/api/v1/customers/user_123") + .with(headers: bearer(public_key)) + .to_return(status: 200, body: "") + + Customerio::UnifiedClient.new(public_key).identify(id: "user_123") + expect(stub).to have_been_requested + end + end + + describe "transactional" do + let(:client) { Customerio::UnifiedClient.new(us_key) } + + it "sends email to the App API with Bearer" do + stub_request(:post, "https://api.customer.io/v1/send/email") + .with(headers: bearer(us_key), body: email_request.message) + .to_return(status: 200, body: { delivery_id: 1 }.to_json) + + expect(client.send_email(email_request)).to eq({ "delivery_id" => 1 }) + end + + { + send_push: [Customerio::SendPushRequest, { transactional_message_id: 1, identifiers: { id: "c1" } }, "/v1/send/push"], + send_sms: [Customerio::SendSMSRequest, { transactional_message_id: 1, identifiers: { id: "c1" } }, "/v1/send/sms"], + send_whatsapp: [Customerio::SendWhatsAppRequest, { transactional_message_id: 1, identifiers: { id: "c1" } }, "/v1/send/whatsapp"], + send_inbox_message: [Customerio::SendInboxMessageRequest, { transactional_message_id: 1, identifiers: { id: "c1" } }, "/v1/send/inbox_message"], + send_in_app: [Customerio::SendInAppRequest, { transactional_message_id: 1, identifiers: { id: "c1" } }, "/v1/send/in_app"], + trigger_broadcast: [Customerio::TriggerBroadcastRequest, { broadcast_id: 12 }, "/v1/campaigns/12/triggers"] + }.each do |method, (request_class, opts, path)| + it "sends #{method} to the App API with Bearer" do + stub = stub_request(:post, "https://api.customer.io#{path}") + .with(headers: bearer(us_key)) + .to_return(status: 200, body: { delivery_id: 1 }.to_json) + + client.public_send(method, request_class.new(opts)) + expect(stub).to have_been_requested + end + end + + it "uses EU hosts for an ak_eu_ key" do + track = stub_request(:put, "https://track-eu.customer.io/api/v1/customers/user_123").to_return(status: 200, body: "") + api = stub_request(:post, "https://api-eu.customer.io/v1/send/email").to_return(status: 200, body: "{}") + + client = Customerio::UnifiedClient.new(eu_key) + client.identify(id: "user_123") + client.send_email(email_request) + + expect(track).to have_been_requested + expect(api).to have_been_requested + end + + it "lets custom URLs override the region hosts" do + track = stub_request(:put, "http://track.test/api/v1/customers/1").to_return(status: 200, body: "") + api = stub_request(:post, "http://api.test/v1/send/email").to_return(status: 200, body: "{}") + + client = Customerio::UnifiedClient.new(us_key, track_url: "http://track.test", api_url: "http://api.test") + client.identify(id: "1") + client.send_email(email_request) + + expect(track).to have_been_requested + expect(api).to have_been_requested + end + end + + describe "public keys" do + let(:client) { Customerio::UnifiedClient.new(public_key) } + + it "raise KeyCapabilityError before sending a transactional message" do + expect { client.send_email(email_request) }.to raise_error(Customerio::KeyCapabilityError) { |error| + expect(error.capability).to eq(:transactional) + expect(error.code).to be_nil + } + expect(a_request(:any, /customer\.io/)).not_to have_been_made + end + + it "raise KeyCapabilityError from api_client" do + expect { client.api_client }.to raise_error(Customerio::KeyCapabilityError) + end + end + + describe "server refusals" do + let(:client) { Customerio::UnifiedClient.new(us_key) } + + [401, 403].each do |status| + it "re-raises a #{status} from the Track API as KeyCapabilityError" do + stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: status, body: "forbidden") + + expect { client.identify(id: "1") }.to raise_error(Customerio::KeyCapabilityError) { |error| + expect(error.capability).to eq(:track) + expect(error.code).to eq(status.to_s) + expect(error.message).to eq("This key can't send tracking data (#{status}): forbidden") + expect(error.cause).to be_a(Customerio::InvalidResponse) + } + end + + it "re-raises a #{status} from the App API as KeyCapabilityError" do + stub_request(:post, "https://api.customer.io/v1/send/email").to_return(status: status, body: "nope") + + expect { client.send_email(email_request) }.to raise_error(Customerio::KeyCapabilityError) { |error| + expect(error.capability).to eq(:transactional) + expect(error.code).to eq(status.to_s) + expect(error.message).to eq("This key can't send transactional messages (#{status}): nope") + expect(error.cause).to be_a(Customerio::InvalidResponse) + } + end + end + + it "leaves other errors alone" do + stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: 500, body: "boom") + + expect { client.identify(id: "1") }.to raise_error(Customerio::InvalidResponse) + end + end +end From 59f6194b64b9fcf8282a3d314d4e9152ca71cdf5 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:48:59 -0400 Subject: [PATCH 2/7] fix: strip whitespace from the key before parsing it A padded ak_eu_ key passed the empty check, missed the prefix match, defaulted to US, and went on the wire with the spaces. Co-Authored-By: Claude Fable 5.1 --- lib/customerio/unified_client.rb | 3 ++- spec/unified_client_spec.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/customerio/unified_client.rb b/lib/customerio/unified_client.rb index 09751b8..78ab9e0 100644 --- a/lib/customerio/unified_client.rb +++ b/lib/customerio/unified_client.rb @@ -41,7 +41,8 @@ class UnifiedClient attr_reader :track_client, :region def initialize(api_key, options = {}) - raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty? + api_key = api_key.to_s.strip + raise ArgumentError, "api_key is required" if api_key.empty? match = KEY_PREFIX.match(api_key) @key_kind = match ? match[1].to_sym : :legacy diff --git a/spec/unified_client_spec.rb b/spec/unified_client_spec.rb index bf341e2..70a17a0 100644 --- a/spec/unified_client_spec.rb +++ b/spec/unified_client_spec.rb @@ -41,6 +41,18 @@ def bearer(key) expect(Customerio::UnifiedClient.new(legacy_key).region).to eq(Customerio::Regions::US) expect(Customerio::UnifiedClient.new(legacy_key, region: Customerio::Regions::EU).region).to eq(Customerio::Regions::EU) end + + it "ignores whitespace around the key" do + client = Customerio::UnifiedClient.new(" #{eu_key}\n") + expect(client.region).to eq(Customerio::Regions::EU) + + stub = stub_request(:put, "https://track-eu.customer.io/api/v1/customers/user_123") + .with(headers: bearer(eu_key)) + .to_return(status: 200, body: "", headers: {}) + + client.identify(id: "user_123", email: "a@example.com") + expect(stub).to have_been_requested + end end describe "tracking" do From 3cf0ba933439f792f69179ecd5f3b974dfd98eb1 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:12:01 -0400 Subject: [PATCH 3/7] feat: rename API key prefixes to pk_/sk_ Read the region from any key shaped {xx}_{us|eu}_ and treat pk_ as the public key. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- lib/customerio/unified_client.rb | 17 +++++++++-------- spec/unified_client_spec.rb | 11 ++++++----- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c7f6c6f..3ec7e59 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ You'll be able to integrate **fully** with [Customer.io](https://customer.io) wi ### Quick start -Create a client with your secret key (`ak_…`). It sends tracking calls to the Track API and transactional messages to the App API, and picks the US or EU region from the key. +Create a client with your secret key (`sk_…`). It sends tracking calls to the Track API and transactional messages to the App API, and picks the US or EU region from the key. ```ruby cio = Customerio::UnifiedClient.new(ENV["CIO_API_KEY"]) @@ -68,7 +68,7 @@ Pass `region:` to override the region in the key. Legacy App API keys also work `identify`, `track`, `track_anonymous`, `pageview`, the `send_*` methods and `trigger_broadcast` are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. -If the key can't do what you asked, the call raises `Customerio::KeyCapabilityError`. A public key (`wk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, with the original error as `cause`. +If the key can't do what you asked, the call raises `Customerio::KeyCapabilityError`. A public key (`pk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, with the original error as `cause`. ```ruby begin diff --git a/lib/customerio/unified_client.rb b/lib/customerio/unified_client.rb index 78ab9e0..d4f3ef6 100644 --- a/lib/customerio/unified_client.rb +++ b/lib/customerio/unified_client.rb @@ -3,7 +3,7 @@ module Customerio # Raised by UnifiedClient when its key can't do what a method asks. # - # Raised before the request when the key's type rules it out (a public `wk_` + # Raised before the request when the key's type rules it out (a public `pk_` # key can't send transactional messages). Otherwise the server's 401 or 403 # is re-raised as this error, with the original InvalidResponse as `cause`. class KeyCapabilityError < StandardError @@ -24,14 +24,15 @@ def initialize(capability, message, code = nil) # single key sent as a Bearer token. # # Tracking methods go to the Track API; send methods go to the App API. For - # `ak_us_…` / `ak_eu_…` keys the region comes from the key; an explicit + # `sk_us_…` / `sk_eu_…` keys the region comes from the key; an explicit # `region` wins, and legacy keys default to Regions::US. # # Other Track and App API methods are on #track_client and #api_client. class UnifiedClient - # Keys in the shared format look like `{ak|wk}_{us|eu}_{random}_{checksum}`. - # The server checks the checksum; the client only reads the prefix. - KEY_PREFIX = /\A(ak|wk)_(us|eu)_/ + # Keys in the shared format look like `{type}_{us|eu}_{random}_{checksum}`, + # where type is `pk` (public) or `sk` (secret). The server checks the + # checksum; the client only reads the prefix. + KEY_PREFIX = /\A([a-z]{2})_(us|eu)_/ CAPABILITY_ACTIONS = { track: "send tracking data", @@ -52,12 +53,12 @@ def initialize(api_key, options = {}) @api_client = APIClient.new(api_key, api_options(options)) end - # The App API client. Raises KeyCapabilityError for a public `wk_` key. + # The App API client. Raises KeyCapabilityError for a public `pk_` key. def api_client - if @key_kind == :wk + if @key_kind == :pk raise KeyCapabilityError.new( :transactional, - "This key can't use the App API: public keys (wk_) are for tracking only. Use a secret key (ak_)." + "This key can't use the App API: public keys (pk_) are for tracking only. Use a secret key (sk_)." ) end diff --git a/spec/unified_client_spec.rb b/spec/unified_client_spec.rb index 70a17a0..aabcebe 100644 --- a/spec/unified_client_spec.rb +++ b/spec/unified_client_spec.rb @@ -2,9 +2,9 @@ require 'json' describe Customerio::UnifiedClient do - let(:us_key) { "ak_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } - let(:eu_key) { "ak_eu_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } - let(:public_key) { "wk_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } + let(:us_key) { "sk_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } + let(:eu_key) { "sk_eu_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } + let(:public_key) { "pk_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" } let(:legacy_key) { "abc123" } let(:email_request) do @@ -122,7 +122,7 @@ def bearer(key) end end - it "uses EU hosts for an ak_eu_ key" do + it "uses EU hosts for an sk_eu_ key" do track = stub_request(:put, "https://track-eu.customer.io/api/v1/customers/user_123").to_return(status: 200, body: "") api = stub_request(:post, "https://api-eu.customer.io/v1/send/email").to_return(status: 200, body: "{}") @@ -159,7 +159,8 @@ def bearer(key) end it "raise KeyCapabilityError from api_client" do - expect { client.api_client }.to raise_error(Customerio::KeyCapabilityError) + expect { client.api_client } + .to raise_error(Customerio::KeyCapabilityError, /public keys \(pk_\).*secret key \(sk_\)/) end end From 5df2d834c16ce06b12e8a0aafa755de412d3133f Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:53:10 -0400 Subject: [PATCH 4/7] fix: say the server refused the request instead of blaming the key, and qualify legacy keys in the README Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 ++-- lib/customerio/unified_client.rb | 3 ++- spec/unified_client_spec.rb | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3ec7e59..7ef044e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ cio.send_email(Customerio::SendEmailRequest.new( )) ``` -Pass `region:` to override the region in the key. Legacy App API keys also work here; they default to `Customerio::Regions::US` unless you pass `region:`. +Pass `region:` to override the region in the key. Legacy App API keys also work here for transactional calls; they default to `Customerio::Regions::US` unless you pass `region:`. Tracking calls on Pipelines workspaces need a source-bound `pk_` or `sk_` key. `identify`, `track`, `track_anonymous`, `pageview`, the `send_*` methods and `trigger_broadcast` are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. @@ -74,7 +74,7 @@ If the key can't do what you asked, the call raises `Customerio::KeyCapabilityEr begin cio.send_email(request) rescue Customerio::KeyCapabilityError => e - puts e.capability, e.message # :transactional, "This key can't send transactional messages ..." + puts e.capability, e.message # :transactional, "The server refused this request to send transactional messages ..." end ``` diff --git a/lib/customerio/unified_client.rb b/lib/customerio/unified_client.rb index d4f3ef6..c377106 100644 --- a/lib/customerio/unified_client.rb +++ b/lib/customerio/unified_client.rb @@ -128,7 +128,8 @@ def guard(capability) raise KeyCapabilityError.new( capability, - "This key can't #{CAPABILITY_ACTIONS[capability]} (#{e.code}): #{e.message}", + "The server refused this request to #{CAPABILITY_ACTIONS[capability]} (#{e.code}): #{e.message}. " \ + "Check the key type, its IP restrictions and the workspace status.", e.code ) end diff --git a/spec/unified_client_spec.rb b/spec/unified_client_spec.rb index aabcebe..1b8469d 100644 --- a/spec/unified_client_spec.rb +++ b/spec/unified_client_spec.rb @@ -174,7 +174,7 @@ def bearer(key) expect { client.identify(id: "1") }.to raise_error(Customerio::KeyCapabilityError) { |error| expect(error.capability).to eq(:track) expect(error.code).to eq(status.to_s) - expect(error.message).to eq("This key can't send tracking data (#{status}): forbidden") + expect(error.message).to eq("The server refused this request to send tracking data (#{status}): forbidden. Check the key type, its IP restrictions and the workspace status.") expect(error.cause).to be_a(Customerio::InvalidResponse) } end @@ -185,7 +185,7 @@ def bearer(key) expect { client.send_email(email_request) }.to raise_error(Customerio::KeyCapabilityError) { |error| expect(error.capability).to eq(:transactional) expect(error.code).to eq(status.to_s) - expect(error.message).to eq("This key can't send transactional messages (#{status}): nope") + expect(error.message).to eq("The server refused this request to send transactional messages (#{status}): nope. Check the key type, its IP restrictions and the workspace status.") expect(error.cause).to be_a(Customerio::InvalidResponse) } end From 5865af8a1c651a47f7538eff0e4c382315570b74 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:27:07 -0400 Subject: [PATCH 5/7] fix: integer KeyCapabilityError code, a broadcast capability, and refuse a region that disagrees with the key Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +-- lib/customerio/unified_client.rb | 60 +++++++++++++++++++++++--------- spec/unified_client_spec.rb | 37 +++++++++++++++++--- 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7ef044e..6c670a5 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,11 @@ cio.send_email(Customerio::SendEmailRequest.new( )) ``` -Pass `region:` to override the region in the key. Legacy App API keys also work here for transactional calls; they default to `Customerio::Regions::US` unless you pass `region:`. Tracking calls on Pipelines workspaces need a source-bound `pk_` or `sk_` key. +A `pk_` or `sk_` key belongs to one region, so passing a `region:` that disagrees with it raises `ArgumentError`. Legacy App API keys also work here for transactional calls; they default to `Customerio::Regions::US` unless you pass `region:`. Tracking calls on Pipelines workspaces need a source-bound `pk_` or `sk_` key. `identify`, `track`, `track_anonymous`, `pageview`, the `send_*` methods and `trigger_broadcast` are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. -If the key can't do what you asked, the call raises `Customerio::KeyCapabilityError`. A public key (`pk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, with the original error as `cause`. +If the key can't do what you asked, the call raises `Customerio::KeyCapabilityError`. A public key (`pk_…`) raises it before any request when you send a transactional message or trigger a broadcast. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, with the original error as `cause`. ```ruby begin diff --git a/lib/customerio/unified_client.rb b/lib/customerio/unified_client.rb index c377106..4278cd7 100644 --- a/lib/customerio/unified_client.rb +++ b/lib/customerio/unified_client.rb @@ -4,12 +4,13 @@ module Customerio # Raised by UnifiedClient when its key can't do what a method asks. # # Raised before the request when the key's type rules it out (a public `pk_` - # key can't send transactional messages). Otherwise the server's 401 or 403 - # is re-raised as this error, with the original InvalidResponse as `cause`. + # key can't send transactional messages or trigger broadcasts). Otherwise + # the server's 401 or 403 is re-raised as this error, with the original + # InvalidResponse as `cause`. class KeyCapabilityError < StandardError - # What the key can't do: :track or :transactional. + # What the key can't do: :track, :transactional or :broadcast. attr_reader :capability - # The HTTP status code, when the server refused the key. + # The HTTP status code as an Integer, when the server refused the key. attr_reader :code def initialize(capability, message, code = nil) @@ -24,8 +25,9 @@ def initialize(capability, message, code = nil) # single key sent as a Bearer token. # # Tracking methods go to the Track API; send methods go to the App API. For - # `sk_us_…` / `sk_eu_…` keys the region comes from the key; an explicit - # `region` wins, and legacy keys default to Regions::US. + # `sk_us_…` / `sk_eu_…` keys the region comes from the key, and a `region` + # that disagrees with it raises ArgumentError. Legacy keys take any `region` + # and default to Regions::US. # # Other Track and App API methods are on #track_client and #api_client. class UnifiedClient @@ -36,9 +38,13 @@ class UnifiedClient CAPABILITY_ACTIONS = { track: "send tracking data", - transactional: "send transactional messages" + transactional: "send transactional messages", + broadcast: "trigger broadcasts" }.freeze + KEY_REGIONS = { "us" => Regions::US, "eu" => Regions::EU }.freeze + private_constant :KEY_REGIONS + attr_reader :track_client, :region def initialize(api_key, options = {}) @@ -47,7 +53,7 @@ def initialize(api_key, options = {}) match = KEY_PREFIX.match(api_key) @key_kind = match ? match[1].to_sym : :legacy - @region = options[:region] || (match && match[2] == "eu" ? Regions::EU : Regions::US) + @region = resolve_region(match && match[2], options[:region]) @track_client = BearerTrackClient.new(api_key, track_options(options)) @api_client = APIClient.new(api_key, api_options(options)) @@ -55,13 +61,7 @@ def initialize(api_key, options = {}) # The App API client. Raises KeyCapabilityError for a public `pk_` key. def api_client - if @key_kind == :pk - raise KeyCapabilityError.new( - :transactional, - "This key can't use the App API: public keys (pk_) are for tracking only. Use a secret key (sk_)." - ) - end - + refuse_public_key(:transactional) @api_client end @@ -106,11 +106,37 @@ def send_in_app(req) end def trigger_broadcast(req) - guard(:transactional) { api_client.trigger_broadcast(req) } + guard(:broadcast) do + refuse_public_key(:broadcast) + @api_client.trigger_broadcast(req) + end end private + # A prefixed key belongs to one region, so a different `region` would only + # get a redirect or a 401 from the server. + def resolve_region(key_region, region) + return region || Regions::US if key_region.nil? + return KEY_REGIONS[key_region] if region.nil? + + if region.is_a?(Regions::Region) && region != KEY_REGIONS[key_region] + name = KEY_REGIONS.key(region) || region.track_url + raise ArgumentError, "region #{name} does not match the #{key_region} region of this key" + end + + region + end + + def refuse_public_key(capability) + return unless @key_kind == :pk + + raise KeyCapabilityError.new( + capability, + "This key can't use the App API: public keys (pk_) are for tracking only. Use a secret key (sk_)." + ) + end + def track_options(options) options.except(:api_url).merge(region: @region, url: options[:track_url]) end @@ -130,7 +156,7 @@ def guard(capability) capability, "The server refused this request to #{CAPABILITY_ACTIONS[capability]} (#{e.code}): #{e.message}. " \ "Check the key type, its IP restrictions and the workspace status.", - e.code + e.code.to_i ) end diff --git a/spec/unified_client_spec.rb b/spec/unified_client_spec.rb index 1b8469d..95d776b 100644 --- a/spec/unified_client_spec.rb +++ b/spec/unified_client_spec.rb @@ -33,8 +33,17 @@ def bearer(key) expect(Customerio::UnifiedClient.new(public_key).region).to eq(Customerio::Regions::US) end - it "uses an explicit region over the key" do - expect(Customerio::UnifiedClient.new(eu_key, region: Customerio::Regions::US).region).to eq(Customerio::Regions::US) + it "accepts an explicit region that matches the key" do + expect(Customerio::UnifiedClient.new(eu_key, region: Customerio::Regions::EU).region).to eq(Customerio::Regions::EU) + end + + it "raises when an explicit region disagrees with the key" do + expect { + Customerio::UnifiedClient.new(eu_key, region: Customerio::Regions::US) + }.to raise_error(ArgumentError, "region us does not match the eu region of this key") + expect { + Customerio::UnifiedClient.new(public_key, region: Customerio::Regions::EU) + }.to raise_error(ArgumentError, "region eu does not match the us region of this key") end it "keeps the region option and US default for legacy keys" do @@ -158,6 +167,15 @@ def bearer(key) expect(a_request(:any, /customer\.io/)).not_to have_been_made end + it "raise KeyCapabilityError before triggering a broadcast" do + expect { client.trigger_broadcast(Customerio::TriggerBroadcastRequest.new(broadcast_id: 12)) } + .to raise_error(Customerio::KeyCapabilityError) { |error| + expect(error.capability).to eq(:broadcast) + expect(error.code).to be_nil + } + expect(a_request(:any, /customer\.io/)).not_to have_been_made + end + it "raise KeyCapabilityError from api_client" do expect { client.api_client } .to raise_error(Customerio::KeyCapabilityError, /public keys \(pk_\).*secret key \(sk_\)/) @@ -173,7 +191,7 @@ def bearer(key) expect { client.identify(id: "1") }.to raise_error(Customerio::KeyCapabilityError) { |error| expect(error.capability).to eq(:track) - expect(error.code).to eq(status.to_s) + expect(error.code).to eq(status) expect(error.message).to eq("The server refused this request to send tracking data (#{status}): forbidden. Check the key type, its IP restrictions and the workspace status.") expect(error.cause).to be_a(Customerio::InvalidResponse) } @@ -184,13 +202,24 @@ def bearer(key) expect { client.send_email(email_request) }.to raise_error(Customerio::KeyCapabilityError) { |error| expect(error.capability).to eq(:transactional) - expect(error.code).to eq(status.to_s) + expect(error.code).to eq(status) expect(error.message).to eq("The server refused this request to send transactional messages (#{status}): nope. Check the key type, its IP restrictions and the workspace status.") expect(error.cause).to be_a(Customerio::InvalidResponse) } end end + it "re-raises a 403 from a broadcast trigger as a broadcast refusal" do + stub_request(:post, "https://api.customer.io/v1/campaigns/12/triggers").to_return(status: 403, body: "nope") + + expect { client.trigger_broadcast(Customerio::TriggerBroadcastRequest.new(broadcast_id: 12)) } + .to raise_error(Customerio::KeyCapabilityError) { |error| + expect(error.capability).to eq(:broadcast) + expect(error.code).to eq(403) + expect(error.message).to eq("The server refused this request to trigger broadcasts (403): nope. Check the key type, its IP restrictions and the workspace status.") + } + end + it "leaves other errors alone" do stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: 500, body: "boom") From 0f11b4e6618bd3340f512477dba43619e1d0a4b8 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:47:05 -0400 Subject: [PATCH 6/7] fix: require https URLs, connect to IPv6 hosts, trust custom regions, show the server's reason, and only read pk_/sk_ prefixes Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- lib/customerio/base_client.rb | 3 +- lib/customerio/unified_client.rb | 55 ++++++++++++++++++++---- spec/base_client_spec.rb | 11 +++++ spec/unified_client_spec.rb | 74 ++++++++++++++++++++++++++++---- 5 files changed, 126 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6c670a5..ba669ae 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ cio.send_email(Customerio::SendEmailRequest.new( )) ``` -A `pk_` or `sk_` key belongs to one region, so passing a `region:` that disagrees with it raises `ArgumentError`. Legacy App API keys also work here for transactional calls; they default to `Customerio::Regions::US` unless you pass `region:`. Tracking calls on Pipelines workspaces need a source-bound `pk_` or `sk_` key. +A `pk_` or `sk_` key belongs to one region, so passing `Customerio::Regions::US` or `Customerio::Regions::EU` when it disagrees with the key raises `ArgumentError`. A custom `Customerio::Regions::Region` is used as given. Every URL (`track_url`, `api_url` and a custom region's) must start with `https://`. Legacy App API keys also work here for transactional calls; they default to `Customerio::Regions::US` unless you pass `region:`. Tracking calls on Pipelines workspaces need a source-bound `pk_` or `sk_` key. `identify`, `track`, `track_anonymous`, `pageview`, the `send_*` methods and `trigger_broadcast` are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. diff --git a/lib/customerio/base_client.rb b/lib/customerio/base_client.rb index 4ff47bb..b0bd6a5 100644 --- a/lib/customerio/base_client.rb +++ b/lib/customerio/base_client.rb @@ -41,7 +41,8 @@ def execute(method, path, body = nil, headers = {}) uri = URI.join(@base_uri, path) request_headers = headers.dup - session = Net::HTTP.new(uri.host, uri.port) + # hostname drops the brackets around an IPv6 address, which host keeps. + session = Net::HTTP.new(uri.hostname, uri.port) session.use_ssl = uri.scheme == "https" session.open_timeout = @timeout session.read_timeout = @timeout diff --git a/lib/customerio/unified_client.rb b/lib/customerio/unified_client.rb index 4278cd7..c53349f 100644 --- a/lib/customerio/unified_client.rb +++ b/lib/customerio/unified_client.rb @@ -25,16 +25,18 @@ def initialize(capability, message, code = nil) # single key sent as a Bearer token. # # Tracking methods go to the Track API; send methods go to the App API. For - # `sk_us_…` / `sk_eu_…` keys the region comes from the key, and a `region` - # that disagrees with it raises ArgumentError. Legacy keys take any `region` - # and default to Regions::US. + # `pk_` / `sk_` keys the region comes from the key, and Regions::US or + # Regions::EU disagreeing with it raises ArgumentError; a custom Region is + # used as given. Legacy keys take any `region` and default to Regions::US. + # + # Every URL must be https, since the key is sent as a Bearer token. # # Other Track and App API methods are on #track_client and #api_client. class UnifiedClient # Keys in the shared format look like `{type}_{us|eu}_{random}_{checksum}`, # where type is `pk` (public) or `sk` (secret). The server checks the # checksum; the client only reads the prefix. - KEY_PREFIX = /\A([a-z]{2})_(us|eu)_/ + KEY_PREFIX = /\A(pk|sk)_(us|eu)_/ CAPABILITY_ACTIONS = { track: "send tracking data", @@ -54,6 +56,7 @@ def initialize(api_key, options = {}) match = KEY_PREFIX.match(api_key) @key_kind = match ? match[1].to_sym : :legacy @region = resolve_region(match && match[2], options[:region]) + check_urls(options) @track_client = BearerTrackClient.new(api_key, track_options(options)) @api_client = APIClient.new(api_key, api_options(options)) @@ -114,20 +117,43 @@ def trigger_broadcast(req) private - # A prefixed key belongs to one region, so a different `region` would only - # get a redirect or a 401 from the server. + # A prefixed key belongs to one region, so the other built-in region would + # only get a redirect or a 401 from the server. A custom Region may be a + # proxy, so it's trusted. def resolve_region(key_region, region) return region || Regions::US if key_region.nil? return KEY_REGIONS[key_region] if region.nil? - if region.is_a?(Regions::Region) && region != KEY_REGIONS[key_region] - name = KEY_REGIONS.key(region) || region.track_url + name = KEY_REGIONS.key(region) + if name && name != key_region raise ArgumentError, "region #{name} does not match the #{key_region} region of this key" end region end + # The key goes out as a Bearer token, so never send it over plain http. + def check_urls(options) + check_https("track_url", options[:track_url]) + check_https("api_url", options[:api_url]) + return unless @region.is_a?(Regions::Region) + + check_https("region track_url", @region.track_url) + check_https("region api_url", @region.api_url) + end + + def check_https(name, url) + return if url.nil? || url.to_s.empty? + + uri = begin + URI.parse(url.to_s) + rescue URI::InvalidURIError + raise ArgumentError, "#{name} is not a valid URL: #{url}" + end + raise ArgumentError, "#{name} must start with https://" unless uri.scheme == "https" + raise ArgumentError, "#{name} is not a valid URL: #{url}" if uri.host.to_s.empty? + end + def refuse_public_key(capability) return unless @key_kind == :pk @@ -152,14 +178,25 @@ def guard(capability) rescue InvalidResponse => e raise unless %w[401 403].include?(e.code.to_s) + reason = refusal_reason(e.message) raise KeyCapabilityError.new( capability, - "The server refused this request to #{CAPABILITY_ACTIONS[capability]} (#{e.code}): #{e.message}. " \ + "The server refused this request to #{CAPABILITY_ACTIONS[capability]} (#{e.code}): #{reason}. " \ "Check the key type, its IP restrictions and the workspace status.", e.code.to_i ) end + # The App API sends `{"errors":[{"detail":…}]}` and the Track API sends + # `{"meta":{"error":…}}`; anything else is shown as it came. + def refusal_reason(body) + parsed = JSON.parse(body.to_s) + reason = parsed.dig("errors", 0, "detail") || parsed.dig("meta", "error") if parsed.is_a?(Hash) + reason.is_a?(String) && !reason.empty? ? reason : body + rescue JSON::ParserError, TypeError + body + end + # Client only speaks Basic auth, so swap in a Bearer transport. class BearerTrackClient < Client def initialize(api_key, options) diff --git a/spec/base_client_spec.rb b/spec/base_client_spec.rb index ea38e1c..b7d6107 100644 --- a/spec/base_client_spec.rb +++ b/spec/base_client_spec.rb @@ -45,6 +45,17 @@ def api_client_request_headers end end + describe "with an IPv6 host" do + it "connects to the address without brackets" do + client = Customerio::BaseClient.new({ app_key: app_key }, { url: "https://[::1]:8443" }) + stub = stub_request(:get, "https://[::1]:8443/some/path").to_return(status: 200, body: "") + + expect(Net::HTTP).to receive(:new).with("::1", 8443).and_call_original + client.request(:get, '/some/path') + expect(stub).to have_been_requested + end + end + describe "GET requests" do describe "with a site ID and API key" do it "sends a GET request with basic auth" do diff --git a/spec/unified_client_spec.rb b/spec/unified_client_spec.rb index 95d776b..36c1c36 100644 --- a/spec/unified_client_spec.rb +++ b/spec/unified_client_spec.rb @@ -46,6 +46,30 @@ def bearer(key) }.to raise_error(ArgumentError, "region eu does not match the us region of this key") end + it "accepts a custom region with a prefixed key" do + custom = Customerio::Regions::Region.new("https://track.proxy.test", "https://api.proxy.test") + expect(Customerio::UnifiedClient.new(eu_key, region: custom).region).to eq(custom) + + stub = stub_request(:put, "https://track.proxy.test/api/v1/customers/user_123") + .with(headers: bearer(eu_key)) + .to_return(status: 200, body: "") + + Customerio::UnifiedClient.new(eu_key, region: custom).identify(id: "user_123") + expect(stub).to have_been_requested + end + + it "treats an unknown prefix as a legacy key" do + client = Customerio::UnifiedClient.new("xx_us_abc", region: Customerio::Regions::EU) + expect(client.region).to eq(Customerio::Regions::EU) + + stub = stub_request(:post, "https://api-eu.customer.io/v1/send/email") + .with(headers: bearer("xx_us_abc")) + .to_return(status: 200, body: "{}") + + client.send_email(email_request) + expect(stub).to have_been_requested + end + it "keeps the region option and US default for legacy keys" do expect(Customerio::UnifiedClient.new(legacy_key).region).to eq(Customerio::Regions::US) expect(Customerio::UnifiedClient.new(legacy_key, region: Customerio::Regions::EU).region).to eq(Customerio::Regions::EU) @@ -144,10 +168,10 @@ def bearer(key) end it "lets custom URLs override the region hosts" do - track = stub_request(:put, "http://track.test/api/v1/customers/1").to_return(status: 200, body: "") - api = stub_request(:post, "http://api.test/v1/send/email").to_return(status: 200, body: "{}") + track = stub_request(:put, "https://track.test/api/v1/customers/1").to_return(status: 200, body: "") + api = stub_request(:post, "https://api.test/v1/send/email").to_return(status: 200, body: "{}") - client = Customerio::UnifiedClient.new(us_key, track_url: "http://track.test", api_url: "http://api.test") + client = Customerio::UnifiedClient.new(us_key, track_url: "https://track.test", api_url: "https://api.test") client.identify(id: "1") client.send_email(email_request) @@ -156,6 +180,31 @@ def bearer(key) end end + describe "urls" do + it "refuses a track_url or api_url that isn't https" do + expect { Customerio::UnifiedClient.new(us_key, track_url: "http://track.test") } + .to raise_error(ArgumentError, "track_url must start with https://") + expect { Customerio::UnifiedClient.new(us_key, api_url: "http://api.test") } + .to raise_error(ArgumentError, "api_url must start with https://") + end + + it "refuses a URL it can't parse" do + expect { Customerio::UnifiedClient.new(us_key, api_url: "https://api test") } + .to raise_error(ArgumentError, "api_url is not a valid URL: https://api test") + expect { Customerio::UnifiedClient.new(us_key, track_url: "https://") } + .to raise_error(ArgumentError, "track_url is not a valid URL: https://") + end + + it "refuses a custom region that isn't https" do + custom = Customerio::Regions::Region.new("https://track.proxy.test", "http://api.proxy.test") + + expect { Customerio::UnifiedClient.new(us_key, region: custom) } + .to raise_error(ArgumentError, "region api_url must start with https://") + expect { Customerio::UnifiedClient.new(legacy_key, region: custom) } + .to raise_error(ArgumentError, "region api_url must start with https://") + end + end + describe "public keys" do let(:client) { Customerio::UnifiedClient.new(public_key) } @@ -187,7 +236,7 @@ def bearer(key) [401, 403].each do |status| it "re-raises a #{status} from the Track API as KeyCapabilityError" do - stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: status, body: "forbidden") + stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: status, body: '{"meta":{"error":"forbidden"}}') expect { client.identify(id: "1") }.to raise_error(Customerio::KeyCapabilityError) { |error| expect(error.capability).to eq(:track) @@ -198,28 +247,37 @@ def bearer(key) end it "re-raises a #{status} from the App API as KeyCapabilityError" do - stub_request(:post, "https://api.customer.io/v1/send/email").to_return(status: status, body: "nope") + stub_request(:post, "https://api.customer.io/v1/send/email").to_return(status: status, body: '{"errors":[{"detail":"unauthorized","status":"401"}]}') expect { client.send_email(email_request) }.to raise_error(Customerio::KeyCapabilityError) { |error| expect(error.capability).to eq(:transactional) expect(error.code).to eq(status) - expect(error.message).to eq("The server refused this request to send transactional messages (#{status}): nope. Check the key type, its IP restrictions and the workspace status.") + expect(error.message).to eq("The server refused this request to send transactional messages (#{status}): unauthorized. Check the key type, its IP restrictions and the workspace status.") expect(error.cause).to be_a(Customerio::InvalidResponse) } end end it "re-raises a 403 from a broadcast trigger as a broadcast refusal" do - stub_request(:post, "https://api.customer.io/v1/campaigns/12/triggers").to_return(status: 403, body: "nope") + stub_request(:post, "https://api.customer.io/v1/campaigns/12/triggers").to_return(status: 403, body: '{"errors":[{"detail":"unauthorized","status":"401"}]}') expect { client.trigger_broadcast(Customerio::TriggerBroadcastRequest.new(broadcast_id: 12)) } .to raise_error(Customerio::KeyCapabilityError) { |error| expect(error.capability).to eq(:broadcast) expect(error.code).to eq(403) - expect(error.message).to eq("The server refused this request to trigger broadcasts (403): nope. Check the key type, its IP restrictions and the workspace status.") + expect(error.message).to eq("The server refused this request to trigger broadcasts (403): unauthorized. Check the key type, its IP restrictions and the workspace status.") } end + it "shows a body it can't read as it came" do + stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: 401, body: "nope") + + expect { client.identify(id: "1") }.to raise_error( + Customerio::KeyCapabilityError, + "The server refused this request to send tracking data (401): nope. Check the key type, its IP restrictions and the workspace status." + ) + end + it "leaves other errors alone" do stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: 500, body: "boom") From e6ec51bc3343cf260b9c8d49de383caaa458f2c9 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:48:52 -0400 Subject: [PATCH 7/7] fix: show a non-object JSON refusal body as it came Co-Authored-By: Claude Opus 5.5 --- lib/customerio/unified_client.rb | 4 +++- spec/unified_client_spec.rb | 16 +++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/customerio/unified_client.rb b/lib/customerio/unified_client.rb index c53349f..fab0778 100644 --- a/lib/customerio/unified_client.rb +++ b/lib/customerio/unified_client.rb @@ -191,7 +191,9 @@ def guard(capability) # `{"meta":{"error":…}}`; anything else is shown as it came. def refusal_reason(body) parsed = JSON.parse(body.to_s) - reason = parsed.dig("errors", 0, "detail") || parsed.dig("meta", "error") if parsed.is_a?(Hash) + return body unless parsed.is_a?(Hash) + + reason = parsed.dig("errors", 0, "detail") || parsed.dig("meta", "error") reason.is_a?(String) && !reason.empty? ? reason : body rescue JSON::ParserError, TypeError body diff --git a/spec/unified_client_spec.rb b/spec/unified_client_spec.rb index 36c1c36..fc94625 100644 --- a/spec/unified_client_spec.rb +++ b/spec/unified_client_spec.rb @@ -269,13 +269,15 @@ def bearer(key) } end - it "shows a body it can't read as it came" do - stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: 401, body: "nope") - - expect { client.identify(id: "1") }.to raise_error( - Customerio::KeyCapabilityError, - "The server refused this request to send tracking data (401): nope. Check the key type, its IP restrictions and the workspace status." - ) + ["nope", '["nope"]', '"nope"', '{"errors":"nope"}'].each do |body| + it "shows the body #{body} as it came" do + stub_request(:put, "https://track.customer.io/api/v1/customers/1").to_return(status: 401, body: body) + + expect { client.identify(id: "1") }.to raise_error( + Customerio::KeyCapabilityError, + "The server refused this request to send tracking data (401): #{body}. Check the key type, its IP restrictions and the workspace status." + ) + end end it "leaves other errors alone" do