A stateful mock Stripe server for testing Elixir applications.
Testing payment processing requires simulating complex Stripe workflows: subscription billing cycles, invoice finalization, webhook delivery, idempotency handling. Using real Stripe test accounts introduces external dependencies, network latency, rate limits, and non-deterministic test data. Stubbing individual Stripe API calls leads to brittle tests that break when Stripe's API evolves.
PaperTiger solves this by providing a complete, stateful implementation of the Stripe API that runs in-process. Tests execute in milliseconds instead of seconds, work offline, and produce deterministic results. The dual-mode contract testing system validates that PaperTiger's behavior matches production Stripe, catching API drift automatically.
State over stubs: PaperTiger maintains actual resource state (customers, subscriptions, invoices) instead of returning canned responses. This enables testing complex workflows like subscription lifecycle management, trial expiration, and invoice finalization.
Contract validation: The same test suite runs against both PaperTiger and real Stripe. This ensures the mock accurately reflects production behavior while maintaining zero-setup development experience.
Time control: Subscription billing, trial periods, and webhook retry logic depend on time progression. PaperTiger provides accelerated and manual clock modes for testing time-dependent behavior without waiting.
Elixir-native: Built with ETS, GenServers, and Plug. No external databases or runtimes required.
- Complete Stripe API Coverage: Customers, Subscriptions, Invoices, PaymentMethods, and more
- Stateful In-Memory Storage: ETS-backed GenServers with concurrent reads and serialized writes
- Webhook Delivery: HMAC-signed webhook events with retry logic
- Dual-Mode Contract Testing: Run same tests against PaperTiger or real Stripe API
- Zero External Dependencies: No Stripe account or API keys required for normal testing
- Idempotency: Request deduplication with 24-hour TTL
- Object Expansion: Hydrator system for nested resource expansion
- Time Control: Accelerated, manual, or real-time clock for testing
- Billing Engine: Automated subscription billing simulation
- Chaos Testing: Unified ChaosCoordinator for payment, event, and API chaos
Add paper_tiger to your dependencies in mix.exs:
def deps do
[
{:paper_tiger, "~> 0.9.10"}
]
endTry the interactive Livebook tutorial for a hands-on introduction!
# Start PaperTiger in your test setup
{:ok, _} = PaperTiger.start()
# Make requests using any HTTP client (e.g., Req)
response = Req.post!(
"http://localhost:4001/v1/customers",
form: [email: "user@example.com", name: "Test User"],
auth: {:bearer, "sk_test_mock"}
)
# Or use the TestClient for dual-mode testing
alias PaperTiger.TestClient
{:ok, customer} = TestClient.create_customer(%{
"email" => "user@example.com",
"name" => "Test User"
})
# Clean up between tests
PaperTiger.flush()PaperTiger integrates seamlessly with Phoenix applications for local development and testing.
Add PaperTiger to your dependencies:
# mix.exs
def deps do
[
{:paper_tiger, "~> 0.9.4", only: [:dev, :test]},
{:stripity_stripe, "~> 3.0"}
]
endUse PaperTiger's configuration helper in your config files:
# config/test.exs
config :stripity_stripe, PaperTiger.stripity_stripe_config()
# Optional: Start HTTP server (runs on port 4001 by default)
config :paper_tiger, start: true
# Optional: Register webhooks automatically
config :paper_tiger,
webhooks: [
[url: "http://localhost:4000/webhooks/stripe"]
]For conditional use in development (e.g., PR apps):
# config/runtime.exs
if System.get_env("USE_PAPER_TIGER") == "true" do
config :stripity_stripe, PaperTiger.stripity_stripe_config()
config :paper_tiger, start: true
endPaperTiger respects environment variables for runtime configuration:
PAPER_TIGER_START- Set to "true" to enable HTTP serverPAPER_TIGER_PORT- Port to run on (default: 4001)PAPER_TIGER_PORT_DEV- Port for dev environment (overridesPAPER_TIGER_PORT)PAPER_TIGER_PORT_TEST- Port for test environment (overridesPAPER_TIGER_PORT)
Port precedence: PAPER_TIGER_PORT_{ENV} > PAPER_TIGER_PORT > config > 4001
This allows running dev server and tests simultaneously on different ports:
# In .env or shell
export PAPER_TIGER_PORT_DEV=4001
export PAPER_TIGER_PORT_TEST=4003This is also useful for Fly.io or other PaaS deployments:
# Enable PaperTiger for PR apps
fly secrets set PAPER_TIGER_START=true -a my-app-pr-123PaperTiger automatically emits Stripe events when resources are created, updated, or deleted. These events are delivered to registered webhook endpoints with proper HMAC signatures.
Supported events:
customer.created,customer.updated,customer.deletedcustomer.subscription.created,customer.subscription.updated,customer.subscription.deletedinvoice.created,invoice.updated,invoice.finalized,invoice.paid,invoice.payment_succeededpayment_intent.created,product.created,price.created
# config/test.exs
config :paper_tiger,
start: true,
webhooks: [
[url: "http://localhost:4000/webhooks/stripe"]
]
# In your test setup
setup do
PaperTiger.register_configured_webhooks()
PaperTiger.flush()
:ok
end# In test setup or IEx
PaperTiger.register_webhook(url: "http://localhost:4000/webhooks/stripe")Your Phoenix webhook controller works unchanged:
defmodule MyAppWeb.StripeWebhookController do
use MyAppWeb, :controller
def webhook(conn, params) do
# PaperTiger signs webhooks identically to Stripe
case Stripe.Webhook.construct_event(
conn.assigns.raw_body,
get_req_header(conn, "stripe-signature"),
Application.get_env(:stripity_stripe, :webhook_signing_key)
) do
{:ok, event} ->
handle_event(event)
send_resp(conn, 200, "ok")
{:error, _} ->
send_resp(conn, 400, "invalid signature")
end
end
end# Terminal 1: Start Phoenix (port 4000)
mix phx.server
# Terminal 2: Start PaperTiger (port 4001)
iex -S mix
iex> PaperTiger.start()
iex> PaperTiger.register_webhook(url: "http://localhost:4000/webhooks/stripe")
# Now Stripe API calls from your app go to PaperTiger
# Webhooks are delivered to your Phoenix appOr use start for zero-config testing:
# config/test.exs
config :paper_tiger, start: true
config :stripity_stripe, PaperTiger.stripity_stripe_config()
# In tests - PaperTiger runs automatically
test "subscription creation triggers webhook" do
# Create subscription via Stripe client
{:ok, _sub} = Stripe.Subscription.create(%{customer: customer_id, ...})
# Webhook delivered to your Phoenix app
assert_receive {:webhook, %{type: "customer.subscription.created"}}
endFor tests that need to verify webhooks without running a web server, use :collect mode. This stores webhooks in-memory for inspection instead of delivering them via HTTP.
defmodule MyApp.SubscriptionTest do
use ExUnit.Case, async: true
import PaperTiger.Test
setup :checkout_paper_tiger
test "creating subscription triggers correct webhook" do
# Enable webhook collection for this test
enable_webhook_collection()
# Create subscription - this triggers the webhook
{:ok, subscription} = Stripe.Subscription.create(%{
customer: customer_id,
items: [%{price: price_id}]
})
# Verify the webhook that Stripe would send
[delivery] = assert_webhook_delivered("customer.subscription.created")
# Inspect the webhook payload
assert delivery.event_data.object.id == subscription.id
assert delivery.event_data.object.status == "active"
assert delivery.event_data.object.customer == customer_id
end
test "cancellation triggers delete webhook" do
enable_webhook_collection()
{:ok, subscription} = Stripe.Subscription.create(%{...})
clear_delivered_webhooks() # Clear creation webhook
{:ok, _} = Stripe.Subscription.cancel(subscription.id)
[delivery] = assert_webhook_delivered("customer.subscription.deleted")
assert delivery.event_data.object.status == "canceled"
end
test "update does not trigger delete webhook" do
enable_webhook_collection()
{:ok, subscription} = Stripe.Subscription.create(%{...})
clear_delivered_webhooks()
{:ok, _} = Stripe.Subscription.update(subscription.id, %{metadata: %{foo: "bar"}})
refute_webhook_delivered("customer.subscription.deleted")
assert_webhook_delivered("customer.subscription.updated")
end
endAvailable helpers:
enable_webhook_collection/0- Enables:collectmode for the test (auto-cleanup on exit)get_delivered_webhooks/0- Returns all collected webhooksget_delivered_webhooks/1- Filter by event type (supports wildcards like"customer.*")assert_webhook_delivered/1- Asserts webhook was delivered, returns matchesrefute_webhook_delivered/1- Asserts webhook was NOT deliveredclear_delivered_webhooks/0- Clears collected webhooks (useful between operations)
When to use each mode:
| Mode | Use Case |
|---|---|
:collect |
Unit tests verifying webhook payloads without HTTP |
:sync |
Integration tests with a real webhook endpoint |
| Default (async) | Development with Phoenix running |
PaperTiger uses port 4001 by default to avoid conflicts with Phoenix's port 4000.
If you need a different port:
# config/test.exs
config :stripity_stripe, PaperTiger.stripity_stripe_config(port: 4002)
# Or via environment variable
# PAPER_TIGER_PORT=4002 mix testPaperTiger supports running tests concurrently with async: true through a sandbox mechanism similar to Ecto's SQL Sandbox.
Use checkout_paper_tiger in your test setup:
defmodule MyApp.StripeTest do
use ExUnit.Case, async: true
import PaperTiger.Test
setup :checkout_paper_tiger
test "creates a customer" do
# Data is isolated to this test process
conn = post("/v1/customers", %{email: "test@example.com"})
assert conn.status == 200
end
endcheckout_paper_tiger/1stores the test process PID as a namespace- All PaperTiger operations (reads/writes) are scoped to that namespace
- On test exit, only that namespace's data is automatically cleaned up
This allows hundreds of tests to run in parallel without data interference.
When using stripity_stripe, configure it to use PaperTiger.StripityStripeHackney for automatic sandbox isolation:
# config/test.exs
config :stripity_stripe,
api_key: "sk_test_paper_tiger",
api_base_url: "http://localhost:4001",
http_module: PaperTiger.StripityStripeHackneyOr use the helper:
# config/test.exs
config :stripity_stripe, PaperTiger.stripity_stripe_config()This ensures that all Stripe API calls made via stripity_stripe automatically include the namespace header for test isolation - including calls made from child processes like Phoenix LiveView.
How it works:
checkout_paper_tiger/1sets a shared namespace via Application envPaperTiger.StripityStripeHackneyinjects thex-paper-tiger-namespaceheader into HTTP requests- Child processes (LiveView, async tasks) automatically pick up the shared namespace
- All Stripe operations are scoped to the test's namespace
Example with LiveView:
defmodule MyApp.BillingLiveTest do
use MyApp.ConnCase, async: true
import PaperTiger.Test
setup do
checkout_paper_tiger(%{})
# ... rest of setup
end
test "billing page shows subscription", %{conn: conn} do
# Create subscription in test process
{:ok, _sub} = Stripe.Subscription.create(%{...})
# LiveView's Stripe calls are automatically isolated to same sandbox
{:ok, live, _html} = live(conn, "/billing")
assert has_element?(live, "[data-subscription]")
end
endReplace:
# Before
use ExUnit.Case
setup do
PaperTiger.flush()
:ok
endWith:
# After
use ExUnit.Case, async: true
import PaperTiger.Test
setup :checkout_paper_tigerEach Stripe resource has a dedicated ETS-backed GenServer store:
- Reads: Direct ETS access (concurrent, no GenServer bottleneck)
- Writes: Through GenServer (serialized, prevents race conditions)
- Operations:
get/1,list/1,insert/1,update/1,delete/1,clear/0
All stores use a shared PaperTiger.Store macro to eliminate boilerplate.
Plug-based request pipeline:
- Auth: Validates
Authorization: Bearer sk_test_*headers (lenient mode) - CORS: Cross-origin request support
- Idempotency: Prevents duplicate POST requests via
Idempotency-Keyheader - UnflattenParams: Converts form-encoded bracket notation to nested Elixir structures
Router uses macro-based route generation for DRY resource definitions.
PaperTiger.WebhookDelivery GenServer handles asynchronous webhook delivery:
- HMAC SHA256 signing with configurable secrets
- Exponential backoff retry logic (5 attempts)
- Event type filtering per endpoint
- Delivery tracking and logging
PaperTiger.Clock provides deterministic time for testing:
# Real time (default)
PaperTiger.set_clock_mode(:real)
# Accelerated time (10x speed)
PaperTiger.set_clock_mode(:accelerated, multiplier: 10)
# Manual time control
PaperTiger.set_clock_mode(:manual, timestamp: 1234567890)
PaperTiger.advance_time(3600) # Advance 1 hourPaperTiger includes a BillingEngine for simulating subscription billing cycles. This enables testing of payment failures, retry logic, and subscription lifecycle without waiting for real time to pass.
# Enable billing engine in config
config :paper_tiger, :billing_engine, true
# Or start manually
PaperTiger.BillingEngine.start_link([])
# Process all due subscriptions
{:ok, stats} = PaperTiger.BillingEngine.process_billing()
# => %{processed: 5, succeeded: 4, failed: 1}PaperTiger provides a unified ChaosCoordinator for comprehensive chaos testing across payment processing, webhook delivery, and API responses.
Simulate payment failures with configurable rates and decline codes:
# Configure global payment failure rate
PaperTiger.ChaosCoordinator.configure(
payment_failure_rate: 0.3, # 30% failure rate
decline_codes: [:card_declined, :insufficient_funds, :expired_card]
)
# Per-customer failure simulation
PaperTiger.ChaosCoordinator.simulate_failure("cus_123", :insufficient_funds)
# Clear per-customer simulation
PaperTiger.ChaosCoordinator.clear_simulation("cus_123")
# Reset all chaos settings
PaperTiger.ChaosCoordinator.reset()Test webhook handler resilience with out-of-order and duplicate events:
PaperTiger.ChaosCoordinator.configure(
event_out_of_order_rate: 0.2, # 20% of events delivered out of order
event_duplicate_rate: 0.1, # 10% duplicate events
event_delay_range: {100, 5000} # Random delay 100-5000ms
)Simulate API failures and rate limiting:
PaperTiger.ChaosCoordinator.configure(
api_timeout_rate: 0.1, # 10% of requests timeout
api_error_rate: 0.05, # 5% server errors
rate_limit_rate: 0.02 # 2% rate limit responses
)PaperTiger supports 22 Stripe decline codes for realistic failure simulation:
- Common:
card_declined,insufficient_funds,expired_card,do_not_honor - Authentication:
authentication_required,incorrect_cvc,incorrect_zip - Fraud:
fraudulent,stolen_card,lost_card,pickup_card - Limits:
card_velocity_exceeded,withdrawal_count_limit_exceeded - Technical:
processing_error,try_again_later,issuer_not_available
Track chaos events for test assertions:
stats = PaperTiger.ChaosCoordinator.stats()
# => %{
# payment_failures: 5,
# events_reordered: 3,
# events_duplicated: 2,
# api_timeouts: 1
# }The billing engine handles the full subscription lifecycle:
- Finds subscriptions where
current_period_endhas passed - Creates invoice with line items
- Creates payment intent and attempts charge
- On success: Updates invoice to
paid, advances subscription period - On failure: Increments
attempt_count, marks subscriptionpast_dueafter 4 failures
Combined with time control, you can simulate months of billing in seconds:
# Set up subscription due for billing
PaperTiger.set_clock_mode(:manual, timestamp: :os.system_time(:second))
# Process billing cycle
{:ok, _} = PaperTiger.BillingEngine.process_billing()
# Advance 30 days
PaperTiger.advance_time(30 * 24 * 60 * 60)
# Process next cycle
{:ok, _} = PaperTiger.BillingEngine.process_billing()PaperTiger includes a dual-mode testing system that runs the same tests against both the mock server and real Stripe API, ensuring accuracy.
mix testZero configuration required. Tests run against the in-memory mock server.
export STRIPE_API_KEY=sk_test_your_key_here
export VALIDATE_AGAINST_STRIPE=true
mix test test/paper_tiger/contract_test.exsTests run against stripe.com to validate that PaperTiger behavior matches production. Requires a Stripe test account.
🛡️ Safety Guard: PaperTiger performs two-layer validation before running against real Stripe:
- Validates the API key prefix (rejects
sk_live_*,rk_live_*)- Makes a live API call to
/v1/balanceand verifieslivemode: falseIf you accidentally configure a live-mode key, the tests will refuse to run with a clear error message. This prevents accidental charges to real customers.
defmodule MyApp.ContractTest do
use ExUnit.Case
alias PaperTiger.TestClient
setup do
if TestClient.paper_tiger?() do
PaperTiger.flush()
end
:ok
end
test "customer CRUD lifecycle" do
# Create
{:ok, customer} = TestClient.create_customer(%{
"email" => "user@example.com",
"name" => "Test User"
})
# Retrieve
{:ok, retrieved} = TestClient.get_customer(customer["id"])
assert retrieved["email"] == "user@example.com"
# Update
{:ok, updated} = TestClient.update_customer(customer["id"], %{
"name" => "Updated Name"
})
assert updated["name"] == "Updated Name"
# Delete
{:ok, deleted} = TestClient.delete_customer(customer["id"])
assert deleted["deleted"] == true
# Cleanup for real Stripe
if TestClient.real_stripe?() do
# Already deleted above
end
end
endThe TestClient module routes operations to the appropriate backend based on environment variables, normalizing responses to ensure consistent map structures with string keys.
Customers:
create_customer/1,get_customer/1,update_customer/2,delete_customer/1,list_customers/1
Subscriptions:
create_subscription/1,get_subscription/1,update_subscription/2,delete_subscription/1,list_subscriptions/1
PaymentMethods:
create_payment_method/1,get_payment_method/1
Invoices:
create_invoice/1,get_invoice/1
PaperTiger provides comprehensive coverage of core Stripe resources with full CRUD operations:
Billing & Subscriptions: Customers, Subscriptions, SubscriptionItems, Invoices, InvoiceItems, Products, Prices, Plans, Coupons, TaxRates
Payments: PaymentMethods, PaymentIntents, SetupIntents, Charges, Refunds
Payment Sources: Cards, BankAccounts, Sources, Tokens
Platform & Connect: Payouts, BalanceTransactions, ApplicationFees, Disputes
Checkout & Events: CheckoutSessions, WebhookEndpoints, Events, Reviews, Topups
Note: PaperTiger implements Stripe API v1 resources. Some v2-only resources (e.g., v2 billing features) are not yet supported. Check the issues page for planned additions or open a feature request.
# Create
{:ok, customer} = TestClient.create_customer(%{
"email" => "user@example.com",
"name" => "John Doe",
"metadata" => %{"user_id" => "12345"}
})
# Retrieve
{:ok, customer} = TestClient.get_customer("cus_123")
# Update
{:ok, updated} = TestClient.update_customer("cus_123", %{
"name" => "Jane Doe"
})
# Delete
{:ok, deleted} = TestClient.delete_customer("cus_123")
# List with pagination
{:ok, list} = TestClient.list_customers(%{
"limit" => 10,
"starting_after" => "cus_123"
})# Create subscription with inline price
{:ok, subscription} = TestClient.create_subscription(%{
"customer" => "cus_123",
"items" => [
%{
"price_data" => %{
"currency" => "usd",
"product_data" => %{"name" => "Premium Plan"},
"recurring" => %{"interval" => "month"},
"unit_amount" => 2000
}
}
]
})
# Update
{:ok, updated} = TestClient.update_subscription("sub_123", %{
"metadata" => %{"tier" => "premium"}
})
# Cancel
{:ok, canceled} = TestClient.delete_subscription("sub_123")# Create draft invoice
{:ok, invoice} = TestClient.create_invoice(%{
"customer" => "cus_123"
})
# Finalize and pay (PaperTiger HTTP API)
conn = post("/v1/invoices/#{invoice["id"]}/finalize")
conn = post("/v1/invoices/#{invoice["id"]}/pay")# config/test.exs
config :paper_tiger,
port: 4001, # Avoids conflict with Phoenix's default 4000
clock_mode: :real,
webhook_secret: "whsec_test_secret"
# For contract testing (optional)
config :stripity_stripe,
api_key: System.get_env("STRIPE_API_KEY") || "sk_test_mock"PaperTiger can pre-populate products, prices, and customers on startup via the init_data config. Since ETS is ephemeral, this runs on every application start - useful for development environments where you need consistent Stripe data available immediately.
# config/dev.exs - From a JSON file
config :paper_tiger,
init_data: "priv/paper_tiger/init_data.json"
# Or inline in config
config :paper_tiger,
init_data: %{
products: [
%{
id: "prod_dev_standard",
name: "Standard Plan",
active: true,
metadata: %{credits: "100"}
}
],
prices: [
%{
id: "price_dev_standard_monthly",
product: "prod_dev_standard",
unit_amount: 7900,
currency: "usd",
recurring: %{interval: "month", interval_count: 1}
}
]
}Use custom IDs (like prod_dev_*) to ensure deterministic data across restarts. This is particularly useful when your app syncs from Stripe on startup - the data will be there before your sync runs.
# All tests
mix test
# Specific resource tests
mix test test/paper_tiger/resources/customer_test.exs
# Contract tests (PaperTiger mode)
mix test test/paper_tiger/contract_test.exs
# Contract tests (Stripe validation mode)
STRIPE_API_KEY=sk_test_xxx VALIDATE_AGAINST_STRIPE=true \
mix test test/paper_tiger/contract_test.exs# Compilation warnings as errors
mix compile --warnings-as-errors
# Code formatting
mix format --check-formatted
# Static analysis
mix credo --strict --all
# Type checking
mix dialyzer
# All quality checks
mix compile --warnings-as-errors && \
mix format --check-formatted && \
mix credo --strict --all && \
mix dialyzer && \
mix testForm-encoded parameters are automatically coerced to proper types:
# Input: "cancel_at_period_end=true&quantity=5"
# Output: %{cancel_at_period_end: true, quantity: 5}Bracket notation is converted to Elixir lists:
# Input: "items[0][price]=price_123&items[1][price]=price_456"
# Output: %{items: [%{price: "price_123"}, %{price: "price_456"}]}The Hydrator system supports Stripe's expand[] parameter:
# Request: GET /v1/customers/cus_123?expand[]=default_payment_method
# Response includes full PaymentMethod object instead of ID stringAll resources generate Stripe-compatible IDs:
- Customers:
cus_+ 24 random chars - Subscriptions:
sub_+ 24 random chars - Invoices:
in_+ 24 random chars - etc.
Cursor-based pagination using starting_after, ending_before, and limit:
%{
"object" => "list",
"data" => [...],
"has_more" => true,
"url" => "/v1/customers"
}- No persistent storage (in-memory only)
- Webhook delivery is asynchronous but not distributed
- Some Stripe API edge cases may differ from production
- Time-based features (trials, billing periods) require manual time control
Contributions are welcome. When adding support for new Stripe resources or operations:
- Add the resource store using
use PaperTiger.Storemacro - Implement resource handlers in
lib/paper_tiger/resources/ - Add routes to
lib/paper_tiger/router.ex - Write comprehensive tests in
test/paper_tiger/resources/ - Add contract tests to validate against real Stripe API
- Update this README with new capabilities
MIT
- Stripe API Documentation
- Stripity Stripe (Elixir Stripe client)
- Hex Package