Skip to main content

Upgrading From 3.20 To 3.21

info

To follow the zero-downtime strategy when upgrading to 3.21, It is recommended to first migrate to latest 3.20.X and turn on the Celery worker to process all data migrations asynchronously. Otherwise, you will need to downtime your solution to ensure correct data migration.

Authentication required for changing Order and OrderLine metadata​

The updateMetadata mutation for Order and OrderLine now requires the MANAGE_ORDERS permission. Once an order is created, customers cannot edit its metadata. However, merchants and apps with the necessary permissions can modify this data. Fetching metadata remains unchanged.

Merging metadata in transactionUpdate mutation​

The transactionUpdate mutation now merges the provided metadata and private metadata with existing values, instead of overwriting them. To delete a specific metadata entry, use the deleteMetadata mutation. For private metadata, use the deletePrivateMetadata mutation.

checkout.shippingMethods and checkout.availableShippingMethods do not return external shipping methods if their currency differs from the checkout's currency​

If an app subscribed to SHIPPING_LIST_METHODS_FOR_CHECKOUT returns an external shipping method with a currency different from the checkout's currency, Saleor will filter out that shipping method. As a result, external shipping methods with a different currency than the checkout's currency will not be returned via the API (in the checkout.shippingMethods and checkout.availableShippingMethods fields). Any potential currency validation for external shipping methods on the frontend / app side is no longer necessary.

Blocked external calls for checkout and order bulk queries​

To enhance performance and efficiency, bulk queries that fetch multiple checkout or order objects will no longer trigger external calls to third-party services. The following synchronous webhooks will be affected:

  • CHECKOUT_CALCULATE_TAXES webhooks
  • ORDER_CALCULATE_TAXES webhooks
  • SHIPPING_LIST_METHODS_FOR_CHECKOUT webhooks
  • CHECKOUT_FILTER_SHIPPING_METHODS webhooks
  • ORDER_FILTER_SHIPPING_METHODS webhooks

External calls from the AvataxPlugin will also be skipped.

Instead of recalculating taxes externally, the system will return pre-stored prices from the database. Additionally, only built-in shipping methods will be returned. The CHECKOUT_FILTER_SHIPPING_METHODS and ORDER_FILTER_SHIPPING_METHODS webhooks will not be triggered, and all built-in shipping methods will be listed as available.

Any external shipping methods assigned to the checkout will still be visible through denormalized data stored in the database.

Affected queries​

The following queries will not trigger external calls:

  • checkouts(...)
  • me.checkouts(...)
  • checkoutLines(...)
  • orders(...)
  • me.orders(...)
  • draftOrders(...)

Example of impacted queries​

{
checkouts(first: 10) {
edges {
node {
id
deliveryMethod {
... on ShippingMethod {
id
name
}
}
shippingMethods {
id
name
}
totalPrice {
gross {
amount
}
}
}
}
}
}
Expand β–Ό
{
orders(first: 100) {
edges {
node {
deliveryMethod {
... on ShippingMethod {
id
name
}
}
shippingMethods {
id
name
}
total {
gross {
amount
}
}
}
}
}
}
Expand β–Ό
note

The checkout and order queries will still work as before. If tax information is outdated, external calls will be made to recalculate taxes. External shipping methods and filters will be fetched if necessary.

checkout.privateMetadata no longer stores external-shipping-method-id​

The external-shipping-method-id is no longer stored within checkout.privateMetadata. To retrieve this ID, use the checkout.deliveryMethod field instead.

checkoutDeliveryMethodUpdate and checkoutShippingMethodUpdate mutations do not trigger CHECKOUT_UPDATED webhook or tax invalidation when adding the same method already assigned​

Previously, the checkoutDeliveryMethodUpdate and checkoutShippingMethodUpdate mutations would trigger the CHECKOUT_UPDATED webhook and invalidate taxes regardless of whether the same delivery method was already assigned to the checkout. Starting from version 3.21, if the same method that is already assigned to the checkout is provided, the CHECKOUT_UPDATED webhook will not be triggered, and taxes will not be invalidated. From Saleor's perspective, nothing has changed, as the checkout remains in the same state as it was before the mutation was called.

Introduced price freeze period for editable orders​

In previous versions, all editable orders (those with DRAFT and UNCONFIRMED statuses) refreshed their base prices by fetching them from the current product catalog during each recalculation. Additionally, all discounts were applied based on the latest reward values.

To ensure price consistency over time, regardless of updates to the product catalog, Saleor has introduced a price freeze period. During this period, order lines use denormalized base prices instead of retrieving them from the catalog. More details can be found here

  • Draft orders: the base prices are frozen for 24 hours by default. This duration can be modified using the Channel.draftOrderLinePriceFreezePeriod setting.
  • Unconfirmed orders: the base prices remain frozen indefinitely, and this setting is not configurable.
  • Other orders: remain unchanged and cannot be recalculated.

DraftOrderUpdate mutation no longer calls DRAFT_ORDER_UPDATED webhook if nothing has changed​

The DraftOrderUpdate mutation no longer triggers the DRAFT_ORDER_UPDATED webhook event if there are no changes to the draft order. This adjustment ensures that the event is only fired when there are actual updates to the draft order, reducing unnecessary event emissions.

OrderUpdate mutation no longer calls ORDER_UPDATED webhook if nothing has changed​

The OrderUpdate mutation no longer triggers the ORDER_UPDATED webhook event if there are no changes to the draft order. This adjustment ensures that the event is only fired when there are actual updates to the order, reducing unnecessary event emissions.

Required transactionId field in OrderGrantRefundCreateInput​

The OrderGrantRefundCreateInput now requires a transactionId field. This change affects the OrderGrantRefundCreate mutation. The transactionId must be provided as part of the input; otherwise, the mutation will return an error.

Plugin changes​

Removed plugins​

The invoicing plugin was removed. There is no direct replacement available. See https://github.com/saleor/example-app-invoices for an example of a replacement app.

The "Stripe (Deprecated)" (mirumee.payments.stripe) plugin was removed. Use the "Stripe" (saleor.payments.stripe) plugin instead or see https://github.com/saleor/saleor-app-payment-stripe for an example of a replacement app.

Deprecated manager.perform_mutation method​

Note: Affects open-source Saleor users with custom plugins overriding the perform_mutation plugin method.

As part of gradual plugin deprecation, Saleor 3.20 deprecates the perform_mutation plugin hook. This method allowed to customize the mutation execution flow and call arbitrary logic during mutation execution. From now on, we recommend building such customizations via apps by reacting to webhooks triggered by mutations. The perform_mutation plugin method will be removed in Saleor 3.21.

Note: Affects open-source Saleor users with custom plugins that override webhook-related plugin hooks (see the list below).

The WebhookPlugin is deprecated and will be removed in a future release, as all webhook-related functionality will be moved from plugin to core modules. Rationale behind this change is that webhooks are essential part of Saleor and should be available out of the box, without the need for additional plugins. As a result, multiple plugin manager hooks will be deprecated, as they are only used by the WebhookPlugin.

If you have custom plugins that override any of the following plugin hooks, you should migrate your custom logic to Saleor App that listens to webhooks.

The list of deprecated plugin hooks is as follows:

  • account_change_email_requested
  • account_confirmation_requested
  • account_confirmed
  • account_delete_requested
  • account_deleted
  • account_email_changed
  • account_set_password_requested
  • address_created
  • address_deleted
  • address_updated
  • app_deleted
  • app_installed
  • app_status_changed
  • app_updated
  • attribute_created
  • attribute_deleted
  • attribute_updated
  • attribute_value_created
  • attribute_value_deleted
  • attribute_value_updated
  • category_created
  • category_deleted
  • category_updated
  • channel_created
  • channel_deleted
  • channel_metadata_updated
  • channel_status_changed
  • channel_updated
  • checkout_created
  • checkout_fully_paid
  • checkout_metadata_updated
  • checkout_updated
  • collection_created
  • collection_deleted
  • collection_metadata_updated
  • collection_updated
  • customer_created
  • customer_deleted
  • customer_metadata_updated
  • customer_updated
  • draft_order_created
  • draft_order_deleted
  • draft_order_updated
  • event_delivery_retry
  • fulfillment_approved
  • fulfillment_canceled
  • fulfillment_created
  • fulfillment_metadata_updated
  • get_shipping_methods_for_checkout
  • get_taxes_for_checkout
  • get_taxes_for_order
  • gift_card_created
  • gift_card_deleted
  • gift_card_export_completed
  • gift_card_metadata_updated
  • gift_card_status_changed
  • gift_card_updated
  • invoice_delete
  • invoice_sent
  • list_stored_payment_methods
  • menu_created
  • menu_deleted
  • menu_item_created
  • menu_item_deleted
  • menu_item_updated
  • menu_updated
  • order_bulk_created
  • order_cancelled
  • order_created
  • order_expired
  • order_fulfilled
  • order_fully_paid
  • order_fully_refunded
  • order_metadata_updated
  • order_paid
  • order_refunded
  • order_updated
  • page_created
  • page_deleted
  • page_type_created
  • page_type_deleted
  • page_type_updated
  • page_updated
  • payment_gateway_initialize_session
  • payment_gateway_initialize_tokenization
  • payment_method_initialize_tokenization
  • payment_method_process_tokenization
  • permission_group_created
  • permission_group_deleted
  • permission_group_updated
  • product_created
  • product_deleted
  • product_export_completed
  • product_media_created
  • product_media_deleted
  • product_media_updated
  • product_metadata_updated
  • product_updated
  • product_variant_back_in_stock
  • product_variant_created
  • product_variant_deleted
  • product_variant_metadata_updated
  • product_variant_out_of_stock
  • product_variant_stock_updated
  • product_variant_updated
  • promotion_created
  • promotion_deleted
  • promotion_ended
  • promotion_rule_created
  • promotion_rule_deleted
  • promotion_rule_updated
  • promotion_started
  • promotion_updated
  • sale_created
  • sale_deleted
  • sale_updated
  • shipping_price_created
  • shipping_price_deleted
  • shipping_price_updated
  • shipping_zone_created
  • shipping_zone_deleted
  • shipping_zone_metadata_updated
  • shipping_zone_updated
  • shop_metadata_updated
  • staff_created
  • staff_deleted
  • staff_set_password_requested
  • staff_updated
  • stored_payment_method_request_delete
  • thumbnail_created
  • transaction_cancelation_requested
  • transaction_charge_requested
  • transaction_initialize_session
  • transaction_item_metadata_updated
  • transaction_process_session
  • transaction_refund_requested
  • voucher_code_export_completed
  • voucher_codes_created
  • voucher_codes_deleted
  • voucher_created
  • voucher_deleted
  • voucher_metadata_updated
  • voucher_updated
  • warehouse_created
  • warehouse_deleted
  • warehouse_metadata_updated
  • warehouse_updated

change_user_address method removed from Plugin Manager​

Note: Affects open-source Saleor users with custom plugins overriding the change_user_address plugin method.

The change_user_address method has been removed from the Plugin Manager and is no longer available.

Changed order status behavior for WAITING_FOR_APPROVAL fulfillments​

In previous versions, orders that had any unapproved fulfillments and no returns, were given status PARTIALLY_FULFILLED. This happened even when no items were actually fulfilled. Currently, fulfillments with WAITING_FOR_APPROVAL status are excluded from determination of order status. Adding unapproved fulfillment to order will no longer result in a status change. Order can only receive PARTIALLY_FULFILLED status if there exists FULFILLED fulfillment and there are some items that are either unfulfilled or awaiting approval.

Changes to discount propagation in orders created from checkout​

In Saleor 3.21, discount propagation for orders created from checkout has been standardized to match the behavior of draft orders, ensuring consistent handling of vouchers and promotions across all order types. Below, you’ll find details on how discount propagation now behaves for different types of discounts.

Voucher handling before version 3.21​

In versions prior to 3.21, how vouchers were applied and represented depended on how the order was created:

Order sourceSPECIFIC_PRODUCT voucherENTIRE_ORDER with apply-once-per-order voucher
CheckoutRepresented as OrderDiscount in order.discountsRepresented as OrderDiscount in order.discounts
Draft orderNot included in order.discountsNot included in order.discounts

Additionally, percentage-based vouchers applied during checkout were automatically converted to fixed-value discounts, while draft orders retained the original percentage type:

Order sourceSPECIFIC_PRODUCT (percentage)ENTIRE_ORDER with apply-once-per-order (percentage)
CheckoutConverted to FIXEDConverted to FIXED
Draft orderRetained as PERCENTAGERetained as PERCENTAGE

Changes introduced in version 3.21​

Starting in version 3.21, voucher propagation is consistent across all order types:

  • All orders created via checkout now use OrderLineDiscount objects for both SPECIFIC_PRODUCT and ENTIRE_ORDER (with apply-once-per-order) vouchers β€” same as draft orders.
  • Percentage-based vouchers are no longer converted to fixed-value discounts. The original PERCENTAGE type is preserved and correctly represented in the API.

Changes to unitDiscountReason and unitDiscount.amount for orders created from checkout​

Previously, for orders created via checkout using a Voucher:ENTIRE_ORDER, the discount was represented in two ways:

  • As an OrderDiscount object in order.discounts
  • As a proportional amount in each line’s unitDiscount.amount, along with a corresponding unitDiscountReason

Before version 3.21​

Order sourceunitDiscountReasonunitDiscount.amount
CheckoutIncluded reason for ENTIRE_ORDER discountIncluded line-level portion of the ENTIRE_ORDER discount
Draft orderReason for ENTIRE_ORDER discount not includedLine-level portion of the ENTIRE_ORDER discount not included

This also applied to discounts from active OrderPromotions:

Order sourceunitDiscountReasonunitDiscount.amount
CheckoutIncluded reason for promotion discountIncluded line-level portion of promotion discount
Draft orderReason for promotion discount not includedLine-level portion of promotion discount not included

After version 3.21​

As of version 3.21, unitDiscountReason and unitDiscount.amount no longer include information related to:

  • Voucher:ENTIRE_ORDER discounts
  • OrderPromotion discounts

This behavior is now consistent with how draft orders work.

Backward compatibility: useLegacyLineDiscountPropagation feature flag​

To maintain compatibility with existing integrations, version 3.21 introduces a feature flag: useLegacyLineDiscountPropagation, which allows you to configure the discount propagation behavior per channel.

This flag controls how voucher and promotion discounts appear in the API for orders created via checkout.

How discounts are represented based on the flag​

Checkout order + voucher3.203.21 + Legacy propagation enabled3.21 + Legacy propagation disabled
SPECIFIC_PRODUCTOrderDiscount (in order.discounts)OrderDiscount (in order.discounts)OrderLineDiscount (in OrderLine.discounts) only
ENTIRE_ORDER + apply-once-per-orderOrderDiscount (in order.discounts)OrderDiscount (in order.discounts)OrderLineDiscount (in OrderLine.discounts) only

Discount type (FIXED vs PERCENTAGE) based on the flag​

Checkout order + percentage voucher3.203.21 + Legacy propagation enabled3.21 + Legacy propagation disabled
SPECIFIC_PRODUCT (percentage)Converted to FIXEDConverted to FIXEDRetained as PERCENTAGE
ENTIRE_ORDER (percentage)Converted to FIXEDConverted to FIXEDRetained as PERCENTAGE

unitDiscountReason and unitDiscount.amount based on the flag​

Checkout order + voucher type3.203.21 + Legacy propagation enabled3.21 + Legacy propagation disabled
Voucher:ENTIRE_ORDERIncluded in unitDiscountReason & unitDiscount.amountIncluded in unitDiscountReason & unitDiscount.amountNot included in unitDiscountReason & unitDiscount.amount
OrderPromotionIncluded in unitDiscountReason & unitDiscount.amountIncluded in unitDiscountReason & unitDiscount.amountNot included in unitDiscountReason & unitDiscount.amount

Default behavior​

  • Existing channels: useLegacyLineDiscountPropagation is enabled by default
  • New channels: the flag is disabled by default, unless explicitly set otherwise
note

Changing the useLegacyLineDiscountPropagation flag only affects orders created after upgrading to version 3.21.
It does not retroactively change:

  • Discount types for vouchers already converted from PERCENTAGE to FIXED
  • unitDiscountReason and unitDiscount.amount fields on existing orders

Creating new channel with legacy flow enabled​

If you need to create a new channel with the legacy voucher propagation flow active, you must explicitly set useLegacyLineDiscountPropagation in the channelCreate mutation input:

mutation {
channelCreate(
input: {
name: "New channel"
slug: "new-channel"
currencyCode: USD
defaultCountry: PL
orderSettings: {
useLegacyLineDiscountPropagation: true
}
}
) {
channel {
id
}
errors {
field
message
}
}
}
Expand β–Ό

By default, new channels have legacy propagation disabled unless this field is explicitly set to true.

Example​

The examples below show how order line discounts are represented in the GraphQL response, depending on the useLegacyLineDiscountPropagation configuration.

With useLegacyLineDiscountPropagation enabled:​
{
"data": {
"order": {
"id": "T3JkZXI6NzJiM2NhNjktNzJmYS00ZjQ5LWE3ZGMtOTgxZGI4MDIwNmJj",
"discounts": [
{
"id": "T3JkZXJEaXNjb3VudDo1NjhhMGE5OS1hY2Y1LTQ1NzctYjE2YS0zNjFlMjU3MTAyYmE=",
"name": "My voucher",
"type": "VOUCHER",
"valueType": "FIXED",
"value": 11,
"total": {
"amount": 11,
"currency": "USD"
},
"amount": {
"amount": 11,
"currency": "USD"
}
}
],
"lines": [
{
"unitDiscount": { "amount": 3.67 },
"discounts": []
},
{
"unitDiscount": { "amount": 0 },
"discounts": []
}
]
}
}
}
Expand β–Ό
With useLegacyLineDiscountPropagation disabled:​
{
"data": {
"order": {
"id": "T3JkZXI6NzJiM2NhNjktNzJmYS00ZjQ5LWE3ZGMtOTgxZGI4MDIwNmJj",
"discounts": [],
"lines": [
{
"unitDiscount": { "amount": 3.67 },
"discounts": [
{
"name": "My voucher",
"type": "VOUCHER",
"valueType": "FIXED",
"value": 11,
"unit": { "amount": 3.67 },
"total": { "amount": 11 }
}
]
},
{
"unitDiscount": { "amount": 0 },
"discounts": []
}
]
}
}
}
Expand β–Ό

How to migrate to the new flow​

To fully support the updated propagation model:

  • Extend webhook subscriptions to include: order.lines[].discounts[]
  • Update all queries to retrieve OrderLineDiscount data from order.lines[].discounts[]
  • Adjust internal logic to rely on line-level discount data
  • Disable legacy propagation using the following mutation:
mutation {
channelUpdate(
id: "<channel-id>"
input: { orderSettings: { useLegacyLineDiscountPropagation: false } }
) {
channel {
orderSettings {
useLegacyLineDiscountPropagation
}
}
}
}

Google Cloud Platform (GCP): Configuration Changes​

The environment variable GS_MEDIA_BUCKET_NAME no longer applies for files that may contain sensitive information. The following steps should be taken:

  1. Keep the original configuration for GS_MEDIA_BUCKET_NAME - it will still be used for public media files, such as product images.
  2. Set a new environment variable GS_MEDIA_PRIVATE_BUCKET_NAME - it should point to a private bucket that's not exposed to the Internet.

For more information, see Storing Files on Google Cloud Storage (GCS).