Banner for What Goes Into a Custom Shopify Fulfillment App: Pallets, Customs PDFs, Thermal Labels, and AI Restocking

What Goes Into a Custom Shopify Fulfillment App: Pallets, Customs PDFs, Thermal Labels, and AI Restocking

A custom Shopify app that builds cross-border pallets, generates Section 232 customs PDFs, prints Zebra labels, and restocks with AI. Full architecture.

In 2025, the economics of shipping parcels from Canada to American customers quietly fell apart. I build software for a Canadian automotive-parts retailer that sells almost exclusively to US customers, and for years their fulfillment was as simple as it gets: order comes in, box goes to FedEx, FedEx crosses the border.

Then the rules changed, fast. The US suspended the $800 de minimis exemption effective August 29, 2025 — every commercial parcel from Canada now needs a customs entry, no matter how cheap the contents (White & Case ). Section 232 steel and aluminum tariffs doubled to 50% in June 2025, with no USMCA exemption (White & Case ), and in August, Commerce added 407 derivative product categories to the list — explicitly including automotive parts (BIS ). The couriers layered their own costs on top: clearance entry fees of $9.75–$19.50 per parcel, disbursement fees of $8.50 or more, and a $2.50 international processing fee on every package (Value Added Resource ).

For a parts retailer, that math is brutal. Shipping costs climbed, and parcels started sitting in customs holds at FedEx — because each individual order had become its own customs event, with its own entry, its own fees, and its own opportunity to get stuck.

The fix is the one every logistics person now recommends: stop clearing customs per parcel. The company opened a warehouse just across the border in upstate New York. Pre-sold orders and stocking inventory get consolidated onto pallets, a truck carries them across under a single customs manifest, the broker clears one formal entry, and orders dispatch domestically inside the US — cheaper, faster, and far more predictable. A formal entry runs roughly $100–$150 in broker fees plus a Merchandise Processing Fee capped at $651.50 — paid once per truckload instead of $20–$30 in fees per box (ICE Transport ).

The strategy is well documented. The software to operate it is not. I went looking for a Shopify app that could take a queue of orders, build pallets out of them, generate a consolidated commercial invoice plus a Section 232 metal-content addendum, print carton labels a warehouse can work from, and track inventory at the destination. As far as I can tell, it doesn't exist — freight apps quote LTL rates at checkout, invoice apps do per-parcel paperwork, and full WMS products want your whole operation. So I built it. Here's the architecture, end to end.

(If you want the operator-facing version of this story — the playbook rather than the architecture — that's over here.)

In this post:

Orders arrive by webhook and get sorted by geography

Everything starts with an orders/create webhook. The handler ACKs Shopify with a 200 immediately and processes in the background; a duplicate check on the order ID keeps retries idempotent, and if the Admin API client isn't available, it falls back to building the record straight from the raw webhook payload — losing only product images.

Then a classifier sorts each order. Non-US orders ship from the Canadian facility. Oversized "exception" SKUs that physically can't ride a pallet — splitters, hoods, widebody kits — ship from Canada too. Line items with sufficient live inventory at the US warehouse (checked in real time against Shopify's inventory API, not a cached list) get marked for domestic dispatch. Everything else queues for the next pallet. Mixed orders get split — more on that in a second.

The data model is the tariff workaround

There's no workflow engine. The whole pipeline runs on plain-string state machines stored in SQLite via Prisma: nine order statuses, seven line-item statuses, and a five-stage pallet lifecycle (OPEN → CLOSED → SHIPPED → DELIVERED → ARCHIVED) with transition guards as thrown errors — "Cannot close an empty pallet," "Pallet must be shipped before marking as delivered." You can read the business pivot straight out of the schema: a paps field on every pallet (the pre-arrival number truckers file with US customs), a CustomsMapping table from product to HTS code, and line-item statuses like TO_BE_PALLETIZED and DISPATCH_FROM_STOCK that each encode a routing decision.

The most interesting relational choice is the split group. A single checkout can fan out into different destinies: order #1042 becomes #1042-A riding a pallet across the border and #1042-B shipping FedEx direct from Canada — each chunk with its own status, pallet assignment, and eventually its own tracking number. That letter suffix survives all the way to the tracking CSV the US warehouse sends back, where it matches to exactly the right line items.

Physical reality leaks into the schema in pleasing ways. A line item has a boxCount — a body kit might ship as three cartons — and a boxSkus JSON array for per-box SKUs, because each carton carries its own SKU on its own label, lettered A, B, C…

Pallet assignment lives where staff already work

Pallets are auto-numbered (PAL-2026-0001) and managed in an embedded admin app, but the people doing assignment mostly never leave the Shopify order page. Two admin extensions live there: a persistent block that shows pallet state at a glance ("3 on pallet, 2 unassigned") with a one-click remove, and an action modal for assigning items, with inline pallet creation. The two extensions are sandboxed iframes that can't talk to each other, so when the block launches the modal, it just polls the backend every three seconds for up to two minutes until the assignment shows up. Inelegant, honest, works.

Closing a pallet is the big moment: it emails the fulfillment team a manifest CSV and increments inventory at the destination warehouse's Shopify location — the US side's stock counts update the instant the pallet is sealed.

Customs PDFs: pdf-lib, and the form you must not flatten

One button generates two documents: a commercial invoice and the steel/aluminum/copper addendum, both built client-side in the browser with pdf-lib — no PDF microservice, no server queue.

The invoice's key trick is SKU collapsing. Hundreds of retail SKUs map to eighteen curated customs categories — each with a fixed description, HS code, country of origin, and declared unit price — so the broker sees a clean ten-line invoice instead of a 300-line dump. Unmapped products don't fail silently; they show up highlighted on the invoice preview with an inline dropdown, and the assignment persists shop-wide. The paperwork screen doubles as the data-cleanup screen.

The addendum was the war. Because these are automotive products, every pallet needs a Section 232 metal-content declaration: steel weight per unit, country of melt and pour, smelt-and-cast countries for aluminum. The stakes are real — CBP applies the 50% duty to the entire product value if you can't substantiate metal content, and reporting "unknown" smelt or cast countries for aluminum triggers the 200% Russia duty (CBP FAQ ). The broker supplies an official fillable PDF, and the app fills it programmatically, five line items per page. That meant reverse-engineering AcroForm field names like Aluminum Country of CastYes No NA and Text6Text10 (the steel-weight column, obviously), discovering the template ships with sample data pre-filled in every row (which bleeds into your output unless you explicitly blank every field first), and learning that copyPages drops the destination's AcroForm entries unless you call updateFieldAppearances() before copying.

The biggest lesson: do not call form.flatten(). Flattening squashed every checkbox in the template. The fix was to go a level deeper into the AcroField API — set each field's value to its on-state, then set every widget's appearance state to match — and ship a live, un-flattened form.

The regulation also moved mid-build. The template went from rev5 ("Steel and Aluminum") to rev9.2 ("Steel-Aluminum-Copper") when copper joined the declaration regime.

Zebra labels are just HTML

There's no ZPL anywhere in the codebase. Every label is an HTML route with print CSS — @page { size: 4in 6in; margin: 0 } — and a window.print() call. To the browser, the Zebra thermal printer is just another paper size.

Three label designs cover three flows: per-carton 4×6 labels for customer orders (one per physical box, so an order shipping in three boxes prints three lettered labels, each with its own per-box SKU), a manifest-style label for stocking pallets, and an 8.5×11 placard for the pallet wrap with the pallet number at 48pt and the PAPS pre-clearance number the carrier files before reaching the border. The battle scars are pure CSS: page-break-after: always with a last-child override so the printer doesn't spit out a blank trailing label, and dissolving the embedded-app chrome at print time.

Claude decides what rides in the empty space

A pallet of pre-sold orders rarely fills the truck, and every cubic foot crossing under the same manifest is nearly free compared with shipping that inventory later. So the app tops pallets up with replenishment stock — and that decision goes to an LLM.

When a pallet is created, the app calls Claude Haiku with each tracked SKU's current quantity at the US warehouse, its user-configured minimum threshold, and 30 days of sales history. Code, not the model, does the math: deficits, sales velocity, candidate pre-filtering. The model picks items and quantities and writes a one-line reason — which is stored as a database column, so the "why" ships with the pallet record instead of evaporating. The response is treated as untrusted input: markdown fences stripped, IDs validated against the database, quantities clamped, duplicates dropped.

My favorite production lesson: a variant title containing stray text like "Do not include" was confusing the model, so the app simply stopped sending variant titles. Your own product catalog is adversarial input.

It's human-in-the-loop by design. Suggestions land in an editable table with per-row removal; nothing touches real inventory until a person closes the pallet.

A cron, a ledger, and CSV over email

The destination warehouse needed inventory tracking, but I didn't build a parallel stock database. The US warehouse is just another Shopify location, and every movement flows through Shopify's inventory adjustments with meaningful reasons — "received" for pallet arrivals, "shrinkage" for warehouse sales. The app stores only what Shopify can't: alert thresholds, a signed sales ledger, backorder state.

node-cron runs inside the web process: a daily noon-UTC threshold sweep that emails low-stock alerts, and a Monday summary where Claude writes a few honest paragraphs about inventory trends. (The prompt explicitly forbids tables and bullet lists, because the data table is already rendered below the prose in the email.)

And the integration with the US warehouse crew? CSV over email. The dispatch email carries a CSV with a deliberately blank tracking-number column; the warehouse fills it in and mails the same file back; the import writes fulfillments to Shopify first and only updates local state on success, so the customer notification and the database can never disagree. Email plus a spreadsheet is the API contract — because that's the workflow the warehouse already knew.

The stack, boring on purpose

React Router 7 in framework mode, Prisma with SQLite on a persistent volume, a single shared-CPU 1GB Fly.io machine in Toronto that never scales to zero (the cron scheduler and the database live inside the web process), pdf-lib for documents, Resend with React Email for the mail, and the Anthropic SDK for the two AI features. Fourteen runtime dependencies. No queue, no Postgres, no worker fleet. For a system whose job is moving a few pallets a week across a border, that's the right amount of infrastructure — and one person can hold all of it in their head.

If you're staring at the same wall

This is what bespoke line-of-business software looks like in 2026: small, unglamorous, and shaped precisely to one company's physical reality. The rules have kept moving since launch (de minimis, the 50% Section 232 expansion, copper joining the addendum), and each change landed as a diff: a new customs category, a new template revision, a one-line steel-weight update — "add brake calipers" is literally a commit message in this repo. That's the real case for custom tooling — when regulation is a moving target, software you own moves with it.

If you're a Canadian merchant trying to keep selling into the US, or a developer weighing a build like this, I'm happy to compare notes — get in touch.

Ready to get started?

Schedule a demo to see how we can help streamline your workflow.

Share this article