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.
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:
| Aspect | Supplier A | Supplier B | Supplier C |
|---|---|---|---|
| API style | REST v2 | GraphQL | SOAP/XML |
| Pricing | Per person, dynamic | Fixed price, tiers | Per group, negotiated |
| Availability | Real-time API | Daily sync file | Webhook on change |
| Booking | Two-step (reserve + confirm) | One-step (instant book) | Three-step (quote + reserve + confirm) |
| Cancellation | Free up to 24h | Non-refundable | Partial refund policy |
| ID format | UUID | Numeric | Alphanumeric 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:
| Concept | Question | Example |
|---|---|---|
| Visibility | Which suppliers can this channel see? | German website sees suppliers A, B, C. Partner API sees only supplier A. |
| Publication | Is this product published and active? | Product exists but is unpublished (draft). |
| Availability | Can 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:
| Strategy | Behavior | Best For |
|---|---|---|
| All or nothing | Cancel supplier A if B fails | High-value orders, events |
| Partial fulfillment | Confirm A, notify customer about B | Commodity products |
| Retry then cancel | Retry B 3 times, cancel A if B ultimately fails | Balance 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 Point | What Happened | Response |
|---|---|---|
| During search | Supplier API timeout | Show results from other suppliers |
| During availability check | Stale price | Show cached price with warning |
| During reservation | Inventory exhausted | Remove item from cart, suggest alternatives |
| During confirmation | Payment declined by supplier | Cancel reservation, retry payment, or refund |
| After confirmation | Supplier sends cancellation | Notify customer, offer rebooking or refund |
| During cancellation | Supplier API down | Queue 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
-
Assuming all suppliers have the same booking flow. Two-step, one-step, and three-step flows all exist. The adapter must normalize them.
-
Ignoring price changes between search and checkout. Dynamic pricing means the checkout price may differ from the search price. Validate at checkout time.
-
No source tracking on bidirectional sync. Without it, every update loops infinitely between systems.
-
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.
-
No partial failure strategy. Multi-supplier orders will partially fail. Decide the business policy before it happens in production.
-
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
Related Guides
Event-Driven Architecture in Practice: What Actually Goes Wrong
Real event-driven architecture patterns from production. Event storms, bidirectional sync loops, dead letters, idempotency stores, and choosing between Kafka, RabbitMQ, BullMQ, and Symfony Messenger.
Read guideEnterprise Guide to Agentic AI Systems
Technical guide to agentic AI systems in enterprise environments. Learn the architecture, capabilities, and applications of autonomous AI agents.
Read guideAgentic Commerce: How to Let AI Agents Buy Things Safely
How to design governed AI agent-initiated commerce. Policy engines, HITL approval gates, HMAC receipts, idempotency, tenant scoping, and the full Agentic Checkout Protocol.
Read guideReady 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