> Documentation index: [Saleor](/llms.txt) · [This section](/upgrade-guides/llms.txt)
> Source: https://docs.saleor.io/upgrade-guides/core/3-22-to-3-23

# Upgrading From 3.22 To 3.23

info

To follow a zero-downtime upgrade strategy to 3.23, **we recommend first migrating to the latest 3.22.x release** and enabling the Celery worker to process all data migrations asynchronously. Otherwise, you will need to schedule downtime to ensure data migrations complete correctly.

<a id="customer-email-on-saleor-cloud"></a>

## Customer email on Saleor Cloud

On Saleor Cloud, [Customer Emails](/developer/app-store/apps/customer-emails/overview.md) is the app for customer transactional mail from 3.23. It cannot be installed on 3.22 — stay on the [SMTP app](/developer/app-store/apps/smtp/overview.md) until you upgrade. Self-hosted stores keep using the SMTP app.

<a id="changes-in-webhook-emission-for-instance-updates"></a>

## Changes in Webhook Emission for Instance Updates

A new setting, `useLegacyUpdateWebhookEmission`, has been introduced in shop settings to control the behavior of update webhook emissions. This setting allows users to toggle between the legacy behavior (sending both update webhooks and metadata-specific webhooks for metadata-only changes) and the new behavior (sending only metadata-specific webhooks instead). The legacy behavior is now deprecated as of version 3.22 and will be removed in future versions. The new flow will become the default behavior.

<a id="description"></a>

### Description

-   **When enabled - legacy behavior**: Both update webhooks (e.g., `customerUpdated`, `productVariantUpdated`) and metadata-specific webhooks (e.g., `customerMetadataUpdated`, `productVariantMetadataUpdated`) are sent for metadata-only changes.
-   **When disabled - new behavior**: Only metadata-specific webhooks (e.g., `customerMetadataUpdated`, `productVariantMetadataUpdated`) are sent for metadata-only changes, and update webhooks are not triggered.

<a id="benefits-of-disabling"></a>

### Benefits of Disabling

Disabling this setting prevents sending update webhooks when only metadata changes are made to the instance, reducing unnecessary webhook traffic.

<a id="applicable-mutations"></a>

### Applicable Mutations

This setting applies to all mutations capable of triggering both `<object>Updated` and `<object>MetadataUpdated` webhooks.

<a id="dashboard-extensions"></a>

## Dashboard extensions

Since 3.22, Dashboard extension manifests are validated on the frontend. Installing apps directly via GraphQL is discouraged, as such apps may not work properly.

**For Cloud users:** You should not be affected by these changes.

**For self-hosted:**

-   Ensure you use the latest matching Dashboard versions. Early Dashboard v3.22.x releases are not compatible with later Core 3.22 versions. Dashboard v3.23.x and Core v3.23.x are fully compatible.
-   Fields on type `AppExtension` and `AppExtensionManifest`: `mount`, `target`, and `options` have been removed. Use the fields `mountName`, `targetName`, and `settings` instead, which directly replace the old ones.
-   New fields are intended to be used by Dashboard only. Modifying them requires caution and may require frontend adjustments.
-   New fields are no longer strictly validated, which means installing an app extension _without_ Dashboard can cause unexpected Dashboard behavior.

<a id="explicit-delivery-options-calculation"></a>

## Explicit Delivery Options Calculation

In previous versions, querying delivery-related fields on `Checkout` like `shippingMethods`, `availableShippingMethods`, `deliveryMethod` and `shippingMethod` could implicitly trigger sync webhooks (`SHIPPING_LIST_METHODS_FOR_CHECKOUT`, `CHECKOUT_FILTER_SHIPPING_METHODS`). Additionally, checkout mutations would silently invalidate the shipping cache, resulting in additional webhook calls.

<a id="deprecation-of-shippingmethod-id-in-checkoutdeliverymethodupdate"></a>

### Deprecation of ShippingMethod ID in `checkoutDeliveryMethodUpdate`

Using a `ShippingMethod` ID as `deliveryMethodId` in `checkoutDeliveryMethodUpdate` is deprecated. Use the `CheckoutDelivery` ID (returned by `deliveryOptionsCalculate`) instead.

<a id="whats-new"></a>

### What's New

Saleor 3.23 makes delivery options calculation explicit by introducing:

-   New mutation: [`deliveryOptionsCalculate`](/api-reference/shipping/mutations/delivery-options-calculate.md). Returns a list of available deliveries for the checkout. Calling this mutation explicitly fires `SHIPPING_LIST_METHODS_FOR_CHECKOUT` and `CHECKOUT_FILTER_SHIPPING_METHODS` webhooks. The mutation replaces the deprecated fields: `checkout.shippingMethods` and `checkout.availableShippingMethods`.
    
-   New field: [`Checkout.delivery`](/api-reference/checkout/objects/checkout.md#delivery). It represents the currently assigned delivery method. The field replaces the deprecated `checkout.deliveryMethod` and `checkout.shippingMethod`.
    
-   New checkout problems:
    
    -   [`CheckoutProblemDeliveryMethodStale`](/api-reference/checkout/objects/checkout-problem-delivery-method-stale.md) - the assigned delivery method needs recalculation (e.g. after a voucher change, address update, or line quantity change). Call `deliveryOptionsCalculate` to resolve.
    -   [`CheckoutProblemDeliveryMethodInvalid`](/api-reference/checkout/objects/checkout-problem-delivery-method-invalid.md) - the assigned delivery method is no longer valid. The customer must re-select a delivery method.

warning

When reading `problem`, just like other interface implementations, you should rely on additional `__typename` field check, because new implementations shape may differ.

For example, `CheckoutProblemDeliveryMethodInvalid` and `CheckoutProblemDeliveryMethodStale` do not provide `line` field. Code from `3.22`: `problem.line` will always work, but in 3.23 it may break.

Change `if(problem.line)` to `if(problem.__typename === "CheckoutLineProblemVariantNotAvailable" && problem.line)` to ensure it's safe before an upgrade.

See [delivery method problems](/developer/checkout/problems.md#checkoutproblemdeliverymethodstale) for full handling guidance.

-   Deprecations introduced in 3.23:
    -   `checkout.shippingMethods` - use `deliveryOptionsCalculate` mutation
    -   `checkout.availableShippingMethods` - use `deliveryOptionsCalculate` mutation
    -   `checkout.deliveryMethod` - use `checkout.delivery`
    -   `checkout.shippingMethod` - use `checkout.delivery`

warning

Querying the deprecated fields (`shippingMethods`, `availableShippingMethods`, `deliveryMethod`, `shippingMethod`) still works in 3.23, but these fields will be removed in a future release. Requesting these fields can implicitly trigger the `SHIPPING_LIST_METHODS_FOR_CHECKOUT`, `CHECKOUT_FILTER_SHIPPING_METHODS` webhooks.

<a id="what-has-changed"></a>

### What Has Changed

-   In previous releases, fetching available shipping methods was implicitly done by requesting the shipping-related fields:

```graphql
query GetCheckout($id: ID) {
  checkout(id: $id) {
    availableShippingMethods {
      id
    }
    shippingMethods {
      id
      name
      active
      price {
        amount
        currency
      }
    }
  }
}
```

Starting from 3.23, there is a dedicated mutation `deliveryOptionsCalculate`, to fetch deliveries:

```graphql
mutation DeliveryOptionsCalculate($id: ID!) {
  deliveryOptionsCalculate(id: $id) {
    deliveries {
      id
      shippingMethod {
        name
        active
        price {
          amount
        }
      }
    }
    errors {
      field
      message
      code
    }
  }
}
```

<a id="validating-the-delivery-assigned-to-the-checkout"></a>

#### Validating the Delivery Assigned to the Checkout

In previous versions, Saleor was making the validation after each change on Checkout, causing multiple webhooks called implicitly. This slows down the checkout process, by increasing the network traffic and costs. Starting from 3.23, [Checkout.problems](/api-reference/checkout/objects/checkout.md#problems) can contain the problem: [CheckoutProblemDeliveryMethodStale](/api-reference/checkout/objects/checkout-problem-delivery-method-stale.md).

```graphql
query Checkout($id: ID!) {
  checkout(id: $id) {
    problems {
      ... on CheckoutProblemDeliveryMethodStale {
        __typename
      }
    }
  }
}
```

The problem provides the information that mutation call: `deliveryOptionsCalculate` is needed to confirm that stale delivery method is still valid for the checkout. The storefront decides when to call `deliveryOptionsCalculate`.

info

Calling `checkoutComplete` with existing `CheckoutProblemDeliveryMethodStale` will implicitly refresh the delivery method.

<a id="handling-invalid-delivery-assigned-to-the-checkout"></a>

#### Handling Invalid Delivery Assigned to the Checkout

In previous versions, Saleor was implicitly removing the delivery method that was not valid anymore. In such case, the delivery-related fields were returning `null`. Starting from 3.23, the invalid delivery is represented by the new problem. The `checkout.delivery` field returns the invalid delivery assigned by customer:

```graphql
query Checkout($id: ID!) {
  checkout(id: $id) {
    delivery {
      id
    }
    problems {
      ... on CheckoutProblemDeliveryMethodInvalid {
        __typename
      }
    }
  }
}
```

Saleor won't unassign it implicitly anymore. The storefront decides when to change the invalid delivery method.

info

Calling `checkoutComplete` with existing `CheckoutProblemDeliveryMethodInvalid` will return error.

<a id="migration-steps"></a>

### Migration steps

-   Replace fetching shipping methods:
    -   Instead of `checkout.shippingMethods`, `checkout.availableShippingMethods`, use the explicit mutation: `deliveryOptionsCalculate(id: <checkoutID>)`
-   Handling new checkout problems
    -   When `CheckoutProblemDeliveryMethodStale` appears in `checkout.problems`:
        -   The assigned shipping method is considered stale.
        -   To verify its applicability, call `deliveryOptionsCalculate`. Based on the response:
            -   If `CheckoutProblemDeliveryMethodStale` is removed from `checkout.problems`, the shipping method is still applicable.
            -   If `CheckoutProblemDeliveryMethodStale` is replaced with `CheckoutProblemDeliveryMethodInvalid`, the shipping method is no longer valid.
    -   When `CheckoutProblemDeliveryMethodInvalid` appears in `checkout.problems`:
        -   The assigned shipping method is not applicable.
        -   A new shipping method must be selected by calling `deliveryOptionsCalculate` to retrieve valid options.
        -   `checkoutDeliveryMethodUpdate` needs to be called to replace non-applicable shipping method.
-   Updating shipping method checks
    -   Replace usage of `checkout.deliveryMethod` or `checkout.shippingMethod` with `checkout.delivery`
    -   Replace conditions like `checkout.deliveryMethod == null` or `checkout.shippingMethod == null` with:
        -   Shipping is not applicable anymore: `checkout.problems` contains `CheckoutProblemDeliveryMethodInvalid`.
        -   Shipping has not been set: `checkout.delivery == null`.

<a id="removal-of-legacy-digital-products-flow"></a>

## Removal of Legacy Digital Products Flow

If you were using the old digital content API:

1.  Remove all usage of:
    
    -   Queries: `digitalContent`, `digitalContents`
    -   Mutations:
        -   `digitalContentCreate`
        -   `digitalContentUpdate`
        -   `digitalContentDelete`
        -   `digitalContentUrlCreate`
2.  Update deprecated product type filtering and sorting:
    
    The following fields are deprecated and will be removed in v3.24.0:
    
    -   `productTypes(filter: { productType: DIGITAL })` - instead, use metadata or attributes to represent and filter digital product types.
    -   `productTypes(sortBy: { field: DIGITAL })` - instead, use `sortBy: { field: SHIPPING_REQUIRED }`.
3.  Migrate to the official supported solution by [following the official guide](/recipes/digital-products.md)
    
    In short:
    
    -   Store digital files externally (e.g., AWS S3 or similar)
    -   Use **metadata** to associate files with variants/orders
    -   Use **webhooks (e.g., order fulfillment events)** to:
        -   Generate download links
        -   Send them to customers

<a id="removal-of-deprecated-editorjs-behaviors"></a>

## Removal of Deprecated EditorJS Behaviors

-   **[`EDITOR_JS_LINK_REL`](/setup/configuration.md#editor_js_link_rel):** the default behavior has changed. Links rendered by EditorJS (`<a href="..." rel="...">`) now include `rel="noopener noreferrer"` by default (previously, no `rel` attribute was added).
    
    Either:
    
    1.  (Recommended) Do nothing and accept the new default (`noopener noreferrer`)
    2.  Or, set the environment variable `EDITOR_JS_LINK_REL` to an empty value (e.g., `export EDITOR_JS_LINK_REL=`) to retain the previous behavior
-   **[`UNSAFE_EDITOR_JS_ALLOWED_URL_SCHEMES`](/setup/configuration.md#unsafe_editor_js_allowed_url_schemes)**: this setting has been removed. It is no longer possible to extend the list of allowed URL schemes via configuration.
    
    This change may affect content relying on custom or non-standard URL schemes. If you require support for additional URL schemes, open a request: [https://github.com/saleor/saleor/issues](https://github.com/saleor/saleor/issues)
    

<a id="editorjs-parser-strictness"></a>

### EditorJS Parser Strictness

The EditorJS parser no longer accepts unknown or extra fields in content blocks. Previously, unexpected fields were accepted. If you only use Saleor Dashboard to edit rich-text descriptions, then nothing needs to be done. Only the following EditorJS extensions are supported:

-   [`@editorjs/header`](https://www.npmjs.com/package/@editorjs/header)
-   [`@editorjs/list`](https://www.npmjs.com/package/@editorjs/list)
-   [`@editorjs/paragraph`](https://www.npmjs.com/package/@editorjs/paragraph)
-   [`@editorjs/quote`](https://www.npmjs.com/package/@editorjs/quote)
-   [`@editor-js/embed`](https://www.npmjs.com/package/@editorjs/embed)
-   [`@editor-js/image`](https://www.npmjs.com/package/@editorjs/image)

Any other extension is unsupported and will lead to parsing errors when running mutations such as `productUpdate()`. If you need support for additional extensions, open a request: [https://github.com/saleor/saleor/issues](https://github.com/saleor/saleor/issues)

<a id="asynchronous-image-fetching-for-product-media"></a>

## Asynchronous Image Fetching for Product Media

Previously, `productMediaCreate` and `productBulkCreate` mutations blocked until external images (provided via `mediaUrl`) were fully downloaded and processed. Starting with version 3.23, images from external URLs are fetched **asynchronously** in the background.

<a id="what-has-changed-1"></a>

### What Has Changed

-   When creating product media with an external image URL, the mutation now returns immediately without waiting for the image to be downloaded.
-   The image is fetched in the background by a Celery task with automatic retries (up to 5 attempts with exponential backoff).
-   While the image is being fetched:
    -   The `url` field on `ProductMedia` returns a **proxy URL** that responds with **HTTP 503** (`"Image has not been fetched yet, try later."`) until the image is downloaded and stored.
    -   Thumbnail requests for pending images also return **HTTP 503** with the same message.
-   If the image fetch fails permanently (after all retries), the `ProductMedia` entry is deleted.

<a id="required-client-changes"></a>

### Required client changes

1.  **Handle HTTP 503 responses** when requesting thumbnails or media URLs. This status code indicates the image is still being processed. Implement a retry mechanism with appropriate backoff.
    
2.  **Do not assume images are available immediately** after `productMediaCreate` or `productBulkCreate` returns. The media object will exist, but the image may not be ready yet.
    
3.  **`ProductMedia.url` no longer returns `null` for missing images**: Previously, when a `ProductMedia` had no stored image, the `url` field returned `null`. Now it returns a proxy URL instead. If your client code checks for `null` on `ProductMedia.url` to detect missing images, you need to update that logic — the URL will always be returned, but it may respond with HTTP 503 if the image is still being fetched.
    

<a id="affected-mutations"></a>

### Affected mutations

-   [`productMediaCreate`](/api-reference/products/mutations/product-media-create.md) — when using the `mediaUrl` input field with an image URL.
-   [`productBulkCreate`](/api-reference/products/mutations/product-bulk-create.md) — when providing media with `mediaUrl` in the product input.

info

This change only affects media created via external image URLs (`mediaUrl`). Direct file uploads and video/oEmbed URLs are not affected.

<a id="new-image-endpoint"></a>

### New `/image/` endpoint

A new URL endpoint has been added: `/image/<instance_id>/`. While the image is being fetched, this endpoint returns HTTP 503. Once the image is stored, it redirects to the image file.

If your CDN or reverse proxy is configured to allowlist specific Saleor URL patterns (e.g., `/thumbnail/`, `/media/`), you will need to add `/image/` to the allowed paths.

<a id="password-login-mode"></a>

## Password Login Mode

A new `passwordLoginMode` setting has been added to `Shop` / `SiteSettings`, allowing administrators to control password-based authentication. This is especially useful for organizations transitioning staff to OIDC/SSO.

Three modes are available:

-   **`ENABLED`** (default) — password login works normally for all users. This is backward-compatible; existing deployments are unaffected.
-   **`CUSTOMERS_ONLY`** — customers can still log in with passwords, but staff tokens are issued with `is_staff=false` and no permissions.
-   **`DISABLED`** — all password-based mutations (`tokenCreate`, `passwordChange`, `setPassword`, `requestPasswordReset`) are blocked for all users.

For full details, including configuration examples and migration steps, see the [Password Login Mode documentation](/api-usage/authentication.md#password-login-mode).

<a id="removal-of-adyen-plugin"></a>

## Removal of Adyen Plugin

The legacy Adyen payment gateway plugin has been removed from Saleor core. If you were using the Adyen plugin, switch to the [Adyen App](/developer/app-store/apps/adyen/overview.md) before upgrading. Remove any plugin configuration referencing `mirumee.payments.adyen` from your environment.

<a id="removal-of-np-atobarai-plugin"></a>

## Removal of NP Atobarai Plugin

The legacy NP Atobarai payment gateway plugin has been removed from Saleor core. If you were using this plugin, switch to the [NP Atobarai App](/developer/app-store/apps/np-atobarai/overview.md). See the [migration from plugin guide](/developer/app-store/apps/np-atobarai/migration-from-plugin.md) for detailed steps. Remove any plugin configuration referencing the NP Atobarai plugin ID from your environment.

<a id="removal-of-paymentpartial-field"></a>

## Removal of `Payment.partial` Field

The `partial` field has been removed from the `Payment` GraphQL type. It was only used by the removed Adyen integration, so likely you haven't used it if you haven't used the Adyen plugin. If your queries or client code references `Payment.partial`, remove it from your GraphQL selections. This field is no longer available in the schema.

<a id="staffdelete-behavior-change"></a>

## `staffDelete` Behavior Change

The `staffDelete` mutation now fully deletes staff users from the database, regardless of whether they have associated order history. Previously, staff users with orders were only deactivated instead of deleted. Review any workflows that rely on deactivated staff accounts remaining in the system. If you need to preserve staff user data, export it before deletion.

<a id="removal-of-jwt_expire-setting"></a>

## Removal of `JWT_EXPIRE` Setting

The `JWT_EXPIRE` configuration option, which allowed ignoring JWT token expiration, has been removed. If you had `JWT_EXPIRE` set in your environment, remove it. All JWT tokens are now subject to standard expiration rules. Ensure your clients handle token refresh correctly.

<a id="attribute-fields-are-now-non-nullable"></a>

## `Attribute` Fields Are Now Non-Nullable

The fields `name`, `slug`, and `type` on the `Attribute` GraphQL type are now non-nullable — they always return a value. If your client code has null-checks or optional chaining for `Attribute.name`, `Attribute.slug`, or `Attribute.type`, those checks are no longer necessary. Update your types or codegen accordingly.

<a id="refundsettingsupdate-mutation-change"></a>

## `RefundSettingsUpdate` Mutation Change

The `refundSettings` field in the `RefundSettingsUpdate` mutation response is now nullable, correctly reflecting `null` when errors occur. If your client code assumes `refundSettings` is always present in the response, add a null-check to handle error cases.

<a id="shipping-method-metadata-denormalization"></a>

## Shipping Method Metadata Denormalization

Shipping method metadata is now copied to dedicated order fields during checkout-to-order conversion. After an order is created, updates to the original shipping method's metadata are no longer reflected in the order's `shippingMethod.metadata`. If your integration reads shipping method metadata from orders and expects it to stay in sync with the shipping method object, update your logic. To update metadata on existing orders, modify the order's shipping method metadata directly.

<a id="graphql-federation-type-change"></a>

## GraphQL Federation Type Change

The `representations` field type in the `_entities` query has changed from `[_Any]` to `[_Any!]!` (non-nullable list of non-nullable items). If you use Apollo Federation or a custom gateway that interacts with Saleor's `_entities` query, ensure your gateway is compatible with the stricter type. Update any federation tooling that may break on the non-nullable constraint.

<a id="new-nonnegativeint-scalar-and-stricter-time-unit-scalars"></a>

## New `NonNegativeInt` Scalar and Stricter Time Unit Scalars

A new `NonNegativeInt` GraphQL scalar type has been added that rejects negative integers at the schema level. The existing `Minute`, `Hour`, and `Day` scalars now inherit from `NonNegativeInt` instead of `Int`, enforcing non-negative values directly in the GraphQL layer.

This is a **breaking change**: `channelCreate` and `channelUpdate` mutations now raise GraphQL type errors when negative time values are provided (e.g., negative `orderMarkAsPaidStrategyTimeout`). Previously, they returned an `INVALID` business error code.

<a id="migration-steps-1"></a>

### Migration steps

-   If you have error handling that checks for `INVALID` codes from channel mutations when passing time values, update it to handle GraphQL-level type errors instead.
-   **Update your codegen**: If you use a GraphQL code generator (e.g., `graphql-codegen`), add the new `NonNegativeInt` scalar to your scalar type mappings. For example, in your codegen config:
    
    ```yaml
    scalars:
      NonNegativeInt: number
    ```
    
    Re-run your code generator to pick up the new scalar and the updated `Minute`, `Hour`, and `Day` types.

<a id="appinstallinput-now-requires-appname-and-manifesturl"></a>

## `AppInstallInput` Now Requires `appName` and `manifestUrl`

The `AppInstallInput` type now requires both `appName` and `manifestUrl` fields in the schema. If you call app installation mutations, ensure you always provide both `appName` and `manifestUrl`. Requests missing either field will now fail with a schema validation error.

<a id="gift-card-payment-support-via-transaction-api"></a>

## Gift Card Payment Support via Transaction API

Gift cards can now be used as a payment method through the Transaction API using a built-in payment gateway (`saleor.io.gift-card-payment-gateway`). This allows applying gift card balances during checkout via `transactionInitialize`.

If your storefront supports gift cards, you can integrate them as a native payment option without a third-party payment app. See the [Gift Cards as payment method](/developer/gift-cards.md#gift-cards-as-payment-method) guide for setup details including mutation examples and gateway configuration.

<a id="oidc-password-invalidation"></a>

## OIDC Password Invalidation

When an existing user account is claimed by an OIDC provider, the user's password is now automatically invalidated. If your users authenticate via both password and OIDC, be aware that once an OIDC provider claims their account, password login will no longer work. Users must use the OIDC provider going forward, or reset their password if password login is still enabled.

<a id="simplified-stock-availability"></a>

## Simplified Stock Availability

Stock availability is no longer determined by shipping zones. Instead, it is based solely on the direct warehouse-channel link — if a warehouse is assigned to a channel, its stock counts toward product availability in that channel.

The previous behavior, where stock was filtered by shipping zones and the customer's destination address, is now deprecated. It is controlled by the [`useLegacyShippingZoneStockAvailability`](/api-reference/miscellaneous/objects/shop.md#use-legacy-shipping-zone-stock-availability) setting on `Shop` / `SiteSettings`:

-   **Existing instances** have this set to `true` to preserve backwards compatibility.
-   **New instances** default to `false`.

We recommend disabling legacy shipping zone filtering as soon as possible. It will be removed in a future version.

<a id="what-changes"></a>

### What changes

With the new default behavior, shipping zones and the customer's address are no longer used to filter warehouses for stock calculations. This affects:

-   **Stock validation and availability**: `Product.isAvailable`, `ProductVariant.quantityAvailable`, and stock filters (`IN_STOCK`/`OUT_OF_STOCK`) consider all warehouses assigned to the channel.
-   **Stock allocation**: Stocks are allocated from any warehouse in the channel, not just those matching the order's country via shipping zones.
-   **Stock reservation**: Reservations are made from all channel warehouses regardless of country.
-   **Order fulfillment**: Fulfillment lines are created from stock in all channel warehouses.
-   **Webhook payloads**: Checkout and fulfillment webhook payloads resolve the warehouse address from the channel directly.

<a id="migration-steps-2"></a>

### Migration steps

1.  **Remove `address` argument from stock availability queries.** The `address` argument on the following fields is deprecated and ignored:
    
    -   `Product.isAvailable(address:)`
    -   `ProductVariant.quantityAvailable(address:)`
    -   `ProductVariant.stocks(address:)`
2.  **Disable legacy shipping zone filtering.** Once the above steps are complete, switch to the new behavior:
    
    ```graphql
    mutation {
      shopSettingsUpdate(
        input: { useLegacyShippingZoneStockAvailability: false }
      ) {
        shop {
          useLegacyShippingZoneStockAvailability
        }
        errors {
          field
          message
        }
      }
    }
    ```
    
3.  **Subscribe to channel-aware stock webhooks.** With `useLegacyShippingZoneStockAvailability` disabled, four new async events become available:
    
    -   `PRODUCT_VARIANT_OUT_OF_STOCK_IN_CHANNEL`
    -   `PRODUCT_VARIANT_BACK_IN_STOCK_IN_CHANNEL`
    -   `PRODUCT_VARIANT_OUT_OF_STOCK_FOR_CLICK_AND_COLLECT`
    -   `PRODUCT_VARIANT_BACK_IN_STOCK_FOR_CLICK_AND_COLLECT`
    
    Unlike `PRODUCT_VARIANT_OUT_OF_STOCK` / `PRODUCT_VARIANT_BACK_IN_STOCK`, these fire per channel and can be filtered to a subset of channels via the `channels:` argument on the subscription field. Register a webhook for each event and list the channel slugs you want to follow:
    
    ```graphql
    mutation {
      webhookCreate(
        input: {
          name: "Variant out of stock – UK, US"
          targetUrl: "https://example.com/webhooks/out-of-stock"
          asyncEvents: [PRODUCT_VARIANT_OUT_OF_STOCK_IN_CHANNEL]
          query: """
          subscription {
            productVariantOutOfStockInChannel(channels: ["uk", "us"]) {
              productVariant {
                id
                sku
              }
              channel {
                slug
              }
            }
          }
          """
          isActive: true
        }
      ) {
        webhook {
          id
        }
        errors {
          field
          code
          message
        }
      }
    }
    ```
    
    Omit `channels:` to receive events from every channel. Only one filterable subscription field is allowed per webhook, so create a separate webhook for each of the four events.
    

For full details, see [Stock Overview](/developer/stock/overview.md).

<a id="apps-can-edit-staff-user-metadata"></a>

## Apps can edit Staff User metadata

Historically apps were not able to perform any mutation on Staff Users due to security concerns. This still remains true, but metadata and private metadata operations are now considered safe.

Mutations: `deleteMetadata`, `updateMetadata`, `deletePrivateMetadata`, and `updatePrivateMetadata` are now allowed for apps with `MANAGE_STAFF` permission. This unifies permissions with human user.

<a id="apps-reliably-receive-their-own-lifecycle-webhooks"></a>

## Apps Reliably Receive Their Own Lifecycle Webhooks

The `APP_INSTALLED`, `APP_UPDATED`, `APP_STATUS_CHANGED`, and `APP_DELETED` events are now reliably delivered to the app they relate to. Previously, these events were silently dropped if the receiving app did not hold the `MANAGE_APPS` permission, or if the app was already deactivated or soft-deleted at dispatch time. As a result, apps could not observe their own deactivation or uninstallation.

Apps no longer need `MANAGE_APPS` to subscribe to their own lifecycle events. The most useful pattern enabled by this change is letting an app **clean up after itself** when it is uninstalled — for example, removing tenant rows from its own database, revoking external credentials, or cancelling scheduled jobs — by subscribing to `APP_DELETED`.

See the new guide: [Subscribing to App Lifecycle Webhooks](/developer/extending/apps/developing-apps/apps-patterns/app-lifecycle-webhooks.md).

<a id="deprecations"></a>

## Deprecations

Deprecated fields will be removed in next versions. To make future upgrades easier, we recommend to remove deprecated fields as soon as possible.

<a id="deprecation-of-hasvariants-on-producttype"></a>

### Deprecation of `hasVariants` on `ProductType`

The `hasVariants` field on `ProductType` is deprecated. It was a legacy artifact that is no longer necessary. Stop relying on `hasVariants` in your queries. Instead, check whether a product has variants by querying the `variants` field directly.

<a id="deprecation-of-export-mutations"></a>

### Deprecation of Export Mutations

The mutations `exportProducts`, `exportGiftCards`, and `exportVoucherCodes` are now deprecated.

To export these entities, use standard GraphQL queries.

<a id="deprecation-of-voucher-input-on-draft-orders"></a>

### Deprecation of `voucher` Input on Draft Orders

The `voucher` field on `DraftOrderInput` and `DraftOrderCreateInput` is deprecated. Use the `voucherCode` field instead when creating or updating draft orders.
