Technical Guide

Multi-Channel Commerce: The Architecture of Unified Checkout Across Five Suppliers

How to build unified checkout across multiple suppliers. Bidirectional sync, real-time availability proxy, channel scoping, order routing, and error handling when suppliers fail mid-checkout.

March 16, 202614 min readOronts Engineering Team

Multi-Channel Is Not "Connect to Amazon"

Most multi-channel commerce articles describe connecting your store to marketplace APIs. That's syndication, not architecture. Real multi-channel commerce means multiple suppliers with different APIs, different pricing models, different availability formats, and different booking flows, all presented to the customer as one seamless checkout.

We built a multi-supplier aggregation platform that unifies checkout across 5 supplier APIs. One cart, five suppliers, one order. This article covers the architecture that makes it work. For the broader commerce platform evaluation, see our Vendure production guide and ecommerce platforms guide.

The Unified Checkout Problem

Each supplier has its own API, its own data format, its own availability model, and its own booking flow:

AspectSupplier ASupplier BSupplier C
API styleREST v2GraphQLSOAP/XML
PricingPer person, dynamicFixed price, tiersPer group, negotiated
AvailabilityReal-time APIDaily sync fileWebhook on change
BookingTwo-step (reserve + confirm)One-step (instant book)Three-step (quote + reserve + confirm)
CancellationFree up to 24hNon-refundablePartial refund policy
ID formatUUIDNumericAlphanumeric prefix

A unified checkout must normalize all of this behind one interface. The customer adds items from different suppliers to one cart and checks out once. The system routes each item to the correct supplier, handles the booking flow per supplier, and presents one confirmation to the customer.

Supplier Adapter Pattern

Each supplier gets an adapter that implements a common interface:

interface SupplierAdapter {
    search(query: SearchQuery): Promise<Product[]>;
    checkAvailability(productId: string, date: string, persons: PersonConfig[]): Promise<AvailabilityResult>;
    reserve(params: ReservationParams): Promise<Reservation>;
    confirm(reservationId: string, bookerInfo: BookerInfo): Promise<BookingConfirmation>;
    cancel(bookingId: string): Promise<CancellationResult>;
}

// Each supplier implements the interface differently
class SupplierAAdapter implements SupplierAdapter {
    async checkAvailability(productId: string, date: string, persons: PersonConfig[]) {
        // Supplier A: real-time API call
        const response = await this.httpClient.get(`/v2/availability/${productId}`, {
            params: { date, adults: persons.filter(p => p.type === 'adult').length },
        });
        return this.normalizeAvailability(response.data);
    }
}

class SupplierBAdapter implements SupplierAdapter {
    async checkAvailability(productId: string, date: string, persons: PersonConfig[]) {
        // Supplier B: GraphQL query
        const { data } = await this.graphqlClient.query({
            query: AVAILABILITY_QUERY,
            variables: { productId, date },
        });
        return this.normalizeAvailability(data.availability);
    }
}

The adapter normalizes each supplier's response to a common format. The checkout service works with the common format, never with supplier-specific data structures.

Real-Time Availability Proxy

Some suppliers provide real-time pricing that changes by the minute (dynamic pricing, limited inventory). The search results show a cached price, but at checkout time the system must verify the current price.

async function getAvailabilityWithFallback(
    productId: string, date: string, persons: PersonConfig[]
): Promise<AvailabilityResult> {
    const supplier = getSupplierForProduct(productId);

    if (supplier.supportsRealTimeAvailability) {
        try {
            // Live price from supplier API
            const live = await supplier.adapter.checkAvailability(productId, date, persons);
            await cache.set(`avail:${productId}:${date}`, live, { ttl: 300 });
            return live;
        } catch (error) {
            // Supplier API down: return cached price with warning
            const cached = await cache.get(`avail:${productId}:${date}`);
            if (cached) {
                return { ...cached, stale: true, warning: 'Price may have changed' };
            }
            throw new AvailabilityError('Cannot determine availability');
        }
    }

    // Supplier with batch availability: return from index
    return searchIndex.getAvailability(productId, date);
}

The proxy pattern: try live, fall back to cache, fall back to index. Always tell the customer if the price might be stale.

Bidirectional Sync: Preventing Infinite Loops

When two systems sync data bidirectionally (commerce system and operations system), each update from system A triggers a sync to system B, which triggers a sync back to A.

// Source tracking prevents infinite loops
interface SyncMessage {
    entityId: string;
    entityType: string;
    data: any;
    source: string;           // "commerce" | "operations" | "import"
    correlationId: string;
}

async function handleSync(message: SyncMessage) {
    // Ignore messages that originated from this system
    if (message.source === THIS_SYSTEM_ID) {
        return; // ACK and skip
    }

    // Process the sync
    await updateEntity(message.entityId, message.data);

    // Publish update with OUR source tag
    await publishSync({
        ...message,
        source: THIS_SYSTEM_ID,  // Tag so the other system ignores it
    });
}

Every message carries a source field. When a system receives a message from itself (via the other system), it ignores it. The loop is broken at the first round-trip.

For more event-driven patterns including deduplication and dead letter handling, see our event-driven architecture guide.

Channel Scoping: Visibility vs Publication vs Availability

Three concepts that must be separate:

ConceptQuestionExample
VisibilityWhich suppliers can this channel see?German website sees suppliers A, B, C. Partner API sees only supplier A.
PublicationIs this product published and active?Product exists but is unpublished (draft).
AvailabilityCan this product be booked right now?Product is published but sold out for Saturday.
// Channel determines visibility
const channel = await channelStore.get(tenantId, channelId);
const visibleSupplierIds = channel.supplierIds;

// Filter products by channel visibility
const products = await searchIndex.search({
    query: userQuery,
    filters: {
        supplier_id: { $in: visibleSupplierIds },  // Channel scoping
        status: 'active',                           // Publication
    },
});

// Availability checked at checkout time (separate concern)

A product can be visible (in the channel's supplier list), published (status = active), but unavailable (sold out). Each is a different filter at a different stage of the flow.

Order Routing

When an order contains items from multiple suppliers, each item must be routed to the correct supplier for fulfillment:

async function processMultiSupplierOrder(order: Order): Promise<OrderResult> {
    // Group items by supplier
    const itemsBySupplier = groupBy(order.items, item => item.supplierId);

    // Process each supplier's items independently
    const results = await Promise.allSettled(
        Object.entries(itemsBySupplier).map(async ([supplierId, items]) => {
            const adapter = getAdapter(supplierId);

            // Reserve
            const reservation = await adapter.reserve({
                items,
                booker: order.bookerInfo,
                expiresIn: 900, // 15 min hold
            });

            // Confirm
            return adapter.confirm(reservation.id, order.bookerInfo);
        })
    );

    // Handle mixed results
    const successful = results.filter(r => r.status === 'fulfilled');
    const failed = results.filter(r => r.status === 'rejected');

    if (failed.length > 0 && successful.length > 0) {
        // Partial success: some suppliers booked, others failed
        // Cancel successful bookings? Or confirm partial order?
        // This is a business decision, not a technical one.
        return handlePartialSuccess(order, successful, failed);
    }

    return { status: failed.length === 0 ? 'COMPLETED' : 'FAILED', results };
}

The Partial Success Problem

What happens when supplier A confirms but supplier B fails? Options:

StrategyBehaviorBest For
All or nothingCancel supplier A if B failsHigh-value orders, events
Partial fulfillmentConfirm A, notify customer about BCommodity products
Retry then cancelRetry B 3 times, cancel A if B ultimately failsBalance of UX and reliability

The right strategy depends on the business. For event tickets (non-fungible), all-or-nothing is usually correct. For commodity products (replaceable), partial fulfillment is better.

Error Handling: When Suppliers Fail Mid-Checkout

Failure PointWhat HappenedResponse
During searchSupplier API timeoutShow results from other suppliers
During availability checkStale priceShow cached price with warning
During reservationInventory exhaustedRemove item from cart, suggest alternatives
During confirmationPayment declined by supplierCancel reservation, retry payment, or refund
After confirmationSupplier sends cancellationNotify customer, offer rebooking or refund
During cancellationSupplier API downQueue cancellation for retry

Every failure must be handled explicitly. "Something went wrong" is never an acceptable customer-facing error for a commerce transaction.

Common Pitfalls

  1. Assuming all suppliers have the same booking flow. Two-step, one-step, and three-step flows all exist. The adapter must normalize them.

  2. Ignoring price changes between search and checkout. Dynamic pricing means the checkout price may differ from the search price. Validate at checkout time.

  3. No source tracking on bidirectional sync. Without it, every update loops infinitely between systems.

  4. Treating channel visibility as a UI concern. Channel scoping must be enforced at the query layer, not just the frontend. API consumers see the same filters.

  5. No partial failure strategy. Multi-supplier orders will partially fail. Decide the business policy before it happens in production.

  6. Synchronous supplier calls during checkout. If one supplier is slow, the entire checkout waits. Process suppliers in parallel with individual timeouts.

Key Takeaways

  • Supplier adapters normalize diversity. Each supplier gets an adapter implementing a common interface. Business logic never touches supplier-specific formats.

  • Real-time availability is a proxy pattern. Try live, fall back to cache, fall back to index. Always communicate staleness to the customer.

  • Source tracking prevents sync loops. Every message carries its origin. Systems ignore messages from themselves.

  • Visibility, publication, and availability are three separate concerns. Channel determines visibility. Product status determines publication. Inventory determines availability. Don't conflate them.

  • Partial failure is a business decision. All-or-nothing vs partial fulfillment depends on the product type. Define the policy before building the code.

We build multi-channel commerce systems as part of our ecommerce and custom software practice. If you need help with supplier integration architecture, talk to our team or request a quote.

Topics covered

multi-channel commerceomnichannel architecturemarketplace integrationcommerce syncunified checkoutsupplier aggregationbidirectional sync

Ready to build production AI systems?

Our team specializes in building production-ready AI systems. Let's discuss how we can help transform your enterprise with cutting-edge technology.

Start a conversation