Skip to content
HeapByte — Senior Shopify Engineering Studio

Architecture · 22 October 2026

Shopify B2B trade account onboarding: automated VAT validation, credit gating, and GraphQL company creation

A European wholesale homeware brand opens its seasonal trade ordering window ahead of the autumn buying cycle. On Monday morning, twenty-five independent boutique retailers, interior design studios, and regional retail chains across the Netherlands, Germany, and France navigate to the merchant's Shopify Plus storefront to register for wholesale purchasing. Each buyer submits their business name, registered address, European VAT number, and a request for Net 30 trade credit. In an off-the-shelf setup, those submissions land as unverified retail customer accounts tagged wholesale_applicant or get dumped into a shared Google Sheet. By Wednesday afternoon, the operations desk has manually validated only nine VAT IDs against the European Commission's VIES portal, fat-fingered two tax exemptions, and left sixteen high-value accounts stranded without access to wholesale pricing while competing brands capture their seasonal inventory spend.

8 min read
Written by the HeapByte engineering team

Shopify B2B trade account onboarding

The tag trap: why customer tags cannot drive Shopify Plus B2B

For a decade, Shopify agencies handled wholesale using customer tags like wholesale or net_30. Liquid themes inspected customer.tags to render discounted prices or reveal manual payment methods.

In native Shopify Plus B2B, customer tags carry zero operational authority: they do not assign B2B Catalogs, cannot enforce CompanyLocation payment terms, and cause dual-identity leaks when staff members transition between wholesale entities.

The B2B entity graph: Companies, Locations, and Catalogs

Placing a wholesale order in Shopify Plus requires an interconnected entity graph: Company, CompanyLocation, CompanyContact, assigned Catalogs, and payment terms schedules.

Manual administration requires clicking through five disjointed screens per applicant. High-volume wholesale operations require automated programmatic orchestration via GraphQL.

Automated fiscal verification: real-time European VIES VAT validation

Under EU Council Directive 2006/112/EC, cross-border intra-Community supplies are zero-rated for VAT only if the buyer's VAT ID is actively registered in VIES.

Regex checks confirm string syntax but cannot prove fiscal activity or legal name alignment. Our engine pairs local format guards with real-time VIES API lookups, falling back to manual review if government services fluctuate.

ts
import assert from "node:assert/strict";

export interface TradeApplicationInput {
  companyName: string;
  countryCode: "NL" | "DE" | "BE" | "FR" | "GB" | "US";
  taxRegistrationNumber: string;
  contactFirstName: string;
  contactLastName: string;
  contactEmail: string;
  addressLine1: string;
  city: string;
  postalCode: string;
  requestedCreditLimitEur?: number;
  yearsInBusiness: number;
}

export interface ViesValidationResult {
  isValid: boolean;
  registeredName?: string;
  serviceAvailable: boolean;
}

export interface B2BOnboardingDecision {
  status: "APPROVED" | "MANUAL_REVIEW" | "REJECTED";
  riskScore: number;
  assignedCreditLimitEur: number;
  assignedPaymentTerms: "NET_15" | "NET_30" | "NET_60" | "DUE_ON_RECEIPT";
  assignedCatalogHandle: string;
  rejectionReasons: string[];
  manualReviewFlags: string[];
  graphqlPayload?: {
    companyInput: { name: string };
    locationInput: {
      name: string;
      shippingAddress: { address1: string; city: string; zip: string; countryCode: string };
      billingAddress: { address1: string; city: string; zip: string; countryCode: string };
    };
    contactInput: {
      firstName: string;
      lastName: string;
      email: string;
      role: "LOCATION_ADMIN" | "ORDERING_ONLY";
    };
    paymentTermsTemplate: string;
    catalogHandle: string;
  };
}

export function validateViesVatFormat(countryCode: string, vatNumber: string): boolean {
  const cleanVat = vatNumber.replace(/[\s.-]/g, "").toUpperCase();
  if (!cleanVat.startsWith(countryCode)) return false;
  const digits = cleanVat.slice(countryCode.length);
  switch (countryCode) {
    case "NL": return /^\d{9}B\d{2}$/.test(digits);
    case "DE": return /^\d{9}$/.test(digits);
    case "BE": return /^\d{10}$/.test(digits);
    case "FR": return /^[A-Z0-9]{2}\d{9}$/.test(digits);
    case "GB": return /^\d{9}(\d{3})?$/.test(digits);
    default: return cleanVat.length >= 8 && cleanVat.length <= 15;
  }
}

export function processB2BTradeApplication(
  app: TradeApplicationInput,
  viesResult: ViesValidationResult
): B2BOnboardingDecision {
  const rejectionReasons: string[] = [];
  const manualReviewFlags: string[] = [];
  let riskScore = 10;

  if (!app.contactEmail.includes("@") || !app.contactEmail.includes(".")) {
    rejectionReasons.push("INVALID_EMAIL_ADDRESS");
  }

  const consumerDomains = ["gmail.com", "hotmail.com", "yahoo.com", "outlook.com"];
  const emailDomain = app.contactEmail.split("@")[1]?.toLowerCase() ?? "";
  if (consumerDomains.includes(emailDomain)) {
    manualReviewFlags.push("FREE_MAIL_PROVIDER_REQUIRES_IDENTITY_CHECK");
    riskScore += 25;
  }

  if (!validateViesVatFormat(app.countryCode, app.taxRegistrationNumber)) {
    rejectionReasons.push("INVALID_TAX_ID_FORMAT");
  }

  if (!viesResult.serviceAvailable) {
    manualReviewFlags.push("VIES_SERVICE_UNAVAILABLE_FALLBACK_TO_MANUAL");
    riskScore += 20;
  } else if (!viesResult.isValid) {
    rejectionReasons.push("VIES_TAX_ID_NOT_REGISTERED_FOR_CROSS_BORDER_TRADE");
  }

  if (app.yearsInBusiness < 1) {
    manualReviewFlags.push("NEW_BUSINESS_UNDER_1_YEAR");
    riskScore += 30;
  }

  if (
    viesResult.registeredName &&
    !app.companyName.toLowerCase().includes(viesResult.registeredName.toLowerCase().slice(0, 5)) &&
    !viesResult.registeredName.toLowerCase().includes(app.companyName.toLowerCase().slice(0, 5))
  ) {
    manualReviewFlags.push("COMPANY_NAME_DEVIATES_FROM_GOVERNMENT_REGISTRATION");
    riskScore += 20;
  }

  if (rejectionReasons.length > 0) {
    return {
      status: "REJECTED",
      riskScore: 100,
      assignedCreditLimitEur: 0,
      assignedPaymentTerms: "DUE_ON_RECEIPT",
      assignedCatalogHandle: "retail-fallback",
      rejectionReasons,
      manualReviewFlags,
    };
  }

  if (manualReviewFlags.length > 0 || riskScore >= 40) {
    return {
      status: "MANUAL_REVIEW",
      riskScore,
      assignedCreditLimitEur: 0,
      assignedPaymentTerms: "DUE_ON_RECEIPT",
      assignedCatalogHandle: "wholesale-pending-verification",
      rejectionReasons: [],
      manualReviewFlags,
    };
  }

  let assignedPaymentTerms: "NET_15" | "NET_30" | "NET_60" | "DUE_ON_RECEIPT" = "DUE_ON_RECEIPT";
  let assignedCreditLimitEur = 0;

  if (app.yearsInBusiness >= 5 && riskScore <= 15) {
    assignedPaymentTerms = "NET_30";
    assignedCreditLimitEur = Math.min(app.requestedCreditLimitEur ?? 5000, 15000);
  } else if (app.yearsInBusiness >= 2) {
    assignedPaymentTerms = "NET_15";
    assignedCreditLimitEur = Math.min(app.requestedCreditLimitEur ?? 2500, 5000);
  }

  return {
    status: "APPROVED",
    riskScore,
    assignedCreditLimitEur,
    assignedPaymentTerms,
    assignedCatalogHandle: "b2b-tier-1-wholesale",
    rejectionReasons: [],
    manualReviewFlags: [],
    graphqlPayload: {
      companyInput: { name: app.companyName },
      locationInput: {
        name: `${app.city} Distribution Hub`,
        shippingAddress: { address1: app.addressLine1, city: app.city, zip: app.postalCode, countryCode: app.countryCode },
        billingAddress: { address1: app.addressLine1, city: app.city, zip: app.postalCode, countryCode: app.countryCode },
      },
      contactInput: {
        firstName: app.contactFirstName,
        lastName: app.contactLastName,
        email: app.contactEmail,
        role: "LOCATION_ADMIN",
      },
      paymentTermsTemplate: assignedPaymentTerms,
      catalogHandle: "b2b-tier-1-wholesale",
    },
  };
}
Why this function gates payment terms by verified fiscal tenure rather than requested limits: on an open wholesale registration form, applicants routinely request €50,000 credit limits using newly minted business registrations. By decoupling the requested amount from the granted amount, our engine calculates risk strictly on verifiable external signals—requiring five years of clean business tenure and zero VIES name deviation before granting Net 30 terms. If the external VIES service suffers temporary downtime, the function refuses to throw an unhandled exception; it downgrades the application to MANUAL_REVIEW with Due on Receipt terms, protecting cash flow without blocking the applicant's purchasing account.

Atomic GraphQL orchestration: companyCreate to catalogContextUpdate

Once an application is approved, the backend executes four sequential mutations: companyCreate, companyContactCreate, catalogContextUpdate, and companyLocationAssignPaymentTerms.

The entire sequence executes in under 1,200ms, immediately triggering a New Customer Accounts passwordless single sign-on email.

Platform boundaries: B2B role limits, New Customer Accounts, and audit logging

Native Shopify B2B strictly requires New Customer Accounts; Classic Customer Accounts will not render B2B company switching or wholesale checkout surfaces.

Shopify supports two native roles per location: LOCATION_ADMIN and ORDERING_ONLY. Custom granular permissions require custom Checkout Validation Functions.

When this needs an engineer

You do not need a custom build for two wholesale accounts a month. You need an engineer when seasonal order spikes flood your sales desk with international applications, when EU VIES compliance must be automated to stop tax liabilities, and when credit terms must be algorithmically gated by tenure.

Review our work in B2B operations engineering, explore the Weldaad Supplier Portal case study, or inspect our Shopify B2B development services.

Insights

Apply this to your store.

An audit turns the general principle into a specific list of changes, ordered by what actually pays back.