A minimal Stripe SDK for Elixir with webhook handling, built on Req and Spark.
Install using the Igniter installer, which handles everything automatically:
mix igniter.install pin_stripeThis automatically:
- Adds the dependency to your
mix.exs - Configures your Phoenix endpoint for webhook signature verification
- Creates webhook handler and controller modules
- Sets up routing
- Configures code formatting
If not using Igniter, add to your mix.exs:
{:pin_stripe, "~> 0.1"}Then manually configure endpoints, handlers, and routes (see Manual Setup section in README).
Configure your Stripe credentials. Typically done in config/runtime.exs:
config :pin_stripe,
stripe_api_key: System.get_env("YOUR_STRIPE_KEY_ENV_VAR"),
stripe_webhook_secret: System.get_env("YOUR_WEBHOOK_SECRET_ENV_VAR")Required configuration keys:
:pin_stripe, :stripe_api_key- Your Stripe API key for making requests:pin_stripe, :stripe_webhook_secret- Your webhook signing secret for verifying webhooks
Important: Never commit API keys to version control. Always use environment variables or a secrets manager.
Use PinStripe.request/2 to make Stripe API calls:
# GET request
{:ok, customer} = PinStripe.request(:get, "/v1/customers/cus_123")
# POST request with params
{:ok, customer} = PinStripe.request(:post, "/v1/customers",
email: "customer@example.com",
name: "Jane Doe"
)
# DELETE request
{:ok, _} = PinStripe.request(:delete, "/v1/customers/cus_123")All requests return {:ok, response} or {:error, reason} tuples.
The installer creates a StripeWebhookHandlers module. Define handlers using the DSL:
defmodule MyApp.StripeWebhookHandlers do
use PinStripe.WebhookHandler
# Function handler - inline
handle "customer.created", fn event ->
customer = event.data.object
# Handle the event
:ok
end
# Module handler - separate module
handle "invoice.paid", MyApp.InvoicePaidHandler
endImportant:
- Always return
:okfrom handlers to acknowledge successful processing - Return
{:error, reason}to indicate processing failure (webhook will be retried by Stripe) - The
eventparameter contains the full Stripe event object
Function Handlers - Quick inline handlers:
handle "customer.updated", fn event ->
# Process event inline
:ok
endModule Handlers - Better for complex logic:
# In your WebhookHandler module
handle "subscription.created", MyApp.SubscriptionCreatedHandler
# Separate module
defmodule MyApp.SubscriptionCreatedHandler do
def handle_event(event) do
subscription = event.data.object
# Complex processing logic
:ok
end
endUse the generator to create handlers quickly:
# Generate a function handler
mix pin_stripe.gen.handler customer.subscription.updated
# Generate a module handler
mix pin_stripe.gen.handler invoice.paid --handler-type moduleThe installer creates lib/my_app_web/stripe_webhook_controller.ex which:
- Verifies webhook signatures automatically
- Routes events to your handlers
- Handles errors gracefully
Note: The controller is created in lib/my_app_web/, not in lib/my_app_web/controllers/. You can move it to the controllers directory if preferred.
The installer configures PinStripe.ParsersWithRawBody in your endpoint, which:
- Caches the raw request body for signature verification
- Is required for Stripe webhook security
- Replaces the standard
Plug.Parsers
Critical: Never skip webhook signature verification in production. The installer handles this automatically.
Stripe may send the same webhook multiple times. Make your handlers idempotent:
handle "payment_intent.succeeded", fn event ->
payment_intent_id = event.data.object.id
# Check if already processed
case MyApp.Payments.get_by_stripe_id(payment_intent_id) do
nil ->
# First time, process it
MyApp.Payments.create_from_stripe(event.data.object)
:ok
_existing ->
# Already processed, skip
:ok
end
endReturn errors to have Stripe retry:
handle "invoice.payment_failed", fn event ->
case MyApp.Billing.handle_failed_payment(event.data.object) do
{:ok, _} -> :ok
{:error, :temporary_failure} -> {:error, "Database unavailable, retry later"}
{:error, _reason} -> :ok # Don't retry for permanent failures
end
endFor long-running operations, enqueue a job:
handle "customer.subscription.deleted", fn event ->
# Quick acknowledgment, process async
MyApp.Jobs.queue_subscription_cancellation(event.data.object.id)
:ok
endCommon Stripe events:
customer.created,customer.updated,customer.deletedpayment_intent.succeeded,payment_intent.payment_failedinvoice.paid,invoice.payment_failedcustomer.subscription.created,customer.subscription.updated,customer.subscription.deletedcharge.succeeded,charge.failed,charge.refunded
View all supported events:
cat deps/pin_stripe/priv/supported_stripe_events.txtPinStripe provides comprehensive test helpers in PinStripe.Test.Mock and PinStripe.Test.Fixtures for testing your Stripe integration without hitting the real API.
PinStripe.Test.Mock - High-level mocking functions for common operations:
stub_read/2- Mock reading/listing resourcesstub_create/2- Mock creating resourcesstub_update/2- Mock updating resourcesstub_delete/1- Mock deleting resourcesstub_error/1orstub_error/2- Mock error responsesstub_fixture/1orstub_fixture/2- Mock using pre-built fixtures
PinStripe.Test.Fixtures - Load realistic Stripe response data (can use live API or cached fixtures)
defmodule MyAppTest do
use ExUnit.Case
alias PinStripe.Test.Mock
test "creates a customer" do
# Mock the create response
Mock.stub_create(:customers, %{
"id" => "cus_123",
"email" => "test@example.com"
})
# Your application code
{:ok, customer} = MyApp.create_customer("test@example.com")
assert customer["id"] == "cus_123"
end
test "reads a customer" do
Mock.stub_read("cus_123", %{
"id" => "cus_123",
"email" => "test@example.com"
})
{:ok, customer} = MyApp.get_customer("cus_123")
assert customer["email"] == "test@example.com"
end
test "lists customers" do
Mock.stub_read(:customers, [
%{"id" => "cus_1", "email" => "user1@example.com"},
%{"id" => "cus_2", "email" => "user2@example.com"}
])
{:ok, customers} = MyApp.list_customers()
assert length(customers) == 2
end
endUse stub_error/1 with predefined error atoms or stub_error/2 for custom errors:
test "handles not found errors" do
Mock.stub_error(:not_found)
assert {:error, %{status: 404}} = MyApp.get_customer("cus_invalid")
end
test "handles rate limiting" do
Mock.stub_error(:rate_limit)
assert {:error, %{status: 429}} = MyApp.create_customer("test@example.com")
end
test "handles custom validation errors" do
Mock.stub_error(:bad_request, %{
message: "Invalid email address",
param: "email"
})
assert {:error, response} = MyApp.create_customer("invalid")
assert response.body["error"]["param"] == "email"
endAvailable error atoms:
:not_found(404) - Resource doesn't exist:bad_request(400) - Missing or invalid parameters:unauthorized(401) - Invalid API key:rate_limit(429) - Too many requests:server_error(500) - Stripe server error
For realistic error responses, use stub_fixture/1 with error atoms:
test "handles card declined errors" do
Mock.stub_fixture(:error_402)
{:error, response} = MyApp.charge_card(payment_method)
assert response.body["error"]["type"] == "card_error"
assert response.body["error"]["code"] == "card_declined"
end
test "handles idempotency conflicts" do
Mock.stub_fixture(:error_409)
{:error, response} = MyApp.create_payment(idempotency_key: "duplicate")
assert response.body["error"]["type"] == "idempotency_error"
endAvailable error fixtures:
:error_400- Bad Request (missing required parameter):error_401- Unauthorized (invalid API key):error_402- Request Failed (card declined):error_403- Forbidden (insufficient permissions):error_404- Not Found (resource doesn't exist):error_409- Conflict (idempotency key in use):error_424- External Dependency Failed:error_429- Too Many Requests (rate limit):error_500,:error_502,:error_503,:error_504- Server Errors
Use fixtures to load real Stripe response data (requires Stripe CLI for first-time generation):
test "handles real customer data structure" do
# Loads actual Stripe customer response (cached after first load)
Mock.stub_fixture("customer")
{:ok, customer} = MyApp.get_customer("cus_test")
# Test against real Stripe data structure
assert Map.has_key?(customer, "id")
assert Map.has_key?(customer, "email")
assert customer["object"] == "customer"
endFixture Types:
- Error Fixtures - Use atoms (
:error_404,:error_400, etc.), self-contained, no Stripe CLI required - API Resources - Use strings (
"customer","payment_intent"), require Stripe CLI for initial generation - Webhook Events - Use strings (
"customer.created"), require Stripe CLI
Error fixtures are generated instantly and don't create cached files.
Use the Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:4000/webhooks/stripeTrigger test events:
stripe trigger customer.created
stripe trigger payment_intent.succeededTest webhook handlers directly without HTTP:
test "handles customer.created event" do
event = %{
id: "evt_test",
type: "customer.created",
data: %{
object: %{
id: "cus_test",
email: "test@example.com"
}
}
}
assert :ok = MyApp.StripeWebhookHandlers.handle_event(event)
endIn your test_helper.exs, configure the test adapter:
# Use Req.Test adapter for mocking
Application.put_env(:pin_stripe, :req_options, plug: {Req.Test, PinStripe})This allows Mock functions to intercept Stripe API calls in tests.
mix pin_stripe.install- Install and configure PinStripemix pin_stripe.gen.handler <event>- Generate a handler for a specific eventmix pin_stripe.sync_webhook_handlers- Sync handlers with Stripe (if using Spark introspection)
- Don't hardcode API keys: Always use environment variables
- Don't skip signature verification: The installer configures this automatically
- Don't block webhook handlers: Keep handlers fast, enqueue long operations
- Don't forget to return
:ok: Handlers must return:okor{:error, reason} - Don't process webhooks twice: Make handlers idempotent
- Don't use in production without testing: Test with Stripe CLI first
- Keep handlers simple: Complex logic should be in separate modules
- Log webhook processing: Helpful for debugging
- Monitor webhook failures: Set up alerts for repeated failures
- Version your API: Stripe has multiple API versions, be consistent
- Handle all expected events: Unhandled events are logged but don't cause errors
- Test with Stripe CLI: Always test webhooks before deploying
Webhook signature verification fails:
- Check that
ParsersWithRawBodyis configured in your endpoint - Verify
:pin_stripe, :stripe_webhook_secretconfig is set correctly - Ensure you're using the secret from the Stripe webhook endpoint settings
Events not being handled:
- Check handler module is referenced in the WebhookController
- Verify handler is defined for that specific event type
- Check application logs for errors
API requests failing:
- Verify
:pin_stripe, :stripe_api_keyconfig is set - Check API key has correct permissions
- Ensure you're using the correct API version