Skip to main content
Contact us
A tablet showing two checkout requests priced from Shopify, one becoming a draft order and one stopped by the pricing guard

Project Story

A custom hat checkout on Shopify that can't undercharge

Billest's hat builder lets a customer design hats in any mix of styles and colors with their own logo, then pay through Shopify's checkout. We built the checkout on Shopify Draft Orders, so there's no SKU for every combination. The server prices every order from Shopify itself and refuses to create one whose price doesn't add up.

Client
Billest
Industry
Custom apparel
When
2026
Status
Live on billest.com since August 2026
  • Next.js 15
  • TypeScript
  • Shopify Admin GraphQL API
  • Shopify Draft Orders
  • Shopify App Proxy
  • Theme App Extensions
  • UploadThing
  • Vercel
  • Sentry
  • Datadog

Every hat is its own job

Billest sells custom hats. A customer picks a style and a color, uploads a logo, chooses embroidery, 3D puff embroidery or a DTF print, sets the logo’s size and orders at least 12. Shopify is built to sell a fixed catalog, and a catalog with a product for every combination of style, color, method, size and logo isn’t one anybody could keep.

The builder lives on Billest’s own storefront as a Theme App Extension block, and every call it makes goes through Shopify’s App Proxy to a Next.js app on Vercel. The first version ended in a quote request that someone at Billest had to answer by hand. In May 2026 we replaced that with a real checkout, so a customer goes from the builder straight to paying.

We built it on Shopify Draft Orders. Each hat style is an unlisted Shopify product and each color is a variant of it, at a price the merchant sets in the Shopify admin. When a customer checks out, the server calls draftOrderCreate with one line item per hat, each on the real variant the customer picked, and sends the browser to the draft order’s invoiceUrl. That’s Shopify’s own secure checkout. When the customer pays, the draft becomes a normal Shopify order like anything else Billest sells.

What production sees on the order

A variant already says the style and the color, so the line item doesn’t repeat them. Everything else production needs rides along as custom attributes on each line: the decoration method, a link to the customer’s original logo file as the print file, the logo width and a link to the mockup the customer approved.

A few of those rules came from thinking about the people on the floor. The mockup’s label says it’s a mockup, so nobody produces from it. The width is rounded down to the tenth of an inch, never up, so 2.45" can’t reach production as 2.5". An advisory that the artwork may be too fine for the chosen method goes on a key that starts with an underscore (_Detail Advisory), because Shopify shows those properties in the admin and hides them from the customer’s checkout and emails. And every line carries the whole set, since production works each line on its own and a note that appears under only one hat reads as being about that hat.

Staff click these links to fetch artwork, so the server checks that every URL on the order points at the app’s own UploadThing storage. Otherwise anyone posting to the endpoint could plant a link of their choosing in the merchant’s admin.

The browser never sends a price

The checkout request carries a product handle, a variant ID and a quantity for each hat, plus the design. It has no price field. Whatever the page displayed, the server works out the charge again from Shopify, in this order:

  1. Each handle has to be one of the styles the builder sells, and each variant ID has to look like a Shopify variant ID.
  2. Quantities are checked against the order rules (more on those below). A failure here is a 400 with a reason.
  3. Every variant is fetched in one Admin GraphQL nodes(ids:) call. Each one has to belong to the product its line names, which stops a client from sending a cheaper variant from some other product. A style the merchant has just set to draft is refused with a 409, even for a customer whose page loaded before the change.
  4. The variant prices go through resolveCheckoutPricing, which picks the volume tier and builds the discount for each line.

That last step fails closed. A variant price under $1 or over $1,000 is treated as a catalog mistake, since a hat priced at $0.01 is almost certainly a typo in the admin. A discount that would bring a hat to zero or below, or a quantity with no matching tier, fails too. Any of those stops the checkout with a 409 and a message telling the customer the item is temporarily unavailable. The server logs a checkout.pricing_guard_tripped event with each line’s variant price and quantity, and sends Sentry a warning. The event has its own name so a Datadog monitor can watch for it alone. If it fires, a sale was blocked that would otherwise have been charged wrong.

The builder and the checkout route call the same function, calculateOrderPrice, to get the number. The builder shows it and the route charges it, so the two can’t drift apart.

A made-up checkout request with no price in it is priced again from Shopify's variant prices. One version passes and becomes a draft order, and one with a misconfigured price is stopped before Shopify sees it.

Prices live in Shopify and discounts live in code

The first version kept the tier prices in code: $17 a hat at 12, $16 at 24, $15 at 50. That meant a price change in the Shopify admin did nothing until a developer changed a file. In June we turned it around. The base price is now whatever the variant costs in Shopify, and the code only holds the volume discount: nothing off from 12 to 23 hats, $1 a hat from 24 to 47, $2 a hat from 48 up. Billest moved the top tier from 50 to 48 in August, which was a two-line change.

The discount goes on each line as a fixed amount per hat. We started with a percentage and it broke at checkout. Shopify accepts at most two decimal places in appliedDiscount.value, and $1 off a $17 hat is 5.88235…%, so every order in the 24+ tier failed with a decimal error. A FIXED_AMOUNT discount of $1 is exact. Shopify multiplies it by the line’s quantity and the totals come out to the cent.

Draft orders also skip a shop’s automatic discounts unless they’re asked to apply them. A customized order was missing Billest’s free shipping at 24 hats until we set acceptAutomaticDiscounts, and in November we added allowDiscountCodesInCheckout so customers can type a promo code on the invoice checkout. Both apply on top of the volume tier. The builder can’t see a code the customer hasn’t typed yet, so the price it shows is the price before codes. Shopify also doesn’t support free-shipping or buy X get Y codes on draft orders.

Twelve per style, priced on the whole order

In September the builder started taking orders of several hats with one logo, in any mix of styles and colors, up to 20 lines. It all goes into one draft order. The volume tier comes from the order’s total, so 12 of one style and 12 of another are both priced as 24.

The minimum took a second pass. We first applied the 12-hat minimum to the whole order, which let a customer add 2 hats in a second style. Billest pointed out that every style change is a new machine setup, so in October the minimum moved to each style. Colors of one style share it: 7 black and 5 white of the same hat is fine, while 12 of one style and 2 of another is refused.

validateOrderQuantity is the rule, and the builder and the server both call it. The builder uses it to stay ahead of the customer. A hat in a new style starts at 12, another color of a style already in the order starts at whatever that style is short, and a style’s only hat can’t go below 12. When a style is short, the order list says which and by how much (“add 4 more hats of Ozark”). The server runs the same check, so a stale tab or a hand-built request gets a 400.

Lines for the same variant are merged into one, since the builder lets a customer pick the same color twice. That merge runs after the quantities are checked, so two lines of 6 for one hat can’t pass as a style that met its minimum. The decoration method is the order’s too. A method withdrawn on one style (DTF on a hat with a seam down the front panel) is withdrawn for the whole order, since every hat gets the same design.

A made-up order in the builder, with two colors of one style sharing its 12-hat minimum, a second style held at 12, and the total priced at the 24-hat tier.

How it was tested, and the part that isn't

To be clear, the app has no automated test suite. Pricing changes have been checked by hand and by script, and that’s a real gap for code that decides what a customer pays. When we moved checkout to multiple hats, we ran the old route and the new one against the same fake Shopify client. For 24 single-hat orders covering the tiers, notes, background removal and old request shapes, the draftOrderCreate input came out identical, and 14 invalid requests got the same status and message as before. Then we placed an order of 24 hats in three styles on Billest’s development store, and Shopify’s checkout showed $16 a hat on every line and a $384 subtotal. That was two weeks before the per-style minimum, and the same order (12, 10 and 2) would be refused today.

Every way the route can say no is logged. A refusal writes checkout.rejected with a reason code, and a created order writes the subtotal and each line’s price and discount, so a “you charged me wrong” email can be answered from the logs without opening the Shopify admin. The invoice URL is never logged, since anyone holding it can pay the order, and only the length of a customer’s note is logged, since a note can hold a phone number.

Admin API calls retry on Shopify’s throttling, which arrives as an HTTP 200 with a THROTTLED error that a status-code check misses. Creating a draft order isn’t idempotent, though. If the connection drops after Shopify has made the order, the builder can’t tell, so it doesn’t offer a free retry. It tells the customer to check their email for an invoice first.

The builder went live on billest.com the week of August 17, 2026. Three days later Billest told us they’d had 13 orders through it without marketing it at all.

Questions about this project

HQ, a software design and development agency in Ogden, Utah, built the custom hat builder on billest.com for Billest. Customers pick hat styles and colors, upload a logo, choose how it's decorated, and pay through Shopify's own checkout. Each order is a Shopify Draft Order with one line item per hat on a real product variant, with the production details stored as line item attributes, so Billest keeps no SKU per combination. It has been live since August 2026.

The checkout request carries a variant ID and a quantity for each hat, never a price. HQ's server refetches every variant from the Shopify Admin GraphQL API and confirms each one belongs to the product its line names. It then prices the order from Shopify's variant prices with the same function the builder uses to display the price. A variant price outside $1 to $1,000, or a discount that would bring a hat to zero, stops the checkout with a 409 and logs a checkout.pricing_guard_tripped event for monitoring.

Shopify accepts at most two decimal places in a draft order line's appliedDiscount value. A $1 volume discount on a $17 hat is 5.88235 percent, so percentage discounts made every order in the 24-hat tier fail at checkout. HQ switched to a FIXED_AMOUNT discount per hat, which Shopify multiplies by the line quantity, so totals are exact to the cent.

Each hat style needs at least 12 hats, because every style change is a new machine setup, and colors of the same style share that minimum. The volume tier comes from the whole order's total, so 12 hats of one style and 12 of another are both priced at the 24-hat tier. Tiers take $1 a hat off from 24 hats and $2 a hat from 48, subtracted from the variant's price in Shopify. An order can hold up to 20 lines and carries one logo.

Yes. HQ creates Billest's draft orders with acceptAutomaticDiscounts, so shop-wide automatic discounts such as free shipping at 24 hats apply, and with allowDiscountCodesInCheckout, so customers can enter a code on the invoice checkout. Both stack on top of the volume discount. Shopify does not support free-shipping or buy X get Y codes on draft orders, and the builder's displayed price does not include a code the customer has not entered yet.

Let's build together

Whether you're looking to design, build, update, or refresh an existing app or website, we can help. Even if we're not a good fit, we will help point you to somebody who might be.

Contact us