Skip to main content

05 - Saleor: GraphQL-Native E-commerce, Advanced Checkout API and 160+ Webhooks

Saleor is a Python e-commerce backend with a GraphQL API. See the checkout in four mutations, the webhook time limits and how to verify their signature.

What do you think about this article?

At a Glance

Saleor is a Python e-commerce backend with a GraphQL API. See the checkout in four mutations, the webhook time limits and how to verify their signature.

Headless Commerce 9 min

Saleor is an open-source e-commerce platform in Python with a GraphQL API. Here you see how catalogue and checkout work, what you need to know about webhooks (time limits and signature) and where the guide you find around risks being out of date.

saleor graphql commerce checkout API webhook headless ecommerce python

Saleor in brief: a single GraphQL API, and a checkout in place of the cart

Saleor is written in Python and Django and is used almost entirely through a GraphQL API: catalogue, orders, customers, shipping and configuration all go through it. If you come from Medusa, the most obvious difference is not the language but the style: a single entrance where you describe the fields you need, instead of many REST routes (for the REST contract and API-first principles see the article on MACH architecture). If instead you come from Shopify, there is another difference in the model to know: according to the checkout documentation, Saleor has no separate “cart” object. The checkout is the cart: it is born with the first lines, accumulates addresses and delivery method, and in the end turns into an order.

A security consequence that the same page makes clear: a checkout is identified by a UUID-based ID, and anyone who knows that ID can read and modify it. Cart data, email and addresses are therefore readable by whoever has the ID; user data, transactions and private metadata instead require the owner or staff or app permissions. Treat a checkout ID as a session secret: do not put it in a shared address, do not record it in third-party logs.

The catalogue: a query, channels, and a filter that is changing

Reading the catalogue uses the Relay connection schema: edges with node, and pageInfo to restart from the cursor, as in the pagination loop seen in the introductory article. The channel parameter deserves attention: in Saleor a channel groups currency, available products, shipping and payment methods, and prices change from channel to channel. That is why sorting by price, in the schema, declares that it requires a channel to work: without one, the price is not defined.

query Catalogo($channel: String!, $first: Int!, $after: String) {
  products(channel: $channel, first: $first, after: $after, sortBy: { field: PRICE, direction: ASC }) {
    totalCount
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        name
        slug
        thumbnail(size: 512) { url alt }
        pricing { onSale priceRange { start { gross { amount currency } } } }
        category { name slug }
      }
    }
  }
}

I checked this query against the schema published in the Saleor repository (main branch, downloaded on 2026-11-12) with a small script that walks every field and verifies that it exists on the right type: 23 fields checked, none missing. It is not a full validation (arguments and variable types are not checked) and the query was not sent to a Saleor instance. A detail that the schema shows and that older guides do not mention: the filter argument of products is marked as deprecated in favour of where. That is why the query above does not filter and only sorts: if you need to filter, start from where.

The checkout in four mutations

The minimal flow has four steps, and the order is not arbitrary: the address comes before the delivery method because the available methods depend on where you ship, and the method comes before completion because it changes the total. The mutations are checkoutCreate (with the lines and the channel), checkoutShippingAddressUpdate, checkoutDeliveryMethodUpdate and checkoutComplete. Each returns an errors field with field, code and message: domain errors (invalid address, product sold out) arrive there, not as HTTP errors, and if you do not read them the flow looks successful.

# 1. The checkout is created with its lines: Saleor has no separate "cart" object
mutation CheckoutCreate($input: CheckoutCreateInput!) {
  checkoutCreate(input: $input) {
    checkout { id totalPrice { gross { amount currency } } lines { id quantity } }
    errors { field code message }
  }
}

# 2. The shipping address: afterwards, the checkout lists the possible delivery methods
mutation ShippingAddress($id: ID!, $address: AddressInput!) {
  checkoutShippingAddressUpdate(id: $id, shippingAddress: $address) {
    checkout { id shippingMethods { id name price { amount currency } } }
    errors { field code message }
  }
}

# 3. Choosing the delivery method recalculates the total
mutation DeliveryMethod($id: ID!, $deliveryMethodId: ID!) {
  checkoutDeliveryMethodUpdate(id: $id, deliveryMethodId: $deliveryMethodId) {
    checkout { id totalPrice { gross { amount currency } } }
    errors { field code message }
  }
}

# 4. Completion turns the checkout into an order
mutation Complete($id: ID!) {
  checkoutComplete(id: $id) {
    order { id number status }
    confirmationNeeded
    confirmationData
    errors { field code message }
  }
}

These four operations too were verified against the schema (49 fields, none missing), not run against a store. The comparison with the schema has a concrete effect compared with many guides: today the mutations identify the checkout with the id argument, while checkoutId and token are marked as deprecated. A tutorial that still uses checkoutId works until the field is removed, and then stops abruptly. On completion the documentation lists the conditions: valid addresses, available stock and processed payments (unless the channel allows unpaid orders or the total is zero). How to build the payment with Stripe is in the article on headless payments. Checkouts that are not completed are deleted after a time that depends on their content: six hours if without lines, thirty days if anonymous with lines, ninety if tied to a user or an email.

Webhooks: two ways of receiving, a time limit, a signature to verify

Webhooks are the way Saleor notifies another system of what happens (an order created, a payment received) or asks for help during an operation (calculating taxes, listing shipping methods). The documentation distinguishes two categories: asynchronous events, sent when the request that caused them has finished; synchronous events, sent immediately during the GraphQL request, so the response time of your endpoint adds to that of the customer’s request. If your service takes four seconds, the customer waits four seconds more at the checkout step. How much the timings weigh on the metrics the customer perceives you can see in the article on Core Web Vitals and SEO.

The limit written in the documentation consulted on 2026-11-12 is 20 seconds for the whole exchange, of which at most 2 for the connection and 18 for the response, and the same page warns that several synchronous calls in the same operation can add up and make Saleor’s API time out. Translated into a design rule: an endpoint that answers synchronous webhooks must be fast and separate from the heavy ones, and for asynchronous events it is better to put the messages in a queue and work through them calmly.

The signature is used to know that the request really comes from Saleor. The Saleor-Signature header, according to the signature page, contains a JWS signature with the RS256 algorithm and a detached payload if you have not set a secret key on the webhook; the HMAC SHA-256 signature is indicated as deprecated. With RS256 you verify with a public key that Saleor publishes in a JWKS, and you do not have to keep any shared secret. The following listing simulates both sides: it generates a key pair, signs a body as Saleor would, and verifies it as you should, building the text to verify from the header and the raw body re-encoded in base64url.

import { createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';

const b64url = (b: Buffer | string) => Buffer.from(b).toString('base64url');

// --- Saleor side (simulated): RS256 JWS signature with a "detached" payload ---
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
function firma(corpo: string): string {
  const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
  const firmaBytes = sign('RSA-SHA256', Buffer.from(`${header}.${b64url(corpo)}`), privateKey);
  return `${header}..${b64url(firmaBytes)}`; // the payload in the middle is empty: it is detached
}

// --- Your side: verify with the public key (in production it comes from the JWKS) ---
const jwk = publicKey.export({ format: 'jwk' });
function verifica(jws: string, corpoGrezzo: string): boolean {
  const [header, , firmaB64] = jws.split('.');
  return verify(
    'RSA-SHA256',
    Buffer.from(`${header}.${b64url(corpoGrezzo)}`),
    createPublicKey({ key: jwk, format: 'jwk' }),
    Buffer.from(firmaB64, 'base64url'),
  );
}

const corpo = JSON.stringify({ event: 'ORDER_CREATED', data: { order: { number: 42 } } });
const jws = firma(corpo);
console.log('intact body   :', verifica(jws, corpo));
console.log('altered body  :', verifica(jws, corpo.replace('42', '43')));
console.log('rewritten body:', verifica(jws, JSON.stringify(JSON.parse(corpo), null, 2)));

// Output:
// intact body   : true
// altered body  : false
// rewritten body: false

I ran the file with Node 24 and the output is the one shown. The third case is the one that costs hours: the “rewritten” body has the same JSON content but different whitespace, and the verification fails. The documentation says it plainly (you must supply the raw string of the body, not an already parsed object), and that is why a framework that reads and reinterprets the body before it reaches your code breaks the verification. A limitation of the listing: the public key here comes from the same pair generated locally; in production you take it from the JWKS address of the Saleor instance, and the jose library cited by the documentation does the retrieval and the verification for you.

When Saleor, and when not

Saleor makes sense if your team prefers Python or GraphQL, if you need a model with several sales channels already in the core (stores per country, B2B and B2C on the same installation), and if you are willing to manage the infrastructure: database, queues for asynchronous tasks, updates. It makes no sense if you want a managed service without a sysadmin (for that see the article on Shopify) or if all your code is TypeScript and you do not want a second language ( Medusa ). The real cost, in both open-source cases, is not the licence: it is the time of whoever keeps the system standing.

What you can do now

You can explain why in Saleor the checkout acts as the cart and what it implies for the security of the ID, read the catalogue per channel with pagination, drive the checkout in four mutations while checking errors, and receive a webhook respecting the twenty-second limit and verifying the signature on the raw body. In the next article we build the storefront with Next.js that consumes one of these APIs.

Sources

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects