Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

Note: For changes to the API, see https://shopify.dev/changelog?filter=api
## Unreleased
- [#1459](https://github.com/Shopify/shopify-api-ruby/pull/1459) Add `ShopifyAPI.log` and Global API client credentials for App Events.

## 16.3.0 (2026-08-04)
- [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) Add `ShopifyAPI::Utils::ShopValidator` with `sanitize_shop_domain` and `sanitize!`.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ Once your app can perform OAuth, it can now make authenticated Shopify API calls
* Making [Admin GraphQL API](docs/usage/graphql.md) requests
* Making [Storefront GraphQL API](docs/usage/graphql_storefront.md) requests

### Log App Events

Use [`ShopifyAPI.log`](docs/usage/app_events.md) to send App Events for shops where your app is installed. The library uses the app credentials configured in `ShopifyAPI::Context` and caches the Global API access token.

## Breaking Change Notices

### Breaking change notice for version 15.0.0
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ You can follow our getting started guide to learn how to use this library.
- [REST Admin API](usage/rest.md)
- [Make a GraphQL API call](usage/graphql.md)
- [Make a Storefront API call](usage/graphql_storefront.md)
- [App Events](usage/app_events.md)
- [Webhooks](usage/webhooks.md)
67 changes: 67 additions & 0 deletions docs/usage/app_events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Logging App Events

Use `ShopifyAPI.log` to send an App Event to Shopify for a shop where your app is installed. The library mints and caches the app-level Global API access token from the `api_key` and `api_secret_key` configured in `ShopifyAPI::Context`.

`ShopifyAPI.log` is unrelated to `ShopifyAPI::Logger`. `ShopifyAPI::Logger` writes diagnostic output from this library; `ShopifyAPI.log` sends partner-facing App Events to Shopify.

## Find the shop ID

The App Events API requires a numeric shop ID or a Shop GID. If your application only stores the shop domain, query the Admin GraphQL API for the ID:

```graphql
{
shop {
id
}
}
```

The response contains a value such as `gid://shopify/Shop/23423423`. This library does not resolve a shop domain to an ID.

## Log an event

```ruby
result = ShopifyAPI.log(
shop_id: "gid://shopify/Shop/23423423",
event_handle: "onboarding_completed",
idempotency_key: "onboard_23423423_v3",
attributes: {
onboarding_version: 3,
source: "embedded_app",
},
timestamp: Time.now,
)

puts "Shopify replayed this event" if result.replayed
```

The app must be installed on the target shop. The App Events API requires `attributes`, so pass `{}` when the event carries no data. `timestamp` is optional; the library uses the current time when you omit it.

The `idempotency_key` must be unique across all shops for your app. Shopify keys the idempotency cache by app and key, not by shop. Reusing one key for different shops can replay the first response instead of recording the later event.

## Global API version

App Events is served by the Global API, which is versioned separately from the Admin API. Configure `global_api_version` independently from `ShopifyAPI::Context.api_version`.

The supported Global API versions are `unstable`, `2026-10`, and `2026-07`. The current default is `2026-07`.

```ruby
ShopifyAPI::Context.setup(
# ...
api_version: "2026-07",
global_api_version: "2026-07",
)
```

## Target a non-production Shopify environment

The Global API defaults to `https://api.shopify.com`. Override `global_api_url` only when Shopify provides a different Global API host for a non-production environment:

```ruby
ShopifyAPI::Context.setup(
# ...
global_api_url: "https://api.shop.dev",
)
```

`global_api_url` must be an absolute HTTPS URL.
21 changes: 21 additions & 0 deletions lib/shopify_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,27 @@ module ShopifyAPI
class << self
extend T::Sig

# Sends an App Event to Shopify using the app's client credentials. Unrelated to
# ShopifyAPI::Logger, which writes this library's own diagnostic output.
sig do
params(
shop_id: T.any(String, Integer),
event_handle: String,
idempotency_key: String,
attributes: T::Hash[T.any(String, Symbol), T.untyped],
timestamp: T.nilable(Time),
).returns(AppEvents::LogResult)
end
def log(shop_id:, event_handle:, idempotency_key:, attributes:, timestamp: nil)
AppEvents.log(
shop_id: shop_id,
event_handle: event_handle,
idempotency_key: idempotency_key,
attributes: attributes,
timestamp: timestamp,
)
end

# REST resources are only autoloaded for API versions this gem bundles (see
# Context.load_rest_resources). Without this hook, using a version whose
# resources aren't bundled - a newly released version, or `unstable` - fails
Expand Down
115 changes: 115 additions & 0 deletions lib/shopify_api/app_events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module AppEvents
extend T::Sig

# App Events is served by the Global API, which is versioned independently from the Admin API.
EVENTS_PATH = "events"
IDEMPOTENCY_CONFLICT_MAX_RETRIES = 2
IDEMPOTENCY_CONFLICT_RETRY_WAIT_TIME = 1
# The server sets `Idempotent-Replayed`; shopify.dev documents `Idempotent-Replay`.
# Both carry the string `true`. HttpResponse#headers keys are downcased by Net::HTTPHeader#to_h.
REPLAY_HEADERS = T.let(["idempotent-replayed", "idempotent-replay"], T::Array[String])

class << self
extend T::Sig

sig do
params(
shop_id: T.any(String, Integer),
event_handle: String,
idempotency_key: String,
attributes: T::Hash[T.any(String, Symbol), T.untyped],
timestamp: T.nilable(Time),
).returns(LogResult)
end
def log(shop_id:, event_handle:, idempotency_key:, attributes:, timestamp: nil)
unless ShopifyAPI::Context.setup?
raise ShopifyAPI::Errors::ContextNotSetupError,
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

payload = EventPayload.build(
shop_id: shop_id,
event_handle: event_handle,
idempotency_key: idempotency_key,
attributes: attributes,
timestamp: timestamp,
)
token = Auth::GlobalApiClientCredentials.global_api_client_credentials(force_refresh: false)
response = begin
post_event_with_retries(payload: payload, token: token)
rescue ShopifyAPI::Errors::HttpResponseError => error
raise unless error.code == 401

replacement_token = Auth::GlobalApiClientCredentials.global_api_client_credentials(
force_refresh: true,
rejected_access_token: token.access_token,
)
begin
post_event_with_retries(payload: payload, token: replacement_token)
rescue ShopifyAPI::Errors::HttpResponseError => retry_error
if retry_error.code == 401
Auth::GlobalApiClientCredentials.clear_cached_token_if_matches!(
access_token: replacement_token.access_token,
)
end
raise
end
end

LogResult.new(replayed: replayed?(response))
end

private

sig do
params(
payload: T::Hash[Symbol, T.untyped],
token: Auth::GlobalApiToken,
).returns(Clients::HttpResponse)
end
def post_event(payload:, token:)
client = Clients::GlobalApiClient.new(
base_path: "/app/#{Context.global_api_version}",
access_token: token.access_token,
)
client.request(
Clients::HttpRequest.new(
http_method: :post,
path: EVENTS_PATH,
body: payload,
body_type: "application/json",
),
)
end

sig do
params(
payload: T::Hash[Symbol, T.untyped],
token: Auth::GlobalApiToken,
).returns(Clients::HttpResponse)
end
def post_event_with_retries(payload:, token:)
retries = 0
loop do
return post_event(payload: payload, token: token)
rescue ShopifyAPI::Errors::HttpResponseError => error
raise unless error.code == 409 && retries < IDEMPOTENCY_CONFLICT_MAX_RETRIES

retries += 1
sleep(error.response.retry_request_after || IDEMPOTENCY_CONFLICT_RETRY_WAIT_TIME)
end
end

sig { params(response: Clients::HttpResponse).returns(T::Boolean) }
def replayed?(response)
REPLAY_HEADERS.any? do |name|
response.headers[name]&.any? { |value| value.strip.casecmp?("true") }
end
end
end
end
end
132 changes: 132 additions & 0 deletions lib/shopify_api/app_events/event_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module AppEvents
module EventPayload
extend T::Sig

MAX_IDEMPOTENCY_KEY_LENGTH = 64
MAX_ATTRIBUTE_KEYS = 15
MAX_ATTRIBUTE_KEY_LENGTH = 64
MAX_ATTRIBUTE_STRING_VALUE_LENGTH = 128
MAX_FUTURE_TIMESTAMP_SECONDS = 300
ATTRIBUTE_KEY_PATTERN = /\A[a-zA-Z0-9_.\-]+\z/
SHOP_GID_PREFIX = "gid://shopify/Shop/"
NUMERIC_ID_PATTERN = /\A\d+\z/
ALLOWED_ATTRIBUTE_VALUE_TYPES = T.let(
[String, Integer, Float, TrueClass, FalseClass],
T::Array[Module],
)

class << self
extend T::Sig

sig do
params(
shop_id: T.any(String, Integer),
event_handle: String,
idempotency_key: String,
attributes: T::Hash[T.any(String, Symbol), T.untyped],
timestamp: T.nilable(Time),
).returns(T::Hash[Symbol, T.untyped])
end
def build(shop_id:, event_handle:, idempotency_key:, attributes:, timestamp: nil)
normalized_shop_id = String(shop_id).delete_prefix(SHOP_GID_PREFIX)
unless NUMERIC_ID_PATTERN.match?(normalized_shop_id)
raise Errors::InvalidAppEventError, "shop_id must be a numeric ID or Shopify Shop GID"
end

if event_handle.strip.empty?
raise Errors::InvalidAppEventError, "event_handle must not be blank"
end

if idempotency_key.strip.empty?
raise Errors::InvalidAppEventError, "idempotency_key must not be blank"
end

if idempotency_key.length > MAX_IDEMPOTENCY_KEY_LENGTH
raise Errors::InvalidAppEventError,
"idempotency_key must be at most #{MAX_IDEMPOTENCY_KEY_LENGTH} characters"
end

event_timestamp = timestamp || Time.now
if event_timestamp > Time.now + MAX_FUTURE_TIMESTAMP_SECONDS
raise Errors::InvalidAppEventError,
"timestamp must not be more than #{MAX_FUTURE_TIMESTAMP_SECONDS} seconds in the future"
end

payload = {
shop_id: normalized_shop_id,
event_handle: event_handle,
timestamp: event_timestamp.utc.strftime("%FT%T.%LZ"),
idempotency_key: idempotency_key,
}

payload[:attributes] = normalize_attributes(attributes)
payload
end

private

sig do
params(
attributes: T::Hash[T.any(String, Symbol), T.untyped],
).returns(T::Hash[String, T.untyped])
end
def normalize_attributes(attributes)
normalized = T.let({}, T::Hash[String, T.untyped])
attributes.each do |raw_key, value|
key = raw_key.to_s
if normalized.key?(key)
raise Errors::InvalidAppEventError,
"attributes contains duplicate key #{key.inspect} after key stringification"
end

normalized[key] = value
end

if normalized.length > MAX_ATTRIBUTE_KEYS
raise Errors::InvalidAppEventError, "attributes must contain at most #{MAX_ATTRIBUTE_KEYS} keys"
end

normalized.each do |key, value|
validate_attribute_key(key)
validate_attribute_value(key, value)
end

normalized
end

sig { params(key: String).void }
def validate_attribute_key(key)
unless ATTRIBUTE_KEY_PATTERN.match?(key)
raise Errors::InvalidAppEventError,
"attributes key #{key.inspect} may contain only letters, numbers, underscores, periods, and hyphens"
end
if key.length > MAX_ATTRIBUTE_KEY_LENGTH
raise Errors::InvalidAppEventError,
"attributes key #{key.inspect} must be at most #{MAX_ATTRIBUTE_KEY_LENGTH} characters"
end
end

sig { params(key: String, value: T.untyped).void }
def validate_attribute_value(key, value)
unless ALLOWED_ATTRIBUTE_VALUE_TYPES.include?(value.class)
raise Errors::InvalidAppEventError,
"attributes value for #{key.inspect} must be a String, Integer, Float, true, or false"
end
if value.is_a?(Float) && !value.finite?
raise Errors::InvalidAppEventError, "attributes Float value for #{key.inspect} must be finite"
end

if value.is_a?(String) && value.length > MAX_ATTRIBUTE_STRING_VALUE_LENGTH
raise Errors::InvalidAppEventError,
"attributes String value for #{key.inspect} must be at most " \
"#{MAX_ATTRIBUTE_STRING_VALUE_LENGTH} characters"
end
end
end
end
end
end
11 changes: 11 additions & 0 deletions lib/shopify_api/app_events/log_result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module AppEvents
class LogResult < T::Struct
# True when Shopify replayed a cached response for this idempotency key.
const :replayed, T::Boolean
end
end
end
Loading
Loading