CheckoutBuild
Gratis verzending per land, per klanttype en per klant. Uit één veld.
Diek Thunnissen8 sep 202611 min leestijd
De balk zegt 75, de korting rekent met 100, en voor een Belg klopt allebei niet. Dus haalden we de gratis-verzenddrempel uit vier teksten en zetten hem één keer op de shop: per land, per B2B, met een override per klant. Het theme, de checkout, de Function en Flow lezen allemaal hetzelfde veld.

Op onze eigen demo-store zei de winkelwagen: je hebt gratis verzending. Eén klik verder zei de checkout: nog €75 te gaan. Allebei lazen ze een ander getal, en voor een Belg klopte geen van beide.
Dat is geen slordigheid van één shop. Het is hoe bijna elke Shopify-shop de gratis-verzenddrempel opslaat: als hardgecodeerd getal in de announcement bar, nog een keer op de productpagina, nog een keer in de balk in de winkelwagen, en dan als losse voorwaarde op het verzendtarief. Vier plekken, nul verbinding. Verhoog je België naar 100, dan pas je er één aan en beweren de andere drie iets anders.
Dus haalden we het getal uit al die teksten en zetten het één keer op de shop, als metafield. Met per land een eigen waarde, een aparte voor B2B, en een override op de klant of de vestiging voor wie een eigen afspraak heeft. Het theme, de checkout, de korting en Flow lezen allemaal dat ene veld.
Hoe het standaard werkt, en waar het stopt
Je verzendtarieven staan per zone. Een zone is een groep landen, en per zone maak je tarieven met een prijsvoorwaarde: standaard €4,95 tot €75, daarboven gratis. Per 1 oktober verhuist dit naar Markets, dan hangt het aan je markt in plaats van aan een los profiel. Netter, maar het principe blijft: een tarief kijkt alleen naar land en orderwaarde.
Voor per land is dat genoeg. Nederland vanaf 75, België vanaf 100, Duitsland vanaf 120: drie tarieven met drie voorwaarden, niets aan te bouwen. Waar het stopt is per klanttype en per klant. Een tarief weet niet dat iemand inlogt namens een bedrijf. Het weet niet dat dit de klant is die elke maand bestelt. Daarvoor heb je een Shopify Function nodig, een klein stuk logica dat bij elke checkout meekijkt en zegt: deze verzending wordt 100% gratis.
En zelfs dan heb je alleen de kassa geregeld. De belofte die de klant onderweg naar die kassa leest, staat nog steeds op drie andere plekken.
De lapmiddelen
Een theme setting voor het bedrag. Dan staat het getal op één plek in de editor, maar die plek is thema-gebonden: de Function kan er niet bij, het checkout-blok ook niet, en bij een thema-wissel is hij weg. Waar dat verschil precies zit staat in het stuk over theme settings, metafields en metaobjects.
Een verzendbalk-app. Die heeft een eigen instelling met een eigen bedrag. Dat is geen vierde plek minder, dat is een vijfde plek erbij. En hij weet niets van je B2B-klanten.
Verzendtarieven per markt, wat vanaf oktober kan. Per land klopt het dan aan de kassa. Maar per klant of per bedrijf niet, en je winkelwagen-balk leest nog steeds niet wat er in je tarieven staat.
Eén veld, vier lezers
Een shop-metafield is het enige stuk data in Shopify dat elke laag mag lezen. Liquid leest het in elk template. Een Function krijgt het in zijn input query. Een checkout-blok krijgt het via appMetafields. De Storefront API geeft het aan alles wat daarbuiten draait. En Flow kan het lezen én schrijven, ook op een tijdschema.
Dat maakt de drempel geen tekst meer maar een instelling. Het theme leest hem en kent het land van de bezoeker, dus de announcement bar zegt in België iets anders dan in Nederland. Het checkout-blok leest hem voor de balk naast de betaalknop. De Function leest hem en maakt de verzending ook echt gratis. Flow schrijft ernaartoe, dus een actieweek zet zichzelf aan en weer uit.
“Niet vier plekken beter synchroniseren. Het getal maar één keer laten bestaan.”
Het datamodel
Eén json op de shop voor de matrix, plus dezelfde sleutel als los getal op de klant en op de B2B-vestiging. Dit zijn de waarden die op de demo-store staan.
Shop-metafield custom.free_shipping_rules (json)
{
"default": 150,
"countries": { "NL": 75, "BE": 100, "DE": 120 },
"b2b": 500
}
Customer-metafield custom.free_shipping_threshold (number_decimal)
Russel Winfield (VIP) 0 → altijd gratis
CompanyLocation-metafield custom.free_shipping_threshold (number_decimal)
Powderbound 250 → eigen B2B-afspraak
Alle drie definities: storefront-toegang PUBLIC_READ,
anders ziet het theme ze niet.Waarom json en niet drie losse velden: de matrix groeit. Volgende maand komt er een land bij, of een tweede B2B-groep. Eén veld dat je in de admin onder Instellingen, Custom data, Shop aanpast, is beter te overzien dan een rij losse getallen.
Waarom de override op de klant en niet als tag: de checkout ziet tags niet, een Function alleen via een vaste lijst in de query. Een getal op de klant lezen alle drie de lagen. Flow zet dat veld, en de tag mag er voor het overzicht in de admin bij, maar de tag is nooit de bron.
De leesvolgorde
Elke lezer loopt dezelfde zes stappen af en stopt bij de eerste die een waarde geeft. Dat is de hele reden dat de lagen nooit uit elkaar lopen: niet omdat ze synchroniseren, maar omdat ze dezelfde regel toepassen op hetzelfde veld.
1. klant-veld custom.free_shipping_threshold op de klant
2. vestiging-veld idem op de B2B-vestiging (companyLocation)
3. rules.b2b ingelogd namens een bedrijf
4. rules.countries landcode uit localization.country
5. rules.default
6. oud los shop-veld custom.free_shipping_threshold op de shop (fallback)
Regel: 0 op de klant of vestiging betekent "altijd gratis".
0 of leeg op de shop betekent "geen drempel", de Function doet niets.De volgorde is van specifiek naar algemeen. Een afspraak met één klant wint van een afspraak met zijn bedrijf, die wint van de B2B-standaard, die wint van het land. Het oude losse shop-veld blijft onderaan staan zodat lezers van vóór de matrix niet ineens niets meer zien.
De Function
Een Delivery Discount Function op cart.delivery-options.discounts.generate.run. De input query haalt het subtotaal, de klant, de vestiging, het land en de twee shop-velden op. Dit is de query zoals hij in de repo staat.
43 regels codeToon code +Verberg code −
# extensions/threshold-shipping/src/cart_delivery_options_discounts_generate_run.graphql
query DeliveryInput {
cart {
cost {
subtotalAmount {
amount
}
}
deliveryGroups {
id
}
buyerIdentity {
customer {
threshold: metafield(namespace: "custom", key: "free_shipping_threshold") {
value
}
}
purchasingCompany {
location {
threshold: metafield(namespace: "custom", key: "free_shipping_threshold") {
value
}
}
}
}
}
localization {
country {
isoCode
}
}
shop {
rules: metafield(namespace: "custom", key: "free_shipping_rules") {
jsonValue
}
legacyThreshold: metafield(namespace: "custom", key: "free_shipping_threshold") {
value
}
}
discount {
discountClasses
}
}En de logica. resolveThreshold is de leesvolgorde van hierboven, letterlijk. De run-functie doet daarna maar drie dingen: is er een drempel, is het subtotaal eraan, zijn er bezorggroepen. Zo ja: 100% korting op elke bezorggroep, met de tekst "Gratis verzending". Het bestand hieronder is compleet op het documentatiecommentaar bovenaan na.
94 regels codeToon code +Verberg code −
// extensions/threshold-shipping/src/cart_delivery_options_discounts_generate_run.ts
import {
DeliveryDiscountSelectionStrategy,
DiscountClass,
DeliveryInput,
CartDeliveryOptionsDiscountsGenerateRunResult,
} from "../generated/api";
type Rules = {
default?: unknown;
countries?: Record<string, unknown>;
b2b?: unknown;
};
function num(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? n : null;
}
/** Geeft de geldende drempel in euro, of null als er geen drempel is. */
export function resolveThreshold(input: DeliveryInput): number | null {
const buyer = input.cart.buyerIdentity;
const customerOwn = num(buyer?.customer?.threshold?.value);
if (customerOwn !== null) return customerOwn;
const location = buyer?.purchasingCompany?.location;
const locationOwn = num(location?.threshold?.value);
if (locationOwn !== null) return locationOwn;
const rules = (input.shop.rules?.jsonValue ?? null) as Rules | null;
if (rules && typeof rules === "object") {
if (buyer?.purchasingCompany) {
const b2b = num(rules.b2b);
if (b2b !== null) return b2b;
}
const iso = input.localization.country.isoCode;
const byCountry = num(rules.countries?.[iso]);
if (byCountry !== null) return byCountry;
const fallback = num(rules.default);
if (fallback !== null) return fallback;
}
return num(input.shop.legacyThreshold?.value);
}
export function cartDeliveryOptionsDiscountsGenerateRun(
input: DeliveryInput,
): CartDeliveryOptionsDiscountsGenerateRunResult {
if (!input.discount.discountClasses.includes(DiscountClass.Shipping)) {
return { operations: [] };
}
const threshold = resolveThreshold(input);
// Op shop-niveau is 0 "geen drempel"; alleen een eigen klant- of
// vestigingsveld mag 0 = altijd gratis betekenen.
const hasOwnField =
num(input.cart.buyerIdentity?.customer?.threshold?.value) !== null ||
num(input.cart.buyerIdentity?.purchasingCompany?.location?.threshold?.value) !== null;
if (threshold === null || (threshold === 0 && !hasOwnField)) {
return { operations: [] };
}
const subtotal = Number(input.cart.cost.subtotalAmount.amount);
if (!Number.isFinite(subtotal) || subtotal < threshold) {
return { operations: [] };
}
if (input.cart.deliveryGroups.length === 0) {
return { operations: [] };
}
return {
operations: [
{
deliveryDiscountsAdd: {
candidates: input.cart.deliveryGroups.map((group) => ({
message: "Gratis verzending",
targets: [
{
deliveryGroup: { id: group.id },
},
],
value: {
percentage: { value: 100 },
},
})),
selectionStrategy: DeliveryDiscountSelectionStrategy.All,
},
},
],
};
}Twee details die ertoe doen. De vergelijking gebruikt cart.cost.subtotalAmount, vóór belasting en vóór kortingen op cart-niveau, hetzelfde bedrag als shopify.cost.subtotalAmount in het checkout-blok. En een 0 wordt op twee manieren gelezen: op de shop is het "geen drempel" en doet de Function niets, op de klant of vestiging is het "altijd gratis". Die uitzondering zit in hasOwnField.
De balk in de winkelwagen
Het theme heeft één snippet die de drempel oplost, met precies dezelfde zes stappen. Hij geeft "geen", "altijd" of het bedrag in centen terug, en de cart-balk en de announcement bar renderen dat. Het land komt uit localization.country.iso_code, dus wie via de landkiezer naar België schakelt ziet meteen de Belgische drempel, in euro's.
52 regels codeToon code +Verberg code −
{% comment %} snippets/diek-verzenddrempel.liquid {% endcomment %}
{%- liquid
assign dv_euro = nil
assign dv_eigen = false
if customer
assign dv_klant = customer.metafields.custom.free_shipping_threshold.value
if dv_klant != blank
assign dv_euro = dv_klant
assign dv_eigen = true
endif
if dv_euro == nil and customer.b2b? and customer.current_location
assign dv_vestiging = customer.current_location.metafields.custom.free_shipping_threshold.value
if dv_vestiging != blank
assign dv_euro = dv_vestiging
assign dv_eigen = true
endif
endif
endif
assign dv_rules = shop.metafields.custom.free_shipping_rules.value
if dv_euro == nil and dv_rules
if customer and customer.b2b? and dv_rules.b2b != blank
assign dv_euro = dv_rules.b2b
else
assign dv_land = localization.country.iso_code
assign dv_land_drempel = dv_rules.countries[dv_land]
if dv_land_drempel != blank
assign dv_euro = dv_land_drempel
elsif dv_rules.default != blank
assign dv_euro = dv_rules.default
endif
endif
endif
if dv_euro == nil
assign dv_euro = shop.metafields.custom.free_shipping_threshold.value
endif
if dv_euro == blank
echo 'geen'
else
assign dv_cent = dv_euro | times: 100 | round
if dv_cent == 0 and dv_eigen
echo 'altijd'
elsif dv_cent > 0
echo dv_cent
else
echo 'geen'
endif
endif
-%}De cart-balk zelf is een tweede snippet die dit resultaat vangt met capture, het resterende bedrag berekent en de balk tekent. Die laten we hier weg, het is alleen presentatie. Het enige dat telt: hij rekent met hetzelfde getal als de kassa.
De balk in de checkout
Het checkout-blok declareert de twee sleutels in zijn toml, op drie owners tegelijk. Shopify levert ze dan via appMetafields zonder netwerkcall. De klant- en vestigingsvelden komen alleen mee als de app toegang heeft tot beschermde klantdata. Als fallback voor de matrix doet het blok een Storefront API-query, zodat hij ook werkt als appMetafields niets levert.
# extensions/checkout-progress/shopify.extension.toml (fragment) [[extensions.targeting]] target = "purchase.checkout.block.render" module = "./src/ProgressBlock.tsx" # Storefront API-toegang als fallback voor de matrix [extensions.capabilities] api_access = true # Zelfde sleutel op drie owners: shop, customer, companyLocation [[extensions.metafields]] namespace = "custom" key = "free_shipping_rules" [[extensions.metafields]] namespace = "custom" key = "free_shipping_threshold"
Daarna dezelfde resolveThreshold als in de Function, en één extra regel die we pas na een live-test toevoegden.
// extensions/checkout-progress/src/ProgressBlock.tsx (fragment)
/** Zelfde volgorde als de threshold-shipping Function. null = geen drempel. */
function resolveThreshold(opts: {
customerOwn: number | null;
locationOwn: number | null;
isB2b: boolean;
iso: string | undefined;
rules: Rules | null;
legacy: number | null;
}): { threshold: number | null; own: boolean } {
if (opts.customerOwn !== null) return { threshold: opts.customerOwn, own: true };
if (opts.locationOwn !== null) return { threshold: opts.locationOwn, own: true };
const r = opts.rules;
if (r) {
if (opts.isB2b) {
const b2b = num(r.b2b);
if (b2b !== null) return { threshold: b2b, own: false };
}
const byCountry = opts.iso ? num(r.countries?.[opts.iso]) : null;
if (byCountry !== null) return { threshold: byCountry, own: false };
const fallback = num(r.default);
if (fallback !== null) return { threshold: fallback, own: false };
}
return { threshold: opts.legacy, own: false };
}
// ...
// De kassa is de waarheid: zodra een verzendoptie gekozen is en de
// verzendkosten 0 zijn (Function of tarief), zegt de balk "gratis", ook als
// de balk de klant zelf niet kent (gast die alleen een e-mailadres intikte:
// de Function ziet die klant wel, appMetafields niet).
const shippingCost = shopify.cost.totalShippingAmount.value;
const shippingIsFree = !!shippingCost && Number(shippingCost.amount) === 0;
const amount = Number(subtotal.amount);
const remaining = shippingIsFree ? 0 : Math.max(0, threshold - amount);
const reached = hasPreorder || remaining <= 0;Die extra regel kwam uit een test waarin een gast alleen zijn e-mailadres intikte. De Function herkent een bestaande klant al op dat adres en maakte de verzending gratis. Het blok kende de klant nog niet, want appMetafields voor een klant komen pas na inloggen, en zei "Nog €11". Sindsdien volgt de balk shopify.cost.totalShippingAmount: zijn de verzendkosten €0 zodra er een adres staat, dan zegt de balk gratis, wat hij verder ook denkt. De kassa is de waarheid.
Flow maakt het veld levend
Omdat het een metafield is, kan Flow ernaartoe schrijven. Dat verandert de drempel van een instelling in een knop die andere systemen mogen indrukken. Twee workflows die op de demo-store staan, handmatig geklikt in Flow omdat workflows niet via de API aan te maken zijn.
Workflow "Actieweek"
Trigger: Scheduled time (maandag 00:00)
Actie: Update shop metafield custom.free_shipping_rules
{"default":150,"countries":{"NL":50,"BE":100,"DE":120},"b2b":500}
Tweede workflow zondag 23:59 zet NL terug op 75.
Workflow "VIP na 3 orders"
Trigger: Order paid
Conditie: customer.numberOfOrders >= 3
Actie: Update customer metafield custom.free_shipping_threshold = 0
(en de tag VIP, voor het overzicht in de admin)Die tweede is een loyaliteitsregel zonder loyalty-app. Vanaf de derde bestelling heeft de klant overal gratis verzending: in de balk, in de checkout en aan de kassa. Niemand hoeft een theme-tekst aan te raken.
De valkuilen
Het oude tarief. Zolang "Gratis verzending vanaf €75" nog als verzendtarief actief staat naast de Function, vuren ze allebei bij een Nederlandse cart boven de 75: de kassa toont dan een gratis tarief én een kortingsregel. Zet het gratis-tarief uit en laat het standaardtarief zonder bovengrens staan, zodat de Function de enige plek is die verzending gratis maakt.
Valuta. Alle drie de lagen vergelijken de drempel met het subtotaal in de valuta die de klant ziet. Consistent, maar "150" betekent €150 in Nederland en $150 in de Verenigde Staten. Geef elk land met een andere valuta een eigen waarde in de matrix en gebruik default alleen waar dat acceptabel is.
Verkoopkanaal. Een Function ziet niet of een order uit de Shop-app of van TikTok komt. Per land, per klanttype en per klant kan dit, per kanaal niet. Beloof dat dus ook niet.
Storefront-toegang. Een metafield-definitie zonder PUBLIC_READ op de storefront werkt in de admin, in Flow en in de Function, maar het theme en het checkout-blok zien niets. Zet het bij alle drie de definities aan.
Waar dit wel en niet voor is
Het theme, de Function en Flow werken op elk plan. Het blok naast de betaalknop is een checkout UI extension en die vraagt Shopify Plus. De B2B-laag met vestigingen vraagt Plus ook, want companies zijn een Plus-onderdeel. Zonder Plus houd je dus per land en per klant over, met de kassa en de winkelwagen die hetzelfde zeggen. Dat is al meer dan de meeste shops hebben.
En verzending is eigenlijk het saaiste voorbeeld. De staffelkorting op de demo-store werkt precies zo: de tredes staan in één veld, de balk en de korting lezen dezelfde tredes. Het memberprogramma ook: tiers in een veld, en daar hangen korting, gratis express, een cadeau en de betaalmethodes aan. Hetzelfde patroon elke keer. Het veld is de instelling, Function en theme doen wat het veld zegt, Flow verandert het veld.
Veelgestelde vragen.
- Werkt dit zonder Shopify Plus?
- Grotendeels. De Function die verzending gratis maakt, de snippet in het theme en de Flow-workflows draaien op elk plan. Het blok in de checkout is een checkout UI extension en vraagt Plus, net als de B2B-laag met bedrijven en vestigingen. Zonder Plus houd je per land en per klant over, met winkelwagen en kassa die hetzelfde zeggen.
- Wat gebeurt er als het veld leeg is of op 0 staat?
- Op de shop betekent leeg of 0: geen drempel. De Function doet niets en de balk verbergt zich. Op een klant of vestiging betekent 0 juist: altijd gratis. Dat verschil zit bewust in alle drie de lezers, zodat een VIP-klant nooit een lege balk ziet.
- Waarom niet gewoon verzendtarieven per markt, dat kan toch vanaf 1 oktober?
- Per land kan dat inderdaad met tarieven, zonder te bouwen. Maar een tarief weet niets van de klant of het bedrijf achter de order, en je winkelwagen-balk leest niet wat er in je tarieven staat. De matrix lost die twee dingen op: één bron voor kassa én belofte, met een laag voor B2B en een override per klant.
Diek ThunnissenFounder & lead developer. Bouwt, verbetert en migreert de Shopify-laag voor DTC- en B2B-merken. LinkedInStuur me je checkout →

