Printify only has UK-based print providers for 3/10 product types. The other 7 ship from the US, adding cost and delivery time for UK customers. Printful runs its own facility in Wolverhampton with ~252 products across DTG, embroidery, DTF, and sublimation. Critically, Printful has a **mockup generation API** — Prodigi doesn't, which was a dealbreaker.
## API overview
- **Base URL:** `https://api.printful.com/` (v1) and `https://api.printful.com/v2/` (v2 beta)
- **Auth:** OAuth 2.0 private tokens (Bearer header). For single-store use (our case), generate a token in the Developer Portal — no OAuth flow needed. Same pattern as Printify's API key, stored encrypted in ProviderConnection
- **Rate limit:** 120 requests/60s (authenticated), leaky bucket in v2
- **No sandbox.** Testing via draft orders (not charged until confirmed) and webhook simulator
- **Store scoping:** `X-PF-Store-Id` header for store-specific endpoints
### Key difference from Printify
Printful IS the manufacturer — no blueprint/print-provider indirection. The data model is simpler:
Implements the `Provider` behaviour. The big difference from Printify is that Printful products don't need a "shop" — they come from the global catalogue. But orders require a store_id.
- Store `store_id` in connection config (same as Printify's `shop_id`)
**`fetch_products/1`**
- No direct equivalent to Printify's "list my products" — Printful's catalogue is global
- Two approaches:
- **Option A (recommended):** Fetch products the seller has added via Printful's sync products (`GET /sync/products`). This lists products the seller has configured in their Printful store with designs already applied
- **Option B:** Browse the full catalogue and let the seller pick. More complex UI, better for later
- For Option A: paginate through `GET /sync/products`, then for each, call `GET /sync/products/{id}` to get variants with pricing
- Normalize to the same structure as Printify products
This is a pragmatic reuse of the existing schema. The field names are Printify-centric but the data works for any provider.
Alternatively, query rates for all countries in `Shipping.@country_names` to populate the country selector. Printful's rate limit (120/min) allows this — one request per country with all items batched.
Currently `get_provider_connection/0` is hardcoded to `"printify"`. For multi-provider support, orders need to route to the correct provider based on which connection owns the product.
Change `submit_to_provider/1`:
1. Load the order's items with their product associations
2. Group items by `product.provider_connection_id`
3. For each group, get the provider connection and submit that subset
4. If all succeed, mark the order as submitted. If any fail, mark as failed with details
This is the main architectural change needed in the orders context. For now (single Printful connection), each order's items will all belong to one provider. Multi-provider order splitting (items from Printify AND Printful in one cart) is future work.
Simpler first step: look up the provider connection from the first item's product, rather than hardcoding "printify":
```elixir
defp get_provider_connection_for_order(order) do
# Get the provider connection from the first item's product
1. Get artwork URL from product's sync data (Printful sync products store the design file)
2. Pick representative variant IDs (one per colour, front placement)
3. Submit mockup task via client
4. Poll with backoff (1s, 2s, 4s, 8s... up to 30s between polls, max 5 minutes)
5. Download generated mockup URLs
6. Process through existing image pipeline (WebP conversion, responsive variants)
7. Update product images in DB
### 2.3 Wire into product sync
In `ProductSyncWorker`, after syncing a Printful product's data, enqueue a `MockupGenerationWorker` job if the product doesn't already have images (or if images are stale).
---
## Phase 3: Shipping rates
### 3.1 Printful shipping in `fetch_shipping_rates/2`
Unlike Printify's static rate tables, Printful calculates shipping live based on recipient + items. For caching:
1. After product sync, collect all unique `sync_variant_id` values
2. For each target country (GB, US, plus REST_OF_THE_WORLD mapped to a representative like DE):
- Call `POST /shipping/rates` with the country and all variant IDs
- Extract the STANDARD rate (cheapest)
3. Normalize into the same rate map format as Printify:
```elixir
%{
blueprint_id: catalog_product_id, # repurposed field
print_provider_id: 0, # Printful is the provider
country_code: "GB",
first_item_cost: rate_cents,
additional_item_cost: 0, # Printful quotes per-order, not per-item
currency: "USD", # Printful returns USD
handling_time_days: max_delivery_days
}
```
4. Upsert via existing `Shipping.upsert_rates/3` with exchange rate conversion
### 3.2 Shipping calculation differences
Printful quotes shipping per-order (not per-item like Printify). The existing `Shipping.calculate_for_cart/2` groups by `print_provider_id` and uses first_item + additional_item pricing. For Printful products (all with `print_provider_id: 0`), they'll naturally group together and the first_item_cost will be the full shipping quote.
This works correctly with the existing calculation logic — no changes needed to `Shipping.calculate_for_cart/2`.
**Using sync products (not catalogue browsing):** Printful's model expects sellers to set up products in Printful's dashboard first (apply designs, choose products, set pricing). Berrypod syncs those configured products. This matches the Printify workflow where products exist in the provider's system first. Full catalogue browsing + product creation via API is possible but significantly more complex (need artwork upload, placement positioning, pricing config) — better suited for a v2.
**Reusing ShippingRate schema fields:** `blueprint_id` and `print_provider_id` are Printify-specific names, but they serve as generic "product type ID" and "provider ID" slots. Renaming them would be a migration + touch every query. Not worth it until a third provider makes the naming confusing.
**Draft + confirm order flow:** Printful charges on confirm, not create. We create as draft, then immediately confirm. No "test mode" needed — if something goes wrong between create and confirm, the draft sits harmlessly.
**Mockups as a separate worker:** Mockup generation is slow (5-30 seconds) and shouldn't block product sync. Running it as a separate Oban job means sync completes fast and mockups trickle in afterwards. Products display with Printful's default thumbnail until mockups are ready.
**V1 vs v2 API:** Use v2 for catalogue endpoints (better filtering, availability data, mockup styles). Use v1 for orders and mockups (stable, well-documented). Both versions use the same auth token.
---
## Prerequisites
- Printful account with API token
- Products set up in Printful's dashboard with designs applied
- No code changes needed to existing Printify integration — they coexist
## Verification
1.`mix test` passes
2. Connect Printful in admin settings, test connection shows store name
3. Sync pulls products with images and variants
4. Mockups generate after sync (check Oban jobs + product images)
5. Cart shipping estimates work for Printful products
6. Stripe checkout shows shipping options
7. Order submits to Printful, status updates via webhook