Embedded checkout

Embedded checkout lets sellers offer checkout on their own website. The seller backend creates a short-lived checkout session with a secret API key. The browser receives only the session token and can only perform the checkout actions allowed by that session.

Use embedded checkout when moving from legacy PlanVariant and Subscription forms to the V2 product, price, bundle and contract model.

Overview

A normal integration has four parts:

  1. Configure V2 catalog products, prices or bundle templates.

  2. Create a sales channel that controls which offers, origins and payment methods are available.

  3. Create a checkout session from the seller backend.

  4. Mount the askell.js version for embedded checkout with the returned session token.

The secret API key must stay on the seller backend.

Create a session

POST /api/v2/checkout-sessions/
Authorization: Api-Key your-secret-api-key
Content-Type: application/json
{
  "sales_channel": "memberships",
  "customer_reference": "seller-user-123",
  "contract_reference": "order-1042",
  "terms_url": "https://seller.example/terms",
  "terms_required": true,
  "metadata": {
    "seller_cart_id": "cart-456"
  },
  "expires_in_seconds": 1800
}

Response:

{
  "token": "cs_abc123",
  "status": "pending",
  "expires_at": "2026-06-16T12:30:00Z",
  "sales_channel": "memberships",
  "allowed_origins": ["https://seller.example"],
  "theme": {
    "primary_color": "#0F766E"
  },
  "contract_reference": "order-1042"
}

The token is safe to send to the browser. It does not replace the seller API key and cannot be used to call normal private V2 APIs.

The session’s metadata is returned by GET /api/v2/checkout-sessions/{token}/ and copied to the contract when the checkout is finalized, see Metadata on the contract.

With contract_reference the backend can give the contract the session creates its own reference, such as an order number, see Contract reference.

If terms are configured on the sales channel, you do not need to send terms_url or terms_required when creating a session. These fields are only used when a single session should use different terms than the sales channel.

Mount the widget

<div id="askell-checkout"></div>
<script src="https://cdn.askell.is/js/dist/askell.js"></script>
<script>
  Askell.mountCheckout("#askell-checkout", {
    sessionToken: "SESSION_TOKEN_FROM_SELLER_BACKEND",
    title: "Veldu áskrift",
    description: "Veldu áskriftarleið og viðbætur áður en gengið er frá greiðslu.",
    language: "is",
    colorScheme: "auto",
    onSuccess(result) {
      window.location.href = "/account?checkout=success";
    },
    onError(error) {
      console.error(error);
    }
  });
</script>

askell.js automatically loads its companion askell.css stylesheet from the same directory as the script itself. If the script is self-hosted, serve askell.css from the same location.

Use title to customize the widget heading. If title is omitted, the widget uses its default heading.

Use description to show short help text below the heading on the first checkout step. If description is omitted, no extra text is shown.

Use language to select the widget text language. Supported values are "is" and "en". If language is omitted, the widget tries to use the page lang attribute, otherwise it uses English.

Use colorScheme to select a light or dark widget appearance. Supported values are "light", "dark" and "auto". "auto" follows the browser prefers-color-scheme setting. The default value is "light". You can also use color_scheme if the seller backend uses snake_case.

Fixed and pre-selected offers

By default the widget shows all of the sales channel’s offers on the first checkout step and selects the first price available. The seller can instead fix the offer or pre-select it.

With offer the offer is fixed. The widget then skips the first step, where the buyer chooses an offer, and the buyer cannot change the offer:

Askell.mountCheckout("#askell-checkout", {
  sessionToken: "SESSION_TOKEN_FROM_SELLER_BACKEND",
  offer: {
    items: [{ price: 123, quantity: 1 }]
  }
});

With defaultOffer (or default_offer) the offer is pre-selected but the first step is still shown, so the buyer can change the selection before continuing. This suits, for example, a buyer who arrives from the page of a particular product when the sales channel offers more products:

Askell.mountCheckout("#askell-checkout", {
  sessionToken: "SESSION_TOKEN_FROM_SELLER_BACKEND",
  defaultOffer: {
    items: [{ price: 123, quantity: 2 }],
    promotion_code: "HAUST25"
  }
});

defaultOffer uses the fields items, initial_items, bundle_template, bundle_quantity, bundle_item_selections and promotion_code. Other fields, for example additional_items and additional_bundles, are ignored, and add-ons start out unselected as they otherwise would. Quantities are kept within the minimum and maximum of the price or bundle template. If both offer and defaultOffer are given, offer applies and defaultOffer is ignored.

The ids in defaultOffer must be in the session’s offer_catalog. A price or bundle template that is not offered, or is sold out, is ignored and the widget logs a warning with console.warn. If nothing valid is left, the widget uses its normal default selection.

In bundle_item_selections only the price option of the bundle’s required items (required: true) can be pre-selected, as that is all the buyer can choose in the widget. Entries for optional items (required: false), entries with include: false and per-item quantity values are ignored with the same warning; use a fixed offer to sell a bundle with such items or a changed quantity. A price option the item does not offer is ignored too, and the item keeps its default price. One-time items (initial_items) cannot be pre-selected together with a bundle and are ignored with the same warning; use a fixed offer to sell a bundle with one-time items.

A promotion_code in offer or defaultOffer is applied from the start, as if the buyer had typed it in, and the buyer can remove it on the review step. If the code is rejected it is removed and the reason is shown next to the field. For offer the error is also passed to onError. For defaultOffer the widget logs a warning with console.warn instead, because the buyer can change the selection and so be the one who made the code stop applying.

Browser API

The browser SDK uses session-token endpoints:

GET  /api/v2/checkout-sessions/{token}/public/
POST /api/v2/checkout-sessions/{token}/quote/
POST /api/v2/checkout-sessions/{token}/payment-processor-options/
POST /api/v2/checkout-sessions/{token}/checkout/
POST /api/v2/checkout-sessions/{token}/payment-method-registrations/
GET  /api/v2/checkout-sessions/{token}/payment-method-registrations/{registration_token}/
POST /api/v2/checkout-sessions/{token}/finalize/

The token is passed in the URL path for these browser endpoints. The browser cannot broaden the session policy, change customer binding, set arbitrary amounts or submit unapproved metadata.

POST /api/v2/checkout-sessions/{token}/checkout/ keeps one checkout per session. Repeating it with the same body, for example after a double click or a retry after a network error, returns the same checkout with 200. If the buyer changed their selection and sent a different body, the earlier checkout is canceled and a new one is created with 201, so finalize always charges what was sent last. An invalid new body is rejected with 400 and the earlier checkout stays as it was. The earlier checkout’s card registration does not carry over: finalize refuses its payment_method_registration_token, so create a new card registration once the checkout has been replaced. A checkout whose payment has started is never replaced; a request with a different body then gets 409.

When a payment is declined, the checkout gets the status failed but keeps the contract and the billing run that finalize created. The buyer can then pay for the same checkout with another card: create a new card registration on the session and call finalize again with the new registration’s payment_method_registration_token. The same billing run is then retried, so the purchase is never charged twice. finalize refuses a card registration made before the last payment attempt, the declined one included, so every attempt needs a new card registration. Only the newest card registration is accepted, since a newer registration replaces the older card. checkout/ with the same body returns the declined checkout with 200, while a different body gets 409: the offer cannot be changed after a decline. If a product has sold out since the decline, the new attempt is refused before any card is charged. When it is uncertain whether an earlier payment for the checkout went through, for example when the payment processor did not respond in time, the payment cannot be retried from the browser.

The widget only skips card registration when nothing is due now and the offer has no recurring items, for example a free one-time purchase. An offer with recurring items always registers a card before the checkout is finalized, even when the initial total is zero because of a trial period or a fully discounted first period, because the card is charged when the first paid period starts. The finalize endpoint enforces the same rule and rejects the request with a payment_method error when the customer has no verified payment method with the selected payment processor.

Cart

mountCheckout suits a page that sells one offer. When the products are on many pages of the site, the buyer can instead collect them in a cart and buy them all in one checkout:

  • mountProduct sets up a product picker on a page. The buyer chooses products, prices, bundles and add-ons as on the first step of the checkout and clicks “Add to cart”.

  • mountCart sets up a cart button, for example in the site’s header, that shows how many things are in the cart. The button opens a side panel with the cart’s contents and total, where the checkout for the whole cart takes place.

One session per visitor

Call Askell.init once on every page and mount the widgets on the instance it returns. The seller’s backend creates one checkout session per visitor and sends the same session token to every page until the session has been used. getSessionToken fetches a new session token from the backend:

<header>
  <div id="askell-cart"></div>
</header>
<div id="askell-product"></div>
<script src="https://cdn.askell.is/js/dist/askell.js"></script>
<script>
  const askell = Askell.init({
    sessionToken: "SESSION_TOKEN_FROM_SELLER_BACKEND",
    async getSessionToken() {
      const response = await fetch("/askell/cart-session/", {
        method: "POST",
        headers: { "X-CSRFToken": "CSRF_TOKEN" },
      });
      return (await response.json()).sessionToken;
    },
    language: "is",
  });
  askell.mountCart("#askell-cart", {
    onSuccess(result) {
      window.location.href = "/account?checkout=success";
    },
  });
  askell.mountProduct("#askell-product", { products: [12] });
</script>

The widgets call getSessionToken when the session has expired, been completed or is not found (the browser endpoints then answer with 404), and when the cart’s checkout is completed, as the session has then been used. If sessionToken is left out, the first request fetches the session token with getSessionToken. Without getSessionToken the buyer has to reload the page when the session has expired or been used.

A checkout that has started never moves to a new session, as the customer and the checkout it has created belong to the session. If the session expires in the middle of a checkout, the cart shows “Checkout expired” and a “Start again” button, which fetches a new session with getSessionToken. Without getSessionToken the cart instead asks the buyer to reload the page and offers a “Reload page” button. The cart keeps its contents.

The endpoint getSessionToken calls creates a new session, stores the session token for the visitor and returns it. With django-askell:

from django.http import JsonResponse
from django.views.decorators.http import require_POST
from askell.client import client


def create_cart_session(request):
    result = client.create_checkout_session(
        sales_channel="webshop",
        user=request.user,
        expires_in_seconds=24 * 60 * 60,
    )
    if result["status"] != "success":
        raise RuntimeError(result["response"])
    token = result["response"]["token"]
    request.session["askell_session_token"] = token
    return token


def cart_session_token(request):
    # Sama lota á hverri síðu þar til hún hefur verið notuð.
    return request.session.get("askell_session_token") or create_cart_session(request)


@require_POST
def renew_cart_session(request):
    return JsonResponse({"sessionToken": create_cart_session(request)})

The pages send cart_session_token(request) as sessionToken, and renew_cart_session is routed to /askell/cart-session/.

Product picker

mountProduct shows the sales channel’s offers and an “Add to cart” button:

askell.mountProduct("#askell-product", {
  products: [12],
  title: "Kaffiáskrift",
  onAdd(cart) {
    console.log(cart);
  },
});

products, prices and bundleTemplates are lists of the ids of the products, prices and bundle templates the product picker offers. If none of them is given, the product picker offers everything the sales channel offers. Add-ons show up as in the checkout when the product or bundle they belong to is selected. A product picker that offers only one-time products lets them be added to the cart on their own, even when the sales channel does not sell them without a subscription. The cart then waits for a subscription, see What one cart can hold.

defaultOffer, title, description, language, colorScheme and theme work as they do for mountCheckout. onAdd(cart) is called with the cart when the buyer has added to it, and onError(error) when an addition is refused or the session cannot be loaded.

Before the product picker adds to the cart, it checks that the cart can hold the selection and then requests a quote (quote/) for the whole cart. If the API refuses the quote, the reason is shown and the cart is left unchanged. Otherwise the product picker confirms that the cart was added to and offers a “View cart” button, which opens the cart.

Cart button

mountCart sets up the cart button and the side panel:

askell.mountCart("#askell-cart", {
  metadata: { source: "webshop" },
  termsRequired: true,
  onSuccess(result) {},
  onError(error) {},
  onStateChange(step) {},
  onOpenChange(open) {},
});

With trigger: false no button is shown, and the page opens the cart with its own button and askell.cart.open(). The side panel opens over the page and follows the widget’s theme and colorScheme.

In the cart the buyer can change quantities and remove lines. An add-on its product requires goes when the product is removed. The cart shows the total from a quote for the whole cart and a “Go to checkout” button, which runs the same steps as mountCheckout for the cart’s contents. Checkout options such as metadata, termsUrl, termsRequired, collectionMethod, customer and returnUrl apply to that checkout as they do to mountCheckout.

  • onSuccess(result) is called when the cart’s checkout is completed. What was bought leaves the cart, while anything added in the meantime, for example in another tab, stays.

  • onError(error) and onStateChange(step) work as they do for mountCheckout.

  • onOpenChange(open) is called when the side panel opens (true) or closes (false).

If the cart changes, for example in another tab, before the checkout is created, the checkout stops and the cart shows again with the message “Your cart changed”. Once the checkout has been created, it carries on with what it started with. Lines the sales channel no longer offers, or that have sold out, are removed from the cart when a page opens, and the cart says so.

The cart in JavaScript

askell.cart gives access to the cart for a seller’s own interface. Each call waits for the session and returns a Promise:

  • get() returns the cart, and lines() its lines with their name, price, quantity, minimum and maximum.

  • add(offer) adds an offer to the cart the same way the product picker does. If the cart cannot hold it, the Promise is rejected with an error carrying a translated message for the buyer.

  • setQuantity(line, quantity) and remove(line) take a line from lines() or its key, for example "items:12".

  • clear() empties the cart, and open() and close() open and close the cart button’s side panel.

  • subscribe(listener) calls listener with the cart right away and whenever it changes, and returns a function that stops it.

mountProduct and mountCart also return cart along with destroy, so Askell.mountCart(...).cart can be used without Askell.init.

What one cart can hold

The cart is bought in one checkout and becomes one contract, so it can hold what one offer can:

  • Subscriptions or one bundle, not both. The cart can hold only one bundle, and the same bundle again only with the same options.

  • One-time products can go with either. A cart of one-time products only can only be bought if the sales channel allows it; otherwise the cart waits for a subscription and says so.

  • Everything in the same currency.

  • No subscriptions that renew automatically together with single-period subscriptions.

  • All subscriptions with the same trial period. Monthly and yearly subscriptions can, however, go together.

  • Only one subscription price if the sales channel allows only one (price_selection_mode: "single").

  • Add-ons only with the product or bundle they belong to.

  • Quantities within their minimum and maximum. If the buyer adds beyond the maximum, the quantity is set to the maximum and the widget says so.

The widget refuses what breaks these rules before it asks the API, and the quote for the whole cart refuses anything else the sales channel does not sell.

Where the cart is kept

The cart is kept in the buyer’s browser (localStorage), not by Áskell. There is one for each origin of the site and each sales channel: all pages and tabs of the same origin share it, and a change in one tab shows in the others. Different origins do not share a cart, so https://www.seller.example and https://shop.seller.example each have their own. In a private window, or when the browser does not allow storage, the cart only lasts while the page is open.

When the cart’s checkout is completed, the session has been used, and getSessionToken then fetches the next one, so the buyer can keep shopping without reloading the page.

Sales channel setup

Sales channels are reusable checkout profiles. They control what a buyer can purchase and from where checkout may be used.

Add-ons are loaded automatically from active add-on rules for selected products and bundles. The sales channel does not need to select add-on products separately; disable add-ons on the sales channel only when they should not be shown.

If prices are alternatives for the same product, for example monthly and yearly prices, set offer_policy.price_selection_mode to "single". The widget then displays prices as options where the buyer chooses one price, and the API rejects requests that try to buy more than one base price. The default value is "multiple" and keeps the previous behavior where the buyer can select more than one price.

The sales channel also controls how the widget gets the customer reference with customer_reference_setting. "kennitala" shows a field for an Icelandic ID number and validates it before binding the customer to the session. "random" hides the field and the widget generates a random UUID as the reference.

If the selected product or a product in a bundle is not marked as electronic, the widget collects the buyer address. The buyer can also choose a different delivery address. When the country is Iceland, the widget shows a searchable Icelandic postcode field and resolves the city from the postcode. For other countries, the widget shows a free-form postcode field and a separate city field. The widget sends delivery_address with the checkout request when a different delivery address is selected, and the delivery address is saved on the contract when checkout is completed.

If the account has active shipping options, the widget also shows a shipping choice when the cart contains a product that is not electronic. The browser endpoint GET /api/v2/checkout-sessions/<token>/public/ returns shipping_options, and GET /api/v2/checkout-sessions/<token>/shipping-locations/?option=<id> returns pickup locations for options that require a location choice, for example parcel lockers or Dropp pickup points. The checkout request then sends a shipping object with option and, where it applies, location_id, and the selection is saved as shipping_selection on the contract when the checkout is finalized.

The list in public/ is unpriced; a shipping option with a rate table by zone and weight appears there with price_amount: null and requires_zip: true. The quote request (quote/) accepts delivery_zip_code, and the response returns shipping_options with the options priced for that postal code and the weight of the products in the quote (zone and weight_band show which cell of the rate table applied); an option whose rate table does not cover the shipment is left out. The widget sends the postal code with every quote request once an address has been entered, and shows the list from the quote. The checkout request is priced the same way from delivery_address (otherwise the customer’s address) and is rejected with 400, a shipping explanation and shipping_code if the rate table does not cover the shipment. If the quote returns an empty shipping_options list even though the account offers shipping options, the widget stops the purchase at the review step and says that no option is available for the delivery address.

The quote request also accepts the shipping object itself, just like the checkout request. When it is included, the shipping fee is calculated and added to the quote’s subtotal_amount, tax_amount and total_amount, and returned in shipping_fee (amount, subtotal_amount, tax_amount, total_amount). If the first period ships no products — a trial period defers the products to the first renewal, which then becomes the first shipment — shipping_fee is null and the fee is in none of the quote’s amounts; it is charged with the first billing run that ships products. If no shipping object is sent, shipping_fee is also null; if shipping is free — for example because of the free-shipping threshold or in-store pickup — shipping_fee is returned with zeros so the buyer can be shown that shipping costs nothing. The free-shipping threshold is evaluated after the promotion code discount has been deducted, exactly as when a checkout is created, and the quote is rejected with the same 400 and shipping / shipping_code as the checkout request if the rate table does not cover the shipment. The widget sends the selection with every quote request, asks for a new quote when the buyer changes the shipping option or delivery location, and shows the shipping fee as a separate line on the review step. The amount the buyer sees is therefore the same as the amount charged. Renewals price their own shipment, so recurring_total_amount does not include it.

If the account has an active promotion code the buyer can use, GET /api/v2/checkout-sessions/<token>/public/ returns has_coupons: true and the widget shows a promotion code field on the review step. The code is validated with promotion_code in the quote request (quote/), sent again with the checkout request and validated again when the checkout is finalized. The quote response includes discount with the discount amount, its duration (once, repeating or forever) and the discounted renewal price (recurring_final_amount). Discounts the seller applies without a promotion code show no field, as on payment pages. If promotion codes are not enabled for the account, promotion_code is rejected in the quote request, when the checkout is created and when it is finalized, and likewise when a contract is created directly through the API. If a code stops being valid between creating and finalizing the checkout — or if the seller changes its terms in the meantime — that checkout is canceled and detached from the session so a new one can be created. Both cases return 400 with promotion_code; changed terms also come with code: "promotion_terms_changed".

Before activating a sales channel, confirm:

  • products are active

  • prices are active

  • bundle templates have active items and valid default prices

  • selected currencies and billing schedules are compatible

  • allowed origins are configured

  • allowed collection methods are configured

  • payment processor policy is either empty, a valid allowlist, or one selected account payment processor

  • metadata policy only allowlists harmless frontend tracking keys

Allowed origins must be origins only:

https://seller.example
http://localhost:3000

Do not include paths, query strings, credentials or wildcards:

https://seller.example/checkout
https://user:pass@seller.example
https://*.seller.example

Terms

A sales channel can define terms that the buyer must accept before checkout is created. This is usually configured once on the sales channel:

{
  "terms_policy": {
    "url": "https://seller.example/terms",
    "required": true
  }
}

terms_policy.url is the URL of the seller’s terms page. terms_policy.required means the buyer must accept the terms before they can pay. If url is configured, the widget shows a link next to the acceptance checkbox.

Terms can be overridden for a single checkout session with terms_url and terms_required in POST /api/v2/checkout-sessions/. Existing sessions keep a snapshot of the terms policy, so create a new session after changing the sales channel.

Checkout theme is not owned by the sales channel. The widget uses a built-in default theme, but the seller backend can pass a per-session override and the widget JavaScript setup can override individual theme values.

Theme variables

Checkout themes are sanitized and snapshotted when a checkout session is created. Seller backends can pass a per-session override in the checkout session request:

{
  "sales_channel": "memberships",
  "customer_reference": "seller-user-123",
  "theme": {
    "primary_color": "#0F766E",
    "accent_color": "#2563EB",
    "background_color": "#FFFFFF",
    "surface_color": "#F8FAFC",
    "text_color": "#0F172A",
    "muted_text_color": "#475569",
    "border_color": "#CBD5E1",
    "error_color": "#DC2626",
    "success_color": "#15803D",
    "border_radius": "md",
    "font_family": "system"
  }
}

The embedded widget applies the resolved theme as CSS variables on the checkout root element:

Theme field

CSS variable

Purpose

primary_color

--askell-primary-color

Primary buttons and highlighted actions.

accent_color

--askell-accent-color

Secondary accents and supporting highlights.

background_color

--askell-background-color

Checkout page background.

surface_color

--askell-surface-color

Product cards, panels and confirmation surfaces.

text_color

--askell-text-color

Primary text.

muted_text_color

--askell-muted-text-color

Secondary text and descriptions.

border_color

--askell-border-color

Borders and dividers.

error_color

--askell-error-color

Error states.

success_color

--askell-success-color

Success states.

border_radius

--askell-border-radius

Widget corner radius.

font_family

--askell-font-family

Widget font stack.

Color values must be valid hex colors, either #RGB or #RRGGBB. Unsupported theme fields are rejected. border_radius supports none, sm, md and lg. font_family supports system, inter and inherit.

The API also checks basic contrast rules: text must contrast with the background and surface colors, muted text must contrast with the background, and primary_color must contrast with white button text.

Hosted payment frames may not inherit every visual setting from the widget. The checkout shell uses the full theme, while payment method registration applies the sanitized session theme where the selected processor supports it.

Security checklist

  • Store secret keys only in server-side settings or environment variables.

  • Create sessions from trusted server-side code.

  • Bind sessions to customer or customer_reference when checkout is customer-specific.

  • Configure allowed origins before activating a sales channel.

  • Keep session lifetimes short.

  • Reject frontend metadata, or allowlist its keys explicitly.

  • Verify the created contract server-side before granting paid access.

The browser must not receive:

  • ASKELL_SECRET_KEY

  • private API keys

  • account payment processor secrets

  • unrestricted product, price, amount, accounting, contract or migration fields

  • unrestricted metadata

Frontend metadata

The seller backend can attach metadata to the session with metadata when it creates it (POST /api/v2/checkout-sessions/). The sales channel’s metadata policy does not apply to that metadata.

The browser can also send metadata when it creates a checkout in the session (POST /api/v2/checkout-sessions/{token}/checkout/) or registers a payment method. frontend_allowed_keys in the sales channel’s metadata_policy decides which keys it may send. metadata_policy can be:

  • {}, or frontend_allowed_keys set to null: the browser may send metadata with any keys. Older sales channels without a frontend_allowed_keys field behave this way.

  • {"frontend_allowed_keys": []}: the browser may not send any metadata. A request without metadata, or with an empty metadata object, is still accepted. New sales channels created in the dashboard use this setting by default.

  • {"frontend_allowed_keys": ["utm_source", "campaign"]}: the browser may send only the listed keys.

If the browser sends a key the policy does not allow, the request is rejected with 400 and a metadata error.

The policy is snapshotted when the session is created, so a change to the sales channel applies only to sessions created after the change.

Warning

A sales channel that accepts any key (“Any metadata key” in the dashboard, saved as {}) does not restrict the browser: it can send any keys and they end up on the contract. Older sales channels are set up this way. Choose “Only listed keys” and list the keys the browser needs, or “No browser metadata”. Put anything that matters, such as an order number or anything else the integration relies on, in the session metadata on the backend, because the session’s value takes precedence over the browser’s for the same key. Keys Áskell uses itself are never copied to the contract.

Do not allow frontend keys that affect accounting, entitlement, contract references, migration source ids, processor selection or customer identity.

Metadata on the contract

When the checkout is finalized, the metadata is copied into metadata on the contract it creates. It therefore travels with the contract in the V2 contracts API and in every subscription_contract.* webhook, including subscription_contract.created:

  • The session’s metadata stays on the session (GET /api/v2/checkout-sessions/{token}/) and is copied to the contract.

  • The browser’s metadata is stored on the checkout and copied to the contract.

  • Metadata a backend sends with POST /api/v2/checkouts/, without a session, is stored on the checkout and copied to the contract the same way.

If the session and the browser send the same key, the session’s value wins, since it comes from the seller backend. The result is the same whether the checkout is finalized in the browser (POST /api/v2/checkout-sessions/{token}/finalize/) or by the backend (POST /api/v2/checkouts/{token}/finalize/).

Keys Áskell itself uses on contracts, such as billing_anchor_mode, which changes when the contract is billed, are not copied to the contract; they stay only on the session or the checkout. The list is in Contract reference and metadata.

Áskell records where the contract came from in askell_source. A contract from a session gets, for example:

{
  "seller_cart_id": "cart-456",
  "utm_source": "newsletter",
  "askell_source": {
    "type": "checkout_session",
    "checkout_session_token": "cs_abc123",
    "checkout_id": 15,
    "checkout_token": "f2dd7db2-4ae1-45c0-b8f6-2f98d1b70a9a"
  }
}

A contract from POST /api/v2/checkouts/ without a session gets "type": "checkout", checkout_id and checkout_token, but no checkout_session_token.

Contract reference

The seller backend can give the contract its own reference, such as an order number, with contract_reference. It sends it when it creates the session (POST /api/v2/checkout-sessions/) or when it creates a checkout without a session (POST /api/v2/checkouts/). When the checkout is finalized, the reference goes into the contract’s reference as soon as the contract is created. It is therefore in every subscription_contract.* webhook, including subscription_contract.created, and GET /api/v2/subscription-contracts/?reference=... finds the contract right away. There is no need to add it afterwards with PATCH, which comes too late for the first webhooks.

  • Only the seller backend can set the reference, never the browser. If the browser sends contract_reference when it creates a checkout in the session, the request is rejected with 400. The reference is neither in the public session data nor in responses to the browser.

  • It can be at most 128 characters and must not contain a comma; otherwise the request is rejected with 400 and an error on contract_reference. Surrounding whitespace is trimmed, and a blank value or null means no reference.

  • It does not have to be unique, so ?reference= returns a list.

  • GET /api/v2/checkout-sessions/{token}/ returns the session’s reference and GET /api/v2/checkouts/{token}/ the checkout’s. A checkout the browser creates in a session has no reference of its own, and the contract gets the session’s reference.

contract_reference is not the same as customer_reference. customer_reference is the customer’s reference and binds the session to a customer, while contract_reference is the reference of the contract that is created.

Django backend example

With django-askell:

from django.http import JsonResponse
from askell.client import client


def create_askell_session(request):
    result = client.create_checkout_session(
        sales_channel="memberships",
        user=request.user,
        metadata={"source": "pricing-page"},
        expires_in_seconds=1800,
    )
    if result["status"] != "success":
        return JsonResponse(
            {"error": result["response"]},
            status=result["status_code"],
        )
    return JsonResponse({"sessionToken": result["response"]["token"]})

The package also exposes a logged-in helper view at /askell/checkout-session/ when the package URLs are included. You can subclass the view and set sales_channel if the channel should be fixed server-side.

Migration from legacy plan forms

For every legacy PlanVariant exposed in a seller frontend, map it to one of:

  • a V2 CatalogProduct and CatalogPrice

  • a V2 BundleTemplate

  • an initial one-time CatalogPrice

Then create a sales channel that exposes only the mapped V2 offers and replace the old frontend form with embedded checkout.

Legacy access checks should move from “active legacy subscription for plan variant” to “active V2 subscription contract item for product or price”. Do not grant access based only on the browser callback; verify the resulting contract server-side.

Admin-operated migration of existing live legacy subscriptions is documented in the internal migration runbook. Embedded checkout only changes how new buyers enter the V2 contract model.

Once the account’s subscription model is contracts, legacy forms that sell a PlanVariant stop working, and they reject the purchase before anything is charged. POST /api/checkouts/ with plan and payment pages that sell legacy plans respond with a 400 error whose code is legacy_subscriptions_disabled. The subscribe button rejects purchases the same way, before a customer is created, but responds with an error page (400) for the buyer rather than a JSON response with code. POST /api/checkouts/ without plan (card registration or a one-time amount) keeps working. Replace the forms with embedded checkout before the account is switched over. See Subscriptions.

Troubleshooting

Session returns 404

Common causes:

  • token is wrong or stale

  • session is expired

  • session is completed or canceled

  • frontend is using a token from another environment

Create a fresh session and confirm the token, status and expiry.

Origin mismatch

If checkout works locally but fails in production, check the sales channel allowed origins. Existing sessions keep their snapshotted origin policy, so create a new session after changing the sales channel.

Offer rejected

Confirm the selected price, product, bundle or add-on appears in the public session offer_catalog. If it does not, update the sales channel offer policy and create a new session.

Payment processor unavailable

Check that the selected or pinned account payment processor supports the currency and collection method. Non-card collection methods require a customer with the required verified payment method.

Payment method registration fails

Create checkout before registering a payment method, use the registration token returned by the session endpoint, and poll the session-scoped registration status endpoint until it reaches a terminal state.

Finalize fails

Confirm the session is still pending, the checkout belongs to the session, and payment method registration succeeded. An offer with recurring items requires a verified payment method even when the initial total is zero. Retrying finalize is safe after a completed session because the endpoint returns the idempotent result.

Payment pages and sales channels

Payment pages can optionally attach a sales channel. Legacy payment pages without a sales channel keep their page-specific settings. When a sales channel is attached, V2 bundle payment pages use the sales channel bundle allowlist, collection methods and payment processor policy.

Stored payment page quote

POST /public/api/payment-pages/quote/ now returns quote_token and expires_at along with the quote itself. The price is calculated once, stored on PaymentPageQuote and valid for 30 minutes. The response also carries promotion: the promotion code the price was calculated with, in the same format that validate_promotion_code returns, or null if the code no longer applied. The summary on the page shows the discount from the quote, not from the earlier validation of the code. The endpoint is limited to 120 requests per minute per IP address (PAYMENT_PAGE_QUOTE_THROTTLE).

Every later step accepts quote_token and prices from the stored quote: card sessions (POST /adyen/checkout/ and POST /teya/checkout/), POST /public/api/payment-pages/verify_card/ and POST /public/api/payment-pages/process_payment_and_finalize/. With the token, the amount is the quote’s total_amount, the shipping fee is charged unchanged as it was priced, the promotion code is the one the quote used, and the shipping selection is saved from the stored snapshot. The quote is consumed when the contract is created; the same token cannot be used again.

Item lines (items, initial_items, bundle_template_id, bundle_selections, additional_items, additional_bundles), promotion_code, shipping and zip_code as pricing inputs are deprecated on these endpoints. They still work for one release for older browser clients, and are logged with a warning when used, but will be removed in the next release. zip_code as part of the customer’s address in process_payment_and_finalize stays.

Error codes when a stored quote fails validation:

quote_not_found

The token was not found (HTTP 404).

quote_page_mismatch

The token belongs to a different payment page.

quote_expired

The quote has expired; the page needs to fetch a new quote.

quote_consumed

This quote has already been paid.

quote_mismatch

The card was verified (or the card session created) for a different quote than the one sent to process_payment_and_finalize.

quote_changed

The pricing or the offer has changed since the price was quoted.

promotion_terms_changed

The promotion code’s terms were changed after the quote was issued.

combo_eligibility_changed

The combo discounts that apply have changed.

invalid_promotion_code

The stored promotion code is no longer valid.

On verify_card and process_payment_and_finalize the errors return {"message": ..., "code": ...} with HTTP 400, except quote_not_found, which returns HTTP 404. The card session endpoints (POST /adyen/checkout/ and POST /teya/checkout/) keep their own format and return the same codes as {"error": ..., "code": ...} with the same status codes.

Deleting a payment page does not take its quotes with it: unpaid quotes stop being valid (quote_page_mismatch), while paid quotes keep their link to the contract.