From a3dc29dde6e57d31f1375c6f5c4120c23dd1de26 Mon Sep 17 00:00:00 2001 From: Tetiana Holovina Date: Tue, 18 Aug 2026 21:34:24 +0300 Subject: [PATCH 1/2] Add Global Profile API support Co-Authored-By: Claude --- HISTORY | 3 + README.md | 21 ++++ lib/sift.rb | 12 ++ lib/sift/client.rb | 119 ++++++++++++++++++ lib/sift/version.rb | 2 +- spec/unit/client_global_profile_spec.rb | 158 ++++++++++++++++++++++++ 6 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 spec/unit/client_global_profile_spec.rb diff --git a/HISTORY b/HISTORY index 4c6d5e8..657db8f 100644 --- a/HISTORY +++ b/HISTORY @@ -1,3 +1,6 @@ +=== 4.7.0 2026-08-18 +- Global Profile API support + === 4.6.0 2026-02-10 - Bump the minimum version of httparty to 0.23.3 to ensure protection against CVE-2025-68696 - Refactor Client to use dedicated internal HTTP clients for different API endpoints diff --git a/README.md b/README.md index 1b92c2d..2faa44a 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,27 @@ response = client.get_psp_merchant_profiles() response = client.get_psp_merchant_profiles('batch_size', 'batch_token') ``` +## Global Profile API + +To learn more about the Global Profile endpoint visit our [developer docs](https://sift.com/developers/docs/curl/global-profile-api). + +```ruby +# Get the Global Profile for a user. +response = client.get_global_profile('example_user_id') + +# Get the Global Profile for a user, excluding the requesting tenant's own +# network connections and the user's own feature values. +response = client.get_global_profile('example_user_id', + :global_only => true, + :include_own_data => false) + +# Look up a Global Profile using account attributes instead of a Sift user_id. +# At least one of :email or :phone must be provided. +response = client.get_global_profile_by_attributes( + :email => 'user@example.com', + :phone => '+15555550100') +``` + ## Response Object All requests to our apis will return a `Response` instance. diff --git a/lib/sift.rb b/lib/sift.rb index adc93c8..0f22e84 100644 --- a/lib/sift.rb +++ b/lib/sift.rb @@ -83,6 +83,18 @@ def self.psp_merchant_id_api_path(account_id, merchant_id) "/psp_management/merchants/#{ERB::Util.url_encode(merchant_id)}" end + # Returns the path for the Global Profile API for a specific user + def self.global_profile_api_path(account_id, user_id) + "/v3/accounts/#{ERB::Util.url_encode(account_id)}" \ + "/global_profile/users/#{ERB::Util.url_encode(user_id)}" + end + + # Returns the path for the Global Profile lookup-by-attributes API + def self.global_profile_lookup_api_path(account_id) + "/v3/accounts/#{ERB::Util.url_encode(account_id)}" \ + "/global_profile/lookup" + end + # Module-scoped public API key class << self attr_accessor :api_key diff --git a/lib/sift/client.rb b/lib/sift/client.rb index c357192..cfe06c3 100644 --- a/lib/sift/client.rb +++ b/lib/sift/client.rb @@ -928,6 +928,125 @@ def get_psp_merchant_profiles(batch_size = nil, batch_token = nil, opts = {}) Response.new(response.body, response.code, response.response) end + # Retrieves the Global Profile for a user, including cross-tenant identity, + # decision, chargeback, order, transaction, and location signals. + # + # See https://sift.com/developers/docs/curl/global-profile-api . + # + # ==== Parameters: + # + # user_id:: + # The ID of the user whose Global Profile should be retrieved. + # + # opts (optional):: + # A Hash of optional parameters for this request -- + # + # :account_id:: + # Overrides the account id for this call. + # + # :api_key:: + # Overrides the API key for this call. + # + # :timeout:: + # Overrides the timeout (in seconds) for this call. + # + # :global_only:: + # If true, excludes the requesting tenant's own network connections + # from the response. Defaults to false. + # + # :include_own_data:: + # If true, includes the requested user's own feature values in the + # response. Defaults to true. + # + # ==== Returns: + # + # A Response object if the call succeeded, else raises an ApiException. + # + def get_global_profile(user_id, opts = {}) + account_id = opts[:account_id] || @account_id + api_key = opts[:api_key] || @api_key + timeout = opts[:timeout] || @timeout + global_only = opts[:global_only] + include_own_data = opts[:include_own_data] + + raise("api_key cannot be empty") if api_key.empty? + raise("account_id cannot be empty") if account_id.empty? + raise("user_id must be a non-empty string") if (!user_id.is_a? String) || user_id.to_s.empty? + + query = {} + query["global_only"] = global_only unless global_only.nil? + query["include_own_data"] = include_own_data unless include_own_data.nil? + + options = { + :headers => { "User-Agent" => user_agent, "Content-Type" => "application/json" }, + :basic_auth => { :username => api_key, :password => "" }, + :query => query + } + options.merge!(:timeout => timeout) unless timeout.nil? + response = self.class.api_client.get(Sift.global_profile_api_path(account_id, user_id), options) + Response.new(response.body, response.code, response.response) + end + + # Looks up a Global Profile using account attributes (email and/or phone) + # instead of a Sift user_id. + # + # See https://sift.com/developers/docs/curl/global-profile-api . + # + # ==== Parameters: + # + # params:: + # A Hash of lookup attributes -- + # + # :email:: + # The email address to look up. Either :email or :phone (or both) + # must be provided. + # + # :phone:: + # The phone number to look up. Either :email or :phone (or both) + # must be provided. + # + # opts (optional):: + # A Hash of optional parameters for this request -- + # + # :account_id:: + # Overrides the account id for this call. + # + # :api_key:: + # Overrides the API key for this call. + # + # :timeout:: + # Overrides the timeout (in seconds) for this call. + # + # ==== Returns: + # + # A Response object if the call succeeded, else raises an ApiException. + # + def get_global_profile_by_attributes(params = {}, opts = {}) + account_id = opts[:account_id] || @account_id + api_key = opts[:api_key] || @api_key + timeout = opts[:timeout] || @timeout + + email = params[:email] + phone = params[:phone] + + raise("api_key cannot be empty") if api_key.empty? + raise("account_id cannot be empty") if account_id.empty? + raise("email or phone must be provided") if (!email || email.to_s.empty?) && (!phone || phone.to_s.empty?) + + body = {} + body["email"] = email if email + body["phone"] = phone if phone + + options = { + :body => MultiJson.dump(body), + :headers => { "User-Agent" => user_agent, "Content-Type" => "application/json" }, + :basic_auth => { :username => api_key, :password => "" } + } + options.merge!(:timeout => timeout) unless timeout.nil? + response = self.class.api_client.post(Sift.global_profile_lookup_api_path(account_id), options) + Response.new(response.body, response.code, response.response) + end + private def handle_response(response) diff --git a/lib/sift/version.rb b/lib/sift/version.rb index 9f7968d..29a1d22 100644 --- a/lib/sift/version.rb +++ b/lib/sift/version.rb @@ -1,5 +1,5 @@ module Sift - VERSION = "4.6.0" + VERSION = "4.7.0" API_VERSION = "205" VERIFICATION_API_VERSION = "1.1" end diff --git a/spec/unit/client_global_profile_spec.rb b/spec/unit/client_global_profile_spec.rb new file mode 100644 index 0000000..4833b8b --- /dev/null +++ b/spec/unit/client_global_profile_spec.rb @@ -0,0 +1,158 @@ +require_relative "../spec_helper" +require "sift" + +describe Sift::Client do + + before :each do + Sift.api_key = nil + end + + def global_profile_response + { + :status => 0, + :error_message => "OK", + :error_code => nil, + :lookback_months => 12, + :profile_summary => { + :identity_found => true, + :has_links => true, + :link_count => 7, + :linked_accounts_count_per_industry => { :finances => 3, :internet => 4 } + }, + :identity_age => { + :oldest_account_age_timestamp => 1681090536, + :newest_account_age_timestamp => 1881090536, + :average_account_age_timestamp => 1781090536 + }, + :user_decisions => { + :total => 12, :blocked => 2, :watched => 3, :accepted => 6, + :manual => 4, :auto => 8, :last_type => "BLOCK", :last_timestamp => 1881090536 + }, + :chargebacks => { + :total => 3, :fraudulent => 2, :other => 1, + :last_timestamp => 1881090536, :last_fraudulent_timestamp => 1881090536 + }, + :orders => { + :total => 50, :blocked => 2, :watched => 5, :accepted => 40, + :last_timestamp => 1881090536, :last_blocked_timestamp => 1881090536 + }, + :transactions => { + :total => 120, :failed_fraud => 3, :failed_other => 5, :successful => 112, + :last_timestamp => 1881090536, :last_failed_fraud_timestamp => 1881090536 + }, + :locations => { + :unique_billing_addresses => 2, :unique_shipping_addresses => 4, + :distinct_countries_count => 3, :distinct_regions_count => 5, + :location_connected_accounts => [{ :city => "Kyiv", :country => "UA" }], + :location_last_used_timestamp => 1881090536 + } + } + end + + it "Successfully gets a Global Profile for a user" do + api_key = "foobar1" + + stub_request(:get, "https://foobar1:@api.siftscience.com/v3/accounts/ACCT/global_profile/users/user1") + .to_return(:status => 200, :body => MultiJson.dump(global_profile_response)) + + response = Sift::Client.new(:api_key => api_key, :account_id => "ACCT").get_global_profile("user1") + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + expect(response.api_error_message).to eq("OK") + expect(response.body["profile_summary"]["link_count"]).to eq(7) + end + + it "Successfully gets a Global Profile for a user with global_only and include_own_data" do + api_key = "foobar1" + + stub_request(:get, "https://foobar1:@api.siftscience.com/v3/accounts/ACCT/global_profile/users/user1?global_only=true&include_own_data=false") + .to_return(:status => 200, :body => MultiJson.dump(global_profile_response)) + + response = Sift::Client.new(:api_key => api_key, :account_id => "ACCT") + .get_global_profile("user1", :global_only => true, :include_own_data => false) + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + end + + it "Raises when user_id is empty" do + api_key = "foobar1" + + expect { + Sift::Client.new(:api_key => api_key, :account_id => "ACCT").get_global_profile("") + }.to raise_error(RuntimeError, "user_id must be a non-empty string") + end + + it "Successfully looks up a Global Profile by email" do + api_key = "foobar1" + + stub_request(:post, "https://foobar1:@api.siftscience.com/v3/accounts/ACCT/global_profile/lookup") + .with(:body => MultiJson.dump({ "email" => "user@example.com" })) + .to_return(:status => 200, :body => MultiJson.dump(global_profile_response)) + + response = Sift::Client.new(:api_key => api_key, :account_id => "ACCT") + .get_global_profile_by_attributes(:email => "user@example.com") + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + end + + it "Successfully looks up a Global Profile by phone" do + api_key = "foobar1" + + stub_request(:post, "https://foobar1:@api.siftscience.com/v3/accounts/ACCT/global_profile/lookup") + .with(:body => MultiJson.dump({ "phone" => "+15555550100" })) + .to_return(:status => 200, :body => MultiJson.dump(global_profile_response)) + + response = Sift::Client.new(:api_key => api_key, :account_id => "ACCT") + .get_global_profile_by_attributes(:phone => "+15555550100") + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + end + + it "Successfully looks up a Global Profile by email and phone" do + api_key = "foobar1" + + stub_request(:post, "https://foobar1:@api.siftscience.com/v3/accounts/ACCT/global_profile/lookup") + .with(:body => MultiJson.dump({ "email" => "user@example.com", "phone" => "+15555550100" })) + .to_return(:status => 200, :body => MultiJson.dump(global_profile_response)) + + response = Sift::Client.new(:api_key => api_key, :account_id => "ACCT") + .get_global_profile_by_attributes(:email => "user@example.com", :phone => "+15555550100") + expect(response.ok?).to eq(true) + end + + it "Raises when neither email nor phone is provided" do + api_key = "foobar1" + + expect { + Sift::Client.new(:api_key => api_key, :account_id => "ACCT").get_global_profile_by_attributes({}) + }.to raise_error(RuntimeError, "email or phone must be provided") + end + + it "Handles identity_found => false with null fields" do + api_key = "foobar1" + + response_json = { + :status => 0, + :error_message => "OK", + :error_code => nil, + :lookback_months => nil, + :profile_summary => { :identity_found => false, :has_links => nil, :link_count => nil, + :linked_accounts_count_per_industry => nil }, + :identity_age => nil, + :user_decisions => nil, + :chargebacks => nil, + :orders => nil, + :transactions => nil, + :locations => nil + } + + stub_request(:get, "https://foobar1:@api.siftscience.com/v3/accounts/ACCT/global_profile/users/user2") + .to_return(:status => 200, :body => MultiJson.dump(response_json)) + + response = Sift::Client.new(:api_key => api_key, :account_id => "ACCT").get_global_profile("user2") + expect(response.ok?).to eq(true) + expect(response.body["profile_summary"]["identity_found"]).to eq(false) + expect(response.body["identity_age"]).to be_nil + end + +end From fec9edcb7842da57585e60ad1d51420ad6c2405b Mon Sep 17 00:00:00 2001 From: Tetiana Holovina Date: Wed, 19 Aug 2026 17:21:15 +0300 Subject: [PATCH 2/2] fix: address Global Profile API code review findings - Add nil guard before account_id.empty? in both methods (P1) - Add params ||= {} to fix nil crash in get_global_profile_by_attributes (P2) - Strip whitespace from email/phone before empty check and before body assignment (P2) - Fix RDoc exception class from ApiException to RuntimeError with network error note (P2) - Convert global_only/include_own_data booleans to strings for consistency with track (P3) - Content-Type on GET left as-is: PSP merchant GET also sets it (P2/finding 5) - Add inline comment noting HTTP client host uncertainty (P0) - Add tests for nil account_id and nil params raising RuntimeError Co-Authored-By: Claude --- lib/sift/client.rb | 25 ++++++++++++++++--------- spec/unit/client_global_profile_spec.rb | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/lib/sift/client.rb b/lib/sift/client.rb index cfe06c3..91c67cf 100644 --- a/lib/sift/client.rb +++ b/lib/sift/client.rb @@ -960,7 +960,8 @@ def get_psp_merchant_profiles(batch_size = nil, batch_token = nil, opts = {}) # # ==== Returns: # - # A Response object if the call succeeded, else raises an ApiException. + # A Response object if the call succeeded. Raises RuntimeError for + # validation failures. Network errors propagate from the underlying HTTP client. # def get_global_profile(user_id, opts = {}) account_id = opts[:account_id] || @account_id @@ -970,12 +971,12 @@ def get_global_profile(user_id, opts = {}) include_own_data = opts[:include_own_data] raise("api_key cannot be empty") if api_key.empty? - raise("account_id cannot be empty") if account_id.empty? + raise("account_id cannot be empty") if account_id.nil? || account_id.empty? raise("user_id must be a non-empty string") if (!user_id.is_a? String) || user_id.to_s.empty? query = {} - query["global_only"] = global_only unless global_only.nil? - query["include_own_data"] = include_own_data unless include_own_data.nil? + query["global_only"] = global_only.to_s unless global_only.nil? + query["include_own_data"] = include_own_data.to_s unless include_own_data.nil? options = { :headers => { "User-Agent" => user_agent, "Content-Type" => "application/json" }, @@ -983,6 +984,8 @@ def get_global_profile(user_id, opts = {}) :query => query } options.merge!(:timeout => timeout) unless timeout.nil? + # NOTE: using api_client (api.siftscience.com) following PSP merchant precedent. + # Verify against Sift docs that Global Profile is NOT on api3.siftscience.com. response = self.class.api_client.get(Sift.global_profile_api_path(account_id, user_id), options) Response.new(response.body, response.code, response.response) end @@ -1019,9 +1022,11 @@ def get_global_profile(user_id, opts = {}) # # ==== Returns: # - # A Response object if the call succeeded, else raises an ApiException. + # A Response object if the call succeeded. Raises RuntimeError for + # validation failures. Network errors propagate from the underlying HTTP client. # def get_global_profile_by_attributes(params = {}, opts = {}) + params ||= {} account_id = opts[:account_id] || @account_id api_key = opts[:api_key] || @api_key timeout = opts[:timeout] || @timeout @@ -1030,12 +1035,12 @@ def get_global_profile_by_attributes(params = {}, opts = {}) phone = params[:phone] raise("api_key cannot be empty") if api_key.empty? - raise("account_id cannot be empty") if account_id.empty? - raise("email or phone must be provided") if (!email || email.to_s.empty?) && (!phone || phone.to_s.empty?) + raise("account_id cannot be empty") if account_id.nil? || account_id.empty? + raise("email or phone must be provided") if email.to_s.strip.empty? && phone.to_s.strip.empty? body = {} - body["email"] = email if email - body["phone"] = phone if phone + body["email"] = email.to_s.strip if email && !email.to_s.strip.empty? + body["phone"] = phone.to_s.strip if phone && !phone.to_s.strip.empty? options = { :body => MultiJson.dump(body), @@ -1043,6 +1048,8 @@ def get_global_profile_by_attributes(params = {}, opts = {}) :basic_auth => { :username => api_key, :password => "" } } options.merge!(:timeout => timeout) unless timeout.nil? + # NOTE: using api_client (api.siftscience.com) following PSP merchant precedent. + # Verify against Sift docs that Global Profile is NOT on api3.siftscience.com. response = self.class.api_client.post(Sift.global_profile_lookup_api_path(account_id), options) Response.new(response.body, response.code, response.response) end diff --git a/spec/unit/client_global_profile_spec.rb b/spec/unit/client_global_profile_spec.rb index 4833b8b..a7e4341 100644 --- a/spec/unit/client_global_profile_spec.rb +++ b/spec/unit/client_global_profile_spec.rb @@ -128,6 +128,23 @@ def global_profile_response }.to raise_error(RuntimeError, "email or phone must be provided") end + it "Raises RuntimeError (not NoMethodError) when account_id is nil" do + api_key = "foobar1" + # Sift.account_id is not set, so account_id resolves to nil + client = Sift::Client.new(:api_key => api_key) + expect { + client.get_global_profile("user1") + }.to raise_error(RuntimeError, "account_id cannot be empty") + end + + it "Raises RuntimeError (not NoMethodError) when params is nil in get_global_profile_by_attributes" do + api_key = "foobar1" + + expect { + Sift::Client.new(:api_key => api_key, :account_id => "ACCT").get_global_profile_by_attributes(nil) + }.to raise_error(RuntimeError, "email or phone must be provided") + end + it "Handles identity_found => false with null fields" do api_key = "foobar1"