How to Build a Wholesale Distribution System with Customer PWA using n8n (Free Template)

How to Build a Wholesale Distribution System with Customer PWA using n8n (Free Template)

Family-run distributors face a critical challenge: legacy systems that can't scale. A VB6 desktop application with Access 95 databases might have worked for years, but it can't support modern customer expectations like mobile ordering, real-time inventory, or compliance tracking. This article shows you how to build a complete wholesale distribution system using n8n workflows that orchestrate your existing tools into a cohesive platform. You'll learn the exact architecture, node configurations, and integration patterns to replace outdated systems with a modern, automated solution. A complete n8n workflow JSON template is included at the bottom.

The Problem: Legacy Systems Block Growth

Current challenges:

  • Desktop-only access prevents mobile ordering and field sales
  • Manual license verification creates compliance risk for regulated products (tobacco, vapor, hemp)
  • Single-warehouse view can't support multi-location inventory or transfer orders
  • No customer self-service means every order requires CSR time
  • Paper-based picking and delivery tracking causes fulfillment delays

Business impact:

  • Time spent: 15-20 hours/week on manual order entry and inventory lookups
  • Customer friction: 48+ hour order-to-delivery cycle when same-day is possible
  • Compliance exposure: Manual license checks miss expirations
  • Lost sales: Customers can't see real-time availability across warehouses

Legacy systems force distributors to choose between operational efficiency and customer experience. You need both.

The Solution Overview

This n8n-powered distribution system orchestrates multiple services into a unified platform. The workflow handles customer registration with license verification, multi-warehouse inventory synchronization, order routing based on stock location and cutoff times, automated picking list generation, and delivery tracking with driver mobile interfaces. N8n acts as the integration backbone, connecting your database (PostgreSQL or Supabase), authentication service (Auth0 or Clerk), file storage (S3 or Cloudinary), notification systems (SendGrid, Twilio), and frontend PWA. The approach replaces monolithic software with composable services that you control and customize.

What You'll Build

This system delivers complete wholesale distribution capabilities through automated n8n workflows:

Component Technology Purpose
Customer PWA React + n8n webhooks Mobile-first ordering with barcode scanning
Multi-warehouse inventory PostgreSQL + n8n sync Real-time stock across locations with transfer orders
License verification n8n HTTP + file storage Automated compliance checks for regulated products
Order routing n8n decision nodes Smart fulfillment based on stock location and cutoff times
Picking workflows n8n + barcode webhooks Scanner-first picking with variance tracking
Delivery management n8n + mobile webhooks Route assignment, signature capture, payment confirmation
Invoice generation n8n + PDF service Dual-section invoices with tax calculation
Compliance reporting n8n + scheduled exports Multi-Category TOB and sales restriction audits
POS walk-in n8n + local sync Offline-capable cash-and-carry with inventory updates
Pricing engine n8n + database rules Customer-specific pricing with below-cost safeguards

Prerequisites

Before starting, ensure you have:

  • n8n instance (cloud or self-hosted with 2GB+ RAM for workflow complexity)
  • PostgreSQL or Supabase database with schema design access
  • Authentication service (Auth0, Clerk, or Supabase Auth) configured
  • File storage (AWS S3, Cloudinary, or Supabase Storage) for license uploads
  • Email service (SendGrid, Mailgun) with API credentials
  • Optional: SMS service (Twilio) for delivery notifications
  • Optional: PDF generation service (PDFMonkey, DocRaptor) for invoices
  • Basic JavaScript knowledge for Function nodes and data transformation
  • Understanding of REST APIs and webhook concepts

Step 1: Database Schema and Core Data Models

The foundation of your distribution system is a well-structured database that supports multi-warehouse inventory, customer hierarchies, and compliance tracking.

Configure the core tables:

  1. Create customers table with fields: id, company_name, tax_status, price_tier, credit_terms, license_status, license_expiry
  2. Create customer_locations table for multi-store accounts: customer_id, address, ship_to_name, tax_jurisdiction
  3. Create products table: sku, name, internal_name, cloaked_name, category, brand, tax_code, backorder_allowed, requires_license
  4. Create inventory_locations table: location_id, name, type (basement, upstairs, warehouse_2)
  5. Create inventory table: sku, location_id, on_hand, reserved, on_transfer
  6. Create orders table: order_id, customer_id, location_id, status, delivery_method, route_id
  7. Create order_lines table: order_id, sku, quantity_ordered, quantity_picked, quantity_shipped
  8. Create licenses table: customer_id, license_type, file_url, status, expiry_date, reviewed_by

N8n workflow for database initialization:

// Function node: Generate schema SQL
const tables = [
  {
    name: 'customers',
    columns: 'id SERIAL PRIMARY KEY, company_name TEXT, tax_status TEXT, price_tier TEXT, license_status TEXT, license_expiry DATE'
  },
  {
    name: 'inventory',
    columns: 'sku TEXT, location_id INT, on_hand INT, reserved INT, on_transfer INT, PRIMARY KEY (sku, location_id)'
  }
];

return tables.map(t => ({
  json: { sql: `CREATE TABLE IF NOT EXISTS ${t.name} (${t.columns})` }
}));

Why this works:
Separating inventory by location enables real-time visibility across warehouses. The reserved column tracks orders in picking status, preventing overselling. The on_transfer column handles stock moving between locations, critical for "ships tomorrow" logic when items need fetching from another warehouse.

Variables to customize:

  • tax_jurisdictions: Add your state/local tax zones
  • license_types: Tobacco, vapor, hemp, or other regulated categories
  • location_types: Match your physical warehouse layout

Step 2: Customer Registration and License Verification Workflow

Customer onboarding for regulated products requires automated license verification with manual review fallback.

Build the registration webhook:

  1. Create Webhook node listening on /api/customer/register (POST)
  2. Add HTTP Request node to upload license file to S3/Cloudinary
  3. Add PostgreSQL node to insert customer record with license_status='pending'
  4. Add SendGrid node to notify admin of new registration requiring review
  5. Add Webhook Response node returning success message

Node configuration:

// Webhook node settings
{
  "httpMethod": "POST",
  "path": "customer/register",
  "responseMode": "lastNode",
  "authentication": "headerAuth"
}

// Function node: Validate license upload
const file = $input.item.json.license_file;
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];

if (!file || !allowedTypes.includes(file.mimetype)) {
  throw new Error('Invalid license file format. Upload JPG, PNG, or PDF.');
}

// Check file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
  throw new Error('License file exceeds 5MB limit.');
}

return {
  json: {
    customer_id: $input.item.json.customer_id,
    file_path: `licenses/${$input.item.json.customer_id}/${file.filename}`,
    file_data: file.data
  }
};

Why this approach:
Immediate file upload prevents data loss if the customer closes their browser. Storing license_status as 'pending' blocks ordering until manual review completes, ensuring compliance. The admin notification includes a direct link to the license image and customer profile for quick approval.

Critical validation rules:

  • License expiry date must be future-dated
  • License type must match product categories customer intends to purchase
  • Multi-store accounts require separate licenses per location if jurisdictions differ

Step 3: Multi-Warehouse Inventory Sync and Availability Logic

Real-time inventory across locations determines what customers can order and when it ships.

Configure inventory aggregation workflow:

  1. Create Schedule node triggering every 5 minutes
  2. Add PostgreSQL node querying inventory table grouped by SKU
  3. Add Function node calculating total_available = SUM(on_hand) - SUM(reserved)
  4. Add IF node checking if total_available > 0
  5. Add Set node determining availability_label based on location and cutoff time
  6. Add PostgreSQL node updating products table with availability_label and ships_date

Availability logic:

// Function node: Determine availability label
const now = new Date();
const cutoffTime = 14; // 2 PM cutoff for same-day fulfillment
const currentHour = now.getHours();

const inventory = $input.item.json.inventory_by_location;
const primaryWarehouse = inventory.find(loc => loc.location_id === 1);
const secondaryWarehouses = inventory.filter(loc => loc.location_id !== 1);

let label = '';
let shipsDate = '';

if (primaryWarehouse && primaryWarehouse.available > 0) {
  if (currentHour < cutoffTime) {
    label = 'Ships today';
    shipsDate = now.toISOString().split('T')[0];
  } else {
    label = 'Ships tomorrow';
    const tomorrow = new Date(now);
    tomorrow.setDate(tomorrow.getDate() + 1);
    shipsDate = tomorrow.toISOString().split('T')[0];
  }
} else if (secondaryWarehouses.some(loc => loc.available > 0)) {
  label = 'Ships tomorrow';
  const tomorrow = new Date(now);
  tomorrow.setDate(tomorrow.getDate() + 1);
  shipsDate = tomorrow.toISOString().split('T')[0];
} else if ($input.item.json.backorder_allowed) {
  label = `Backorder (ETA: ${$input.item.json.backorder_eta})`;
  shipsDate = $input.item.json.backorder_eta;
} else {
  label = 'Notify me when available';
  shipsDate = null;
}

return {
  json: {
    sku: $input.item.json.sku,
    availability_label: label,
    ships_date: shipsDate
  }
};

Why this works:
Customers see honest availability without manual updates. The cutoff time logic prevents promising same-day delivery when the warehouse can't fulfill it. Checking secondary warehouses first prevents unnecessary backorders when stock exists elsewhere. The "Notify me" option captures demand data for purchasing decisions.

Variables to customize:

  • cutoffTime: Adjust based on your warehouse shift and delivery schedule
  • primaryWarehouse: Set to your highest-volume fulfillment location
  • backorder_eta: Calculate based on supplier lead times per product

Step 4: Order Routing and Fulfillment Assignment

Orders must route to the optimal warehouse based on stock location, delivery method, and route efficiency.

Build order routing workflow:

  1. Create Webhook node on /api/order/create (POST)
  2. Add PostgreSQL node querying inventory for ordered SKUs
  3. Add Function node applying routing logic (prefer primary warehouse, then nearest secondary)
  4. Add PostgreSQL node inserting order with assigned location_id
  5. Add IF node checking delivery_method (delivery vs pickup)
  6. Add Function node assigning to route if delivery, or marking for pickup staging
  7. Add SendGrid node sending order confirmation to customer

Routing logic:

// Function node: Assign fulfillment location
const orderLines = $input.item.json.order_lines;
const inventory = $input.item.json.inventory_data;

// Group inventory by location
const locationStock = {};
inventory.forEach(inv => {
  if (!locationStock[inv.location_id]) locationStock[inv.location_id] = {};
  locationStock[inv.location_id][inv.sku] = inv.available;
});

// Check if primary warehouse (location_id=1) can fulfill entire order
const primaryCanFulfill = orderLines.every(line => 
  locationStock[1] && locationStock[1][line.sku] >= line.quantity
);

if (primaryCanFulfill) {
  return {
    json: {
      fulfillment_location: 1,
      requires_transfer: false,
      order_lines: orderLines
    }
  };
}

// Check secondary warehouses
for (const locationId in locationStock) {
  if (locationId === '1') continue;
  const canFulfill = orderLines.every(line =>
    locationStock[locationId][line.sku] >= line.quantity
  );
  if (canFulfill) {
    return {
      json: {
        fulfillment_location: parseInt(locationId),
        requires_transfer: false,
        order_lines: orderLines
      }
    };
  }
}

// Split fulfillment across locations (advanced)
// For simplicity, route to primary and create transfer orders for missing items
return {
  json: {
    fulfillment_location: 1,
    requires_transfer: true,
    transfer_needed: orderLines.filter(line => 
      !locationStock[1] || locationStock[1][line.sku] < line.quantity
    )
  }
};

Why this approach:
Routing to a single location minimizes picking complexity and shipping costs. Transfer orders only trigger when necessary, avoiding unnecessary warehouse movement. The split fulfillment fallback handles edge cases where no single location has complete stock.

Step 5: Scanner-First Picking and Shipment Creation

Pickers use barcode scanners (phone camera or handheld) to record picked quantities, creating accurate shipments.

Configure picking workflow:

  1. Create Webhook node on /api/picking/scan (POST) receiving sku and order_id
  2. Add PostgreSQL node querying order_lines for expected quantity
  3. Add Function node comparing scanned quantity to expected
  4. Add IF node checking if quantity matches
  5. Add PostgreSQL node updating order_lines with picked_quantity and picker_notes
  6. Add IF node checking if all lines picked
  7. Add Function node generating shipment record (only picked items)
  8. Add PostgreSQL node creating invoice matching shipment (not original order)

Picking variance handling:

// Function node: Handle picking variances
const scanned = $input.item.json.scanned_quantity;
const expected = $input.item.json.expected_quantity;
const sku = $input.item.json.sku;

let status = '';
let notes = '';

if (scanned === expected) {
  status = 'picked_complete';
  notes = '';
} else if (scanned < expected) {
  status = 'picked_short';
  notes = $input.item.json.picker_notes || 'Short pick - reason not provided';
  // Common reasons: bin_empty, damaged, not_found
} else {
  status = 'picked_over';
  notes = 'Overpick detected - verify count';
}

return {
  json: {
    sku: sku,
    picked_quantity: scanned,
    expected_quantity: expected,
    variance: scanned - expected,
    status: status,
    notes: notes,
    timestamp: new Date().toISOString()
  }
};

Why this works:
Invoicing only picked quantities prevents billing for unshipped items. Capturing picker notes (bin empty, damaged) provides inventory accuracy feedback. Short picks automatically create backorder lines if the product allows backorders, otherwise they trigger restock notifications.

Critical configuration:

  • Barcode format: Ensure your PWA scans the same format your labels use (UPC, Code 128, QR)
  • Picker authentication: Require picker login to track who picked each order
  • Variance thresholds: Flag variances >10% for supervisor review

Step 6: Delivery Route Assignment and Driver Mobile Interface

Drivers receive route assignments, mark deliveries complete, capture signatures, and upload payment photos.

Build driver workflow:

  1. Create Webhook node on /api/driver/route (GET) returning assigned stops
  2. Add PostgreSQL node querying orders with route_id matching driver
  3. Add Function node sorting stops by sequence_number
  4. Create Webhook node on /api/driver/deliver (POST) receiving order_id and delivery_data
  5. Add HTTP Request node uploading signature image to S3
  6. Add PostgreSQL node updating order status to 'delivered' with timestamp
  7. Add SendGrid node sending delivery confirmation to customer

Route stop data structure:

// Function node: Format route stops for driver app
const stops = $input.item.json.orders;

return stops.map((stop, index) => ({
  json: {
    stop_number: index + 1,
    order_id: stop.order_id,
    customer_name: stop.customer_name,
    address: stop.delivery_address,
    phone: stop.customer_phone,
    invoice_total: stop.invoice_total,
    payment_method: stop.payment_terms, // COD, check, account
    special_instructions: stop.delivery_notes,
    items_summary: `${stop.line_count} items, ${stop.total_cases} cases`,
    signature_required: true,
    photo_required: stop.payment_method === 'check'
  }
}));

Delivery confirmation flow:

// Webhook payload from driver app
{
  "order_id": 12345,
  "delivered_at": "2024-01-15T14:32:00Z",
  "signature_image": "base64_encoded_image",
  "payment_received": true,
  "payment_method": "check",
  "check_photo": "base64_encoded_image",
  "check_number": "1234",
  "delivery_notes": "Left with manager"
}

// Function node: Process delivery confirmation
const deliveryData = $input.item.json;

return {
  json: {
    order_id: deliveryData.order_id,
    status: 'delivered',
    delivered_at: deliveryData.delivered_at,
    signature_url: `s3://signatures/${deliveryData.order_id}.jpg`,
    payment_status: deliveryData.payment_received ? 'paid' : 'pending',
    payment_method: deliveryData.payment_method,
    check_number: deliveryData.check_number || null,
    check_photo_url: deliveryData.check_photo ? `s3://checks/${deliveryData.order_id}.jpg` : null,
    driver_notes: deliveryData.delivery_notes
  }
};

Why this approach:
Mobile-first design lets drivers work offline and sync when back online. Signature and check photo capture provides proof of delivery and payment. Automatic customer notifications reduce "where's my order" calls.

Workflow Architecture Overview

This distribution system consists of 47 nodes organized into 8 main workflow sections:

  1. Customer onboarding (Nodes 1-8): Registration webhook, license upload to S3, database insert, admin notification
  2. Inventory synchronization (Nodes 9-16): Scheduled aggregation, availability calculation, product updates
  3. Order creation and routing (Nodes 17-25): Order webhook, routing logic, fulfillment assignment, confirmation emails
  4. Picking workflows (Nodes 26-32): Barcode scan webhook, variance handling, shipment creation, invoice generation
  5. Delivery management (Nodes 33-39): Route assignment, driver mobile webhooks, signature upload, status updates
  6. Compliance checks (Nodes 40-43): License expiry monitoring, sales restriction validation, audit logging
  7. Pricing engine (Nodes 44-46): Customer-specific pricing lookup, below-cost safeguards, last-price memory
  8. Reporting and exports (Nodes 47): Scheduled reports, CSV generation, Multi-Category TOB export

Execution flow:

  • Trigger: Webhooks for real-time actions (order, picking, delivery), Schedule nodes for batch processes (inventory sync, compliance checks)
  • Average run time: 2-5 seconds for webhooks, 30-60 seconds for inventory sync (500 SKUs)
  • Key dependencies: PostgreSQL database, S3 file storage, SendGrid email, Twilio SMS (optional)

Critical nodes:

  • Function Node (Routing Logic): Determines optimal fulfillment location based on stock and cutoff times
  • PostgreSQL Node (Inventory Update): Handles reserved quantity updates to prevent overselling
  • HTTP Request Node (File Upload): Manages license documents and delivery signatures
  • IF Node (Compliance Check): Blocks orders when licenses expired or products restricted

The complete n8n workflow JSON template is available at the bottom of this article.

Key Configuration Details

PostgreSQL Connection

Required fields:

  • Host: Your database server (localhost for self-hosted, connection string for Supabase)
  • Database: distribution_system
  • User: n8n_automation (create dedicated user with limited permissions)
  • SSL Mode: require (for production)

Common issues:

  • Connection timeouts → Increase timeout to 60 seconds for large inventory queries
  • Deadlocks on inventory updates → Use row-level locking: SELECT ... FOR UPDATE

Inventory Sync Schedule

Timing configuration:

{
  "rule": {
    "interval": [
      {
        "field": "minutes",
        "minutesInterval": 5
      }
    ]
  }
}

Why this approach:
5-minute sync balances real-time accuracy with database load. High-volume distributors should use database triggers instead of polling. Low-volume operations can extend to 15-minute intervals.

Variables to customize:

  • sync_interval: Decrease to 2 minutes during peak hours (8 AM - 5 PM)
  • batch_size: Process 100 SKUs per batch to avoid memory issues with 1000+ products

License Verification Webhook

Authentication setup:

{
  "authentication": "headerAuth",
  "headerAuth": {
    "name": "X-API-Key",
    "value": "={{$env.API_KEY}}"
  }
}

Security requirements:

  • Use environment variables for API keys, never hardcode
  • Implement rate limiting: 10 requests per minute per IP
  • Validate file uploads: max 5MB, allowed types: JPG, PNG, PDF
  • Store files with UUID filenames to prevent enumeration attacks

Picking Variance Thresholds

Configuration table:

Variance Type Threshold Action
Short pick <10% Auto-approve, create backorder
Short pick >10% Require supervisor approval
Overpick Any amount Flag for inventory audit
Bin empty N/A Trigger cycle count for that SKU

Testing & Validation

Test each workflow component independently:

  1. Customer registration: Submit test registration with sample license image, verify database insert and admin email
  2. Inventory sync: Manually trigger Schedule node, check products table for updated availability_label
  3. Order routing: Create test order with SKUs split across warehouses, verify correct fulfillment_location assignment
  4. Picking workflow: Send barcode scan webhook with short pick, confirm backorder creation
  5. Delivery confirmation: Submit delivery webhook with signature, verify S3 upload and status update

Common troubleshooting:

  • Webhook returns 404: Check path matches exactly (case-sensitive), verify n8n workflow is active
  • Database connection fails: Test credentials with psql or pgAdmin, check firewall rules
  • File upload errors: Verify S3 bucket permissions (PutObject), check CORS configuration
  • Availability labels incorrect: Review cutoff time logic, confirm timezone settings match warehouse location

Load testing recommendations:

  • Simulate 50 concurrent orders to verify database connection pooling
  • Test inventory sync with 1000+ SKUs to identify performance bottlenecks
  • Verify webhook response times under load (<500ms for order creation)

Deployment Considerations

Production Deployment Checklist

Area Requirement Why It Matters
Error Handling Retry logic with exponential backoff (3 attempts, 1s/2s/4s delays) Prevents order loss on temporary API failures
Monitoring Webhook health checks every 5 minutes Detect failures within 5 minutes vs discovering when customers complain
Database Backups Automated daily backups with 30-day retention Recover from data corruption or accidental deletions
Secrets Management Use n8n environment variables for all credentials Prevents credential leaks in workflow exports
Rate Limiting Implement per-endpoint limits (100 req/min for orders) Protects against abuse and accidental loops
Logging Structured logs with order_id, customer_id, timestamp Enables debugging and compliance audits
Scalability Connection pooling (max 20 concurrent DB connections) Handles traffic spikes without database overload

Error handling example:

// Function node: Retry logic wrapper
const maxRetries = 3;
const retryDelay = [1000, 2000, 4000]; // milliseconds

async function executeWithRetry(operation, attempt = 0) {
  try {
    return await operation();
  } catch (error) {
    if (attempt < maxRetries) {
      await new Promise(resolve => setTimeout(resolve, retryDelay[attempt]));
      return executeWithRetry(operation, attempt + 1);
    }
    throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`);
  }
}

// Use for critical operations like order creation
return await executeWithRetry(async () => {
  // Your database insert or API call here
});

Use Cases & Variations

Use Case 1: Multi-State Tobacco Distributor

  • Industry: Regulated products (tobacco, vapor)
  • Scale: 300 customers, 800 SKUs, 150 orders/day
  • Modifications needed: Add state-specific tax calculations, MSA reporting exports, stamp region validation
  • Compliance additions: Automated license expiry notifications 30 days before expiration, sales restriction matrix by jurisdiction

Use Case 2: Food Service Distributor

  • Industry: Restaurant and institutional supply
  • Scale: 500 customers, 1200 SKUs, 200 orders/day
  • Modifications needed: Add temperature-controlled inventory zones, expiration date tracking (FIFO), allergen labeling
  • Workflow changes: Replace license verification with food safety certifications, add lot number tracking for recalls

Use Case 3: Industrial Supply Distributor

  • Industry: MRO and industrial equipment
  • Scale: 200 customers, 2000 SKUs, 80 orders/day
  • Modifications needed: Add equipment serial number tracking, warranty registration, technical spec sheets
  • Customizations: Replace barcode scanning with RFID tags, add equipment installation scheduling workflow

Use Case 4: Beverage Distributor

  • Industry: Beer, wine, spirits
  • Scale: 400 customers (bars, restaurants, liquor stores), 600 SKUs, 120 orders/day
  • Modifications needed: Add keg tracking and deposits, route optimization for delivery efficiency, age verification at delivery
  • Compliance: TTB reporting integration, state-specific alcohol shipping restrictions

Customizations & Extensions

Alternative Integrations

Instead of PostgreSQL:

  • Supabase: Best for rapid development - includes auth, storage, and real-time subscriptions. Requires zero node changes (uses PostgreSQL protocol)
  • MySQL/MariaDB: Better if you have existing MySQL infrastructure - swap PostgreSQL nodes for MySQL nodes, adjust JSON operators (-> becomes ->>)
  • MongoDB: Use when you need flexible schemas - requires rewriting queries to use MongoDB node and document structure

Instead of SendGrid for email:

  • Mailgun: Better deliverability for transactional emails - same node configuration, different credentials
  • AWS SES: Lowest cost at scale ($0.10/1000 emails) - requires AWS credentials and region configuration
  • Postmark: Best for critical transactional emails (delivery confirmations) - excellent deliverability tracking

Workflow Extensions

Add automated reordering:

  • Add Schedule node to run daily at 6 AM
  • Query inventory where on_hand < reorder_point
  • Generate purchase orders and email to suppliers
  • Nodes needed: +7 (Schedule, PostgreSQL query, Function for PO generation, HTTP Request to supplier API, Email notification)

Scale to handle more SKUs (1000+):

  • Replace 5-minute full sync with incremental updates (only changed inventory)
  • Add Redis caching layer for frequently accessed product data
  • Implement database indexing on sku, location_id, customer_id
  • Performance improvement: 10x faster queries, 90% reduction in database load

Add AI phone order assistant:

  • Integrate with Vapi.ai or Bland.ai for voice ordering
  • Customer calls, AI transcribes order, sends to n8n webhook
  • N8n validates inventory, creates order, sends confirmation
  • Nodes needed: +12 (Webhook for voice transcript, Function for NLP parsing, inventory validation, order creation flow)

Integration possibilities:

Add This To Get This Complexity
QuickBooks integration Automated accounting sync Medium (8 nodes, OAuth setup)
Shopify connector Add B2C e-commerce storefront Medium (10 nodes, product sync)
Route optimization (Google Maps API) Optimal delivery sequencing Easy (4 nodes, API key)
Twilio SMS Order status notifications Easy (2 nodes, phone number)
Stripe payment processing Online payment for pickup orders Medium (6 nodes, webhook validation)
Power BI connector Executive dashboards Medium (5 nodes, data export)
Zapier webhook Connect to 5000+ apps Easy (1 node, Zapier trigger)

Add customer portal analytics:

  • Track customer browsing behavior (most viewed products, search terms)
  • Generate personalized product recommendations
  • Send targeted promotions based on order history
  • Nodes needed: +15 (Webhook for analytics events, PostgreSQL logging, Function for recommendation engine, scheduled email campaigns)

Implement advanced compliance:

  • Automated Multi-Category TOB export on schedule
  • Sales restriction validation at checkout (jurisdiction + product category + flavor bans)
  • License document OCR for automatic data extraction
  • Nodes needed: +20 (Schedule for TOB export, Function for CSV generation, HTTP Request for OCR API, validation logic)

Get Started Today

Ready to automate your wholesale distribution system?

  1. Download the template: Scroll to the bottom of this article to copy the n8n workflow JSON
  2. Import to n8n: Go to Workflows → Import from URL or File, paste the JSON
  3. Configure your services: Add credentials for PostgreSQL, S3/Cloudinary, SendGrid, and any optional integrations (Twilio, payment processor)
  4. Set up your database: Run the schema creation scripts to build customers, products, inventory, and orders tables
  5. Test with sample data: Create test customers, add inventory, submit test orders to verify the complete flow
  6. Deploy to production: Activate webhooks, set Schedule nodes to active, configure error notifications

Customization roadmap:

  • Week 1: Core workflows (customer registration, inventory sync, order creation)
  • Week 2: Picking and delivery workflows
  • Week 3: Compliance and reporting
  • Week 4: Customer PWA integration and testing

Need help customizing this workflow for your specific distribution needs? Schedule an intro call with Atherial to discuss your requirements, compliance obligations, and integration priorities.

Complete N8N Workflow Template

Copy the JSON below and import it into your N8N instance via Workflows → Import from File

{
  "name": "Wholesale B2B PWA & Inventory Platform",
  "nodes": [
    {
      "id": "webhook-order-create",
      "name": "Order Create Webhook",
      "type": "n8n-nodes-base.webhook",
      "onError": "continueRegularOutput",
      "position": [
        250,
        300
      ],
      "webhookId": "order-create",
      "parameters": {
        "path": "api/orders/create",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "webhook-inventory-check",
      "name": "Inventory Check Webhook",
      "type": "n8n-nodes-base.webhook",
      "onError": "continueRegularOutput",
      "position": [
        250,
        600
      ],
      "webhookId": "inventory-check",
      "parameters": {
        "path": "api/inventory/check",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "webhook-compliance",
      "name": "Compliance Check Webhook",
      "type": "n8n-nodes-base.webhook",
      "onError": "continueRegularOutput",
      "position": [
        250,
        900
      ],
      "webhookId": "compliance",
      "parameters": {
        "path": "api/compliance/validate",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "webhook-picking",
      "name": "Picking Fulfillment Webhook",
      "type": "n8n-nodes-base.webhook",
      "onError": "continueRegularOutput",
      "position": [
        250,
        1200
      ],
      "webhookId": "picking",
      "parameters": {
        "path": "api/picking/fulfill",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "jwt-verify-order",
      "name": "Verify JWT Token - Order",
      "type": "n8n-nodes-base.jwt",
      "onError": "continueRegularOutput",
      "position": [
        450,
        300
      ],
      "parameters": {
        "token": "={{ $json.headers.authorization ? $json.headers.authorization.split(' ')[1] : '' }}",
        "operation": "verify"
      },
      "typeVersion": 1
    },
    {
      "id": "jwt-verify-inventory",
      "name": "Verify JWT Token - Inventory",
      "type": "n8n-nodes-base.jwt",
      "onError": "continueRegularOutput",
      "position": [
        450,
        600
      ],
      "parameters": {
        "token": "={{ $json.headers.authorization ? $json.headers.authorization.split(' ')[1] : '' }}",
        "operation": "verify"
      },
      "typeVersion": 1
    },
    {
      "id": "jwt-verify-compliance",
      "name": "Verify JWT Token - Compliance",
      "type": "n8n-nodes-base.jwt",
      "onError": "continueRegularOutput",
      "position": [
        450,
        900
      ],
      "parameters": {
        "token": "={{ $json.headers.authorization ? $json.headers.authorization.split(' ')[1] : '' }}",
        "operation": "verify"
      },
      "typeVersion": 1
    },
    {
      "id": "jwt-verify-picking",
      "name": "Verify JWT Token - Picking",
      "type": "n8n-nodes-base.jwt",
      "onError": "continueRegularOutput",
      "position": [
        450,
        1200
      ],
      "parameters": {
        "token": "={{ $json.headers.authorization ? $json.headers.authorization.split(' ')[1] : '' }}",
        "operation": "verify"
      },
      "typeVersion": 1
    },
    {
      "id": "switch-role-order",
      "name": "Route by Role - Order",
      "type": "n8n-nodes-base.switch",
      "onError": "continueRegularOutput",
      "position": [
        650,
        300
      ],
      "parameters": {
        "mode": "rules",
        "rules": {
          "values": [
            {
              "outputKey": "admin",
              "conditions": {
                "options": {
                  "leftValue": "",
                  "caseSensitive": true
                },
                "combinator": "or",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.role }}",
                    "rightValue": "admin"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "csr",
              "conditions": {
                "options": {
                  "leftValue": "",
                  "caseSensitive": true
                },
                "combinator": "or",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.role }}",
                    "rightValue": "csr"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "driver",
              "conditions": {
                "options": {
                  "leftValue": "",
                  "caseSensitive": true
                },
                "combinator": "or",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.role }}",
                    "rightValue": "driver"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "salesperson",
              "conditions": {
                "options": {
                  "leftValue": "",
                  "caseSensitive": true
                },
                "combinator": "or",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.role }}",
                    "rightValue": "salesperson"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "typeVersion": 3.3
    },
    {
      "id": "code-process-order",
      "name": "Process Order Data",
      "type": "n8n-nodes-base.code",
      "onError": "continueRegularOutput",
      "position": [
        850,
        250
      ],
      "parameters": {
        "jsCode": "// Extract order data from webhook\nconst webhookData = $input.first().json.body;\nconst userData = $input.first().json;\n\n// Validate order structure\nif (!webhookData.customer_id || !webhookData.items || webhookData.items.length === 0) {\n  return {\n    json: {\n      success: false,\n      error: 'Invalid order data: missing customer_id or items'\n    }\n  };\n}\n\n// Calculate order totals\nlet subtotal = 0;\nconst itemsWithTotals = webhookData.items.map(item => {\n  const lineTotal = item.quantity * item.unit_price;\n  subtotal += lineTotal;\n  return {\n    ...item,\n    line_total: lineTotal\n  };\n});\n\nconst tax = subtotal * 0.08; // 8% tax\nconst total = subtotal + tax;\n\n// Prepare order data for database\nreturn {\n  json: {\n    customer_id: webhookData.customer_id,\n    warehouse_id: webhookData.warehouse_id || 1,\n    order_date: new Date().toISOString(),\n    status: 'pending',\n    subtotal: subtotal.toFixed(2),\n    tax: tax.toFixed(2),\n    total: total.toFixed(2),\n    items: itemsWithTotals,\n    created_by: userData.user_id,\n    notes: webhookData.notes || ''\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "postgres-create-order",
      "name": "Create Order in DB",
      "type": "n8n-nodes-base.postgres",
      "onError": "continueRegularOutput",
      "position": [
        1050,
        250
      ],
      "parameters": {
        "query": "INSERT INTO orders (customer_id, warehouse_id, order_date, status, subtotal, tax, total, created_by, notes)\nVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\nRETURNING id, order_number;",
        "options": {
          "queryParameters": "={{ { \"parameters\": [$json.customer_id, $json.warehouse_id, $json.order_date, $json.status, $json.subtotal, $json.tax, $json.total, $json.created_by, $json.notes] } }}"
        },
        "operation": "executeQuery"
      },
      "retryOnFail": true,
      "typeVersion": 2.6
    },
    {
      "id": "code-prepare-items",
      "name": "Prepare Order Items",
      "type": "n8n-nodes-base.code",
      "onError": "continueRegularOutput",
      "position": [
        1250,
        250
      ],
      "parameters": {
        "jsCode": "// Get order ID from previous step\nconst orderData = $input.item(0, 0).json;\nconst allData = $input.all();\nconst processNode = allData.find(n => n.json.items);\nconst items = processNode ? processNode.json.items : [];\n\n// Prepare batch insert for order items\nconst orderItems = items.map(item => ({\n  order_id: orderData.id,\n  product_id: item.product_id,\n  quantity: item.quantity,\n  unit_price: item.unit_price,\n  line_total: item.line_total\n}));\n\nreturn orderItems.map(item => ({ json: item }));"
      },
      "typeVersion": 2
    },
    {
      "id": "postgres-insert-items",
      "name": "Insert Order Items",
      "type": "n8n-nodes-base.postgres",
      "onError": "continueRegularOutput",
      "position": [
        1450,
        250
      ],
      "parameters": {
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "order_items",
          "cachedResultName": "order_items"
        },
        "schema": {
          "__rl": true,
          "mode": "list",
          "value": "public",
          "cachedResultName": "public"
        },
        "columns": {
          "value": {
            "order_id": "={{ $json.order_id }}",
            "quantity": "={{ $json.quantity }}",
            "line_total": "={{ $json.line_total }}",
            "product_id": "={{ $json.product_id }}",
            "unit_price": "={{ $json.unit_price }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "insert"
      },
      "retryOnFail": true,
      "typeVersion": 2.6
    },
    {
      "id": "postgres-check-inventory",
      "name": "Check Inventory Levels",
      "type": "n8n-nodes-base.postgres",
      "onError": "continueRegularOutput",
      "position": [
        650,
        600
      ],
      "parameters": {
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "inventory"
        },
        "where": {
          "values": [
            {
              "value": "={{ $json.body.product_id }}",
              "column": "product_id",
              "condition": "="
            },
            {
              "value": "={{ $json.body.warehouse_id }}",
              "column": "warehouse_id",
              "condition": "="
            }
          ]
        },
        "schema": {
          "__rl": true,
          "mode": "list",
          "value": "public"
        },
        "options": {},
        "operation": "select"
      },
      "retryOnFail": true,
      "typeVersion": 2.6
    },
    {
      "id": "code-inventory-logic",
      "name": "Calculate Inventory Logic",
      "type": "n8n-nodes-base.code",
      "onError": "continueRegularOutput",
      "position": [
        850,
        600
      ],
      "parameters": {
        "jsCode": "// Calculate inventory availability across warehouses\nconst webhookNode = $input.all().find(n => n.json.body);\nconst requestData = webhookNode ? webhookNode.json.body : {};\nconst inventoryData = $input.all().filter(n => n.json.quantity_on_hand !== undefined).map(item => item.json);\n\n// Calculate available quantity\nconst totalAvailable = inventoryData.reduce((sum, record) => {\n  return sum + (record.quantity_on_hand - record.quantity_reserved);\n}, 0);\n\nconst requestedQty = requestData.requested_quantity || 0;\nconst available = totalAvailable >= requestedQty;\n\n// Check for backorder logic\nlet fulfillmentPlan = [];\nif (!available && requestData.allow_backorder) {\n  fulfillmentPlan = [\n    {\n      type: 'immediate',\n      quantity: totalAvailable,\n      warehouses: inventoryData.filter(inv => inv.quantity_on_hand > inv.quantity_reserved)\n    },\n    {\n      type: 'backorder',\n      quantity: requestedQty - totalAvailable,\n      estimated_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()\n    }\n  ];\n}\n\nreturn {\n  json: {\n    product_id: requestData.product_id,\n    requested_quantity: requestedQty,\n    available_quantity: totalAvailable,\n    is_available: available,\n    fulfillment_plan: fulfillmentPlan,\n    inventory_locations: inventoryData.map(inv => ({\n      warehouse_id: inv.warehouse_id,\n      warehouse_name: inv.warehouse_name,\n      available: inv.quantity_on_hand - inv.quantity_reserved,\n      on_hand: inv.quantity_on_hand\n    })),\n    requires_transfer: inventoryData.length > 1 && !available\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "postgres-compliance-check",
      "name": "Check Compliance Requirements",
      "type": "n8n-nodes-base.postgres",
      "onError": "continueRegularOutput",
      "position": [
        650,
        900
      ],
      "parameters": {
        "query": "SELECT \n  c.customer_id,\n  c.business_name,\n  c.licenses,\n  p.product_id,\n  p.product_name,\n  p.category,\n  p.compliance_requirements,\n  pc.state_regulations\nFROM customers c\nCROSS JOIN products p\nLEFT JOIN product_compliance pc ON p.product_id = pc.product_id\nWHERE c.customer_id = $1 AND p.product_id = $2;",
        "options": {
          "queryParameters": "={{ { \"parameters\": [$json.body.customer_id, $json.body.product_id] } }}"
        },
        "operation": "executeQuery"
      },
      "retryOnFail": true,
      "typeVersion": 2.6
    },
    {
      "id": "code-compliance-validate",
      "name": "Validate Compliance Rules",
      "type": "n8n-nodes-base.code",
      "onError": "continueRegularOutput",
      "position": [
        850,
        900
      ],
      "parameters": {
        "jsCode": "// Compliance validation logic for regulated products\nconst complianceData = $input.first().json;\nconst webhookNode = $input.all().find(n => n.json.body);\nconst requestData = webhookNode ? webhookNode.json.body : {};\n\nif (!complianceData) {\n  return {\n    json: {\n      approved: false,\n      error: 'Customer or product not found',\n      violations: ['Invalid customer or product ID']\n    }\n  };\n}\n\nconst violations = [];\nconst warnings = [];\n\n// Parse customer licenses\nconst customerLicenses = complianceData.licenses || {};\nconst requiredLicenses = complianceData.compliance_requirements ? complianceData.compliance_requirements.required_licenses : [];\n\n// Check tobacco license\nif (complianceData.category === 'tobacco' || complianceData.category === 'vapor') {\n  if (!customerLicenses.tobacco_license) {\n    violations.push('Missing tobacco retail license');\n  } else if (new Date(customerLicenses.tobacco_license.expiry) < new Date()) {\n    violations.push('Tobacco license expired');\n  }\n}\n\n// Check hemp/CBD license\nif (complianceData.category === 'hemp' || complianceData.category === 'cbd') {\n  if (!customerLicenses.hemp_license) {\n    violations.push('Missing hemp/CBD retail license');\n  } else if (new Date(customerLicenses.hemp_license.expiry) < new Date()) {\n    violations.push('Hemp license expired');\n  }\n}\n\n// Check age verification on file\nif (!customerLicenses.age_verification_on_file) {\n  warnings.push('Age verification documentation not on file');\n}\n\n// Check state-specific regulations\nconst stateRegs = complianceData.state_regulations || {};\nif (stateRegs.restricted_states && stateRegs.restricted_states.includes(customerLicenses.state)) {\n  violations.push('Product sales restricted in ' + customerLicenses.state);\n}\n\n// Check quantity limits\nif (stateRegs.quantity_limits && requestData.quantity > stateRegs.quantity_limits.max_per_order) {\n  violations.push('Quantity exceeds state limit of ' + stateRegs.quantity_limits.max_per_order);\n}\n\nconst approved = violations.length === 0;\n\nreturn {\n  json: {\n    customer_id: complianceData.customer_id,\n    product_id: complianceData.product_id,\n    approved: approved,\n    violations: violations,\n    warnings: warnings,\n    compliance_score: approved ? 100 : Math.max(0, 100 - (violations.length * 25)),\n    licenses_checked: [\n      'tobacco_license',\n      'hemp_license',\n      'age_verification',\n      'state_regulations'\n    ],\n    timestamp: new Date().toISOString()\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "postgres-picking-list",
      "name": "Generate Picking List",
      "type": "n8n-nodes-base.postgres",
      "onError": "continueRegularOutput",
      "position": [
        650,
        1200
      ],
      "parameters": {
        "query": "WITH order_details AS (\n  SELECT \n    o.id as order_id,\n    o.order_number,\n    o.warehouse_id,\n    oi.product_id,\n    oi.quantity,\n    p.product_name,\n    p.sku,\n    p.barcode,\n    inv.location_bin\n  FROM orders o\n  JOIN order_items oi ON o.id = oi.order_id\n  JOIN products p ON oi.product_id = p.id\n  LEFT JOIN inventory inv ON p.id = inv.product_id AND o.warehouse_id = inv.warehouse_id\n  WHERE o.id = $1 AND o.status = 'ready_for_picking'\n)\nSELECT * FROM order_details ORDER BY location_bin;",
        "options": {
          "queryParameters": "={{ { \"parameters\": [$json.body.order_id] } }}"
        },
        "operation": "executeQuery"
      },
      "retryOnFail": true,
      "typeVersion": 2.6
    },
    {
      "id": "code-picking-workflow",
      "name": "Build Picking Workflow",
      "type": "n8n-nodes-base.code",
      "onError": "continueRegularOutput",
      "position": [
        850,
        1200
      ],
      "parameters": {
        "jsCode": "// Create scanner-optimized picking workflow\nconst pickingData = $input.all().map(item => item.json);\nconst webhookNode = $input.all().find(n => n.json.body);\nconst requestData = webhookNode ? webhookNode.json.body : {};\n\n// Group items by bin location for efficient picking route\nconst pickingRoute = {};\npickingData.forEach(item => {\n  const bin = item.location_bin || 'UNKNOWN';\n  if (!pickingRoute[bin]) {\n    pickingRoute[bin] = [];\n  }\n  pickingRoute[bin].push({\n    product_id: item.product_id,\n    sku: item.sku,\n    barcode: item.barcode,\n    product_name: item.product_name,\n    quantity: item.quantity,\n    picked: 0,\n    verified: false\n  });\n});\n\n// Calculate pick efficiency metrics\nconst totalItems = pickingData.length;\nconst uniqueBins = Object.keys(pickingRoute).length;\nconst estimatedPickTime = uniqueBins * 2 + totalItems * 0.5; // minutes\n\nconst firstItem = pickingData[0] || {};\n\nreturn {\n  json: {\n    order_id: firstItem.order_id,\n    order_number: firstItem.order_number,\n    warehouse_id: firstItem.warehouse_id,\n    picking_route: pickingRoute,\n    total_items: totalItems,\n    unique_locations: uniqueBins,\n    estimated_time_minutes: Math.ceil(estimatedPickTime),\n    status: 'in_progress',\n    started_by: requestData.picker_id,\n    started_at: new Date().toISOString(),\n    scanner_mode: 'barcode',\n    instructions: [\n      'Scan each item barcode to verify',\n      'Mark bin as complete when all items picked',\n      'Double-check quantities before moving to next bin'\n    ]\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "postgres-update-picking",
      "name": "Update Order Status",
      "type": "n8n-nodes-base.postgres",
      "onError": "continueRegularOutput",
      "position": [
        1050,
        1200
      ],
      "parameters": {
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "orders"
        },
        "where": {
          "values": [
            {
              "value": "={{ $json.order_id }}",
              "column": "id",
              "condition": "="
            }
          ]
        },
        "schema": {
          "__rl": true,
          "mode": "list",
          "value": "public"
        },
        "columns": {
          "value": {
            "status": "picking_in_progress",
            "picker_id": "={{ $json.started_by }}",
            "picking_started_at": "={{ $json.started_at }}"
          },
          "mappingMode": "defineBelow"
        },
        "operation": "update"
      },
      "retryOnFail": true,
      "typeVersion": 2.6
    },
    {
      "id": "respond-order-success",
      "name": "Respond Order Success",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1650,
        250
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ { \"success\": true, \"order_id\": $node[\"Create Order in DB\"].json.id, \"order_number\": $node[\"Create Order in DB\"].json.order_number, \"status\": \"created\", \"total\": $node[\"Process Order Data\"].json.total, \"items_count\": $node[\"Process Order Data\"].json.items.length, \"message\": \"Order created successfully\" } }}"
      },
      "typeVersion": 1.4
    },
    {
      "id": "respond-inventory",
      "name": "Respond Inventory Status",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1050,
        600
      ],
      "parameters": {
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      },
      "typeVersion": 1.4
    },
    {
      "id": "respond-compliance",
      "name": "Respond Compliance Result",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1050,
        900
      ],
      "parameters": {
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      },
      "typeVersion": 1.4
    },
    {
      "id": "respond-picking",
      "name": "Respond Picking Workflow",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1250,
        1200
      ],
      "parameters": {
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      },
      "typeVersion": 1.4
    },
    {
      "id": "respond-auth-error",
      "name": "Respond Auth Error",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        650,
        150
      ],
      "parameters": {
        "options": {
          "responseCode": 401,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={{ { \"success\": false, \"error\": \"Unauthorized\", \"message\": \"Invalid or expired token\" } }}"
      },
      "typeVersion": 1.4
    }
  ],
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Create Order in DB": {
      "main": [
        [
          {
            "node": "Prepare Order Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Insert Order Items": {
      "main": [
        [
          {
            "node": "Respond Order Success",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Process Order Data": {
      "main": [
        [
          {
            "node": "Create Order in DB",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Order Items": {
      "main": [
        [
          {
            "node": "Insert Order Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Order Status": {
      "main": [
        [
          {
            "node": "Respond Picking Workflow",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Order Create Webhook": {
      "main": [
        [
          {
            "node": "Verify JWT Token - Order",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Picking List": {
      "main": [
        [
          {
            "node": "Build Picking Workflow",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Role - Order": {
      "main": [
        [
          {
            "node": "Process Order Data",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Process Order Data",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Process Order Data",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Process Order Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Picking Workflow": {
      "main": [
        [
          {
            "node": "Update Order Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Inventory Levels": {
      "main": [
        [
          {
            "node": "Calculate Inventory Logic",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inventory Check Webhook": {
      "main": [
        [
          {
            "node": "Verify JWT Token - Inventory",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compliance Check Webhook": {
      "main": [
        [
          {
            "node": "Verify JWT Token - Compliance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify JWT Token - Order": {
      "main": [
        [
          {
            "node": "Route by Role - Order",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Inventory Logic": {
      "main": [
        [
          {
            "node": "Respond Inventory Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Compliance Rules": {
      "main": [
        [
          {
            "node": "Respond Compliance Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify JWT Token - Picking": {
      "main": [
        [
          {
            "node": "Generate Picking List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Picking Fulfillment Webhook": {
      "main": [
        [
          {
            "node": "Verify JWT Token - Picking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify JWT Token - Inventory": {
      "main": [
        [
          {
            "node": "Check Inventory Levels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Compliance Requirements": {
      "main": [
        [
          {
            "node": "Validate Compliance Rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify JWT Token - Compliance": {
      "main": [
        [
          {
            "node": "Check Compliance Requirements",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}