A flexible Elixir client library for Square API integration, focused on subscription management and payment processing.
- 💳 JSON-Driven Payment Plans - Complete guide for JSON-based plan management
- 🚀 Multi-App Payments - Strategy for multiple apps sharing Square
- 📖 Webhook Integration Guide - Complete guide for webhook implementation
- 📝 Changelog - Version history and changes
- 🧪 Test Cards - Test credit card numbers for sandbox
- JSON-driven payment plans - Manage plans and pricing without code changes
- Direct Square API integration - No proxy service or message queue required
- Webhook handling infrastructure - Standardized webhook processing with signature verification
- Subscription plan and variation management - Following Square's recommended patterns
- Reusable subscription schema - Drop-in Ecto schema with Square sync capabilities
- Prorated refund calculations - Automatic refund processing for subscription cancellations
- One-time purchase support - Sell passes and time-limited access
- Subscription access control generators - Complete auth system for premium features
- LiveView and Plug authentication - Protect routes and LiveViews based on subscription status
- Comprehensive test generators - 36 pre-built tests for subscription authentication
- Synchronous REST API - Immediate feedback for payment processing
- Environment-aware configuration - Automatic sandbox/production switching
- Runtime configuration validation - Catch missing config at app startup with helpful errors
- Comprehensive test coverage - Fast (0.1s), clean tests with mocked API calls
- Multiple configuration methods - Application config, environment variables, or defaults
- Behaviour-based extensibility - Implement webhooks consistently across all apps
The easiest way to add Square integration to your Phoenix app:
Step 1: Add the dependency
# In mix.exs
def deps do
[
{:square_client, github: "zyzyva/square_client"}
]
endStep 2: Install the dependency
mix deps.getStep 3: Run the installer
mix square_client.installThis automatically generates:
- ✅ Subscription schema (
lib/your_app/payments/subscription.ex) - ✅ Webhook handler implementation (
lib/your_app/payments/square_webhook_handler.ex) - ✅ Webhook controller (
lib/your_app_web/controllers/square_webhook_controller.ex) - ✅ Database migration (
priv/repo/migrations/TIMESTAMP_create_subscriptions.exs) - ✅ Configuration files (
config/config.exsandconfig/prod.exs)
The installer auto-detects your Phoenix app structure and assumes standard gen.auth conventions (User module, user_id foreign key).
Optional: Generate authentication helpers
# Add subscription-based access control
mix square_client.gen.auth
# Generate comprehensive test suite
mix square_client.gen.auth_testsThis adds:
- ✅ Subscription auth plugs for HTTP routes
- ✅ LiveView hooks for real-time authentication
- ✅ Template helpers for conditional rendering
- ✅ 36 pre-built tests for complete coverage
- ✅ Test fixtures for subscriptions
Step 4: Complete the manual steps
After running the installer, you'll see instructions for:
- Adding runtime validation to
application.ex - Adding webhook route to
router.ex - Running the migration
- Setting environment variables
Step 5: Run and configure
# Run the migration
mix ecto.migrate
# Set environment variables
export SQUARE_ACCESS_TOKEN="your_sandbox_token"
export SQUARE_LOCATION_ID="your_location_id"
# Start your app
mix phx.serverIf you prefer to set everything up yourself, add the dependency:
def deps do
[
{:square_client, github: "zyzyva/square_client"}
]
endThen follow the Quick Start guide below to configure manually.
# In your application.ex
def start(_type, _args) do
# Validate Square config at startup
SquareClient.Config.validate_runtime!()
children = [
# ... your other children
]
# ...
end# config/config.exs
config :square_client,
api_url: "https://connect.squareupsandbox.com/v2",
access_token: System.get_env("SQUARE_ACCESS_TOKEN"),
location_id: System.get_env("SQUARE_LOCATION_ID"),
webhook_handler: MyApp.Payments.SquareWebhookHandler
# config/prod.exs
config :square_client,
api_url: "https://connect.squareup.com/v2" # Production URLdefmodule MyApp.Payments.Subscription do
use SquareClient.Subscriptions.Schema,
repo: MyApp.Repo,
belongs_to: [
{:user, MyApp.Accounts.User}
]
enddefmodule MyApp.Payments.SquareWebhookHandler do
@behaviour SquareClient.WebhookHandler
def handle_event(%{event_type: "subscription.created", data: data}) do
# Sync subscription to your database
:ok
end
def handle_event(_event), do: :ok
endThat's it! You now have a complete Square integration with subscriptions, webhooks, and refunds.
The library includes powerful generators for implementing subscription-based access control in your Phoenix application. This provides a complete authentication and authorization system for premium features.
After running the main installer, generate the auth helpers:
# Generate authentication helpers
mix square_client.gen.auth
# Generate comprehensive test suite
mix square_client.gen.auth_tests-
Subscription Auth Module (
lib/your_app_web/subscription_auth.ex)- Plug-based authentication for HTTP routes
- Helper functions for templates
- API access control with 402 Payment Required responses
-
LiveView Hooks (
lib/your_app_web/subscription_hooks.ex)on_mounthooks for LiveView authentication- Automatic subscription status assignment
- Plan-specific access control
- Comprehensive Test Coverage (36 tests total)
- Plug authentication tests
- LiveView hook tests
- Context function tests
- Test fixtures for subscriptions
After generating the auth helpers, add these functions to your Payments context:
# In lib/your_app/payments/payments.ex
# Add ID-based overload for has_premium?
def has_premium?(user_id) when is_integer(user_id) do
case Accounts.get_user!(user_id) do
nil -> false
user -> has_premium?(user)
end
rescue
Ecto.NoResultsError -> false
end
def has_premium?(_), do: false
# Check if user has a specific plan
def has_plan?(%User{} = user, plan_id) when is_binary(plan_id) do
case get_active_subscription(user) do
nil -> false
subscription -> subscription.plan_id == plan_id && subscription.status == "ACTIVE"
end
end
def has_plan?(user_id, plan_id) when is_integer(user_id) and is_binary(plan_id) do
case Accounts.get_user!(user_id) do
nil -> false
user -> has_plan?(user, plan_id)
end
rescue
Ecto.NoResultsError -> false
end
def has_plan?(_, _), do: false
# Get current plan for a user
def get_current_plan(%User{} = user) do
case get_active_subscription(user) do
nil -> "free"
%{status: "ACTIVE", plan_id: plan_id} -> plan_id
_ -> "free"
end
end
def get_current_plan(user_id) when is_integer(user_id) do
case Accounts.get_user!(user_id) do
nil -> "free"
user -> get_current_plan(user)
end
rescue
Ecto.NoResultsError -> "free"
end
def get_current_plan(_), do: "free"
# Check if user has access to a feature
def has_feature?(%User{} = user, feature) when is_atom(feature) or is_binary(feature) do
plan_id = get_current_plan(user)
case SquareClient.Plans.get_plan_features(plan_id) do
nil -> false
features when is_list(features) ->
feature_str = to_string(feature)
Enum.member?(features, feature_str)
_ -> false
end
end
def has_feature?(user_id, feature) when is_integer(user_id) do
case Accounts.get_user!(user_id) do
nil -> false
user -> has_feature?(user, feature)
end
rescue
Ecto.NoResultsError -> false
end
def has_feature?(_, _), do: false# In router.ex
import YourAppWeb.SubscriptionAuth
# Create a pipeline for premium routes
pipeline :require_premium do
plug :require_premium
end
# Protect entire scopes
scope "/premium", YourAppWeb do
pipe_through [:browser, :require_authenticated_user, :require_premium]
get "/analytics", AnalyticsController, :index
get "/export", ExportController, :new
end
# Or protect individual routes
scope "/", YourAppWeb do
pipe_through [:browser, :require_authenticated_user]
get "/settings", SettingsController, :index
get "/settings/billing", SettingsController, :billing |> require_premium()
end
# Require specific plans
pipeline :require_yearly_plan do
plug :require_plan, "premium_yearly"
end
# API endpoints with 402 Payment Required
scope "/api", YourAppWeb do
pipe_through [:api, :authenticate_api]
post "/export", ApiController, :export |> require_api_subscription()
end# In router.ex
# Protect entire live_session
live_session :premium_features,
on_mount: [
{YourAppWeb.UserAuth, :ensure_authenticated},
{YourAppWeb.SubscriptionHooks, :require_premium}
] do
live "/analytics", AnalyticsLive, :index
live "/reports", ReportsLive, :index
end
# Require specific plan
live_session :yearly_features,
on_mount: [
{YourAppWeb.UserAuth, :ensure_authenticated},
{YourAppWeb.SubscriptionHooks, {:require_plan, "premium_yearly"}}
] do
live "/advanced-analytics", AdvancedAnalyticsLive, :index
end
# Assign subscription status without enforcing
live_session :mixed_access,
on_mount: [
{YourAppWeb.UserAuth, :ensure_authenticated},
{YourAppWeb.SubscriptionHooks, :assign_subscription}
] do
live "/dashboard", DashboardLive, :index
end<!-- In templates -->
<%= if YourAppWeb.SubscriptionAuth.has_premium?(@conn) do %>
<.link navigate="/premium-feature" class="btn-primary">
Access Premium Feature
</.link>
<% else %>
<.link navigate="/subscription" class="btn-upgrade">
Upgrade to Premium
</.link>
<% end %>
<!-- Check specific plans -->
<%= if YourAppWeb.SubscriptionAuth.has_plan?(@conn, "premium_yearly") do %>
<div class="yearly-benefits">
You have yearly access!
</div>
<% end %>
<!-- In LiveView templates, use assigns -->
<%= if @has_premium? do %>
<div class="premium-content">
<!-- Premium features here -->
</div>
<% else %>
<div class="upgrade-prompt">
<p>This feature requires a premium subscription</p>
<.link navigate="/subscription">Upgrade Now</.link>
</div>
<% end %># In your business logic
defmodule YourApp.Analytics do
alias YourApp.Payments
def export_data(user) do
if Payments.has_feature?(user, :advanced_export) do
# Perform export
{:ok, generate_export(user)}
else
{:error, :premium_required}
end
end
def get_analytics_limit(user) do
case Payments.get_current_plan(user) do
"premium_yearly" -> 10_000
"premium_monthly" -> 1_000
"free" -> 100
end
end
endThe generated tests provide comprehensive coverage:
# Run just the auth tests
mix test test/your_app_web/subscription_auth_test.exs \
test/your_app_web/subscription_hooks_test.exs \
test/your_app/payments_auth_functions_test.exs
# All 36 auth tests should passExample test fixture usage:
# In your tests
import YourApp.SubscriptionFixtures
test "premium feature requires subscription", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
# Without subscription
conn = get(conn, "/premium-feature")
assert redirected_to(conn) == "/subscription"
# With subscription
_subscription = active_subscription_fixture(user)
conn = get(conn, "/premium-feature")
assert html_response(conn, 200)
endThe auth system uses a layered approach:
- Router Level - Plugs for HTTP request filtering
- LiveView Level - on_mount hooks for WebSocket connections
- Context Level - Business logic functions
- Template Level - Helper functions for UI
- API Level - 402 Payment Required for API endpoints
This ensures consistent access control across your entire application.
SquareClient supports flexible configuration with clear precedence:
Single Set of Keys with Separate Plans (Recommended) Use one Square account across all apps with app-specific plans:
- Single business entity with multiple applications
- All payments go to one bank account
- Each app maintains its own subscription plans with unique IDs
- Clear attribution through plan naming and reference IDs
- See MULTI_APP_PAYMENTS.md for implementation details
Multiple Sets of Keys Only needed when:
- Different legal entities (each app is a separate business)
- Marketplace model (each app represents different merchants)
- Compliance requirements for payment isolation
- Payments need separate bank account destinations
- Application Config - Set in your app's config files
- Environment Variables - For deployment and secrets
- Default Values - Sensible defaults for development
For Shared Keys Across All Apps:
# Each app uses the same Square account
# config/config.exs in each app
config :square_client,
api_url: "https://connect.squareupsandbox.com/v2",
access_token: System.get_env("SQUARE_ACCESS_TOKEN") # Same token for all appsFor App-Specific Keys:
# contacts4us/config/config.exs
config :square_client,
access_token: System.get_env("CONTACTS4US_SQUARE_TOKEN")
# analytics_app/config/config.exs
config :square_client,
access_token: System.get_env("ANALYTICS_SQUARE_TOKEN")For environment-specific configuration:
# config/prod.exs
config :square_client,
api_url: "https://connect.squareup.com/v2" # Production API
# config/test.exs
config :square_client,
api_url: "http://localhost:4001/v2", # Mock server for tests
disable_retries: true # Faster test executionFor deployments where you can't modify config files, set these environment variables:
SQUARE_ACCESS_TOKEN- Your Square API access token (required)SQUARE_LOCATION_ID- Your Square location ID (required)SQUARE_APPLICATION_ID- Your Square application ID (optional)SQUARE_ENVIRONMENT- Controls which plan IDs to use (optional):"production"- Uses production plan IDs"sandbox"- Uses sandbox plan IDs- Auto-detected from
api_urlif not set (recommended)
Example:
export SQUARE_ACCESS_TOKEN="YOUR_SANDBOX_TOKEN"
export SQUARE_LOCATION_ID="YOUR_LOCATION_ID"
export SQUARE_APPLICATION_ID="YOUR_APP_ID"
# SQUARE_ENVIRONMENT is optional - auto-detected from api_urlNote: Application config takes precedence over environment variables.
The library automatically detects whether to use sandbox or production plan IDs based on your configured api_url:
https://connect.squareupsandbox.com/v2→ Uses sandbox plan IDshttps://connect.squareup.com/v2→ Uses production plan IDs
Configuration Required:
You must configure the api_url in your config files:
# config/config.exs (or config/dev.exs)
config :square_client,
api_url: "https://connect.squareupsandbox.com/v2"
# config/prod.exs
config :square_client,
api_url: "https://connect.squareup.com/v2"The library will automatically use the corresponding plan IDs from your priv/square_plans.json based on which URL is configured.
Detection Precedence (first match wins):
Application.get_env(app, :square_environment)- App-specific overrideApplication.get_env(:square_client, :environment)- Global overrideSystem.get_env("SQUARE_ENVIRONMENT")- Environment variable- Auto-detect from
api_url- Based on configured URL (recommended) - Defaults to
"sandbox"for safety
Why not use Mix.env() or config_env()?
Mix.env()is not available in production releases - Mix is a build tool and isn't included in compiled releasesconfig_env()is a compile-time macro that only works inside config files, not at runtime in application code
The api_url approach works reliably in all environments including production releases, and users already configure different URLs for dev/prod anyway.
The library includes a complete JSON-driven payment system. See JSON_PLANS.md for full documentation.
Quick Start:
# Initialize plans configuration
mix square.init_plans --app my_app
# Edit priv/square_plans.json to define your plans
# Use in your LiveView
alias SquareClient.Plans.Formatter
plans = Formatter.get_subscription_plans(:my_app)Square recommends using base plans with variations for different billing periods. This allows better catalog organization and pricing flexibility.
# Create a base subscription plan
{:ok, plan} = SquareClient.Catalog.create_base_subscription_plan(%{
name: "Premium Plan",
description: "Access to premium features"
})
# Create pricing variations
{:ok, monthly} = SquareClient.Catalog.create_plan_variation(%{
base_plan_id: plan.plan_id,
name: "Monthly",
cadence: "MONTHLY",
amount: 999, # $9.99 in cents
currency: "USD"
})
{:ok, annual} = SquareClient.Catalog.create_plan_variation(%{
base_plan_id: plan.plan_id,
name: "Annual",
cadence: "ANNUAL",
amount: 9900, # $99.00 in cents
currency: "USD"
})# List all subscription plans
{:ok, plans} = SquareClient.Catalog.list_subscription_plans()
# List all plan variations
{:ok, variations} = SquareClient.Catalog.list_plan_variations()
# Get a specific catalog object
{:ok, object} = SquareClient.Catalog.get(object_id)# Process a payment with full control
{:ok, payment} = SquareClient.Payments.create(
source_id,
amount,
currency,
customer_id: customer_id,
reference_id: "order-123"
)Perfect for selling time-based access (30-day passes, yearly access, etc.) instead of auto-renewing subscriptions:
# Simple one-time payment for time-based access
{:ok, payment} = SquareClient.Payments.create_one_time(
customer_id,
source_id, # Card nonce or saved card ID
9999, # Amount in cents ($99.99)
description: "30-day premium access",
app_name: :my_app
)
# In your app, grant time-limited access after successful payment:
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
# Update user with expiration dateWhen to use one-time purchases vs subscriptions:
- One-time: User manually renews, better for annual plans or trial offers
- Subscriptions: Auto-renews, better for monthly plans and regular revenue
- Both: Offer choice - some users prefer control over auto-renewal
# Example: Offering both subscription and one-time options
plans = [
%{type: :subscription, name: "Monthly Premium", price: 999}, # Auto-renews
%{type: :one_time, name: "30-Day Pass", price: 999, days: 30}, # Manual renewal
%{type: :one_time, name: "Annual Pass", price: 9999, days: 365} # Better value
]The library provides complete subscription management infrastructure that you can drop into your app.
Use the reusable schema macro to create your subscription table:
defmodule MyApp.Payments.Subscription do
use SquareClient.Subscriptions.Schema,
repo: MyApp.Repo,
belongs_to: [
{:user, MyApp.Accounts.User}
]
# Optional: Add app-specific helper functions
defdelegate get_active_for_user(user_or_id), to: __MODULE__, as: :get_active_for_owner
endThis automatically gives you:
- Complete Ecto schema with all Square subscription fields
square_subscription_id,status,tier, billing dates, etc.- Query helpers:
active/0,for_owner/2,get_active_for_owner/1 - Automatic Square sync capabilities
Generate the migration:
mix ecto.gen.migration create_subscriptionsdefmodule MyApp.Repo.Migrations.CreateSubscriptions do
use Ecto.Migration
def change do
create table(:subscriptions) do
add :square_subscription_id, :string, null: false
add :square_customer_id, :string
add :status, :string, null: false
add :tier, :string, null: false
add :charged_through_date, :date
add :canceled_date, :date
add :start_date, :date
add :next_billing_date, :date
add :user_id, references(:users, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create unique_index(:subscriptions, [:square_subscription_id])
create index(:subscriptions, [:user_id])
create index(:subscriptions, [:status])
end
end# Create subscription with Square
{:ok, subscription} = SquareClient.Subscriptions.create_with_plan_lookup(
customer_id,
"premium_monthly", # Plan key from your config
card_token
)
# Sync to your database
SquareClient.Subscriptions.Context.sync_from_square(
MyApp.Payments.Subscription,
MyApp.Repo,
subscription
)defmodule MyApp.Payments.SquareWebhookHandler do
@behaviour SquareClient.WebhookHandler
alias MyApp.Payments.Subscription
alias MyApp.Repo
def handle_event(%{event_type: "subscription.updated", data: %{"object" => %{"subscription" => square_sub}}}) do
# Automatically sync changes from Square
SquareClient.Subscriptions.Context.sync_from_square(
Subscription,
Repo,
square_sub
)
:ok
end
def handle_event(_event), do: :ok
endalias SquareClient.Subscriptions.Refunds
# Cancel subscription
{:ok, _} = SquareClient.Subscriptions.cancel(subscription.square_subscription_id)
# Calculate prorated refund
subscription = Repo.get!(MyApp.Payments.Subscription, subscription_id)
days_remaining = Refunds.calculate_remaining_days(subscription)
refund_amount = Refunds.calculate_prorated_refund(
subscription,
days_remaining,
%{monthly: 999, yearly: 9999} # Your plan pricing
)
# Process automatic refund
Refunds.process_automatic_refund(
subscription,
refund_amount,
payment_id: last_payment_id
)The schema provides built-in query helpers:
# Get active subscription for a user
subscription = MyApp.Payments.Subscription.get_active_for_owner(user.id)
# Query active subscriptions
active_subs =
MyApp.Payments.Subscription
|> MyApp.Payments.Subscription.active()
|> Repo.all()
# Query subscriptions for a specific user
user_subs =
MyApp.Payments.Subscription
|> MyApp.Payments.Subscription.for_owner(user.id)
|> Repo.all()Access Square status and tier constants:
alias SquareClient.Subscriptions.Constants
# Tier constants
Constants.tier_free() # "free"
Constants.tier_premium() # "premium"
# Status constants
Constants.status_active() # "active"
Constants.status_canceled() # "canceled"
Constants.status_past_due() # "past_due"
# Square status constants
Constants.square_status_active() # "ACTIVE"
Constants.square_status_canceled() # "CANCELED"
Constants.square_status_delinquent() # "DELINQUENT"
# Convert Square status to internal status
internal_status = Constants.square_to_internal_status("ACTIVE") # "active"Apps using this library can create Mix tasks for plan management:
# In your app, create lib/mix/tasks/square.setup_plans.ex
defmodule Mix.Tasks.Square.SetupPlans do
use Mix.Task
def run(_) do
Mix.Task.run("app.start")
# Create your app's subscription plans
{:ok, plan} = SquareClient.Catalog.create_base_subscription_plan(%{
name: "MyApp Premium"
})
# Save plan IDs to your config/database
IO.puts("Created plan: #{plan.plan_id}")
end
endThe library includes comprehensive tests with mocked API responses:
# Run tests (0.1s execution time)
mix test
# Tests use Bypass to mock Square API - no real API calls
# All logs are captured for clean output using ExUnit.CaptureLogWhen testing code that uses SquareClient:
# In your test config
config :square_client,
api_url: "http://localhost:#{bypass_port}/v2",
disable_retries: true # Important for test speedTests run in 0.1 seconds because:
- API calls are mocked with Bypass (no network requests)
- Retries are disabled in test environment
- Logs are captured to prevent output noise
This library uses Square API version 2025-01-23 (latest stable).
Key differences from older versions:
- Uses
pricingfield for variations (notrecurring_price_money) - Supports latest subscription features
- Improved error messages
This library uses synchronous REST API calls instead of message queues (RabbitMQ) because:
-
Immediate feedback - Payment processing needs instant response for:
- Card declines
- Validation errors
- Insufficient funds
-
Simpler error handling - Direct error responses vs async callbacks
-
Easier debugging - Synchronous flow is easier to trace
-
No infrastructure dependencies - No need for RabbitMQ, Broadway, etc.
-
Better UX - Users get immediate feedback on payment issues
Each app manages its own Square resources (plans, customers) rather than centralizing in a payment service:
- Flexibility - Apps can have different subscription models
- Direct control - Apps manage their own pricing and plans
- Easier testing - No dependency on external services
- No single point of failure - Each app is independent
The library checks multiple configuration sources in order:
- Application config (for app-specific overrides)
- Environment variables (for deployment flexibility)
- Defaults (for quick development start)
This allows apps to:
- Override settings in their config files
- Deploy with environment variables
- Start developing with zero configuration
📖 For comprehensive webhook documentation, see WEBHOOK.md
SquareClient provides a standardized webhook infrastructure for all your apps:
- Automatic signature verification - Ensures webhooks are from Square
- Standardized behaviour - Consistent webhook handling across all apps
- Plug-based architecture - Easy integration with Phoenix/Plug applications
- Comprehensive error handling - Graceful handling of invalid webhooks
- Implement the webhook handler behaviour in your app:
defmodule MyApp.SquareWebhookHandler do
@behaviour SquareClient.WebhookHandler
@impl true
def handle_event(%{event_type: "payment.created", data: data}) do
# Process payment
MyApp.Payments.process_payment(data)
:ok
end
@impl true
def handle_event(%{event_type: "subscription.created", data: data}) do
# Create local subscription record
MyApp.Subscriptions.create_from_square(data)
:ok
end
# Catch-all for unhandled events
@impl true
def handle_event(_event) do
# Log or ignore
:ok
end
end- Configure the handler and signature key:
# In config/config.exs
config :square_client,
webhook_handler: MyApp.SquareWebhookHandler,
webhook_signature_key: System.get_env("SQUARE_WEBHOOK_SIGNATURE_KEY")- Add the webhook plug to your router:
# In your Phoenix router
pipeline :square_webhook do
plug :accepts, ["json"]
plug SquareClient.WebhookPlug
end
scope "/webhooks", MyAppWeb do
pipe_through :square_webhook
post "/square", WebhookController, :handle
end- Create a webhook controller using the library behavior:
defmodule MyAppWeb.WebhookController do
use MyAppWeb, :controller
use SquareClient.Controllers.WebhookController
# That's it! The behavior provides complete webhook handling
# You can optionally override any response handlers:
# def handle_success(conn, event) do
# # Custom success response
# conn |> put_status(:accepted) |> json(%{ok: true})
# end
endManual Implementation (if you prefer):
defmodule MyAppWeb.WebhookController do
use MyAppWeb, :controller
def handle(conn, _params) do
case conn.assigns[:square_event] do
{:ok, event} ->
# Event was processed by your handler
json(conn, %{received: true})
{:error, :invalid_signature} ->
conn
|> put_status(:unauthorized)
|> json(%{error: "Invalid signature"})
{:error, _reason} ->
conn
|> put_status(:bad_request)
|> json(%{error: "Invalid webhook"})
end
end
end- Square sends webhook to your endpoint
- WebhookPlug intercepts the request
- Signature is verified using HMAC-SHA256
- Event is parsed from JSON
- Handler is called with the parsed event
- Result stored in
conn.assigns.square_event - Controller responds based on the result
config :square_client,
# Required: Square API URL
api_url: "https://connect.squareupsandbox.com/v2", # or production URL
# Required: Square access token
access_token: System.get_env("SQUARE_ACCESS_TOKEN"),
# Required: Square location ID
location_id: System.get_env("SQUARE_LOCATION_ID"),
# Required: Your webhook handler module
webhook_handler: MyApp.SquareWebhookHandler,
# Optional: Square webhook signature key (from Square dashboard)
webhook_signature_key: System.get_env("SQUARE_WEBHOOK_SIGNATURE_KEY")Configuration Validation:
The library validates all required configuration at app startup:
# In your application.ex
def start(_type, _args) do
# Validates api_url, access_token, location_id, and webhook_handler
# Raises clear error with examples if anything is missing
SquareClient.Config.validate_runtime!()
children = [...]
# ...
endIf configuration is invalid, you'll get a helpful error message:
SquareClient configuration is invalid:
• API URL is not configured. Add :api_url to your config :square_client
• Webhook handler is not configured. Add :webhook_handler to your config :square_client if you use webhooks
Required configuration:
config :square_client,
api_url: "https://connect.squareupsandbox.com/v2", # or production URL
access_token: System.get_env("SQUARE_ACCESS_TOKEN"),
location_id: System.get_env("SQUARE_LOCATION_ID"),
webhook_handler: MyApp.Payments.SquareWebhookHandler # if using webhooks
Environment variables:
SQUARE_ACCESS_TOKEN - Your Square API access token (required)
SQUARE_LOCATION_ID - Your Square location ID (required)
Get these from: https://developer.squareup.com/apps
Or use environment variables:
SQUARE_ACCESS_TOKEN- Your Square API access token (required)SQUARE_LOCATION_ID- Your Square location ID (required)SQUARE_WEBHOOK_SIGNATURE_KEY- Your webhook signature key
In your tests:
defmodule MyAppWeb.WebhookControllerTest do
use MyAppWeb.ConnCase
test "processes valid webhook", %{conn: conn} do
body = ~s({"type": "payment.created", "data": {...}})
signature = generate_signature(body, "test_key")
conn =
conn
|> put_req_header("x-square-hmacsha256-signature", signature)
|> post("/webhooks/square", body)
assert json_response(conn, 200) == %{"received" => true}
end
defp generate_signature(payload, key) do
:crypto.mac(:hmac, :sha256, key, payload)
|> Base.encode64()
end
endCommon Square webhook events your handler might receive:
Payments:
payment.created- Payment completedpayment.updated- Payment status changed
Subscriptions:
subscription.created- New subscription startedsubscription.updated- Subscription modifiedsubscription.canceled- Subscription ended
Invoices:
invoice.payment_made- Subscription payment successfulinvoice.payment_failed- Payment failed (card declined, etc.)
Customers:
customer.created- New customer createdcustomer.updated- Customer information changed
Refunds:
refund.created- Refund processedrefund.updated- Refund status changed
- Always verify signatures - The plug handles this automatically
- Use HTTPS only - Never accept webhooks over HTTP in production
- Validate event data - Don't trust webhook data without validation
- Idempotency - Handle duplicate webhooks gracefully (Square may retry)
- Timeout handling - Respond quickly (< 10 seconds) to avoid retries
The library automatically detects and adapts to the environment:
-
Test (
MIX_ENV=test)- Disables HTTP retries for fast tests
- Can use custom test URL for mocking
-
Development (
MIX_ENV=dev)- Uses sandbox API by default
- Full retry behavior enabled
-
Production (
MIX_ENV=prod)- Ready for production API
- Set
SQUARE_ENVIRONMENT=productionto use live API
- Ensure
disable_retries: trueis set in test config - Check that
SQUARE_ENVIRONMENTisn't overriding test settings - Verify Bypass is running (check for port conflicts)
- Verify
SQUARE_ACCESS_TOKENis set correctly - Check you're using the right environment (sandbox vs production)
- Ensure API version compatibility (check Square dashboard)
- Look for rate limiting (Square has API rate limits)
- Square doesn't allow deletion of subscription plans once created
- This is by design to maintain historical records
- Use new plan names for testing
- Plans can be archived but not deleted
- Check configuration precedence (app config > env vars > defaults)
- Use
Application.get_env(:square_client, :api_url)to verify - Ensure config files are loaded (
import_config)
- Sandbox Dashboard: https://squareupsandbox.com/dashboard
- Production Dashboard: https://squareup.com/dashboard
- Developer Dashboard: https://developer.squareup.com/apps
For testing in sandbox:
- Success: 4111 1111 1111 1111
- Declined: 4000 0000 0000 0002
- See
TEST_CARDS.mdfor complete list
-
Write tests for new features
- Use
capture_logfor clean test output - Mock API calls with Bypass
- Use
-
Follow Elixir best practices
- Pattern matching over conditionals
- Function heads for different cases
- Meaningful function and variable names
-
Update documentation
- Keep this README current
- Document new configuration options
- Add examples for new features
-
Ensure fast tests
- All tests should complete in < 1 second
- Disable retries in test environment
- Mock all external API calls
MIT