Beschikbaar voor nieuwe Shopify-projecten

CheckoutBuild

Bots kopen je drop leeg. Dus draaiden we de checkout om.

Diek ThunnissenDiek Thunnissen8 sep 20268 min leestijd

Niet de snelste wint, maar wie er al was. Een limited release die alleen af te rekenen is door accounts van vóór de aankondiging, server-side in de Shopify-checkout. Eén datumveld op het product en één Function.

Je kondigt een limited release aan. Om 20:00 gaat hij live. Negen seconden later is alles weg en staat de helft op StockX. Je klanten zien een uitverkocht-melding, de resellers zien winst.

Wat je dan normaal doet is er een laagje tegenaan gooien. Een wachtrij. Een captcha. Bot-detectie. Rate limits. Allemaal aan de voorkant, allemaal te omzeilen door iemand met genoeg IP-adressen en geduld. En elke laag kost je echte klanten ook een stukje geduld.

Dus deden we het andersom. Op onze demo-store is de drop alleen af te rekenen door accounts die al bestonden vóór de aankondiging. Een bot die op de dag zelf honderd accounts aanmaakt, komt tot de checkout en dan niet verder.

Waarom lapmiddelen aan de voorkant verliezen

Alles wat je op de pagina zet, draait in de browser van de aanvaller. Een wachtrij is een token dat je kunt delen. Een captcha is een dienst van een paar cent per oplossing. Rate limits werken per IP en een botfarm heeft er duizenden. Je bent aan het racen tegen iemand wiens verdienmodel het is om die race te winnen.

De enige plek waar je niet te omzeilen bent, is de plek waar Shopify zelf beslist of een bestelling doorgaat. Dat is de checkout, en sinds Shopify Functions kun je daar je eigen regels in zetten. Server-side. Geen script op de pagina.

Het speelveld omdraaien: wie er al was

De vraag die we de checkout laten stellen is niet "ben jij een mens?" maar "was jij er al?". Een account dat bestond vóór de aankondiging van de drop, mag afrekenen. Een account van daarna niet. Gasten ook niet, die krijgen een login-melding.

Dat verschuift de kosten. Een e-mailadres is gratis, maar tijd niet. Wie mee wil doen, moet al klant zijn geweest voordat hij wist dat er iets te halen viel. Precies de mensen die je wilt belonen.

Je beloont niet de reseller met de beste verbinding. Je beloont de klant die vorig jaar ook al bij je kocht.

Laag 1: accountleeftijd

Sinds Functions API-versie 2026-10 kan een Function het veld customer.createdAt lezen. Vóór die tijd moest je accountleeftijd via tags of een Flow-sync in de checkout krijgen. Nu is het native input, net als het aantal bestellingen en het bestede bedrag.

Op het product staat één datumveld: custom.drop_announced_at. De Function vergelijkt dat met de aanmaakdatum van het account. Is het account jonger dan de aankondiging, dan gaat de checkout dicht met een melding waarom.

Eerlijk is eerlijk: deze laag alleen houdt een geduldige bot niet tegen. Wie drie maanden vooruit accounts aanmaakt, staat er gewoon tussen. Daarom is er een tweede laag.

Laag 2: bestelhistorie kost geld

Optioneel lezen we uit een json-veld op de shop twee drempels: een minimum aantal bestellingen en een minimum besteed bedrag. Honderd accounts aanmaken is gratis. Honderd accounts die elk al voor honderd euro hebben besteld, is een investering van tienduizend euro. Ergens daartussen wordt botten duurder dan de resell-marge.

Laag 1 kost een aanvaller geduld, laag 2 kost hem geld. Samen maken ze de drop oninteressant voor wie er niet echt klant is, zonder dat je echte klanten iets hoeven te doen behalve inloggen.

De build: één datumveld en één Function

Het datamodel is klein. Een date-metafield op het product voor de aankondiging, en een json-veld op de shop dat we toch al hadden voor het memberprogramma. Daar zit een drops-object in met de twee drempels.

// Shop-metafield custom.member_tiers (json), het relevante deel
{
  "drops": { "min_orders": 1, "min_spent": 100 }
}

// Product-metafield custom.drop_announced_at (date)
"2026-08-20"

De Function is een Cart and Checkout Validation. De input query haalt de klant, de cart-regels met het drop-veld en de shop-config op.

query CartValidationsGenerateRunInput {
  cart {
    buyerIdentity {
      customer {
        createdAt
        numberOfOrders
        amountSpent { amount }
      }
    }
    lines {
      merchandise {
        __typename
        ... on ProductVariant {
          product {
            title
            dropAnnouncedAt: metafield(namespace: "custom", key: "drop_announced_at") {
              value
            }
          }
        }
      }
    }
  }
  shop {
    memberConfig: metafield(namespace: "custom", key: "member_tiers") {
      jsonValue
    }
  }
  buyerJourney { step }
}

En de kern van de logica. Per cart-regel: geen drop-veld, dan niets doen. Wel een drop-veld, dan de twee lagen langs.

export function cartValidationsGenerateRun(input) {
  const errors = [];
  const step = input.buyerJourney.step;
  if (step !== "CHECKOUT_INTERACTION" && step !== "CHECKOUT_COMPLETION") {
    return { operations: [{ validationAdd: { errors } }] };
  }

  const customer = input.cart.buyerIdentity?.customer;
  const createdDate = customer?.createdAt?.slice(0, 10);
  const drops = input.shop.memberConfig?.jsonValue?.drops ?? {};

  for (const line of input.cart.lines) {
    if (line.merchandise.__typename !== "ProductVariant") continue;
    const announced = line.merchandise.product.dropAnnouncedAt?.value;
    if (!announced) continue;
    const title = line.merchandise.product.title;

    if (!customer || !createdDate) {
      errors.push({ message: `${title} is een member-drop. Log in met je account om af te rekenen.`, target: "$.cart" });
      continue;
    }
    if (createdDate > announced) {
      errors.push({ message: `${title} is een member-drop, alleen voor accounts van vóór de aankondiging (${announced}).`, target: "$.cart" });
      continue;
    }
    if (drops.min_orders && customer.numberOfOrders < drops.min_orders) {
      errors.push({ message: `${title} is een member-drop, alleen voor klanten die hier al eerder besteld hebben.`, target: "$.cart" });
      continue;
    }
    if (drops.min_spent && Number(customer.amountSpent.amount) < drops.min_spent) {
      errors.push({ message: `${title} is een member-drop, gereserveerd voor klanten die hier al voor minimaal €${drops.min_spent} besteld hebben.`, target: "$.cart" });
    }
  }

  return { operations: [{ validationAdd: { errors } }] };
}

Let op de datumvergelijking: createdAt is een datetime, het productveld een date. We vergelijken op dagniveau, zodat een account van de aankondigingsdag zelf nog mee mag. Anders sluit je de klant buiten die 's ochtends je nieuwsbrief las en meteen een account aanmaakte.

De valkuil: validations draaien ook bij toevoegen aan de cart

Dit kostte ons een middag. Een validation Function draait niet alleen in de checkout, maar ook bij elke add-to-cart. Zonder guard blokkeer je het product dus al op de productpagina, met een foutmelding die daar nergens op slaat.

De oplossing zit in de input: buyerJourney.step. Alleen bij CHECKOUT_INTERACTION en CHECKOUT_COMPLETION gaat de gate dicht. Bij alles daarbuiten, ook bij null, laat de Function de cart met rust. Zo kan iedereen het product in zijn winkelwagen leggen en de melding lezen op de plek waar hij hoort: bij het afrekenen.

Wat de klant ziet

Op de productkaart en de productpagina staat een chip "Member-drop", uit hetzelfde datumveld via Liquid. Dat toont alleen, het dwingt niets af. In de checkout krijgt een gast "Log in met je account om af te rekenen", een te jong account de datum van de aankondiging, en een account zonder bestelhistorie de uitleg dat de drop voor bestaande klanten is.

Aanzetten is één datumveld invullen op het product. Uitzetten is het veld leegmaken. Geen deploy, geen app, geen instelling in drie systemen.

Waar dit wel en niet voor is

Dit werkt alleen als mensen inloggen. Voor een merk met een echte community is dat geen drempel maar een filter. Voor een shop waar de meeste klanten als gast afrekenen, moet je eerst die stap bouwen. Nieuwe klantaccounts met een inlogcode per mail maken dat een stuk lichter dan het vroeger was.

Ook goed om te weten: createdAt is niet te backdaten. Een klant die al jaren bij je koopt maar nooit een account had, telt vanaf de dag dat hij er een aanmaakt. Wil je die groep meenemen, dan moet je bij de migratie of import de accounts aanmaken vóór de aankondiging, of laag 2 de doorslag laten geven.

En het is een regel, geen wapen. De bot die je hiermee tegenhoudt, is de bot die op de dag zelf accounts aanmaakt. Wie maanden vooruit plant én bereid is om bij je te kopen, is voor de meeste merken gewoon een klant.

Veelgestelde vragen.

Heb je Shopify Plus nodig voor deze checkout-gate?
Voor de gate zelf niet. Cart and Checkout Validation Functions draaien op elk Shopify-plan. De extra checkout-blokken uit onze demo (member-balk, cadeau, USP's) zijn checkout UI extensions en die vragen wel Plus.
Kun je de aanmaakdatum van bestaande klanten aanpassen?
Nee. customer.createdAt is de datum waarop het account in Shopify is aangemaakt en die is niet te wijzigen. Bij een migratie tellen accounts vanaf de importdatum. Zet daarom de aankondigingsdatum ná de import, of gebruik de tweede laag (bestelhistorie) als doorslaggevende regel.
Wat gebeurt er met klanten die als gast afrekenen?
Die zien in de checkout de melding dat het product een member-drop is en dat ze moeten inloggen. Het product blijft gewoon in de winkelwagen staan. Na inloggen beoordeelt de Function het account opnieuw.
Diek ThunnissenDiek ThunnissenFounder & lead developer. Bouwt, verbetert en migreert de Shopify-laag voor DTC- en B2B-merken. LinkedInStuur me je checkout

Technische Shopify-inzichten uit de praktijk.

Geen sales, wel techniek.