livery_stripe
View SourceA Stripe API client for Erlang/OTP, built on the livery HTTP client. It covers core Stripe resources and subscriptions, and can back the same billing flow friendpaste uses (Checkout + Billing Portal + webhooks).
Documentation
New here? Start with what you can build. Then dive into the task you have:
- Getting started - configure, first call, errors, request options
- Subscription billing
- One-time payments
- Saving cards
- Discounts and promotions
- Invoicing
- Webhooks
Feature support
| Resource | Module | Operations |
|---|---|---|
| Customers | livery_stripe_customer | create, retrieve, update, delete, list, list_payment_methods, delete_discount |
| Products | livery_stripe_product | create, retrieve, update, list |
| Prices | livery_stripe_price | create, retrieve, update, list |
| Checkout | livery_stripe_checkout | create_session, retrieve_session, expire_session, subscription_session |
| Billing portal | livery_stripe_portal | create_session |
| Subscriptions | livery_stripe_subscription | create, retrieve, update, cancel, list, pause, resume, delete_discount |
| Payment intents | livery_stripe_payment_intent | create, retrieve, update, confirm, capture, cancel, list |
| Payment methods | livery_stripe_payment_method | attach, detach, retrieve, update, list |
| Setup intents | livery_stripe_setup_intent | create, retrieve, confirm, cancel, list |
| Refunds | livery_stripe_refund | create, retrieve, update, cancel, list |
| Invoices | livery_stripe_invoice | create, retrieve, list, pay, finalize, void, send, mark_uncollectible, delete, upcoming |
| Coupons | livery_stripe_coupon | create, retrieve, update, delete, list |
| Promotion codes | livery_stripe_promotion_code | create, retrieve, update, list |
| Events | livery_stripe_event | retrieve, list |
| Webhooks | livery_stripe_webhook, livery_stripe_webhook_handler | signature verification + mountable handler |
Any endpoint without a wrapper is reachable via
livery_stripe_client:do_request/4,5.
Why livery
The client is built on livery_client and wired with livery's flow-control
layers so calls retry safely and degrade gracefully under load:
timeout- a hard ceiling over the whole call.retry- exponential backoff with jitter, honorsRetry-After. Retries on transport errors and on409/429/5xx.circuit_breaker- trips on a failure ratio so a Stripe outage fails fast instead of piling up.concurrency- an in-flight admission gate (a semaphore) that caps real connections; excess calls return{error, overloaded}.
The client value is built once at app start and cached in persistent_term, so
the breaker and gate state is shared across every caller.
Safe retries
Every mutating request (POST) carries an Idempotency-Key. livery's retry
replays the same request map, so the key is identical on every attempt and
Stripe deduplicates instead of, say, creating two subscriptions. Because of
this the retry layer enables retry_non_idempotent safely. Supply your own key
for cross-process at-least-once flows:
livery_stripe_customer:create(Client, Params, #{idempotency_key => <<"order-42">>}).Configuration
Configure via the livery_stripe application environment (see
config/sys.config.example). Secrets are better supplied through the OS
environment, which overrides app env at runtime:
STRIPE_SECRET_KEY->secret_keySTRIPE_WEBHOOK_SECRET->webhook_secret
Price ids map to a plan + billing period under the prices key, e.g.
livery_stripe:price_id(pro, monthly) looks up pro_monthly.
Usage
%% Uses the cached, app-configured client:
{ok, Customer} = livery_stripe:create_customer(#{
email => <<"a@b.c">>, name => <<"A B">>,
metadata => #{<<"user_id">> => <<"u1">>}
}),
CustomerId = maps:get(<<"id">>, Customer),
%% End-to-end subscription checkout (the friendpaste flow):
{ok, Session} = livery_stripe:subscription_checkout(#{
customer => CustomerId,
plan => pro, billing_period => monthly,
success_url => <<"https://app/billing?success=1">>,
cancel_url => <<"https://app/billing?canceled=1">>,
metadata => #{<<"user_id">> => <<"u1">>, <<"plan">> => <<"pro">>}
}),
CheckoutUrl = maps:get(<<"url">>, Session),
{ok, Sub} = livery_stripe:get_subscription(<<"sub_123">>),
{ok, Portal} = livery_stripe:create_portal_session(#{
customer => CustomerId, return_url => <<"https://app/billing">>
}).For an explicit client (multiple accounts, tests), call the domain modules
directly: livery_stripe_customer, livery_stripe_checkout,
livery_stripe_subscription (create/retrieve/update/cancel/pause/resume),
livery_stripe_portal, livery_stripe_price, livery_stripe_product,
livery_stripe_payment_intent, livery_stripe_payment_method
(attach/detach/list), livery_stripe_setup_intent, livery_stripe_refund,
livery_stripe_invoice (create/finalize/void/send/pay/upcoming),
livery_stripe_event (retrieve/list), livery_stripe_coupon, and
livery_stripe_promotion_code. Customers and subscriptions also expose
delete_discount/2. The facade exposes
livery_stripe:create_subscription/1.
Build an explicit client with livery_stripe_client:build(Config).
Results are {ok, map()} (decoded JSON) or {error, Reason} where Reason is
{stripe_error, Status, ErrorMap}, {decode, Body}, or a livery client error
(timeout, circuit_open, overloaded, a transport reason).
Webhooks
Verify and decode events with livery_stripe_webhook:construct_event/3,4
(the equivalent of stripe.Webhook.construct_event):
case livery_stripe_webhook:construct_event(RawBody, SigHeader, Secret) of
{ok, Event} -> handle(Event);
{error, invalid_signature} -> reject;
{error, invalid_payload} -> reject;
{error, timestamp_out_of_tolerance} -> reject
end.Pass the RAW request body bytes; any re-encoding breaks the signature.
Or mount the ready-made livery handler, which verifies the signature and
dispatches to your webhook_callback (handle_event(Type, Event)):
Router = livery_router:compile(
livery_stripe_webhook_handler:routes(<<"/api/billing/webhook">>)
++ OtherRoutes
).Persistence (updating a user's subscription, etc.) lives in the callback, so the client stays storage-agnostic.
Build and test
livery is consumed locally via _checkouts/livery (a symlink to a sibling
livery checkout) and is declared in rebar.config:
ln -s ../livery _checkouts/livery # if not already present
rebar3 compile
rebar3 eunit # form encoding, webhook verification, util
rebar3 ct # see suites below
rebar3 xref
rebar3 dialyzer
rebar3 do eunit, ct, cover # combined coverage report
rebar3 ex_doc # generate HTML API docs into doc/
Test suites:
livery_stripe_form_tests,livery_stripe_webhook_tests,livery_stripe_util_tests(eunit) - encoding and signature edge cases.livery_stripe_client_SUITE- resilience over a mock adapter: retry + same-key replay,Retry-Afteron 429, no-retry on card errors, transport errors, decode/error mapping, query encoding, the concurrency gate, and the circuit breaker.livery_stripe_resources_SUITE- every domain call's method + path.livery_stripe_facade_SUITE- the facade,price_id/2, env override.livery_stripe_billing_SUITE- end-to-end flow against a live livery mock Stripe server + webhook dispatch.livery_stripe_webhook_handler_SUITE- webhook handler dispatch and the 200/400 responses.livery_stripe_webhook_e2e_SUITE- boots a real livery service mounting the webhook route and posts signed events over HTTP (200 + dispatch, 400 on a bad or missing signature).livery_stripe_live_SUITE- opt-in, hits the real Stripe API (see below).
Requires Erlang/OTP 27+ (uses the stdlib json module).
Testing against a real Stripe account
Use a TEST-mode key (sk_test_...), never a live key. The operations below
do not charge anyone.
Getting test-mode keys
- Open the Stripe Dashboard and turn on Test mode (toggle, top right).
- Go to Developers -> API keys and reveal the Secret key. In test
mode it starts with
sk_test_...and only ever touches test data. - For webhook tests, the signing secret (
whsec_...) comes fromstripe listen(see below) or Developers -> Webhooks -> [endpoint] -> Signing secret.
Never use a live key (sk_live_...); the suite and examples are test-only.
Automated live suite
test/livery_stripe_live_SUITE is skipped unless STRIPE_SECRET_KEY is set.
It exercises the real API and cleans up after itself (deletes customers,
archives products/prices, cancels subscriptions). Coverage: customer
lifecycle, idempotency-key replay, product + recurring price + subscription
Checkout session, a payment-intent lifecycle, a full subscription lifecycle
(attach a test card, create, retrieve, update, cancel), invoice listing, and
the cached-client facade path.
STRIPE_SECRET_KEY=sk_test_xxx rebar3 ct --suite test/livery_stripe_live_SUITE
Running the live suite in CI
.github/workflows/live.yml runs the suite weekly and on manual dispatch,
reading the key from a repo secret. Set it once, then trigger on demand:
gh secret set STRIPE_SECRET_KEY # paste the sk_test_... key
gh workflow run live.yml # gh run watch to follow
Without the secret the job auto-skips and stays green.
Interactive exploration
STRIPE_SECRET_KEY=sk_test_xxx rebar3 shell
livery_stripe:configure(),
{ok, Cust} = livery_stripe:create_customer(#{email => <<"you@example.test">>}),
{ok, P} = livery_stripe_product:create(livery_stripe:client(), #{name => <<"Pro">>}),
{ok, Pr} = livery_stripe_price:create(livery_stripe:client(),
#{product => maps:get(<<"id">>, P), unit_amount => 1000,
currency => <<"usd">>, recurring => #{interval => <<"month">>}}),
{ok, Sess} = livery_stripe:create_checkout_session(#{
customer => maps:get(<<"id">>, Cust), mode => <<"subscription">>,
line_items => [#{<<"price">> => maps:get(<<"id">>, Pr), <<"quantity">> => 1}],
success_url => <<"https://example.test/ok">>,
cancel_url => <<"https://example.test/no">>}),
%% Open maps:get(<<"url">>, Sess) in a browser and pay with card 4242 4242 4242 4242.Webhooks with the Stripe CLI
Webhook signatures can only be exercised with a real signing secret, which the Stripe CLI provides:
Mount the handler in a livery service and start it:
livery:start_service(#{http => #{port => 4000}, router => livery_router:compile( livery_stripe_webhook_handler:routes(<<"/stripe/webhook">>))}).Set
webhook_callbackin config to ahandle_event(Type, Event)callback, andwebhook_secretto thewhsec_...thatstripe listenprints.Forward events and trigger one:
stripe login stripe listen --forward-to localhost:4000/stripe/webhook # prints whsec_... stripe trigger checkout.session.completed
The handler verifies the signature against the raw body and dispatches the
event to your callback; a verified event returns 200, a bad signature 400.
To watch retries and idempotency in action, point base_url at a proxy (or
inspect the Stripe dashboard's request logs): a retried create reuses the same
Idempotency-Key, so Stripe records one object, not two.