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.
| Provider | What to paste | Where to get it |
|---|---|---|
| Printful | Private API token · Store ID (only for account-level tokens that cover several stores) | Printful → Settings → Stores → API → Add token. Scopes: stores, products (sync), orders. |
| Printify | Personal 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. |
| Gooten | Partner 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:
| Field | Value |
|---|---|
fulfillment | pod (everything else is digital) |
pod_connection_id, pod_product_id | which account and which product there |
pod_variants | JSON list: external_id, name, sku, price_cents (retail), cost_cents (when the provider reports it), currency, image, enabled |
price_cents | the cheapest enabled variant ("from $25.00" on the storefront) |
shipping_cents | flat fee per order, from the connection default; editable per product |
category | Merch |
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_idis 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/failedreported 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.status | Meaning |
|---|---|
pending | Paid, waiting for the worker (or for the retry backoff) |
submitted | Accepted by the provider, not yet in production |
in_production | Being printed |
partial | Some items shipped |
shipped / delivered | Tracking available / carrier reports delivery (Printify) |
on_hold | Provider needs something (address, payment, stock) |
failed | Could not be submitted, or the provider gave up — see last_error |
canceled | Stopped 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 /orderscreates a draft,POST /orders/{id}/confirmsubmits 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,recipeidon 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.