A minimalist Stripe integration for Elixir.
This library is still experimental! It's thoroughly tested in ExUnit, but that's it.
Stripe doesn't provide an official Elixir SDK, and maintaining a full-featured SDK has proven to be a challenge. As I see it, this is because the Elixir community is pretty senior-driven. People have no problem rolling their own integration with the incredible Req library.
This library is an attempt to wrap those community learnings in an easy-to-use package modeled after patterns set forth in Dashbit's SDKs with Req: Stripe article by Wojtek Mach.
My hope is that this should suffice for 95% of all apps that need to integrate with Stripe, and that the remaining 5% of use cases have a built-in escape hatch with Req.
- Simple API Client built on Req with automatic ID prefix recognition
- Webhook Handler DSL using Spark for clean, declarative webhook handling
- Automatic Signature Verification for webhook security
- Code Generators powered by Igniter for zero-config setup
- Sync with Stripe to keep your local handlers in sync with your Stripe dashboard
The fastest way to install is using the Igniter installer:
mix igniter.install pin_stripeThis will:
- Add the dependency to your
mix.exs - Replace
Plug.ParserswithPinStripe.ParsersWithRawBodyin your Phoenix endpoint - Generate a
StripeWebhookControllerwith example event handlers - Add the webhook route to your router (default:
/webhooks/stripe) - Configure
webhook_pathsinconfig/runtime.exs - Add DSL formatting support to
.formatter.exs
Then configure your Stripe credentials:
# config/runtime.exs
config :pin_stripe,
stripe_api_key: System.get_env("STRIPE_SECRET_KEY"),
stripe_webhook_secret: System.get_env("STRIPE_WEBHOOK_SECRET")Add to your mix.exs:
def deps do
[
{:pin_stripe, "~> 0.3"}
]
endThen follow the Manual Setup instructions below.
To handle multiple webhook endpoints (e.g., regular and Stripe Connect):
- Add paths to config:
# config/runtime.exs
config :pin_stripe,
webhook_paths: ["/webhooks/stripe", "/webhooks/stripe_connect"]- Create additional controllers:
defmodule MyAppWeb.StripeConnectWebhookController do
use PinStripe.WebhookController
handle "account.updated", fn event ->
# Handle Connect-specific events
:ok
end
end- Add routes:
scope "/webhooks" do
post "/stripe", MyAppWeb.StripeWebhookController, :create
post "/stripe_connect", MyAppWeb.StripeConnectWebhookController, :create
end- Add config:
# config/runtime.exs
config :pin_stripe,
stripe_api_key: System.get_env("STRIPE_SECRET_KEY"),
stripe_webhook_secret: System.get_env("STRIPE_WEBHOOK_SECRET"),
webhook_paths: ["/webhooks/stripe"]- Replace Plug.Parsers in your endpoint:
# lib/my_app_web/endpoint.ex
plug PinStripe.ParsersWithRawBody,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Jason- Create a webhook controller:
# lib/my_app_web/stripe_webhook_controller.ex
defmodule MyAppWeb.StripeWebhookController do
use PinStripe.WebhookController
end- Add the route:
# lib/my_app_web/router.ex
scope "/webhooks" do
post "/stripe", MyAppWeb.StripeWebhookController, :create
end- Add formatter config:
# .formatter.exs
[
import_deps: [:pin_stripe]
]PinStripe provides a clean DSL for handling webhook events. Webhooks are automatically verified, parsed, and dispatched to your handlers.
For simple event processing:
defmodule MyAppWeb.StripeWebhookController do
use PinStripe.WebhookController
handle "customer.created", fn event ->
customer_id = event["data"]["object"]["id"]
email = event["data"]["object"]["email"]
# Your business logic here
MyApp.Customers.create_from_stripe(customer_id, email)
:ok
end
handle "customer.updated", fn event ->
# Handle customer updates
:ok
end
handle "invoice.payment_succeeded", fn event ->
# Handle successful payments
:ok
end
endFor more complex event processing, use separate handler modules:
defmodule MyAppWeb.StripeWebhookController do
use PinStripe.WebhookController
handle "customer.subscription.created", MyApp.StripeWebhookHandlers.SubscriptionCreated
handle "customer.subscription.updated", MyApp.StripeWebhookHandlers.SubscriptionUpdated
handle "customer.subscription.deleted", MyApp.StripeWebhookHandlers.SubscriptionDeleted
enddefmodule MyApp.StripeWebhookHandlers.SubscriptionCreated do
@moduledoc """
Handles subscription creation events.
"""
def handle_event(event) do
subscription = event["data"]["object"]
customer_id = subscription["customer"]
# Complex business logic
with {:ok, user} <- MyApp.Users.find_by_stripe_customer(customer_id),
{:ok, _subscription} <- MyApp.Subscriptions.create(user, subscription) do
:ok
else
{:error, reason} ->
{:error, reason}
end
end
endQuickly scaffold handlers:
mix pin_stripe.gen.handler customer.created
mix pin_stripe.gen.handler customer.subscription.created --handler-type module
mix pin_stripe.gen.handler charge.succeeded --handler-type module --module MyApp.Payments.ChargeHandlerSync your local handlers with your Stripe webhook configuration:
mix pin_stripe.sync_webhook_handlersThis fetches your Stripe webhook endpoints, compares them with existing handlers, and generates stubs for missing events.
Options:
--handler-type function|module|ask--skip-confirmationor-y--api-keyor-k
Example output:
Fetching webhook endpoints from Stripe...
Found 1 webhook endpoint(s):
• https://myapp.com/webhooks/stripe (5 events)
Collecting all enabled events...
Events configured in Stripe:
✓ customer.created (handler exists)
✗ customer.updated (missing)
✗ invoice.payment_succeeded (missing)
✓ subscription.created (handler exists)
✗ subscription.deleted (missing)
Found 3 missing handler(s) out of 5 total Stripe event(s).
Generate handlers for missing events? (y/n) y
What type of handlers would you like to generate?
1. function
2. module
3. ask
> 1
Generating handlers...
• customer.updated (function handler)
• invoice.payment_succeeded (function handler)
• subscription.deleted (function handler)
✓ Done! Generated 3 new handler(s).
Simple CRUD interface built on Req:
alias PinStripe.Client
# Fetch a customer by ID
{:ok, response} = Client.read("cus_123")
customer = response.body
# List customers with pagination
{:ok, response} = Client.read(:customers, limit: 10, starting_after: "cus_123")
customers = response.body["data"]
# Create a customer
{:ok, response} = Client.create(:customers, %{
email: "customer@example.com",
name: "Jane Doe",
metadata: %{user_id: "12345"}
})
# Update a customer
{:ok, response} = Client.update("cus_123", %{
name: "Jane Smith",
metadata: %{premium: true}
})
# Delete a customer
{:ok, response} = Client.delete("cus_123")Automatic ID recognition:
Client.read("cus_123") # => /customers/cus_123
Client.read("sub_456") # => /subscriptions/sub_456
Client.read("price_789") # => /prices/price_789
Client.read("product_abc") # => /products/product_abc
Client.read("inv_xyz") # => /invoices/inv_xyz
Client.read("evt_123") # => /events/evt_123
Client.read("cs_test_abc") # => /checkout/sessions/cs_test_abcEntity types:
Client.create(:customers, %{email: "test@example.com"})
Client.create(:subscriptions, %{customer: "cus_123", items: [%{price: "price_abc"}]})
Client.create(:products, %{name: "Premium Plan"})
Client.create(:prices, %{product: "prod_123", unit_amount: 1000, currency: "usd"})
Client.create(:checkout_sessions, %{mode: "payment", line_items: [...]})
Client.read(:customers, limit: 100)
Client.read(:subscriptions, customer: "cus_123")
Client.read(:invoices, status: "paid")Bang functions:
# Raises RuntimeError on failure
response = Client.read!("cus_123")
customer = Client.create!(:customers, %{email: "test@example.com"})Advanced usage with Req:
# Direct Req request with custom options
{:ok, response} = Client.request("/charges/ch_123", retry: :transient)
# Or build a custom client
client = Client.new(receive_timeout: 30_000)
{:ok, response} = Req.get(client, url: "/customers/cus_123")Test your Stripe integrations without making real API calls.
High-level helpers for stubbing Stripe API responses:
Setup:
# config/test.exs
config :pin_stripe,
req_options: [plug: {Req.Test, PinStripe}]Examples:
alias PinStripe.Test.Mock
alias PinStripe.Client
test "reads a customer" do
Mock.stub_read("cus_123", %{
"id" => "cus_123",
"email" => "test@example.com"
})
{:ok, response} = Client.read("cus_123")
assert response.body["email"] == "test@example.com"
end
test "lists customers" do
Mock.stub_read(:customers, %{
"object" => "list",
"data" => [
%{"id" => "cus_1", "email" => "user1@example.com"},
%{"id" => "cus_2", "email" => "user2@example.com"}
],
"has_more" => false
})
{:ok, response} = Client.read(:customers)
assert length(response.body["data"]) == 2
end
test "creates a product" do
Mock.stub_create(:products, %{
"id" => "prod_new",
"name" => "Test Product"
})
{:ok, response} = Client.create(:products, %{name: "Test Product"})
assert response.body["id"] == "prod_new"
end
test "updates a customer" do
Mock.stub_update("cus_123", %{
"id" => "cus_123",
"name" => "Updated Name"
})
{:ok, response} = Client.update("cus_123", %{name: "Updated Name"})
assert response.body["name"] == "Updated Name"
end
test "deletes a customer" do
Mock.stub_delete("cus_123", %{
"id" => "cus_123",
"deleted" => true,
"object" => "customer"
})
{:ok, response} = Client.delete("cus_123")
assert response.body["deleted"] == true
end
test "handles not found error" do
Mock.stub_error("cus_nonexistent", 404, %{
"error" => %{
"type" => "invalid_request_error",
"code" => "resource_missing"
}
})
assert {:error, %{status: 404}} = Client.read("cus_nonexistent")
end
test "handles validation error on create" do
Mock.stub_error(:customers, 400, %{
"error" => %{
"message" => "Invalid email address",
"param" => "email"
}
})
{:error, response} = Client.create(:customers, %{email: "invalid"})
assert response.body["error"]["param"] == "email"
end
test "handles API key error for any request" do
Mock.stub_error(:any, 401, %{
"error" => %{"message" => "Invalid API key"}
})
assert {:error, %{status: 401}} = Client.read("cus_123")
endAvailable helpers:
stub_read/2- Stub read operations (by ID or entity type for lists)stub_create/2- Stub create operations (by entity type)stub_update/2- Stub update operations (by ID)stub_delete/2- Stub delete operations (by ID)stub_error/3- Stub error responses (for ID, entity type, or:any)
These helpers work seamlessly with fixtures:
test "uses fixture with helper" do
customer = PinStripe.Test.Fixtures.load(:customer)
Mock.stub_read("cus_123", customer)
{:ok, response} = Client.read("cus_123")
assert response.body["object"] == "customer"
end
test "uses error fixture with helper" do
error = PinStripe.Test.Fixtures.load(:error_404)
Mock.stub_error("cus_missing", 404, error)
assert {:error, %{status: 404}} = Client.read("cus_missing")
endAdvanced stubbing:
test "handles multiple operations in one stub" do
Mock.stub(fn conn ->
case {conn.method, conn.request_path} do
{"GET", "/v1/customers/" <> id} ->
Mock.json(conn, %{"id" => id, "email" => "#{id}@example.com"})
{"POST", "/v1/customers"} ->
Mock.json(conn, %{"id" => "cus_new", "email" => "new@example.com"})
{"DELETE", "/v1/customers/" <> id} ->
Mock.json(conn, %{"id" => id, "deleted" => true})
_ ->
conn
end
end)
{:ok, read_resp} = Client.read("cus_123")
{:ok, create_resp} = Client.create(:customers, %{email: "new@example.com"})
{:ok, delete_resp} = Client.delete("cus_123")
endSee PinStripe.Test.Mock for full documentation.
Generate realistic test fixtures from actual Stripe data:
Two types:
- Error fixtures (atoms like
:error_404): Instant, no setup required - API resources (atoms like
:customer): Require Stripe CLI, created once and cached
Requirements for API resources:
- Stripe CLI installed
- Test mode API key
Setup:
# config/test.exs
config :pin_stripe,
stripe_api_key: System.get_env("STRIPE_SECRET_KEY"),
req_options: [plug: {Req.Test, PinStripe}]Usage:
# Error fixtures - instant
error = PinStripe.Test.Fixtures.load(:error_404)
# API resources - created once, cached
customer = PinStripe.Test.Fixtures.load(:customer)Customization:
customer = PinStripe.Test.Fixtures.load(:customer, email: "alice@test.com")
event = PinStripe.Test.Fixtures.load("customer.created", data: %{...})API version management:
When you upgrade Stripe API versions:
mix pin_stripe.sync_api_versionSupported fixtures:
- API Resources:
customer,product,price,subscription,invoice,charge,payment_intent,refund - Webhook Events:
customer.created,customer.subscription.updated,invoice.paid, etc. - Errors:
error_404,error_400,error_401,error_429
See PinStripe.Test.Fixtures for full documentation.
# config/runtime.exs
config :pin_stripe,
stripe_api_key: System.get_env("STRIPE_SECRET_KEY"),
stripe_webhook_secret: System.get_env("STRIPE_WEBHOOK_SECRET"),
webhook_paths: ["/webhooks/stripe"]
# config/test.exs
config :pin_stripe,
req_options: [plug: {Req.Test, PinStripe}]- Stripity Stripe
- Wojtek Mach
- Dashbit
- Zach Daniel and the Ash Team
- All contributors to this discussion