# livery_stripe
A Stripe API client for Erlang/OTP, built on the [livery](https://github.com/benoitc/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](docs/overview.md). Then dive into
the task you have:
- [Getting started](docs/guides/getting-started.md) - configure, first call,
errors, request options
- [Subscription billing](docs/guides/subscriptions.md)
- [One-time payments](docs/guides/payments.md)
- [Saving cards](docs/guides/saving-cards.md)
- [Discounts and promotions](docs/guides/discounts.md)
- [Invoicing](docs/guides/invoicing.md)
- [Webhooks](docs/guides/webhooks.md)
## 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, honors `Retry-After`. Retries on
transport errors and on `409/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:
```erlang
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_key`
- `STRIPE_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
```erlang
%% 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`):
```erlang
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)`):
```erlang
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`:
```sh
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-After` on 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
1. Open the [Stripe Dashboard](https://dashboard.stripe.com) and turn on
**Test mode** (toggle, top right).
2. Go to **Developers -> API keys** and reveal the **Secret key**. In test
mode it starts with `sk_test_...` and only ever touches test data.
3. For webhook tests, the signing secret (`whsec_...`) comes from
`stripe 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.
```sh
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:
```sh
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
```sh
STRIPE_SECRET_KEY=sk_test_xxx rebar3 shell
```
```erlang
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:
1. Mount the handler in a livery service and start it:
```erlang
livery:start_service(#{http => #{port => 4000},
router => livery_router:compile(
livery_stripe_webhook_handler:routes(<<"/stripe/webhook">>))}).
```
Set `webhook_callback` in config to a `handle_event(Type, Event)` callback,
and `webhook_secret` to the `whsec_...` that `stripe listen` prints.
2. Forward events and trigger one:
```sh
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.