2026-02-15 09:01:05 +00:00
|
|
|
defmodule SimpleshopTheme.Clients.Printful do
|
|
|
|
|
@moduledoc """
|
|
|
|
|
HTTP client for the Printful API.
|
|
|
|
|
|
|
|
|
|
Uses v2 endpoints where available, v1 for sync products (store products).
|
|
|
|
|
Handles authentication via Bearer tokens stored in the process dictionary.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@base_url "https://api.printful.com"
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Auth
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get the API token.
|
|
|
|
|
|
|
|
|
|
Checks process dictionary first (for provider connections with stored credentials),
|
|
|
|
|
then falls back to environment variable (for development/testing).
|
|
|
|
|
"""
|
|
|
|
|
def api_token do
|
|
|
|
|
Process.get(:printful_api_key) ||
|
|
|
|
|
System.get_env("PRINTFUL_API_TOKEN") ||
|
|
|
|
|
raise "PRINTFUL_API_TOKEN environment variable is not set"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get the store ID from the process dictionary.
|
|
|
|
|
"""
|
|
|
|
|
def store_id do
|
|
|
|
|
Process.get(:printful_store_id)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Core HTTP
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Make a GET request to the Printful API.
|
|
|
|
|
"""
|
|
|
|
|
def get(path, _opts \\ []) do
|
|
|
|
|
url = @base_url <> path
|
|
|
|
|
|
|
|
|
|
case Req.get(url, headers: auth_headers(), receive_timeout: 30_000) do
|
|
|
|
|
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
|
|
|
|
{:ok, unwrap_response(path, body)}
|
|
|
|
|
|
|
|
|
|
{:ok, %Req.Response{status: status, body: body}} ->
|
|
|
|
|
{:error, {status, body}}
|
|
|
|
|
|
|
|
|
|
{:error, reason} ->
|
|
|
|
|
{:error, reason}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Make a POST request to the Printful API.
|
|
|
|
|
"""
|
|
|
|
|
def post(path, body, _opts \\ []) do
|
|
|
|
|
url = @base_url <> path
|
|
|
|
|
|
|
|
|
|
case Req.post(url, json: body, headers: auth_headers(), receive_timeout: 60_000) do
|
|
|
|
|
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
|
|
|
|
{:ok, unwrap_response(path, body)}
|
|
|
|
|
|
|
|
|
|
{:ok, %Req.Response{status: status, body: body}} ->
|
|
|
|
|
{:error, {status, body}}
|
|
|
|
|
|
|
|
|
|
{:error, reason} ->
|
|
|
|
|
{:error, reason}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Make a DELETE request to the Printful API.
|
|
|
|
|
"""
|
|
|
|
|
def delete(path, _opts \\ []) do
|
|
|
|
|
url = @base_url <> path
|
|
|
|
|
|
|
|
|
|
case Req.delete(url, headers: auth_headers(), receive_timeout: 30_000) do
|
|
|
|
|
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
|
|
|
|
{:ok, unwrap_response(path, body)}
|
|
|
|
|
|
|
|
|
|
{:ok, %Req.Response{status: 204}} ->
|
|
|
|
|
{:ok, nil}
|
|
|
|
|
|
|
|
|
|
{:ok, %Req.Response{status: status, body: body}} ->
|
|
|
|
|
{:error, {status, body}}
|
|
|
|
|
|
|
|
|
|
{:error, reason} ->
|
|
|
|
|
{:error, reason}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# v1 responses wrap data in "result", v2 wraps in "data"
|
|
|
|
|
defp unwrap_response("/v2/" <> _, %{"data" => data}), do: data
|
|
|
|
|
defp unwrap_response(_path, %{"code" => _, "result" => result}), do: result
|
|
|
|
|
defp unwrap_response(_path, body), do: body
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Stores (v2)
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
List all stores for the authenticated account.
|
|
|
|
|
"""
|
|
|
|
|
def get_stores do
|
|
|
|
|
get("/v2/stores")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get the first store's ID.
|
|
|
|
|
"""
|
|
|
|
|
def get_store_id do
|
|
|
|
|
case get_stores() do
|
|
|
|
|
{:ok, stores} when is_list(stores) and stores != [] ->
|
|
|
|
|
{:ok, hd(stores)["id"]}
|
|
|
|
|
|
|
|
|
|
{:ok, []} ->
|
|
|
|
|
{:error, :no_stores}
|
|
|
|
|
|
|
|
|
|
error ->
|
|
|
|
|
error
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Catalogue (v2)
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get a catalogue product by ID.
|
|
|
|
|
"""
|
|
|
|
|
def get_catalog_product(product_id) do
|
|
|
|
|
get("/v2/catalog-products/#{product_id}")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get variants for a catalogue product.
|
|
|
|
|
"""
|
|
|
|
|
def get_catalog_variants(product_id) do
|
|
|
|
|
get("/v2/catalog-products/#{product_id}/catalog-variants")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get stock availability for a catalogue product.
|
|
|
|
|
"""
|
|
|
|
|
def get_product_availability(product_id) do
|
|
|
|
|
get("/v2/catalog-products/#{product_id}/availability")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Sync Products (v1 — no v2 equivalent)
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
List the seller's configured products (sync products).
|
|
|
|
|
|
|
|
|
|
These are products the seller has set up in Printful's dashboard with designs.
|
|
|
|
|
Supports pagination via `offset` and `limit` options.
|
|
|
|
|
"""
|
|
|
|
|
def list_sync_products(opts \\ []) do
|
|
|
|
|
offset = Keyword.get(opts, :offset, 0)
|
|
|
|
|
limit = Keyword.get(opts, :limit, 20)
|
|
|
|
|
get("/store/products?offset=#{offset}&limit=#{limit}")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get a single sync product with all its variants and files.
|
|
|
|
|
"""
|
|
|
|
|
def get_sync_product(product_id) do
|
|
|
|
|
get("/store/products/#{product_id}")
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-15 16:52:53 +00:00
|
|
|
@doc """
|
|
|
|
|
Create a sync product with variants and design files.
|
|
|
|
|
|
|
|
|
|
## Example
|
|
|
|
|
|
|
|
|
|
create_sync_product(%{
|
|
|
|
|
sync_product: %{name: "My T-Shirt"},
|
|
|
|
|
sync_variants: [%{
|
|
|
|
|
variant_id: 4011,
|
|
|
|
|
retail_price: "29.99",
|
|
|
|
|
files: [%{url: "https://example.com/design.png", type: "default"}]
|
|
|
|
|
}]
|
|
|
|
|
})
|
|
|
|
|
"""
|
|
|
|
|
def create_sync_product(product_data) do
|
|
|
|
|
post("/store/products", product_data)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Delete a sync product and all its variants.
|
|
|
|
|
"""
|
|
|
|
|
def delete_sync_product(product_id) do
|
|
|
|
|
delete("/store/products/#{product_id}")
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-15 09:01:05 +00:00
|
|
|
# =============================================================================
|
|
|
|
|
# Shipping (v2)
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Calculate shipping rates for a set of items to a recipient.
|
|
|
|
|
|
|
|
|
|
## Example
|
|
|
|
|
|
|
|
|
|
calculate_shipping(
|
|
|
|
|
%{country_code: "GB"},
|
|
|
|
|
[%{source: "catalog", catalog_variant_id: 474, quantity: 1}]
|
|
|
|
|
)
|
|
|
|
|
"""
|
|
|
|
|
def calculate_shipping(recipient, items) do
|
2026-02-15 09:15:47 +00:00
|
|
|
post("/v2/shipping-rates", %{recipient: recipient, order_items: items})
|
2026-02-15 09:01:05 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Orders (v2)
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Create a new order (draft status).
|
|
|
|
|
"""
|
|
|
|
|
def create_order(order_data) do
|
|
|
|
|
post("/v2/orders", order_data)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Confirm an order for fulfilment. This triggers charges and production.
|
|
|
|
|
"""
|
|
|
|
|
def confirm_order(order_id) do
|
|
|
|
|
post("/v2/orders/#{order_id}/confirmation", %{})
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get an order by ID.
|
|
|
|
|
"""
|
|
|
|
|
def get_order(order_id) do
|
|
|
|
|
get("/v2/orders/#{order_id}")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get shipments for an order.
|
|
|
|
|
"""
|
|
|
|
|
def get_order_shipments(order_id) do
|
|
|
|
|
get("/v2/orders/#{order_id}/shipments")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Mockups (v2)
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Create a mockup generation task.
|
|
|
|
|
"""
|
|
|
|
|
def create_mockup_task(body) do
|
|
|
|
|
post("/v2/mockup-tasks", body)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get mockup task results. Pass `task_id` to poll a specific task.
|
|
|
|
|
"""
|
|
|
|
|
def get_mockup_tasks(params \\ %{}) do
|
|
|
|
|
query = URI.encode_query(params)
|
|
|
|
|
path = if query == "", do: "/v2/mockup-tasks", else: "/v2/mockup-tasks?#{query}"
|
|
|
|
|
get(path)
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-15 16:52:53 +00:00
|
|
|
# =============================================================================
|
|
|
|
|
# Mockup generator (legacy, multi-angle)
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Create a mockup generator task for a catalog product.
|
|
|
|
|
|
|
|
|
|
Returns `{:ok, %{"task_key" => "gt-...", "status" => "pending"}}`.
|
|
|
|
|
"""
|
|
|
|
|
def create_mockup_generator_task(catalog_product_id, body) do
|
|
|
|
|
post("/mockup-generator/create-task/#{catalog_product_id}", body)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Poll a mockup generator task by task key.
|
|
|
|
|
|
|
|
|
|
Returns `{:ok, %{"status" => "completed", "mockups" => [...]}}` when done.
|
|
|
|
|
"""
|
|
|
|
|
def get_mockup_generator_task(task_key) do
|
|
|
|
|
get("/mockup-generator/task?task_key=#{task_key}")
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-15 09:01:05 +00:00
|
|
|
# =============================================================================
|
|
|
|
|
# Files (v2)
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Upload a file to the Printful file library.
|
|
|
|
|
"""
|
|
|
|
|
def upload_file(url) do
|
|
|
|
|
post("/v2/files", %{url: url})
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Webhooks (v2)
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Set up webhook configuration.
|
|
|
|
|
"""
|
|
|
|
|
def setup_webhooks(url, events) do
|
|
|
|
|
post("/v2/webhooks", %{url: url, events: events})
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Get current webhook configuration.
|
|
|
|
|
"""
|
|
|
|
|
def get_webhooks do
|
|
|
|
|
get("/v2/webhooks")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Disable webhook support.
|
|
|
|
|
"""
|
|
|
|
|
def delete_webhooks do
|
|
|
|
|
delete("/v2/webhooks")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# File Downloads
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Download a file from a URL to a local path.
|
|
|
|
|
"""
|
|
|
|
|
def download_file(url, output_path) do
|
|
|
|
|
case Req.get(url, into: File.stream!(output_path), receive_timeout: 60_000) do
|
|
|
|
|
{:ok, %Req.Response{status: status}} when status in 200..299 ->
|
|
|
|
|
{:ok, output_path}
|
|
|
|
|
|
|
|
|
|
{:ok, %Req.Response{status: status}} ->
|
|
|
|
|
File.rm(output_path)
|
|
|
|
|
{:error, {:http_error, status}}
|
|
|
|
|
|
|
|
|
|
{:error, reason} ->
|
|
|
|
|
File.rm(output_path)
|
|
|
|
|
{:error, reason}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Private
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
defp auth_headers do
|
|
|
|
|
headers = [
|
|
|
|
|
{"Authorization", "Bearer #{api_token()}"},
|
|
|
|
|
{"Content-Type", "application/json"}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
case store_id() do
|
|
|
|
|
nil -> headers
|
|
|
|
|
id -> [{"X-PF-Store-Id", to_string(id)} | headers]
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|