feat: add Printify webhook endpoint for real-time product updates

- Add /webhooks/printify endpoint with HMAC-SHA256 signature verification
- Add Webhooks context to handle product:updated, product:deleted events
- Add ProductDeleteWorker for async product deletion
- Add webhook API methods to Printify client (create, list, delete)
- Add register_webhooks/2 to Printify provider
- Add mix register_webhooks task for one-time webhook registration
- Cache raw request body in endpoint for signature verification

Usage:
1. Generate webhook secret: openssl rand -hex 20
2. Add to provider connection config as "webhook_secret"
3. Register with Printify: mix register_webhooks https://yourshop.com/webhooks/printify

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-01-31 22:41:15 +00:00
parent a2157177b8
commit a9c15ea6ae
13 changed files with 596 additions and 4 deletions

View File

@ -0,0 +1,69 @@
defmodule Mix.Tasks.RegisterWebhooks do
@moduledoc """
Registers Printify webhooks for a shop.
Usage:
mix register_webhooks https://yourshop.com/webhooks/printify
Prerequisites:
- Printify provider connection must exist with shop_id configured
- webhook_secret must be set in the connection config
Generate a webhook secret with:
openssl rand -hex 20
"""
use Mix.Task
@shortdoc "Register Printify webhooks for product updates"
@impl Mix.Task
def run([url]) do
Mix.Task.run("app.start")
alias SimpleshopTheme.Products
alias SimpleshopTheme.Providers.Printify
case Products.get_provider_connection_by_type("printify") do
nil ->
Mix.shell().error("No Printify connection found. Create one at /admin/providers first.")
conn ->
Mix.shell().info("Registering webhooks for URL: #{url}")
case Printify.register_webhooks(conn, url) do
{:ok, results} ->
Enum.each(results, fn
{:ok, event, response} ->
Mix.shell().info("✓ Registered: #{event} (ID: #{response["id"]})")
{:error, event, reason} ->
Mix.shell().error("✗ Failed: #{event} - #{inspect(reason)}")
end)
Mix.shell().info("\nDone!")
{:error, :no_webhook_secret} ->
Mix.shell().error("""
No webhook secret configured.
Generate one with: openssl rand -hex 20
Then add it to your provider connection config as "webhook_secret".
""")
{:error, reason} ->
Mix.shell().error("Failed: #{inspect(reason)}")
end
end
end
def run(_) do
Mix.shell().error("""
Usage: mix register_webhooks <url>
Example:
mix register_webhooks https://yourshop.com/webhooks/printify
""")
end
end

View File

@ -183,6 +183,41 @@ defmodule SimpleshopTheme.Clients.Printify do
get("/shops/#{shop_id}/orders/#{order_id}.json") get("/shops/#{shop_id}/orders/#{order_id}.json")
end end
# =============================================================================
# Webhooks
# =============================================================================
@doc """
Register a webhook with Printify.
## Event types
- "product:publish:started"
- "product:updated"
- "product:deleted"
- "shop:disconnected"
"""
def create_webhook(shop_id, url, topic, secret) do
post("/shops/#{shop_id}/webhooks.json", %{
topic: topic,
url: url,
secret: secret
})
end
@doc """
List registered webhooks for a shop.
"""
def list_webhooks(shop_id) do
get("/shops/#{shop_id}/webhooks.json")
end
@doc """
Delete a webhook.
"""
def delete_webhook(shop_id, webhook_id) do
delete("/shops/#{shop_id}/webhooks/#{webhook_id}.json")
end
@doc """ @doc """
Download a file from a URL to a local path. Download a file from a URL to a local path.
""" """

View File

@ -114,6 +114,66 @@ defmodule SimpleshopTheme.Providers.Printify do
end end
end end
# =============================================================================
# Webhook Registration
# =============================================================================
@webhook_events ["product:updated", "product:deleted", "product:publish:started"]
@doc """
Registers webhooks for product events with Printify.
Returns {:ok, results} or {:error, reason}.
"""
def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do
shop_id = config["shop_id"]
secret = config["webhook_secret"]
cond do
is_nil(shop_id) ->
{:error, :no_shop_id}
is_nil(secret) or secret == "" ->
{:error, :no_webhook_secret}
true ->
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
:ok <- set_api_key(api_key) do
results =
Enum.map(@webhook_events, fn event ->
case Client.create_webhook(shop_id, webhook_url, event, secret) do
{:ok, response} -> {:ok, event, response}
{:error, reason} -> {:error, event, reason}
end
end)
{:ok, results}
else
nil -> {:error, :no_api_key}
end
end
end
@doc """
Lists registered webhooks for the shop.
"""
def list_webhooks(%ProviderConnection{config: config} = conn) do
shop_id = config["shop_id"]
if is_nil(shop_id) do
{:error, :no_shop_id}
else
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
:ok <- set_api_key(api_key),
{:ok, webhooks} <- Client.list_webhooks(shop_id) do
{:ok, webhooks}
else
nil -> {:error, :no_api_key}
{:error, _} = error -> error
end
end
end
# ============================================================================= # =============================================================================
# Data Normalization # Data Normalization
# ============================================================================= # =============================================================================

View File

@ -0,0 +1,45 @@
defmodule SimpleshopTheme.Webhooks do
@moduledoc """
Handles incoming webhook events from POD providers.
"""
alias SimpleshopTheme.Products
alias SimpleshopTheme.Sync.ProductSyncWorker
alias SimpleshopTheme.Webhooks.ProductDeleteWorker
require Logger
@doc """
Handles a Printify webhook event.
Returns :ok or {:ok, job} on success, {:error, reason} on failure.
"""
def handle_printify_event("product:updated", %{"id" => _product_id}) do
enqueue_product_sync()
end
def handle_printify_event("product:publish:started", %{"id" => _product_id}) do
enqueue_product_sync()
end
def handle_printify_event("product:deleted", %{"id" => product_id}) do
ProductDeleteWorker.enqueue(product_id)
end
def handle_printify_event("shop:disconnected", _resource) do
Logger.warning("Printify shop disconnected - manual intervention needed")
:ok
end
def handle_printify_event(event_type, _resource) do
Logger.info("Ignoring unhandled Printify event: #{event_type}")
:ok
end
defp enqueue_product_sync do
case Products.get_provider_connection_by_type("printify") do
nil -> {:error, :no_connection}
conn -> ProductSyncWorker.enqueue(conn.id)
end
end
end

View File

@ -0,0 +1,41 @@
defmodule SimpleshopTheme.Webhooks.ProductDeleteWorker do
@moduledoc """
Oban worker for deleting products removed from POD providers.
"""
use Oban.Worker, queue: :sync, max_attempts: 3
alias SimpleshopTheme.Products
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"provider_product_id" => provider_product_id}}) do
case Products.get_provider_connection_by_type("printify") do
nil ->
Logger.warning("No Printify connection found for product deletion")
{:cancel, :no_connection}
conn ->
case Products.get_product_by_provider(conn.id, provider_product_id) do
nil ->
Logger.info("Product #{provider_product_id} already deleted or not found")
:ok
product ->
Logger.info("Deleting product #{product.id} (provider: #{provider_product_id})")
case Products.delete_product(product) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
end
def enqueue(provider_product_id) do
%{provider_product_id: to_string(provider_product_id)}
|> new()
|> Oban.insert()
end
end

View File

@ -0,0 +1,36 @@
defmodule SimpleshopThemeWeb.WebhookController do
use SimpleshopThemeWeb, :controller
alias SimpleshopTheme.Webhooks
require Logger
@doc """
Receives Printify webhook events.
Events:
- product:publish:started - Product publish initiated
- product:updated - Product was modified
- product:deleted - Product was deleted
- shop:disconnected - Shop was disconnected
"""
def printify(conn, params) do
event_type = params["type"] || params["event"]
resource = params["resource"] || params["data"] || %{}
Logger.info("Received Printify webhook: #{event_type}")
case Webhooks.handle_printify_event(event_type, resource) do
:ok ->
json(conn, %{status: "ok"})
{:ok, _} ->
json(conn, %{status: "ok"})
{:error, reason} ->
Logger.warning("Webhook handling failed: #{inspect(reason)}")
# Still return 200 to prevent Printify retrying
json(conn, %{status: "ok"})
end
end
end

View File

@ -54,6 +54,7 @@ defmodule SimpleshopThemeWeb.Endpoint do
plug Plug.Parsers, plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json], parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"], pass: ["*/*"],
body_reader: {SimpleshopThemeWeb.Plugs.CacheRawBody, :read_body, []},
json_decoder: Phoenix.json_library() json_decoder: Phoenix.json_library()
plug Plug.MethodOverride plug Plug.MethodOverride

View File

@ -0,0 +1,22 @@
defmodule SimpleshopThemeWeb.Plugs.CacheRawBody do
@moduledoc """
Custom body reader that caches the raw request body for webhook signature verification.
Used with Plug.Parsers :body_reader option.
"""
def read_body(conn, opts) do
case Plug.Conn.read_body(conn, opts) do
{:ok, body, conn} ->
conn = Plug.Conn.assign(conn, :raw_body, body)
{:ok, body, conn}
{:more, body, conn} ->
existing = conn.assigns[:raw_body] || ""
conn = Plug.Conn.assign(conn, :raw_body, existing <> body)
{:more, body, conn}
{:error, reason} ->
{:error, reason}
end
end
end

View File

@ -0,0 +1,63 @@
defmodule SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook do
@moduledoc """
Verifies Printify webhook signatures using HMAC-SHA256.
Expects:
- Raw body cached in conn.assigns[:raw_body]
- X-Pfy-Signature header in format "sha256={hex_digest}"
- Webhook secret stored in provider connection config
"""
import Plug.Conn
require Logger
alias SimpleshopTheme.Products
def init(opts), do: opts
def call(conn, _opts) do
with {:ok, signature} <- get_signature(conn),
{:ok, secret} <- get_webhook_secret(),
:ok <- verify_signature(conn.assigns[:raw_body], secret, signature) do
conn
else
{:error, reason} ->
Logger.warning("Printify webhook verification failed: #{reason}")
conn
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: "Invalid signature"}))
|> halt()
end
end
defp get_signature(conn) do
case get_req_header(conn, "x-pfy-signature") do
["sha256=" <> hex_digest] -> {:ok, hex_digest}
[_other] -> {:error, :invalid_signature_format}
[] -> {:error, :missing_signature}
end
end
defp get_webhook_secret do
case Products.get_provider_connection_by_type("printify") do
%{config: %{"webhook_secret" => secret}} when is_binary(secret) and secret != "" ->
{:ok, secret}
_ ->
{:error, :no_webhook_secret}
end
end
defp verify_signature(body, secret, expected_hex) do
computed =
:crypto.mac(:hmac, :sha256, secret, body || "")
|> Base.encode16(case: :lower)
if Plug.Crypto.secure_compare(computed, String.downcase(expected_hex)) do
:ok
else
{:error, :signature_mismatch}
end
end
end

View File

@ -17,6 +17,10 @@ defmodule SimpleshopThemeWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end end
pipeline :printify_webhook do
plug SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook
end
pipeline :shop do pipeline :shop do
plug :put_root_layout, html: {SimpleshopThemeWeb.Layouts, :shop_root} plug :put_root_layout, html: {SimpleshopThemeWeb.Layouts, :shop_root}
plug SimpleshopThemeWeb.Plugs.LoadTheme plug SimpleshopThemeWeb.Plugs.LoadTheme
@ -46,10 +50,12 @@ defmodule SimpleshopThemeWeb.Router do
get "/:id/recolored/:color", ImageController, :recolored_svg get "/:id/recolored/:color", ImageController, :recolored_svg
end end
# Other scopes may use custom stacks. # Webhook endpoints (no CSRF, signature verified)
# scope "/api", SimpleshopThemeWeb do scope "/webhooks", SimpleshopThemeWeb do
# pipe_through :api pipe_through [:api, :printify_webhook]
# end
post "/printify", WebhookController, :printify
end
# Enable LiveDashboard and Swoosh mailbox preview in development # Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:simpleshop_theme, :dev_routes) do if Application.compile_env(:simpleshop_theme, :dev_routes) do

View File

@ -0,0 +1,48 @@
defmodule SimpleshopTheme.Webhooks.ProductDeleteWorkerTest do
use SimpleshopTheme.DataCase
use Oban.Testing, repo: SimpleshopTheme.Repo
alias SimpleshopTheme.Webhooks.ProductDeleteWorker
alias SimpleshopTheme.Products
import SimpleshopTheme.ProductsFixtures
describe "perform/1" do
test "deletes product when found" do
conn = provider_connection_fixture(%{provider_type: "printify"})
{:ok, product, :created} =
Products.upsert_product(conn, %{
provider_product_id: "test-product-123",
title: "Test Product",
provider_data: %{}
})
assert Products.get_product(product.id) != nil
assert :ok =
perform_job(ProductDeleteWorker, %{
provider_product_id: "test-product-123"
})
assert Products.get_product(product.id) == nil
end
test "returns ok when product not found" do
_conn = provider_connection_fixture(%{provider_type: "printify"})
assert :ok =
perform_job(ProductDeleteWorker, %{
provider_product_id: "nonexistent-product"
})
end
test "cancels when no provider connection" do
# No connection created
assert {:cancel, :no_connection} =
perform_job(ProductDeleteWorker, %{
provider_product_id: "test-product-123"
})
end
end
end

View File

@ -0,0 +1,65 @@
defmodule SimpleshopTheme.WebhooksTest do
use SimpleshopTheme.DataCase
alias SimpleshopTheme.Webhooks
import SimpleshopTheme.ProductsFixtures
setup do
conn = provider_connection_fixture(%{provider_type: "printify"})
{:ok, provider_connection: conn}
end
describe "handle_printify_event/2" do
test "product:updated triggers sync", %{provider_connection: _conn} do
# With inline Oban, the job executes immediately (and fails due to no real API key)
# But the handler should still return {:ok, _} after inserting the job
result =
Webhooks.handle_printify_event(
"product:updated",
%{"id" => "123", "shop_id" => "456"}
)
assert {:ok, %Oban.Job{}} = result
end
test "product:publish:started triggers sync", %{provider_connection: _conn} do
result =
Webhooks.handle_printify_event(
"product:publish:started",
%{"id" => "123"}
)
assert {:ok, %Oban.Job{}} = result
end
test "product:deleted triggers delete", %{provider_connection: _conn} do
result =
Webhooks.handle_printify_event(
"product:deleted",
%{"id" => "123"}
)
assert {:ok, %Oban.Job{}} = result
end
test "shop:disconnected returns ok" do
assert :ok = Webhooks.handle_printify_event("shop:disconnected", %{})
end
test "unknown event returns ok" do
assert :ok = Webhooks.handle_printify_event("unknown:event", %{})
end
test "returns error when no provider connection" do
# Delete all connections first
SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection)
assert {:error, :no_connection} =
Webhooks.handle_printify_event(
"product:updated",
%{"id" => "123"}
)
end
end
end

View File

@ -0,0 +1,101 @@
defmodule SimpleshopThemeWeb.WebhookControllerTest do
use SimpleshopThemeWeb.ConnCase
import SimpleshopTheme.ProductsFixtures
@webhook_secret "test_webhook_secret_123"
setup do
_conn =
provider_connection_fixture(%{
provider_type: "printify",
config: %{"shop_id" => "12345", "webhook_secret" => @webhook_secret}
})
:ok
end
describe "POST /webhooks/printify" do
test "returns 401 without signature", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post(~p"/webhooks/printify", %{type: "product:updated"})
assert json_response(conn, 401)["error"] == "Invalid signature"
end
test "returns 401 with invalid signature", %{conn: conn} do
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123"}})
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pfy-signature", "sha256=invalid")
|> post(~p"/webhooks/printify", body)
assert json_response(conn, 401)["error"] == "Invalid signature"
end
test "accepts valid signature and returns 200", %{conn: conn} do
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123", shop_id: "12345"}})
signature = compute_signature(body, @webhook_secret)
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|> post(~p"/webhooks/printify", body)
assert json_response(conn, 200)["status"] == "ok"
end
test "handles product:updated event", %{conn: conn} do
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123", shop_id: "12345"}})
signature = compute_signature(body, @webhook_secret)
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|> post(~p"/webhooks/printify", body)
# Should return 200 even if job processing fails (inline mode tries to execute)
assert json_response(conn, 200)["status"] == "ok"
end
test "handles product:deleted event", %{conn: conn} do
body = Jason.encode!(%{type: "product:deleted", resource: %{id: "456"}})
signature = compute_signature(body, @webhook_secret)
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|> post(~p"/webhooks/printify", body)
assert json_response(conn, 200)["status"] == "ok"
end
test "returns 401 when no webhook secret configured", %{conn: conn} do
# Remove the provider connection to simulate no secret
SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection)
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123"}})
signature = compute_signature(body, @webhook_secret)
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|> post(~p"/webhooks/printify", body)
assert json_response(conn, 401)["error"] == "Invalid signature"
end
end
defp compute_signature(body, secret) do
:crypto.mac(:hmac, :sha256, secret, body)
|> Base.encode16(case: :lower)
end
end