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:
Configure V2 catalog products, prices or bundle templates.
Create a sales channel that controls which offers, origins and payment methods are available.
Create a checkout session from the seller backend.
Mount the
askell.jsversion 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.
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.
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 buttons and highlighted actions. |
|
|
Secondary accents and supporting highlights. |
|
|
Checkout page background. |
|
|
Product cards, panels and confirmation surfaces. |
|
|
Primary text. |
|
|
Secondary text and descriptions. |
|
|
Borders and dividers. |
|
|
Error states. |
|
|
Success states. |
|
|
Widget corner radius. |
|
|
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
customerorcustomer_referencewhen 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_KEYprivate 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:
{}, orfrontend_allowed_keysset tonull: the browser may send metadata with any keys. Older sales channels without afrontend_allowed_keysfield behave this way.{"frontend_allowed_keys": []}: the browser may not send any metadata. A request withoutmetadata, or with an emptymetadataobject, 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_referencewhen it creates a checkout in the session, the request is rejected with400. 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
400and an error oncontract_reference. Surrounding whitespace is trimmed, and a blank value ornullmeans 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 andGET /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
CatalogProductandCatalogPricea V2
BundleTemplatean 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_foundThe token was not found (HTTP 404).
quote_page_mismatchThe token belongs to a different payment page.
quote_expiredThe quote has expired; the page needs to fetch a new quote.
quote_consumedThis quote has already been paid.
quote_mismatchThe card was verified (or the card session created) for a different quote than the one sent to
process_payment_and_finalize.quote_changedThe pricing or the offer has changed since the price was quoted.
promotion_terms_changedThe promotion code’s terms were changed after the quote was issued.
combo_eligibility_changedThe combo discounts that apply have changed.
invalid_promotion_codeThe 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.