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. Pass a Global API access token minted by `ShopifyAPI::Auth::GlobalApiClientCredentials.global_api_client_credentials`; the library sends one request per call and does not cache tokens.

## 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)
68 changes: 68 additions & 0 deletions docs/usage/app_events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Logging App Events

`ShopifyAPI.log` sends one App Events request using the Global API access token you pass in. Mint the token with `ShopifyAPI::Auth::GlobalApiClientCredentials.global_api_client_credentials`, which returns a `ShopifyAPI::Auth::GlobalApiToken` (`access_token`, `expires_at`) and mints a new token on every call. The library does not cache tokens; cache `access_token` until `expires_at` in your application.

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

## Identify the shop

Pass `myshopify_domain:` as `example.myshopify.com`. The library also accepts `https://example.myshopify.com` and `https://admin.shopify.com/store/example` and normalizes them to `example.myshopify.com`.

Numeric IDs, Shop GIDs, and untrusted domains raise `ShopifyAPI::Errors::InvalidShopError`.

## Log an event

```ruby
token = ShopifyAPI::Auth::GlobalApiClientCredentials.global_api_client_credentials

result = ShopifyAPI.log(
myshopify_domain: "example.myshopify.com",
event_handle: "onboarding_completed",
idempotency_key: "onboard_23423423_v3",
attributes: {
onboarding_version: 3,
source: "embedded_app",
},
access_token: token.access_token,
timestamp: Time.now,
)
```

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-10`.

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

## 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.

## Errors

`ShopifyAPI.log` returns `ShopifyAPI::AppEvents::LogResult` after Shopify accepts the event. It raises `ShopifyAPI::Errors::HttpResponseError` for every HTTP error, including `401`, `409`, and `429`. It sends exactly one request and never retries.

It raises `ShopifyAPI::Errors::MissingRequiredArgumentError` for a blank `access_token`. After a `401`, mint a new token in your application and call `ShopifyAPI.log` again.

It raises `ShopifyAPI::Errors::RequestAccessTokenError` when a successful token response does not contain an access token.
24 changes: 24 additions & 0 deletions lib/shopify_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

require_relative "shopify_api/inflector"
require_relative "shopify_api/admin_versions"
require_relative "shopify_api/global_api_versions"
require_relative "shopify_api/webhooks/webhook_handler"

loader = Zeitwerk::Loader.for_gem
Expand All @@ -30,6 +31,29 @@ module ShopifyAPI
class << self
extend T::Sig

# Sends one App Event to Shopify with the given Global API access token. Unrelated to
# ShopifyAPI::Logger, which writes this library's own diagnostic output.
sig do
params(
myshopify_domain: String,
event_handle: String,
idempotency_key: String,
attributes: T::Hash[T.any(String, Symbol), T.untyped],
access_token: String,
timestamp: T.nilable(Time),
).returns(AppEvents::LogResult)
end
def log(myshopify_domain:, event_handle:, idempotency_key:, attributes:, access_token:, timestamp: nil)
AppEvents.log(
myshopify_domain: myshopify_domain,
event_handle: event_handle,
idempotency_key: idempotency_key,
attributes: attributes,
access_token: access_token,
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
80 changes: 80 additions & 0 deletions lib/shopify_api/app_events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 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"
# 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(
myshopify_domain: String,
event_handle: String,
idempotency_key: String,
attributes: T::Hash[T.any(String, Symbol), T.untyped],
access_token: String,
timestamp: T.nilable(Time),
).returns(LogResult)
end
def log(myshopify_domain:, event_handle:, idempotency_key:, attributes:, access_token:, timestamp: nil)
unless ShopifyAPI::Context.setup?
raise ShopifyAPI::Errors::ContextNotSetupError,
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

if access_token.strip.empty?
raise ShopifyAPI::Errors::MissingRequiredArgumentError, "access_token argument is required"
end

payload = EventPayload.build(
myshopify_domain: myshopify_domain,
event_handle: event_handle,
idempotency_key: idempotency_key,
attributes: attributes,
timestamp: timestamp,
)
response = post_event(payload: payload, access_token: access_token)

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

private

sig do
params(
payload: T::Hash[Symbol, T.untyped],
access_token: String,
).returns(Clients::HttpResponse)
end
def post_event(payload:, access_token:)
client = Clients::GlobalApiClient.new(
base_path: "/app/#{Context.global_api_version}",
access_token: access_token,
)
client.request(
Clients::HttpRequest.new(
http_method: :post,
path: EVENTS_PATH,
body: payload,
body_type: "application/json",
),
)
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
99 changes: 99 additions & 0 deletions lib/shopify_api/app_events/event_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module AppEvents
module EventPayload
extend T::Sig

ATTRIBUTE_KEY_PATTERN = /\A[a-zA-Z0-9_.\-]+\z/
ALLOWED_ATTRIBUTE_VALUE_TYPES = T.let(
[String, Integer, Float, TrueClass, FalseClass],
T::Array[Module],
)

class << self
extend T::Sig

sig do
params(
myshopify_domain: String,
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(myshopify_domain:, event_handle:, idempotency_key:, attributes:, timestamp: nil)
validated_domain = Utils::ShopValidator.sanitize!(myshopify_domain)

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

event_timestamp = timestamp || Time.now

payload = {
myshopify_domain: validated_domain,
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

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
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
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