Merch (print on demand)

Creators sell tees, hoodies, mugs and posters with no stock: the workspace connects its own Printful, Printify

or Gooten account, imports the products it designed there, and every paid order is submitted to that provider for

printing and shipping. Krunc never holds inventory or a platform-wide print account — each workspace pays its

provider directly, and keeps the margin between the provider's cost and the retail price.

Dashboard → Store → Merch (/dashboard/store/merch).

Connecting a provider

One connection per provider per workspace (admin/owner). The key is verified with the provider before it is saved,

stored encrypted (AES-256-GCM, pod_connections.api_key_enc), shown afterwards only as a 4-character hint, and

never rendered again. Re-pasting a key replaces it; Disconnect revokes it and hides the imported products.

ProviderWhat to pasteWhere to get it
PrintfulPrivate API token · Store ID (only for account-level tokens that cover several stores)Printful → Settings → Stores → API → Add token. Scopes: stores, products (sync), orders.
PrintifyPersonal access token · Shop ID (only when the account has several shops)Printify → My profile → Connections → Generate token. Scopes: shops.read, products.read, orders.read, orders.write.
GootenPartner billing key (private) · Recipe ID (public, required)Gooten Admin → Settings → API.

Per connection, Settings holds the list of countries offered on the Stripe address form (ship_countries, ISO

alpha-2; default: US, CA, GB, IE, AU, NZ and most of Western Europe + JP) and the default shipping fee applied to

new imports. Keep the country list within what the provider actually ships to.

Importing products

Browse & import lists the products in the connected account (Printful sync products, Printify shop products,

Gooten Print Ready Products). Import creates an ordinary products row:

FieldValue
fulfillmentpod (everything else is digital)
pod_connection_id, pod_product_idwhich account and which product there
pod_variantsJSON list: external_id, name, sku, price_cents (retail), cost_cents (when the provider reports it), currency, image, enabled
price_centsthe cheapest enabled variant ("from $25.00" on the storefront)
shipping_centsflat fee per order, from the connection default; editable per product
categoryMerch

Retail prices and which variants are on sale are managed in the provider's dashboard; Refresh on the product

(or re-importing) pulls them again. Name, description, image and shipping fee edits in Krunc are kept across

refreshes; the price list and variant set are always the provider's. Merch products are always one-time purchases

(the form ignores kind/price for them). Deliverables still work — a hoodie can also grant a Discord role.

Shipping is a flat fee, not a live quote. Stripe Checkout needs the rate before it knows the address, and the

three providers quote differently, so the creator sets one fee per product. What the provider actually charged

(product + shipping + tax) is recorded on the order once it is submitted, so margins are visible per order.

Buying

The product page shows a size/colour picker (enabled variants only), the variant's price and the shipping fee.

Checkout (CheckoutController) stores the variant on the order item (order_items.variant_id, variant_name),

adds orders.shipping_cents once per order, and asks Stripe to collect a shipping address and phone number

(shipping_address_collection, one fixed_amount shipping option, phone_number_collection). Coupons apply to

the items, not to shipping.

Fulfilment

checkout.session.completed → order PAID → PodService::onOrderPaid()
   → orders.shipping (address from the session), pod_orders row (pending) → job pod.submit_order
   → provider createOrder (draft) → confirmOrder → submitted
   → scheduler pod.poll_orders (every 10 min) → in_production → shipped (tracking) → delivered
  • Nothing reaches a provider before Stripe confirms payment (rule #2). pod_orders.order_id is unique and the

provider's order id is stored as soon as it exists, so webhook replays and retries never create a second print

order (Printful is also looked up by @external_id first; Printify is created on hold and then sent to

production; Gooten submits in one call with IsPartnerSourceIdUnique).

  • Transient failures (provider down, rate limit) retry with exponential backoff up to 8 times; permanent ones

(rejected key, bad address, unknown variant) mark the fulfilment failed and notify owners/admins

(orders.merch_failed). The order page has Retry submission once the cause is fixed.

  • on_hold / failed reported by the provider (address issue, payment not received, out of stock) notify the

owners once (orders.merch_attention); resolve it in the provider dashboard and polling picks the change up.

  • When the first tracking number appears the buyer gets a "has shipped" email and an in-app notification, the

order.shipped automation trigger fires (payload: order, products, carrier, tracking number/URL, provider) and

the order.shipped outbound webhook is emitted. These hooks fire once per order.

  • Refund or dispute (Stripe webhook or the dashboard Refund button) cancels the print order when it is still

pending/submitted/on_hold; once it is in production or shipped the owner is told it could not be stopped.

Cancel print order on the order page does the same by hand (it does not refund).

pod_orders.statusMeaning
pendingPaid, waiting for the worker (or for the retry backoff)
submittedAccepted by the provider, not yet in production
in_productionBeing printed
partialSome items shipped
shipped / deliveredTracking available / carrier reports delivery (Printify)
on_holdProvider needs something (address, payment, stock)
failedCould not be submitted, or the provider gave up — see last_error
canceledStopped before production

orders.fulfillment_status mirrors this so order lists and the buyer's My purchases page show it without a join.

Provider notes

  • Printful: POST /orders creates a draft, POST /orders/{id}/confirm submits it (billed to the Printful wallet

or card). Orders are read with GET /orders/{id}; DELETE /orders/{id} cancels drafts/pending. 120 requests per

minute per store. Variant mockups come from the sync variant's preview file.

  • Printify: POST /shops/{shop}/orders.json (on hold) then /send_to_production.json. Shipping method 1

(standard). Prices and costs are integer cents. Cancel only works while on-hold / payment-not-received.

  • Gooten: built against Gooten's public API docs (api.print.io/api/v/5/source/api, recipeid on every call,

PartnerBillingKey in the order body). Gooten's docs omit the example payloads for /prpproducts and

/prpvariants, so the client reads the common key spellings (Sku/SKU, Name/ProductName, Price as a number

or {Price, CurrencyCode}) and the order is submitted with ShipType: standard, IsPreconfiguredSku: true.

Verify with a real Gooten key before relying on it; the request bodies are pinned by tests/MerchTest.php.

In APP_ENV=test a fourth provider, mock (App\Providers\Pod\MockPodProvider), is available to the connect form

and drives the end-to-end tests.

Tables

pod_connections (tenant-scoped, one per provider), pod_orders (one per order, unique), plus the columns added

in database/migrations/0048_print_on_demand.sql on products, orders and order_items.