berrypod/docs/plans/printful-integration.md
jamey edef628214 tidy docs: condense progress, trim readme, mark plan statuses
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:18 +00:00

18 KiB

Printful integration plan

Status: Complete Depends on: Shipping sync (done), Provider behaviour (done)


Why Printful

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:

Printify Printful
Blueprint -> Print Provider -> Variants Catalog Product -> Variants
blueprint_id + print_provider_id + variant_id catalog_variant_id (globally unique)
Provider-generated mockups on product creation Dedicated async mockup generation API
Static shipping rate tables per blueprint/provider POST /shipping/rates with recipient + items

This simplification means provider_data stores different fields and shipping rate lookup works differently.


Phase 1: Core integration (product sync + orders)

1.1 HTTP client

New file: lib/berrypod/clients/printful.ex

Same pattern as clients/printify.ex. Uses Req with Bearer token auth.

@base_url "https://api.printful.com"

Core methods:
- get(path) — GET with 30s timeout
- post(path, body) — POST with 60s timeout
- delete(path) — DELETE with 30s timeout

Printful-specific endpoints:
- get_store() — GET /stores (verify connection, get store_id)
- list_catalog_products(opts) — GET /v2/catalog-products (with DSR filter for UK)
- get_catalog_product(id) — GET /v2/catalog-products/{id}
- get_catalog_variants(product_id) — GET /v2/catalog-products/{id}/catalog-variants
- get_product_availability(id) — GET /v2/catalog-products/{id}/availability
- get_product_prices(id) — GET /v2/catalog-products/{id}/prices
- get_mockup_styles(id) — GET /v2/catalog-products/{id}/mockup-styles
- calculate_shipping(recipient, items) — POST /shipping/rates
- create_order(order_data) — POST /orders
- confirm_order(order_id) — POST /orders/{order_id}/confirm
- get_order(order_id) — GET /orders/{order_id}
- create_mockup_task(product_id, body) — POST /mockup-generator/create-task/{product_id}
- get_mockup_task(task_key) — GET /mockup-generator/task?task_key={key}
- setup_webhooks(url, events) — POST /webhooks
- list_webhooks() — GET /webhooks

API key stored in process dictionary (Process.put(:printful_api_key, key)) same as Printify client.

1.2 Provider implementation

New file: lib/berrypod/providers/printful.ex

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.

test_connection/1

  • Decrypt API key from ProviderConnection
  • Call Client.get_store() to verify credentials
  • Return {:ok, %{store_id: id, store_name: name}}
  • 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

Normalized product structure:

%{
  provider_product_id: to_string(sync_product["id"]),
  title: sync_product["name"],
  description: "",  # sync products don't have descriptions
  category: extract_category(sync_product),
  images: normalize_images(sync_product),
  variants: normalize_variants(sync_product["sync_variants"]),
  provider_data: %{
    catalog_product_id: variant["product"]["product_id"],
    catalog_variant_ids: [...],  # for mockup generation + shipping
    thumbnail_url: sync_product["thumbnail_url"],
    raw: sync_product
  }
}

Key mapping differences:

  • Printify blueprint_id / print_provider_id -> Printful catalog_product_id (single ID, no provider layer)
  • Printify variant id (integer within blueprint scope) -> Printful variant_id or sync_variant_id
  • Printify variant title "Navy / S" -> Printful variant has explicit color, size, color_code fields (cleaner!)

submit_order/2

Printful orders include the artwork in the order payload (unlike Printify where artwork is on the product). This is the biggest structural difference.

%{
  external_id: order_data.order_number,
  recipient: %{
    name: address["name"],
    address1: address["line1"],
    address2: address["line2"],
    city: address["city"],
    country_code: address["country"],
    state_code: address["state"],
    zip: address["postal_code"],
    email: order_data.customer_email
  },
  items: Enum.map(order_data.line_items, fn item ->
    %{
      sync_variant_id: parse_int(item.provider_variant_id),
      quantity: item.quantity
    }
  end)
}

After creation, immediately call confirm_order/1 to submit for fulfilment (Printful's two-step flow: create draft -> confirm).

get_order_status/2

Status mapping:

  • draft -> "submitted" (shouldn't happen post-confirm, but just in case)
  • pending -> "submitted"
  • inprocess -> "processing"
  • fulfilled, shipped -> "shipped" (Printful splits these — fulfilled = printed, shipped = dispatched)
  • delivered -> "delivered" (only via webhook, not always available)
  • canceled -> "cancelled"
  • failed, onhold -> "submitted" (retriable states)

Extract tracking from shipments array (same pattern as Printify).

fetch_shipping_rates/2

Different approach to Printify. Printful has a live rate calculator rather than static tables:

POST /shipping/rates with recipient country + item variant IDs.

For caching in the ShippingRate table, we need to query representative rates at sync time:

  1. For each unique catalog_variant_id in the synced products, pick one representative variant
  2. Query shipping rates to GB and US (the two countries we show at checkout)
  3. Store using the existing ShippingRate schema, mapping:
    • blueprint_id -> catalog_product_id (repurpose the field)
    • print_provider_id -> 0 (Printful is always the provider)
    • country_code, first_item_cost, additional_item_cost, currency

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.

1.3 Wire into Provider dispatch

File: lib/berrypod/providers/provider.ex

Change line 97:

defp default_for_type("printful"), do: {:ok, Berrypod.Providers.Printful}

1.4 Order submission: multi-provider routing

File: lib/berrypod/orders.ex

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":

defp get_provider_connection_for_order(order) do
  # Get the provider connection from the first item's product
  first_item = List.first(order.items)
  product = Products.get_product(first_item.product_id)
  Products.get_provider_connection(product.provider_connection_id)
end

Phase 2: Mockup generation

2.1 Mockup generation client methods

Already covered in the client (1.1). The flow:

  1. POST /mockup-generator/create-task/{catalog_product_id} with:
    • variant_ids — which colour/size combos to render
    • format"jpg" or "png"
    • files — artwork URL + placement + positioning
  2. Response: {"task_key": "gt-123456", "status": "pending"}
  3. Poll: GET /mockup-generator/task?task_key=gt-123456
  4. When status: "completed", response includes mockups array with URLs

2.2 Mockup worker

New file: lib/berrypod/sync/mockup_generation_worker.ex

Oban worker that generates mockups for a product after sync:

use Oban.Worker, queue: :sync, max_attempts: 3

def perform(%{args: %{"product_id" => product_id}}) do
  product = Products.get_product!(product_id)
  conn = Products.get_provider_connection(product.provider_connection_id)

  if conn.provider_type == "printful" do
    generate_printful_mockups(conn, product)
  else
    :ok  # other providers handle mockups differently
  end
end

Flow:

  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:

    %{
      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.


Phase 4: Webhooks

4.1 Webhook handler

New file: lib/berrypod_web/controllers/printful_webhook_controller.ex

Printful webhooks POST JSON to a configured URL. Events we care about:

  • order_updated — order status changed (most important)
  • package_shipped — shipment dispatched with tracking
  • order_failed — production issue
  • order_canceled — order cancelled
  • package_returned — shipment returned

Handler flow:

  1. Parse JSON body
  2. Match on type field
  3. For order events: look up order by external_id (our order_number), update fulfilment status
  4. For package_shipped: extract tracking number/URL, trigger shipping notification email

4.2 Webhook registration

During connection setup (after test_connection succeeds), register webhooks:

Client.setup_webhooks(webhook_url, [
  "order_updated",
  "package_shipped",
  "order_failed",
  "order_canceled",
  "package_returned"
])

4.3 Route

post "/webhooks/printful", PrintfulWebhookController, :handle

Phase 5: Admin UI updates

5.1 Provider connection form

The existing admin settings page handles provider connections. Currently it's Printify-specific in places (e.g. "Shop ID" in config). Needs:

  • When provider_type == "printful": show token input (same as Printify's API key field)
  • After test_connection: store store_id in config automatically
  • Connection test shows store name on success

5.2 Sync flow

Identical to Printify — the admin clicks "Sync" and the ProductSyncWorker runs. The provider abstraction handles the differences transparently.


Affected files

New files (6)

File Purpose
lib/berrypod/clients/printful.ex HTTP client
lib/berrypod/providers/printful.ex Provider behaviour implementation
lib/berrypod/sync/mockup_generation_worker.ex Async mockup generation
lib/berrypod_web/controllers/printful_webhook_controller.ex Webhook handler
test/berrypod/providers/printful_test.exs Provider tests
test/berrypod/clients/printful_test.exs Client tests (with Req mocking)

Modified files (6)

File Change
lib/berrypod/providers/provider.ex Wire "printful" to Printful module
lib/berrypod/orders.ex Route to correct provider per order (not hardcoded "printify")
lib/berrypod_web/router.ex Add Printful webhook route
config/config.exs (optional) Printful-specific config
lib/berrypod/sync/product_sync_worker.ex Enqueue mockup generation for Printful products
lib/berrypod_web/live/admin/settings.ex Provider form tweaks for Printful

Implementation order

# Task Est Notes
1 HTTP client (clients/printful.ex) 1.5h All API methods, Req-based, process dict auth
2 Provider implementation (sync + orders) 3h fetch_products, submit_order, get_order_status, normalization
3 Wire dispatch + order routing 1h provider.ex dispatch, orders.ex provider lookup from product
4 Shipping rates (fetch_shipping_rates) 1.5h Live rate queries, cache in ShippingRate schema
5 Mockup generation worker 2h Async task submission, polling, image pipeline
6 Webhook handler + registration 1.5h Controller, route, event handling
7 Admin UI tweaks 1h Provider-specific form fields
8 Tests 3h Mox provider tests, client tests with Req stubs
9 Manual integration testing 1.5h Real Printful account, end-to-end flow

Total: ~16 hours (~8 sessions)

Session groupings

  • Sessions 1-2: Client + provider implementation (tasks 1-3)
  • Sessions 3-4: Shipping rates + mockup generation (tasks 4-5)
  • Sessions 5-6: Webhooks + admin UI (tasks 6-7)
  • Sessions 7-8: Tests + integration testing (tasks 8-9)

Decisions and trade-offs

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
  8. Printify integration unchanged and still works