Engineering

Treating Physical Goods as API Endpoints: Building a Merch Fulfillment CLI

Treating Physical Goods as API Endpoints: Building a Merch Fulfillment CLI

I once spent three days manually exporting shipping labels and packing die-cut laptop stickers into manila envelopes. I even stood in line at the post office for a SaaS side project. I thought I was building a brand. I was actually just performing minimum-wage manual labor while my monthly recurring revenue stagnated.

Every developer who launches a product eventually wants physical merchandise. We want our users typing on keyboards adorned with our logos. But the logistics of physical goods are miserable. You generally face two bad options: do it yourself and burn precious engineering hours, or use a premium print-on-demand service with a beautiful user interface that charges an absurd premium per unit, effectively destroying your margins.

I remember staring at a shopping cart on a popular custom sticker website. The user experience was flawless. The die-cut previews were rendering beautifully in the browser. Then I looked at the unit economics. I was effectively paying people to market my own tool. Meanwhile, local printing shops could do the exact same job for a fraction of the cost, but their "interface" was a phone call and an email attachment.

There had to be a way to bridge this gap. We need the developer experience of a premium tech product with the unit economics of a local print shop.

The solution is to stop treating merchandise as a retail purchase and start treating it as an API endpoint. We can build a command-line interface that abstracts away the physical supply chain.

The Architecture of a Physical API

When we type something like /stickers order --qty 50 into our terminal, several things need to happen behind the scenes.

  1. Asset Validation: The CLI needs to verify the print files (usually vectors or high-res PNGs) meet the vendor's specifications.
  2. Vendor Abstraction: We need an adapter pattern to switch between the expensive, high-quality vendor (for special VIP orders) and the cheaper local vendor (for bulk conference handouts).
  3. Idempotency: If your server crashes and retries a payment API, you might get a duplicate charge you can refund. If your CLI gets stuck in a loop and hits a physical printing API repeatedly, a delivery truck will eventually dump a pallet of cardboard boxes on your driveway.
  4. Asynchronous Tracking: Physical goods take days to process. We need webhooks to update the status of our order in our database.

Let's build the core of this system using TypeScript and Node.js.

Step 1: Building the Vendor Adapter Pattern

The most critical part of this system is realizing that vendors will change. You might start with a premium international vendor because their API is well-documented, but eventually, you will want to route high-volume orders to a local shop that only accepts FTP uploads or clunky REST calls.

We start by defining a strict interface for our printing vendors.

// types/vendor.ts
 
export interface OrderRequest {
  idempotencyKey: string;
  assetUrl: string;
  quantity: number;
  shippingAddress: Address;
  type: 'die-cut' | 'kiss-cut' | 'bumper';
}
 
export interface Address {
  name: string;
  street1: string;
  street2?: string;
  city: string;
  state: string;
  zipCode: string;
  country: string;
}
 
export interface OrderResponse {
  vendorOrderId: string;
  estimatedDelivery: string;
  status: 'accepted' | 'processing' | 'rejected';
  cost: number;
}
 
export interface PrintVendor {
  name: string;
  placeOrder(request: OrderRequest): Promise<OrderResponse>;
  checkStatus(vendorOrderId: string): Promise<string>;
}

Now we can implement a specific vendor. Let's create an adapter for a hypothetical premium vendor that provides a modern JSON API.

// vendors/premiumVendor.ts
import { PrintVendor, OrderRequest, OrderResponse } from '../types/vendor';
import crypto from 'crypto';
 
export class PremiumStickerAPI implements PrintVendor {
  public name = 'Premium Vendor';
  private apiKey: string;
  private baseUrl = 'https://api.premium-stickers.example.com/v1';
 
  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }
 
  async placeOrder(request: OrderRequest): Promise<OrderResponse> {
    // Never hit a physical API without an idempotency key.
    const response = await fetch(`${this.baseUrl}/orders`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': request.idempotencyKey,
      },
      body: JSON.stringify({
        artwork_url: request.assetUrl,
        count: request.quantity,
        cut_style: request.type,
        shipping: request.shippingAddress
      })
    });
 
    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(`Vendor rejected order: ${JSON.stringify(errorData)}`);
    }
 
    const data = await response.json();
    
    return {
      vendorOrderId: data.order_id,
      estimatedDelivery: data.eta,
      status: 'accepted',
      cost: data.total_cents / 100
    };
  }
 
  async checkStatus(vendorOrderId: string): Promise<string> {
    const response = await fetch(`${this.baseUrl}/orders/${vendorOrderId}/status`, {
      headers: { 'Authorization': `Bearer ${this.apiKey}` }
    });
    const data = await response.json();
    return data.current_status;
  }
}

This abstraction saves us from vendor lock-in. If we discover a cheaper local supplier next month, we simply write a LocalVendorAPI class that implements PrintVendor and handles whatever archaic XML or SOAP payload they require, keeping our main business logic completely insulated from the mess.

Step 2: CLI Implementation with Commander.js

With our vendor logic abstracted, we need an interface. We are building this for developers, which means the terminal is our UI. We will use commander, a robust library for building Node.js command-line interfaces.

The CLI needs to parse arguments and generate a unique idempotency key based on the local timestamp and user context. Then, it invokes the vendor adapter.

// cli/index.ts
import { Command } from 'commander';
import { PremiumStickerAPI } from '../vendors/premiumVendor';
import { OrderRequest } from '../types/vendor';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
 
const program = new Command();
 
program
  .name('swag-ops')
  .description('CLI to manage physical merchandise fulfillment')
  .version('1.0.0');
 
program.command('stickers:order')
  .description('Order a new batch of stickers')
  .requiredOption('-q, --quantity <number>', 'Number of stickers to order')
  .requiredOption('-a, --asset <url>', 'URL of the print-ready artwork')
  .requiredOption('-t, --type <type>', 'Cut type (die-cut, kiss-cut)', 'die-cut')
  .option('-v, --vendor <name>', 'Vendor to use', 'premium')
  .action(async (options) => {
    console.log(`Preparing to order ${options.quantity} ${options.type} stickers...`);
 
    // In a real system, you'd load this from an encrypted config or env var
    const apiKey = process.env.VENDOR_API_KEY;
    if (!apiKey) {
      console.error('Error: VENDOR_API_KEY environment variable is missing.');
      process.exit(1);
    }
 
    // Initialize the vendor
    const vendor = new PremiumStickerAPI(apiKey);
 
    // Generate a robust idempotency key. 
    // We combine the asset URL, quantity, and the current date (YYYY-MM-DD)
    // This ensures that running the command twice in one day with the same args
    // won't result in a double order.
    const dateString = new Date().toISOString().split('T')[0];
    const baseKey = `${options.asset}-${options.quantity}-${dateString}`;
    const idempotencyKey = crypto.createHash('sha256').update(baseKey).digest('hex');
 
    // Load shipping address from a local config file for this example
    const configPath = path.join(process.cwd(), '.swagconfig.json');
    if (!fs.existsSync(configPath)) {
      console.error('Error: Missing .swagconfig.json for shipping details.');
      process.exit(1);
    }
    const address = JSON.parse(fs.readFileSync(configPath, 'utf8'));
 
    const request: OrderRequest = {
      idempotencyKey,
      assetUrl: options.asset,
      quantity: parseInt(options.quantity, 10),
      type: options.type as 'die-cut' | 'kiss-cut',
      shippingAddress: address
    };
 
    try {
      const result = await vendor.placeOrder(request);
      console.log('\n✅ Order successfully placed!');
      console.log(`Vendor ID: ${result.vendorOrderId}`);
      console.log(`Estimated Delivery: ${result.estimatedDelivery}`);
      console.log(`Total Cost: $${result.cost.toFixed(2)}`);
    } catch (error) {
      console.error('\n❌ Order failed.');
      if (error instanceof Error) {
        console.error(error.message);
      }
      process.exit(1);
    }
  });
 
program.parse();

When you run this code, you are executing physical logistics through a terminal. You bypass the web interface and the upsells. You also avoid manual address entry. You are operating in an environment you control.

Step 3: Handling the Asynchronous Reality of Physical Goods

Code executes in milliseconds. Printing presses, however, require days to operate, and delivery trucks take weeks.

When we dispatch our CLI command, the vendor returns an accepted status. This just means the data parsed correctly and they charged our card. It tells us nothing about when the physical object will exist. To bridge the digital-physical divide, our infrastructure needs a way to listen for updates. We need to implement a webhook handler.

Most modern print-on-demand APIs will hit a specified URL on your server when an order moves from printing to shipped.

Let's write a small Express server to catch these physical state changes. We must verify the signature of incoming webhooks to ensure malicious actors aren't faking shipment notifications to trick our internal accounting or trigger automated emails to users.

// server/webhooks.ts
import express from 'express';
import crypto from 'crypto';
 
const app = express();
const PORT = process.env.PORT || 3000;
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'super_secret_string';
 
// We need the raw body to verify the cryptographic signature
app.use(express.raw({ type: 'application/json' }));
 
app.post('/webhooks/vendor', (req, res) => {
  const signature = req.headers['x-vendor-signature'] as string;
  const payload = req.body;
 
  if (!signature) {
    return res.status(401).send('Missing signature');
  }
 
  // Verify the payload originated from our vendor
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');
 
  if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)) === false) {
    console.error('Invalid webhook signature detected.');
    return res.status(403).send('Invalid signature');
  }
 
  // Parse the safe payload
  const event = JSON.parse(payload.toString());
 
  switch (event.type) {
    case 'order.printing':
      console.log(`Order ${event.orderId} has hit the presses.`);
      // Update your internal database here
      break;
    case 'order.shipped':
      console.log(`Order ${event.orderId} is on the way. Tracking: ${event.trackingNumber}`);
      // Trigger an email to the end-user with their tracking link
      break;
    case 'order.returned':
      console.log(`Order ${event.orderId} bounced. Bad address: ${event.reason}`);
      // Flag user account for address correction
      break;
    default:
      console.log(`Unhandled physical event type: ${event.type}`);
  }
 
  res.status(200).send('Webhook received');
});
 
app.listen(PORT, () => {
  console.log(`Physical fulfillment listener running on port ${PORT}`);
});

Notice the use of crypto.timingSafeEqual. When dealing with external APIs, especially ones moving physical goods or money, you must protect against timing attacks. Standard string comparison (===) fails fast if the first character doesn't match, allowing an attacker to guess your secret key character by character based on response times.

The Engineering Mindset Shift

The code above isn't particularly complex. It is standard API interaction. The actual difficulty lies in the mindset shift.

Developers tend to compartmentalize the world. We write code for servers and browsers, as well as databases. We assume physical logistics, including boxes, tape, printing presses, and shipping rates, belong to a completely different domain requiring manual intervention or massive enterprise software.

This is a failure of system thinking. A printing press at a local vendor is just a very slow, very heavy output device. A postal service is just a high-latency, high-packet-loss network.

By writing a CLI that interfaces with printing APIs, you save yourself a trip to the post office. You also bring physical logistics under version control. You can script marketing campaigns the same way you script database migrations.

Next time you need to order swag, resist the urge to click through a beautiful web interface. Find a vendor with an API. Open your editor and write a script. Let the machines handle the logistics so you can get back to writing software.